Compare commits
2
Commits
10c97f75e1
...
7d8089c299
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d8089c299 | ||
|
|
2cab6c216d |
No files matched your search
@@ -60,6 +60,7 @@ add_library(sacredK SHARED
|
|||||||
src/mods/combos.cpp
|
src/mods/combos.cpp
|
||||||
src/mods/aurahits.cpp
|
src/mods/aurahits.cpp
|
||||||
src/mods/chainlightning.cpp
|
src/mods/chainlightning.cpp
|
||||||
|
src/mods/dwarfarts.cpp
|
||||||
src/mods/arttooltip.cpp
|
src/mods/arttooltip.cpp
|
||||||
src/mods/escape.cpp
|
src/mods/escape.cpp
|
||||||
src/mods/recordcache.cpp
|
src/mods/recordcache.cpp
|
||||||
@@ -77,7 +78,9 @@ add_library(sacredK SHARED
|
|||||||
src/mods/mainmenu.cpp
|
src/mods/mainmenu.cpp
|
||||||
src/mods/mapreveal.cpp
|
src/mods/mapreveal.cpp
|
||||||
src/mods/questbook.cpp
|
src/mods/questbook.cpp
|
||||||
|
src/mods/followers.cpp
|
||||||
src/mods/questfail.cpp
|
src/mods/questfail.cpp
|
||||||
|
src/mods/questgiver.cpp
|
||||||
src/mods/questnpc.cpp
|
src/mods/questnpc.cpp
|
||||||
src/mods/anticheat.cpp
|
src/mods/anticheat.cpp
|
||||||
src/mods/loadingscreen.cpp
|
src/mods/loadingscreen.cpp
|
||||||
|
|||||||
@@ -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
@@ -36,11 +36,38 @@
|
|||||||
|
|
||||||
A failed quest is not final: talking to the quest giver again offers it
|
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
|
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
|
script has run) the quest's flags 2/4/8 are cleared and flag 1 is kept
|
||||||
removed, as if never triggered; quests failed before the mod are reset once
|
(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);
|
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.
|
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)
|
## Quest book (measured 2026-10-25)
|
||||||
|
|
||||||
- Book entries (quest manager +0x424, 0x174 bytes): kind +0 (3 main quest,
|
- 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`,
|
Dialogs without a quest (healers, closed shops: `geschlossen101`,
|
||||||
`heilen_global`) stay as the game has them.
|
`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).
|
||||||
@@ -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
|
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).
|
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"
|
## Rename life leech to "omnivamp"
|
||||||
|
|
||||||
Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes
|
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
|
turning and be drawn blue (like other NPCs). Done 2026-10-25
|
||||||
(docs/quests.md, "Quest NPCs after the quest").
|
(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)
|
## Inventory drawn without its dark backgrounds (2026-10-25, intermittent)
|
||||||
|
|
||||||
Reported twice: on opening the inventory the item list had no dark
|
Reported twice: on opening the inventory the item list had no dark
|
||||||
|
|||||||
+13
-3
@@ -86,8 +86,8 @@ constexpr uint16_t kArtHeldenmut = 0x3f7;
|
|||||||
constexpr uint16_t kArtKlingenblick = 0x3f8;
|
constexpr uint16_t kArtKlingenblick = 0x3f8;
|
||||||
constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
||||||
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
||||||
constexpr uint16_t kArtMeteor = 0x43e;
|
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
|
||||||
constexpr uint16_t kArtKanone = 0x43f;
|
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
|
||||||
|
|
||||||
// cCreatureArts fields.
|
// cCreatureArts fields.
|
||||||
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
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
|
// Arts regenerating in the magic group whatever their entry says
|
||||||
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
|
// (cCreatureArts::AddGroupRegen; the countdown only knows Energieblitze).
|
||||||
inline bool InMagicRegenGroup(uint32_t group, uint16_t art) {
|
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;
|
art == kArtKanone;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +160,16 @@ inline bool EntryAtMagicRate(const uint8_t* entry) {
|
|||||||
*reinterpret_cast<const uint16_t*>(entry + kCooldownArt) == kArtEnergieblitze;
|
*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 {
|
struct ArtCooldown {
|
||||||
float remaining = 0.0f; // in the game's units
|
float remaining = 0.0f; // in the game's units
|
||||||
float total = 0.0f;
|
float total = 0.0f;
|
||||||
|
|||||||
@@ -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.
|
// The object `id` as a cCreature, or null.
|
||||||
inline void* GetCreature(uint32_t id) {
|
inline void* GetCreature(uint32_t id) {
|
||||||
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
|
return DynamicCast(GetObjectById(id), kTypeObject, kTypeCreature);
|
||||||
|
|||||||
@@ -68,6 +68,47 @@ constexpr size_t kCreatureId = 0xc;
|
|||||||
constexpr size_t kCreatureX = 0x1c;
|
constexpr size_t kCreatureX = 0x1c;
|
||||||
constexpr size_t kCreatureY = 0x20;
|
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 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
|
// 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.
|
// step's sub-unit remainder (x, y, 1/1024 units) at +0x37d / +0x381.
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ constexpr uintptr_t kScriptLabelsEnd = 0x00aa968c;
|
|||||||
constexpr size_t kScriptLabelSize = 0x54;
|
constexpr size_t kScriptLabelSize = 0x54;
|
||||||
constexpr size_t kScriptLabelNameSize = 0x48;
|
constexpr size_t kScriptLabelNameSize = 0x48;
|
||||||
constexpr size_t kScriptLabelQuest = 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* ScriptLabelsBegin() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsBegin); }
|
||||||
inline const uint8_t* ScriptLabelsEnd() { return *reinterpret_cast<const uint8_t* const*>(kScriptLabelsEnd); }
|
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) {
|
inline int32_t ScriptLabelQuest(const uint8_t* label) {
|
||||||
return *reinterpret_cast<const int32_t*>(label + kScriptLabelQuest);
|
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
|
// The quest manager, a static object (script interpreter; ecx of 0x475970 and
|
||||||
// of Quest_End 0x46ced0). Its quest book is a vector of plain 0x174-byte entries
|
// of Quest_End 0x46ced0). Its quest book is a vector of plain 0x174-byte entries
|
||||||
|
|||||||
@@ -149,11 +149,6 @@ unsigned Chains(unsigned level) {
|
|||||||
return 1 + level / 10;
|
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) {
|
bool Contains(const std::vector<uint32_t>& ids, uint32_t id) {
|
||||||
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
return std::find(ids.begin(), ids.end(), id) != ids.end();
|
||||||
}
|
}
|
||||||
@@ -276,7 +271,7 @@ void BeforeFxEvent(uint8_t* fx, uint32_t* event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Forget();
|
Forget();
|
||||||
const unsigned level = ArtLevel(caster, g_heroArt);
|
const unsigned level = game::ArtLevel(caster, g_heroArt);
|
||||||
const uint32_t hits = Hits(level);
|
const uint32_t hits = Hits(level);
|
||||||
const unsigned chains = Chains(level);
|
const unsigned chains = Chains(level);
|
||||||
event[kEventHitsLeft] = hits;
|
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) {
|
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 {
|
struct TooltipRegistration {
|
||||||
|
|||||||
+1
-2
@@ -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.
|
// The art's level now (cooldown entry level + bonus levels), 0 if unknown.
|
||||||
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
|
uint16_t CurrentLevel(uint8_t* arts, uint32_t art) {
|
||||||
uint8_t* entry = art ? FindCooldownEntry(arts, static_cast<uint16_t>(art)) : nullptr;
|
return art ? static_cast<uint16_t>(ArtLevelIn(arts, static_cast<uint16_t>(art))) : 0;
|
||||||
return entry ? static_cast<uint16_t>(entry[kCooldownLevel] + entry[kCooldownBonusLevels]) : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SyncCombos(uint8_t* hero) {
|
void SyncCombos(uint8_t* hero) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,43 +1,126 @@
|
|||||||
// Failed quests can be restarted (request of 2026-10-17): a failed quest is not
|
// 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
|
// 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
|
// 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
|
// (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,
|
// 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
|
// The mod: when a quest is accepted in a dialog, the nearest NPC's map cell is
|
||||||
// quest's flags are cleared and its book entries removed, as if it had never
|
// kept as the quest's giver (saved with the hero data, "questGivers"; quest
|
||||||
// been triggered. Quests that failed before the mod are reset the same way once
|
// ids are the same in every world, and so are the givers). After Quest_End
|
||||||
// after loading.
|
// 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 <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include "game/engine/events.h"
|
#include "game/engine/events.h"
|
||||||
|
#include "game/engine/objects.h"
|
||||||
|
#include "game/engine/orders.h"
|
||||||
#include "game/engine/quests.h"
|
#include "game/engine/quests.h"
|
||||||
|
#include "mods/inventory_save.h"
|
||||||
|
#include "mods/questfail.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
namespace mods::questfail {
|
namespace mods::questfail {
|
||||||
namespace {
|
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_resetAfterLoad = false;
|
||||||
|
bool g_inDialogAnswer = false;
|
||||||
|
|
||||||
// The quest at table `index` as if it had never been triggered.
|
// Quest id -> the giver's map cell.
|
||||||
void Restart(int index) {
|
std::map<int32_t, std::pair<int32_t, int32_t>> g_givers;
|
||||||
uint8_t* quest = game::Quest(index);
|
|
||||||
const int32_t id = game::QuestId(quest);
|
// The nearest living, not hostile, non-hero creature near the hero, or 0. All
|
||||||
re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest));
|
// objects are searched (the nearby-creature list 0xad1c60 did not hold the
|
||||||
game::QuestFlags(quest) &= ~kResetFlags;
|
// givers).
|
||||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
uint32_t NearestNpc(void* hero) {
|
||||||
if (game::BookQuestId(game::BookEntry(i)) == id) {
|
const int32_t heroX = game::CreatureField(hero, game::kCreatureX);
|
||||||
game::EraseBookEntry(i);
|
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() {
|
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*) {
|
void OnTick(uint8_t*) {
|
||||||
if (g_resetAfterLoad) {
|
if (g_resetAfterLoad) {
|
||||||
g_resetAfterLoad = false;
|
g_resetAfterLoad = false;
|
||||||
@@ -65,8 +157,48 @@ void OnLoaded() {
|
|||||||
g_resetAfterLoad = true;
|
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
|
} // 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):
|
// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc):
|
||||||
// LoseQuest passes success 0, ExitQuest 1.
|
// LoseQuest passes success 0, ExitQuest 1.
|
||||||
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
|
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 {
|
namespace {
|
||||||
struct Register {
|
struct Register {
|
||||||
Register() {
|
Register() {
|
||||||
game::OnEngineTick(&OnTick);
|
game::OnEngineTick(&OnTick);
|
||||||
game::OnSaveGameLoaded(&OnLoaded);
|
game::OnSaveGameLoaded(&OnLoaded);
|
||||||
|
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
|
||||||
}
|
}
|
||||||
} g_register;
|
} g_register;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -413,3 +413,21 @@
|
|||||||
0x0044ddc0 cCreature::SetDirectionVector # thiscall (vec3*, ?), ret 8: writes +0x70..+0x78
|
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
|
0x004265c0 TypeTable_IsKind4 # thiscall (type table *0xaa9564, type), ret 4: the type's kind byte is 4
|
||||||
0x0049a180 cDialogManager::DialogType # thiscall (dialog, creature), ret 8: record +0x48 of 0x50-byte records at +0x755c; creature flags 0x1000.. -> 0x20..0x23
|
0x0049a180 cDialogManager::DialogType # thiscall (dialog, creature), ret 8: record +0x48 of 0x50-byte records at +0x755c; creature flags 0x1000.. -> 0x20..0x23
|
||||||
|
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
|
||||||
Reference in new issue
Block a user