Compare commits

..
12 Commits
Author SHA1 Message Date
khallaandClaude Opus 5.5 e13b82f040 Quests: restart resets variables, respawns giver and NPCs; final failures
Quest audit (docs/quest-audit.md, tools/questscript.mjs), fixes 2-8.
New mods/questscript.cpp scans the script code once (giver, own variables,
needed NPCs, QIS_OnEnter creations, story-ending failure labels).
questfail: generic, giverless, story-ending and NPC-gone failures stay
final; a restart restores the variables snapshot taken at Quest_Trigger,
deletes OnEnter leftovers, respawns the giver and needed NPCs and gives the
accept dialog back. questgiver slimmed on top of questscript.

Not yet tested in game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:03:20 +01:00
khallaandClaude Opus 5.5 7ec0fa2b8e Quest givers: givers killed as enemies keep their death script
"Familienbande" (702) turns its giver hostile and wants him killed; the
giver death rule restarted it instead. A giver hostile to the hero at death
now runs the game's label, and the load repair skips quests whose script
changes their giver's creature type (opcode 0x4f). Deferred notes: frame
drops while walking, Eingraben clearing slows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:57:43 +01:00
khallaandClaude Opus 5.5 471b8b13d5 Eingraben: 2 s move inside a barrier turning solid, 4 s duration
The barrier's model alpha goes to opaque over 1 s, the dwarf is moved,
and it clears over 1 s; orders to him are refused meanwhile (new
game::FilterCreatureOrders). The art's duration in its definition is a
fixed 4 s (was 7 + level), which the barrier, its bonus and the tooltip use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:28:07 +01:00
khallaandClaude Opus 5.5 198bbab6f9 Eingraben: aimed escape to a point in sight, 3 s barrier, no lock
Eingraben becomes ground-targeted; after its own case the dwarf moves to
the end of Phasenverschiebung's walk towards the point (stops before walls
and cliffs). The barrier's bonus lasts 3 s and its lock in place
(effect kind 0x3a) is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:01:43 +01:00
khallaandClaude Opus 5.5 55e48dd302 Quests: main quest items no longer refused, restart resets bit-field variables
AddMainQuestItem refused every new main quest item once the list held an
entry with script id 0 (scripts give such items); the Goldbeeren of quest
402 were picked up and lost. The script id test is patched out.

Quest variables are bit fields (op 0x44 sets a bit): the quest giver
restart now writes the values directly, 0 and 1 (available) for the
variable named after the quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:07:06 +01:00
khallaandClaude Opus 5.5 e539cab604 Followers: escorts left behind are teleported to the hero
A follower that fell more than 40 cells behind was removed when its sector
was switched off, and the game ran its death label (the husband of "Die
Sklaven der Orks"). Every 500 ms followers of the local hero that far away
are teleported to him with cCreature::Teleport.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:39:21 +01:00
khallaandClaude Opus 5.5 42b99526a4 Guard: grenade impact particles out of the colour table
Mörsergranate's explosion crashed twice reading the colour of a particle
with a huge life (sacred.exe+0x361b54). Particles with life >= 256 or the
unset fourth row are switched off before drawing; logged cases were all
row 3 with garbage sizes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 23:39:20 +01:00
khallaandClaude Opus 5.5 91e7f844cc Mörsergranate: explosion radius in the tooltip
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 22:21:50 +01:00
khallaandClaude Opus 5.5 a4d5683403 Mörsergranate: impact marker on the ground until the explosion
The landing point is noted from the grenade's cEventFX 0x96 at launch; a
translucent red circle with the explosion radius (shared ImpactRadius) is
drawn after the world's ground, under the objects, and ends when that
grenade's impact gathers its targets. Ground -> screen projection in
game/engine/worldview.h.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 22:08:08 +01:00
khallaandClaude Opus 5.5 32f953fc01 Large map: explored border as a continuous line along the fog edge
The game drew 2x2 px red dots per visited-sector neighbour (0x6c8580), in
64-tile steps and unrelated to the explored mask. Replaced by lines traced
from the explored cells (2x2 tiles), simplified to 0.75 px, rebuilt while
the map is open when the masks change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 21:57:30 +01:00
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
33 changed files with 3880 additions and 52 deletions

No files matched your search

+6
View File
@@ -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
@@ -75,10 +76,15 @@ add_library(sacredK SHARED
src/mods/screen.cpp src/mods/screen.cpp
src/mods/characters.cpp src/mods/characters.cpp
src/mods/mainmenu.cpp src/mods/mainmenu.cpp
src/mods/mapborder.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/questitems.cpp
src/mods/questnpc.cpp src/mods/questnpc.cpp
src/mods/questscript.cpp
src/mods/anticheat.cpp src/mods/anticheat.cpp
src/mods/loadingscreen.cpp src/mods/loadingscreen.cpp
) )
+143
View File
@@ -0,0 +1,143 @@
# Dwarf arts: Kanonenschuss, Mörsergranate, Eingraben
## 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).
## Mörsergranate impact marker (asked 2026-10-25, done 2026-10-26)
"Show exactly where the grenade will land and its explosion radius, a red
circle with low alpha on the ground."
Measured:
- The landing point is decided at launch (`0x5145d0` case 0x43e): the
target's position, or, closer than 200, a point 200 from the dwarf with a
+-15 jitter. It goes to the new grenade (type 0x3fa) in cEventFX 0x96,
constructed at `0x5169fa` (`cEventFX::Construct` 0x41e0a0, thiscall, ret
0x44: code, owner id, target id, position area / x / y / level, ...).
- The impact gathers its targets at exactly that point, on several frames.
The grenade object stays about 30 s after the explosion.
- Ground -> screen: `0x622700` (world units -> world pixels, the isometric
plane), then (pixel - camera +0x14/+0x18) / zoom +0xb60 + screen centre
(floating texts 0x41e546). Checked with the hero: the screen centre.
- The world view draws the ground (`0x62d530`, ground quad list), then the
objects.
Mod: the construct call notes grenade, owner, landing point and radius
(`ImpactRadius`, shared with the hit check); after `0x62d530` a disc (alpha
0x40) with a 2 px rim (alpha 0xa0) is drawn there, without depth test, until
the impact of that owner's grenade at that point gathers its targets.
Tooltip (2026-10-26, "the skills tooltip does not show the current radius"):
a game::Art tooltip filter adds "Explosionsradius: N" after the last value
line of each level section (current level, and the next level's in the skill
book), in that line's colour, from the same `ImpactRadiusAt(level)` as the
hit check and the marker.
## Grenade impact crash (2026-10-26)
Crashes at sacred.exe+0x361b54 (2026-10-26 00:12 and 22:39) in the particle
drawer `0x7613e0`, called by `cParticleSystem_grenadeimpact::Render` (vf04
0x7b66b0): colour = table +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and a
particle had a life of over 500000. Guard (`mods/guards.cpp`): live particles
with life >= 256 or row >= 3 are switched off before drawing. Logged in the
next session: 5 particles, all row 3, life 255, garbage sizes (e.g. 190555,
4.6e-40): the explosion emits into a fourth row whose emitter parameters it
never sets (vf10 0x7b6980 fills three rows at +0x30d0). No crash since.
## Eingraben: the dwarf's escape (request 2026-10-28)
"I want it to be the Dwarfs option to Teleport/Escape. The Skill will cast a
protective barrier around the Dwarf (current visual effect), clear him of all
movement impending effects and then teleport to the position marked by the
cursor (if possible). The Teleport is visually shown by the dwarf going
underground and coming out again at target position." Range: the visible
area; the barrier is the skill's current defensive bonus for 3 s; digging in
and out 1 s each; "he should be able to see the target location", compared
to the battlemage's Phasenverschiebung; the original lock in place goes.
Found in the code:
- Spells run in `cCreature::CastSpell` (0x5222c0, thiscall (cast), ret 4;
cast +0x08 / +0x0c the ground point, +0x20 the spell id). Eingraben is
spell 0x39 (`gui_moves_eingraben`, launch type 5 around the caster).
- Case 0x39 creates object type 0x480 at the caster with cEventFX 0x9d (owner
and target the caster) and plays sound 0x840: the barrier, which follows
the dwarf.
- Case 1 (Phasenverschiebung, launch type 3) walks from the caster towards the
target in steps of 10 while the cell is passable (`cWorld::IsPassable`
0x636b60) and keeps the last point a creature can stand on
(`cWorld::CanStand` 0x636eb0); a wall or cliff in the way ends the walk.
Logged (2026-10-28): the barrier adds, all with art 0x39, kind 0x3a value 0
from its particles (0x7b9602), the lock in place (the dwarf was stuck 2-3 s),
and kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
the defensive bonus (resistances up while it lasts).
Mod (`mods/dwarfarts.cpp`): Eingraben's definition +0x3c becomes 3 (a point);
after the original case the dwarf is teleported to the end of
Phasenverschiebung's walk towards the point (the cursor keeps it within the
visible area). The effect hook (`aurarecast.cpp`, `KeepEffect`) drops kind
0x3a for art 0x39. Confirmed 2026-10-28: aimed, stops before walls and
cliffs, casts at once at the screen edge, the barrier moves with him, no lock.
Duration ("more like 8 seconds as described in the tooltip, i want that to go
down to 4 seconds"): an art's duration is 0x4d24f0 on its definition,
+0x76 + level x +0x78, at most +0x82 when set (Eingraben: 7 + level, at most
15). The barrier's lifetime (shield +0x20a8), its bonus (the effect applier,
0x5b56ab) and the tooltip all use it; the mod sets 4 / 0 / 0.
Digging in and out: lowering the dwarf's height (creature +0xf0) moved only
his marker; lowering the terrain height the game asks for him (0x449b20)
moved the camera with him, so he never looked sunk. Instead ("change the
barrier animation to turn from transparent to solid and back"): the barrier
is `cParticleSystem_dwarf_shield` (constructor 0x7b8fc0, update vf08
0x7b91a0, draw vf04 0x7b92b0; models fx_shield_phase1..4.grn). Its draw
passes the model with alpha pulse x 128 (pulse 0.4..1 at +0x20b8; __ftol at
0x7b9483), then again additively. The mod redirects that __ftol: during a dig
the alpha goes to 255 over 1 s, the dwarf is moved (engine tick), and it goes
back over 1 s. Orders to the dwarf are refused meanwhile
(`game::FilterCreatureOrders`). Confirmed 2026-10-28, barrier and bonus 4 s.
Open: clearing slows, roots and stuns.
+21
View File
@@ -42,3 +42,24 @@ default (0x6ce677), 300 where 0x63d280 holds for the hero's area (0x6ce6b5),
0x182cdb8 & 2 shows them. The mod scales the three base values by 10 as well 0x182cdb8 & 2 shows them. The mod scales the three base values by 10 as well
(about 120 tiles by day). The view only lists creatures of the sectors around (about 120 tiles by day). The view only lists creatures of the sectors around
the hero (25 entries at [esp+1ech]). the hero (25 entries at [esp+1ech]).
## Border line on the large map (2026-10-26)
"The red outline on the mega map is choppy and feels weird."
Measured (disassembly): `cUI_Megamap::DrawVisitedBorder` 0x6c8580 drew a
2x2 px red dot (0xffff0000) per bit of a 100x100 byte grid (world +0xb73c):
unvisited sectors next to visited ones (sector flag 0x20000, set when the
hero's sector becomes the centre of the active 3x3, 0x626603), one bit per
visited neighbour, the dot 10 / 20 px from the sector origin towards it.
Built on load by 0x633230 and per sector change by 0x6332d0 / 0x6334f0. So:
dots in 64-tile steps, following the sectors stood in rather than the
explored mask the fog shows.
Mod (`mods/mapborder.cpp`): the draw is replaced. The sides between explored
and unexplored cells (2x2 tiles) of the 100x100 map sectors are chained into
polylines (clockwise around explored cells; at a corner touch the chain turns
right), projected with `WorldToMapPixel` (linear), simplified to 0.75 px
(Douglas-Peucker) and drawn as 2 px red lines. Rebuilt while the map is open
when the masks change (checked every 250 ms). Cells of missing sectors count
as neither explored nor unexplored, so no line runs along the world's edge.
+278
View File
@@ -0,0 +1,278 @@
# Quest audit: how quests fail or get stuck (2026-10-30)
Request (2026-10-26, started 2026-10-30): "iterate through all quests in the
game, and check their conditions for potential failing or stuck up points
given the current implementation and our attempted reset". Done one quest at
a time from the compiled scripts with `tools/questscript.mjs` (full dump:
`<dir> <id>`; compact: `<dir> --facts <id>`).
Scripts: Ancaria per hero class `bin\TYPE_NPC_<class>\` (the game writes the
one in use to `bin\sgf.bin`; this audit reads `TYPE_NPC_GLADIATOR`, the class
variants differ in a few class quests), Underworld `bin\Addon\NetScriptCamp\`.
Ancaria: 603 quests in the table (87 + 13 side quests, ~37 main quests, 352
generic `DQ_` quests from 16 templates, multiplayer / unnamed sets);
Underworld: 190.
## What our mods do (the "reset")
- **M1 failure restart** (`questfail.cpp`, `Quest_End` hook): after
`LoseQuest` ran the quest's failure script (`QIS_OnLose<id>`), the flags go
back to available (1), the book entry stays failed with its compass on the
giver. Quest variables and NPC states are left as they are.
- **M2 giver death** (`questgiver.cpp`): a label of a running quest run for
its dead giver (the giver: the NPC spawned with a dialog that has the accept
button) is skipped, unless the giver is hostile to the hero (2026-10-30);
the quest restarts (M1 plus the variables only this quest writes: 0, the
one named after the quest: 1) and the giver's spawn instruction runs again
once the body is gone.
- **M3 load repair** (`questgiver.cpp`): a running quest whose giver is not
alive after loading gets M2, unless the quest morphs its giver.
- **M4 followers** (`followers.cpp`): escorts back on the hero's follower
list after loading; followers more than 40 cells away are teleported to the
hero.
## General findings
- **G1 M1 keeps the quest variables.** Givers' dialogs choose their branch by
bits of the quest variable (`IF 49(bit, "<id>")`); bits set while the quest
ran stay set after a failure, so the giver shows a "still running" branch
without the accept button and the quest can never be taken again. Example
702: the girl's death runs `lose702`; variable 702 keeps bit 1, the giver
answers "Qoffen01" with `ok702`. Fix: M1 resets the quest's own variables
like M2.
- **G2 generic quests (2600..3599)** are the game's random quest pool;
`Quest_End` already copies them under a new id. M1 should leave them (and
their runtime copies) to the game.
- **G3 one-time world progress.** Many targets are spawned once by the start
script or `Region<N>Init`, not by the quest. When M2/M3 reset the quest's
variables after such a target died, the progress is lost and the target
never comes back: stuck. Either keep the variables on M2/M3 or respawn the
quest's one-time NPCs (see G6).
- **G4 duplicates on re-accept.** `QIS_OnEnter` creates the quest's NPCs and
objects again; after a failure the old ones may still be alive.
- **G5 givers deleted by the script** (`DelNPC` on the giver): M3 restarts a
quest whose giver is gone on purpose.
- **G6 restart without the NPCs.** The restart is pointless when the NPCs the
quest needs are one-time spawns and dead or deleted. Idea: respawn them from
their spawn instructions like the giver.
- **G7 quests without a giver** (trigger areas): M1 "restarts" them and erases
the failed book entry.
- **G8 givers turned hostile by `SetNPCState ... 08 12`** instead of `Morph`
are not in `g_giverTurns`; M3 restarts those quests wrongly, M1 points the
book at an enemy.
- **G9 shared variables.** M2 doesn't reset quest variables another quest's
label also writes, so the respawned giver shows a running branch.
- **G10 giver killed after the quest ended** (by its own script): M2 doesn't
act, the giver stays dead.
- **G11 failure continues a chain.** Some failures are part of a quest chain
(`LoseQuest` together with `SetUpQuest` of the next quest, giver's dialog
moved on); M1 must not restart those.
- **G12 state in dialogs (Underworld).** Underworld quests keep their state
in the NPCs' dialogs; a failure sets a "done/lost" dialog (or none), and M1
doesn't put the accept dialog back (7614, 10260).
- **G13 named ids.** Spawns with named ids (`res:AUFTRAGGEBER_10254`,
`res:Sam1_Knappe`) or the id after the type argument aren't read by
`InstructionScriptId`; those givers aren't in `g_givers`.
- **V1/V2 vanilla bugs:** 1101's time-out never ends the quest; Res:17163 is
used by both 602 and 1202, and 602 deletes 1202's patient.
## Per quest
Columns: how it can fail (vanilla), what happens with our mods, verdict
(ok / G<n> / finding).
| Quest | Fails by | With our mods | Verdict |
|---|---|---|---|
| 151 Tierischer Wilddieb | never (no LoseQuest); wolf Res:17081 (start script, death `stirb151`: bit 2, triggers the quest) | M2/M3 on giver death: variable 151 reset to 1; if the wolf is already dead, bit 2 is lost and the wolf never respawns: stuck | G3 |
| 152 Botendienst | time limit (`SetTimer` 4320 -> `zeit152`: bit 4, `LoseQuest`) | M1: bits 1 + 4 stay, the giver answers "Qfail" / "Qpostfail" without the accept button: stuck. With G1 reset: offered again; re-accept spawns the parcel (Res:6027) and later the recipient Res:17077 again (the old recipient stays, dialog off) | G1, G4 |
| 154 verschwundener Schmied | never | the script deletes the giver Res:17626 (`talkedbride154`, `take154`): after the next load M3 restarts the running quest and spawns the giver again | G5 |
| 201 Vermisste Tochter nach Ausritt | daughter Res:18006 dies (`Tochtertot201`: bit 8, `LoseQuest`); she is spawned once (`Region2Init`) | M1: bit 8 stays, giver answers `okt201` then `ok201`: stuck. With G1: offered again, but the daughter stays dead: cannot be finished | G1, G6 |
| 202 Eskorte für Händler | merchant Res:18012 (the giver) and bodyguard Res:18013 both dead, or the bodyguard alone at the bridge | the merchant's death is M2 (restart, respawn; his dialog has no bit tests); the bodyguard is not needed | ok |
| 203 Schatzkarte | never | giver death -> M2; re-accept creates map + chest again (old chest stays) | ok (G4) |
| 204 Die Bücher der Gelehrigkeit | never | giver death -> M2; the boss and the book come from `QIS_OnEnter204`, so a restart can be redone | ok |
| 301 Orks auf Bauernhof | time limit (`SetTimer` 1440 -> `lose301`); farmer Res:17033 and daughter Res:17032 both dead | no giver (trigger area, deleted on start); `QIS_OnLose301` deletes both NPCs. M1 makes it available but nothing can start it again, and with no known giver its failed book entry is erased | G7 |
| 302 Von Räubern und Rindern | six cattle dead (`302a` = 6, `LoseQuest`); the giver Res:17029 (escorted) dies (`tot1_302`) | giver death -> M2 (but `302a` is also set by `startcode`, so it is not "only this quest's" and keeps its count). Cattle failure -> M1: the dialog's `48(6,"302a")` branch sets bit 3 -> "ok" forever: stuck, also with G1 unless G1 counts start-script initialisation as the quest's own. Re-accept spawns the six cattle again | G1 (start-script variables), G4 |
| 303 Bodycount Orks | never; ogre Res:17031 is a one-time spawn (`Region3Init`, death `tot303`: bit 2) | as 151: giver death resets bit 2, a dead ogre never returns: stuck | G3 |
| 401 Jagd auf wildernde Wölfe | never; wolves one-time (`Region4Init`, death `stirb401`: bit 2) | as 151 | G3 |
| 402 Kräuter für Kräuterhexe | never | herbs from a permanent `MouseEvent` (`Region4Init`), can be picked again after M2 | ok |
| 403 Kühe vor Wölfen retten | all cows dead (bit 4), then the giver's answer runs `lose403` | M1: bits 4 + 5 stay, giver "ok403" forever: stuck. With G1: offered again, but the cows (`Region4Init`) are gone | G1, G6 |
| 501 Geister auf Landsitz | never by `LoseQuest`; choice `change501` / `nochange501` | `postchange501` turns giver Res:17071 and Res:17070 hostile with `SetNPCState ... 08 12` (not Morph): M2 sees the hostility at death, but M3 after a load would restart the quest (only Morph is known as "turns") | G8 |
| 502 Verstecktes Waffenlager | never | enemies and boss from `QIS_OnEnter502` (variant by `Globalchange1`): redoable after M2 | ok |
| 503 Botschaft an Geliebte | never | giver Res:17105's death label `weg503` belongs to no quest (M2 does not react); not needed: the quest ends at the lover Res:17104 | ok |
| 601 Kunstraub von Händler | never | thief Res:17135 gives the item by dialog; redoable after M2 | ok |
| 602 Wegelagerer | never | the knight Res:17136 is beaten, not killed (`ritter602` sets his HP back); after M2 his dialog offers the fight again | ok |
| 603 Entlaufener Bräutigam | never | the groom Res:17140 is beaten (`braut603` heals him), the fight can be repeated after M2 | ok |
| 701 Vergifteter Brunnen | never (`lose701` is not called) | boss Res:17063 one-time (`Region7Init`, drops the items); M2 resets bit 20 (item taken): stuck if the boss is dead | G3 |
| 702 Mädchen bei Ausritt verschwunden ("Familienbande") | the girl Res:17060 dies (`lose702`) | giver turned hostile and killed: handled (7ec0fa2). Girl's death -> M1: bit 1 stays, giver "Qoffen01": stuck; with G1 redoable (the girl comes from `QIS_OnEnter702`) | G1 |
| 703 Objektschutz Wegbrücke | leaving the bridge area / region before the bandits are dead (`zeit703`: bit 4), then the giver's answer runs `lose703` | M1: bits 4 + 5 stay, giver "ok703": stuck. With G1: redoable (bandits not all dead, else the quest would have been won) | G1 |
| 801 Holzfällerstreit mit den Elfen | the giver Res:17144 dies (`tod801` -> `lose801`) | giver death -> M2 (restart, respawn) instead of the failure | ok |
| 802 Königliche Jäger Verschollen | never | hunters, herbs and amulet are one-time (`Region8Init`); M2 resets 802 (progress bits): stuck when they are used up | G3 |
| 803 Kopfgeldjagd | never | targets one-time (`Region8Init`). On giver death M2 does not reset `803` (quest 801's elf dialog also sets a bit in it), so the respawned giver shows the running branch without accept and the flags say "available": stuck | G3, G9 |
| 901 Soldaten verschleppen Frauen | time limit (`zeit901`, also kills quest 902's women); the women Res:17051..17054 die (`lose901`: bit 5), then the giver's answer runs `lose901` | M1: bit 6 stays, giver "ok901": stuck. With G1: redoable after the time limit; after the women died they are gone (`Region9Init`) | G1, G6 |
| 902 Rache an Reichem Händler | never | boss Res:17049 one-time (`Region9Init`, death `tot902`: bit 2) | G3 |
| 903 Robin Hood | never | Res:17045 is beaten and healed (`re903`), the choice can be made again after M2 | ok |
| 1001 Übergriffe auf die Bevölkerung | never | boss Res:17041 one-time (`Region10Init`, drops the proof) | G3 |
| 1002 Spionage | seen by the spy's group (tag 0x60 `lose1002` on the NPCs from `QIS_OnEnter1002`: bit 4, `LoseQuest`) | M1: giver sets bit 5, "ok" forever: stuck. With G1 redoable; the old group (now hostile) stays next to the new one | G1, G4 |
| 1003 Schlägertypen | the giver Res:17034 dies (`verreckt1003`); seven villagers die (bit 4), then the giver's answer runs `lose1003` | giver death -> M2. Villagers -> M1: bit 5, "ok" forever: stuck. With G1 redoable (the attackers come from the accept trigger again) | G1 |
| 1101 Sektenpriester opfern Jungfrauen | the virgin Res:17112 dies (`Verloren1101` -> `lose11a1`); time limit (`Zeitum1101`: demon instead of the virgin, bit 3) | death -> M1: giver sets bit 5, "ok": stuck; with G1 redoable (all from `QIS_OnEnter1101`, old priests stay). Time limit: vanilla never ends the quest (the giver's bit 3 branch has no `LoseQuest`): running forever | G1, V1 |
| 1102 Kompromittierende Situatuion | time limit (`Zeitum1102`: bit 3, `LoseQuest`) | M1: bit 1 stays, giver "Ok1102": stuck; with G1 redoable (the lady and the letter come from `QIS_OnEnter1102`). After the time limit the lady's bit 3 answer kills the giver (`SetHP 0`); the quest is no longer running then, so M2 does not respawn him | G1, G10 |
| 1103 Verhandelnder Räuber | never | the robber Res:17118 is beaten and healed (`tot1103`) | ok |
| 1111 DeModreys Steuerpresser | the escorted Res:17121 dies (`lose1111` -> `geld1111`: bit 5, `LoseQuest`) | M1: giver sets bit 6, "ok": stuck. With G1: the boss Res:17123 is one-time (`Region111Init`) and `geld1111` deleted his proof Res:6051: cannot be finished | G1, G6 |
| 1112 Animagus terrorisiert Bauernhof | the spared Animagus Res:17127 dies (`ex1112` with bit 4: `LoseQuest`) | M1: bit 4 stays, the giver's answer then runs `postchange1112` (turns the giver hostile) instead of an offer. With G1: the Animagus is one-time (`Region111Init`). `postchange1112` turns the giver hostile by `SetNPCState` (M3 after a load: G8) | G1, G6, G8 |
| 1113 Elfischer Kunstraub | never | thief Res:17129 and the goal object Res:6052 one-time (`Region111Init`) | G3 |
| 1201 Sekte beschwört kleine Dämonen | never | cult one-time (`Region13Init`, group dead: bit 2) | G3 |
| 1202 Heilmittel für Händler | time limit (`zeit1202` kills the patient Res:17163, `LoseQuest`) | M1: giver sets bit 5, "ok": stuck. With G1 the patient (`Region13Init`) is dead: cannot be finished. Vanilla: quest 602's `ok1_602` / `ritterweg_602` walk away and delete `res:17163` too (the same id): finishing 602 removes 1202's patient | G1, G6, V2 |
| 1203 Der Richter und sein Henker | never | judge Res:17166 and executioner Res:17167 one-time (`Region13Init`) | G3 |
| 1301 Entlaufener Gladiatoren-Minotaurus | never | the giver's death label is the quest's end (`exit1301`): `attack1301` turns him hostile (M2 lets it run since 7ec0fa2), the quest ends at once (no M3). Minotaur from `QIS_OnEnter1301` | ok |
| 1302 Das Soldaten-Tunier | timer → `lose1302` | after M1 bits 4/5 and `1302a` stay (G1); opponent Res:17173 is deleted by `attacke1302_1` and created only by region init (G6). Vanilla typos: `DelNPC res17173`/`res17174` (no colon), `attacke1302_2` attacks 17174 | G1 + G6 |
| 1303 Händler - legaler Warentransport | timer → `lose1303` | after M1 bits 3/4 stay (G1); re-accept creates recipient Res:17177 again (G4) | G1 + G4 |
| 1401 Händler - Warenschmuggel | the side chosen dies (`lose1401`) | after M1 bits 1-6 and `1401a`/`1401b` stay (G1); re-accept creates Res:17180/17181 again (G4). Vanilla: soldier's death label `attack1401` doesn't exist (`attacke1401`) | G1 + G4 |
| 1402 Junge Dame vor Übergriffen eines Offiziers schützen | never | officer and soldiers from `QIS_OnEnter1402` | ok |
| 1403 Eskortiere Händler zu geheimen Treffen | recipient after the escorted giver died (`losequest1403`) | giver death is caught by M2 first (restart, giver back); re-accept creates recipient Res:17187 again (G4). Vanilla typo `ok1024` | G4 |
| 1404 Eskortiere Dame zu Treffen mit Geliebten | the lady dies (`lose1404`) | after M1 bits 1/4 stay (G1); lady Res:17189 and boss Res:17190 are one-time (region init), dead lady makes a restart pointless (G6) | G1 + G6 |
| 1405 Sekte von Dämonenanbetern | never | sect leader Res:17195, cultists and the three captives are one-time (region init); M2 reset after the leader died loses the progress | G3 |
| 1501 Menschlicher Wilderer | never | the giver follows; vanilla his death (`ex1501`) lets the quest go on, M2 restarts it and brings him back instead. Re-accept creates the poachers again (G4) | M2 changes vanilla + G4 |
| 1502 Hetzjagd | a label deletes the giver Res:17015, then `losequest1502` | the giver is spawned once by trigger area `hetz1_1502` (deleted after use); after M1 the book points at a deleted giver and nothing spawns him again (G6). Vanilla: his death label `lose1502` doesn't exist | G6 |
| 1503 Arak-Attack | never | spiders and the target from `QIS_OnEnter1503` | ok |
| 1601 Kunstraub | never | thief from `QIS_OnEnter1601` | ok |
| 1602 Attentäter abfangen | the target dies (`target1602`) | after M1 bit 4 stays (G1); target Res:17205 and assassin Res:17201 are one-time (region init), the target is dead (G6) | G1 + G6 |
| 1603 Blockadebrecher | never | | ok |
| 1701 Getrennte Liebende | the lover dies (`lose1701`); afterwards the giver turns hostile and fails it again (`ende1701`) | after M1 bit 6 stays, the giver's dialog runs `ende1701` (G1); lover Res:17209 one-time (G6); `ende1701` makes the giver hostile by `SetNPCState 08 12`, M1 then points at an enemy (G8) | G1 + G6 + G8 |
| 1702 Korruption | never | official Res:17215 one-time (region init) | G3 |
| 1703 Unschuldiger am Galgen | judge after the time-out (`lose1703`) | after M1 bits 6/7 stay (G1); victim Res:17218 and boss Res:17221 are deleted by `zeit1703`, the victim is one-time (G6). `lose1703` kills the giver by `SetHP 0` on purpose; M2 skips his death label `less1703` and brings him back | G1 + G6, M2 changes vanilla |
| 1801 Monströsere Wilderer | never | poachers one-time (region init) | G3 |
| 1802 Dunkle Priester beschwören Dämon | never | priests from `QIS_OnEnter1802` | ok |
| 1803 Dunkelelfengift | time-out (`zeitum1803`) | after M1 bit 4 stays, the giver's dialog runs `losequest1803` again (G1); boss Res:17229 one-time (region init), once killed the poison can't drop again (G6) | G1 + G6 |
| 1901 Rache an bösem Anführer in Dungeon | never | boss Res:17232 one-time (region init) | G3 |
| 1902 Monster haben Item verschleppt in Dungeon | never | monster Res:17020 one-time (region init) | G3 |
| 1903 Entlaufene 'Tochter' | the girl dies (`lose1903`) | after M1 bit 5 stays, the giver's dialog morphs him into the monster at once (G1); girl Res:17237 one-time (G6) | G1 + G6 |
| 2001 Untote Kühe auf Wiesen | never | boss Res:17239 spawns on the 4th trigger area; `2001a` starts at 1 from the start script, a reset to 0 matches no branch of `move2001` and the boss never comes (G1 start-script variable); trigger bits in `2001` | G3 (start-script variable) |
| 2002 Kämpfe gegen Wilde Monster | never | repeatable arena, waves from `monster2_2002` | ok |
| 2003 Anführer der Dämonensekte | never | the giver is the cult leader: `QIS_OnEnter2003` makes him hostile by `SetNPCState 08 12` (G8, M3 would restart). His death runs (hostile, 7ec0fa2). Vanilla typo `res:174242` | G8 |
| 2101 Hands of my Dragon | never | chain with 2102/2103 (and 2303's egg) on dragon Res:17245 (one-time); prince Res:17246 and his guards from `QIS_OnEnter2101`. Shared variables `riesendiamant`/`drachenei` are written by several of these quests (G9 if one is restarted) | ok |
| 2102 Drache - Zahnstocher | never | prince is the giver; boss Res:17249 from `QIS_OnEnter2102` | ok |
| 2103 Return for Refill | never | knights created one after the other by `ziel2103` | ok |
| 2201 Kuss der Spinnefrau | the girl dies (`lose2201`) | after M1 bits stay (G1); girl Res:17283 one-time (G6); `postchange2201` turns the giver Res:17281 hostile by `SetNPCState 08 12` (G8) | G1 + G6 + G8 |
| 2202 Narzisstische Zauberin | never | `change2202` turns the giver Res:17255 hostile by `SetNPCState 08 12` (G8) | G8 |
| 2203 Hinterhältiger Giftmischer | never | poisoner Res:17260 and the six barrels (counter `2303a`) are one-time (region init) | G3 |
| 2301 Besorge Reagentium | never | bush is a fixed MouseEvent, item re-created in OnEnter | ok |
| 2302 Larbortiere | never | the four monsters are one-time (region init), counted in `2302b` | G3 |
| 2401 Botschaft für Scouts | never | chain 2401-2404 on giver Res:17268 (his death label `mann2404` belongs to 2404); scouts one-time (region init), moved/morphed by the quest | G3 |
| 2402 Verlaufene Sielder finden | the settler dies after joining (`losequest2402`) | the failure continues the chain (`SetUpQuest 2403`, giver's dialog set to 2403); M1 restarting 2402 is wrong (G11) | G11 |
| 2403 Verschleppte Sielder befreien | all settlers die (`losequest2403`) | like 2402: the failure continues the chain with 2404 (G11) | G11 |
| 2404 Verteidige Lager | never | vanilla the giver's death (`mann2404`) hands the reward to Res:17280; M2 restarts and brings him back instead | M2 changes vanilla |
| 2411 Das Orkische Relikt | never | relic from `QIS_OnEnter2411` | ok |
| 2412 Der Feige Hund | never | dog Res:17584 one-time (region init) | G3 |
| 2413 Kleine Wuselorks | never | all from `QIS_OnEnter2413` | ok |
| 2501 Finde Drachnschuppe für Rüstung | never | dragon Res:17292 one-time (region init); counts towards `finale` | G3 |
| 2502 Eierdieb | the time-out, reported to the giver (`losequest2502`) | the failure counts towards the region's `finale` (bit 2 + `finalestart`) like a success (G11); after M1 bits 4/5 stay (G1) | G11 |
| 2503 Drachentöter vertreiben | never | dragon slayers one-time (region init) | G3 |
| 2504 Rette Jungfrau | the maiden (= giver) dies (`lose2504`) | M2 catches her death first: restart and respawn instead of failure + `finale` bit; redoable | M2 changes vanilla |
| 9003 Versteckter Händler | `rg3_goodie1_lose` (merchant's death) | progress lives in the shared variable `goodies` (8 quests), M1/M2 can't reset it (G1 + G9): after M1 bit 1 stays and the dialog offers the reward branch (`ExitQuest`) without the quest running | G1 + G9 |
| 9004 Vermisstes Kind | `rg1_goodie_lose` (child's death) | `goodies` bit 4 stays, the giver only answers "ok" (G1 + G9); child Res:17513 one-time (G6) | G1 + G6 + G9 |
| 9005 Der Waldläufer | never | | ok |
| 9006 Der unbekannte Krieger | never | progress in shared `goodies` | ok |
| 9007 Jacquaire's Waffen | never | guards from `QIS_OnEnter9007` | ok |
| 9008 Furchtbares Verbrechen | escorted giver Res:17539 dies (`losequest9008`) | M2 catches the death (restart, respawn); `QIS_OnEnter9008` sets the trigger area again | M2 changes vanilla |
| 9009 Whiskeylieferung | never | | ok |
| 9010 Vergrabene Sklaven | escorted giver Res:17575 dies (`lose9010`) | M2 restarts; but `9010` is a counter (0 = offer, needs key Res:17580 in the inventory), M2's reset to 1 shows the "follow" branch, and `QIS_OnEnter9010` deleted the key, so it can't be accepted again (G6) | G6 |
| 9011 Jacquaires Vergangenheit | never | boss and target from `QIS_OnEnter9011` | ok |
| main quests 1-81, 670 | never | started by trigger areas (`SelfTriggerQuest<N>`), no giver, so M1-M3 don't touch them; 31's giver is only set by `SetNPCState` (not in `g_givers`) | ok |
| 65 Trockenlegung durch Zwergenstaudamm (main) | never | giver Res:18306 (Torfstecher, start script) is in `g_givers`. Vanilla his death leaves 65 unfinishable (61 is activated by `Exit65`); M2 brings him back, but resets `65` incl. bit 5 (pump switched by `SwitchPump65`); if the pump can't be used twice the quest is stuck (G3). Re-accept creates the rune Res:17569 again (G4) | G3 + G4 |
| 210 Verschleppte Novizinnen in Räuberöhle | never | boss Res:17083 one-time (start script) | G3 |
| 9000 Das Untotenlager | the shaman's drop is picked up while the quest runs | refusing makes the giver Res:17500 attackable by `SetNPCState 08 14` (not Morph, G8); killing him drops the item whose label opens the Khorad Nur gate (`ablehnensieg_goodie_wueste`). If M2 sees him as not hostile it skips `drop_goodie_wueste`: **no item, the gate (main quest 55's way) never opens**. Check `IsHostileTo` for `08 14` | G8, critical |
| 5003 Opferritual | the hero refuses at the end (`nein5003`) | a story choice: the shaman Res:17699 and his tribe turn hostile (`SetNPCState 08 12`) and the quest fails. M1 restarts it with bit 6 kept, so the (now hostile) shaman would offer the choice again (G11 + G8) | G8 + G11 |
| 5005 Pony Express | a time limit runs out (`losepe`) | `losepe` deletes the giver Res:17753 (spawned once by main quest label `NachOrkland55`) and all stations; after M1 the book points at nobody and the quest can't be taken again (G5/G6); `5005c` stays (G1) | G1 + G5 |
| 6001 Die Invasion der Eiselfen | all elves of the camp die (`od_welfen6001`) | after M1 bits stay (G1); the elves (incl. giver Res:17706) are one-time, dead (G6); re-accept creates the invaders again (G4). Accept label starts with `SetNPCState`, so the giver isn't in `g_givers` (no M2/M3) | G1 + G4 + G6 |
| 6002 Das Orklager | never | | ok |
| 6003 Elfenbeschützer | the escorted elf Res:17750 dies (`lose6003`) | after M1 bit 3 stays (G1); the elf is one-time (G6); her dialog is set by `SetNPCState`, so she is probably not in `g_givers` (no M2) | G1 + G6 |
| 2600-3599 generic quests (16 templates × 22 regions) | `DQ_RG<n>_..._LOSE_<id>` (and `_ABORT_` for escorts) | no giver (runtime-assigned NPC), so no M2/M3; M1 restarts them although `Quest_End` already copies them (G2) | G2 |
### Underworld (`bin\Addon\NetScriptCamp`)
Underworld quests keep their state in the NPCs' dialogs (`SetNPCState ...
dialog"dlg_<npc>_<id>_<step>"`), rarely in variables, and most givers are
spawned by `QIS_OnSetUp<id>` (run from the start script) without a dialog.
| Quest | Fails when | Finding | Class |
|---|---|---|---|
| 7612 Eskorte | escorted Nanoduk Res:19547 dies (`od_nanoduk`) | he is the giver and one-time (`QIS_OnSetUp7612`); after M1 nobody offers the quest (G6) | G6 |
| 7614 Vermisstes Kind (Underworld) | the child Res:19549 dies (`od_erdriac`) | the giver's dialog is set to the "child is dead" step `_d` (only "ok"); M1 doesn't restore the accept dialog `_a`, so it can't be taken again (G12). The child comes from `QIS_OnEnter7614` | G12 |
| 7617 Eskorte in den Süden | escorted giver Res:19586 dies (`od_7617`) | he is spawned with his accept dialog, so M2 catches his death: restart and respawn | M2 changes vanilla |
| 7619 Tod den Toten | the giver Res:19545 dies (`od_threepwood_7619`) | his accept dialog is only set by `SetNPCState` in `QIS_OnSetUp7619` (not in `g_givers`); after M1 he is dead (G6) | G6 |
| 9518 Unter der Herrschaft von Dämonen | the mother Res:19530 dies while following (`od_lose9518`) | after M1 bits 1/2/4 stay, the giver only answers "ok" (G1); re-accept creates mother and demons again (G4) | G1 + G4 |
| 9520 Cookies Quest | the hero gives the cookies to Von Endlich, then the girl ends it (`btn_FinishLose9520`, gold for experience) | a story choice (G11); after M1 bit 4 stays, so the girl offers `btn_FinishLose9520` again and again (repeatable gold-for-experience trade) | G11 |
| 10254 Der Hilferuf | the giver dies (`od_Auftraggeber_10254`) | the giver is spawned once when the letter is picked up (`ot_Brief_10254`); after M1 he's dead and the letter gone (G6). The 10250-10261 givers have named ids (`res:AUFTRAGGEBER_10254`, after the type argument), which `InstructionScriptId` doesn't read: none of them is in `g_givers` (G13) | G6 + G13 |
| 10255 Panik vor Spinnen | never (its "lose" is a label name; the giver offers a restart himself) | | ok |
| 10260 Das Rätsel in der Wüßte | the wrong chest is opened (`fillchest_chestQJ_0x`) | the giver's dialog is removed (`0a#0`); after M1 nobody offers it (G12) | G12 |
| other Underworld quests (main 11-56, 7600-7630, 9511-9522, 9900, 11101-11110, 15001-15084) | never | main quests have no giver (untouched). Side quests: givers spawned with their accept dialog in `QIS_OnSetUp<id>` are in `g_givers` (7600, 7601, 7605, 7609, 7614, 7617, 7618, 7620, 7622-7624, 7626, 7630, 9511, 9513, 9515, 9518, 9520, 9521); a chain's later quests on the same giver (dialog by `SetNPCState`) are not. Their targets also come from `QIS_OnSetUp` (one-time), so M2 after a target died loses progress only where variables hold it (G3, rare here); named ids (`res:Sam1_Knappe`, `res:AUFTRAGGEBER_…`) are skipped (G13) | ok / G3 |
## Summary and proposed fixes
Counted over the side quests (Ancaria 151-2504, 9003-9011, 210, 65, the
5003-6003 set and the Underworld quests that can fail): about a third are
fine, the rest fall into the classes above. In order of impact:
1. **G8 critical, 9000** (Khorad Nur gate): verify by a log whether a creature
set by `SetNPCState ... 08 14` is hostile to the hero (`IsHostileTo`). If
not, M2 skips the shaman's death label and the gate never opens. Fix
either way: treat a giver as "turned" when any of its quest's labels set
`08 12`/`08 14` on it (extends `ScanGiverTurns` beyond Morph); that also
fixes M3 for 1112, 1301, 1701, 2003, 2201, 2202, 501, 5003.
2. **G1 reset on failure:** M1 resets the quest's own variables like M2
(including the ones only start/region scripts initialise, with their
initial values, e.g. 302a, 2001a = 1). Fixes most "giver only says ok"
cases (152, 702, 703, 1002, 1003, 1302, 1303, 1401, 1404, 1602, 1803,
1903, 9518, ...).
3. **G11 failures that are part of a story or chain** (2402, 2403, 2502,
5003, 9520): M1 must not restart a quest whose failure label also calls
`SetUpQuest`/`finalestart`, or sets the giver hostile.
4. **G2 generic quests:** M1 leaves 2600-3599 (and their runtime copies) to
the game.
5. **G6 restart without NPCs / G3 lost progress:** the bigger design
question. Options: (a) on restart, respawn the quest's one-time NPCs from
their spawn instructions in start/region scripts (like the giver), (b) keep
the variables on M2/M3 (no progress loss, but the giver may answer with a
running branch), (c) don't restart quests whose needed NPCs are gone and
leave them failed. Recommendation: (a) for NPCs the quest's dialogs or
death labels name, else (c).
6. **G4 duplicates:** before re-running `QIS_OnEnter`, delete the quest's
still-alive named NPCs/objects it is about to create.
7. **G5/G7** (154, 301, 1502, 5005): M3 skips quests whose labels `DelNPC`
their giver; M1 skips quests without a giver.
8. **G9/G12/G13** (803, 9003/9004 `goodies`, 7614, 10260, named ids): lower
priority; G12 needs the accept dialog put back (it is in the giver's spawn
instruction or `QIS_OnSetUp`).
## Fixes (2026-10-30)
Request: "all, but leave 1, as i cant play for now" (2-8 above; G8 waits for a
measurement in the game). Not yet tested in the game.
- `mods/questscript.cpp` (new) reads the script code once and keeps, per
quest: the giver (spawned with the accept dialog, else given it by an NPC
state instruction: the Underworld's `QIS_OnSetUp<id>`), its variables (set
only by its labels, plus the one named after it: G9), what `QIS_OnEnter<id>`
creates, the NPCs it needs (its labels set their state, don't create them),
whether it morphs or deletes its giver, and the failure labels that end its
story (`LoseQuest` with `SetUpQuest`/`ActivateQuest`, experience or gold, a
call into another quest's label, or an NPC offering the quest turned hostile
or set to 0 hit points: 1701 `ende1701`, 1703, 2402, 2403, 2502, 2504, 5003,
9520). It also keeps the labels running (for `Quest_End`) and spawns NPCs
once their bodies are gone.
- `questfail.cpp`: at `Quest_Trigger` (first time) the quest's variables are
kept ("questVariables" with the hero). A failed quest stays failed when it
is a generic quest or a copy (G2), has no giver (G7), its failure ends the
story (G11) or its giver / an NPC it needs is gone without a spawn
instruction (G5/G6). Else the restart also: resets its variables to the kept
values (G1; without them: 1 for the one named after the quest, 0 else),
deletes what `QIS_OnEnter` created (G4), spawns dead needed NPCs and the giver
again (G6, from region start labels, another quest's label or
`QIS_OnSetUp`), and gives a living giver the accept dialog again (G12).
After a load, failed quests restarted earlier get their variables reset
(repairs saves stuck by G1).
- `questgiver.cpp` uses the same scan; the load repair skips quests that
delete their giver (G5); the giver comes back through `questfail::Restart`.
- Not done: G8 (fix 1), G3 for progress held in one-time objects (2203's
barrels), G10, G13 (named ids such as `res:AUFTRAGGEBER_10254`: the game's
name table is unknown, those givers can't be checked or respawned).
+158 -2
View File
@@ -36,11 +36,44 @@
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.
2026-10-30 (quest audit, `docs/quest-audit.md`): a restart also resets the
quest's variables, deletes what its `QIS_OnEnter` created, brings back the
giver and the NPCs it needs, and gives the giver the accept dialog again; some
failures stay final (generic quests, no giver, story endings, NPCs gone for
good). The script facts come from `mods/questscript.cpp`.
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 +184,126 @@ 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, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 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:
0, the one named after the quest 1), 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).
Givers killed as enemies (2026-10-30): "Familienbande" (702, book title
"Mädchen bei Ausritt verschwunden") was restarted twice when the hero killed
its giver. The quest wants that: back at the giver with the girl
(Res:17060), answer `change702` changes the creature type of the giver
Res:17061 (668 -> 262) and two relatives (opcode 0x4f {"Res:<id>", 0x02
type}), they attack, and their death label `tot702` sends the hero back to the
girl (`ziel702`, `exit702` ends the quest). Now a giver hostile to the hero
at death runs the label as the game has it, and the load repair skips quests
whose labels change their giver's type (0x4f on the giver; 2 of the 85 known
givers, 702 among them). Confirmed in game 2026-10-30.
## Quest variables are bit fields (measured 2026-10-28)
Found while repairing "Kräuter für Kräuterhexe" (402; the user knows it as
Windgeflüster). Variables live in the quest manager's vector +0x7550 /
+0x7554 (0x24-byte entries: name[0x20], value +0x20; `game::ScriptVariable`).
Script writes: 0x43 sets the value, 0x44 sets bit n (0x49bb30: value |= 1 <<
n), 0x45 clears bit n (0x49c450), 0x4b adds n (0x49cc20). Conditions
(`Script_Condition` 0x498aa0): 0x49 bit n set, 0x4a bit n clear (logged "if
IsNotVarBit"), 0x48 value == n; a missing variable is false. The variable
named after a quest has bit 0 while the quest is available: all 87 quests not
yet accepted in a dwarf save had value 1, and nothing in the quest's own labels
sets it. Quest 402: accepting sets bit 1, the berries bit 2, the reward bit 3;
the giver's dialog offers the quest on bit 0.
The quest giver restart first ran 0x44 with 0 ("variable = 0"), which set bit
0 and cleared nothing: the giver kept saying "Zögerst du in deinen Taten?".
A reset to 0 then matched no dialog branch at all. Now the value is written
directly: 0, or 1 for the quest's own variable. Confirmed 2026-10-28.
## Main quest items refused (request 2026-10-28)
"I cannot finish the Quest Windgeflüster." The berries (type 6029) are an item
`Region4Init` places once on the ground near the Goldbeerenbusch (op 0x5e,
pickup label `take402`); picking it up logged "cInventoryEntry::addMainQuestItem()
Already has mainQuestItem(6029)" and the item was gone.
`cInventoryEntry::AddMainQuestItem` (0x5d8240, thiscall (type, script id,
notify)) keeps {type, script id} pairs and refuses a new item when an entry
has the same type or script id 0 (0x5d83c6); scripts that give an item
without a "Res:" id add it with 0 (0x486bd8). The dwarf held {4609, 0}, so
every later main quest item was refused (a bug of the original).
`mods/questitems.cpp` removes the script id test. The save was repaired once
by running `take402` for the hero after the load (quest accepted, bit 2
missing); the quest then finished. Confirmed 2026-10-28.
## Escorts never left behind (2026-10-26)
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
longer following me" (Borf). Measured in the log: the husband (Res:17584,
type 86; spawned in `startcode.bin` with death label `drop2412`) fell behind,
the game's own catch-up teleport moved him once (type 86 line), then the hero
walked on, the sectors behind were switched off, and the game removed him
and ran `drop2412` (quest variable 5, a token item res:17585 dropped where he
was; picking it up runs `take2412`, the "husband lost" branch). His object id
was reused right after: removed, not dead. The teleport potion came 50 s
later and was not involved; portals and the potion take followers along.
`mods/followers.cpp`: every 500 ms each follower of the local hero more than
40 cells away (sectors are switched off at 65 and more) on the same level is
teleported to the hero with `cCreature::Teleport` 0x54d840 (thiscall (cell x,
cell y, 0, 1), ret 0x10). Borf's husband was respawned once by a temporary
repair (his spawn instruction, variable 1, token removed, `follow2412`); the
quest then finished with him alive.
+42
View File
@@ -231,6 +231,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
@@ -242,3 +266,21 @@ Possibly related (2026-10-25, once): right after a game start the main menu
texts were drawn as solid gold blocks (the gold text texture without the texts were drawn as solid gold blocks (the gold text texture without the
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
log showed nothing unusual (all hooks and patches applied). log showed nothing unusual (all hooks and patches applied).
## Frame drops while walking (2026-10-27, deferred 2026-10-30)
Reported 2026-10-27. Measured 2026-10-29 (about 54 min, temporary timing in
the Flip, engine tick and map border, since removed): 549 frames over 40 ms,
mostly 0-1 per 10 s, worst 833 ms. Present (Flip) was slow only once; the
time goes to the game's own engine tick (227 ticks of 18-70 ms). Our tick
callbacks were slow only at the 10-min autosave. The map border is drawn only
while the map is open, so it is not the cause. Next step if resumed: time the
parts of the game tick (sector streaming while walking is the main suspect).
Deferred by the user 2026-10-30.
## Eingraben clears slows, roots and stuns (deferred 2026-10-30)
Step 3 of Eingraben (docs/dwarf-arts.md): cast clears slowing, rooting and
stunning effects on the hero. The effect kinds are unknown; plan: temporary
log of the effects added to the hero while the user gets slowed or frozen,
then remove those kinds on cast. Deferred by the user 2026-10-30.
+14 -3
View File
@@ -86,8 +86,9 @@ 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)
constexpr uint16_t kArtEingraben = 0x39; // spell (gui_moves_eingraben)
// cCreatureArts fields. // cCreatureArts fields.
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
@@ -106,7 +107,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 +161,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;
+32
View File
@@ -14,6 +14,38 @@ namespace game {
// The world's sector grid (ecx of World_SetExplored). // The world's sector grid (ecx of World_SetExplored).
inline void* World() { return *reinterpret_cast<void**>(0x00ad14e0); } inline void* World() { return *reinterpret_cast<void**>(0x00ad14e0); }
// The sector grid: 128x128 sectors of 64x64 tiles (tiles < 0x2000), a u16
// index per sector at +0x284 (row-major, 0 = none) into the sector pointer
// vector at +0x240 (World_SetExplored 0x638f00).
constexpr int kSectorTiles = 64;
constexpr int kSectorGrid = 128;
constexpr int kExploredCellTiles = 2; // one mask bit per 2x2 tiles
constexpr int kSectorCells = kSectorTiles / kExploredCellTiles; // 32
constexpr size_t kSectorExploredMask = 0x180; // 32 rows of 4 bytes
inline const uint8_t* SectorAt(int sectorX, int sectorY) {
const auto* world = static_cast<const uint8_t*>(World());
if (!world || sectorX < 0 || sectorY < 0 || sectorX >= kSectorGrid || sectorY >= kSectorGrid) {
return nullptr;
}
const uint16_t index = reinterpret_cast<const uint16_t*>(world + 0x284)[sectorY * kSectorGrid + sectorX];
if (index == 0) {
return nullptr;
}
return reinterpret_cast<const uint8_t* const*>(*reinterpret_cast<const uintptr_t*>(world + 0x240))[index];
}
// The sector's explored mask (128 bytes), or null without a sector.
inline const uint8_t* SectorExploredMask(int sectorX, int sectorY) {
const uint8_t* sector = SectorAt(sectorX, sectorY);
return sector ? sector + kSectorExploredMask : nullptr;
}
// Bit of cell (x, y) within its sector's mask (Sector_SetExplored 0x622bf0).
inline bool MaskCellExplored(const uint8_t* mask, int cellX, int cellY) {
return (mask[cellY * 4 + (cellX >> 3)] >> (cellX & 7)) & 1;
}
// World_SetExplored (0x638f00, thiscall (tile x, tile y, explored), ret 0xc): // World_SetExplored (0x638f00, thiscall (tile x, tile y, explored), ret 0xc):
// sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in // sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in
// a sector that isn't loaded are ignored. // a sector that isn't loaded are ignored.
+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. // 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);
+14
View File
@@ -15,8 +15,17 @@ std::vector<CreatureOrderFn>& Callbacks() {
return callbacks; return callbacks;
} }
std::vector<CreatureOrderFilter>& Filters() {
static std::vector<CreatureOrderFilter> filters;
return filters;
}
} // namespace } // namespace
void FilterCreatureOrders(CreatureOrderFilter filter) {
Filters().push_back(filter);
}
void OnCreatureOrder(CreatureOrderFn callback) { void OnCreatureOrder(CreatureOrderFn callback) {
Callbacks().push_back(callback); Callbacks().push_back(callback);
} }
@@ -26,6 +35,11 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde
RE_HOOK(0x0052e400, Creature_ReceiveOrder); RE_HOOK(0x0052e400, Creature_ReceiveOrder);
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) { void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
if (self && order) { if (self && order) {
for (CreatureOrderFilter filter : Filters()) {
if (!filter(self, *order)) {
return;
}
}
void* caller = _ReturnAddress(); void* caller = _ReturnAddress();
for (CreatureOrderFn callback : Callbacks()) { for (CreatureOrderFn callback : Callbacks()) {
callback(self, *order, caller); callback(self, *order, caller);
+59
View File
@@ -63,11 +63,70 @@ inline void* ControlledCreature(uint8_t* engine) {
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller); using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
void OnCreatureOrder(CreatureOrderFn callback); void OnCreatureOrder(CreatureOrderFn callback);
// Asked before the callbacks: an order any filter refuses (returns false) is
// dropped, the creature never gets it.
using CreatureOrderFilter = bool (*)(void* creature, const CreatureOrder& order);
void FilterCreatureOrders(CreatureOrderFilter filter);
// Creature fields. // Creature fields.
constexpr size_t kCreatureId = 0xc; 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);
}
// The ids on the leader's follower list.
template <typename Fn>
void ForEachFollower(const void* leader, Fn&& fn) {
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) {
fn(*reinterpret_cast<const uint32_t*>(entry));
}
}
inline bool HasFollower(const void* leader, uint32_t id) {
bool found = false;
ForEachFollower(leader, [&](uint32_t follower) { found = found || follower == id; });
return found;
}
// cCreature::Teleport (0x54d840, thiscall (cell x, cell y, 0, 1), ret 0x10):
// as the portals move the hero (EE_USEPORTAL 0x6197c3); the game's log line
// "Teleport (type): <type>, ... (x,y)".
inline void Teleport(void* creature, int32_t cellX, int32_t cellY) {
using Fn = void(__fastcall*)(void*, void*, int32_t, int32_t, int32_t, int32_t);
reinterpret_cast<Fn>(0x0054d840)(creature, nullptr, cellX, cellY, 0, 1);
}
// 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.
+123
View File
@@ -5,6 +5,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <initializer_list>
namespace game { namespace game {
@@ -55,6 +56,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 +65,107 @@ 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). Script variables are
// bit fields ({0x01 name, 0x0b n}; measured 2026-10-27): 0x43 sets the value,
// 0x44 sets bit n (0x49bb30), 0x45 clears bit n (0x49c450), 0x4b adds n
// (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value
// (0x498aa0; a missing variable is false).
constexpr uint16_t kScriptSpawn = 0x01;
// More opcodes (keyword table 0x452540, name i <-> opcode; docs/quest-audit.md):
// 3 NPC state ("Res:<id>", 0x09 dialog, flag 0x08 with 0x12 hostile, ...),
// 8 create an object, 0x0f / 0x36 end the quest (ExitQuest / LoseQuest), 0x11 /
// 0x12 give experience / gold, 0x14 start quest #id, 0x15 set up quest #id,
// 0x16 call a label, 0x34 set hit points ("Res:<id>", percent), 0x37 delete an
// NPC or object, 0x3a / 0x42 if / else if, 0x3c dialog answer (text, label),
// 0x4f change creature type, 0x75 activate quest #id.
constexpr uint16_t kScriptSetNpcState = 0x03;
constexpr uint16_t kScriptCreateObject = 0x08;
constexpr uint16_t kScriptGiveExperience = 0x11;
constexpr uint16_t kScriptGiveGold = 0x12;
constexpr uint16_t kScriptStartQuest = 0x14;
constexpr uint16_t kScriptSetUpQuest = 0x15;
constexpr uint16_t kScriptCallLabel = 0x16;
constexpr uint16_t kScriptSetHitPoints = 0x34;
constexpr uint16_t kScriptLoseQuest = 0x36;
constexpr uint16_t kScriptDelete = 0x37;
constexpr uint16_t kScriptIf = 0x3a;
constexpr uint16_t kScriptAnswer = 0x3c;
constexpr uint16_t kScriptElseIf = 0x42;
constexpr uint16_t kScriptChangeType = 0x4f;
constexpr uint16_t kScriptActivateQuest = 0x75;
constexpr uint16_t kScriptSetVarValue = 0x43;
constexpr uint16_t kScriptSetVarBit = 0x44;
constexpr uint16_t kScriptClearVarBit = 0x45;
constexpr uint16_t kScriptAddToVar = 0x4b;
inline bool IsScriptVariableWrite(uint16_t opcode) {
return opcode == kScriptSetVarValue || opcode == kScriptSetVarBit || opcode == kScriptClearVarBit ||
opcode == kScriptAddToVar;
}
constexpr uint8_t kScriptArgString = 0x01;
constexpr uint8_t kScriptArgInt = 0x0b;
constexpr uint8_t kScriptArgDialog = 0x09;
constexpr uint8_t kScriptArgAttitude = 0x08; // flag, followed by 0x12 (hostile) or 0x14
// The generic quests (Quest_End copies them under a new id; docs/quests.md).
constexpr int32_t kGenericQuestFirst = 2600;
constexpr int32_t kGenericQuestLast = 3599;
// 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
@@ -71,6 +175,25 @@ inline uint8_t* QuestManager() { return reinterpret_cast<uint8_t*>(0x00aaaf00);
constexpr size_t kBookBegin = 0x424; constexpr size_t kBookBegin = 0x424;
constexpr size_t kBookEnd = 0x428; constexpr size_t kBookEnd = 0x428;
constexpr size_t kBookEntrySize = 0x174; constexpr size_t kBookEntrySize = 0x174;
// The script variables: a vector at quest manager +0x7550 / +0x7554 of 0x24-byte
// entries {name[0x20], value} (read by the conditions, 0x498aa0 case 0x49).
constexpr size_t kVariablesBegin = 0x7550;
constexpr size_t kVariablesEnd = 0x7554;
constexpr size_t kVariableSize = 0x24;
constexpr size_t kVariableValue = 0x20;
// The value slot of script variable `name`, nullptr if it does not exist yet.
inline int32_t* ScriptVariable(const char* name) {
uint8_t* begin = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesBegin);
uint8_t* end = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesEnd);
for (uint8_t* entry = begin; entry && entry < end; entry += kVariableSize) {
if (strncmp(reinterpret_cast<const char*>(entry), name, kVariableValue) == 0) {
return reinterpret_cast<int32_t*>(entry + kVariableValue);
}
}
return nullptr;
}
constexpr size_t kBookCapacity = 0x42c; constexpr size_t kBookCapacity = 0x42c;
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450) constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
constexpr size_t kBookState = 0x04; constexpr size_t kBookState = 0x04;
+48
View File
@@ -0,0 +1,48 @@
#pragma once
// The world view (cWorldView0, vtable 0x8900f8): camera and the projection of
// world positions onto the screen. docs/widescreen.md.
#include <cstdint>
#include "game/addresses.h"
#include "game/render/dx_driver7.h"
namespace game {
constexpr size_t kViewCameraX = 0x14; // int32, world pixels
constexpr size_t kViewCameraY = 0x18;
constexpr size_t kViewZoom = 0xb60; // float, 0.5 .. 2.0
constexpr float kViewHalfHeight = 384.0f; // the render height stays 768
struct Point2 {
float x, y;
};
// World units -> world pixels: the ground plane's isometric projection
// (0x622700, cdecl (out, in), plain ret; matrix at 0xad5710; a 53.66563-unit
// tile is a 96x48 pixel diamond). The large map uses it too.
inline Point2 WorldToPlane(float x, float y) {
float in[3] = {x, y, 0};
float out[3] = {};
using Fn = void(__cdecl*)(float*, const float*);
reinterpret_cast<Fn>(0x00622700)(out, in);
return {out[0], out[1]};
}
// World pixels -> screen: (pixel - camera) / zoom + screen centre (the
// floating texts 0x41e546, the object passes 0x62f925). `halfWidth`: half
// the render width (the centre the widescreen mod gives the view).
inline Point2 PlaneToScreen(const uint8_t* view, const Point2& plane, float halfWidth) {
const float zoom = *reinterpret_cast<const float*>(view + kViewZoom);
const auto cameraX = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraX));
const auto cameraY = static_cast<float>(*reinterpret_cast<const int32_t*>(view + kViewCameraY));
return {(plane.x - cameraX) / zoom + halfWidth, (plane.y - cameraY) / zoom + kViewHalfHeight};
}
// The Direct3D 7 device the world is drawn with.
inline void* RenderDevice() {
const auto* driver = *reinterpret_cast<DxDriver7* const*>(addr::g_dxDriver);
return driver ? driver->device : nullptr;
}
} // namespace game
+16
View File
@@ -80,6 +80,22 @@ inline void FillCorners(void* device, float x0, float y0, float x1, float y1, ui
quad, 4, 0); quad, 4, 0);
} }
// Untextured triangles, three vertices each (a triangle list), in batches the
// device takes in one call.
inline void FillTriangles(void* device, ScreenVertex* vertices, size_t count) {
constexpr DWORD kTriangleList = 4;
constexpr size_t kBatch = 3 * 10000;
void** vtable = *static_cast<void***>(device);
using SetTextureFn = HRESULT(__stdcall*)(void*, DWORD, void*);
using DrawPrimitiveFn = HRESULT(__stdcall*)(void*, DWORD, DWORD, void*, DWORD, DWORD);
reinterpret_cast<SetTextureFn>(vtable[kDeviceSetTexture])(device, 0, nullptr);
for (size_t first = 0; first < count; first += kBatch) {
const size_t batch = count - first < kBatch ? count - first : kBatch;
reinterpret_cast<DrawPrimitiveFn>(vtable[kDeviceDrawPrimitive])(
device, kTriangleList, kFvfScreenColourTexture, vertices + first, static_cast<DWORD>(batch), 0);
}
}
// Fills (x0, y0)-(x1, y1) with the ARGB colour `color`. // Fills (x0, y0)-(x1, y1) with the ARGB colour `color`.
inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) { inline void FillRect(void* device, float x0, float y0, float x1, float y1, uint32_t color) {
FillCorners(device, x0, y0, x1, y1, color, color, color, color); FillCorners(device, x0, y0, x1, y1, color, color, color, color);
+37
View File
@@ -0,0 +1,37 @@
#pragma once
// The large map (M, cUI_Megamap): a scrollable view of the 2048x2816 world
// map image. docs/hud.md, mods/megamap.cpp.
#include <cstdint>
namespace game {
// Scroll position (map pixel at the view's left / top edge).
constexpr size_t kMegamapScrollX = 0xc016c;
constexpr size_t kMegamapScrollY = 0xc0170;
inline int32_t MegamapScrollX(const uint8_t* megamap) {
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollX);
}
inline int32_t MegamapScrollY(const uint8_t* megamap) {
return *reinterpret_cast<const int32_t*>(megamap + kMegamapScrollY);
}
struct Vec3 {
float x, y, z;
};
// cUI_Megamap::WorldToMapPixel (0x6cb130, thiscall (out, world x / y / level),
// ret 8): the map pixel of a world position (world units, 53.66563 per tile).
// Linear: the world plane projection 0x622700, then x * 0.007446 + 1018.75,
// y * 0.014914 - 186.95.
inline Vec3 WorldToMapPixel(uint8_t* megamap, const Vec3& world) {
Vec3 pixel = {};
using Fn = void(__fastcall*)(uint8_t*, void*, Vec3*, const Vec3*);
reinterpret_cast<Fn>(0x006cb130)(megamap, nullptr, &pixel, &world);
return pixel;
}
constexpr float kWorldUnitsPerTile = 53.66563f; // 0x8946b0
} // namespace game
+4
View File
@@ -19,6 +19,7 @@
#include "game/engine/fx.h" #include "game/engine/fx.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "mods/dwarfarts.h"
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
#include "re/patch.h" #include "re/patch.h"
@@ -126,6 +127,9 @@ uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_
RE_HOOK(0x00582c10, Arts_AddEffect); RE_HOOK(0x00582c10, Arts_AddEffect);
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration, uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) { uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) {
if (!mods::dwarfarts::KeepEffect(kind, art)) {
return 1;
}
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) { if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
return 1; return 1;
} }
+2 -7
View File
@@ -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
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. // 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) {
+684
View File
@@ -0,0 +1,684 @@
// 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.
//
// Impact marker (asked 2026-10-25): where the grenade will land and its
// explosion radius, a red circle with low alpha on the ground. The landing
// point is decided at launch (0x5145d0 case 0x43e: the target's position, or
// 200 units from the dwarf with +-15 jitter when closer) and sent to the new
// grenade with cEventFX 0x96 (constructed at 0x5169fa: code, owner, 0,
// position area / x / y / level). The construct call notes the grenade and
// the point; the circle is drawn after the world view's ground (0x62d530,
// before the objects) until the impact gathers its targets (the grenade
// object itself stays about 30 s longer).
//
// Eingraben (spell 0x39), the dwarf's escape (request of 2026-10-28): "cast a
// protective barrier around the Dwarf (current visual effect), clear him of
// all movement impeding effects and then teleport to the position marked by
// the cursor (if possible)", shown as the dwarf going underground and coming
// out at the target, 1 s each; range the visible area; the barrier is the
// skill's current defensive bonus for 3 s; "he should be able to see the
// target location"; like the battlemage's Phasenverschiebung. The spells run
// in cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4; cast +0x08 /
// +0x0c the ground point, +0x20 the spell). Case 0x39 creates object type
// 0x480 at the caster with cEventFX 0x9d (owner and target the caster) and
// plays sound 0x840: the barrier, which gives the bonus. Case 1
// (Phasenverschiebung) walks from the caster towards the target in steps of
// 10 while the cell is passable (world 0x636b60) and keeps the last point a
// creature can stand on (0x636eb0); a wall in the way ends the walk, so the
// target has to be in sight. The mod aims Eingraben at a point (definition
// +0x3c: 5 around the caster -> 3 a point), runs the original case (the barrier
// and its bonus last kBarrierSeconds) and moves the dwarf to the end of that
// walk; the cursor limits it to the visible area. The move takes 2 s without
// orders: the barrier turns solid, he moves inside it, it clears again
// ("not instant ... from transparent to solid and back", 2026-10-28; sinking
// him into the ground failed: the camera follows his height).
#include "mods/dwarfarts.h"
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <map>
#include <vector>
#include "game/engine/art.h"
#include "game/engine/arts.h"
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/worldview.h"
#include "game/ui/draw.h"
#include "mods/widescreen.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);
void DrawMarkers(const uint8_t* view);
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);
}
// The explosion radius of a grenade thrown by `owner` (world units; a
// creature is hit when within it plus its own size).
int32_t ImpactRadiusAt(unsigned level) {
const int32_t size = TypeSize(kGrenadeImpactType);
return static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
}
int32_t ImpactRadius(void* owner) {
return ImpactRadiusAt(owner ? game::ArtLevel(owner, game::kArtMoerser) : 0);
}
// The tooltip (game::Art::Tooltip filter, as for Kettenblitz in
// mods/chainlightning.cpp): sections start with the level line ("Aktuelle
// Stufe:", in the skill book also "Nächste erlernte Stufe:") followed by
// "Name: value" lines; each section gets "Explosionsradius: N" after its last
// value, in that line's colour.
constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:";
constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:";
constexpr const wchar_t* kValueSeparator = L": ";
constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB"
std::wstring LeadingColourCodes(const std::wstring& line) {
size_t end = 0;
while (line.compare(end, 2, L"\\c") == 0 && end + kColourCodeLength <= line.size()) {
end += kColourCodeLength;
}
return line.substr(0, end);
}
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
std::vector<std::wstring> lines;
for (size_t start = 0; start < text.size();) {
size_t end = text.find(L'\n', start);
end = end == std::wstring::npos ? text.size() : end + 1;
lines.push_back(text.substr(start, end - start));
start = end;
}
const unsigned current = game::ArtLevel(creature, art.Id());
std::vector<std::wstring> out;
unsigned level = current;
bool inSection = false;
size_t lastValue = SIZE_MAX; // index in `out`
const auto closeSection = [&] {
if (!inSection || lastValue == SIZE_MAX) {
return;
}
std::wstring& after = out[lastValue];
const bool last = after.empty() || after.back() != L'\n';
if (last) {
after += L'\n';
}
out.insert(out.begin() + static_cast<ptrdiff_t>(lastValue) + 1,
LeadingColourCodes(after) + L"Explosionsradius: " + std::to_wstring(ImpactRadiusAt(level)) +
(last ? L"" : L"\n"));
};
for (const std::wstring& line : lines) {
const bool currentHeader = line.find(kCurrentLevelLine) != std::wstring::npos;
const bool nextHeader = line.find(kNextLevelLine) != std::wstring::npos;
if (currentHeader || nextHeader) {
closeSection();
inSection = true;
level = nextHeader ? current + 1 : current;
lastValue = SIZE_MAX;
} else if (inSection && line.find(kValueSeparator) != std::wstring::npos) {
lastValue = out.size();
}
out.push_back(line);
}
closeSection();
text.clear();
for (const std::wstring& line : out) {
text += line;
}
}
// 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
}
}
// --- impact marker ---------------------------------------------------------
constexpr uint32_t kGrenadeEventConstruct = 0x005169fa; // call cEventFX::Construct
constexpr uint32_t kFxEventConstruct = 0x0041e0a0; // thiscall, ret 0x44
constexpr uint32_t kFxEventLand = 0x96;
constexpr size_t kObjectId = 0xc;
constexpr uint32_t kMarkerFill = 0x40ff0000; // red, low alpha
constexpr uint32_t kMarkerRim = 0xa0ff2020;
constexpr float kRimWidth = 2.0f; // screen pixels
constexpr int kCircleSegments = 48;
// A grenade object outlives its explosion by about 30 s (measured
// 2026-10-26), so its marker ends with the explosion: the impact gathering
// its targets (GatherTargets; it does so on several frames, at exactly the
// noted landing point).
constexpr float kImpactMatchDistance = 100.0f; // world units; the landing jitter is +-15
struct Grenade {
uint8_t* object;
uint32_t id;
uint32_t owner;
float x, y; // landing point, world units
float radius;
};
std::vector<Grenade> g_grenades;
// The explosion of `owner`'s grenade at `position` (world units): ends the
// marker of that grenade, the owner's one closest to it.
void OnImpact(uint32_t owner, float x, float y) {
auto closest = g_grenades.end();
float best = kImpactMatchDistance;
for (auto it = g_grenades.begin(); it != g_grenades.end(); ++it) {
const float distance = std::hypot(it->x - x, it->y - y);
if (it->owner == owner && distance < best) {
best = distance;
closest = it;
}
}
if (closest != g_grenades.end()) {
g_grenades.erase(closest);
}
}
// Construct's arguments: code, owner id, target id, position (area, x, y,
// level), ...; the new grenade object is in esi.
void __cdecl NoteGrenade(const uint32_t* args, uint8_t* grenade) {
if (args[0] != kFxEventLand || !grenade) {
return;
}
const auto x = static_cast<float>(static_cast<int32_t>(args[4]));
const auto y = static_cast<float>(static_cast<int32_t>(args[5]));
const auto radius = static_cast<float>(ImpactRadius(game::GetCreature(args[1])));
g_grenades.push_back({grenade, *reinterpret_cast<uint32_t*>(grenade + kObjectId), args[1], x, y, radius});
}
__declspec(naked) void GrenadeEventThunk() {
__asm {
pushad
lea eax, [esp+24h] // the arguments (above pushad and the return address)
push esi // the grenade
push eax
call NoteGrenade
add esp, 8
popad
mov eax, kFxEventConstruct
jmp eax
}
}
bool Alive(const Grenade& grenade) {
return game::GetObjectById(grenade.id) == grenade.object;
}
using SetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD);
using GetRenderStateFn = HRESULT(__stdcall*)(void*, DWORD, DWORD*);
// Render states for a translucent overlay without depth, restored after.
class OverlayStates {
public:
explicit OverlayStates(void* device) : device_(device) {
void** vtable = *static_cast<void***>(device);
set_ = reinterpret_cast<SetRenderStateFn>(vtable[game::kDeviceSetRenderState]);
const auto get = reinterpret_cast<GetRenderStateFn>(vtable[game::kDeviceGetRenderState]);
for (size_t i = 0; i < kCount; ++i) {
get(device_, kStates[i], &saved_[i]);
set_(device_, kStates[i], kValues[i]);
}
}
~OverlayStates() {
for (size_t i = 0; i < kCount; ++i) {
set_(device_, kStates[i], saved_[i]);
}
}
OverlayStates(const OverlayStates&) = delete;
OverlayStates& operator=(const OverlayStates&) = delete;
private:
// D3DRENDERSTATE ZENABLE, ZWRITEENABLE off; alpha blending SRCALPHA /
// INVSRCALPHA.
static constexpr size_t kCount = 5;
static constexpr DWORD kStates[kCount] = {7, 14, game::kStateAlphaBlendEnable, game::kStateSourceBlend,
game::kStateDestinationBlend};
static constexpr DWORD kValues[kCount] = {FALSE, FALSE, TRUE, 5, 6};
void* device_;
SetRenderStateFn set_ = nullptr;
DWORD saved_[kCount] = {};
};
game::ScreenVertex Vertex(const game::Point2& p, uint32_t colour) {
return {p.x, p.y, 0, 1, colour, 0, 0, 0};
}
// The circle on the ground: a translucent disc and a brighter rim.
void DrawMarker(const uint8_t* view, void* device, const Grenade& grenade) {
const float halfWidth = static_cast<float>(mods::widescreen::RenderWidth()) / 2;
const auto screen = [&](float x, float y) {
return game::PlaneToScreen(view, game::WorldToPlane(x, y), halfWidth);
};
const game::Point2 centre = screen(grenade.x, grenade.y);
game::Point2 rim[kCircleSegments + 1];
for (int i = 0; i <= kCircleSegments; ++i) {
const float angle = 6.2831853f * static_cast<float>(i % kCircleSegments) / kCircleSegments;
rim[i] = screen(grenade.x + grenade.radius * std::cos(angle), grenade.y + grenade.radius * std::sin(angle));
}
// The rim band reaches inwards from the circle by kRimWidth.
const auto inner = [&](const game::Point2& p) {
const float dx = centre.x - p.x, dy = centre.y - p.y;
const float length = std::hypot(dx, dy);
return length > kRimWidth ? game::Point2{p.x + dx / length * kRimWidth, p.y + dy / length * kRimWidth}
: centre;
};
static std::vector<game::ScreenVertex> vertices;
vertices.clear();
for (int i = 0; i < kCircleSegments; ++i) {
vertices.insert(vertices.end(),
{Vertex(centre, kMarkerFill), Vertex(rim[i], kMarkerFill), Vertex(rim[i + 1], kMarkerFill)});
}
for (int i = 0; i < kCircleSegments; ++i) {
const game::Point2 a = rim[i], b = rim[i + 1], c = inner(rim[i]), d = inner(rim[i + 1]);
vertices.insert(vertices.end(), {Vertex(a, kMarkerRim), Vertex(b, kMarkerRim), Vertex(c, kMarkerRim),
Vertex(c, kMarkerRim), Vertex(b, kMarkerRim), Vertex(d, kMarkerRim)});
}
game::FillTriangles(device, vertices.data(), vertices.size());
}
// --- Eingraben ----------------------------------------------------------------
// The art's duration (0x4d24f0: definition +0x76 + level × +0x78, at most
// +0x82 when set; Eingraben 7 + level, at most 15), which the barrier, its
// bonus and the tooltip use: a fixed kBarrierSeconds ("down to 4 seconds",
// 2026-10-28).
constexpr int16_t kBarrierSeconds = 4;
constexpr size_t kSpellDurationBase = 0x76; // i16
constexpr size_t kSpellDurationPerLevel = 0x78; // i16
constexpr size_t kSpellDurationMax = 0x82; // u16, 0: none
// The barrier's effects (logged 2026-10-28, all with art 0x39): kind 0x3a
// value 0 from its particles (0x7b9602), which holds the dwarf in place, and
// kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
// the defensive bonus.
constexpr uint32_t kEffectDugIn = 0x3a;
constexpr int32_t kWalkStep = 10; // Phasenverschiebung's step (0x52288a: 10.0)
constexpr size_t kSpellLaunchType = 0x3c;
// The world (cWorld, the object manager's first field), as the art code
// passes it to its cell checks.
void* World() {
void* manager = game::ObjectManager();
return manager ? *static_cast<void**>(manager) : nullptr;
}
// cWorld 0x636b60 (thiscall (position), ret 4): the cell is passable.
bool Passable(void* world, game::MapPosition& at) {
using Fn = bool(__fastcall*)(void*, void*, game::MapPosition*);
return reinterpret_cast<Fn>(0x00636b60)(world, nullptr, &at);
}
// cWorld 0x636eb0 (thiscall (creature id, position), ret 8): the creature can
// stand there (may correct the position's area).
bool Standable(void* world, uint32_t creature, game::MapPosition& at) {
using Fn = bool(__fastcall*)(void*, void*, uint32_t, game::MapPosition*);
return reinterpret_cast<Fn>(0x00636eb0)(world, nullptr, creature, &at);
}
// Phasenverschiebung's walk (0x5227a6..): from the creature towards (x, y),
// the last point it can stand on before a cell that is not passable.
game::MapPosition DigDestination(uint8_t* creature, int32_t x, int32_t y) {
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
game::MapPosition best = from;
void* world = World();
const float dx = static_cast<float>(x - from.x), dy = static_cast<float>(y - from.y);
const float length = std::sqrt(dx * dx + dy * dy);
if (!world || length < kWalkStep) {
return best;
}
const auto stepX = static_cast<int32_t>(dx / length * kWalkStep);
const auto stepY = static_cast<int32_t>(dy / length * kWalkStep);
const auto steps = static_cast<int32_t>(length / kWalkStep);
const uint32_t id = *reinterpret_cast<const uint32_t*>(creature + game::kCreatureId);
game::MapPosition at = from;
for (int32_t i = 0; i <= steps && Passable(world, at); ++i) {
game::MapPosition stand = at;
if (Standable(world, id, stand)) {
best = stand;
}
at.x += stepX;
at.y += stepY;
}
return best;
}
// The dig takes kFadeMs twice: the barrier turns solid, the dwarf moves
// inside it, and it turns transparent again; he takes no orders meanwhile.
constexpr DWORD kFadeMs = 1000;
struct Digging {
uint32_t id = 0; // 0: none
DWORD start = 0; // GetTickCount of the cast, or of the move once moved
game::MapPosition to;
bool moved = false;
};
Digging g_dig;
void Dig(uint8_t* creature, int32_t x, int32_t y) {
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
const game::MapPosition to = DigDestination(creature, x, y);
re::Log("dwarfarts: Eingraben %d,%d towards %d,%d -> %d,%d", from.x, from.y, x, y, to.x, to.y);
if (to.x == from.x && to.y == from.y) {
return;
}
g_dig = {*reinterpret_cast<const uint32_t*>(creature + game::kCreatureId), GetTickCount(), to, false};
}
// Engine tick: once the barrier is solid, the move; the dig's end.
void OnTick(uint8_t*) {
auto* creature = g_dig.id ? static_cast<uint8_t*>(game::GetCreature(g_dig.id)) : nullptr;
if (!creature || GetTickCount() - g_dig.start < kFadeMs) {
g_dig.id = creature ? g_dig.id : 0;
return;
}
if (g_dig.moved) {
g_dig.id = 0;
return;
}
game::Teleport(creature, static_cast<int32_t>(g_dig.to.x / game::kMapCellSize),
static_cast<int32_t>(g_dig.to.y / game::kMapCellSize));
g_dig.moved = true;
g_dig.start = GetTickCount();
}
bool AcceptOrder(void* creature, const game::CreatureOrder&) {
return !g_dig.id || *reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId) != g_dig.id;
}
void OnGameEnd() {
g_dig = {};
}
// The barrier (cParticleSystem_dwarf_shield, draw 0x7b92b0) draws its model
// with alpha pulse × 128 (pulse 0.4..1, +0x20b8; __ftol at 0x7b9483), then
// again additively. While a dig runs that alpha goes to opaque and back.
constexpr uint32_t kShieldAlphaToInt = 0x007b9483;
constexpr uint32_t kFloatToInt = 0x00848f94; // __ftol
constexpr int32_t kOpaque = 255;
int32_t __cdecl ShieldAlpha(int32_t alpha) {
if (!g_dig.id) {
return alpha;
}
const float part = std::fmin(static_cast<float>(GetTickCount() - g_dig.start) / kFadeMs, 1.0f);
const float solid = g_dig.moved ? 1.0f - part : part;
return alpha + static_cast<int32_t>(static_cast<float>(kOpaque - alpha) * solid);
}
__declspec(naked) void ShieldAlphaThunk() {
__asm {
mov eax, kFloatToInt
call eax
push eax
call ShieldAlpha
add esp, 4
ret
}
}
bool AimEingraben() {
auto* def = const_cast<uint8_t*>(game::FindSpellDef(game::kArtEingraben));
if (!def || def[kSpellLaunchType] != static_cast<uint8_t>(game::LaunchType::AroundSelf)) {
return false;
}
def[kSpellLaunchType] = static_cast<uint8_t>(game::LaunchType::Ground);
*reinterpret_cast<int16_t*>(def + kSpellDurationBase) = kBarrierSeconds;
*reinterpret_cast<int16_t*>(def + kSpellDurationPerLevel) = 0;
*reinterpret_cast<uint16_t*>(def + kSpellDurationMax) = 0;
return true;
}
void Startup() {
const bool aim = AimEingraben();
re::Log("dwarfarts: Eingraben aimed at a point, barrier %d s%s", kBarrierSeconds, aim ? "" : " (patch FAILED)");
const bool fade = re::RedirectCall(kShieldAlphaToInt, kFloatToInt, reinterpret_cast<const void*>(&ShieldAlphaThunk));
re::Log("dwarfarts: Eingraben barrier turns solid%s", fade ? "" : " (patch FAILED)");
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)");
const bool marker = re::RedirectCall(kGrenadeEventConstruct, kFxEventConstruct,
reinterpret_cast<const void*>(&GrenadeEventThunk));
re::Log("dwarfarts: Mörsergranate impact marker%s", marker ? "" : " (patch FAILED)");
}
RE_STARTUP(DwarfArtsStartup, Startup);
struct Registration {
Registration() {
game::OnCreatureOrder(&OnOrder);
game::FilterCreatureOrders(&AcceptOrder);
game::OnGameEnd(&OnGameEnd);
game::OnEngineTick(&OnTick);
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
}
} g_registration;
} // namespace
bool KeepEffect(uint32_t kind, uint32_t art) {
return art != game::kArtEingraben || kind != kEffectDugIn;
}
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) {
const uint32_t owner = *reinterpret_cast<const uint32_t*>(fx + kFxOwner);
radius = ImpactRadius(game::GetCreature(owner));
// The position: area (u16), x, y (int32, world units), level.
const auto* at = static_cast<const uint8_t*>(position);
OnImpact(owner, static_cast<float>(*reinterpret_cast<const int32_t*>(at + 4)),
static_cast<float>(*reinterpret_cast<const int32_t*>(at + 8)));
}
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);
}
// The impact markers of the grenades in flight, on the ground of `view`.
void DrawMarkers(const uint8_t* view) {
g_grenades.erase(std::remove_if(g_grenades.begin(), g_grenades.end(), [](const Grenade& g) { return !Alive(g); }),
g_grenades.end());
void* device = game::RenderDevice();
if (g_grenades.empty() || !device) {
return;
}
OverlayStates states(device);
for (const Grenade& grenade : g_grenades) {
DrawMarker(view, device, grenade);
}
}
} // namespace mods::dwarfarts
namespace game {
// cWorldView0::DrawGroundQuads (0x62d530, thiscall (renderer), ret 4): the
// ground; the objects are drawn after it.
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer);
RE_HOOK(0x0062d530, WorldView_DrawGroundQuads);
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer) {
WorldView_DrawGroundQuads_original(self, edx, renderer);
if (self) {
mods::dwarfarts::DrawMarkers(self);
}
}
// cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4): the spells; after
// Eingraben's own case the dwarf digs to the point (cast +0x08 / +0x0c).
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast);
RE_HOOK(0x005222c0, Creature_CastSpell);
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast) {
const bool dig = self && cast && cast[8] == kArtEingraben;
const int32_t x = dig ? static_cast<int32_t>(cast[2]) : 0, y = dig ? static_cast<int32_t>(cast[3]) : 0;
Creature_CastSpell_original(self, edx, cast);
if (dig) {
mods::dwarfarts::Dig(self, x, y);
}
}
} // namespace game
+12
View File
@@ -0,0 +1,12 @@
#pragma once
// The dwarf's arts (dwarfarts.cpp).
#include <cstdint>
namespace mods::dwarfarts {
// Whether cCreatureArts::AddEffect adds an effect of `kind` for art `art`:
// Eingraben no longer holds the dwarf in place.
bool KeepEffect(uint32_t kind, uint32_t art);
} // namespace mods::dwarfarts
+128
View File
@@ -0,0 +1,128 @@
// Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
//
// On the follower list: "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.
//
// Never left behind: in "Die Sklaven der Orks" (2412) the husband fell
// behind (the game's own catch-up teleport moved him once), and when the hero
// walked on, his sector was switched off: the game removed him and ran his
// spawn's death label (drop2412: quest variable 5, a token item dropped where
// he was), with him alive (measured 2026-10-26 in the log: his object id
// reused right after). Sectors are switched off at 2 sectors (65 tiles and
// more) from the hero's; the mod teleports every follower of the local hero
// that is more than kLeftBehindCells away to the hero (cCreature::Teleport,
// as the portals), checked every kCheckMs.
#include <windows.h>
#include <cstdint>
#include <cstdlib>
#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;
constexpr int32_t kLeftBehindCells = 40;
constexpr DWORD kCheckMs = 500;
// 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;
DWORD g_lastCheck = 0;
int32_t Field(const void* creature, size_t offset) {
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
}
const game::MapPosition& Position(void* object) {
return *reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(object) + game::kObjectPosition);
}
int32_t Cell(int32_t units) {
return static_cast<int32_t>(static_cast<float>(units) / game::kMapCellSize);
}
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);
}
}
// Followers of the local hero too far behind are teleported to him.
void BringAlong() {
void* hero = game::GetPlayerHero();
if (!hero) {
return;
}
const game::MapPosition& at = Position(hero);
const int32_t heroX = Cell(at.x), heroY = Cell(at.y);
game::ForEachFollower(hero, [&](uint32_t id) {
void* follower = game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
if (!follower || !game::IsAlive(follower)) {
return;
}
const game::MapPosition& position = Position(follower);
const int32_t x = Cell(position.x), y = Cell(position.y);
if (position.level != at.level ||
(std::abs(x - heroX) <= kLeftBehindCells && std::abs(y - heroY) <= kLeftBehindCells)) {
return;
}
re::Log("followers: %x left behind at %d,%d, brought to the hero at %d,%d", id, x, y, heroX, heroY);
game::Teleport(follower, heroX + 1, heroY + 1);
});
}
void OnTick(uint8_t*) {
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
RepairFollowers();
}
const DWORD now = GetTickCount();
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
g_lastCheck = now;
BringAlong();
}
}
void OnStart() {
g_ticksToCheck = kTicksAfterStart;
}
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnGameStart(&OnStart);
}
} g_registration;
} // namespace
} // namespace mods::followers
+48
View File
@@ -3,6 +3,9 @@
#include <intrin.h> #include <intrin.h>
#include <cstddef>
#include <cstdint>
#include "re/hook.h" #include "re/hook.h"
#include "re/log.h" #include "re/log.h"
@@ -31,4 +34,49 @@ void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4
BurningBone_Render_original(self, edx, device, transform); BurningBone_Render_original(self, edx, device, transform);
} }
// cParticleSystem_grenadeimpact::Render (vf04 0x7b66b0, vtable 0x896cdc;
// (device, matrix by value), ret 0x44): Mörsergranate's explosion. Crashed
// twice (2026-10-26 00:12 and 22:39, sacred.exe+0x361b54 in the particle
// drawer 0x7613e0): a particle's colour is read from the table at
// +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and the particle had a life of
// over 500000 (it counts down from about 255, at 250..400 per second, by
// row: cParticleSystem update 0x763ea0). Particles are 0x40 bytes in the
// vector +0x68..+0x6c; alive while life (+0x2c) and size (+0x28) are above 0.
// How a particle gets there is not known yet: such particles are switched
// off (life 0, the game's dead state) and the first ones logged.
constexpr size_t kParticlesBegin = 0x68;
constexpr size_t kParticlesEnd = 0x6c;
constexpr size_t kParticleSize = 0x40;
constexpr size_t kParticleSizeValue = 0x28; // float
constexpr size_t kParticleLife = 0x2c; // float, colour column 0..255
constexpr size_t kParticleRow = 0x3d; // u8, colour row
constexpr float kColourColumns = 256.0f;
constexpr uint8_t kGrenadeImpactRows = 3; // emitter parameter rows at +0x30d0 (0x20 bytes each)
constexpr size_t kGrenadeImpactPhase = 0x20a9; // u8: 0 burning, 1 waiting, 2 fading (vf08 0x7b64c0)
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform);
RE_HOOK(0x007b66b0, GrenadeImpact_Render);
void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform) {
static int logged = 0;
uint8_t* begin = *reinterpret_cast<uint8_t**>(self + kParticlesBegin);
uint8_t* end = *reinterpret_cast<uint8_t**>(self + kParticlesEnd);
for (uint8_t* particle = begin; particle && particle < end; particle += kParticleSize) {
float& life = *reinterpret_cast<float*>(particle + kParticleLife);
const float size = *reinterpret_cast<const float*>(particle + kParticleSizeValue);
const uint8_t row = particle[kParticleRow];
if (!(life > 0.0f) || !(size > 0.0f) || (life < kColourColumns && row < kGrenadeImpactRows)) {
continue;
}
if (logged < 5) {
++logged;
re::Log("guards: grenade impact particle %d of %d out of range (life %g, row %u, size %g, phase %u), "
"switched off",
static_cast<int>((particle - begin) / kParticleSize), static_cast<int>((end - begin) / kParticleSize),
life, row, size, self[kGrenadeImpactPhase]);
}
life = 0.0f;
}
GrenadeImpact_Render_original(self, edx, device, transform);
}
} // namespace game } // namespace game
+376
View File
@@ -0,0 +1,376 @@
// The explored-area border on the large map (M) as a continuous line along
// the fog's edge.
//
// Request (2026-10-26): "the red outline on the mega map is choppy and feels
// weird"; replace it, following the explored area (option A).
//
// Measured (disassembly): cUI_Megamap::DrawVisitedBorder (0x6c8580, called by
// the map's render) draws one 2x2 px red dot (0xffff0000) per bit of a 100x100
// byte grid (world +0xb73c), at a fixed offset of 10 / 20 px from the sector's
// origin towards each neighbour. The grid marks unvisited sectors next to
// visited ones (sector flag 0x20000, set when the hero's sector becomes the
// centre of the active 3x3, 0x626603; rebuilt on load by 0x633230 and per
// sector change by 0x6332d0). So the line was dots in 64-tile steps, and it
// followed the sectors stood in, not the explored mask the fog shows (since
// the 10x map reveal, far inside the cleared area).
//
// The mod replaces the draw: the edges between explored and unexplored cells
// (2x2 tiles, game/engine/explored.h) of the map's 100x100 sectors are chained
// into polylines, simplified to within a pixel and drawn as 2 px lines in the
// game's red. The lines are rebuilt while the map is open whenever the masks
// changed (checked every 250 ms). Cells of a missing sector count as neither,
// so no line runs along the edge of the world.
#include <windows.h>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
#include "game/engine/explored.h"
#include "game/ui/draw.h"
#include "game/ui/megamap.h"
#include "mods/widescreen.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::mapborder {
namespace {
// The map's sector grid (World_RebuildVisitedBorder 0x633230: 100x100).
constexpr int kMapSectors = 100;
constexpr int kMapCells = kMapSectors * game::kSectorCells;
constexpr uint32_t kColour = 0xffff0000; // the game's border colour (0x6c870e)
constexpr float kLineWidth = 2.0f; // the game's dots: 2x2 px
constexpr double kTolerance = 0.75; // map pixels a simplified line may deviate
constexpr DWORD kCheckIntervalMs = 250;
constexpr int kScreenHeight = 768;
enum class Cell : uint8_t { None, Unexplored, Explored };
struct Grid {
const uint8_t* masks[kMapSectors][kMapSectors];
void Load() {
for (int y = 0; y < kMapSectors; ++y) {
for (int x = 0; x < kMapSectors; ++x) {
masks[y][x] = game::SectorExploredMask(x, y);
}
}
}
Cell At(int x, int y) const {
if (x < 0 || y < 0 || x >= kMapCells || y >= kMapCells) {
return Cell::None;
}
const uint8_t* mask = masks[y / game::kSectorCells][x / game::kSectorCells];
if (!mask) {
return Cell::None;
}
return game::MaskCellExplored(mask, x % game::kSectorCells, y % game::kSectorCells) ? Cell::Explored
: Cell::Unexplored;
}
// FNV-1a over the masks and which sectors exist.
uint64_t Hash() const {
uint64_t hash = 0xcbf29ce484222325ull;
const auto add = [&](uint8_t byte) { hash = (hash ^ byte) * 0x100000001b3ull; };
for (const auto& row : masks) {
for (const uint8_t* mask : row) {
add(mask != nullptr);
for (size_t i = 0; mask && i < 128; ++i) {
add(mask[i]);
}
}
}
return hash;
}
};
struct Point {
double x, y;
};
// Cell corner -> map pixel: WorldToMapPixel is linear, so three points give
// it for the whole grid.
struct Projection {
Point origin, stepX, stepY;
static Point Corner(uint8_t* megamap, int cellX, int cellY) {
const float unitsPerCell = game::kExploredCellTiles * game::kWorldUnitsPerTile;
const game::Vec3 pixel = game::WorldToMapPixel(megamap, {cellX * unitsPerCell, cellY * unitsPerCell, 0});
return {pixel.x, pixel.y};
}
explicit Projection(uint8_t* megamap) {
origin = Corner(megamap, 0, 0);
const Point right = Corner(megamap, kMapCells, 0);
const Point down = Corner(megamap, 0, kMapCells);
stepX = {(right.x - origin.x) / kMapCells, (right.y - origin.y) / kMapCells};
stepY = {(down.x - origin.x) / kMapCells, (down.y - origin.y) / kMapCells};
}
Point operator()(int cellX, int cellY) const {
return {origin.x + cellX * stepX.x + cellY * stepY.x, origin.y + cellX * stepX.y + cellY * stepY.y};
}
};
// Corners on the cell grid, (kMapCells + 1)^2 of them.
uint32_t Key(int x, int y) {
return static_cast<uint32_t>(y) * (kMapCells + 1) + static_cast<uint32_t>(x);
}
struct Edge {
int x0, y0, x1, y1;
};
// Every side between an explored and an unexplored cell, clockwise around
// the explored cell (screen orientation, y down), so the edges of a border
// chain head to tail.
std::vector<Edge> BorderEdges(const Grid& grid) {
std::vector<Edge> edges;
for (int sy = 0; sy < kMapSectors; ++sy) {
for (int sx = 0; sx < kMapSectors; ++sx) {
const uint8_t* mask = grid.masks[sy][sx];
bool any = false;
for (int i = 0; mask && i < 128 && !any; ++i) {
any = mask[i] != 0;
}
if (!any) {
continue;
}
for (int ly = 0; ly < game::kSectorCells; ++ly) {
for (int lx = 0; lx < game::kSectorCells; ++lx) {
if (!game::MaskCellExplored(mask, lx, ly)) {
continue;
}
const int x = sx * game::kSectorCells + lx;
const int y = sy * game::kSectorCells + ly;
if (grid.At(x, y - 1) == Cell::Unexplored) {
edges.push_back({x, y, x + 1, y});
}
if (grid.At(x + 1, y) == Cell::Unexplored) {
edges.push_back({x + 1, y, x + 1, y + 1});
}
if (grid.At(x, y + 1) == Cell::Unexplored) {
edges.push_back({x + 1, y + 1, x, y + 1});
}
if (grid.At(x - 1, y) == Cell::Unexplored) {
edges.push_back({x, y + 1, x, y});
}
}
}
}
}
return edges;
}
using Chain = std::vector<std::pair<int, int>>;
// Edges chained into polylines: open ones (from a corner no edge ends at,
// at a missing sector) first, then the closed loops. Where two cells touch
// only at a corner, the chain turns right, staying around its own cell.
std::vector<Chain> ChainEdges(const std::vector<Edge>& edges) {
std::unordered_map<uint32_t, std::vector<size_t>> starting;
std::unordered_map<uint32_t, int> ending;
starting.reserve(edges.size());
ending.reserve(edges.size());
for (size_t i = 0; i < edges.size(); ++i) {
starting[Key(edges[i].x0, edges[i].y0)].push_back(i);
++ending[Key(edges[i].x1, edges[i].y1)];
}
std::vector<bool> used(edges.size(), false);
std::vector<Chain> chains;
const auto follow = [&](size_t first) {
Chain chain = {{edges[first].x0, edges[first].y0}};
size_t current = first;
for (;;) {
used[current] = true;
const Edge& e = edges[current];
chain.emplace_back(e.x1, e.y1);
const int dx = e.x1 - e.x0;
const int dy = e.y1 - e.y0;
size_t next = SIZE_MAX;
int bestTurn = 3;
for (size_t candidate : starting[Key(e.x1, e.y1)]) {
if (used[candidate]) {
continue;
}
const int cx = edges[candidate].x1 - edges[candidate].x0;
const int cy = edges[candidate].y1 - edges[candidate].y0;
// 0 right (-dy, dx), 1 straight, 2 left.
const int turn = (cx == -dy && cy == dx) ? 0 : (cx == dx && cy == dy) ? 1 : 2;
if (turn < bestTurn) {
bestTurn = turn;
next = candidate;
}
}
if (next == SIZE_MAX) {
break;
}
current = next;
}
chains.push_back(std::move(chain));
};
for (size_t i = 0; i < edges.size(); ++i) {
if (!used[i] && ending.find(Key(edges[i].x0, edges[i].y0)) == ending.end()) {
follow(i);
}
}
for (size_t i = 0; i < edges.size(); ++i) {
if (!used[i]) {
follow(i);
}
}
return chains;
}
double DistanceToSegment(const Point& p, const Point& a, const Point& b) {
const double dx = b.x - a.x;
const double dy = b.y - a.y;
const double length2 = dx * dx + dy * dy;
double t = length2 > 0 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2 : 0;
t = t < 0 ? 0 : t > 1 ? 1 : t;
return std::hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
// Douglas-Peucker: keeps first and last, and every point needed to stay
// within kTolerance of the original.
void Simplify(const std::vector<Point>& points, size_t first, size_t last, std::vector<Point>& out) {
double farthest = 0;
size_t index = first;
for (size_t i = first + 1; i < last; ++i) {
const double d = DistanceToSegment(points[i], points[first], points[last]);
if (d > farthest) {
farthest = d;
index = i;
}
}
if (farthest > kTolerance) {
Simplify(points, first, index, out);
Simplify(points, index, last, out);
} else {
out.push_back(points[last]);
}
}
std::vector<Point> SimplifiedLine(const Chain& chain, const Projection& project) {
std::vector<Point> points;
points.reserve(chain.size());
for (const auto& [x, y] : chain) {
points.push_back(project(x, y));
}
std::vector<Point> out = {points.front()};
if (points.size() < 2) {
return out;
}
// A closed loop is split at its point farthest from the start, so both
// halves have distinct ends.
size_t split = points.size() - 1;
if (chain.front() == chain.back()) {
double farthest = -1;
for (size_t i = 1; i + 1 < points.size(); ++i) {
const double d = std::hypot(points[i].x - points[0].x, points[i].y - points[0].y);
if (d > farthest) {
farthest = d;
split = i;
}
}
}
Simplify(points, 0, split, out);
if (split < points.size() - 1) {
Simplify(points, split, points.size() - 1, out);
}
return out;
}
// Map-pixel line segments of the current border.
struct Segment {
float x0, y0, x1, y1;
};
std::vector<Segment> g_segments;
uint64_t g_hash = 0;
DWORD g_lastCheck = 0;
bool g_built = false;
void Rebuild(uint8_t* megamap) {
static Grid grid;
grid.Load();
const uint64_t hash = grid.Hash();
if (g_built && hash == g_hash) {
return;
}
const DWORD start = GetTickCount();
const Projection project(megamap);
const std::vector<Edge> edges = BorderEdges(grid);
const std::vector<Chain> chains = ChainEdges(edges);
g_segments.clear();
for (const Chain& chain : chains) {
const std::vector<Point> line = SimplifiedLine(chain, project);
for (size_t i = 1; i < line.size(); ++i) {
g_segments.push_back({static_cast<float>(line[i - 1].x), static_cast<float>(line[i - 1].y),
static_cast<float>(line[i].x), static_cast<float>(line[i].y)});
}
}
if (!g_built) {
re::Log("map border: %zu edges, %zu lines, %zu segments, %lu ms", edges.size(), chains.size(),
g_segments.size(), GetTickCount() - start);
}
g_hash = hash;
g_built = true;
}
void Draw(uint8_t* megamap, void* device) {
const DWORD now = GetTickCount();
if (!g_built || now - g_lastCheck >= kCheckIntervalMs) {
g_lastCheck = now;
Rebuild(megamap);
}
const float scrollX = static_cast<float>(game::MegamapScrollX(megamap));
const float scrollY = static_cast<float>(game::MegamapScrollY(megamap));
const float width = static_cast<float>(mods::widescreen::RenderWidth());
const float half = kLineWidth / 2;
static std::vector<game::ScreenVertex> vertices;
vertices.clear();
for (const Segment& s : g_segments) {
const float x0 = s.x0 - scrollX, y0 = s.y0 - scrollY;
const float x1 = s.x1 - scrollX, y1 = s.y1 - scrollY;
if ((x0 < -half && x1 < -half) || (x0 > width + half && x1 > width + half) ||
(y0 < -half && y1 < -half) || (y0 > kScreenHeight + half && y1 > kScreenHeight + half)) {
continue;
}
const float length = std::hypot(x1 - x0, y1 - y0);
if (length <= 0) {
continue;
}
// Along and across the segment, half the width each; the ends reach
// half the width further, closing the joints.
const float ax = (x1 - x0) / length * half, ay = (y1 - y0) / length * half;
const float nx = -ay, ny = ax;
const game::ScreenVertex a = {x0 - ax + nx, y0 - ay + ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex b = {x0 - ax - nx, y0 - ay - ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex c = {x1 + ax + nx, y1 + ay + ny, 0, 1, kColour, 0, 0, 0};
const game::ScreenVertex d = {x1 + ax - nx, y1 + ay - ny, 0, 1, kColour, 0, 0, 0};
vertices.insert(vertices.end(), {a, b, c, c, b, d});
}
if (!vertices.empty()) {
game::FillTriangles(device, vertices.data(), vertices.size());
}
}
} // namespace
} // namespace mods::mapborder
namespace game {
// cUI_Megamap::DrawVisitedBorder (0x6c8580, thiscall (device), ret 4).
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void* edx, void* device);
RE_HOOK(0x006c8580, UiMegamap_DrawVisitedBorder);
void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void*, void* device) {
if (self && device) {
mods::mapborder::Draw(self, device);
}
}
} // namespace game
+5 -19
View File
@@ -24,6 +24,7 @@
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/ui/megamap.h"
#include "game/ui/windows.h" #include "game/ui/windows.h"
#include "mods/hud.h" #include "mods/hud.h"
#include "mods/megamap.h" #include "mods/megamap.h"
@@ -90,27 +91,12 @@ constexpr uint32_t kZoomedByButton = 0x100;
constexpr size_t kZoomHeld = 0xc06bc; constexpr size_t kZoomHeld = 0xc06bc;
constexpr size_t kZoomPointX = 0xc0438; constexpr size_t kZoomPointX = 0xc0438;
constexpr size_t kZoomPointY = 0xc043c; constexpr size_t kZoomPointY = 0xc043c;
constexpr size_t kScrollX = 0xc016c;
constexpr size_t kScrollY = 0xc0170;
template <typename T> template <typename T>
T& Field(uint8_t* p, size_t offset) { T& Field(uint8_t* p, size_t offset) {
return *reinterpret_cast<T*>(p + offset); return *reinterpret_cast<T*>(p + offset);
} }
// cUI_Megamap 0x6cb130 (thiscall (out x, y, 0, world x, y, level), ret 8): the
// map pixel of a world position, as opening the map centres on the hero
// (0x6c7bad..).
struct Vec3 {
float x, y, z;
};
Vec3 MapPixel(uint8_t* megamap, const Vec3& world) {
Vec3 pixel = {};
using Fn = void(__fastcall*)(uint8_t*, void*, Vec3*, const Vec3*);
reinterpret_cast<Fn>(0x006cb130)(megamap, nullptr, &pixel, &world);
return pixel;
}
// Every tick while the zoom button's zoom is on: the zoom point on the hero. // Every tick while the zoom button's zoom is on: the zoom point on the hero.
void ZoomOnHero(uint8_t*) { void ZoomOnHero(uint8_t*) {
uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap); uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap);
@@ -122,12 +108,12 @@ void ZoomOnHero(uint8_t*) {
if (!hero) { if (!hero) {
return; return;
} }
const Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)), const game::Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)),
static_cast<float>(game::CreatureField(hero, game::kCreatureY)), static_cast<float>(game::CreatureField(hero, game::kCreatureY)),
static_cast<float>(hero[game::kObjectLevel])}; static_cast<float>(hero[game::kObjectLevel])};
const Vec3 pixel = MapPixel(megamap, world); const game::Vec3 pixel = game::WorldToMapPixel(megamap, world);
Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - Field<int32_t>(megamap, kScrollX); Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - game::MegamapScrollX(megamap);
Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - Field<int32_t>(megamap, kScrollY); Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - game::MegamapScrollY(megamap);
} }
struct Registration { struct Registration {
+364 -19
View File
@@ -1,45 +1,201 @@
// 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.
//
// Quest audit (2026-10-30, docs/quest-audit.md): a restart also puts back what
// the quest's script needs to be offered again (questscript.cpp): its variables
// as they were when it became available (kept at Quest_Trigger, saved with the
// hero; for quests triggered earlier: 1 for the one named after the quest, else
// 0), the giver's accept dialog, the giver and the NPCs it needs from their
// spawn instructions; what its QIS_OnEnter created is deleted (accepting again
// creates it anew). A quest stays failed when the game repeats it itself
// (generic quests), when it has no giver (trigger areas), when its failure ends
// the story (2402 sets up 2403, 5003's shaman turns hostile, ...) or when an NPC
// it needs is gone for good.
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
#include <map>
#include <string>
#include <utility>
#include <vector>
#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 "mods/questscript.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_giverCells;
uint8_t* quest = game::Quest(index);
const int32_t id = game::QuestId(quest); // Quest id -> its variables when it became available.
re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest)); std::map<int32_t, std::map<std::string, int32_t>> g_freshVariables;
game::QuestFlags(quest) &= ~kResetFlags;
for (size_t i = game::BookCount(); i-- > 0;) { // The nearest living, not hostile, non-hero creature near the hero, or 0. All
if (game::BookQuestId(game::BookEntry(i)) == id) { // objects are searched (the nearby-creature list 0xad1c60 did not hold the
game::EraseBookEntry(i); // 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_giverCells[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_giverCells.find(game::BookQuestId(entry));
if (giver == g_giverCells.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;
}
// The variable named after the quest keeps bit 0: "available", which the
// giver's offer tests (auftrag402: IsVarBit 402 0; measured 2026-10-28: all 87
// quests not yet accepted had value 1).
int32_t FreshValue(int32_t questId, const std::string& name) {
const int32_t available = name == std::to_string(questId) ? 1 : 0;
const auto quest = g_freshVariables.find(questId);
if (quest == g_freshVariables.end()) {
return available;
}
const auto value = quest->second.find(name);
return (value != quest->second.end() ? value->second : 0) | available;
}
void ResetVariables(int32_t questId, const questscript::Quest& quest) {
for (const std::string& name : quest.variables) {
int32_t* value = game::ScriptVariable(name.c_str());
const int32_t fresh = FreshValue(questId, name);
if (!value || *value == fresh) {
continue;
}
re::Log("questfail: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh);
*value = fresh;
} }
} }
// Why failed quest `index` cannot be offered again, or null. `afterLoad`: the
// failure is not running now, so any failure of the quest that ends its story
// counts.
const char* StaysFailed(int index, bool afterLoad) {
const int32_t id = game::QuestId(game::Quest(index));
const questscript::Quest* quest = questscript::Find(id);
if (!quest || (id >= game::kGenericQuestFirst && id <= game::kGenericQuestLast)) {
return "the game repeats it itself";
}
if (!quest->hasGiver && !g_giverCells.count(id)) {
return "no giver";
}
if (afterLoad ? quest->hasFinalFailure : questscript::InFinalFailure()) {
return "its failure ends the story";
}
if (quest->hasGiver && questscript::IsGone(quest->giver) && quest->giver.spawn.empty() &&
!questscript::SpawnOf(quest->giver.scriptId, id)) {
return "its giver is gone";
}
for (uint32_t npc : quest->needed) {
if (!questscript::LivingNpc(npc) && !questscript::SpawnOf(npc, id)) {
re::Log("questfail: quest %d needs Res:%u, gone without a spawn instruction", id, npc);
return "an NPC it needs is gone";
}
}
return nullptr;
}
// Failed quests in the book: those failed before the mod are restarted unless
// they stay failed; those restarted before the variables were reset get their
// variables back (their giver may meanwhile offer a later quest, so the
// dialog stays).
void RestartFailedInBook() { void RestartFailedInBook() {
for (size_t i = game::BookCount(); i-- > 0;) { for (size_t i = game::BookCount(); i-- > 0;) {
const uint8_t* entry = game::BookEntry(i); const uint8_t* entry = game::BookEntry(i);
@@ -47,12 +203,30 @@ void RestartFailedInBook() {
continue; continue;
} }
const int index = game::QuestIndex(game::BookQuestId(entry)); const int index = game::QuestIndex(game::BookQuestId(entry));
if (index >= 0) { const uint32_t flags = index >= 0 ? game::QuestFlags(game::Quest(index)) : 0;
Restart(index); if (index < 0 || ((flags & game::kQuestInProgress) && !(flags & game::kQuestDone))) {
continue;
} }
if (!(flags & game::kQuestDone)) {
const int32_t id = game::QuestId(game::Quest(index));
if (const questscript::Quest* quest = questscript::Find(id)) {
ResetVariables(id, *quest);
}
} else if (!StaysFailed(index, true)) {
Restart(index);
i = std::min(i, game::BookCount()); i = std::min(i, game::BookCount());
} }
} }
}
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) {
@@ -65,24 +239,195 @@ void OnLoaded() {
g_resetAfterLoad = true; g_resetAfterLoad = true;
} }
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------
nlohmann::json WriteGivers(unsigned) {
if (g_giverCells.empty()) {
return nlohmann::json();
}
nlohmann::json all = nlohmann::json::object();
for (const auto& [id, cell] : g_giverCells) {
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_giverCells[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
}
}
}
// --- saved with the hero: {"quest id": {"variable": value}} ------------------------
nlohmann::json WriteFreshVariables(unsigned) {
if (g_freshVariables.empty()) {
return nlohmann::json();
}
nlohmann::json all = nlohmann::json::object();
for (const auto& [id, variables] : g_freshVariables) {
nlohmann::json& quest = all[std::to_string(id)] = nlohmann::json::object();
for (const auto& [name, value] : variables) {
quest[name] = value;
}
}
return all;
}
// Merged, as the givers.
void ReadFreshVariables(unsigned, const nlohmann::json& data) {
if (!data.is_object()) {
return;
}
for (const auto& [key, variables] : data.items()) {
if (!variables.is_object()) {
continue;
}
auto& quest = g_freshVariables[std::stoi(key)];
for (const auto& [name, value] : variables.items()) {
if (value.is_number_integer()) {
quest[name] = value.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);
}
}
const questscript::Quest* script = questscript::Find(id);
if (!script) {
return;
}
ResetVariables(id, *script);
for (uint32_t npc : script->createdOnEnter) {
questscript::Delete(npc);
}
for (uint32_t npc : script->needed) {
const std::vector<uint8_t>* spawn = questscript::LivingNpc(npc) ? nullptr : questscript::SpawnOf(npc, id);
if (spawn) {
questscript::SpawnWhenGone(npc, *spawn, std::string(), "needed by its quest");
}
}
if (!script->hasGiver) {
return;
}
const questscript::Giver& giver = script->giver;
if (!questscript::IsGone(giver)) {
questscript::SetDialog(giver.res, giver.dialog);
} else if (!giver.spawn.empty()) {
questscript::SpawnWhenGone(giver.scriptId, giver.spawn, std::string(), "quest giver");
} else if (const std::vector<uint8_t>* spawn = questscript::SpawnOf(giver.scriptId, id)) {
questscript::SpawnWhenGone(giver.scriptId, *spawn, giver.dialog, "quest giver");
}
}
// 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);
RE_HOOK(0x0046ced0, Quest_End); RE_HOOK(0x0046ced0, Quest_End);
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) {
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
const char* staysFailed = !success && known ? StaysFailed(index, false) : nullptr;
Quest_End_original(self, edx, index, a2, success); Quest_End_original(self, edx, index, a2, success);
if (!success && index >= 0 && static_cast<size_t>(index) < game::QuestCount()) { if (!success && known) {
if (staysFailed) {
re::Log("questfail: quest %d '%s' stays failed: %s", game::QuestId(game::Quest(index)),
game::QuestName(game::Quest(index)), staysFailed);
} else {
Restart(index); Restart(index);
} }
} }
}
// Quest_Trigger (0x46de10, cdecl (index, ?, ?, ?), plain ret): makes the quest
// available (flag 1) the first time, running its trigger scripts. Its
// variables are kept as they are then: the state a restart goes back to.
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4);
RE_HOOK(0x0046de10, Quest_Trigger);
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4) {
if (index >= 0 && static_cast<size_t>(index) < game::QuestCount() &&
!(game::QuestFlags(game::Quest(index)) & game::kQuestTriggered)) {
const int32_t id = game::QuestId(game::Quest(index));
if (const questscript::Quest* quest = questscript::Find(id)) {
auto& fresh = g_freshVariables[id];
fresh.clear();
for (const std::string& name : quest->variables) {
if (const int32_t* value = game::ScriptVariable(name.c_str())) {
fresh[name] = *value;
}
}
}
}
return Quest_Trigger_original(index, a2, a3, a4);
}
// 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_giverCells.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);
inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables);
} }
} g_register; } g_register;
} // namespace } // 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
+137
View File
@@ -0,0 +1,137 @@
// 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": bit 1 of quest variable "1501", 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 giver is the NPC whose spawn instruction carries the quest's accept
// dialog (questscript.cpp). When a label of that quest runs for its dead giver
// (her death label), the label is skipped and the quest restarts
// (questfail::Restart: available again, its book entry failed and pointing at
// the giver, its variables as before, the giver spawned again once the body is
// gone). After a load, a quest in progress whose giver no longer exists is
// repaired the same way, unless the quest itself turns the giver into an enemy
// or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose).
#include <cstdint>
#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 "mods/questscript.h"
#include "re/hook.h"
#include "re/log.h"
namespace mods::questgiver {
namespace {
bool g_repairAfterLoad = false;
// The quest's giver if M2 can bring her back: a numbered script id spawned with
// the accept dialog.
const questscript::Giver* RespawnableGiver(int32_t questId) {
const questscript::Quest* quest = questscript::Find(questId);
return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver
: nullptr;
}
// Restarts quest `index` (questfail::Restart, which also brings the giver
// back); its book entry is failed.
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;
}
}
}
// 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);
}
// Quests whose giver turns into an enemy are left alone: a missing giver may
// be the one the hero killed as the quest asks; so are those deleting her.
void RepairAfterLoad() {
for (int index = 0, count = static_cast<int>(game::QuestCount()); index < count; ++index) {
const int32_t id = game::QuestId(game::Quest(index));
const questscript::Giver* giver = IsRunning(index) ? RespawnableGiver(id) : nullptr;
const questscript::Quest* quest = giver ? questscript::Find(id) : nullptr;
if (!quest || quest->turnsGiver || quest->deletesGiver || questscript::LivingNpc(giver->scriptId)) {
continue;
}
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
game::QuestName(game::Quest(index)), giver->scriptId);
ResetQuest(index);
}
}
void OnTick(uint8_t*) {
if (g_repairAfterLoad) {
g_repairAfterLoad = false;
RepairAfterLoad();
}
}
void OnLoaded() { g_repairAfterLoad = true; }
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. Not when the giver died as an enemy: some quests turn
// the giver hostile and their death label goes on ("Familienbande", 702:
// answer change702 turns the giver Res:17061 and two relatives against the
// hero, their death label tot702 sends him back to the girl Res:17060). The
// running labels are kept for questfail (failures that end a story).
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 questscript::LabelScope running(label);
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)) {
const questscript::Giver* giver = RespawnableGiver(questId);
if (giver && game::ObjectScriptId(creature) == giver->scriptId) {
const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize);
if (game::IsHostileTo(creature, game::GetPlayerHero())) {
re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId,
questId, name);
return Script_RunLabel_original(label, object, flag);
}
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", giver->scriptId,
questId, name);
ResetQuest(index);
return 0;
}
}
}
return Script_RunLabel_original(label, object, flag);
}
} // namespace mods::questgiver
+37
View File
@@ -0,0 +1,37 @@
// Main quest items blocked by an entry without a script id (request of
// 2026-10-27: "I cannot finish the Quest Windgeflüster").
//
// Measured: picking at the Goldbeerenbusch runs take402, which gives the hero
// the berries (item type 6029) and sets the quest's variable; the giver's
// dialog then asks for type 6029 in the hero's main quest items. The game
// logged "addMainQuestItem() Already has mainQuestItem(6029)" and the berries
// never arrived: the dwarf's list held {4609, 0}.
//
// cInventoryEntry::AddMainQuestItem (0x5d8240, thiscall (type, script id,
// notify), list of {type, script id} at +0/+4) refuses a new item when an entry
// has the same type or a script id of 0 (0x5d83c6: cmp [edx+4], ebx (0)).
// Scripts that hand out an item without a "Res:" id add it with script id 0
// (0x486bcb..0x486bd8), so after such an item every later main quest item was
// refused. The mod drops the script id test; the same type still blocks.
#include <cstdint>
#include "re/log.h"
#include "re/patch.h"
namespace mods::questitems {
namespace {
// AddMainQuestItem's duplicate test: `cmp [edx+4], ebx; je 0x5d8418` (ebx 0).
constexpr uint32_t kScriptIdTest = 0x005d83c6;
void Startup() {
const uint8_t from[5] = {0x39, 0x5a, 0x04, 0x74, 0x4d};
const uint8_t to[5] = {0x90, 0x90, 0x90, 0x90, 0x90};
const bool ok = re::ReplaceCode(kScriptIdTest, from, to, sizeof(to));
re::Log("questitems: main quest items without a script id no longer block others%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(QuestItemsStartup, Startup);
} // namespace
} // namespace mods::questitems
+619
View File
@@ -0,0 +1,619 @@
// The quest scripts read once (quest audit of 2026-10-30, docs/quest-audit.md):
// what the failure restart (questfail.cpp) and the giver respawn
// (questgiver.cpp) need to know about a quest.
//
// The compiled script code (FunkCode.bin, open as the quest manager's file) is
// a list of labels (the label table, game/engine/quests.h) whose code is a
// sequence of instructions {u16 opcode, u16 size, tagged args}; the argument
// sizes per tag are those of the game's reader (0x472eb0, as tools/
// questscript.mjs decodes them). From it, per quest:
// - the giver: the NPC given a dialog whose answer starts the quest (a spawn
// instruction carrying it, e.g. Region15Init "Res:17013" dialog auftrag1501;
// else an NPC state instruction, as the Underworld's QIS_OnSetUp<id> do);
// - its variables: those only its labels set, and the one named after it;
// - the NPCs it needs: those its labels set states on but don't create, as
// 1302's opponent Res:17173 (Region13Init); they come back from their spawn
// instruction elsewhere when the quest restarts;
// - what QIS_OnEnter<id> creates (deleted on a restart, accepting again
// creates it anew);
// - the failures that end its story: a LoseQuest label that also sets up the
// next quest (2402), calls another quest's label (2502 finalestart), hands
// out a reward (9520), or turns hostile or kills an NPC offering it (5003,
// 1701, 1703).
#include "mods/questscript.h"
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <map>
#include <set>
#include <utility>
#include <windows.h>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "re/log.h"
namespace mods::questscript {
namespace {
constexpr size_t kInstructionHeader = 4;
constexpr size_t kReadChunk = 1 << 20;
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
constexpr int32_t kPositionNamed = -2; // position args: -2 + a name, else 3 ints
constexpr int32_t kNumberNamed = -0x12d687; // 0x0b args: this + a name (DefNum)
constexpr uint8_t kArgHostile = 0x12; // flag after kScriptArgAttitude
// Argument payloads by tag (0x472eb0).
enum class Payload : uint8_t { kUnknown, kString, kU16, kI32, kPosition, kCondition, kFlag, kBytes };
struct ArgTable {
std::array<Payload, 256> payload{};
std::array<uint8_t, 256> bytes{};
ArgTable() {
for (int tag : {0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71,
0x83, 0x9d}) {
payload[tag] = Payload::kString;
}
for (int tag : {0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c}) {
payload[tag] = Payload::kU16;
}
for (int tag : {0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f,
0x73, 0x75, 0x84, 0x86}) {
payload[tag] = Payload::kI32;
}
for (int tag : {0x04, 0x0c, 0x0d}) {
payload[tag] = Payload::kPosition;
}
for (int tag : {0x47, 0x48, 0x49, 0x4a}) {
payload[tag] = Payload::kCondition;
}
for (int tag : {0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27,
0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c,
0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72,
0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9e,
0xa0, 0xa1}) {
payload[tag] = Payload::kFlag;
}
for (auto [tag, size] : std::initializer_list<std::pair<int, int>>{
{0x15, 8}, {0x20, 1}, {0x28, 1}, {0x2a, 1}, {0x33, 8}, {0x34, 8}, {0x35, 12}, {0x37, 1},
{0x4b, 1}, {0x4d, 1}, {0x79, 8}, {0x87, 8}, {0x88, 8}, {0x89, 8}, {0x8b, 1}}) {
payload[tag] = Payload::kBytes;
bytes[tag] = static_cast<uint8_t>(size);
}
}
};
const ArgTable kArgs;
struct Arg {
uint8_t tag = 0;
const char* text = nullptr; // string payloads, in g_code
int32_t number = 0;
};
struct Label {
std::string name;
int32_t quest = -1;
size_t code = 0;
size_t size = 0;
};
struct Spawn {
int32_t quest;
const Label* label;
std::vector<uint8_t> code;
};
struct Pending {
uint32_t scriptId;
std::vector<uint8_t> spawn;
std::string dialog;
void* body;
uint32_t bodyId;
DWORD since;
std::string why;
};
bool g_scanned = false;
std::vector<uint8_t> g_code;
std::vector<Label> g_labels;
std::map<int32_t, Quest> g_quests;
std::map<uint32_t, std::vector<Spawn>> g_spawns; // script id -> its spawn instructions
std::set<uint32_t> g_finalLabels; // label indices
std::vector<uint32_t> g_running; // labels running now
std::vector<Pending> g_pending;
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
int32_t I32(size_t at) {
int32_t value;
std::memcpy(&value, &g_code[at], sizeof(value));
return value;
}
std::string Lower(const char* text) {
std::string lower(text);
for (char& c : lower) {
c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
}
return lower;
}
bool IsRes(const char* text) { return text && _strnicmp(text, "res:", 4) == 0; }
// The number of "Res:<number>", 0 for a named id.
uint32_t ResNumber(const char* text) {
if (!IsRes(text) || !text[4]) {
return 0;
}
char* end = nullptr;
const unsigned long number = std::strtoul(text + 4, &end, 10);
return *end == 0 ? static_cast<uint32_t>(number) : 0;
}
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();
}
}
}
// The arguments of the instruction at `at`; false if they don't decode exactly
// (conditions are not decoded).
bool Decode(size_t at, uint16_t size, std::vector<Arg>& args) {
args.clear();
const size_t end = at + size;
size_t p = at + kInstructionHeader;
auto text = [&](size_t from, const char*& out) {
const void* zero = from < end ? memchr(&g_code[from], 0, end - from) : nullptr;
if (!zero) {
return false;
}
out = reinterpret_cast<const char*>(&g_code[from]);
p = static_cast<const uint8_t*>(zero) - g_code.data() + 1;
return true;
};
while (p < end) {
Arg arg;
arg.tag = g_code[p++];
switch (kArgs.payload[arg.tag]) {
case Payload::kString:
if (!text(p, arg.text)) {
return false;
}
break;
case Payload::kU16:
if (p + 2 > end) {
return false;
}
arg.number = U16(p);
p += 2;
break;
case Payload::kPosition:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
p += 4;
if (arg.number == kPositionNamed) {
if (!text(p, arg.text)) {
return false;
}
} else {
p += 8;
}
break;
case Payload::kI32:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
p += 4;
if (arg.tag == game::kScriptArgInt && arg.number == kNumberNamed && !text(p, arg.text)) {
return false;
}
break;
case Payload::kCondition:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
if (!text(p + 4, arg.text)) {
return false;
}
break;
case Payload::kBytes:
p += kArgs.bytes[arg.tag];
break;
case Payload::kFlag:
break;
default:
return false;
}
args.push_back(arg);
}
return p == end;
}
// Calls `fn(at, opcode, args)` for each decoded instruction of a label.
template <typename Fn>
void ForEachInstruction(const Label& label, Fn fn) {
std::vector<Arg> args;
const size_t end = label.code + label.size;
for (size_t at = label.code; at + kInstructionHeader <= end && end <= g_code.size();) {
const uint16_t opcode = U16(at);
const uint16_t size = U16(at + 2);
if (size < kInstructionHeader || at + size > end) {
return;
}
if (opcode != game::kScriptIf && opcode != game::kScriptElseIf && Decode(at, size, args)) {
fn(at, opcode, args);
}
at += size;
}
}
const Arg* FindArg(const std::vector<Arg>& args, uint8_t tag) {
for (const Arg& arg : args) {
if (arg.tag == tag) {
return &arg;
}
}
return nullptr;
}
// The first "Res:..." string argument (spawns may name the type first).
const char* ResArg(const std::vector<Arg>& args) {
for (const Arg& arg : args) {
if (arg.tag == game::kScriptArgString && IsRes(arg.text)) {
return arg.text;
}
}
return nullptr;
}
bool SetsHostile(const std::vector<Arg>& args) {
for (size_t i = 0; i + 1 < args.size(); ++i) {
if (args[i].tag == game::kScriptArgAttitude && args[i + 1].tag == kArgHostile) {
return true;
}
}
return false;
}
std::vector<uint8_t> Code(size_t at, uint16_t size) {
return std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size);
}
// An instruction with string arguments {tag, text}.
std::vector<uint8_t> Instruction(uint16_t opcode, std::initializer_list<std::pair<uint8_t, std::string>> args) {
std::vector<uint8_t> code(kInstructionHeader);
for (const auto& [tag, text] : args) {
code.push_back(tag);
code.insert(code.end(), text.begin(), text.end());
code.push_back(0);
}
code[0] = static_cast<uint8_t>(opcode);
code[1] = static_cast<uint8_t>(opcode >> 8);
code[2] = static_cast<uint8_t>(code.size());
code[3] = static_cast<uint8_t>(code.size() >> 8);
return code;
}
void Scan() {
// Not before the game has the code open and its labels read.
if (g_scanned || !game::ScriptFile() || !game::ScriptLabelsBegin() ||
game::ScriptLabelsEnd() <= game::ScriptLabelsBegin()) {
return;
}
g_scanned = true;
if (!ReadCode()) {
re::Log("questscript: script code not readable");
return;
}
std::map<std::string, size_t> byName;
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
Label entry;
entry.name.assign(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
entry.quest = game::ScriptLabelQuest(label);
entry.code = game::ScriptLabelCode(label);
entry.size = game::ScriptLabelCodeSize(label);
byName.emplace(entry.name, g_labels.size());
if (entry.quest >= 0) {
g_quests[entry.quest];
}
g_labels.push_back(std::move(entry));
}
// The accept labels: code starting with "start quest #id" (0x48f320; e.g.
// "trigger1501"); the dialogs with an answer (text, label) running one of
// their quest.
std::map<std::string, int32_t> accepts;
for (const Label& label : g_labels) {
const size_t at = label.code;
if (label.size >= kInstructionHeader + 5 && at + kInstructionHeader + 5 <= g_code.size() &&
U16(at) == game::kScriptStartQuest && g_code[at + kInstructionHeader] == game::kScriptArgInt) {
accepts.emplace(label.name, I32(at + kInstructionHeader + 1));
}
}
std::map<std::string, int32_t> dialogs; // lower-case dialog name -> quest
static const char kDialog[] = "Dialog:";
for (const Label& label : g_labels) {
if (label.quest < 0 || label.name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
continue;
}
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
if (opcode != game::kScriptAnswer || args.size() < 2 || !args[1].text) {
return;
}
const auto accept = accepts.find(args[1].text);
if (accept != accepts.end() && accept->second == label.quest) {
dialogs.emplace(Lower(label.name.c_str() + sizeof(kDialog) - 1), label.quest);
}
});
}
// Spawns, givers (and every NPC offering a quest), variable writers.
std::map<std::string, std::set<int32_t>> setters;
std::map<int32_t, Giver> stateGivers;
std::map<int32_t, std::set<std::string>> offering; // quest -> NPCs given its accept dialog
for (const Label& label : g_labels) {
ForEachInstruction(label, [&](size_t at, uint16_t opcode, const std::vector<Arg>& args) {
if (game::IsScriptVariableWrite(opcode) && !args.empty() && args[0].tag == game::kScriptArgString) {
setters[args[0].text].insert(label.quest);
}
if (opcode != game::kScriptSpawn && opcode != game::kScriptSetNpcState) {
return;
}
const char* res = opcode == game::kScriptSpawn ? ResArg(args) : (args.empty() ? nullptr : args[0].text);
if (!IsRes(res)) {
return;
}
if (opcode == game::kScriptSpawn && ResNumber(res)) {
g_spawns[ResNumber(res)].push_back({label.quest, &label, Code(at, U16(at + 2))});
}
const Arg* dialog = FindArg(args, game::kScriptArgDialog);
const auto offered = dialog ? dialogs.find(Lower(dialog->text)) : dialogs.end();
if (offered == dialogs.end()) {
return;
}
offering[offered->second].insert(Lower(res));
Giver giver{Lower(res), ResNumber(res), dialog->text, {}};
Quest& quest = g_quests[offered->second];
if (opcode == game::kScriptSpawn && !quest.hasGiver) {
giver.spawn = Code(at, U16(at + 2));
quest.giver = std::move(giver);
quest.hasGiver = true;
} else if (opcode == game::kScriptSetNpcState) {
stateGivers.emplace(offered->second, std::move(giver));
}
});
}
for (auto& [id, giver] : stateGivers) {
Quest& quest = g_quests[id];
if (!quest.hasGiver) {
quest.giver = std::move(giver);
quest.hasGiver = true;
}
}
for (const auto& [name, quests] : setters) {
if (quests.size() == 1 && *quests.begin() >= 0) {
g_quests[*quests.begin()].variables.push_back(name);
}
}
// Per quest: what its labels create and touch, and its failures.
std::map<int32_t, std::set<uint32_t>> created;
std::map<int32_t, std::set<uint32_t>> touched;
for (uint32_t index = 0; index < g_labels.size(); ++index) {
const Label& label = g_labels[index];
if (label.quest < 0) {
continue;
}
Quest& quest = g_quests[label.quest];
const std::string id = std::to_string(label.quest);
const bool setUp = label.name == "QIS_OnSetUp" + id;
const bool onEnter = label.name == "QIS_OnEnter" + id;
const std::set<std::string>& offers = offering[label.quest];
bool loses = false;
bool ends = false;
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
const char* res = opcode == game::kScriptSpawn || opcode == game::kScriptCreateObject
? ResArg(args)
: (!args.empty() && args[0].tag == game::kScriptArgString ? args[0].text : nullptr);
const uint32_t npc = ResNumber(res);
const std::string lower = IsRes(res) ? Lower(res) : std::string();
const bool isGiver = quest.hasGiver && !lower.empty() && lower == quest.giver.res;
const bool offersIt = !lower.empty() && offers.count(lower);
switch (opcode) {
case game::kScriptSpawn:
case game::kScriptCreateObject:
if (npc && !setUp) {
created[label.quest].insert(npc);
}
if (npc && onEnter && !isGiver) {
quest.createdOnEnter.push_back(npc);
}
break;
case game::kScriptSetNpcState:
if (npc && !setUp) {
touched[label.quest].insert(npc);
}
ends = ends || (offersIt && SetsHostile(args));
break;
case game::kScriptSetHitPoints:
ends = ends || (offersIt && args.size() >= 2 && args[1].number == 0);
break;
case game::kScriptChangeType:
quest.turnsGiver = quest.turnsGiver || isGiver;
break;
case game::kScriptDelete:
quest.deletesGiver = quest.deletesGiver || isGiver;
break;
case game::kScriptLoseQuest:
loses = true;
break;
case game::kScriptSetUpQuest:
case game::kScriptActivateQuest:
case game::kScriptGiveExperience:
case game::kScriptGiveGold:
ends = true;
break;
case game::kScriptCallLabel: {
const auto target = !args.empty() && args[0].text ? byName.find(args[0].text) : byName.end();
ends = ends || target == byName.end() || g_labels[target->second].quest != label.quest;
break;
}
default:
break;
}
});
if (loses && ends) {
g_finalLabels.insert(index);
quest.hasFinalFailure = true;
}
}
unsigned givers = 0;
for (auto& [id, quest] : g_quests) {
const std::string named = std::to_string(id);
if (setters.count(named) && std::find(quest.variables.begin(), quest.variables.end(), named) ==
quest.variables.end()) {
quest.variables.push_back(named);
}
for (uint32_t npc : touched[id]) {
if (!created[id].count(npc) && !(quest.hasGiver && npc == quest.giver.scriptId)) {
quest.needed.push_back(npc);
}
}
givers += quest.hasGiver;
}
re::Log("questscript: %u quests, %u with a giver, %u failure labels ending a story, %u bytes of script code",
static_cast<unsigned>(g_quests.size()), givers, static_cast<unsigned>(g_finalLabels.size()),
static_cast<unsigned>(g_code.size()));
}
void Run(const std::vector<uint8_t>& code) { game::RunScriptInstruction(code.data()); }
void SpawnNow(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog, const std::string& why) {
const int result = game::RunScriptInstruction(spawn.data());
if (!dialog.empty()) {
SetDialog("res:" + std::to_string(scriptId), dialog);
}
re::Log("questscript: Res:%u spawned again (%s, %d): %s", scriptId, why.c_str(), result,
LivingNpc(scriptId) ? "back" : "NOT FOUND");
}
void OnTick(uint8_t*) {
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;
}
SpawnNow(it->scriptId, it->spawn, it->dialog, it->why);
it = g_pending.erase(it);
}
}
void OnLoaded() { g_pending.clear(); }
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
}
} g_registration;
} // namespace
const Quest* Find(int32_t questId) {
Scan();
const auto quest = g_quests.find(questId);
return quest != g_quests.end() ? &quest->second : nullptr;
}
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId) {
Scan();
const auto spawns = g_spawns.find(scriptId);
if (spawns == g_spawns.end()) {
return nullptr;
}
const std::string setUp = "QIS_OnSetUp" + std::to_string(questId);
const std::vector<uint8_t>* other = nullptr;
const std::vector<uint8_t>* own = nullptr;
for (const Spawn& spawn : spawns->second) {
if (spawn.quest < 0) {
return &spawn.code;
}
if (spawn.quest == questId && spawn.label->name == setUp) {
own = own ? own : &spawn.code;
} else if (spawn.quest != questId) {
other = other ? other : &spawn.code;
}
}
return own ? own : other;
}
void* LivingNpc(uint32_t scriptId) {
const uint32_t id = scriptId ? game::ScriptObjectId(scriptId) : 0;
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
return creature && game::ObjectScriptId(creature) == scriptId && game::IsAlive(creature) ? creature : nullptr;
}
bool IsGone(const Giver& giver) { return giver.scriptId && !LivingNpc(giver.scriptId); }
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
const char* why) {
for (const Pending& pending : g_pending) {
if (pending.scriptId == scriptId) {
return;
}
}
const uint32_t bodyId = game::ScriptObjectId(scriptId);
void* body = bodyId ? game::GetObjectById(bodyId) : nullptr;
if (body && game::ObjectScriptId(body) == scriptId) {
g_pending.push_back({scriptId, spawn, dialog, body, bodyId, GetTickCount(), why});
} else {
SpawnNow(scriptId, spawn, dialog, why);
}
}
void Delete(uint32_t scriptId) {
const uint32_t id = game::ScriptObjectId(scriptId);
void* object = id ? game::GetObjectById(id) : nullptr;
if (!object || game::ObjectScriptId(object) != scriptId) {
return;
}
re::Log("questscript: Res:%u deleted", scriptId);
Run(Instruction(game::kScriptDelete, {{game::kScriptArgString, "res:" + std::to_string(scriptId)}}));
}
void SetDialog(const std::string& res, const std::string& dialog) {
Run(Instruction(game::kScriptSetNpcState,
{{game::kScriptArgString, res}, {game::kScriptArgDialog, dialog}}));
}
LabelScope::LabelScope(uint32_t label) { g_running.push_back(label); }
LabelScope::~LabelScope() { g_running.pop_back(); }
bool InFinalFailure() {
Scan();
for (uint32_t label : g_running) {
if (g_finalLabels.count(label)) {
return true;
}
}
return false;
}
} // namespace mods::questscript
+66
View File
@@ -0,0 +1,66 @@
#pragma once
// What the quest scripts say about each quest (questscript.cpp): its giver, its
// variables, the NPCs it needs, the failures that end its story. Shared by the
// failure restart (questfail.cpp) and the giver respawn (questgiver.cpp).
#include <cstdint>
#include <string>
#include <vector>
namespace mods::questscript {
struct Giver {
std::string res; // "res:17013" as the scripts name it, lower case
uint32_t scriptId = 0; // its number, 0 for named ids ("res:AUFTRAGGEBER_10254")
std::string dialog; // the dialog with the accept answer
std::vector<uint8_t> spawn; // the spawn instruction carrying that dialog, or empty
};
struct Quest {
bool hasGiver = false;
Giver giver;
std::vector<std::string> variables; // only this quest's labels set them, and the one named after it
std::vector<uint32_t> createdOnEnter; // script ids QIS_OnEnter<id> creates (not the giver)
std::vector<uint32_t> needed; // NPCs its labels set states on but don't create (not the giver)
bool turnsGiver = false; // a label changes the giver's creature type
bool deletesGiver = false; // a label deletes the giver
bool hasFinalFailure = false; // one of its failures ends the story
};
// The quest's script facts, null if it has no labels (generic quest copies).
const Quest* Find(int32_t questId);
// A spawn instruction for `scriptId` outside the quest's own course: in a label
// of no quest (region start), of another quest, or its QIS_OnSetUp; null if none.
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId);
// The living creature the scripts call Res:<scriptId>, or null.
void* LivingNpc(uint32_t scriptId);
// The NPC Res:<scriptId> is dead or missing (named ids are never known gone).
bool IsGone(const Giver& giver);
// Runs `spawn` now, or once the dead body of Res:<scriptId> is gone; then gives
// the NPC `dialog` (if not empty).
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
const char* why);
// Deletes the NPC or object Res:<scriptId> if it exists.
void Delete(uint32_t scriptId);
// Gives NPC `res` the dialog `dialog` (script NPC state instruction).
void SetDialog(const std::string& res, const std::string& dialog);
// A label of the scripts runs (Script_RunLabel): kept on a stack while it runs.
class LabelScope {
public:
explicit LabelScope(uint32_t label);
~LabelScope();
LabelScope(const LabelScope&) = delete;
LabelScope& operator=(const LabelScope&) = delete;
};
// A running label (or one calling it) is a failure that ends its quest's story.
bool InFinalFailure();
} // namespace mods::questscript
+1
View File
@@ -10,6 +10,7 @@ passed explicitly.
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) | | `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI | | `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` | | `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
| `questscript.mjs <dir> <quest id...>` | decode a quest script folder (`bin\TYPE_NPC_<class>`, `bin\Addon\NetScriptCamp`): a quest's labels and where other labels use its NPCs/variables; `--list`, `--facts [ids]` (giver, death labels), `--label <name>`, `--grep <regex>` (matching instructions in all labels) |
| `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) | | `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) |
## Persistent Ghidra project (`ghidra/`, not in git) ## Persistent Ghidra project (`ghidra/`, not in git)
+42
View File
@@ -413,3 +413,45 @@
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
0x00633230 World_RebuildVisitedBorder # thiscall: clears the 100x100 border bytes (+0xb73c) and runs 0x6332d0 for every sector with flag 0x20000 (visited); on load (0x471086, 0x60c621)
0x006332d0 World_UpdateVisitedBorderAround # thiscall (sector cx, cy), ret 8: border byte of each unvisited 3x3 neighbour (0x6334f0)
0x006334f0 World_ComputeVisitedBorderCell # thiscall (cx, cy), ret 8: 8 bits, one per neighbour sector with flag 0x20000
0x006336b0 World_VisitedBorderCell # thiscall (cx, cy), ret 8: border byte, 0 outside 100x100
0x006c8580 cUI_Megamap::DrawVisitedBorder # thiscall (device), ret 4: 2x2 px red (0xffff0000) dot per border bit, +-10/+-20 px from the sector origin
0x006c87c0 cUI_Megamap::DrawQuestMarkers # thiscall (device), ret 4: quest book entries (0xaaaf00)
0x00622700 WorldToPlane # cdecl (out vec3, in vec3), plain ret: world units -> world pixels (iso matrix 0xad5710)
0x0062d530 cWorldView0::DrawGroundQuads # thiscall (renderer), ret 4: the ground, before the objects
0x0041e0a0 cEventFX::Construct # thiscall (code, owner, target, position 4 dwords, ...), ret 0x44
0x007b66b0 cParticleSystem_grenadeimpact::Render # vf04 (device, matrix by value), ret 0x44; vtable 0x896cdc
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68
0x005d8240 cInventoryEntry::AddMainQuestItem # thiscall (type, script id, notify): {type, script id} pairs; refused if same type (script id 0 test patched out)
0x0049bb30 QuestManager_SetVarBit # script op 0x44: variable |= 1 << n
0x0049c450 QuestManager_ClearVarBit # script op 0x45: variable &= ~(1 << n)
0x0049cc20 QuestManager_AddToVar # script op 0x4b: variable += n
0x005222c0 cCreature::CastSpell # thiscall (cast), ret 4: switch on the spell (cast +0x20); +0x08/+0x0c ground point; case 1 Phasenverschiebung, 0x39 Eingraben
0x00636b60 cWorld::IsPassable # thiscall (position), ret 4: world = *ObjectManager
0x00636eb0 cWorld::CanStand # thiscall (creature id, position), ret 8: may correct the position's area
0x004d24f0 cArtDef::Duration # thiscall (index, level): +0x76 + level * +0x78 (i16), at most +0x82 when set
0x007b8fc0 cParticleSystem_dwarf_shield::cParticleSystem_dwarf_shield # Eingraben's barrier (type 0x480)
0x007b91a0 cParticleSystem_dwarf_shield::Update # vf08 (dt), ret 4; pulse +0x20b8, phase +0x20ac, end +0x20a8
0x007b92b0 cParticleSystem_dwarf_shield::Draw # vf04, ret 0x44; model alpha pulse * 128 (__ftol at 0x7b9483)
+341
View File
@@ -0,0 +1,341 @@
// Decode the compiled quest scripts (docs/quests.md, "Quest scripts").
// node tools/questscript.mjs <script dir> <quest id...> one quest: its labels and every
// reference to it from other labels
// node tools/questscript.mjs <script dir> --list all quests of the quest table
// node tools/questscript.mjs <script dir> --label <name> one label
// <script dir>: the folder with FunkCode.bin + Vectoren.bin, e.g.
// "<Sacred Gold>\bin\TYPE_NPC_GLADIATOR" (Ancaria, one per hero class; the game
// writes the one in use to bin\sgf.bin) or "<Sacred Gold>\bin\Addon\NetScriptCamp"
// (Underworld).
//
// Formats: Vectoren.bin = u32 n, n labels of 0x54 bytes (name, +0x40 code offset,
// +0x44 size, +0x48 quest), then u32 count, count quest records of 0x124 bytes
// (id, name). FunkCode.bin = instructions {u16 opcode, u16 size with the header,
// tagged args}. Opcode names from the game's keyword tables (0x451db0, 0x452300,
// 0x4523d0, 0x452490, 0x452540); argument sizes from the reader 0x472eb0.
// Conditions (IF 0x3a / ELSEIF 0x42) have their own tags, decoded by shape.
import { readFileSync } from "node:fs";
import { join } from "node:path";
const OPS = {
0x1: "CreateNPC", 0x2: "SetObjState", 0x3: "SetNPCState", 0x4: "SetBaseTrigger", 0x5: "DelBaseTrigger",
0x6: "TalkTo", 0x7: "EquipNPC", 0x8: "CreateObj", 0x9: "StartScript", 0xa: "QuestDone", 0xb: "QuestNotDone",
0xc: "QuestInProgress", 0xd: "QuestNotInProgress", 0xe: "DeleteFunktionBlock", 0xf: "ExitQuest", 0x10: "MoveOver",
0x11: "AddExp", 0x12: "AddGold", 0x13: "AddGems", 0x14: "TriggerQuest", 0x15: "SetUpQuest", 0x16: "CallFunktion",
0x17: "DefPos", 0x18: "GroupIsDead", 0x19: "CheckForQuestPool", 0x1a: "Text", 0x1b: "MaxOffen:", 0x1c: "Delay:",
0x1d: "Rettungsmission:", 0x1e: "ToDoBloecke:", 0x1f: "Dialog:", 0x20: "Belohnungen:", 0x21: "HideTmpToDo:",
0x22: "Schatzsuche:", 0x23: "FillRegion", 0x24: "AddHandel", 0x25: "SetRG", 0x26: "UnsetRG", 0x2b: "ExitDlg",
0x2c: "DelDlg", 0x2d: "StartPosition", 0x2e: "Teleport", 0x30: "CreateTrigger", 0x31: "SetTriggerState",
0x32: "TriggerPatch", 0x33: "FillSector", 0x34: "SetHP", 0x35: "QuestBook", 0x36: "LoseQuest", 0x37: "DelNPC",
0x38: "SetTimer", 0x39: "DelTimer", 0x3a: "IF", 0x3b: "ELSE", 0x3c: "SetButton", 0x3d: "ChDlgText", 0x3e: "ENDIF",
0x3f: "QuestKompassPos", 0x40: "QuestKompassObj", 0x41: "DefNum", 0x42: "ELSEIF", 0x43: "SetVar", 0x44: "SetVarBit",
0x45: "UnsetVarBit", 0x46: "SetFocus", 0x47: "NPC_TalkTo", 0x48: "NPC_Goto", 0x49: "SetGroupState", 0x4a: "GroupGoto",
0x4b: "IncVar", 0x4c: "DecVar", 0x4d: "UnTriggerQuest", 0x4e: "FillChest", 0x4f: "Morph", 0x50: "SetST",
0x51: "SetGS", 0x52: "SetWI", 0x53: "SetRP", 0x54: "SetRM", 0x55: "SetCH", 0x56: "SetIcon", 0x57: "SetQuestInfo",
0x58: "SetAnimMode", 0x59: "SetPlayMode", 0x5a: "Wait", 0x5b: "PlayAnim", 0x5c: "Attack", 0x5d: "ActObj",
0x5e: "MouseEvent", 0x5f: "Popup", 0x60: "ReplaceSpawn", 0x61: "SetMapIcon", 0x62: "SecInfo", 0x63: "Partikel",
0x64: "SpawnValues", 0x65: "Toete_NPC", 0x66: "Begleite", 0x67: "SetCV", 0x68: "PlaySound", 0x69: "RndVar",
0x6a: "SetRgnDialog", 0x6b: "BalanceSpawnItem", 0x6c: "AddPoolPos", 0x6d: "GetPoolPos", 0x6e: "RenPos",
0x6f: "ToBegin", 0x70: "AddPoolRgn", 0x71: "GetPoolRgn", 0x72: "Waffenpool", 0x73: "MakeGroup",
0x74: "RegionChange", 0x75: "ActivateQuest", 0x76: "SetClientVars", 0x77: "GetClientVars", 0x78: "GameEnd",
0x79: "AutoSave", 0x7a: "SolveTime:", 0x7b: "Strafen:", 0x7c: "RegionFrei", 0x7d: "Gewinn", 0x7e: "RejectQuest",
0x7f: "PlayMusic", 0x80: "PlayJingle", 0x81: "SetScriptSoundControle", 0x82: "Teleporter", 0x83: "GameActive",
0x84: "InfoPlayer", 0x85: "GetPoolPosition", 0x86: "SetCinemaMode", 0x87: "SetOnKill", 0x88: "SetOnCollect",
0x89: "SetDrop", 0x8a: "GiveStat", 0x8b: "GiveSkill", 0x8c: "MachVersteck",
};
// Argument tags (reader 0x472eb0): payload kinds.
const STR = new Set([0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71, 0x83, 0x9d]);
const U16 = new Set([0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c]);
const I32 = new Set([0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f, 0x73, 0x75, 0x84, 0x86]);
const BYTES = { 0x15: 8, 0x20: 1, 0x28: 1, 0x2a: 1, 0x33: 8, 0x34: 8, 0x35: 12, 0x37: 1, 0x4b: 1, 0x4d: 1, 0x79: 8, 0x87: 8, 0x88: 8, 0x89: 8, 0x8b: 1 };
const FLAG = new Set([0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2b,
0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c, 0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59,
0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72, 0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96,
0x97, 0x98, 0x99, 0x9a, 0x9e, 0xa0, 0xa1]);
// Condition tags with {int32, variable}: 0x49 bit set, 0x4a bit clear (Script_Condition
// 0x498aa0), 0x47 / 0x48 / 0x6e compare values.
const COND_VAR = new Set([0x47, 0x48, 0x49, 0x4a, 0x6e]);
// Opcodes whose first argument is an NPC ("Res:<id>").
const NPC_OPS = new Set([0x01, 0x03, 0x06, 0x07, 0x1d, 0x2e, 0x34, 0x37, 0x40, 0x46, 0x47, 0x48, 0x4f, 0x5b, 0x5c, 0x65, 0x66]);
const TAG_NAMES = { 0x05: "death", 0x09: "dialog", 0x02: "type" };
const hex = (n) => n.toString(16).padStart(2, "0");
function cString(b, p) {
const e = b.indexOf(0, p);
return [b.toString("latin1", p, e), e + 1];
}
function printableAt(b, p, end) {
const e = b.indexOf(0, p);
if (e < 0 || e >= end) return false;
for (let i = p; i < e; i++) if (b[i] < 0x20) return false;
return true;
}
// Decoded arguments: [{tag, kind, value}], ok when the instruction was consumed exactly.
function decodeArgs(b, p, end, op) {
const args = [];
const cond = op === 0x3a || op === 0x42;
while (p < end) {
const tag = b[p++];
let s;
if (cond) {
if (COND_VAR.has(tag) && p + 4 < end) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (p + 4 < end && !printableAt(b, p, end) && printableAt(b, p + 4, end) && Math.abs(b.readInt32LE(p)) < 1e6) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (printableAt(b, p, end)) {
[s, p] = cString(b, p);
args.push({ tag, kind: "str", value: s });
} else if (p + 4 <= end) {
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
p += 4;
} else {
args.push({ tag, kind: "flag" });
}
continue;
}
if (STR.has(tag)) {
[s, p] = cString(b, p);
args.push({ tag, kind: "str", value: s });
} else if (U16.has(tag)) {
args.push({ tag, kind: "int", value: b.readUInt16LE(p) });
p += 2;
} else if (tag === 0x04 || tag === 0x0c || tag === 0x0d) {
const v = b.readInt32LE(p);
p += 4;
if (v === -2) {
[s, p] = cString(b, p);
args.push({ tag, kind: "pos", value: s });
} else {
args.push({ tag, kind: "pos", value: `${v},${b.readInt32LE(p)}` });
p += 8;
}
} else if (tag === 0x0b && b.readInt32LE(p) === -0x12d687) {
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "num", value: s });
} else if (I32.has(tag)) {
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
p += 4;
} else if (BYTES[tag] !== undefined) {
args.push({ tag, kind: "bytes", value: [...b.slice(p, p + BYTES[tag])].map(hex).join("") });
p += BYTES[tag];
} else if (tag === 0x47 || tag === 0x48 || tag === 0x49 || tag === 0x4a) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (FLAG.has(tag)) {
args.push({ tag, kind: "flag" });
} else {
args.push({ tag, kind: "raw", value: [...b.slice(p, end)].map(hex).join("") });
return { args, ok: false };
}
}
return { args, ok: p === end };
}
function formatArg(a) {
const t = TAG_NAMES[a.tag] ?? hex(a.tag);
switch (a.kind) {
case "str": return `${t}"${a.value}"`;
case "int": return `${t}#${a.value}`;
case "num": return `${t}#"${a.value}"`;
case "pos": return `${t}@${a.value}`;
case "cond": return `${t}(${a.value[0]},"${a.value[1]}")`;
case "bytes": return `${t}[${a.value}]`;
case "flag": return t;
default: return `?${t}[${a.value}]`;
}
}
export function load(dir) {
const code = readFileSync(join(dir, "FunkCode.bin"));
const vec = readFileSync(join(dir, "Vectoren.bin"));
const n = vec.readUInt32LE(0);
const labels = [];
for (let i = 0; i < n; i++) {
const o = 4 + i * 0x54;
const label = {
name: vec.toString("latin1", o, vec.indexOf(0, o)),
quest: vec.readInt32LE(o + 0x48),
instructions: [],
};
let p = vec.readUInt32LE(o + 0x40);
const end = p + vec.readUInt32LE(o + 0x44);
while (p + 4 <= end && end <= code.length) {
const op = code.readUInt16LE(p);
const size = code.readUInt16LE(p + 2);
if (size < 4) break;
label.instructions.push({ op, ...decodeArgs(code, p + 4, p + size, op) });
p += size;
}
labels.push(label);
}
// startcode.bin: the world's start script (initial spawns), one instruction stream.
try {
const start = readFileSync(join(dir, "startcode.bin"));
const label = { name: "startcode", quest: -1, instructions: [] };
for (let p = 0; p + 4 <= start.length;) {
const op = start.readUInt16LE(p);
const size = start.readUInt16LE(p + 2);
if (size < 4) break;
label.instructions.push({ op, ...decodeArgs(start, p + 4, Math.min(p + size, start.length), op) });
p += size;
}
labels.push(label);
} catch {
// no start script in this folder
}
const quests = new Map();
let o = 4 + n * 0x54;
const count = vec.readUInt32LE(o);
o += 4;
for (let i = 0; i < count && o + 0x124 <= vec.length; i++, o += 0x124) {
quests.set(vec.readInt32LE(o), vec.toString("latin1", o + 4, vec.indexOf(0, o + 4)).trim());
}
return { labels, quests };
}
export function formatInstruction(ins) {
const name = OPS[ins.op] ?? `op${hex(ins.op)}`;
return `${name} ${ins.args.map(formatArg).join(" ")}${ins.ok ? "" : " (!)"}`.trimEnd();
}
const strings = (ins) => ins.args.flatMap((a) => (a.kind === "str" ? [a.value] : a.kind === "cond" ? [a.value[1]] : []));
const VAR_WRITES = new Set([0x43, 0x44, 0x45, 0x4b, 0x4c, 0x69]);
function printLabel(label, indent = " ") {
console.log(`${indent}${label.name}${label.quest >= 0 ? "" : " (no quest)"}`);
let depth = 0;
for (const ins of label.instructions) {
if (ins.op === 0x3b || ins.op === 0x42 || ins.op === 0x3e) depth = Math.max(0, depth - 1);
console.log(`${indent} ${" ".repeat(depth)}${formatInstruction(ins)}`);
if (ins.op === 0x3a || ins.op === 0x3b || ins.op === 0x42) depth++;
}
}
function printQuest({ labels, quests }, id) {
const own = labels.filter((l) => l.quest === id);
console.log(`== quest ${id} ${quests.get(id) ?? "(not in the quest table)"}: ${own.length} labels`);
for (const label of own) printLabel(label);
// Names other labels may use: own labels, dialogs, NPCs, variables.
const names = new Set(own.map((l) => l.name));
for (const l of own) if (l.name.startsWith("Dialog:")) names.add(l.name.slice(7));
const npcs = new Set();
const vars = new Set();
for (const l of own) {
for (const ins of l.instructions) {
for (const a of ins.args) if (a.kind === "cond") vars.add(a.value[1]);
if (NPC_OPS.has(ins.op) && ins.args[0]?.kind === "str" && ins.args[0].value && ins.args[0].value.toLowerCase() !== "hero") npcs.add(ins.args[0].value.toLowerCase());
if (VAR_WRITES.has(ins.op) && ins.args[0]?.kind === "str") vars.add(ins.args[0].value);
}
}
vars.delete("");
// Variables many quests use (region moods, "goodies") are only named.
const shared = [];
for (const v of vars) {
const users = new Set(labels.filter((l) => l.quest !== id && l.instructions.some((ins) => strings(ins).includes(v))).map((l) => l.quest));
if (users.size > 2) {
vars.delete(v);
shared.push(`${v} (${users.size} quests)`);
}
}
console.log(` -- references from other labels (NPCs ${[...npcs].join(" ") || "-"}; variables ${[...vars].join(" ") || "-"}; shared ${shared.join(" ") || "-"})`);
for (const l of labels) {
if (l.quest === id) continue;
const hits = l.instructions.filter((ins) =>
strings(ins).some((s) => names.has(s) || (vars.has(s) && (VAR_WRITES.has(ins.op) || ins.op === 0x3a || ins.op === 0x42))) ||
(NPC_OPS.has(ins.op) && npcs.has(String(ins.args[0]?.value).toLowerCase())) ||
((ins.op === 0x14 || ins.op === 0x0a || ins.op === 0x0b || ins.op === 0x0c || ins.op === 0x0d || ins.op === 0x4d || ins.op === 0x75) &&
ins.args.some((a) => a.kind === "int" && a.value === id)));
if (hits.length) {
console.log(` ${l.name} [quest ${l.quest}]`);
for (const ins of hits) console.log(` ${formatInstruction(ins)}`);
}
}
}
// Where a label is used: "<op>:<tag> in <label>" for every argument naming it
// (dialog buttons, death labels, CallFunktion, timers, triggers, ...).
function labelUses(labels, name) {
const uses = [];
for (const l of labels) {
for (const ins of l.instructions) {
for (const a of ins.args) {
if (a.kind === "str" && a.value === name && !(ins.op === 0x01 && a.tag === 0x01)) {
uses.push(`${OPS[ins.op] ?? hex(ins.op)}${a.tag === 0x05 ? "(death of " + ins.args[0]?.value + ")" : ""} in ${l.name}`);
}
}
}
}
return uses;
}
// Compact facts for the audit: giver, how the quest ends or fails, NPC deaths.
function printFacts({ labels, quests }, id) {
const own = labels.filter((l) => l.quest === id);
const ownNames = new Set(own.map((l) => l.name));
console.log(`== ${id} ${quests.get(id) ?? "(not in the quest table)"} (${own.length} labels)`);
// Givers: NPCs whose dialog has a button starting the quest.
const accepts = new Set(own.filter((l) => l.instructions[0]?.op === 0x14).map((l) => l.name));
const giverDialogs = own.filter((l) => l.name.startsWith("Dialog:") &&
l.instructions.some((ins) => ins.op === 0x3c && accepts.has(ins.args[1]?.value))).map((l) => l.name.slice(7));
for (const l of labels) {
for (const ins of l.instructions) {
if ((ins.op === 0x01 || ins.op === 0x03) && ins.args.some((a) => a.tag === 0x09 && giverDialogs.includes(a.value))) {
console.log(` giver ${ins.args[0]?.value} (${OPS[ins.op]} in ${l.name}${l.quest === id ? ", own label" : ""}): ${formatInstruction(ins)}`);
}
}
}
if (!giverDialogs.length) console.log(" giver: none found (accept labels: " + ([...accepts].join(" ") || "-") + ")");
for (const l of own) {
const ends = l.instructions.filter((ins) => ins.op === 0x0f || ins.op === 0x36).map((ins) => OPS[ins.op]);
const morph = l.instructions.filter((ins) => ins.op === 0x4f || ins.op === 0x37 || ins.op === 0x2e || ins.op === 0x38).map(formatInstruction);
if (!ends.length && !morph.length) continue;
const uses = labelUses(labels, l.name).filter((u) => !u.endsWith(` in ${l.name}`));
console.log(` ${l.name}: ${[...ends, ...morph].join("; ")} <- ${uses.join("; ") || (l.name.startsWith("QIS_") ? "quest end script" : "?")}`);
}
// Death labels anywhere that run this quest's labels.
for (const l of labels) {
for (const ins of l.instructions) {
const death = ins.args.find((a) => a.tag === 0x05 && ownNames.has(a.value));
if (death) console.log(` death ${ins.args[0]?.value} -> ${death.value} (${OPS[ins.op]} in ${l.name})`);
}
}
}
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop());
if (isMain) {
const [dir, ...rest] = process.argv.slice(2);
if (!dir || !rest.length) {
console.error("usage: node questscript.mjs <script dir> <quest id...> | --list | --facts [ids] | --label <name> | --grep <regex>");
process.exit(1);
}
const script = load(dir);
if (rest[0] === "--list") {
for (const [id, name] of script.quests) {
console.log(`${id}\t${script.labels.filter((l) => l.quest === id).length}\t${name}`);
}
} else if (rest[0] === "--facts") {
const ids = rest.length > 1 ? rest.slice(1).map(Number) : [...script.quests.keys()];
for (const id of ids) printFacts(script, id);
} else if (rest[0] === "--grep") {
// Instructions matching a regular expression, in all labels.
const re = new RegExp(rest[1]);
for (const l of script.labels) {
for (const ins of l.instructions) {
const text = formatInstruction(ins);
if (re.test(text)) console.log(`${l.name} [quest ${l.quest}]: ${text}`);
}
}
} else if (rest[0] === "--label") {
for (const l of script.labels.filter((l) => l.name === rest[1])) printLabel(l, "");
} else {
for (const id of rest) printQuest(script, Number(id));
}
}