// 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