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/mapreveal.cpp
src/mods/questbook.cpp src/mods/questbook.cpp
src/mods/questfail.cpp src/mods/questfail.cpp
src/mods/questnpc.cpp
src/mods/anticheat.cpp src/mods/anticheat.cpp
src/mods/loadingscreen.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 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 it). Off leaves out solved entries (main and side) and areas whose quests are
all known and solved; failed quests stay (restartable). 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 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 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) ## 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. 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 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. 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; 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 // 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 // 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 // (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 0x00ad1c60 g_nearbyCreatures # {object id, ?} x g_nearbyCreatureCount, creatures near the player
0x00ad1b48 g_nearbyCreatureCount 0x00ad1b48 g_nearbyCreatureCount
0x0049a090 cDialogManager::Answer # (owner, answer): cEvent_creature 0x10c to all heroes (ends the dialog lock); answers < 100000 -> quest script 0x46bd80 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 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 0x0075a870 cUI_Manager::TooltipCreate # a panel from the pool at +0x124
0x00756ba0 cUI_Manager::HandleEsc 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) 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 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) 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 0x0049a180 cDialogManager::DialogType # thiscall (dialog, creature), ret 8: record +0x48 of 0x50-byte records at +0x755c; creature flags 0x1000.. -> 0x20..0x23