Quests: escorts stay on the follower list, quest givers come back

Escorts lost their place on the hero's follower list after a load, so
quest checks like the one in "Furchtbares Verbrechen" failed; they are
added back once after loading (town NPCs that tag along briefly are left
out). Restarted quests stay available (flag 1), which their giver's dialog
and accepting them need. Quest givers are read from the quest scripts:
when a running quest's giver dies, the quest restarts and the giver is
spawned again at her start; an accepted quest without its giver is repaired
on load. Notes on the script format and opcodes in docs/quests.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-26 02:30:41 +01:00
1 parent 2cab6c216d
commit 7d8089c299
11 files changed
+872 -18

No files matched your search

+2
View File
@@ -78,7 +78,9 @@ add_library(sacredK SHARED
src/mods/mainmenu.cpp
src/mods/mapreveal.cpp
src/mods/questbook.cpp
src/mods/followers.cpp
src/mods/questfail.cpp
src/mods/questgiver.cpp
src/mods/questnpc.cpp
src/mods/anticheat.cpp
src/mods/loadingscreen.cpp
+87 -2
View File
@@ -36,11 +36,38 @@
A failed quest is not final: talking to the quest giver again offers it
again. `mods/questfail.cpp`: after `Quest_End` with success 0 (its failure
script has run) the quest's flags 1/2/4/8 are cleared and its book entries
removed, as if never triggered; quests failed before the mod are reset once
script has run) the quest's flags 2/4/8 are cleared and flag 1 is kept
(book entry: see below); quests failed before the mod are reset once
after loading. Confirmed in the log (the four quests above reset, 2026-10-25);
the giver offering them again is still to be tested in the game.
2026-10-26: the restart first cleared flag 1 as well. Measured: a quest's
script labels, its giver's dialog too, run only while flag 1 is set
(Script_RunLabel `0x46bf11`; "Dialog faild" in the game log otherwise), and
accepting it (opcode 0x14, `0x48f320`) needs it too; the game sets it when the
hero enters the quest's region (`Quest_Trigger`). A restarted quest is now
"available": flags 1.
Marker back to the giver (request 2026-10-25: "quests that failed, should
have the blue marker point back to the quest giver"):
- Measured: the maps draw a marker for every book entry at its compass cell
+0x18/+0x1c, colour from `0x4975d0` (thiscall (entry, kind), ret 8): open
side quest `ff006cff` (blue), main `ffffff80`, solved green, failed red.
The compass target +0x10/+0x14 is set by the scripts' QuestkompassPos
(`0x49a7a0`: a cell, radius +0x20 "rad:") and QuestkompassOBJ
(`0x49af70`: 0xffffffff + object id, or 0xeeeeeeee + script NPC id);
`0x4a6740` resolves it into +0x18/+0x1c. The game keeps no giver per quest.
- A quest is accepted in a dialog answer (`cDialogManager::Answer` 0x49a090 ->
quest script -> `QuestManager_AddBookEntry` 0x46c450). The mod keeps the map
cell of the NPC nearest to the hero then (within 400) as the giver, saved
with the hero data (`questGivers`: quest id -> cell, merged from save game
and hero file; quest ids and givers are the same in every world).
- On failure the book entry stays (state 101) with its compass on the giver
and is drawn blue; without a known giver it is removed as before (all
quests that failed before this change). Accepting the quest again removes
the failed entry.
## Quest book (measured 2026-10-25)
- Book entries (quest manager +0x424, 0x174 bytes): kind +0 (3 main quest,
@@ -151,3 +178,61 @@ exists, is done (flag 0x2) and not in progress (0x4):
Dialogs without a quest (healers, closed shops: `geschlossen101`,
`heilen_global`) stay as the game has them.
## Quest scripts (measured 2026-10-26)
- Code: `binNetScriptFunkCode.bin` (`binAddonNetScript` differs only past
0x9a653), open as the quest manager's FILE* +4 (`0xaaaf04`). Instructions
{u16 opcode, u16 size with the header, args}; args tagged: 0x01 string,
0x0b int32, 0x09 dialog, 0x05 label (e.g. a death label), ... Jump table of
the executor `0x475970` at `0x4787b0` (opcode * 4).
- Labels: the script label table (`0xaa9688`, 0x54 bytes; from
`Vectoren.bin`): name, +0x40 code offset, +0x44 code size, +0x48 quest.
`Script_RunLabel` `0x46bd80` (cdecl (label, object, flag)) reads the code
and runs it with `0x475970` (quest manager thiscall (code, &offset, quest,
object, flag, 0), ret 0x18; any memory buffer with offset 0 works).
- Opcodes seen: 1 spawn ("Res:<script id>", type, position, 0x09 dialog,
0x05 death label), 3 NPC mode (0x09 dialog, 0x0a follow + death label),
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
compass, 0x44 set variable, 0x48 move NPC, 0x56 dialog state.
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
the entry.
- Example "Jagd auf die Wilderer" (1501): `Region15Init` spawns the elf
(Res:17013, type 134, dialog auftrag1501); accepting (`trigger1501`: 0x14)
runs `QIS_OnEnter1501`: poachers with death label `tot1501`, the elf follows
with death label `ex1501` (variable "1501" = 1, then `tot1501`); `tot1501`
once all poachers are dead: variable 0 -> back to the elf, 1 -> quest ended
without the reward. "Hetzjagd" (1502): the daughter (Res:17015, the giver)
is spawned only when the hero walks into trigger area `hetz1_1502`, with
three cultists; her father (Res:17016) only asks for her.
## Escorts on the follower list (request 2026-10-26)
"Furchtbares Verbrechen" (9008) did not go on at its target point: the
script's test there ("@npc", `0x498aa0` case 0x40) looks the NPC up in the
hero's follower list (+0x39c, 9-byte entries), which was empty after a load,
while the NPC still had its leader (+0x251) and follow bit (+0x1f4: 4 for the
follow order, 0x100 for script escorts). `mods/followers.cpp` adds such NPCs
back with `cCreature::AddFollower` `0x54b070` once after loading. Town NPCs
that run after the hero for a few seconds (status 0x404) are left out.
Confirmed 2026-10-26.
## Quest givers come back (request 2026-10-26)
"Change the death script to spawn her back to her starting point", and "on
load we need a repair ... if not completed or correctly running".
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction
carries a dialog with an answer that accepts the quest (85 givers). When a
label of a running quest runs for its dead giver, it is skipped, the quest
restarts (failed entry at the giver, the variables only its labels set back to
0), and once the body is gone the spawn instruction runs again. After a load,
an accepted quest (flag 4) without its giver gets the same. Not yet seen in
the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
audit of how quests can fail.
Lesson (2026-10-26): flag 1 means available, not running; a first version
took it for running and reset 16 available quests and spawned their NPCs on
Borf (repaired once, then saved).
+33
View File
@@ -42,6 +42,15 @@ Known so far: the periodic-hit aura (effect kind 0x12) uses
mapped yet. Other arts' ranges are not mapped. Fits the data-driven arts
item above (range as a property of an art, one place to compute it).
Mörsergranate impact indicator (asked 2026-10-25, while reworking the dwarf's
Kanonenschuss / Mörsergranate): show exactly where the grenade will land and
its explosion radius, a red circle with low alpha on the ground. The landing
point seems to be decided at launch (cCreature combat move code `0x5145d0`,
case 0x43e: the target point, changed by a random offset when closer than
200; passed as the position of the grenade's cEventFX 0x96), so the circle
could be drawn from the launch until the explosion; to be confirmed with the
diagnostic log. To be built after the radius-per-level change.
## Rename life leech to "omnivamp"
Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes
@@ -231,6 +240,30 @@ turn towards the player; once their quest is finished they should stop
turning and be drawn blue (like other NPCs). Done 2026-10-25
(docs/quests.md, "Quest NPCs after the quest").
## Audit how every quest can fail (2026-10-26)
Request: "at some point we need to check ALL the quests and how they possibly
can fail". Context: "Jagd auf die Wilderer" (1501) - the giver's death label
`ex1501` sets the quest variable and runs `tot1501`, which ends the quest as
solved without the reward once the poachers are dead; her survival is not
needed for the quest itself. `src/mods/questgiver.cpp` currently skips a dead
giver's label, restarts the quest and respawns the giver. Plan: from
FunkCode.bin (label table in Vectoren.bin, 0x54-byte records: name, +0x40
code offset, +0x44 size, +0x48 quest; instructions {u16 op, u16 size, args})
list per quest: death labels given to NPCs (spawn / follow instructions with
a 0x05 label), the labels that end it (opcode 0x0f) or lose it, and which
NPCs must survive (escorts). Then decide per kind: respawn the giver only,
or restart the quest.
## Quest giver death rule untested (2026-10-26)
`src/mods/questgiver.cpp` restarts a running quest whose giver died and
spawns the giver again at her start (docs/quests.md, "Quest givers come
back"). Built and checked against the scripts, not yet seen in the game: test
with an escort whose giver dies (e.g. "Jagd auf die Wilderer" on the
Gladiator, or "Hetzjagd"). Borf's case (the quest ended without the reward)
was repaired once by hand.
## Inventory drawn without its dark backgrounds (2026-10-25, intermittent)
Reported twice: on opening the inventory the item list had no dark
+12
View File
@@ -75,6 +75,18 @@ inline void RemoveFromMap(uint32_t id) {
}
}
// The end of the object ids (0x5fe730: object vector +4..+8, 4 bytes each);
// ids 1..end-1 can be passed to GetObjectById.
inline uint32_t ObjectIdEnd() {
const auto* manager = static_cast<const uint8_t*>(ObjectManager());
if (!manager) {
return 0;
}
const auto begin = *reinterpret_cast<const uintptr_t*>(manager + 4);
const auto end = *reinterpret_cast<const uintptr_t*>(manager + 8);
return static_cast<uint32_t>((end - begin) / 4);
}
// The object `id` as a cCreature, or null.
inline void* GetCreature(uint32_t id) {
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
+41
View File
@@ -68,6 +68,47 @@ constexpr size_t kCreatureId = 0xc;
constexpr size_t kCreatureX = 0x1c;
constexpr size_t kCreatureY = 0x20;
// Escorts (measured 2026-10-26, docs/quests.md): an NPC told to follow
// (cCreature::ReceiveOrder 0x52e400, e.g. 0x52e64f) stores its leader id at
// +0x251, sets bit 4 of +0x1f4 (escorts of quest scripts have 0x100 instead,
// measured on "Jagd auf die Wilderer"; the leave order clears both) and is added to the leader's follower list
// (+0x39c..+0x3a0, 9-byte entries {u32 id, u32, u8}) with AddFollower.
// Quest scripts test "@npc" (0x498aa0 case 0x40) against that list.
constexpr size_t kCreatureLeader = 0x251;
constexpr size_t kCreatureStatus = 0x1f4;
constexpr uint32_t kStatusFollowing = 0x104;
constexpr size_t kCreatureFollowersBegin = 0x39c;
constexpr size_t kCreatureFollowersEnd = 0x3a0;
constexpr size_t kFollowerEntrySize = 9;
#pragma pack(push, 1)
struct FollowerEntry {
uint32_t id;
uint32_t value; // 1 for escorts, as the follow order adds them
uint8_t flag; // 1 for escorts
};
#pragma pack(pop)
static_assert(sizeof(FollowerEntry) == kFollowerEntrySize);
// cCreature::AddFollower (0x54b070, thiscall (const FollowerEntry*), ret 4):
// adds the entry to the leader's list unless its id is in it.
inline void AddFollower(void* leader, const FollowerEntry& entry) {
using Fn = void(__fastcall*)(void*, void*, const FollowerEntry*);
reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry);
}
inline bool HasFollower(const void* leader, uint32_t id) {
const auto* creature = static_cast<const uint8_t*>(leader);
const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
if (*reinterpret_cast<const uint32_t*>(entry) == id) {
return true;
}
}
return false;
}
// The walk path (measured 2026-10-18): waypoints {x, y} at +0x2ed + i * 8,
// the current segment's start index at +0x36d, the count at +0x371, and the
// step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381.
+66
View File
@@ -55,6 +55,8 @@ constexpr uintptr_t kScriptLabelsEnd = 0x00aa968c;
constexpr size_t kScriptLabelSize = 0x54;
constexpr size_t kScriptLabelNameSize = 0x48;
constexpr size_t kScriptLabelQuest = 0x48;
constexpr size_t kScriptLabelCode = 0x40; // offset of its code in FunkCode.bin
constexpr size_t kScriptLabelCodeSize = 0x44; // bytes
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); }
@@ -62,6 +64,70 @@ inline const char* ScriptLabelName(const uint8_t* label) { return reinterpret_ca
inline int32_t ScriptLabelQuest(const uint8_t* label) {
return *reinterpret_cast<const int32_t*>(label + kScriptLabelQuest);
}
inline uint32_t ScriptLabelCode(const uint8_t* label) {
return *reinterpret_cast<const uint32_t*>(label + kScriptLabelCode);
}
inline uint32_t ScriptLabelCodeSize(const uint8_t* label) {
return *reinterpret_cast<const uint32_t*>(label + kScriptLabelCodeSize);
}
// Script code (FunkCode.bin, open as the quest manager's FILE* +4; measured
// 2026-10-26): instructions {u16 opcode, u16 size (with these 4 bytes), args};
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
// (0x46bd80) reads a label's code and runs it instruction by instruction with
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
// position, 0x09 + its dialog, ...; e.g. Region15Init), 0x44 sets a script
// variable ({0x01 name, 0x0b value}).
constexpr uint16_t kScriptSpawn = 0x01;
constexpr uint16_t kScriptSetVariable = 0x44;
constexpr uint8_t kScriptArgString = 0x01;
constexpr uint8_t kScriptArgInt = 0x0b;
constexpr uint8_t kScriptArgDialog = 0x09;
// CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
inline size_t ReadScriptFile(uint32_t offset, void* out, size_t size) {
using SeekFn = int(__cdecl*)(void*, long, int);
using ReadFn = size_t(__cdecl*)(void*, size_t, size_t, void*);
void* file = ScriptFile();
if (!file || reinterpret_cast<SeekFn>(0x0084866d)(file, static_cast<long>(offset), 0) != 0) {
return 0;
}
return reinterpret_cast<ReadFn>(0x00848726)(out, 1, size, file);
}
// Runs the one instruction at `code` (0x475970 on the quest manager, thiscall
// (code, &offset, quest, object, flag, 0), ret 0x18; with an offset other than
// -1 it reads from memory, -1 from the script file). 0 on an error.
inline int RunScriptInstruction(const uint8_t* code, int32_t quest = -1, void* object = nullptr) {
using Fn = int(__fastcall*)(void*, void*, const uint8_t*, int32_t*, int32_t, void*, int32_t, int32_t);
int32_t offset = 0;
return reinterpret_cast<Fn>(0x00475970)(reinterpret_cast<void*>(0x00aaaf00), nullptr, code, &offset, quest,
object, 0, 0);
}
// The script id of an object (0x44a390, thiscall (), ret: +0x3c, or its
// type's default), as "Res:<id>" names it.
inline uint32_t ObjectScriptId(void* object) {
using Fn = uint32_t(__fastcall*)(void*, void*);
return reinterpret_cast<Fn>(0x0044a390)(object, nullptr);
}
// The object id the scripts know as `scriptId` (0x464ec0 on the quest
// manager, thiscall (script id, &object id), ret 8: its map +0x7668, which
// may still hold a dead object's id, then all objects). 0 if none.
// The quest manager's map script id -> object id (+0x7668, std::map;
// operator[] 0x4b37b0, thiscall (&key), ret 4; spawning sets it, 0x4839fa).
inline uint32_t& ScriptObjectSlot(uint32_t scriptId) {
using Fn = uint32_t*(__fastcall*)(void*, void*, const uint32_t*);
return *reinterpret_cast<Fn>(0x004b37b0)(reinterpret_cast<uint8_t*>(0x00aaaf00) + 0x7668, nullptr, &scriptId);
}
inline uint32_t ScriptObjectId(uint32_t scriptId) {
using Fn = char(__fastcall*)(void*, void*, uint32_t, uint32_t*);
uint32_t id = 0;
return reinterpret_cast<Fn>(0x00464ec0)(reinterpret_cast<void*>(0x00aaaf00), nullptr, scriptId, &id) ? id : 0;
}
// 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
+73
View File
@@ -0,0 +1,73 @@
// Escorted NPCs stay on the hero's follower list (2026-10-26, docs/quests.md).
// "Furchtbares Verbrechen" (quest 9008) did not go on at its target point:
// the NPC walked after the hero, but the script's test there ("@npc", 0x498aa0
// case 0x40) looks the NPC up in the hero's follower list (hero +0x39c), and
// that list was empty (measured on Borf's save: NPC 0x1fa with leader 1 and
// the follow bit, no followers on the hero). The NPC keeps its side of the
// escort (leader +0x251, status bit 4) across a load; the hero's list does
// not come back. The mod adds every NPC that follows a hero but is missing
// from its list back with the game's own AddFollower (0x54b070), with the
// entry the follow order uses ({id, 1, 1}), once after loading.
#include <cstdint>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/log.h"
namespace mods::followers {
namespace {
constexpr int kTicksAfterStart = 30;
// Small town NPCs run after the hero for a few seconds (status 0x404, leader
// the hero) without joining his list (measured 2026-10-26: types 0x2be,
// 0x2c0); they are not escorts.
constexpr int32_t kStatusTagAlong = 0x400;
int g_ticksToCheck = -1;
int32_t Field(const void* creature, size_t offset) {
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
}
void RepairFollowers() {
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) {
if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) {
continue;
}
void* creature = game::GetCreature(id);
const int32_t status = creature ? Field(creature, game::kCreatureStatus) : 0;
if (!(status & game::kStatusFollowing) || (status & kStatusTagAlong)) {
continue;
}
const auto leaderId = static_cast<uint32_t>(Field(creature, game::kCreatureLeader));
void* leader = game::IsPlayerHeroId(leaderId) ? game::GetCreature(leaderId) : nullptr;
if (!leader || game::HasFollower(leader, id)) {
continue;
}
game::AddFollower(leader, {id, 1, 1});
re::Log("followers: %x back on the follower list of hero %x", id, leaderId);
}
}
void OnTick(uint8_t*) {
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
RepairFollowers();
}
}
void OnStart() {
g_ticksToCheck = kTicksAfterStart;
}
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnGameStart(&OnStart);
}
} g_registration;
} // namespace
} // namespace mods::followers
+190 -16
View File
@@ -1,43 +1,126 @@
// Failed quests can be restarted (request of 2026-10-17): a failed quest is not
// final, the quest giver offers it again.
// final, the quest giver offers it again. Its marker points back to the quest
// giver, drawn blue like an open side quest (request of 2026-10-25: "quests
// that failed, should have the blue marker point back to the quest giver").
//
// Measured (docs/quests.md): a failed quest keeps flags 3 (triggered + done) in
// the quest table and its quest book entry gets state 101; Quest_Trigger
// (0x46de10) does nothing for a quest with flag 1, so the giver never offers it
// again. The game itself repeats only the generic quests (ids 2600..3599,
// Quest_End copies them under a new id).
// Quest_End copies them under a new id). The maps draw a marker for every book
// entry at its quest compass cell (+0x18/+0x1c), coloured by 0x4975d0: blue
// for open side quests, red for failed ones. The game does not keep the giver:
// a quest is accepted in a dialog answer (cDialogManager::Answer 0x49a090 ->
// quest script -> book entry 0x46c450), so the giver is the NPC next to the
// hero then.
//
// The mod: after Quest_End failed a quest (its failure script has run), the
// quest's flags are cleared and its book entries removed, as if it had never
// been triggered. Quests that failed before the mod are reset the same way once
// after loading.
// The mod: when a quest is accepted in a dialog, the nearest NPC's map cell is
// kept as the quest's giver (saved with the hero data, "questGivers"; quest
// ids are the same in every world, and so are the givers). After Quest_End
// failed a quest (its failure script has run), the quest's flags are reset to
// available (not accepted, not done); its book entry stays as failed with the
// compass on the giver, or is removed if the giver is not known. Accepting it
// again removes the failed entry. Quests that failed before the mod are reset
// the same way once after loading.
#include <algorithm>
#include <cstdint>
#include <map>
#include <string>
#include <utility>
#include <nlohmann/json.hpp>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "mods/inventory_save.h"
#include "mods/questfail.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questfail {
namespace {
constexpr uint32_t kResetFlags = game::kQuestTriggered | game::kQuestDone | game::kQuestInProgress | game::kQuestBusy;
// A restarted quest stays available (kQuestTriggered): its labels, the giver's
// dialog too, run only while it is set (Script_RunLabel 0x46bf11; measured
// 2026-10-26, "Dialog faild" without it), and so does accepting it (script
// opcode 0x14, 0x48f320). The game sets it when the hero enters the quest's
// region (Quest_Trigger 0x46de10).
constexpr uint32_t kResetFlags = game::kQuestDone | game::kQuestInProgress | game::kQuestBusy;
// The giver is the nearest NPC within this distance of the hero when the quest
// is accepted (world units; a talk starts at contact distance, about 100).
constexpr uint32_t kGiverMaxDistance = 400;
// Marker colours of 0x4975d0 for an open side quest (state 2): kind 0 the
// marker, kind 1 the second colour (white).
constexpr uint32_t kOpenSideQuestColour = 0xff006cff;
constexpr uint32_t kOpenQuestColour2 = 0xffffffff;
bool g_resetAfterLoad = false;
bool g_inDialogAnswer = false;
// The quest at table `index` as if it had never been triggered.
void Restart(int index) {
uint8_t* quest = game::Quest(index);
const int32_t id = game::QuestId(quest);
re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest));
game::QuestFlags(quest) &= ~kResetFlags;
for (size_t i = game::BookCount(); i-- > 0;) {
if (game::BookQuestId(game::BookEntry(i)) == id) {
game::EraseBookEntry(i);
// Quest id -> the giver's map cell.
std::map<int32_t, std::pair<int32_t, int32_t>> g_givers;
// The nearest living, not hostile, non-hero creature near the hero, or 0. All
// objects are searched (the nearby-creature list 0xad1c60 did not hold the
// givers).
uint32_t NearestNpc(void* hero) {
const int32_t heroX = game::CreatureField(hero, game::kCreatureX);
const int32_t heroY = game::CreatureField(hero, game::kCreatureY);
uint32_t best = 0;
uint32_t bestDistance = kGiverMaxDistance + 1;
for (uint32_t id = game::kPlayerHeroIdEnd, end = game::ObjectIdEnd(); id < end; ++id) {
void* creature = game::GetCreature(id);
if (!creature) {
continue;
}
const int32_t dx = game::CreatureField(creature, game::kCreatureX) - heroX;
const int32_t dy = game::CreatureField(creature, game::kCreatureY) - heroY;
if (dx < -1000 || dx > 1000 || dy < -1000 || dy > 1000) {
continue;
}
const uint32_t distance = game::DistanceTo(hero, id);
const bool alive = game::IsAlive(creature);
const bool hostile = game::IsHostileTo(hero, creature);
if (alive && !hostile && distance < bestDistance) {
best = id;
bestDistance = distance;
}
}
return best;
}
void RememberGiver(int32_t questId) {
void* hero = game::GetPlayerHero();
const uint32_t npc = hero ? NearestNpc(hero) : 0;
void* creature = npc ? game::GetCreature(npc) : nullptr;
if (!creature) {
re::Log("questfail: quest %d accepted, no giver near the hero", questId);
return;
}
const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) +
game::kObjectPosition);
const game::MapPosition cell = game::CellAt(creature, position->x, position->y);
g_givers[questId] = {cell.x, cell.y};
re::Log("questfail: quest %d accepted from creature %u at cell %d,%d", questId, npc, cell.x, cell.y);
}
// The entry's compass on its quest's giver (a cell target, no radius). False if
// the giver is not known.
bool PointAtGiver(uint8_t* entry) {
const auto giver = g_givers.find(game::BookQuestId(entry));
if (giver == g_givers.end()) {
return false;
}
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
field(game::kBookCompassX) = field(game::kBookX) = giver->second.first;
field(game::kBookCompassY) = field(game::kBookY) = giver->second.second;
field(game::kBookCompassRadius) = 0;
return true;
}
void RestartFailedInBook() {
@@ -54,6 +137,15 @@ void RestartFailedInBook() {
}
}
void EraseFailedEntries(int32_t questId) {
for (size_t i = game::BookCount(); i-- > 0;) {
const uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == questId && game::BookState(entry) == game::kBookFailed) {
game::EraseBookEntry(i);
}
}
}
void OnTick(uint8_t*) {
if (g_resetAfterLoad) {
g_resetAfterLoad = false;
@@ -65,8 +157,48 @@ void OnLoaded() {
g_resetAfterLoad = true;
}
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------
nlohmann::json WriteGivers(unsigned) {
if (g_givers.empty()) {
return nlohmann::json();
}
nlohmann::json all = nlohmann::json::object();
for (const auto& [id, cell] : g_givers) {
all[std::to_string(id)] = {cell.first, cell.second};
}
return all;
}
// Merged: the hero file and the save game both carry the list.
void ReadGivers(unsigned, const nlohmann::json& data) {
if (!data.is_object()) {
return;
}
for (const auto& [key, cell] : data.items()) {
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
g_givers[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
}
}
}
} // namespace
// The quest at table `index` as if it had never been triggered; its failed book
// entries point at the giver (or go).
void Restart(int index) {
uint8_t* quest = game::Quest(index);
const int32_t id = game::QuestId(quest);
re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest));
game::QuestFlags(quest) = (game::QuestFlags(quest) & ~kResetFlags) | game::kQuestTriggered;
for (size_t i = game::BookCount(); i-- > 0;) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == id && !PointAtGiver(entry)) {
game::EraseBookEntry(i);
}
}
}
// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc):
// LoseQuest passes success 0, ExitQuest 1.
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
@@ -78,11 +210,53 @@ void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char succ
}
}
// cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
// quest script for the answer.
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer);
RE_HOOK(0x0049a090, Dialog_Answer);
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer) {
g_inDialogAnswer = true;
Dialog_Answer_original(self, edx, owner, answer);
g_inDialogAnswer = false;
}
// QuestManager_AddBookEntry (0x46c450, quest manager thiscall (quest index, ?), ret 8): adds
// the quest's book entry (state 2).
void __fastcall QuestManager_AddBookEntry(uint8_t* self, void* edx, int index, int a2);
RE_HOOK(0x0046c450, QuestManager_AddBookEntry);
void __fastcall QuestManager_AddBookEntry(uint8_t* self, void* edx, int index, int a2) {
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
const int32_t id = known ? game::QuestId(game::Quest(index)) : 0;
if (known) {
EraseFailedEntries(id);
}
QuestManager_AddBookEntry_original(self, edx, index, a2);
if (known && g_inDialogAnswer && id > game::kMainQuestLast) {
RememberGiver(id);
}
}
// QuestManager_MarkerColour (0x4975d0, quest manager thiscall (entry, kind), ret 8): a
// failed side quest pointing at its giver is drawn as an open one.
uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t index, int16_t kind);
RE_HOOK(0x004975d0, QuestManager_MarkerColour);
uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t index, int16_t kind) {
if (index < game::BookCount() && (kind == 0 || kind == 1)) {
const uint8_t* entry = game::BookEntry(index);
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
g_givers.count(game::BookQuestId(entry))) {
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
}
}
return QuestManager_MarkerColour_original(self, edx, index, kind);
}
namespace {
struct Register {
Register() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
}
} g_register;
} // namespace
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// Failed quests can be restarted (questfail.cpp).
namespace mods::questfail {
// The quest at table `index` as if it had never been triggered; its failed book
// entries point at the giver (or go).
void Restart(int index);
} // namespace mods::questfail
+340
View File
@@ -0,0 +1,340 @@
// Quest givers come back (request of 2026-10-26, docs/quests.md): "Jagd auf
// die Wilderer" (quest 1501): the giver, an elf, follows the hero and fights;
// when she died the quest could not be finished ("change the death script to
// spawn her back to her starting point"; "on load we need a repair ... if not
// completed or correctly running").
//
// Measured: the quest's script (FunkCode.bin) gives the giver a death label
// when she starts following ("ex1501": quest variable "1501" = 1, then the
// poachers' death label "tot1501", which ends the quest without a reward once
// all poachers are dead; until then it stays in progress with no giver). The
// body is removed. The giver is placed by her region's start script
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at
// pos_auftrag1501, dialog auftrag1501), which the game runs once.
//
// The mod finds each quest's giver in the script code: the NPC whose spawn
// instruction carries a dialog that has a "Dialog:<name>" label of the quest.
// When a label of that quest runs for its dead giver (her death label), the
// label is skipped; the quest restarts (questfail::Restart, its book entry
// failed and pointing at the giver), the variables only this quest's labels
// set go back to 0, and once the body is gone the game runs the giver's
// spawn instruction again: she stands at her start, offering the quest.
// After a load, a quest in progress whose giver no longer exists is repaired
// the same way.
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <map>
#include <set>
#include <string>
#include <vector>
#include <windows.h>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "mods/questfail.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questgiver {
namespace {
constexpr size_t kInstructionHeader = 4;
constexpr size_t kMaxInstruction = 0x800; // 0x475970 refuses larger ones
constexpr size_t kReadChunk = 1 << 20;
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
constexpr uint16_t kScriptStartQuest = 0x14; // {0x0b quest id}
constexpr size_t kStartQuestSize = 9;
constexpr uint16_t kScriptAnswer = 0x3c; // dialog answer {0x01 text, 0x01 label}
struct Giver {
uint32_t scriptId = 0;
std::vector<uint8_t> spawn; // the spawn instruction
};
struct Pending {
int questIndex;
void* body; // the dead giver's object
uint32_t bodyId;
DWORD since;
};
bool g_scanned = false;
std::vector<uint8_t> g_code; // FunkCode.bin
std::map<int32_t, Giver> g_givers; // quest id -> giver
std::map<int32_t, std::vector<std::string>> g_variables; // quest id -> variables only it sets
std::vector<Pending> g_pending;
bool g_repairAfterLoad = false;
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
bool ReadCode() {
g_code.clear();
for (uint32_t offset = 0;; offset += kReadChunk) {
g_code.resize(offset + kReadChunk);
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
g_code.resize(offset + read);
if (read < kReadChunk) {
return !g_code.empty();
}
}
}
// Calls `fn(at, opcode, size)` for each instruction of a label's code.
template <typename Fn>
void ForEachInstruction(const uint8_t* label, Fn fn) {
const size_t begin = game::ScriptLabelCode(label);
const size_t end = begin + game::ScriptLabelCodeSize(label);
for (size_t at = begin; at + kInstructionHeader <= end && end <= g_code.size();) {
const uint16_t size = U16(at + 2);
if (size < kInstructionHeader || at + size > end) {
return;
}
fn(at, U16(at), size);
at += size;
}
}
// The spawn instructions in the code: "Res:<number>" with a dialog.
void ScanSpawns(const std::map<std::string, int32_t>& dialogs) {
static const char kRes[] = "res:";
for (size_t at = 0; at + 9 < g_code.size(); ++at) {
if (U16(at) != game::kScriptSpawn || g_code[at + 4] != game::kScriptArgString ||
_strnicmp(reinterpret_cast<const char*>(&g_code[at + 5]), kRes, 4) != 0) {
continue;
}
const uint16_t size = U16(at + 2);
if (size <= 9 || size > kMaxInstruction || at + size > g_code.size()) {
continue;
}
char* numberEnd = nullptr;
const unsigned long scriptId = std::strtoul(reinterpret_cast<const char*>(&g_code[at + 9]), &numberEnd, 10);
if (scriptId == 0 || *numberEnd != 0) {
continue;
}
for (size_t tag = at + 9; tag + 1 < at + size; ++tag) {
if (g_code[tag] != game::kScriptArgDialog) {
continue;
}
const char* name = reinterpret_cast<const char*>(&g_code[tag + 1]);
if (!memchr(name, 0, at + size - tag - 1)) {
break;
}
const auto dialog = dialogs.find(name);
if (dialog != dialogs.end() && !g_givers.count(dialog->second)) {
g_givers[dialog->second] = {static_cast<uint32_t>(scriptId),
std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size)};
}
}
}
}
// The variables each quest's labels set; a variable set by several quests
// (or by labels of none) is not any one quest's.
void ScanVariables() {
std::map<std::string, std::set<int32_t>> setters;
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
const int32_t quest = game::ScriptLabelQuest(label);
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
if (opcode == game::kScriptSetVariable && g_code[at + 4] == game::kScriptArgString &&
memchr(&g_code[at + 5], 0, size - 5)) {
setters[reinterpret_cast<const char*>(&g_code[at + 5])].insert(quest);
}
});
}
for (const auto& [name, quests] : setters) {
if (quests.size() == 1 && *quests.begin() >= 0 && g_givers.count(*quests.begin())) {
g_variables[*quests.begin()].push_back(name);
}
}
}
void Scan() {
if (g_scanned) {
return;
}
g_scanned = true;
if (!ReadCode()) {
re::Log("questgiver: script code not readable");
return;
}
// Labels by name, and the accept labels: code starting with opcode 0x14
// (start quest #id, 0x48f320; e.g. "trigger1501").
std::map<std::string, const uint8_t*> byName;
std::map<std::string, int32_t> accepts; // label name -> quest it starts
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
const std::string name(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
byName.emplace(name, label);
const size_t at = game::ScriptLabelCode(label);
if (game::ScriptLabelCodeSize(label) >= kStartQuestSize && at + kStartQuestSize <= g_code.size() &&
U16(at) == kScriptStartQuest && g_code[at + 4] == game::kScriptArgInt) {
int32_t quest;
std::memcpy(&quest, &g_code[at + 5], sizeof(quest));
accepts.emplace(name, quest);
}
}
// The givers' dialogs: "Dialog:<name>" labels with an answer (opcode
// 0x3c, {0x01 text, 0x01 label}) that accepts their quest.
std::map<std::string, int32_t> dialogs; // dialog name -> quest
static const char kDialog[] = "Dialog:";
for (const auto& [name, label] : byName) {
const int32_t quest = game::ScriptLabelQuest(label);
if (quest < 0 || name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
continue;
}
bool offers = false;
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
if (opcode != kScriptAnswer || g_code[at + 4] != game::kScriptArgString) {
return;
}
const char* text = reinterpret_cast<const char*>(&g_code[at + 5]);
const size_t textEnd = 5 + strnlen(text, size - 5) + 1;
if (textEnd + 1 < size && g_code[at + textEnd] == game::kScriptArgString) {
const auto accept = accepts.find(reinterpret_cast<const char*>(&g_code[at + textEnd + 1]));
offers = offers || (accept != accepts.end() && accept->second == quest);
}
});
if (offers) {
dialogs.emplace(name.substr(sizeof(kDialog) - 1), quest);
}
}
ScanSpawns(dialogs);
ScanVariables();
re::Log("questgiver: %u quest givers in %u bytes of script code", static_cast<unsigned>(g_givers.size()),
static_cast<unsigned>(g_code.size()));
}
// The living giver of `giver`, or null.
void* LivingGiver(const Giver& giver) {
const uint32_t id = game::ScriptObjectId(giver.scriptId);
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
return creature && game::ObjectScriptId(creature) == giver.scriptId && game::IsAlive(creature) ? creature
: nullptr;
}
void ResetVariables(int32_t questId) {
for (const std::string& name : g_variables[questId]) {
std::vector<uint8_t> code(kInstructionHeader);
code.push_back(game::kScriptArgString);
code.insert(code.end(), name.begin(), name.end());
code.push_back(0);
code.push_back(game::kScriptArgInt);
code.insert(code.end(), 4, 0);
code[0] = static_cast<uint8_t>(game::kScriptSetVariable);
code[1] = 0;
code[2] = static_cast<uint8_t>(code.size());
code[3] = static_cast<uint8_t>(code.size() >> 8);
game::RunScriptInstruction(code.data());
re::Log("questgiver: quest %d variable '%s' = 0", questId, name.c_str());
}
}
// Restarts quest `index`: flags (available again), its book entry (failed,
// at the giver) and its variables.
void ResetQuest(int index) {
const int32_t id = game::QuestId(game::Quest(index));
questfail::Restart(index);
for (size_t i = 0; i < game::BookCount(); ++i) {
uint8_t* entry = game::BookEntry(i);
if (game::BookQuestId(entry) == id) {
*reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed;
}
}
ResetVariables(id);
}
void Spawn(int index) {
const int32_t id = game::QuestId(game::Quest(index));
const Giver& giver = g_givers[id];
const int result = game::RunScriptInstruction(giver.spawn.data());
void* creature = LivingGiver(giver);
re::Log("questgiver: quest %d giver Res:%u spawned again (%d): %s", id, giver.scriptId, result,
creature ? "back" : "NOT FOUND");
}
// Accepted and not done (kQuestTriggered alone: available, set when the hero
// enters the quest's region).
bool IsRunning(int index) {
const uint32_t flags = game::QuestFlags(game::Quest(index));
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone);
}
void RepairAfterLoad() {
for (const auto& [id, giver] : g_givers) {
const int index = game::QuestIndex(id);
if (index < 0 || !IsRunning(index) || LivingGiver(giver)) {
continue;
}
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
game::QuestName(game::Quest(index)), giver.scriptId);
ResetQuest(index);
Spawn(index);
}
}
void OnTick(uint8_t*) {
if (g_repairAfterLoad) {
g_repairAfterLoad = false;
Scan();
RepairAfterLoad();
}
for (auto it = g_pending.begin(); it != g_pending.end();) {
const bool gone = game::GetObjectById(it->bodyId) != it->body;
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
++it;
continue;
}
Spawn(it->questIndex);
it = g_pending.erase(it);
}
}
void OnLoaded() {
g_repairAfterLoad = true;
g_pending.clear();
}
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
}
} g_registration;
} // namespace
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label
// of a running quest run for its dead giver (her death label) is skipped and
// the quest restarted.
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
RE_HOOK(0x0046bd80, Script_RunLabel);
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
const uint8_t* labels = game::ScriptLabelsBegin();
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
if (creature && IsRunning(index) && !game::IsAlive(creature)) {
Scan();
const auto giver = g_givers.find(questId);
if (giver != g_givers.end() && game::ObjectScriptId(creature) == giver->second.scriptId) {
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts",
giver->second.scriptId, questId,
game::ScriptLabelName(labels + label * game::kScriptLabelSize));
ResetQuest(index);
const uint32_t bodyId = *reinterpret_cast<uint32_t*>(object + game::kCreatureId);
g_pending.push_back({index, game::GetObjectById(bodyId), bodyId, GetTickCount()});
return 0;
}
}
}
return Script_RunLabel_original(label, object, flag);
}
} // namespace mods::questgiver
+18
View File
@@ -413,3 +413,21 @@
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
0x0046de10 Quest_Trigger # cdecl (index, ?, ?): makes a quest available (flag 1, busy 8), runs its init label +0x114 once and its trigger label +0x10c
0x0046ced0 Quest_End # quest manager thiscall (index, ?, success), ret 0xc: book entry 100 / 101
0x00475970 Script_RunInstruction # quest manager thiscall (code, &offset, quest, object, flag, 0), ret 0x18: one instruction; offset -1 reads from the FILE*
0x00498aa0 Script_Condition # thiscall: script conditions; case 0x40 "@npc": npc on the hero's follower list +0x39c (or +0x570)
0x0048f320 Script_StartQuest # opcode 0x14 #quest: needs quest flag 1; runs the trigger label, adds the book entry
0x0048f110 Script_EndQuest # opcode 0x0f: Quest_End of the current quest
0x00482800 Script_Spawn # opcode 1: spawns a creature ("Res:<script id>", type, position, 0x09 dialog, 0x05 death label, ...)
0x0048be30 Script_NpcMode # opcode 3: an NPC's mode (0x09 dialog, 0x0a follow + death label, ...)
0x00464ec0 QuestManager_ScriptObjectId # thiscall (script id, &object id), ret 8: map +0x7668, then all objects
0x0044a390 cObject::ScriptId # thiscall, plain ret: +0x3c or the type's default
0x0044a380 cObject::SetScriptId # thiscall (script id)
0x004b37b0 ScriptIdMap::operator[] # thiscall (&key), ret 4: quest manager +0x7668
0x0054b070 cCreature::AddFollower # thiscall ({u32 id, u32, u8}*), ret 4: hero +0x39c list, unless the id is in it
0x005145d0 cCreature::CombatMove # combat arts' moves; case 0x43f Kanonenschuss, 0x43e Mörsergranate
0x005ab400 cObjectFX::GatherTargets # effect objects collect their targets (0x5ff820 at 0x5ab63d, effect in ebx)
0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14
0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50
0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100