Compare commits

..
2 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 7d8089c299 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>
2026-10-26 02:30:41 +01:00
khallaandClaude Opus 5.5 2cab6c216d Dwarf arts: Kanonenschuss stuns without knockback, Mörsergranate radius grows
Kanonenschuss: the stun no longer plays the hit motion or pushes the
target; stunned creatures stand still for 1.5 s. The impact effect only
shows on targets that did not resist, and the ray reaches 1800 for the
wider view. Mörsergranate: the impact radius grows by a tenth of its size
every 5 skill levels. Art levels come from a shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 02:30:29 +01:00
16 changed files with 1146 additions and 30 deletions

No files matched your search

+3
View File
@@ -60,6 +60,7 @@ add_library(sacredK SHARED
src/mods/combos.cpp
src/mods/aurahits.cpp
src/mods/chainlightning.cpp
src/mods/dwarfarts.cpp
src/mods/arttooltip.cpp
src/mods/escape.cpp
src/mods/recordcache.cpp
@@ -77,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
+48
View File
@@ -0,0 +1,48 @@
# Dwarf arts: Kanonenschuss and Mörsergranate
## Requests (2026-10-25 / 2026-10-26)
- Kanonenschuss (art 0x43f, `gui_spells_kanone`): "remove the knockback,
instead it can be just a stun" (chosen: no push, only a stun); the range
increased for the wider view; the stun particles not on targets that
avoided the stun. The game's stun resistance stays (2026-10-26: "we dont
ignore anything, the skill is fine for now").
- Mörsergranate (art 0x43e, `gui_spells_moerser`): "the radius of the
explosion should increase every 5 skill levels".
## Measured
- The cannon's code is the combat move function `0x5145d0`, case 0x43f. Its
ray: `cmp edi,19h` at `0x517389`, 25 steps of 40 = 1000 range. Hostile
targets get `cEvent_creature` 5 from `0x51768d`.
- Stun chance `0x4fc810(level, creature)` = level × 5 / creature level × 100
(`UI_STAT_STUNNCHANCE`). On a successful roll: `0x5936e0(MOTION_HIT_A 0xaf,
3.0, 0.25)` at `0x517709` (pushes from `0x5176f8`), then the stun order
`cEvent_creature` 3 with +0x14 = 100, sent by `call [eax+18h]` at
`0x5177aa` (returns to `0x5177ad`).
- The stun *is* the knockback. `cCreature::ReceiveOrder` `0x52e400` case 3:
first the resistance `vf24(0x50)` (`0x55d0a0`: flag +0x4f0 & 0x1000 resists
2 in 3, effect 0x35d a percentage); otherwise push left +0x24d = 100, flag
+0x200 |= 0x20000, attacker +0x259, ResetToIdle. `cCreature::Update` branch
`0x594c82` moves a creature with that flag away from the attacker and lowers
the push left by each step (`0x594deb`), measured about 100 units in 0.3 s;
at 0 the flag is cleared.
- The impact effect `TYPE_FX_DWARF_SHOOT_IMPACT` (0x47f) is created on every
hit target by `CreateAt` at `0x517855` (target in ebx), resisted or not.
- Mörsergranate: the grenade (type 0x3fa) lands and creates
`TYPE_FX_GRENADEIMPACT` (0x3fc), which gathers its targets with
`cObjectManager 0x5ff820` (called from `0x5ab400` at `0x5ab63d`): a creature
is hit when its distance is below the impact's size (150, type table
`0x428bb0`) plus its own type's size.
## The mod (`src/mods/dwarfarts.cpp`)
- The HIT_A motion is jumped over (`0x5176f8`: `jmp` past the call).
- The push step at `0x594deb` calls `PushStepThunk`: creatures the cannon
stunned (order 3 from `0x5177ad`, kept with an end time) move by 0 and keep
their push left above 0 for 1.5 s, so they stand still, then carry on.
- The impact effect is created only on targets in the stun state.
- The ray gets 45 steps (1800); confirmed to reach across the widened view.
- The impact's gather call gets a radius of size × (1 + 0.1 × (level / 5)),
with the owner's Mörsergranate level (`game::ArtLevel`). The growth 0.1 per
step is our choice; not yet seen in the game (Borf's level is 2).
+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
+13 -3
View File
@@ -86,8 +86,8 @@ constexpr uint16_t kArtHeldenmut = 0x3f7;
constexpr uint16_t kArtKlingenblick = 0x3f8;
constexpr uint16_t kArtEnergieblitze = 0x3fb;
constexpr uint16_t kArtFlammenwerfer = 0x43d;
constexpr uint16_t kArtMeteor = 0x43e;
constexpr uint16_t kArtKanone = 0x43f;
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
// cCreatureArts fields.
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
@@ -106,7 +106,7 @@ constexpr uint32_t kRegenGroupCombo = 3; // handled by the combo branch
// Arts regenerating in the magic group whatever their entry says
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMeteor ||
return group == kRegenGroupMagic || art == kArtEnergieblitze || art == kArtFlammenwerfer || art == kArtMoerser ||
art == kArtKanone;
}
@@ -160,6 +160,16 @@ inline bool EntryAtMagicRate(const uint8_t* entry) {
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
}
// The level of art `art` in cCreatureArts `arts` (cooldown entry level + bonus
// levels), 0 if it has none.
inline unsigned ArtLevelIn(uint8_t* arts, uint16_t art) {
const uint8_t* entry = FindCooldownEntry(arts, art);
return entry ? entry[kCooldownLevel] + entry[kCooldownBonusLevels] : 0;
}
inline unsigned ArtLevel(void* creature, uint16_t art) {
return ArtLevelIn(static_cast<uint8_t*>(creature) + kCreatureArts, art);
}
struct ArtCooldown {
float remaining = 0.0f; // in the game's units
float total = 0.0f;
+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
+2 -7
View File
@@ -149,11 +149,6 @@ unsigned Chains(unsigned level) {
return 1 + level / 10;
}
unsigned ArtLevel(void* creature, uint16_t art) {
uint8_t* entry = game::FindCooldownEntry(static_cast<uint8_t*>(creature) + game::kCreatureArts, art);
return entry ? entry[game::kCooldownLevel] + entry[game::kCooldownBonusLevels] : 0;
}
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
return std::find(ids.begin(), ids.end(), id) != ids.end();
}
@@ -276,7 +271,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
return;
}
Forget();
const unsigned level = ArtLevel(caster, g_heroArt);
const unsigned level = game::ArtLevel(caster, g_heroArt);
const uint32_t hits = Hits(level);
const unsigned chains = Chains(level);
event[kEventHitsLeft] = hits;
@@ -416,7 +411,7 @@ std::wstring Rewrite(const std::wstring& text, unsigned currentLevel) {
}
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
text = Rewrite(text, ArtLevel(creature, art.Id()));
text = Rewrite(text, game::ArtLevel(creature, art.Id()));
}
struct TooltipRegistration {
+1 -2
View File
@@ -33,8 +33,7 @@ using namespace game; // combo and cooldown layout (arts.h)
// The art's level now (cooldown entry level + bonus levels), 0 if unknown.
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
uint8_t* entry = art ? FindCooldownEntry(arts, static_cast<uint16_t>(art)) : nullptr;
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
}
void SyncCombos(uint8_t* hero) {
+209
View File
@@ -0,0 +1,209 @@
// The dwarf's Kanonenschuss (0x43f) and Mörsergranate (0x43e) (request of
// 2026-10-25, docs/dwarf-arts.md).
//
// Kanonenschuss: "remove the knockback, instead it can be just a stun". On a
// successful stun roll the dwarf's combat move code (0x5145d0, case 0x43f)
// plays MOTION_HIT_A on the target (0x5936e0 at 0x517709) and sends it the
// stun order (cEvent_creature 3 from 0x5177aa, +0x14 = 100). The game's stun
// is a push (measured: about 100 units away from the attacker in 0.3 s): the
// order sets creature flag 0x20000, the push left (+0x24d) and the attacker
// (+0x259); while the flag is set, cCreature::Update (branch 0x594c82) skips
// everything else and moves the creature away from the attacker, lowering
// the push left by each step, and clears the flag at 0. The mod skips the
// motion, and for creatures the cannon stunned the push step (0x594deb)
// moves them by 0 and keeps the push left above 0 for kStunMs: they stand
// still, doing nothing, then carry on. The shot's ray (25 steps of 40 = 1000) gets 45 steps (1800),
// for the widened view ("range needs to be increased to accommodate for the
// increased viewport", 2026-10-26).
//
// Mörsergranate: "the radius of the explosion should increase every 5 skill
// levels". The grenade (type 0x3fa) creates an impact object
// (TYPE_FX_GRENADEIMPACT, 0x3fc) where it lands; that object gathers its
// targets with cObjectManager 0x5ff820 (called from 0x5ab400 at 0x5ab63d): a
// creature is hit when its distance is below the impact type's size (150)
// plus the creature type's size (object type table 0x428bb0). The mod passes
// that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the
// level of the owner's Mörsergranate.
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <map>
#include "game/engine/arts.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace mods::dwarfarts {
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
uint32_t only, int32_t radius, const uint8_t* fx);
namespace {
// The stunned target's motion: push 3.0f, push 0.25f... (0x5176f8) up to the
// call of 0x5936e0 (0x517709, 5 bytes): jumped over.
constexpr uint32_t kCannonStunMotion = 0x005176f8;
constexpr uint8_t kStunMotionPush[] = {0x68, 0x00}; // push 0x3e800000 (first two bytes)
constexpr uint8_t kSkipStunMotion[] = {0xeb, 0x14}; // jmp 0x51770e, after the call
// The ray's step count: cmp edi, 19h (0x517389, imm8 at +2); steps of 40.
constexpr uint32_t kCannonRaySteps = 0x00517389;
constexpr uint8_t kRayStepsOriginal = 25;
constexpr uint8_t kRaySteps = 45;
// The cannon's stun order: the return address of its call [eax+18h].
constexpr uint32_t kCannonStunOrderReturn = 0x005177ad;
constexpr uint32_t kOrderStun = 3;
constexpr DWORD kStunMs = 1500;
// The push step in cCreature::Update: lea edx,[ebp+18h]; mov edi,[esp+60h]
// (y offset; x offset in ebx); mov [ebp+24dh],eax (push left).
constexpr uint32_t kPushStep = 0x00594deb;
constexpr uint8_t kPushStepCode[] = {0x8d, 0x55, 0x18, 0x8b, 0x7c, 0x24, 0x60, 0x89, 0x85, 0x4d, 0x02, 0x00, 0x00};
std::map<uint32_t, DWORD> g_stunEnds; // creature id -> GetTickCount at which its stun ends
constexpr uint32_t kGrenadeImpactType = 0x3fc; // TYPE_FX_GRENADEIMPACT
constexpr uint32_t kImpactGatherCall = 0x005ab63d; // in 0x5ab400
constexpr uint32_t kGatherInRange = 0x005ff820; // cObjectManager: objects within type sizes
constexpr size_t kFxOwner = 0x54; // u32: the creature the effect hits for
constexpr unsigned kLevelsPerStep = 5;
constexpr float kRadiusGrowth = 0.1f; // of the impact's own size, per step
// The size of object type `type` (0x428bb0 on the type table *0xaa9564,
// 50 when the table has none).
int32_t TypeSize(uint32_t type) {
using Fn = int32_t(__fastcall*)(void* table, void* edx, uint32_t type);
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, type);
}
// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
// ret 0x14) with the effect object in ebx.
__declspec(naked) void GatherTargetsThunk() {
__asm {
push ebx // the effect object
push dword ptr [esp+18h] // radius (-1: the type's size)
push dword ptr [esp+18h] // only objects of this type (0: any)
push dword ptr [esp+18h] // type
push dword ptr [esp+18h] // position
push dword ptr [esp+18h] // out vector
call GatherTargets
ret 14h
}
}
void OnOrder(void* creature, const game::CreatureOrder& order, void* caller) {
if (order.command == kOrderStun && reinterpret_cast<uint32_t>(caller) == kCannonStunOrderReturn) {
g_stunEnds[*reinterpret_cast<uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId)] =
GetTickCount() + kStunMs;
}
}
// The push step of `creature` with `left` push left after it: the push left
// to store (low dword) and whether to stay in place (high dword).
uint64_t __cdecl PushStep(uint8_t* creature, int32_t left) {
const auto it = g_stunEnds.find(*reinterpret_cast<uint32_t*>(creature + game::kCreatureId));
if (it == g_stunEnds.end()) {
return static_cast<uint32_t>(left);
}
const bool stunned = static_cast<int32_t>(GetTickCount() - it->second) < 0;
if (!stunned) {
g_stunEnds.erase(it);
}
return (uint64_t{1} << 32) | static_cast<uint32_t>(stunned ? 1 : 0);
}
__declspec(naked) void PushStepThunk() {
__asm {
push eax // push left after this step
push ebp // the creature
call PushStep
add esp, 8
test edx, edx
jz move
xor ebx, ebx // x offset
mov dword ptr [esp+64h], 0 // y offset (the caller's [esp+60h])
move:
mov edi, [esp+64h]
mov [ebp+24dh], eax
lea edx, [ebp+18h]
ret
}
}
// The cannon creates its impact effect (TYPE_FX_DWARF_SHOOT_IMPACT 0x47f,
// attached to the target with cEventFX 0x9c) on every target it hits; it stays
// on targets that were not pushed. CreateAt (0x5fbaa0, thiscall (type,
// position, link, 0, 0), ret 0x14) at 0x517855, the target in ebx: only for
// targets in the stun state (the order was not resisted); 0 otherwise, which
// the game skips (GetObject(0) is null).
constexpr uint32_t kImpactCreate = 0x00517855;
constexpr uint32_t kCreateAt = 0x005fbaa0;
constexpr size_t kCreatureFlags = 0x200;
constexpr uint32_t kCreaturePushed = 0x20000; // the stun's push state (cCreature::Update 0x594c82)
uint32_t __fastcall CreateImpact(void* manager, void*, uint32_t type, const void* position, uint32_t link, uint32_t a,
uint32_t b, const uint8_t* target) {
if (!target || !(*reinterpret_cast<const uint32_t*>(target + kCreatureFlags) & kCreaturePushed)) {
return 0;
}
using Fn = uint32_t(__fastcall*)(void*, void*, uint32_t, const void*, uint32_t, uint32_t, uint32_t);
return reinterpret_cast<Fn>(kCreateAt)(manager, nullptr, type, position, link, a, b);
}
__declspec(naked) void CreateImpactThunk() {
__asm {
push ebx // the target
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
push dword ptr [esp+18h]
call CreateImpact
ret 14h
}
}
void Startup() {
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
uint8_t call[sizeof(kPushStepCode)];
std::memset(call, 0x90, sizeof(call));
call[0] = 0xe8;
const int32_t rel = reinterpret_cast<int32_t>(&PushStepThunk) - static_cast<int32_t>(kPushStep + 5);
std::memcpy(call + 1, &rel, sizeof(rel));
const bool hold = re::ReplaceCode(kPushStep, kPushStepCode, call, sizeof(call));
re::Log("dwarfarts: Kanonenschuss stun holds in place%s", hold ? "" : " (patch FAILED)");
const bool stun = re::ReplaceCode(kCannonStunMotion, kStunMotionPush, kSkipStunMotion, sizeof(kSkipStunMotion));
re::Log("dwarfarts: Kanonenschuss without stun motion%s", stun ? "" : " (patch FAILED)");
const bool range = re::PatchInstructionByte(kCannonRaySteps, 2, kRayStepsOriginal, kRaySteps);
re::Log("dwarfarts: Kanonenschuss range %d%s", kRaySteps * 40, range ? "" : " (patch FAILED)");
const bool radius =
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)");
}
RE_STARTUP(DwarfArtsStartup, Startup);
struct Registration {
Registration() { game::OnCreatureOrder(&OnOrder); }
} g_registration;
} // namespace
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
uint32_t only, int32_t radius, const uint8_t* fx) {
if (type == kGrenadeImpactType && radius < 0 && fx) {
void* owner = game::GetCreature(*reinterpret_cast<const uint32_t*>(fx + kFxOwner));
const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0;
const int32_t size = TypeSize(type);
radius = static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
}
using Fn = uint32_t*(__fastcall*)(void*, void*, uint32_t*, const void*, uint32_t, uint32_t, int32_t);
return reinterpret_cast<Fn>(kGatherInRange)(manager, nullptr, out, position, type, only, radius);
}
} // namespace mods::dwarfarts
+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