Compare commits
10
Commits
7d8089c299
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13b82f040 | ||
|
|
7ec0fa2b8e | ||
|
|
471b8b13d5 | ||
|
|
198bbab6f9 | ||
|
|
55e48dd302 | ||
|
|
e539cab604 | ||
|
|
42b99526a4 | ||
|
|
91e7f844cc | ||
|
|
a4d5683403 | ||
|
|
32f953fc01 |
No files matched your search
@@ -76,12 +76,15 @@ add_library(sacredK SHARED
|
||||
src/mods/screen.cpp
|
||||
src/mods/characters.cpp
|
||||
src/mods/mainmenu.cpp
|
||||
src/mods/mapborder.cpp
|
||||
src/mods/mapreveal.cpp
|
||||
src/mods/questbook.cpp
|
||||
src/mods/followers.cpp
|
||||
src/mods/questfail.cpp
|
||||
src/mods/questgiver.cpp
|
||||
src/mods/questitems.cpp
|
||||
src/mods/questnpc.cpp
|
||||
src/mods/questscript.cpp
|
||||
src/mods/anticheat.cpp
|
||||
src/mods/loadingscreen.cpp
|
||||
)
|
||||
|
||||
+96
-1
@@ -1,4 +1,4 @@
|
||||
# Dwarf arts: Kanonenschuss and Mörsergranate
|
||||
# Dwarf arts: Kanonenschuss, Mörsergranate, Eingraben
|
||||
|
||||
## 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)),
|
||||
with the owner's Mörsergranate level (`game::ArtLevel`). The growth 0.1 per
|
||||
step is our choice; not yet seen in the game (Borf's level is 2).
|
||||
|
||||
## Mörsergranate impact marker (asked 2026-10-25, done 2026-10-26)
|
||||
|
||||
"Show exactly where the grenade will land and its explosion radius, a red
|
||||
circle with low alpha on the ground."
|
||||
|
||||
Measured:
|
||||
- The landing point is decided at launch (`0x5145d0` case 0x43e): the
|
||||
target's position, or, closer than 200, a point 200 from the dwarf with a
|
||||
+-15 jitter. It goes to the new grenade (type 0x3fa) in cEventFX 0x96,
|
||||
constructed at `0x5169fa` (`cEventFX::Construct` 0x41e0a0, thiscall, ret
|
||||
0x44: code, owner id, target id, position area / x / y / level, ...).
|
||||
- The impact gathers its targets at exactly that point, on several frames.
|
||||
The grenade object stays about 30 s after the explosion.
|
||||
- Ground -> screen: `0x622700` (world units -> world pixels, the isometric
|
||||
plane), then (pixel - camera +0x14/+0x18) / zoom +0xb60 + screen centre
|
||||
(floating texts 0x41e546). Checked with the hero: the screen centre.
|
||||
- The world view draws the ground (`0x62d530`, ground quad list), then the
|
||||
objects.
|
||||
|
||||
Mod: the construct call notes grenade, owner, landing point and radius
|
||||
(`ImpactRadius`, shared with the hit check); after `0x62d530` a disc (alpha
|
||||
0x40) with a 2 px rim (alpha 0xa0) is drawn there, without depth test, until
|
||||
the impact of that owner's grenade at that point gathers its targets.
|
||||
|
||||
Tooltip (2026-10-26, "the skills tooltip does not show the current radius"):
|
||||
a game::Art tooltip filter adds "Explosionsradius: N" after the last value
|
||||
line of each level section (current level, and the next level's in the skill
|
||||
book), in that line's colour, from the same `ImpactRadiusAt(level)` as the
|
||||
hit check and the marker.
|
||||
|
||||
## Grenade impact crash (2026-10-26)
|
||||
|
||||
Crashes at sacred.exe+0x361b54 (2026-10-26 00:12 and 22:39) in the particle
|
||||
drawer `0x7613e0`, called by `cParticleSystem_grenadeimpact::Render` (vf04
|
||||
0x7b66b0): colour = table +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and a
|
||||
particle had a life of over 500000. Guard (`mods/guards.cpp`): live particles
|
||||
with life >= 256 or row >= 3 are switched off before drawing. Logged in the
|
||||
next session: 5 particles, all row 3, life 255, garbage sizes (e.g. 190555,
|
||||
4.6e-40): the explosion emits into a fourth row whose emitter parameters it
|
||||
never sets (vf10 0x7b6980 fills three rows at +0x30d0). No crash since.
|
||||
|
||||
## Eingraben: the dwarf's escape (request 2026-10-28)
|
||||
|
||||
"I want it to be the Dwarfs option to Teleport/Escape. The Skill will cast a
|
||||
protective barrier around the Dwarf (current visual effect), clear him of all
|
||||
movement impending effects and then teleport to the position marked by the
|
||||
cursor (if possible). The Teleport is visually shown by the dwarf going
|
||||
underground and coming out again at target position." Range: the visible
|
||||
area; the barrier is the skill's current defensive bonus for 3 s; digging in
|
||||
and out 1 s each; "he should be able to see the target location", compared
|
||||
to the battlemage's Phasenverschiebung; the original lock in place goes.
|
||||
|
||||
Found in the code:
|
||||
- Spells run in `cCreature::CastSpell` (0x5222c0, thiscall (cast), ret 4;
|
||||
cast +0x08 / +0x0c the ground point, +0x20 the spell id). Eingraben is
|
||||
spell 0x39 (`gui_moves_eingraben`, launch type 5 around the caster).
|
||||
- Case 0x39 creates object type 0x480 at the caster with cEventFX 0x9d (owner
|
||||
and target the caster) and plays sound 0x840: the barrier, which follows
|
||||
the dwarf.
|
||||
- Case 1 (Phasenverschiebung, launch type 3) walks from the caster towards the
|
||||
target in steps of 10 while the cell is passable (`cWorld::IsPassable`
|
||||
0x636b60) and keeps the last point a creature can stand on
|
||||
(`cWorld::CanStand` 0x636eb0); a wall or cliff in the way ends the walk.
|
||||
|
||||
Logged (2026-10-28): the barrier adds, all with art 0x39, kind 0x3a value 0
|
||||
from its particles (0x7b9602), the lock in place (the dwarf was stuck 2-3 s),
|
||||
and kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
|
||||
the defensive bonus (resistances up while it lasts).
|
||||
|
||||
Mod (`mods/dwarfarts.cpp`): Eingraben's definition +0x3c becomes 3 (a point);
|
||||
after the original case the dwarf is teleported to the end of
|
||||
Phasenverschiebung's walk towards the point (the cursor keeps it within the
|
||||
visible area). The effect hook (`aurarecast.cpp`, `KeepEffect`) drops kind
|
||||
0x3a for art 0x39. Confirmed 2026-10-28: aimed, stops before walls and
|
||||
cliffs, casts at once at the screen edge, the barrier moves with him, no lock.
|
||||
|
||||
Duration ("more like 8 seconds as described in the tooltip, i want that to go
|
||||
down to 4 seconds"): an art's duration is 0x4d24f0 on its definition,
|
||||
+0x76 + level x +0x78, at most +0x82 when set (Eingraben: 7 + level, at most
|
||||
15). The barrier's lifetime (shield +0x20a8), its bonus (the effect applier,
|
||||
0x5b56ab) and the tooltip all use it; the mod sets 4 / 0 / 0.
|
||||
|
||||
Digging in and out: lowering the dwarf's height (creature +0xf0) moved only
|
||||
his marker; lowering the terrain height the game asks for him (0x449b20)
|
||||
moved the camera with him, so he never looked sunk. Instead ("change the
|
||||
barrier animation to turn from transparent to solid and back"): the barrier
|
||||
is `cParticleSystem_dwarf_shield` (constructor 0x7b8fc0, update vf08
|
||||
0x7b91a0, draw vf04 0x7b92b0; models fx_shield_phase1..4.grn). Its draw
|
||||
passes the model with alpha pulse x 128 (pulse 0.4..1 at +0x20b8; __ftol at
|
||||
0x7b9483), then again additively. The mod redirects that __ftol: during a dig
|
||||
the alpha goes to 255 over 1 s, the dwarf is moved (engine tick), and it goes
|
||||
back over 1 s. Orders to the dwarf are refused meanwhile
|
||||
(`game::FilterCreatureOrders`). Confirmed 2026-10-28, barrier and bonus 4 s.
|
||||
Open: clearing slows, roots and stuns.
|
||||
@@ -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
|
||||
(about 120 tiles by day). The view only lists creatures of the sectors around
|
||||
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.
|
||||
@@ -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
@@ -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
|
||||
"available": flags 1.
|
||||
|
||||
2026-10-30 (quest audit, `docs/quest-audit.md`): a restart also resets the
|
||||
quest's variables, deletes what its `QIS_OnEnter` created, brings back the
|
||||
giver and the NPCs it needs, and gives the giver the accept dialog again; some
|
||||
failures stay final (generic quests, no giver, story endings, NPCs gone for
|
||||
good). The script facts come from `mods/questscript.cpp`.
|
||||
|
||||
Marker back to the giver (request 2026-10-25: "quests that failed, should
|
||||
have the blue marker point back to the quest giver"):
|
||||
|
||||
@@ -196,7 +202,8 @@ Dialogs without a quest (healers, closed shops: `geschlossen101`,
|
||||
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
|
||||
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
|
||||
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
|
||||
compass, 0x44 set variable, 0x48 move NPC, 0x56 dialog state.
|
||||
compass, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 0x48 move NPC,
|
||||
0x56 dialog state.
|
||||
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
|
||||
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
|
||||
the entry.
|
||||
@@ -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
|
||||
carries a dialog with an answer that accepts the quest (85 givers). When a
|
||||
label of a running quest runs for its dead giver, it is skipped, the quest
|
||||
restarts (failed entry at the giver, the variables only its labels set back to
|
||||
0), and once the body is gone the spawn instruction runs again. After a load,
|
||||
restarts (failed entry at the giver, the variables only its labels set back:
|
||||
0, the one named after the quest 1), and once the body is gone the spawn instruction runs again. After a load,
|
||||
an accepted quest (flag 4) without its giver gets the same. Not yet seen in
|
||||
the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
|
||||
audit of how quests can fail.
|
||||
@@ -236,3 +243,67 @@ audit of how quests can fail.
|
||||
Lesson (2026-10-26): flag 1 means available, not running; a first version
|
||||
took it for running and reset 16 available quests and spawned their NPCs on
|
||||
Borf (repaired once, then saved).
|
||||
|
||||
Givers killed as enemies (2026-10-30): "Familienbande" (702, book title
|
||||
"Mädchen bei Ausritt verschwunden") was restarted twice when the hero killed
|
||||
its giver. The quest wants that: back at the giver with the girl
|
||||
(Res:17060), answer `change702` changes the creature type of the giver
|
||||
Res:17061 (668 -> 262) and two relatives (opcode 0x4f {"Res:<id>", 0x02
|
||||
type}), they attack, and their death label `tot702` sends the hero back to the
|
||||
girl (`ziel702`, `exit702` ends the quest). Now a giver hostile to the hero
|
||||
at death runs the label as the game has it, and the load repair skips quests
|
||||
whose labels change their giver's type (0x4f on the giver; 2 of the 85 known
|
||||
givers, 702 among them). Confirmed in game 2026-10-30.
|
||||
|
||||
## Quest variables are bit fields (measured 2026-10-28)
|
||||
|
||||
Found while repairing "Kräuter für Kräuterhexe" (402; the user knows it as
|
||||
Windgeflüster). Variables live in the quest manager's vector +0x7550 /
|
||||
+0x7554 (0x24-byte entries: name[0x20], value +0x20; `game::ScriptVariable`).
|
||||
Script writes: 0x43 sets the value, 0x44 sets bit n (0x49bb30: value |= 1 <<
|
||||
n), 0x45 clears bit n (0x49c450), 0x4b adds n (0x49cc20). Conditions
|
||||
(`Script_Condition` 0x498aa0): 0x49 bit n set, 0x4a bit n clear (logged "if
|
||||
IsNotVarBit"), 0x48 value == n; a missing variable is false. The variable
|
||||
named after a quest has bit 0 while the quest is available: all 87 quests not
|
||||
yet accepted in a dwarf save had value 1, and nothing in the quest's own labels
|
||||
sets it. Quest 402: accepting sets bit 1, the berries bit 2, the reward bit 3;
|
||||
the giver's dialog offers the quest on bit 0.
|
||||
|
||||
The quest giver restart first ran 0x44 with 0 ("variable = 0"), which set bit
|
||||
0 and cleared nothing: the giver kept saying "Zögerst du in deinen Taten?".
|
||||
A reset to 0 then matched no dialog branch at all. Now the value is written
|
||||
directly: 0, or 1 for the quest's own variable. Confirmed 2026-10-28.
|
||||
|
||||
## Main quest items refused (request 2026-10-28)
|
||||
|
||||
"I cannot finish the Quest Windgeflüster." The berries (type 6029) are an item
|
||||
`Region4Init` places once on the ground near the Goldbeerenbusch (op 0x5e,
|
||||
pickup label `take402`); picking it up logged "cInventoryEntry::addMainQuestItem()
|
||||
Already has mainQuestItem(6029)" and the item was gone.
|
||||
`cInventoryEntry::AddMainQuestItem` (0x5d8240, thiscall (type, script id,
|
||||
notify)) keeps {type, script id} pairs and refuses a new item when an entry
|
||||
has the same type or script id 0 (0x5d83c6); scripts that give an item
|
||||
without a "Res:" id add it with 0 (0x486bd8). The dwarf held {4609, 0}, so
|
||||
every later main quest item was refused (a bug of the original).
|
||||
`mods/questitems.cpp` removes the script id test. The save was repaired once
|
||||
by running `take402` for the hero after the load (quest accepted, bit 2
|
||||
missing); the quest then finished. Confirmed 2026-10-28.
|
||||
|
||||
## Escorts never left behind (2026-10-26)
|
||||
|
||||
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
|
||||
longer following me" (Borf). Measured in the log: the husband (Res:17584,
|
||||
type 86; spawned in `startcode.bin` with death label `drop2412`) fell behind,
|
||||
the game's own catch-up teleport moved him once (type 86 line), then the hero
|
||||
walked on, the sectors behind were switched off, and the game removed him
|
||||
and ran `drop2412` (quest variable 5, a token item res:17585 dropped where he
|
||||
was; picking it up runs `take2412`, the "husband lost" branch). His object id
|
||||
was reused right after: removed, not dead. The teleport potion came 50 s
|
||||
later and was not involved; portals and the potion take followers along.
|
||||
|
||||
`mods/followers.cpp`: every 500 ms each follower of the local hero more than
|
||||
40 cells away (sectors are switched off at 65 and more) on the same level is
|
||||
teleported to the hero with `cCreature::Teleport` 0x54d840 (thiscall (cell x,
|
||||
cell y, 0, 1), ret 0x10). Borf's husband was respawned once by a temporary
|
||||
repair (his spawn instruction, variable 1, token removed, `follow2412`); the
|
||||
quest then finished with him alive.
|
||||
+18
-9
@@ -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
|
||||
item above (range as a property of an art, one place to compute it).
|
||||
|
||||
Mörsergranate impact indicator (asked 2026-10-25, while reworking the dwarf's
|
||||
Kanonenschuss / Mörsergranate): show exactly where the grenade will land and
|
||||
its explosion radius, a red circle with low alpha on the ground. The landing
|
||||
point seems to be decided at launch (cCreature combat move code `0x5145d0`,
|
||||
case 0x43e: the target point, changed by a random offset when closer than
|
||||
200; passed as the position of the grenade's cEventFX 0x96), so the circle
|
||||
could be drawn from the launch until the explosion; to be confirmed with the
|
||||
diagnostic log. To be built after the radius-per-level change.
|
||||
|
||||
## Rename life leech to "omnivamp"
|
||||
|
||||
Asked 2026-10-15: life leech (property 0x349, "Lebensentzug") becomes
|
||||
@@ -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
|
||||
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
|
||||
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.
|
||||
@@ -88,6 +88,7 @@ constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
||||
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
||||
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
|
||||
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
|
||||
constexpr uint16_t kArtEingraben = 0x39; // spell (gui_moves_eingraben)
|
||||
|
||||
// cCreatureArts fields.
|
||||
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
||||
|
||||
@@ -14,6 +14,38 @@ namespace game {
|
||||
// The world's sector grid (ecx of World_SetExplored).
|
||||
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):
|
||||
// sets the bit of the tile's sector; tiles outside the world (>= 0x2000) or in
|
||||
// a sector that isn't loaded are ignored.
|
||||
|
||||
@@ -15,8 +15,17 @@ std::vector<CreatureOrderFn>& Callbacks() {
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
std::vector<CreatureOrderFilter>& Filters() {
|
||||
static std::vector<CreatureOrderFilter> filters;
|
||||
return filters;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void FilterCreatureOrders(CreatureOrderFilter filter) {
|
||||
Filters().push_back(filter);
|
||||
}
|
||||
|
||||
void OnCreatureOrder(CreatureOrderFn callback) {
|
||||
Callbacks().push_back(callback);
|
||||
}
|
||||
@@ -26,6 +35,11 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde
|
||||
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
|
||||
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
|
||||
if (self && order) {
|
||||
for (CreatureOrderFilter filter : Filters()) {
|
||||
if (!filter(self, *order)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
void* caller = _ReturnAddress();
|
||||
for (CreatureOrderFn callback : Callbacks()) {
|
||||
callback(self, *order, caller);
|
||||
|
||||
@@ -63,6 +63,11 @@ inline void* ControlledCreature(uint8_t* engine) {
|
||||
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
|
||||
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.
|
||||
constexpr size_t kCreatureId = 0xc;
|
||||
constexpr size_t kCreatureX = 0x1c;
|
||||
@@ -97,16 +102,29 @@ inline void AddFollower(void* leader, const FollowerEntry& 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* begin = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersBegin);
|
||||
const auto* end = *reinterpret_cast<const uint8_t* const*>(creature + kCreatureFollowersEnd);
|
||||
for (const uint8_t* entry = begin; entry && entry < end; entry += kFollowerEntrySize) {
|
||||
if (*reinterpret_cast<const uint32_t*>(entry) == id) {
|
||||
return true;
|
||||
}
|
||||
fn(*reinterpret_cast<const uint32_t*>(entry));
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
|
||||
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
|
||||
// (0x46bd80) reads a label's code and runs it instruction by instruction with
|
||||
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
|
||||
// position, 0x09 + its dialog, ...; e.g. Region15Init), 0x44 sets a script
|
||||
// variable ({0x01 name, 0x0b value}).
|
||||
// position, 0x09 + its dialog, ...; e.g. Region15Init). Script variables are
|
||||
// bit fields ({0x01 name, 0x0b n}; measured 2026-10-27): 0x43 sets the value,
|
||||
// 0x44 sets bit n (0x49bb30), 0x45 clears bit n (0x49c450), 0x4b adds n
|
||||
// (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value
|
||||
// (0x498aa0; a missing variable is false).
|
||||
constexpr uint16_t kScriptSpawn = 0x01;
|
||||
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 kScriptArgInt = 0x0b;
|
||||
constexpr uint8_t kScriptArgDialog = 0x09;
|
||||
constexpr uint8_t kScriptArgAttitude = 0x08; // flag, followed by 0x12 (hostile) or 0x14
|
||||
|
||||
// The generic quests (Quest_End copies them under a new id; docs/quests.md).
|
||||
constexpr int32_t kGenericQuestFirst = 2600;
|
||||
constexpr int32_t kGenericQuestLast = 3599;
|
||||
|
||||
// CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
|
||||
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
|
||||
@@ -137,6 +175,25 @@ inline uint8_t* QuestManager() { return reinterpret_cast<uint8_t*>(0x00aaaf00);
|
||||
constexpr size_t kBookBegin = 0x424;
|
||||
constexpr size_t kBookEnd = 0x428;
|
||||
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 kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
|
||||
constexpr size_t kBookState = 0x04;
|
||||
|
||||
@@ -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
|
||||
@@ -80,6 +80,22 @@ inline void FillCorners(void* device, float x0, float y0, float x1, float y1, ui
|
||||
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`.
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@
|
||||
#include "game/engine/fx.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "mods/dwarfarts.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.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);
|
||||
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) {
|
||||
if (!mods::dwarfarts::KeepEffect(kind, art)) {
|
||||
return 1;
|
||||
}
|
||||
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
+480
-5
@@ -24,16 +24,58 @@
|
||||
// plus the creature type's size (object type table 0x428bb0). The mod passes
|
||||
// that call a radius of size × (1 + kRadiusGrowth × (level / 5)), with the
|
||||
// level of the owner's Mörsergranate.
|
||||
//
|
||||
// Impact marker (asked 2026-10-25): where the grenade will land and its
|
||||
// explosion radius, a red circle with low alpha on the ground. The landing
|
||||
// point is decided at launch (0x5145d0 case 0x43e: the target's position, or
|
||||
// 200 units from the dwarf with +-15 jitter when closer) and sent to the new
|
||||
// grenade with cEventFX 0x96 (constructed at 0x5169fa: code, owner, 0,
|
||||
// position area / x / y / level). The construct call notes the grenade and
|
||||
// the point; the circle is drawn after the world view's ground (0x62d530,
|
||||
// before the objects) until the impact gathers its targets (the grenade
|
||||
// object itself stays about 30 s longer).
|
||||
//
|
||||
// Eingraben (spell 0x39), the dwarf's escape (request of 2026-10-28): "cast a
|
||||
// protective barrier around the Dwarf (current visual effect), clear him of
|
||||
// all movement impeding effects and then teleport to the position marked by
|
||||
// the cursor (if possible)", shown as the dwarf going underground and coming
|
||||
// out at the target, 1 s each; range the visible area; the barrier is the
|
||||
// skill's current defensive bonus for 3 s; "he should be able to see the
|
||||
// target location"; like the battlemage's Phasenverschiebung. The spells run
|
||||
// in cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4; cast +0x08 /
|
||||
// +0x0c the ground point, +0x20 the spell). Case 0x39 creates object type
|
||||
// 0x480 at the caster with cEventFX 0x9d (owner and target the caster) and
|
||||
// plays sound 0x840: the barrier, which gives the bonus. Case 1
|
||||
// (Phasenverschiebung) walks from the caster towards the target in steps of
|
||||
// 10 while the cell is passable (world 0x636b60) and keeps the last point a
|
||||
// creature can stand on (0x636eb0); a wall in the way ends the walk, so the
|
||||
// target has to be in sight. The mod aims Eingraben at a point (definition
|
||||
// +0x3c: 5 around the caster -> 3 a point), runs the original case (the barrier
|
||||
// and its bonus last kBarrierSeconds) and moves the dwarf to the end of that
|
||||
// walk; the cursor limits it to the visible area. The move takes 2 s without
|
||||
// orders: the barrier turns solid, he moves inside it, it clears again
|
||||
// ("not instant ... from transparent to solid and back", 2026-10-28; sinking
|
||||
// him into the ground failed: the camera follows his height).
|
||||
|
||||
#include "mods/dwarfarts.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
#include "game/engine/art.h"
|
||||
#include "game/engine/arts.h"
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/worldview.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
#include "re/patch.h"
|
||||
@@ -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 only, int32_t radius, const uint8_t* fx);
|
||||
void DrawMarkers(const uint8_t* view);
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -81,6 +124,81 @@ int32_t TypeSize(uint32_t type) {
|
||||
return reinterpret_cast<Fn>(0x00428bb0)(*reinterpret_cast<void**>(0x00aa9564), nullptr, type);
|
||||
}
|
||||
|
||||
// The explosion radius of a grenade thrown by `owner` (world units; a
|
||||
// creature is hit when within it plus its own size).
|
||||
int32_t ImpactRadiusAt(unsigned level) {
|
||||
const int32_t size = TypeSize(kGrenadeImpactType);
|
||||
return static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
|
||||
}
|
||||
|
||||
int32_t ImpactRadius(void* owner) {
|
||||
return ImpactRadiusAt(owner ? game::ArtLevel(owner, game::kArtMoerser) : 0);
|
||||
}
|
||||
|
||||
// The tooltip (game::Art::Tooltip filter, as for Kettenblitz in
|
||||
// mods/chainlightning.cpp): sections start with the level line ("Aktuelle
|
||||
// Stufe:", in the skill book also "Nächste erlernte Stufe:") followed by
|
||||
// "Name: value" lines; each section gets "Explosionsradius: N" after its last
|
||||
// value, in that line's colour.
|
||||
constexpr const wchar_t* kCurrentLevelLine = L"Aktuelle Stufe:";
|
||||
constexpr const wchar_t* kNextLevelLine = L"Nächste erlernte Stufe:";
|
||||
constexpr const wchar_t* kValueSeparator = L": ";
|
||||
constexpr size_t kColourCodeLength = 10; // "\cAARRGGBB"
|
||||
|
||||
std::wstring LeadingColourCodes(const std::wstring& line) {
|
||||
size_t end = 0;
|
||||
while (line.compare(end, 2, L"\\c") == 0 && end + kColourCodeLength <= line.size()) {
|
||||
end += kColourCodeLength;
|
||||
}
|
||||
return line.substr(0, end);
|
||||
}
|
||||
|
||||
void RewriteTooltip(const game::Art& art, void* creature, std::wstring& text) {
|
||||
std::vector<std::wstring> lines;
|
||||
for (size_t start = 0; start < text.size();) {
|
||||
size_t end = text.find(L'\n', start);
|
||||
end = end == std::wstring::npos ? text.size() : end + 1;
|
||||
lines.push_back(text.substr(start, end - start));
|
||||
start = end;
|
||||
}
|
||||
const unsigned current = game::ArtLevel(creature, art.Id());
|
||||
std::vector<std::wstring> out;
|
||||
unsigned level = current;
|
||||
bool inSection = false;
|
||||
size_t lastValue = SIZE_MAX; // index in `out`
|
||||
const auto closeSection = [&] {
|
||||
if (!inSection || lastValue == SIZE_MAX) {
|
||||
return;
|
||||
}
|
||||
std::wstring& after = out[lastValue];
|
||||
const bool last = after.empty() || after.back() != L'\n';
|
||||
if (last) {
|
||||
after += L'\n';
|
||||
}
|
||||
out.insert(out.begin() + static_cast<ptrdiff_t>(lastValue) + 1,
|
||||
LeadingColourCodes(after) + L"Explosionsradius: " + std::to_wstring(ImpactRadiusAt(level)) +
|
||||
(last ? L"" : L"\n"));
|
||||
};
|
||||
for (const std::wstring& line : lines) {
|
||||
const bool currentHeader = line.find(kCurrentLevelLine) != std::wstring::npos;
|
||||
const bool nextHeader = line.find(kNextLevelLine) != std::wstring::npos;
|
||||
if (currentHeader || nextHeader) {
|
||||
closeSection();
|
||||
inSection = true;
|
||||
level = nextHeader ? current + 1 : current;
|
||||
lastValue = SIZE_MAX;
|
||||
} else if (inSection && line.find(kValueSeparator) != std::wstring::npos) {
|
||||
lastValue = out.size();
|
||||
}
|
||||
out.push_back(line);
|
||||
}
|
||||
closeSection();
|
||||
text.clear();
|
||||
for (const std::wstring& line : out) {
|
||||
text += line;
|
||||
}
|
||||
}
|
||||
|
||||
// 0x5ab400 calls 0x5ff820 (thiscall (out, position, type, only, radius),
|
||||
// ret 0x14) with the effect object in ebx.
|
||||
__declspec(naked) void GatherTargetsThunk() {
|
||||
@@ -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() {
|
||||
const bool aim = AimEingraben();
|
||||
re::Log("dwarfarts: Eingraben aimed at a point, barrier %d s%s", kBarrierSeconds, aim ? "" : " (patch FAILED)");
|
||||
const bool fade = re::RedirectCall(kShieldAlphaToInt, kFloatToInt, reinterpret_cast<const void*>(&ShieldAlphaThunk));
|
||||
re::Log("dwarfarts: Eingraben barrier turns solid%s", fade ? "" : " (patch FAILED)");
|
||||
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
|
||||
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
|
||||
uint8_t call[sizeof(kPushStepCode)];
|
||||
@@ -185,25 +603,82 @@ void Startup() {
|
||||
const bool radius =
|
||||
re::RedirectCall(kImpactGatherCall, kGatherInRange, reinterpret_cast<const void*>(&GatherTargetsThunk));
|
||||
re::Log("dwarfarts: Mörsergranate radius%s", radius ? "" : " (patch FAILED)");
|
||||
const bool marker = re::RedirectCall(kGrenadeEventConstruct, kFxEventConstruct,
|
||||
reinterpret_cast<const void*>(&GrenadeEventThunk));
|
||||
re::Log("dwarfarts: Mörsergranate impact marker%s", marker ? "" : " (patch FAILED)");
|
||||
}
|
||||
RE_STARTUP(DwarfArtsStartup, Startup);
|
||||
|
||||
struct Registration {
|
||||
Registration() { game::OnCreatureOrder(&OnOrder); }
|
||||
Registration() {
|
||||
game::OnCreatureOrder(&OnOrder);
|
||||
game::FilterCreatureOrders(&AcceptOrder);
|
||||
game::OnGameEnd(&OnGameEnd);
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
|
||||
}
|
||||
} g_registration;
|
||||
|
||||
} // namespace
|
||||
|
||||
bool KeepEffect(uint32_t kind, uint32_t art) {
|
||||
return art != game::kArtEingraben || kind != kEffectDugIn;
|
||||
}
|
||||
|
||||
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
|
||||
uint32_t only, int32_t radius, const uint8_t* fx) {
|
||||
if (type == kGrenadeImpactType && radius < 0 && fx) {
|
||||
void* owner = game::GetCreature(*reinterpret_cast<const uint32_t*>(fx + kFxOwner));
|
||||
const unsigned level = owner ? game::ArtLevel(owner, game::kArtMoerser) : 0;
|
||||
const int32_t size = TypeSize(type);
|
||||
radius = static_cast<int32_t>(static_cast<float>(size) * (1.0f + kRadiusGrowth * (level / kLevelsPerStep)));
|
||||
const uint32_t owner = *reinterpret_cast<const uint32_t*>(fx + kFxOwner);
|
||||
radius = ImpactRadius(game::GetCreature(owner));
|
||||
// The position: area (u16), x, y (int32, world units), level.
|
||||
const auto* at = static_cast<const uint8_t*>(position);
|
||||
OnImpact(owner, static_cast<float>(*reinterpret_cast<const int32_t*>(at + 4)),
|
||||
static_cast<float>(*reinterpret_cast<const int32_t*>(at + 8)));
|
||||
}
|
||||
using Fn = uint32_t*(__fastcall*)(void*, void*, uint32_t*, const void*, uint32_t, uint32_t, int32_t);
|
||||
return reinterpret_cast<Fn>(kGatherInRange)(manager, nullptr, out, position, type, only, radius);
|
||||
}
|
||||
|
||||
// The impact markers of the grenades in flight, on the ground of `view`.
|
||||
void DrawMarkers(const uint8_t* view) {
|
||||
g_grenades.erase(std::remove_if(g_grenades.begin(), g_grenades.end(), [](const Grenade& g) { return !Alive(g); }),
|
||||
g_grenades.end());
|
||||
void* device = game::RenderDevice();
|
||||
if (g_grenades.empty() || !device) {
|
||||
return;
|
||||
}
|
||||
OverlayStates states(device);
|
||||
for (const Grenade& grenade : g_grenades) {
|
||||
DrawMarker(view, device, grenade);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mods::dwarfarts
|
||||
|
||||
namespace game {
|
||||
|
||||
// cWorldView0::DrawGroundQuads (0x62d530, thiscall (renderer), ret 4): the
|
||||
// ground; the objects are drawn after it.
|
||||
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer);
|
||||
RE_HOOK(0x0062d530, WorldView_DrawGroundQuads);
|
||||
void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* renderer) {
|
||||
WorldView_DrawGroundQuads_original(self, edx, renderer);
|
||||
if (self) {
|
||||
mods::dwarfarts::DrawMarkers(self);
|
||||
}
|
||||
}
|
||||
|
||||
// cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4): the spells; after
|
||||
// Eingraben's own case the dwarf digs to the point (cast +0x08 / +0x0c).
|
||||
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast);
|
||||
RE_HOOK(0x005222c0, Creature_CastSpell);
|
||||
void __fastcall Creature_CastSpell(uint8_t* self, void* edx, uint32_t* cast) {
|
||||
const bool dig = self && cast && cast[8] == kArtEingraben;
|
||||
const int32_t x = dig ? static_cast<int32_t>(cast[2]) : 0, y = dig ? static_cast<int32_t>(cast[3]) : 0;
|
||||
Creature_CastSpell_original(self, edx, cast);
|
||||
if (dig) {
|
||||
mods::dwarfarts::Dig(self, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -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
@@ -1,15 +1,30 @@
|
||||
// Escorted NPCs stay on the hero's follower list (2026-10-26, docs/quests.md).
|
||||
// "Furchtbares Verbrechen" (quest 9008) did not go on at its target point:
|
||||
// the NPC walked after the hero, but the script's test there ("@npc", 0x498aa0
|
||||
// case 0x40) looks the NPC up in the hero's follower list (hero +0x39c), and
|
||||
// that list was empty (measured on Borf's save: NPC 0x1fa with leader 1 and
|
||||
// the follow bit, no followers on the hero). The NPC keeps its side of the
|
||||
// escort (leader +0x251, status bit 4) across a load; the hero's list does
|
||||
// not come back. The mod adds every NPC that follows a hero but is missing
|
||||
// from its list back with the game's own AddFollower (0x54b070), with the
|
||||
// entry the follow order uses ({id, 1, 1}), once after loading.
|
||||
// Escorted NPCs stay with the hero (2026-10-26, docs/quests.md).
|
||||
//
|
||||
// On the follower list: "Furchtbares Verbrechen" (quest 9008) did not go on at
|
||||
// its target point: the NPC walked after the hero, but the script's test
|
||||
// there ("@npc", 0x498aa0 case 0x40) looks the NPC up in the hero's follower
|
||||
// list (hero +0x39c), and that list was empty (measured on Borf's save: NPC
|
||||
// 0x1fa with leader 1 and the follow bit, no followers on the hero). The NPC
|
||||
// keeps its side of the escort (leader +0x251, status bit 4) across a load;
|
||||
// the hero's list does not come back. The mod adds every NPC that follows a
|
||||
// hero but is missing from its list back with the game's own AddFollower
|
||||
// (0x54b070), with the entry the follow order uses ({id, 1, 1}), once after
|
||||
// loading.
|
||||
//
|
||||
// Never left behind: in "Die Sklaven der Orks" (2412) the husband fell
|
||||
// behind (the game's own catch-up teleport moved him once), and when the hero
|
||||
// walked on, his sector was switched off: the game removed him and ran his
|
||||
// spawn's death label (drop2412: quest variable 5, a token item dropped where
|
||||
// he was), with him alive (measured 2026-10-26 in the log: his object id
|
||||
// reused right after). Sectors are switched off at 2 sectors (65 tiles and
|
||||
// more) from the hero's; the mod teleports every follower of the local hero
|
||||
// that is more than kLeftBehindCells away to the hero (cCreature::Teleport,
|
||||
// as the portals), checked every kCheckMs.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
@@ -20,6 +35,8 @@ namespace mods::followers {
|
||||
namespace {
|
||||
|
||||
constexpr int kTicksAfterStart = 30;
|
||||
constexpr int32_t kLeftBehindCells = 40;
|
||||
constexpr DWORD kCheckMs = 500;
|
||||
|
||||
// Small town NPCs run after the hero for a few seconds (status 0x404, leader
|
||||
// the hero) without joining his list (measured 2026-10-26: types 0x2be,
|
||||
@@ -27,11 +44,20 @@ constexpr int kTicksAfterStart = 30;
|
||||
constexpr int32_t kStatusTagAlong = 0x400;
|
||||
|
||||
int g_ticksToCheck = -1;
|
||||
DWORD g_lastCheck = 0;
|
||||
|
||||
int32_t Field(const void* creature, size_t offset) {
|
||||
return *reinterpret_cast<const int32_t*>(static_cast<const uint8_t*>(creature) + offset);
|
||||
}
|
||||
|
||||
const game::MapPosition& Position(void* object) {
|
||||
return *reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(object) + game::kObjectPosition);
|
||||
}
|
||||
|
||||
int32_t Cell(int32_t units) {
|
||||
return static_cast<int32_t>(static_cast<float>(units) / game::kMapCellSize);
|
||||
}
|
||||
|
||||
void RepairFollowers() {
|
||||
for (uint32_t id = 1; id < game::ObjectIdEnd(); ++id) {
|
||||
if (game::IsPlayerHeroId(id) || !game::GetObjectById(id)) {
|
||||
@@ -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*) {
|
||||
if (g_ticksToCheck > 0 && --g_ticksToCheck == 0) {
|
||||
RepairFollowers();
|
||||
}
|
||||
const DWORD now = GetTickCount();
|
||||
if (g_ticksToCheck <= 0 && now - g_lastCheck >= kCheckMs) {
|
||||
g_lastCheck = now;
|
||||
BringAlong();
|
||||
}
|
||||
}
|
||||
|
||||
void OnStart() {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
#include <intrin.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "re/hook.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);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -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
@@ -24,6 +24,7 @@
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/ui/megamap.h"
|
||||
#include "game/ui/windows.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/megamap.h"
|
||||
@@ -90,27 +91,12 @@ constexpr uint32_t kZoomedByButton = 0x100;
|
||||
constexpr size_t kZoomHeld = 0xc06bc;
|
||||
constexpr size_t kZoomPointX = 0xc0438;
|
||||
constexpr size_t kZoomPointY = 0xc043c;
|
||||
constexpr size_t kScrollX = 0xc016c;
|
||||
constexpr size_t kScrollY = 0xc0170;
|
||||
|
||||
template <typename T>
|
||||
T& Field(uint8_t* p, size_t 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.
|
||||
void ZoomOnHero(uint8_t*) {
|
||||
uint8_t* megamap = game::UiWindow(game::UiManager(), game::kUiSlotMegamap);
|
||||
@@ -122,12 +108,12 @@ void ZoomOnHero(uint8_t*) {
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
const Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)),
|
||||
static_cast<float>(game::CreatureField(hero, game::kCreatureY)),
|
||||
static_cast<float>(hero[game::kObjectLevel])};
|
||||
const Vec3 pixel = MapPixel(megamap, world);
|
||||
Field<int32_t>(megamap, kZoomPointX) = static_cast<int32_t>(pixel.x) - Field<int32_t>(megamap, kScrollX);
|
||||
Field<int32_t>(megamap, kZoomPointY) = static_cast<int32_t>(pixel.y) - Field<int32_t>(megamap, kScrollY);
|
||||
const game::Vec3 world = {static_cast<float>(game::CreatureField(hero, game::kCreatureX)),
|
||||
static_cast<float>(game::CreatureField(hero, game::kCreatureY)),
|
||||
static_cast<float>(hero[game::kObjectLevel])};
|
||||
const game::Vec3 pixel = game::WorldToMapPixel(megamap, world);
|
||||
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) - game::MegamapScrollY(megamap);
|
||||
}
|
||||
|
||||
struct Registration {
|
||||
|
||||
+183
-12
@@ -22,12 +22,24 @@
|
||||
// compass on the giver, or is removed if the giver is not known. Accepting it
|
||||
// again removes the failed entry. Quests that failed before the mod are reset
|
||||
// the same way once after loading.
|
||||
//
|
||||
// Quest audit (2026-10-30, docs/quest-audit.md): a restart also puts back what
|
||||
// the quest's script needs to be offered again (questscript.cpp): its variables
|
||||
// as they were when it became available (kept at Quest_Trigger, saved with the
|
||||
// hero; for quests triggered earlier: 1 for the one named after the quest, else
|
||||
// 0), the giver's accept dialog, the giver and the NPCs it needs from their
|
||||
// spawn instructions; what its QIS_OnEnter created is deleted (accepting again
|
||||
// creates it anew). A quest stays failed when the game repeats it itself
|
||||
// (generic quests), when it has no giver (trigger areas), when its failure ends
|
||||
// the story (2402 sets up 2403, 5003's shaman turns hostile, ...) or when an NPC
|
||||
// it needs is gone for good.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@@ -37,6 +49,7 @@
|
||||
#include "game/engine/quests.h"
|
||||
#include "mods/inventory_save.h"
|
||||
#include "mods/questfail.h"
|
||||
#include "mods/questscript.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
@@ -63,7 +76,10 @@ bool g_resetAfterLoad = false;
|
||||
bool g_inDialogAnswer = false;
|
||||
|
||||
// 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
|
||||
// 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) +
|
||||
game::kObjectPosition);
|
||||
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);
|
||||
}
|
||||
|
||||
// The entry's compass on its quest's giver (a cell target, no radius). False if
|
||||
// the giver is not known.
|
||||
bool PointAtGiver(uint8_t* entry) {
|
||||
const auto giver = g_givers.find(game::BookQuestId(entry));
|
||||
if (giver == g_givers.end()) {
|
||||
const auto giver = g_giverCells.find(game::BookQuestId(entry));
|
||||
if (giver == g_giverCells.end()) {
|
||||
return false;
|
||||
}
|
||||
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
|
||||
@@ -123,6 +139,63 @@ bool PointAtGiver(uint8_t* entry) {
|
||||
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() {
|
||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
||||
const uint8_t* entry = game::BookEntry(i);
|
||||
@@ -130,10 +203,19 @@ void RestartFailedInBook() {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
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]} ------------------------------------
|
||||
|
||||
nlohmann::json WriteGivers(unsigned) {
|
||||
if (g_givers.empty()) {
|
||||
if (g_giverCells.empty()) {
|
||||
return nlohmann::json();
|
||||
}
|
||||
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};
|
||||
}
|
||||
return all;
|
||||
@@ -177,7 +259,41 @@ void ReadGivers(unsigned, const nlohmann::json& data) {
|
||||
}
|
||||
for (const auto& [key, cell] : data.items()) {
|
||||
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
|
||||
g_givers[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
|
||||
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);
|
||||
}
|
||||
}
|
||||
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):
|
||||
@@ -204,12 +345,41 @@ void Restart(int index) {
|
||||
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
|
||||
RE_HOOK(0x0046ced0, Quest_End);
|
||||
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);
|
||||
if (!success && index >= 0 && static_cast<size_t>(index) < game::QuestCount()) {
|
||||
Restart(index);
|
||||
if (!success && known) {
|
||||
if (staysFailed) {
|
||||
re::Log("questfail: quest %d '%s' stays failed: %s", game::QuestId(game::Quest(index)),
|
||||
game::QuestName(game::Quest(index)), staysFailed);
|
||||
} else {
|
||||
Restart(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quest_Trigger (0x46de10, cdecl (index, ?, ?, ?), plain ret): makes the quest
|
||||
// available (flag 1) the first time, running its trigger scripts. Its
|
||||
// variables are kept as they are then: the state a restart goes back to.
|
||||
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4);
|
||||
RE_HOOK(0x0046de10, Quest_Trigger);
|
||||
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4) {
|
||||
if (index >= 0 && static_cast<size_t>(index) < game::QuestCount() &&
|
||||
!(game::QuestFlags(game::Quest(index)) & game::kQuestTriggered)) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
if (const questscript::Quest* quest = questscript::Find(id)) {
|
||||
auto& fresh = g_freshVariables[id];
|
||||
fresh.clear();
|
||||
for (const std::string& name : quest->variables) {
|
||||
if (const int32_t* value = game::ScriptVariable(name.c_str())) {
|
||||
fresh[name] = *value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Quest_Trigger_original(index, a2, a3, a4);
|
||||
}
|
||||
|
||||
// cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
|
||||
// quest script for the answer.
|
||||
void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer);
|
||||
@@ -244,7 +414,7 @@ uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t
|
||||
if (index < game::BookCount() && (kind == 0 || kind == 1)) {
|
||||
const uint8_t* entry = game::BookEntry(index);
|
||||
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
|
||||
g_givers.count(game::BookQuestId(entry))) {
|
||||
g_giverCells.count(game::BookQuestId(entry))) {
|
||||
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
|
||||
}
|
||||
}
|
||||
@@ -257,6 +427,7 @@ struct Register {
|
||||
game::OnEngineTick(&OnTick);
|
||||
game::OnSaveGameLoaded(&OnLoaded);
|
||||
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
|
||||
inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables);
|
||||
}
|
||||
} g_register;
|
||||
} // namespace
|
||||
|
||||
+43
-246
@@ -5,238 +5,48 @@
|
||||
// completed or correctly running").
|
||||
//
|
||||
// Measured: the quest's script (FunkCode.bin) gives the giver a death label
|
||||
// when she starts following ("ex1501": quest variable "1501" = 1, then the
|
||||
// when she starts following ("ex1501": bit 1 of quest variable "1501", then the
|
||||
// poachers' death label "tot1501", which ends the quest without a reward once
|
||||
// all poachers are dead; until then it stays in progress with no giver). The
|
||||
// body is removed. The giver is placed by her region's start script
|
||||
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at
|
||||
// pos_auftrag1501, dialog auftrag1501), which the game runs once.
|
||||
//
|
||||
// The mod finds each quest's giver in the script code: the NPC whose spawn
|
||||
// instruction carries a dialog that has a "Dialog:<name>" label of the quest.
|
||||
// When a label of that quest runs for its dead giver (her death label), the
|
||||
// label is skipped; the quest restarts (questfail::Restart, its book entry
|
||||
// failed and pointing at the giver), the variables only this quest's labels
|
||||
// set go back to 0, and once the body is gone the game runs the giver's
|
||||
// spawn instruction again: she stands at her start, offering the quest.
|
||||
// After a load, a quest in progress whose giver no longer exists is repaired
|
||||
// the same way.
|
||||
// The giver is the NPC whose spawn instruction carries the quest's accept
|
||||
// dialog (questscript.cpp). When a label of that quest runs for its dead giver
|
||||
// (her death label), the label is skipped and the quest restarts
|
||||
// (questfail::Restart: available again, its book entry failed and pointing at
|
||||
// the giver, its variables as before, the giver spawned again once the body is
|
||||
// gone). After a load, a quest in progress whose giver no longer exists is
|
||||
// repaired the same way, unless the quest itself turns the giver into an enemy
|
||||
// or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose).
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/engine/events.h"
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
#include "game/engine/quests.h"
|
||||
#include "mods/questfail.h"
|
||||
#include "mods/questscript.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::questgiver {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kInstructionHeader = 4;
|
||||
constexpr size_t kMaxInstruction = 0x800; // 0x475970 refuses larger ones
|
||||
constexpr size_t kReadChunk = 1 << 20;
|
||||
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
|
||||
constexpr uint16_t kScriptStartQuest = 0x14; // {0x0b quest id}
|
||||
constexpr size_t kStartQuestSize = 9;
|
||||
constexpr uint16_t kScriptAnswer = 0x3c; // dialog answer {0x01 text, 0x01 label}
|
||||
|
||||
struct Giver {
|
||||
uint32_t scriptId = 0;
|
||||
std::vector<uint8_t> spawn; // the spawn instruction
|
||||
};
|
||||
|
||||
struct Pending {
|
||||
int questIndex;
|
||||
void* body; // the dead giver's object
|
||||
uint32_t bodyId;
|
||||
DWORD since;
|
||||
};
|
||||
|
||||
bool g_scanned = false;
|
||||
std::vector<uint8_t> g_code; // FunkCode.bin
|
||||
std::map<int32_t, Giver> g_givers; // quest id -> giver
|
||||
std::map<int32_t, std::vector<std::string>> g_variables; // quest id -> variables only it sets
|
||||
std::vector<Pending> g_pending;
|
||||
bool g_repairAfterLoad = false;
|
||||
|
||||
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
|
||||
|
||||
bool ReadCode() {
|
||||
g_code.clear();
|
||||
for (uint32_t offset = 0;; offset += kReadChunk) {
|
||||
g_code.resize(offset + kReadChunk);
|
||||
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
|
||||
g_code.resize(offset + read);
|
||||
if (read < kReadChunk) {
|
||||
return !g_code.empty();
|
||||
}
|
||||
}
|
||||
// The quest's giver if M2 can bring her back: a numbered script id spawned with
|
||||
// the accept dialog.
|
||||
const questscript::Giver* RespawnableGiver(int32_t questId) {
|
||||
const questscript::Quest* quest = questscript::Find(questId);
|
||||
return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
// Calls `fn(at, opcode, size)` for each instruction of a label's code.
|
||||
template <typename Fn>
|
||||
void ForEachInstruction(const uint8_t* label, Fn fn) {
|
||||
const size_t begin = game::ScriptLabelCode(label);
|
||||
const size_t end = begin + game::ScriptLabelCodeSize(label);
|
||||
for (size_t at = begin; at + kInstructionHeader <= end && end <= g_code.size();) {
|
||||
const uint16_t size = U16(at + 2);
|
||||
if (size < kInstructionHeader || at + size > end) {
|
||||
return;
|
||||
}
|
||||
fn(at, U16(at), size);
|
||||
at += size;
|
||||
}
|
||||
}
|
||||
|
||||
// The spawn instructions in the code: "Res:<number>" with a dialog.
|
||||
void ScanSpawns(const std::map<std::string, int32_t>& dialogs) {
|
||||
static const char kRes[] = "res:";
|
||||
for (size_t at = 0; at + 9 < g_code.size(); ++at) {
|
||||
if (U16(at) != game::kScriptSpawn || g_code[at + 4] != game::kScriptArgString ||
|
||||
_strnicmp(reinterpret_cast<const char*>(&g_code[at + 5]), kRes, 4) != 0) {
|
||||
continue;
|
||||
}
|
||||
const uint16_t size = U16(at + 2);
|
||||
if (size <= 9 || size > kMaxInstruction || at + size > g_code.size()) {
|
||||
continue;
|
||||
}
|
||||
char* numberEnd = nullptr;
|
||||
const unsigned long scriptId = std::strtoul(reinterpret_cast<const char*>(&g_code[at + 9]), &numberEnd, 10);
|
||||
if (scriptId == 0 || *numberEnd != 0) {
|
||||
continue;
|
||||
}
|
||||
for (size_t tag = at + 9; tag + 1 < at + size; ++tag) {
|
||||
if (g_code[tag] != game::kScriptArgDialog) {
|
||||
continue;
|
||||
}
|
||||
const char* name = reinterpret_cast<const char*>(&g_code[tag + 1]);
|
||||
if (!memchr(name, 0, at + size - tag - 1)) {
|
||||
break;
|
||||
}
|
||||
const auto dialog = dialogs.find(name);
|
||||
if (dialog != dialogs.end() && !g_givers.count(dialog->second)) {
|
||||
g_givers[dialog->second] = {static_cast<uint32_t>(scriptId),
|
||||
std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The variables each quest's labels set; a variable set by several quests
|
||||
// (or by labels of none) is not any one quest's.
|
||||
void ScanVariables() {
|
||||
std::map<std::string, std::set<int32_t>> setters;
|
||||
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
|
||||
label += game::kScriptLabelSize) {
|
||||
const int32_t quest = game::ScriptLabelQuest(label);
|
||||
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
|
||||
if (opcode == game::kScriptSetVariable && g_code[at + 4] == game::kScriptArgString &&
|
||||
memchr(&g_code[at + 5], 0, size - 5)) {
|
||||
setters[reinterpret_cast<const char*>(&g_code[at + 5])].insert(quest);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const auto& [name, quests] : setters) {
|
||||
if (quests.size() == 1 && *quests.begin() >= 0 && g_givers.count(*quests.begin())) {
|
||||
g_variables[*quests.begin()].push_back(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Scan() {
|
||||
if (g_scanned) {
|
||||
return;
|
||||
}
|
||||
g_scanned = true;
|
||||
if (!ReadCode()) {
|
||||
re::Log("questgiver: script code not readable");
|
||||
return;
|
||||
}
|
||||
// Labels by name, and the accept labels: code starting with opcode 0x14
|
||||
// (start quest #id, 0x48f320; e.g. "trigger1501").
|
||||
std::map<std::string, const uint8_t*> byName;
|
||||
std::map<std::string, int32_t> accepts; // label name -> quest it starts
|
||||
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
|
||||
label += game::kScriptLabelSize) {
|
||||
const std::string name(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
|
||||
byName.emplace(name, label);
|
||||
const size_t at = game::ScriptLabelCode(label);
|
||||
if (game::ScriptLabelCodeSize(label) >= kStartQuestSize && at + kStartQuestSize <= g_code.size() &&
|
||||
U16(at) == kScriptStartQuest && g_code[at + 4] == game::kScriptArgInt) {
|
||||
int32_t quest;
|
||||
std::memcpy(&quest, &g_code[at + 5], sizeof(quest));
|
||||
accepts.emplace(name, quest);
|
||||
}
|
||||
}
|
||||
// The givers' dialogs: "Dialog:<name>" labels with an answer (opcode
|
||||
// 0x3c, {0x01 text, 0x01 label}) that accepts their quest.
|
||||
std::map<std::string, int32_t> dialogs; // dialog name -> quest
|
||||
static const char kDialog[] = "Dialog:";
|
||||
for (const auto& [name, label] : byName) {
|
||||
const int32_t quest = game::ScriptLabelQuest(label);
|
||||
if (quest < 0 || name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
|
||||
continue;
|
||||
}
|
||||
bool offers = false;
|
||||
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
|
||||
if (opcode != kScriptAnswer || g_code[at + 4] != game::kScriptArgString) {
|
||||
return;
|
||||
}
|
||||
const char* text = reinterpret_cast<const char*>(&g_code[at + 5]);
|
||||
const size_t textEnd = 5 + strnlen(text, size - 5) + 1;
|
||||
if (textEnd + 1 < size && g_code[at + textEnd] == game::kScriptArgString) {
|
||||
const auto accept = accepts.find(reinterpret_cast<const char*>(&g_code[at + textEnd + 1]));
|
||||
offers = offers || (accept != accepts.end() && accept->second == quest);
|
||||
}
|
||||
});
|
||||
if (offers) {
|
||||
dialogs.emplace(name.substr(sizeof(kDialog) - 1), quest);
|
||||
}
|
||||
}
|
||||
ScanSpawns(dialogs);
|
||||
ScanVariables();
|
||||
re::Log("questgiver: %u quest givers in %u bytes of script code", static_cast<unsigned>(g_givers.size()),
|
||||
static_cast<unsigned>(g_code.size()));
|
||||
}
|
||||
|
||||
// The living giver of `giver`, or null.
|
||||
void* LivingGiver(const Giver& giver) {
|
||||
const uint32_t id = game::ScriptObjectId(giver.scriptId);
|
||||
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
|
||||
return creature && game::ObjectScriptId(creature) == giver.scriptId && game::IsAlive(creature) ? creature
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
void ResetVariables(int32_t questId) {
|
||||
for (const std::string& name : g_variables[questId]) {
|
||||
std::vector<uint8_t> code(kInstructionHeader);
|
||||
code.push_back(game::kScriptArgString);
|
||||
code.insert(code.end(), name.begin(), name.end());
|
||||
code.push_back(0);
|
||||
code.push_back(game::kScriptArgInt);
|
||||
code.insert(code.end(), 4, 0);
|
||||
code[0] = static_cast<uint8_t>(game::kScriptSetVariable);
|
||||
code[1] = 0;
|
||||
code[2] = static_cast<uint8_t>(code.size());
|
||||
code[3] = static_cast<uint8_t>(code.size() >> 8);
|
||||
game::RunScriptInstruction(code.data());
|
||||
re::Log("questgiver: quest %d variable '%s' = 0", questId, name.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// Restarts quest `index`: flags (available again), its book entry (failed,
|
||||
// at the giver) and its variables.
|
||||
// Restarts quest `index` (questfail::Restart, which also brings the giver
|
||||
// back); its book entry is failed.
|
||||
void ResetQuest(int index) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
questfail::Restart(index);
|
||||
@@ -246,16 +56,6 @@ void ResetQuest(int index) {
|
||||
*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
|
||||
@@ -265,40 +65,30 @@ bool IsRunning(int index) {
|
||||
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone);
|
||||
}
|
||||
|
||||
// Quests whose giver turns into an enemy are left alone: a missing giver may
|
||||
// be the one the hero killed as the quest asks; so are those deleting her.
|
||||
void RepairAfterLoad() {
|
||||
for (const auto& [id, giver] : g_givers) {
|
||||
const int index = game::QuestIndex(id);
|
||||
if (index < 0 || !IsRunning(index) || LivingGiver(giver)) {
|
||||
for (int index = 0, count = static_cast<int>(game::QuestCount()); index < count; ++index) {
|
||||
const int32_t id = game::QuestId(game::Quest(index));
|
||||
const questscript::Giver* giver = IsRunning(index) ? RespawnableGiver(id) : nullptr;
|
||||
const questscript::Quest* quest = giver ? questscript::Find(id) : nullptr;
|
||||
if (!quest || quest->turnsGiver || quest->deletesGiver || questscript::LivingNpc(giver->scriptId)) {
|
||||
continue;
|
||||
}
|
||||
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
|
||||
game::QuestName(game::Quest(index)), giver.scriptId);
|
||||
game::QuestName(game::Quest(index)), giver->scriptId);
|
||||
ResetQuest(index);
|
||||
Spawn(index);
|
||||
}
|
||||
}
|
||||
|
||||
void OnTick(uint8_t*) {
|
||||
if (g_repairAfterLoad) {
|
||||
g_repairAfterLoad = false;
|
||||
Scan();
|
||||
RepairAfterLoad();
|
||||
}
|
||||
for (auto it = g_pending.begin(); it != g_pending.end();) {
|
||||
const bool gone = game::GetObjectById(it->bodyId) != it->body;
|
||||
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
Spawn(it->questIndex);
|
||||
it = g_pending.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
void OnLoaded() {
|
||||
g_repairAfterLoad = true;
|
||||
g_pending.clear();
|
||||
}
|
||||
void OnLoaded() { g_repairAfterLoad = true; }
|
||||
|
||||
struct Registration {
|
||||
Registration() {
|
||||
@@ -311,25 +101,32 @@ struct Registration {
|
||||
|
||||
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label
|
||||
// of a running quest run for its dead giver (her death label) is skipped and
|
||||
// the quest restarted.
|
||||
// the quest restarted. Not when the giver died as an enemy: some quests turn
|
||||
// the giver hostile and their death label goes on ("Familienbande", 702:
|
||||
// answer change702 turns the giver Res:17061 and two relatives against the
|
||||
// hero, their death label tot702 sends him back to the girl Res:17060). The
|
||||
// running labels are kept for questfail (failures that end a story).
|
||||
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
|
||||
RE_HOOK(0x0046bd80, Script_RunLabel);
|
||||
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
|
||||
const questscript::LabelScope running(label);
|
||||
const uint8_t* labels = game::ScriptLabelsBegin();
|
||||
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
|
||||
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
|
||||
const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
|
||||
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
|
||||
if (creature && IsRunning(index) && !game::IsAlive(creature)) {
|
||||
Scan();
|
||||
const auto giver = g_givers.find(questId);
|
||||
if (giver != g_givers.end() && game::ObjectScriptId(creature) == giver->second.scriptId) {
|
||||
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts",
|
||||
giver->second.scriptId, questId,
|
||||
game::ScriptLabelName(labels + label * game::kScriptLabelSize));
|
||||
const questscript::Giver* giver = RespawnableGiver(questId);
|
||||
if (giver && game::ObjectScriptId(creature) == giver->scriptId) {
|
||||
const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize);
|
||||
if (game::IsHostileTo(creature, game::GetPlayerHero())) {
|
||||
re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId,
|
||||
questId, name);
|
||||
return Script_RunLabel_original(label, object, flag);
|
||||
}
|
||||
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", giver->scriptId,
|
||||
questId, name);
|
||||
ResetQuest(index);
|
||||
const uint32_t bodyId = *reinterpret_cast<uint32_t*>(object + game::kCreatureId);
|
||||
g_pending.push_back({index, game::GetObjectById(bodyId), bodyId, GetTickCount()});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -10,6 +10,7 @@ passed explicitly.
|
||||
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
|
||||
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
|
||||
| `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) |
|
||||
|
||||
## Persistent Ghidra project (`ghidra/`, not in git)
|
||||
|
||||
@@ -431,3 +431,27 @@
|
||||
0x005ff820 cObjectManager::ObjectsInRange # thiscall (out, position, type, only, radius = -1: type sizes), ret 0x14
|
||||
0x00428bb0 TypeTable::ObjectSize # thiscall (type), ret 4: +0x14, default 50
|
||||
0x004fc810 Creature_StunChance # (level, creature): level * 5 / creature level * 100
|
||||
0x00633230 World_RebuildVisitedBorder # thiscall: clears the 100x100 border bytes (+0xb73c) and runs 0x6332d0 for every sector with flag 0x20000 (visited); on load (0x471086, 0x60c621)
|
||||
0x006332d0 World_UpdateVisitedBorderAround # thiscall (sector cx, cy), ret 8: border byte of each unvisited 3x3 neighbour (0x6334f0)
|
||||
0x006334f0 World_ComputeVisitedBorderCell # thiscall (cx, cy), ret 8: 8 bits, one per neighbour sector with flag 0x20000
|
||||
0x006336b0 World_VisitedBorderCell # thiscall (cx, cy), ret 8: border byte, 0 outside 100x100
|
||||
0x006c8580 cUI_Megamap::DrawVisitedBorder # thiscall (device), ret 4: 2x2 px red (0xffff0000) dot per border bit, +-10/+-20 px from the sector origin
|
||||
0x006c87c0 cUI_Megamap::DrawQuestMarkers # thiscall (device), ret 4: quest book entries (0xaaaf00)
|
||||
0x00622700 WorldToPlane # cdecl (out vec3, in vec3), plain ret: world units -> world pixels (iso matrix 0xad5710)
|
||||
0x0062d530 cWorldView0::DrawGroundQuads # thiscall (renderer), ret 4: the ground, before the objects
|
||||
0x0041e0a0 cEventFX::Construct # thiscall (code, owner, target, position 4 dwords, ...), ret 0x44
|
||||
0x007b66b0 cParticleSystem_grenadeimpact::Render # vf04 (device, matrix by value), ret 0x44; vtable 0x896cdc
|
||||
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
|
||||
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
|
||||
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68
|
||||
0x005d8240 cInventoryEntry::AddMainQuestItem # thiscall (type, script id, notify): {type, script id} pairs; refused if same type (script id 0 test patched out)
|
||||
0x0049bb30 QuestManager_SetVarBit # script op 0x44: variable |= 1 << n
|
||||
0x0049c450 QuestManager_ClearVarBit # script op 0x45: variable &= ~(1 << n)
|
||||
0x0049cc20 QuestManager_AddToVar # script op 0x4b: variable += n
|
||||
0x005222c0 cCreature::CastSpell # thiscall (cast), ret 4: switch on the spell (cast +0x20); +0x08/+0x0c ground point; case 1 Phasenverschiebung, 0x39 Eingraben
|
||||
0x00636b60 cWorld::IsPassable # thiscall (position), ret 4: world = *ObjectManager
|
||||
0x00636eb0 cWorld::CanStand # thiscall (creature id, position), ret 8: may correct the position's area
|
||||
0x004d24f0 cArtDef::Duration # thiscall (index, level): +0x76 + level * +0x78 (i16), at most +0x82 when set
|
||||
0x007b8fc0 cParticleSystem_dwarf_shield::cParticleSystem_dwarf_shield # Eingraben's barrier (type 0x480)
|
||||
0x007b91a0 cParticleSystem_dwarf_shield::Update # vf08 (dt), ret 4; pulse +0x20b8, phase +0x20ac, end +0x20a8
|
||||
0x007b92b0 cParticleSystem_dwarf_shield::Draw # vf04, ret 0x44; model alpha pulse * 128 (__ftol at 0x7b9483)
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user