Compare commits

...
10 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
29 changed files with 3027 additions and 315 deletions

No files matched your search

+3
View File
@@ -76,12 +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/followers.cpp
src/mods/questfail.cpp src/mods/questfail.cpp
src/mods/questgiver.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
) )
+96 -1
View File
@@ -1,4 +1,4 @@
# Dwarf arts: Kanonenschuss and Mörsergranate # Dwarf arts: Kanonenschuss, Mörsergranate, Eingraben
## Requests (2026-10-25 / 2026-10-26) ## Requests (2026-10-25 / 2026-10-26)
@@ -46,3 +46,98 @@
- The impact's gather call gets a radius of size × (1 + 0.1 × (level / 5)), - 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 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). 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).
+74 -3
View File
@@ -48,6 +48,12 @@ 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 hero enters the quest's region (`Quest_Trigger`). A restarted quest is now
"available": flags 1. "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 Marker back to the giver (request 2026-10-25: "quests that failed, should
have the blue marker point back to the quest giver"): have the blue marker point back to the quest giver"):
@@ -196,7 +202,8 @@ Dialogs without a quest (healers, closed shops: `geschlossen101`,
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named 4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f (0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
compass, 0x44 set variable, 0x48 move NPC, 0x56 dialog state. 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 - 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 quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
the entry. the entry.
@@ -227,8 +234,8 @@ load we need a repair ... if not completed or correctly running".
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction `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 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 label of a running quest runs for its dead giver, it is skipped, the quest
restarts (failed entry at the giver, the variables only its labels set back to restarts (failed entry at the giver, the variables only its labels set back:
0), and once the body is gone the spawn instruction runs again. After a load, 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 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 the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
audit of how quests can fail. audit of how quests can fail.
@@ -236,3 +243,67 @@ audit of how quests can fail.
Lesson (2026-10-26): flag 1 means available, not running; a first version 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 took it for running and reset 16 available quests and spawned their NPCs on
Borf (repaired once, then saved). 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.
+18 -9
View File
@@ -42,15 +42,6 @@ Known so far: the periodic-hit aura (effect kind 0x12) uses
mapped yet. Other arts' ranges are not mapped. Fits the data-driven arts mapped yet. Other arts' ranges are not mapped. Fits the data-driven arts
item above (range as a property of an art, one place to compute it). item above (range as a property of an art, one place to compute it).
Mörsergranate impact indicator (asked 2026-10-25, while reworking the dwarf's
Kanonenschuss / Mörsergranate): show exactly where the grenade will land and
its explosion radius, a red circle with low alpha on the ground. The landing
point seems to be decided at launch (cCreature combat move code `0x5145d0`,
case 0x43e: the target point, changed by a random offset when closer than
200; passed as the position of the grenade's cEventFX 0x96), so the circle
could be drawn from the launch until the explosion; to be confirmed with the
diagnostic log. To be built after the radius-per-level change.
## Rename life leech to "omnivamp" ## Rename life leech to "omnivamp"
Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes
@@ -275,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.
+1
View File
@@ -88,6 +88,7 @@ constexpr uint16_t kArtEnergieblitze = 0x3fb;
constexpr uint16_t kArtFlammenwerfer = 0x43d; constexpr uint16_t kArtFlammenwerfer = 0x43d;
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser) constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone) 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
+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.
+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);
+23 -5
View File
@@ -63,6 +63,11 @@ 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;
@@ -97,16 +102,29 @@ inline void AddFollower(void* leader, const FollowerEntry& entry) {
reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry); reinterpret_cast<Fn>(0x0054b070)(leader, nullptr, &entry);
} }
inline bool HasFollower(const void* leader, uint32_t id) { // 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* creature = static_cast<const uint8_t*>(leader);
const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin); const auto* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd); const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) { for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
if (*reinterpret_cast<const uint32_t*>(entry) == id) { fn(*reinterpret_cast<const uint32_t*>(entry));
return true;
}
} }
return false; }
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,
+60 -3
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 {
@@ -76,13 +77,50 @@ inline uint32_t ScriptLabelCodeSize(const uint8_t* label) {
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel // args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
// (0x46bd80) reads a label's code and runs it instruction by instruction with // (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, // 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
// position, 0x09 + its dialog, ...; e.g. Region15Init), 0x44 sets a script // position, 0x09 + its dialog, ...; e.g. Region15Init). Script variables are
// variable ({0x01 name, 0x0b value}). // 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; constexpr uint16_t kScriptSpawn = 0x01;
constexpr uint16_t kScriptSetVariable = 0x44; // 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 kScriptArgString = 0x01;
constexpr uint8_t kScriptArgInt = 0x0b; constexpr uint8_t kScriptArgInt = 0x0b;
constexpr uint8_t kScriptArgDialog = 0x09; 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*. // CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); } inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
@@ -137,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;
} }
+480 -5
View File
@@ -24,16 +24,58 @@
// plus the creature type's size (object type table 0x428bb0). The mod passes // 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 // that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the
// level of the owner's Mörsergranate. // 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 <windows.h>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <algorithm>
#include <cmath>
#include <map> #include <map>
#include <vector>
#include "game/engine/art.h"
#include "game/engine/arts.h" #include "game/engine/arts.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/engine/worldview.h"
#include "game/ui/draw.h"
#include "mods/widescreen.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"
@@ -42,6 +84,7 @@ namespace mods::dwarfarts {
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type, 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); uint32_t only, int32_t radius, const uint8_t* fx);
void DrawMarkers(const uint8_t* view);
namespace { namespace {
@@ -81,6 +124,81 @@ int32_t TypeSize(uint32_t type) {
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, 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), // 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
// ret 0x14) with the effect object in ebx. // ret 0x14) with the effect object in ebx.
__declspec(naked) void GatherTargetsThunk() { __declspec(naked) void GatherTargetsThunk() {
@@ -168,7 +286,307 @@ __declspec(naked) void CreateImpactThunk() {
} }
} }
// --- 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() { 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)); const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)"); re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
uint8_t call[sizeof(kPushStepCode)]; uint8_t call[sizeof(kPushStepCode)];
@@ -185,25 +603,82 @@ void Startup() {
const bool radius = const bool radius =
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk)); re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)"); 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); RE_STARTUP(DwarfArtsStartup, Startup);
struct Registration { struct Registration {
Registration() { game::OnCreatureOrder(&OnOrder); } Registration() {
game::OnCreatureOrder(&OnOrder);
game::FilterCreatureOrders(&AcceptOrder);
game::OnGameEnd(&OnGameEnd);
game::OnEngineTick(&OnTick);
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
}
} g_registration; } g_registration;
} // namespace } // 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* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
uint32_t only, int32_t radius, const uint8_t* fx) { uint32_t only, int32_t radius, const uint8_t* fx) {
if (type == kGrenadeImpactType && radius < 0 && fx) { if (type == kGrenadeImpactType && radius < 0 && fx) {
void* owner = game::GetCreature(*reinterpret_cast<const uint32_t*>(fx + kFxOwner)); const uint32_t owner = *reinterpret_cast<const uint32_t*>(fx + kFxOwner);
const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0; radius = ImpactRadius(game::GetCreature(owner));
const int32_t size = TypeSize(type); // The position: area (u16), x, y (int32, world units), level.
radius = static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep))); 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); 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); 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 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
+65 -10
View File
@@ -1,15 +1,30 @@
// Escorted NPCs stay on the hero's follower list (2026-10-26, docs/quests.md). // Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
// "Furchtbares Verbrechen" (quest 9008) did not go on at its target point: //
// the NPC walked after the hero, but the script's test there ("@npc", 0x498aa0 // On the follower list: "Furchtbares Verbrechen" (quest 9008) did not go on at
// case 0x40) looks the NPC up in the hero's follower list (hero +0x39c), and // its target point: the NPC walked after the hero, but the script's test
// that list was empty (measured on Borf's save: NPC 0x1fa with leader 1 and // there ("@npc", 0x498aa0 case 0x40) looks the NPC up in the hero's follower
// the follow bit, no followers on the hero). The NPC keeps its side of the // list (hero +0x39c), and that list was empty (measured on Borf's save: NPC
// escort (leader +0x251, status bit 4) across a load; the hero's list does // 0x1fa with leader 1 and the follow bit, no followers on the hero). The NPC
// not come back. The mod adds every NPC that follows a hero but is missing // keeps its side of the escort (leader +0x251, status bit 4) across a load;
// from its list back with the game's own AddFollower (0x54b070), with the // the hero's list does not come back. The mod adds every NPC that follows a
// entry the follow order uses ({id, 1, 1}), once after loading. // 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 <cstdint>
#include <cstdlib>
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
@@ -20,6 +35,8 @@ namespace mods::followers {
namespace { namespace {
constexpr int kTicksAfterStart = 30; 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 // 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, // the hero) without joining his list (measured 2026-10-26: types 0x2be,
@@ -27,11 +44,20 @@ constexpr int kTicksAfterStart = 30;
constexpr int32_t kStatusTagAlong = 0x400; constexpr int32_t kStatusTagAlong = 0x400;
int g_ticksToCheck = -1; int g_ticksToCheck = -1;
DWORD g_lastCheck = 0;
int32_t Field(const void* creature, size_t offset) { int32_t Field(const void* creature, size_t offset) {
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + 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() { void RepairFollowers() {
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) { for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) {
if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) { if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) {
@@ -52,10 +78,39 @@ void RepairFollowers() {
} }
} }
// 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*) { void OnTick(uint8_t*) {
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) { if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
RepairFollowers(); RepairFollowers();
} }
const DWORD now = GetTickCount();
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
g_lastCheck = now;
BringAlong();
}
} }
void OnStart() { void OnStart() {
+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
+7 -21
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 {
+183 -12
View File
@@ -22,12 +22,24 @@
// compass on the giver, or is removed if the giver is not known. Accepting it // 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 // again removes the failed entry. Quests that failed before the mod are reset
// the same way once after loading. // 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 <map>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -37,6 +49,7 @@
#include "game/engine/quests.h" #include "game/engine/quests.h"
#include "mods/inventory_save.h" #include "mods/inventory_save.h"
#include "mods/questfail.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"
@@ -63,7 +76,10 @@ bool g_resetAfterLoad = false;
bool g_inDialogAnswer = false; bool g_inDialogAnswer = false;
// Quest id -> the giver's map cell. // Quest id -> the giver's map cell.
std::map<int32_t, std::pair<int32_t, int32_t>> g_givers; std::map<int32_t, std::pair<int32_t, int32_t>> g_giverCells;
// Quest id -> its variables when it became available.
std::map<int32_t, std::map<std::string, int32_t>> g_freshVariables;
// The nearest living, not hostile, non-hero creature near the hero, or 0. All // The nearest living, not hostile, non-hero creature near the hero, or 0. All
// objects are searched (the nearby-creature list 0xad1c60 did not hold the // objects are searched (the nearby-creature list 0xad1c60 did not hold the
@@ -105,15 +121,15 @@ void RememberGiver(int32_t questId) {
const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) + const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) +
game::kObjectPosition); game::kObjectPosition);
const game::MapPosition cell = game::CellAt(creature, position->x, position->y); const game::MapPosition cell = game::CellAt(creature, position->x, position->y);
g_givers[questId] = {cell.x, cell.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); 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 entry's compass on its quest's giver (a cell target, no radius). False if
// the giver is not known. // the giver is not known.
bool PointAtGiver(uint8_t* entry) { bool PointAtGiver(uint8_t* entry) {
const auto giver = g_givers.find(game::BookQuestId(entry)); const auto giver = g_giverCells.find(game::BookQuestId(entry));
if (giver == g_givers.end()) { if (giver == g_giverCells.end()) {
return false; return false;
} }
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); }; auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
@@ -123,6 +139,63 @@ bool PointAtGiver(uint8_t* entry) {
return true; 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);
@@ -130,10 +203,19 @@ 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;
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); Restart(index);
i = std::min(i, game::BookCount());
} }
i = std::min(i, game::BookCount());
} }
} }
@@ -160,11 +242,11 @@ void OnLoaded() {
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------ // --- saved with the hero: {"quest id": [x, y]} ------------------------------------
nlohmann::json WriteGivers(unsigned) { nlohmann::json WriteGivers(unsigned) {
if (g_givers.empty()) { if (g_giverCells.empty()) {
return nlohmann::json(); return nlohmann::json();
} }
nlohmann::json all = nlohmann::json::object(); nlohmann::json all = nlohmann::json::object();
for (const auto& [id, cell] : g_givers) { for (const auto& [id, cell] : g_giverCells) {
all[std::to_string(id)] = {cell.first, cell.second}; all[std::to_string(id)] = {cell.first, cell.second};
} }
return all; return all;
@@ -177,7 +259,41 @@ void ReadGivers(unsigned, const nlohmann::json& data) {
} }
for (const auto& [key, cell] : data.items()) { for (const auto& [key, cell] : data.items()) {
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) { if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
g_givers[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()}; 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>();
}
} }
} }
} }
@@ -197,6 +313,31 @@ void Restart(int index) {
game::EraseBookEntry(i); 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):
@@ -204,12 +345,41 @@ void Restart(int index) {
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) {
Restart(index); 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);
}
} }
} }
// 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 // cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
// quest script for the answer. // quest script for the answer.
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer); void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer);
@@ -244,7 +414,7 @@ uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t
if (index < game::BookCount() && (kind == 0 || kind == 1)) { if (index < game::BookCount() && (kind == 0 || kind == 1)) {
const uint8_t* entry = game::BookEntry(index); const uint8_t* entry = game::BookEntry(index);
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide && if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
g_givers.count(game::BookQuestId(entry))) { g_giverCells.count(game::BookQuestId(entry))) {
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2; return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
} }
} }
@@ -257,6 +427,7 @@ struct Register {
game::OnEngineTick(&OnTick); game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded); game::OnSaveGameLoaded(&OnLoaded);
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers); inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables);
} }
} g_register; } g_register;
} // namespace } // namespace
+43 -246
View File
@@ -5,238 +5,48 @@
// completed or correctly running"). // completed or correctly running").
// //
// Measured: the quest's script (FunkCode.bin) gives the giver a death label // Measured: the quest's script (FunkCode.bin) gives the giver a death label
// when she starts following ("ex1501": quest variable "1501" = 1, then the // 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 // 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 // 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 // body is removed. The giver is placed by her region's start script
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at // (Region15Init: spawn instruction 1 "Res:17013", type 134, at
// pos_auftrag1501, dialog auftrag1501), which the game runs once. // pos_auftrag1501, dialog auftrag1501), which the game runs once.
// //
// The mod finds each quest's giver in the script code: the NPC whose spawn // The giver is the NPC whose spawn instruction carries the quest's accept
// instruction carries a dialog that has a "Dialog:<name>" label of the quest. // dialog (questscript.cpp). When a label of that quest runs for its dead giver
// When a label of that quest runs for its dead giver (her death label), the // (her death label), the label is skipped and the quest restarts
// label is skipped; the quest restarts (questfail::Restart, its book entry // (questfail::Restart: available again, its book entry failed and pointing at
// failed and pointing at the giver), the variables only this quest's labels // the giver, its variables as before, the giver spawned again once the body is
// set go back to 0, and once the body is gone the game runs the giver's // gone). After a load, a quest in progress whose giver no longer exists is
// spawn instruction again: she stands at her start, offering the quest. // repaired the same way, unless the quest itself turns the giver into an enemy
// After a load, a quest in progress whose giver no longer exists is repaired // or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose).
// the same way.
#include <cstdint> #include <cstdint>
#include <cstdlib>
#include <cstring>
#include <map>
#include <set>
#include <string>
#include <vector>
#include <windows.h>
#include "game/engine/events.h" #include "game/engine/events.h"
#include "game/engine/objects.h" #include "game/engine/objects.h"
#include "game/engine/orders.h" #include "game/engine/orders.h"
#include "game/engine/quests.h" #include "game/engine/quests.h"
#include "mods/questfail.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::questgiver { namespace mods::questgiver {
namespace { namespace {
constexpr size_t kInstructionHeader = 4;
constexpr size_t kMaxInstruction = 0x800; // 0x475970 refuses larger ones
constexpr size_t kReadChunk = 1 << 20;
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
constexpr uint16_t kScriptStartQuest = 0x14; // {0x0b quest id}
constexpr size_t kStartQuestSize = 9;
constexpr uint16_t kScriptAnswer = 0x3c; // dialog answer {0x01 text, 0x01 label}
struct Giver {
uint32_t scriptId = 0;
std::vector<uint8_t> spawn; // the spawn instruction
};
struct Pending {
int questIndex;
void* body; // the dead giver's object
uint32_t bodyId;
DWORD since;
};
bool g_scanned = false;
std::vector<uint8_t> g_code; // FunkCode.bin
std::map<int32_t, Giver> g_givers; // quest id -> giver
std::map<int32_t, std::vector<std::string>> g_variables; // quest id -> variables only it sets
std::vector<Pending> g_pending;
bool g_repairAfterLoad = false; bool g_repairAfterLoad = false;
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); } // The quest's giver if M2 can bring her back: a numbered script id spawned with
// the accept dialog.
bool ReadCode() { const questscript::Giver* RespawnableGiver(int32_t questId) {
g_code.clear(); const questscript::Quest* quest = questscript::Find(questId);
for (uint32_t offset = 0;; offset += kReadChunk) { return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver
g_code.resize(offset + kReadChunk); : nullptr;
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
g_code.resize(offset + read);
if (read < kReadChunk) {
return !g_code.empty();
}
}
} }
// Calls `fn(at, opcode, size)` for each instruction of a label's code. // Restarts quest `index` (questfail::Restart, which also brings the giver
template <typename Fn> // back); its book entry is failed.
void ForEachInstruction(const uint8_t* label, Fn fn) {
const size_t begin = game::ScriptLabelCode(label);
const size_t end = begin + game::ScriptLabelCodeSize(label);
for (size_t at = begin; at + kInstructionHeader <= end && end <= g_code.size();) {
const uint16_t size = U16(at + 2);
if (size < kInstructionHeader || at + size > end) {
return;
}
fn(at, U16(at), size);
at += size;
}
}
// The spawn instructions in the code: "Res:<number>" with a dialog.
void ScanSpawns(const std::map<std::string, int32_t>& dialogs) {
static const char kRes[] = "res:";
for (size_t at = 0; at + 9 < g_code.size(); ++at) {
if (U16(at) != game::kScriptSpawn || g_code[at + 4] != game::kScriptArgString ||
_strnicmp(reinterpret_cast<const char*>(&g_code[at + 5]), kRes, 4) != 0) {
continue;
}
const uint16_t size = U16(at + 2);
if (size <= 9 || size > kMaxInstruction || at + size > g_code.size()) {
continue;
}
char* numberEnd = nullptr;
const unsigned long scriptId = std::strtoul(reinterpret_cast<const char*>(&g_code[at + 9]), &numberEnd, 10);
if (scriptId == 0 || *numberEnd != 0) {
continue;
}
for (size_t tag = at + 9; tag + 1 < at + size; ++tag) {
if (g_code[tag] != game::kScriptArgDialog) {
continue;
}
const char* name = reinterpret_cast<const char*>(&g_code[tag + 1]);
if (!memchr(name, 0, at + size - tag - 1)) {
break;
}
const auto dialog = dialogs.find(name);
if (dialog != dialogs.end() && !g_givers.count(dialog->second)) {
g_givers[dialog->second] = {static_cast<uint32_t>(scriptId),
std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size)};
}
}
}
}
// The variables each quest's labels set; a variable set by several quests
// (or by labels of none) is not any one quest's.
void ScanVariables() {
std::map<std::string, std::set<int32_t>> setters;
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
const int32_t quest = game::ScriptLabelQuest(label);
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
if (opcode == game::kScriptSetVariable && g_code[at + 4] == game::kScriptArgString &&
memchr(&g_code[at + 5], 0, size - 5)) {
setters[reinterpret_cast<const char*>(&g_code[at + 5])].insert(quest);
}
});
}
for (const auto& [name, quests] : setters) {
if (quests.size() == 1 && *quests.begin() >= 0 && g_givers.count(*quests.begin())) {
g_variables[*quests.begin()].push_back(name);
}
}
}
void Scan() {
if (g_scanned) {
return;
}
g_scanned = true;
if (!ReadCode()) {
re::Log("questgiver: script code not readable");
return;
}
// Labels by name, and the accept labels: code starting with opcode 0x14
// (start quest #id, 0x48f320; e.g. "trigger1501").
std::map<std::string, const uint8_t*> byName;
std::map<std::string, int32_t> accepts; // label name -> quest it starts
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
const std::string name(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
byName.emplace(name, label);
const size_t at = game::ScriptLabelCode(label);
if (game::ScriptLabelCodeSize(label) >= kStartQuestSize && at + kStartQuestSize <= g_code.size() &&
U16(at) == kScriptStartQuest && g_code[at + 4] == game::kScriptArgInt) {
int32_t quest;
std::memcpy(&quest, &g_code[at + 5], sizeof(quest));
accepts.emplace(name, quest);
}
}
// The givers' dialogs: "Dialog:<name>" labels with an answer (opcode
// 0x3c, {0x01 text, 0x01 label}) that accepts their quest.
std::map<std::string, int32_t> dialogs; // dialog name -> quest
static const char kDialog[] = "Dialog:";
for (const auto& [name, label] : byName) {
const int32_t quest = game::ScriptLabelQuest(label);
if (quest < 0 || name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
continue;
}
bool offers = false;
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
if (opcode != kScriptAnswer || g_code[at + 4] != game::kScriptArgString) {
return;
}
const char* text = reinterpret_cast<const char*>(&g_code[at + 5]);
const size_t textEnd = 5 + strnlen(text, size - 5) + 1;
if (textEnd + 1 < size && g_code[at + textEnd] == game::kScriptArgString) {
const auto accept = accepts.find(reinterpret_cast<const char*>(&g_code[at + textEnd + 1]));
offers = offers || (accept != accepts.end() && accept->second == quest);
}
});
if (offers) {
dialogs.emplace(name.substr(sizeof(kDialog) - 1), quest);
}
}
ScanSpawns(dialogs);
ScanVariables();
re::Log("questgiver: %u quest givers in %u bytes of script code", static_cast<unsigned>(g_givers.size()),
static_cast<unsigned>(g_code.size()));
}
// The living giver of `giver`, or null.
void* LivingGiver(const Giver& giver) {
const uint32_t id = game::ScriptObjectId(giver.scriptId);
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
return creature && game::ObjectScriptId(creature) == giver.scriptId && game::IsAlive(creature) ? creature
: nullptr;
}
void ResetVariables(int32_t questId) {
for (const std::string& name : g_variables[questId]) {
std::vector<uint8_t> code(kInstructionHeader);
code.push_back(game::kScriptArgString);
code.insert(code.end(), name.begin(), name.end());
code.push_back(0);
code.push_back(game::kScriptArgInt);
code.insert(code.end(), 4, 0);
code[0] = static_cast<uint8_t>(game::kScriptSetVariable);
code[1] = 0;
code[2] = static_cast<uint8_t>(code.size());
code[3] = static_cast<uint8_t>(code.size() >> 8);
game::RunScriptInstruction(code.data());
re::Log("questgiver: quest %d variable '%s' = 0", questId, name.c_str());
}
}
// Restarts quest `index`: flags (available again), its book entry (failed,
// at the giver) and its variables.
void ResetQuest(int index) { void ResetQuest(int index) {
const int32_t id = game::QuestId(game::Quest(index)); const int32_t id = game::QuestId(game::Quest(index));
questfail::Restart(index); questfail::Restart(index);
@@ -246,16 +56,6 @@ void ResetQuest(int index) {
*reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed; *reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed;
} }
} }
ResetVariables(id);
}
void Spawn(int index) {
const int32_t id = game::QuestId(game::Quest(index));
const Giver& giver = g_givers[id];
const int result = game::RunScriptInstruction(giver.spawn.data());
void* creature = LivingGiver(giver);
re::Log("questgiver: quest %d giver Res:%u spawned again (%d): %s", id, giver.scriptId, result,
creature ? "back" : "NOT FOUND");
} }
// Accepted and not done (kQuestTriggered alone: available, set when the hero // Accepted and not done (kQuestTriggered alone: available, set when the hero
@@ -265,40 +65,30 @@ bool IsRunning(int index) {
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone); 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() { void RepairAfterLoad() {
for (const auto& [id, giver] : g_givers) { for (int index = 0, count = static_cast<int>(game::QuestCount()); index < count; ++index) {
const int index = game::QuestIndex(id); const int32_t id = game::QuestId(game::Quest(index));
if (index < 0 || !IsRunning(index) || LivingGiver(giver)) { 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; continue;
} }
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id, re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
game::QuestName(game::Quest(index)), giver.scriptId); game::QuestName(game::Quest(index)), giver->scriptId);
ResetQuest(index); ResetQuest(index);
Spawn(index);
} }
} }
void OnTick(uint8_t*) { void OnTick(uint8_t*) {
if (g_repairAfterLoad) { if (g_repairAfterLoad) {
g_repairAfterLoad = false; g_repairAfterLoad = false;
Scan();
RepairAfterLoad(); RepairAfterLoad();
} }
for (auto it = g_pending.begin(); it != g_pending.end();) {
const bool gone = game::GetObjectById(it->bodyId) != it->body;
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
++it;
continue;
}
Spawn(it->questIndex);
it = g_pending.erase(it);
}
} }
void OnLoaded() { void OnLoaded() { g_repairAfterLoad = true; }
g_repairAfterLoad = true;
g_pending.clear();
}
struct Registration { struct Registration {
Registration() { Registration() {
@@ -311,25 +101,32 @@ struct Registration {
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label // 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 // of a running quest run for its dead giver (her death label) is skipped and
// the quest restarted. // 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); uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
RE_HOOK(0x0046bd80, Script_RunLabel); RE_HOOK(0x0046bd80, Script_RunLabel);
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) { uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
const questscript::LabelScope running(label);
const uint8_t* labels = game::ScriptLabelsBegin(); const uint8_t* labels = game::ScriptLabelsBegin();
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) { if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize); const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
const int index = questId >= 0 ? game::QuestIndex(questId) : -1; const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr; void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
if (creature && IsRunning(index) && !game::IsAlive(creature)) { if (creature && IsRunning(index) && !game::IsAlive(creature)) {
Scan(); const questscript::Giver* giver = RespawnableGiver(questId);
const auto giver = g_givers.find(questId); if (giver && game::ObjectScriptId(creature) == giver->scriptId) {
if (giver != g_givers.end() && game::ObjectScriptId(creature) == giver->second.scriptId) { const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize);
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", if (game::IsHostileTo(creature, game::GetPlayerHero())) {
giver->second.scriptId, questId, re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId,
game::ScriptLabelName(labels + label * game::kScriptLabelSize)); 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); ResetQuest(index);
const uint32_t bodyId = *reinterpret_cast<uint32_t*>(object + game::kCreatureId);
g_pending.push_back({index, game::GetObjectById(bodyId), bodyId, GetTickCount()});
return 0; return 0;
} }
} }
+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)
+24
View File
@@ -431,3 +431,27 @@
0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14 0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14
0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50 0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50
0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100 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));
}
}