From c0eedbce7fa8b606d0f310e31f63abcc67be8b3c Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sun, 25 Oct 2026 18:13:43 +0100 Subject: [PATCH] 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:", 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) --- CMakeLists.txt | 1 + docs/quests.md | 50 +++++++++++++ docs/todo.md | 8 +- src/game/engine/quests.h | 17 +++++ src/mods/questnpc.cpp | 155 +++++++++++++++++++++++++++++++++++++++ tools/names.txt | 5 ++ 6 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/mods/questnpc.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 872d929..94504af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 ) diff --git a/docs/quests.md b/docs/quests.md index 259a4fc..84938e3 100644 --- a/docs/quests.md +++ b/docs/quests.md @@ -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:` (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. diff --git a/docs/todo.md b/docs/todo.md index 45fae14..ada2410 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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). diff --git a/src/game/engine/quests.h b/src/game/engine/quests.h index 52e211e..84fa7f7 100644 --- a/src/game/engine/quests.h +++ b/src/game/engine/quests.h @@ -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(kScriptLabelsBegin); } +inline const uint8_t* ScriptLabelsEnd() { return *reinterpret_cast(kScriptLabelsEnd); } +inline const char* ScriptLabelName(const uint8_t* label) { return reinterpret_cast(label); } +inline int32_t ScriptLabelQuest(const uint8_t* label) { + return *reinterpret_cast(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 diff --git a/src/mods/questnpc.cpp b/src/mods/questnpc.cpp new file mode 100644 index 0000000..8aff1e6 --- /dev/null +++ b/src/mods/questnpc.cpp @@ -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:" 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 +#include +#include +#include + +#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 +T Field(const uint8_t* p, size_t offset) { + return *reinterpret_cast(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(kDialogManager); + const uint8_t* begin = Field(manager, kDialogRecordsBegin); + const uint8_t* end = Field(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& DialogLabelQuests() { + static std::map 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 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(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(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(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(&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 diff --git a/tools/names.txt b/tools/names.txt index 204e58c..c48cfcc 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -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