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>
156 lines
6.3 KiB
C++
156 lines
6.3 KiB
C++
// 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
|