Quest NPCs turn blue and stop facing the hero once their quest is done

A dialog's quest comes from the game's script labels ("Dialog:<name>",
quest at +0x48). When that quest is done and not in progress, IsQuestNpc
answers false (blue on the minimap) and the idle action's type check
skips the turn towards the hero.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-25 18:13:43 +01:00
1 parent 9bc557cd95
commit c0eedbce7f
6 files changed
+235 -1

No files matched your search

+1
View File
@@ -78,6 +78,7 @@ add_library(sacredK SHARED
src/mods/mapreveal.cpp
src/mods/questbook.cpp
src/mods/questfail.cpp
src/mods/questnpc.cpp
src/mods/anticheat.cpp
src/mods/loadingscreen.cpp
)
+50
View File
@@ -101,3 +101,53 @@ ShowCompleted`, default 1) and calls `cUI_Book::Rebuild` 0x6b1670 (thiscall,
no arguments; keeps tab and page if the page still exists, else ours clamps
it). Off leaves out solved entries (main and side) and areas whose quests are
all known and solved; failed quests stay (restartable).
## Quest NPCs after the quest (request 2026-10-25)
Request: NPCs related to a quest are drawn white on the minimaps and turn
towards the player; once their quest is finished they should be drawn blue
and stop turning, like other NPCs. `src/mods/questnpc.cpp`.
Measured:
- The zoomed map view (0x6cc950, call at 0x6cec6b) draws a creature white
when `cCreature::IsQuestNpc` (0x54fa30) is true: creature flags +0x200 bit
0x100 / 0x200 (escorted NPCs, set by the quest manager 0x46ae54..), or a
dialog +0x245 in 1..59999 whose type (`cDialogManager::DialogType`
0x49a180, record +0x48 of the 0x50-byte records at manager 0xaaaf00
+0x755c, name at record +4) is below 50.
- Some quests move their giver on to dialog 0 at the end
(`DLG_15074_START` type 10 → `_OFFEN` 126 → `_ZIEL` 11 → 0); many leave it
on the quest's dialog: `auftrag10`, `auftrag2411`, `AuftragEnde202`,
`auftrag303` kept their dialog with the quest done (flags 3, book entry
solved).
- The turning, found with a hardware write watch (DR0) on the facing +0x70:
the idle action (`cCreature::IdleAction` 0x544700, Think's action 0
handler) turns every creature with a dialog +0x245 in 1..99999 towards the
local hero each Think step (0x546334..0x5463d5, `cCreature::SetDirection`
0x44df60 → 0x44ddc0), unless its type is of kind 4 in the type table
(`TypeTable_IsKind4` 0x4265c0, call at 0x546357) or object flag +0x14 bit 2
is set. So every NPC with a dialog turns, not only quest NPCs.
- Script labels (vector 0xaa9688..0xaa968c, 0x54 bytes each; quest scripts
and dialog answers are indices into it, run by `Script_RunLabel` 0x46bd80):
name +0, the quest the label belongs to +0x48 (-1 none; the label only runs
once that quest is triggered, 0x46bf11). Each dialog has a label
`Dialog:<dialog name>` (logged 2026-10-25: `Dialog:rg1_goodie1_auftrag`
9004 Vermisstes Kind, the Aufgeregter Bauer; `Dialog:auftrag_wueste` 9000;
`Dialog:auftrag11a1` 1101; `Dialog:DLG_15003_ZIEL` 15003;
`Dialog:geschlossen101` none).
The quest of a dialog is its label's quest (`game/engine/quests.h`, script
labels), looked up by the dialog record's name; cached per dialog. A first
version read the quest id from the numbers in the dialog's name; that missed
dialogs without one (the Aufgeregter Bauer's `rg1_goodie1_auftrag`, user
report 2026-10-25) and misread others (`auftrag11a1`). When that quest
exists, is done (flag 0x2) and not in progress (0x4):
- the hook on `IsQuestNpc` returns false (blue on the minimap);
- the type check call at 0x546357 is redirected to a thunk that also answers
true, so the idle action skips the turn.
Dialogs without a quest (healers, closed shops: `geschlossen101`,
`heilen_global`) stay as the game has them.
+7 -1
View File
@@ -228,7 +228,8 @@ window's layout in the width beyond its 1024 frame.
Request: NPCs related to a quest are drawn white on the minimaps and always
turn towards the player; once their quest is finished they should stop
turning and be drawn blue (like other NPCs).
turning and be drawn blue (like other NPCs). Done 2026-10-25
(docs/quests.md, "Quest NPCs after the quest").
## Inventory drawn without its dark backgrounds (2026-10-25, intermittent)
@@ -236,3 +237,8 @@ Reported twice: on opening the inventory the item list had no dark
background behind the grid, and the tab texts had an odd background colour.
Looks like render state (texture / blend / colour) left behind by an earlier
draw; not reproduced yet. To find out: what was open or shown just before.
Possibly related (2026-10-25, once): right after a game start the main menu
texts were drawn as solid gold blocks (the gold text texture without the
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
log showed nothing unusual (all hooks and patches applied).
+17
View File
@@ -46,6 +46,23 @@ inline int QuestIndex(int32_t id) {
return -1;
}
// Script labels (vector begin/end 0xaa9688 / 0xaa968c, 0x54 bytes each; the
// quests' scripts are indices into it, run by 0x46bd80): name +0 (e.g.
// "Dialog:auftrag2411", "rg1_goodie1_annehmen"), the quest the label belongs to
// +0x48 (-1 for none; 0x46bf11: runs only once that quest is triggered).
constexpr uintptr_t kScriptLabelsBegin = 0x00aa9688;
constexpr uintptr_t kScriptLabelsEnd = 0x00aa968c;
constexpr size_t kScriptLabelSize = 0x54;
constexpr size_t kScriptLabelNameSize = 0x48;
constexpr size_t kScriptLabelQuest = 0x48;
inline const uint8_t* ScriptLabelsBegin() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsBegin); }
inline const uint8_t* ScriptLabelsEnd() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsEnd); }
inline const char* ScriptLabelName(const uint8_t* label) { return reinterpret_cast<const char*>(label); }
inline int32_t ScriptLabelQuest(const uint8_t* label) {
return *reinterpret_cast<const int32_t*>(label + kScriptLabelQuest);
}
// The quest manager, a static object (script interpreter; ecx of 0x475970 and
// of Quest_End 0x46ced0). Its quest book is a vector of plain 0x174-byte entries
// (serialized field by field, Quest_End 0x46d9d7..); per player a selected
+155
View File
@@ -0,0 +1,155 @@
// Quest NPCs after their quest (request of 2026-10-25): NPCs related to a quest
// are drawn white on the minimaps and turn towards the player; once their quest
// is finished they should be drawn blue and stop turning, like other NPCs.
//
// Measured: the zoomed map view (0x6cc950, 0x6cec6b) draws a creature white
// when cCreature::IsQuestNpc (0x54fa30) says so: creature flags +0x200 bit
// 0x100 or 0x200 (set by the quest manager for escorted NPCs, 0x46ae54..), or
// a dialog +0x245 (1..59999) whose type (cDialogManager::DialogType 0x49a180:
// record +0x48) is below 50. Some quests move their giver on to dialog 0 when
// they end (DLG_15074_START -> _OFFEN -> _ZIEL -> 0), many leave it on the
// quest's dialog: auftrag10, auftrag2411, AuftragEnde202 kept their dialog
// with the quest done (flags 3, book entry solved).
//
// The turning (found with a hardware write watch on the facing +0x70): the
// idle action (cCreature::IdleAction 0x544700, Think's action 0 handler) turns
// every creature with a dialog +0x245 in 1..99999 towards the local hero each
// Think step (0x546334..0x5463d5, cCreature::SetDirection 0x44df60), unless
// its type is of kind 4 in the type table (0x4265c0, call at 0x546357) or
// object flag +0x14 bit 2 is set.
//
// The mod: a dialog whose quest is done and not running again no longer makes
// its NPC a quest NPC (blue on the minimap) and no longer turns it towards the
// player. A dialog's quest is the game's own: each dialog has a script label
// "Dialog:<name>" and each label the quest it belongs to (game/engine/quests.h,
// script labels; logged 2026-10-25: Dialog:rg1_goodie1_auftrag 9004,
// Dialog:auftrag_wueste 9000, Dialog:auftrag11a1 1101, Dialog:geschlossen101
// none).
#include <cstdint>
#include <cstring>
#include <map>
#include <string>
#include "game/engine/quests.h"
#include "re/hook.h"
#include "re/patch.h"
namespace mods::questnpc {
namespace {
constexpr size_t kCreatureDialog = 0x245; // cCreature::GetDialog 0x549790
constexpr int32_t kLastQuestDialog = 59999; // IsQuestNpc: dialogs 1..59999
constexpr uintptr_t kDialogManager = 0x00aaaf00;
constexpr size_t kDialogRecordsBegin = 0x755c;
constexpr size_t kDialogRecordsEnd = 0x7560;
constexpr size_t kDialogRecordSize = 0x50;
constexpr size_t kDialogRecordName = 0x04; // ANSI
constexpr const char* kDialogLabelPrefix = "Dialog:";
template <typename T>
T Field(const uint8_t* p, size_t offset) {
return *reinterpret_cast<const T*>(p + offset);
}
// The dialog's record (0x50 bytes: name at +4, type at +0x48), null if none.
const uint8_t* DialogRecord(int32_t dialog) {
const auto* manager = reinterpret_cast<const uint8_t*>(kDialogManager);
const uint8_t* begin = Field<const uint8_t*>(manager, kDialogRecordsBegin);
const uint8_t* end = Field<const uint8_t*>(manager, kDialogRecordsEnd);
if (dialog <= 0 || !begin || begin + (dialog + 1) * kDialogRecordSize > end) {
return nullptr;
}
return begin + dialog * kDialogRecordSize;
}
// The quest of each dialog label, by dialog name; empty until the scripts are
// loaded.
const std::map<std::string, int32_t>& DialogLabelQuests() {
static std::map<std::string, int32_t> quests;
if (quests.empty()) {
const size_t prefix = std::strlen(kDialogLabelPrefix);
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
const char* name = game::ScriptLabelName(label);
if (std::strncmp(name, kDialogLabelPrefix, prefix) == 0 && game::ScriptLabelQuest(label) > 0) {
quests[std::string(name + prefix, strnlen(name + prefix, game::kScriptLabelNameSize - prefix))] =
game::ScriptLabelQuest(label);
}
}
}
return quests;
}
// The quest table index of the quest a dialog belongs to, -1 if none. Cached
// per dialog: the minimap and the idle action ask for every creature often.
int DialogQuest(int32_t dialog) {
static std::map<int32_t, int> cache;
if (auto it = cache.find(dialog); it != cache.end()) {
return it->second;
}
const uint8_t* record = DialogRecord(dialog);
const auto& quests = DialogLabelQuests();
if (!record || quests.empty() || game::QuestCount() == 0) {
return -1; // not cached before the scripts are loaded
}
const auto it = quests.find(reinterpret_cast<const char*>(record + kDialogRecordName));
const int index = it != quests.end() ? game::QuestIndex(it->second) : -1;
cache[dialog] = index;
return index;
}
// The creature's dialog belongs to a finished quest.
bool QuestFinished(const uint8_t* creature) {
const int32_t dialog = Field<int32_t>(creature, kCreatureDialog);
if (dialog <= 0 || dialog > kLastQuestDialog) {
return false;
}
const int index = DialogQuest(dialog);
if (index < 0) {
return false;
}
const uint32_t flags = game::QuestFlags(game::Quest(index));
return (flags & game::kQuestDone) && !(flags & game::kQuestInProgress);
}
// The idle action's type check before turning towards the hero (call at
// 0x546357: TypeTable_IsKind4 0x4265c0, thiscall (type table, type), ret 4;
// true skips the turn), creature in esi.
constexpr uint32_t kFacePlayerTypeCheck = 0x00546357;
constexpr uint32_t kTypeTableIsKind4 = 0x004265c0;
char __stdcall KeepsFacing(void* typeTable, int32_t type, const uint8_t* creature) {
using Fn = char(__fastcall*)(void*, void*, int32_t);
return reinterpret_cast<Fn>(kTypeTableIsKind4)(typeTable, nullptr, type) || QuestFinished(creature);
}
__declspec(naked) void KeepsFacingThunk() {
__asm {
push esi
push dword ptr [esp + 8]
push ecx
call KeepsFacing
ret 4
}
}
void Startup() {
re::RedirectCall(kFacePlayerTypeCheck, kTypeTableIsKind4, reinterpret_cast<const void*>(&KeepsFacingThunk));
}
RE_STARTUP(QuestNpcStartup, Startup);
} // namespace
// cCreature::IsQuestNpc (0x54fa30, thiscall (), ret): the minimap draws the
// creature white.
char __fastcall Creature_IsQuestNpc(uint8_t* self, void* edx);
RE_HOOK(0x0054fa30, Creature_IsQuestNpc);
char __fastcall Creature_IsQuestNpc(uint8_t* self, void* edx) {
if (QuestFinished(self)) {
return 0;
}
return Creature_IsQuestNpc_original(self, edx);
}
} // namespace mods::questnpc
+5
View File
@@ -186,6 +186,7 @@
0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
0x00ad1b48 g_nearbyCreatureCount
0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80
0x0046bd80 Script_RunLabel # cdecl (label index, ?, ?): script labels 0xaa9688 (0x54 bytes, quest +0x48); runs only once the label's quest is triggered
0x006e7120 cUI_Tooltip::HandleEvent # dialog answers: click on an entry (+0x2188 list) -> hide, cDialogManager::Answer
0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124
0x00756ba0 cUI_Manager::HandleEsc
@@ -407,4 +408,8 @@
0x006cb130 cUI_Megamap::WorldToMapPixel # thiscall (out vec3, world vec3 x/y/level), ret 8: x * 0.007446 + 1018.75, y * 0.014914 - 186.95 (after 0x622700)
0x0054fa30 cCreature::IsQuestNpc # thiscall, plain ret: +0x200 bit 0x100 / 0x200, or dialog +0x245 of a type < 50; white on the zoomed map view
0x00549790 cCreature::GetDialog # thiscall: dialog +0x245, or a random NPC chatter text (returns 0xd431)
0x00544700 cCreature::IdleAction # thiscall: Think action 0 handler; turns creatures with a dialog towards the local hero (0x546334)
0x0044df60 cCreature::SetDirection # thiscall (x, y, z floats), ret 0xc: through 0x44ddc0
0x0044ddc0 cCreature::SetDirectionVector # thiscall (vec3*, ?), ret 8: writes +0x70..+0x78
0x004265c0 TypeTable_IsKind4 # thiscall (type table *0xaa9564, type), ret 4: the type's kind byte is 4
0x0049a180 cDialogManager::DialogType # thiscall (dialog, creature), ret 8: record +0x48 of 0x50-byte records at +0x755c; creature flags 0x1000.. -> 0x20..0x23