Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e13b82f040 | ||
|
|
7ec0fa2b8e | ||
|
|
471b8b13d5 | ||
|
|
198bbab6f9 | ||
|
|
55e48dd302 |
No files matched your search
@@ -82,7 +82,9 @@ add_library(sacredK SHARED
|
|||||||
src/mods/followers.cpp
|
src/mods/followers.cpp
|
||||||
src/mods/questfail.cpp
|
src/mods/questfail.cpp
|
||||||
src/mods/questgiver.cpp
|
src/mods/questgiver.cpp
|
||||||
|
src/mods/questitems.cpp
|
||||||
src/mods/questnpc.cpp
|
src/mods/questnpc.cpp
|
||||||
|
src/mods/questscript.cpp
|
||||||
src/mods/anticheat.cpp
|
src/mods/anticheat.cpp
|
||||||
src/mods/loadingscreen.cpp
|
src/mods/loadingscreen.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
+55
-1
@@ -1,4 +1,4 @@
|
|||||||
# Dwarf arts: Kanonenschuss and Mörsergranate
|
# Dwarf arts: Kanonenschuss, Mörsergranate, Eingraben
|
||||||
|
|
||||||
## Requests (2026-10-25 / 2026-10-26)
|
## Requests (2026-10-25 / 2026-10-26)
|
||||||
|
|
||||||
@@ -87,3 +87,57 @@ 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,
|
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
|
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.
|
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.
|
||||||
@@ -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).
|
||||||
+55
-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
|
hero enters the quest's region (`Quest_Trigger`). A restarted quest is now
|
||||||
"available": flags 1.
|
"available": flags 1.
|
||||||
|
|
||||||
|
2026-10-30 (quest audit, `docs/quest-audit.md`): a restart also resets the
|
||||||
|
quest's variables, deletes what its `QIS_OnEnter` created, brings back the
|
||||||
|
giver and the NPCs it needs, and gives the giver the accept dialog again; some
|
||||||
|
failures stay final (generic quests, no giver, story endings, NPCs gone for
|
||||||
|
good). The script facts come from `mods/questscript.cpp`.
|
||||||
|
|
||||||
Marker back to the giver (request 2026-10-25: "quests that failed, should
|
Marker back to the giver (request 2026-10-25: "quests that failed, should
|
||||||
have the blue marker point back to the quest giver"):
|
have the blue marker point back to the quest giver"):
|
||||||
|
|
||||||
@@ -196,7 +202,8 @@ Dialogs without a quest (healers, closed shops: `geschlossen101`,
|
|||||||
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
|
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
|
||||||
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
|
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
|
||||||
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
|
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
|
||||||
compass, 0x44 set variable, 0x48 move NPC, 0x56 dialog state.
|
compass, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 0x48 move NPC,
|
||||||
|
0x56 dialog state.
|
||||||
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
|
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
|
||||||
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
|
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
|
||||||
the entry.
|
the entry.
|
||||||
@@ -227,8 +234,8 @@ load we need a repair ... if not completed or correctly running".
|
|||||||
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction
|
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction
|
||||||
carries a dialog with an answer that accepts the quest (85 givers). When a
|
carries a dialog with an answer that accepts the quest (85 givers). When a
|
||||||
label of a running quest runs for its dead giver, it is skipped, the quest
|
label of a running quest runs for its dead giver, it is skipped, the quest
|
||||||
restarts (failed entry at the giver, the variables only its labels set back to
|
restarts (failed entry at the giver, the variables only its labels set back:
|
||||||
0), and once the body is gone the spawn instruction runs again. After a load,
|
0, the one named after the quest 1), and once the body is gone the spawn instruction runs again. After a load,
|
||||||
an accepted quest (flag 4) without its giver gets the same. Not yet seen in
|
an accepted quest (flag 4) without its giver gets the same. Not yet seen in
|
||||||
the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
|
the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
|
||||||
audit of how quests can fail.
|
audit of how quests can fail.
|
||||||
@@ -237,6 +244,51 @@ Lesson (2026-10-26): flag 1 means available, not running; a first version
|
|||||||
took it for running and reset 16 available quests and spawned their NPCs on
|
took it for running and reset 16 available quests and spawned their NPCs on
|
||||||
Borf (repaired once, then saved).
|
Borf (repaired once, then saved).
|
||||||
|
|
||||||
|
Givers killed as enemies (2026-10-30): "Familienbande" (702, book title
|
||||||
|
"Mädchen bei Ausritt verschwunden") was restarted twice when the hero killed
|
||||||
|
its giver. The quest wants that: back at the giver with the girl
|
||||||
|
(Res:17060), answer `change702` changes the creature type of the giver
|
||||||
|
Res:17061 (668 -> 262) and two relatives (opcode 0x4f {"Res:<id>", 0x02
|
||||||
|
type}), they attack, and their death label `tot702` sends the hero back to the
|
||||||
|
girl (`ziel702`, `exit702` ends the quest). Now a giver hostile to the hero
|
||||||
|
at death runs the label as the game has it, and the load repair skips quests
|
||||||
|
whose labels change their giver's type (0x4f on the giver; 2 of the 85 known
|
||||||
|
givers, 702 among them). Confirmed in game 2026-10-30.
|
||||||
|
|
||||||
|
## Quest variables are bit fields (measured 2026-10-28)
|
||||||
|
|
||||||
|
Found while repairing "Kräuter für Kräuterhexe" (402; the user knows it as
|
||||||
|
Windgeflüster). Variables live in the quest manager's vector +0x7550 /
|
||||||
|
+0x7554 (0x24-byte entries: name[0x20], value +0x20; `game::ScriptVariable`).
|
||||||
|
Script writes: 0x43 sets the value, 0x44 sets bit n (0x49bb30: value |= 1 <<
|
||||||
|
n), 0x45 clears bit n (0x49c450), 0x4b adds n (0x49cc20). Conditions
|
||||||
|
(`Script_Condition` 0x498aa0): 0x49 bit n set, 0x4a bit n clear (logged "if
|
||||||
|
IsNotVarBit"), 0x48 value == n; a missing variable is false. The variable
|
||||||
|
named after a quest has bit 0 while the quest is available: all 87 quests not
|
||||||
|
yet accepted in a dwarf save had value 1, and nothing in the quest's own labels
|
||||||
|
sets it. Quest 402: accepting sets bit 1, the berries bit 2, the reward bit 3;
|
||||||
|
the giver's dialog offers the quest on bit 0.
|
||||||
|
|
||||||
|
The quest giver restart first ran 0x44 with 0 ("variable = 0"), which set bit
|
||||||
|
0 and cleared nothing: the giver kept saying "Zögerst du in deinen Taten?".
|
||||||
|
A reset to 0 then matched no dialog branch at all. Now the value is written
|
||||||
|
directly: 0, or 1 for the quest's own variable. Confirmed 2026-10-28.
|
||||||
|
|
||||||
|
## Main quest items refused (request 2026-10-28)
|
||||||
|
|
||||||
|
"I cannot finish the Quest Windgeflüster." The berries (type 6029) are an item
|
||||||
|
`Region4Init` places once on the ground near the Goldbeerenbusch (op 0x5e,
|
||||||
|
pickup label `take402`); picking it up logged "cInventoryEntry::addMainQuestItem()
|
||||||
|
Already has mainQuestItem(6029)" and the item was gone.
|
||||||
|
`cInventoryEntry::AddMainQuestItem` (0x5d8240, thiscall (type, script id,
|
||||||
|
notify)) keeps {type, script id} pairs and refuses a new item when an entry
|
||||||
|
has the same type or script id 0 (0x5d83c6); scripts that give an item
|
||||||
|
without a "Res:" id add it with 0 (0x486bd8). The dwarf held {4609, 0}, so
|
||||||
|
every later main quest item was refused (a bug of the original).
|
||||||
|
`mods/questitems.cpp` removes the script id test. The save was repaired once
|
||||||
|
by running `take402` for the hero after the load (quest accepted, bit 2
|
||||||
|
missing); the quest then finished. Confirmed 2026-10-28.
|
||||||
|
|
||||||
## Escorts never left behind (2026-10-26)
|
## Escorts never left behind (2026-10-26)
|
||||||
|
|
||||||
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
|
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
|
||||||
|
|||||||
@@ -266,3 +266,21 @@ Possibly related (2026-10-25, once): right after a game start the main menu
|
|||||||
texts were drawn as solid gold blocks (the gold text texture without the
|
texts were drawn as solid gold blocks (the gold text texture without the
|
||||||
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
|
font's letter mask). Gone after a restart; Alt+Tab does not cause it. The
|
||||||
log showed nothing unusual (all hooks and patches applied).
|
log showed nothing unusual (all hooks and patches applied).
|
||||||
|
|
||||||
|
## Frame drops while walking (2026-10-27, deferred 2026-10-30)
|
||||||
|
|
||||||
|
Reported 2026-10-27. Measured 2026-10-29 (about 54 min, temporary timing in
|
||||||
|
the Flip, engine tick and map border, since removed): 549 frames over 40 ms,
|
||||||
|
mostly 0-1 per 10 s, worst 833 ms. Present (Flip) was slow only once; the
|
||||||
|
time goes to the game's own engine tick (227 ticks of 18-70 ms). Our tick
|
||||||
|
callbacks were slow only at the 10-min autosave. The map border is drawn only
|
||||||
|
while the map is open, so it is not the cause. Next step if resumed: time the
|
||||||
|
parts of the game tick (sector streaming while walking is the main suspect).
|
||||||
|
Deferred by the user 2026-10-30.
|
||||||
|
|
||||||
|
## Eingraben clears slows, roots and stuns (deferred 2026-10-30)
|
||||||
|
|
||||||
|
Step 3 of Eingraben (docs/dwarf-arts.md): cast clears slowing, rooting and
|
||||||
|
stunning effects on the hero. The effect kinds are unknown; plan: temporary
|
||||||
|
log of the effects added to the hero while the user gets slowed or frozen,
|
||||||
|
then remove those kinds on cast. Deferred by the user 2026-10-30.
|
||||||
@@ -88,6 +88,7 @@ constexpr uint16_t kArtEnergieblitze = 0x3fb;
|
|||||||
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
constexpr uint16_t kArtFlammenwerfer = 0x43d;
|
||||||
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
|
constexpr uint16_t kArtMoerser = 0x43e; // Mörsergranate (gui_spells_moerser)
|
||||||
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
|
constexpr uint16_t kArtKanone = 0x43f; // Kanonenschuss (gui_spells_kanone)
|
||||||
|
constexpr uint16_t kArtEingraben = 0x39; // spell (gui_moves_eingraben)
|
||||||
|
|
||||||
// cCreatureArts fields.
|
// cCreatureArts fields.
|
||||||
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
constexpr size_t kArtsCreature = 0x4; // cCreature*: the owner
|
||||||
|
|||||||
@@ -15,8 +15,17 @@ std::vector<CreatureOrderFn>& Callbacks() {
|
|||||||
return callbacks;
|
return callbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<CreatureOrderFilter>& Filters() {
|
||||||
|
static std::vector<CreatureOrderFilter> filters;
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void FilterCreatureOrders(CreatureOrderFilter filter) {
|
||||||
|
Filters().push_back(filter);
|
||||||
|
}
|
||||||
|
|
||||||
void OnCreatureOrder(CreatureOrderFn callback) {
|
void OnCreatureOrder(CreatureOrderFn callback) {
|
||||||
Callbacks().push_back(callback);
|
Callbacks().push_back(callback);
|
||||||
}
|
}
|
||||||
@@ -26,6 +35,11 @@ void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* orde
|
|||||||
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
|
RE_HOOK(0x0052e400, Creature_ReceiveOrder);
|
||||||
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
|
void __fastcall Creature_ReceiveOrder(void* self, void* edx, CreatureOrder* order) {
|
||||||
if (self && order) {
|
if (self && order) {
|
||||||
|
for (CreatureOrderFilter filter : Filters()) {
|
||||||
|
if (!filter(self, *order)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
void* caller = _ReturnAddress();
|
void* caller = _ReturnAddress();
|
||||||
for (CreatureOrderFn callback : Callbacks()) {
|
for (CreatureOrderFn callback : Callbacks()) {
|
||||||
callback(self, *order, caller);
|
callback(self, *order, caller);
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ inline void* ControlledCreature(uint8_t* engine) {
|
|||||||
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
|
using CreatureOrderFn = void (*)(void* creature, const CreatureOrder& order, void* caller);
|
||||||
void OnCreatureOrder(CreatureOrderFn callback);
|
void OnCreatureOrder(CreatureOrderFn callback);
|
||||||
|
|
||||||
|
// Asked before the callbacks: an order any filter refuses (returns false) is
|
||||||
|
// dropped, the creature never gets it.
|
||||||
|
using CreatureOrderFilter = bool (*)(void* creature, const CreatureOrder& order);
|
||||||
|
void FilterCreatureOrders(CreatureOrderFilter filter);
|
||||||
|
|
||||||
// Creature fields.
|
// Creature fields.
|
||||||
constexpr size_t kCreatureId = 0xc;
|
constexpr size_t kCreatureId = 0xc;
|
||||||
constexpr size_t kCreatureX = 0x1c;
|
constexpr size_t kCreatureX = 0x1c;
|
||||||
|
|||||||
@@ -77,13 +77,50 @@ inline uint32_t ScriptLabelCodeSize(const uint8_t* label) {
|
|||||||
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
|
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
|
||||||
// (0x46bd80) reads a label's code and runs it instruction by instruction with
|
// (0x46bd80) reads a label's code and runs it instruction by instruction with
|
||||||
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
|
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
|
||||||
// position, 0x09 + its dialog, ...; e.g. Region15Init), 0x44 sets a script
|
// position, 0x09 + its dialog, ...; e.g. Region15Init). Script variables are
|
||||||
// variable ({0x01 name, 0x0b value}).
|
// bit fields ({0x01 name, 0x0b n}; measured 2026-10-27): 0x43 sets the value,
|
||||||
|
// 0x44 sets bit n (0x49bb30), 0x45 clears bit n (0x49c450), 0x4b adds n
|
||||||
|
// (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value
|
||||||
|
// (0x498aa0; a missing variable is false).
|
||||||
constexpr uint16_t kScriptSpawn = 0x01;
|
constexpr uint16_t kScriptSpawn = 0x01;
|
||||||
constexpr uint16_t kScriptSetVariable = 0x44;
|
// More opcodes (keyword table 0x452540, name i <-> opcode; docs/quest-audit.md):
|
||||||
|
// 3 NPC state ("Res:<id>", 0x09 dialog, flag 0x08 with 0x12 hostile, ...),
|
||||||
|
// 8 create an object, 0x0f / 0x36 end the quest (ExitQuest / LoseQuest), 0x11 /
|
||||||
|
// 0x12 give experience / gold, 0x14 start quest #id, 0x15 set up quest #id,
|
||||||
|
// 0x16 call a label, 0x34 set hit points ("Res:<id>", percent), 0x37 delete an
|
||||||
|
// NPC or object, 0x3a / 0x42 if / else if, 0x3c dialog answer (text, label),
|
||||||
|
// 0x4f change creature type, 0x75 activate quest #id.
|
||||||
|
constexpr uint16_t kScriptSetNpcState = 0x03;
|
||||||
|
constexpr uint16_t kScriptCreateObject = 0x08;
|
||||||
|
constexpr uint16_t kScriptGiveExperience = 0x11;
|
||||||
|
constexpr uint16_t kScriptGiveGold = 0x12;
|
||||||
|
constexpr uint16_t kScriptStartQuest = 0x14;
|
||||||
|
constexpr uint16_t kScriptSetUpQuest = 0x15;
|
||||||
|
constexpr uint16_t kScriptCallLabel = 0x16;
|
||||||
|
constexpr uint16_t kScriptSetHitPoints = 0x34;
|
||||||
|
constexpr uint16_t kScriptLoseQuest = 0x36;
|
||||||
|
constexpr uint16_t kScriptDelete = 0x37;
|
||||||
|
constexpr uint16_t kScriptIf = 0x3a;
|
||||||
|
constexpr uint16_t kScriptAnswer = 0x3c;
|
||||||
|
constexpr uint16_t kScriptElseIf = 0x42;
|
||||||
|
constexpr uint16_t kScriptChangeType = 0x4f;
|
||||||
|
constexpr uint16_t kScriptActivateQuest = 0x75;
|
||||||
|
constexpr uint16_t kScriptSetVarValue = 0x43;
|
||||||
|
constexpr uint16_t kScriptSetVarBit = 0x44;
|
||||||
|
constexpr uint16_t kScriptClearVarBit = 0x45;
|
||||||
|
constexpr uint16_t kScriptAddToVar = 0x4b;
|
||||||
|
inline bool IsScriptVariableWrite(uint16_t opcode) {
|
||||||
|
return opcode == kScriptSetVarValue || opcode == kScriptSetVarBit || opcode == kScriptClearVarBit ||
|
||||||
|
opcode == kScriptAddToVar;
|
||||||
|
}
|
||||||
constexpr uint8_t kScriptArgString = 0x01;
|
constexpr uint8_t kScriptArgString = 0x01;
|
||||||
constexpr uint8_t kScriptArgInt = 0x0b;
|
constexpr uint8_t kScriptArgInt = 0x0b;
|
||||||
constexpr uint8_t kScriptArgDialog = 0x09;
|
constexpr uint8_t kScriptArgDialog = 0x09;
|
||||||
|
constexpr uint8_t kScriptArgAttitude = 0x08; // flag, followed by 0x12 (hostile) or 0x14
|
||||||
|
|
||||||
|
// The generic quests (Quest_End copies them under a new id; docs/quests.md).
|
||||||
|
constexpr int32_t kGenericQuestFirst = 2600;
|
||||||
|
constexpr int32_t kGenericQuestLast = 3599;
|
||||||
|
|
||||||
// CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
|
// CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*.
|
||||||
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
|
inline void* ScriptFile() { return *reinterpret_cast<void**>(0x00aaaf04); }
|
||||||
@@ -138,6 +175,25 @@ inline uint8_t* QuestManager() { return reinterpret_cast<uint8_t*>(0x00aaaf00);
|
|||||||
constexpr size_t kBookBegin = 0x424;
|
constexpr size_t kBookBegin = 0x424;
|
||||||
constexpr size_t kBookEnd = 0x428;
|
constexpr size_t kBookEnd = 0x428;
|
||||||
constexpr size_t kBookEntrySize = 0x174;
|
constexpr size_t kBookEntrySize = 0x174;
|
||||||
|
|
||||||
|
// The script variables: a vector at quest manager +0x7550 / +0x7554 of 0x24-byte
|
||||||
|
// entries {name[0x20], value} (read by the conditions, 0x498aa0 case 0x49).
|
||||||
|
constexpr size_t kVariablesBegin = 0x7550;
|
||||||
|
constexpr size_t kVariablesEnd = 0x7554;
|
||||||
|
constexpr size_t kVariableSize = 0x24;
|
||||||
|
constexpr size_t kVariableValue = 0x20;
|
||||||
|
|
||||||
|
// The value slot of script variable `name`, nullptr if it does not exist yet.
|
||||||
|
inline int32_t* ScriptVariable(const char* name) {
|
||||||
|
uint8_t* begin = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesBegin);
|
||||||
|
uint8_t* end = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesEnd);
|
||||||
|
for (uint8_t* entry = begin; entry && entry < end; entry += kVariableSize) {
|
||||||
|
if (strncmp(reinterpret_cast<const char*>(entry), name, kVariableValue) == 0) {
|
||||||
|
return reinterpret_cast<int32_t*>(entry + kVariableValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
constexpr size_t kBookCapacity = 0x42c;
|
constexpr size_t kBookCapacity = 0x42c;
|
||||||
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
|
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
|
||||||
constexpr size_t kBookState = 0x04;
|
constexpr size_t kBookState = 0x04;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#include "game/engine/fx.h"
|
#include "game/engine/fx.h"
|
||||||
#include "game/engine/objects.h"
|
#include "game/engine/objects.h"
|
||||||
#include "game/engine/orders.h"
|
#include "game/engine/orders.h"
|
||||||
|
#include "mods/dwarfarts.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
#include "re/patch.h"
|
#include "re/patch.h"
|
||||||
@@ -126,6 +127,9 @@ uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_
|
|||||||
RE_HOOK(0x00582c10, Arts_AddEffect);
|
RE_HOOK(0x00582c10, Arts_AddEffect);
|
||||||
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
|
uint32_t __fastcall Arts_AddEffect(void* self, void* edx, uint32_t kind, uint32_t a2, float value, float duration,
|
||||||
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) {
|
uint32_t art, uint32_t a6, uint32_t a7, uint32_t a8) {
|
||||||
|
if (!mods::dwarfarts::KeepEffect(kind, art)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
|
if (self && mods::aurarecast::Renew(static_cast<uint8_t*>(self), kind, duration, art)) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,30 @@
|
|||||||
// the point; the circle is drawn after the world view's ground (0x62d530,
|
// the point; the circle is drawn after the world view's ground (0x62d530,
|
||||||
// before the objects) until the impact gathers its targets (the grenade
|
// before the objects) until the impact gathers its targets (the grenade
|
||||||
// object itself stays about 30 s longer).
|
// object itself stays about 30 s longer).
|
||||||
|
//
|
||||||
|
// Eingraben (spell 0x39), the dwarf's escape (request of 2026-10-28): "cast a
|
||||||
|
// protective barrier around the Dwarf (current visual effect), clear him of
|
||||||
|
// all movement impeding effects and then teleport to the position marked by
|
||||||
|
// the cursor (if possible)", shown as the dwarf going underground and coming
|
||||||
|
// out at the target, 1 s each; range the visible area; the barrier is the
|
||||||
|
// skill's current defensive bonus for 3 s; "he should be able to see the
|
||||||
|
// target location"; like the battlemage's Phasenverschiebung. The spells run
|
||||||
|
// in cCreature::CastSpell (0x5222c0, thiscall (cast), ret 4; cast +0x08 /
|
||||||
|
// +0x0c the ground point, +0x20 the spell). Case 0x39 creates object type
|
||||||
|
// 0x480 at the caster with cEventFX 0x9d (owner and target the caster) and
|
||||||
|
// plays sound 0x840: the barrier, which gives the bonus. Case 1
|
||||||
|
// (Phasenverschiebung) walks from the caster towards the target in steps of
|
||||||
|
// 10 while the cell is passable (world 0x636b60) and keeps the last point a
|
||||||
|
// creature can stand on (0x636eb0); a wall in the way ends the walk, so the
|
||||||
|
// target has to be in sight. The mod aims Eingraben at a point (definition
|
||||||
|
// +0x3c: 5 around the caster -> 3 a point), runs the original case (the barrier
|
||||||
|
// and its bonus last kBarrierSeconds) and moves the dwarf to the end of that
|
||||||
|
// walk; the cursor limits it to the visible area. The move takes 2 s without
|
||||||
|
// orders: the barrier turns solid, he moves inside it, it clears again
|
||||||
|
// ("not instant ... from transparent to solid and back", 2026-10-28; sinking
|
||||||
|
// him into the ground failed: the camera follows his height).
|
||||||
|
|
||||||
|
#include "mods/dwarfarts.h"
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
@@ -46,6 +70,7 @@
|
|||||||
|
|
||||||
#include "game/engine/art.h"
|
#include "game/engine/art.h"
|
||||||
#include "game/engine/arts.h"
|
#include "game/engine/arts.h"
|
||||||
|
#include "game/engine/events.h"
|
||||||
#include "game/engine/objects.h"
|
#include "game/engine/objects.h"
|
||||||
#include "game/engine/orders.h"
|
#include "game/engine/orders.h"
|
||||||
#include "game/engine/worldview.h"
|
#include "game/engine/worldview.h"
|
||||||
@@ -406,7 +431,162 @@ void DrawMarker(const uint8_t* view, void* device, const Grenade& grenade) {
|
|||||||
game::FillTriangles(device, vertices.data(), vertices.size());
|
game::FillTriangles(device, vertices.data(), vertices.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Eingraben ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// The art's duration (0x4d24f0: definition +0x76 + level × +0x78, at most
|
||||||
|
// +0x82 when set; Eingraben 7 + level, at most 15), which the barrier, its
|
||||||
|
// bonus and the tooltip use: a fixed kBarrierSeconds ("down to 4 seconds",
|
||||||
|
// 2026-10-28).
|
||||||
|
constexpr int16_t kBarrierSeconds = 4;
|
||||||
|
constexpr size_t kSpellDurationBase = 0x76; // i16
|
||||||
|
constexpr size_t kSpellDurationPerLevel = 0x78; // i16
|
||||||
|
constexpr size_t kSpellDurationMax = 0x82; // u16, 0: none
|
||||||
|
// The barrier's effects (logged 2026-10-28, all with art 0x39): kind 0x3a
|
||||||
|
// value 0 from its particles (0x7b9602), which holds the dwarf in place, and
|
||||||
|
// kind 2 value 0.5 plus kinds 7..0xa value 30 from its update (0x5b5862..),
|
||||||
|
// the defensive bonus.
|
||||||
|
constexpr uint32_t kEffectDugIn = 0x3a;
|
||||||
|
constexpr int32_t kWalkStep = 10; // Phasenverschiebung's step (0x52288a: 10.0)
|
||||||
|
constexpr size_t kSpellLaunchType = 0x3c;
|
||||||
|
|
||||||
|
// The world (cWorld, the object manager's first field), as the art code
|
||||||
|
// passes it to its cell checks.
|
||||||
|
void* World() {
|
||||||
|
void* manager = game::ObjectManager();
|
||||||
|
return manager ? *static_cast<void**>(manager) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// cWorld 0x636b60 (thiscall (position), ret 4): the cell is passable.
|
||||||
|
bool Passable(void* world, game::MapPosition& at) {
|
||||||
|
using Fn = bool(__fastcall*)(void*, void*, game::MapPosition*);
|
||||||
|
return reinterpret_cast<Fn>(0x00636b60)(world, nullptr, &at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cWorld 0x636eb0 (thiscall (creature id, position), ret 8): the creature can
|
||||||
|
// stand there (may correct the position's area).
|
||||||
|
bool Standable(void* world, uint32_t creature, game::MapPosition& at) {
|
||||||
|
using Fn = bool(__fastcall*)(void*, void*, uint32_t, game::MapPosition*);
|
||||||
|
return reinterpret_cast<Fn>(0x00636eb0)(world, nullptr, creature, &at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phasenverschiebung's walk (0x5227a6..): from the creature towards (x, y),
|
||||||
|
// the last point it can stand on before a cell that is not passable.
|
||||||
|
game::MapPosition DigDestination(uint8_t* creature, int32_t x, int32_t y) {
|
||||||
|
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
|
||||||
|
game::MapPosition best = from;
|
||||||
|
void* world = World();
|
||||||
|
const float dx = static_cast<float>(x - from.x), dy = static_cast<float>(y - from.y);
|
||||||
|
const float length = std::sqrt(dx * dx + dy * dy);
|
||||||
|
if (!world || length < kWalkStep) {
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
const auto stepX = static_cast<int32_t>(dx / length * kWalkStep);
|
||||||
|
const auto stepY = static_cast<int32_t>(dy / length * kWalkStep);
|
||||||
|
const auto steps = static_cast<int32_t>(length / kWalkStep);
|
||||||
|
const uint32_t id = *reinterpret_cast<const uint32_t*>(creature + game::kCreatureId);
|
||||||
|
game::MapPosition at = from;
|
||||||
|
for (int32_t i = 0; i <= steps && Passable(world, at); ++i) {
|
||||||
|
game::MapPosition stand = at;
|
||||||
|
if (Standable(world, id, stand)) {
|
||||||
|
best = stand;
|
||||||
|
}
|
||||||
|
at.x += stepX;
|
||||||
|
at.y += stepY;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dig takes kFadeMs twice: the barrier turns solid, the dwarf moves
|
||||||
|
// inside it, and it turns transparent again; he takes no orders meanwhile.
|
||||||
|
constexpr DWORD kFadeMs = 1000;
|
||||||
|
|
||||||
|
struct Digging {
|
||||||
|
uint32_t id = 0; // 0: none
|
||||||
|
DWORD start = 0; // GetTickCount of the cast, or of the move once moved
|
||||||
|
game::MapPosition to;
|
||||||
|
bool moved = false;
|
||||||
|
};
|
||||||
|
Digging g_dig;
|
||||||
|
|
||||||
|
void Dig(uint8_t* creature, int32_t x, int32_t y) {
|
||||||
|
const auto& from = *reinterpret_cast<const game::MapPosition*>(creature + game::kObjectPosition);
|
||||||
|
const game::MapPosition to = DigDestination(creature, x, y);
|
||||||
|
re::Log("dwarfarts: Eingraben %d,%d towards %d,%d -> %d,%d", from.x, from.y, x, y, to.x, to.y);
|
||||||
|
if (to.x == from.x && to.y == from.y) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_dig = {*reinterpret_cast<const uint32_t*>(creature + game::kCreatureId), GetTickCount(), to, false};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine tick: once the barrier is solid, the move; the dig's end.
|
||||||
|
void OnTick(uint8_t*) {
|
||||||
|
auto* creature = g_dig.id ? static_cast<uint8_t*>(game::GetCreature(g_dig.id)) : nullptr;
|
||||||
|
if (!creature || GetTickCount() - g_dig.start < kFadeMs) {
|
||||||
|
g_dig.id = creature ? g_dig.id : 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_dig.moved) {
|
||||||
|
g_dig.id = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
game::Teleport(creature, static_cast<int32_t>(g_dig.to.x / game::kMapCellSize),
|
||||||
|
static_cast<int32_t>(g_dig.to.y / game::kMapCellSize));
|
||||||
|
g_dig.moved = true;
|
||||||
|
g_dig.start = GetTickCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AcceptOrder(void* creature, const game::CreatureOrder&) {
|
||||||
|
return !g_dig.id || *reinterpret_cast<const uint32_t*>(static_cast<uint8_t*>(creature) + game::kCreatureId) != g_dig.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnGameEnd() {
|
||||||
|
g_dig = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The barrier (cParticleSystem_dwarf_shield, draw 0x7b92b0) draws its model
|
||||||
|
// with alpha pulse × 128 (pulse 0.4..1, +0x20b8; __ftol at 0x7b9483), then
|
||||||
|
// again additively. While a dig runs that alpha goes to opaque and back.
|
||||||
|
constexpr uint32_t kShieldAlphaToInt = 0x007b9483;
|
||||||
|
constexpr uint32_t kFloatToInt = 0x00848f94; // __ftol
|
||||||
|
constexpr int32_t kOpaque = 255;
|
||||||
|
|
||||||
|
int32_t __cdecl ShieldAlpha(int32_t alpha) {
|
||||||
|
if (!g_dig.id) {
|
||||||
|
return alpha;
|
||||||
|
}
|
||||||
|
const float part = std::fmin(static_cast<float>(GetTickCount() - g_dig.start) / kFadeMs, 1.0f);
|
||||||
|
const float solid = g_dig.moved ? 1.0f - part : part;
|
||||||
|
return alpha + static_cast<int32_t>(static_cast<float>(kOpaque - alpha) * solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
__declspec(naked) void ShieldAlphaThunk() {
|
||||||
|
__asm {
|
||||||
|
mov eax, kFloatToInt
|
||||||
|
call eax
|
||||||
|
push eax
|
||||||
|
call ShieldAlpha
|
||||||
|
add esp, 4
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AimEingraben() {
|
||||||
|
auto* def = const_cast<uint8_t*>(game::FindSpellDef(game::kArtEingraben));
|
||||||
|
if (!def || def[kSpellLaunchType] != static_cast<uint8_t>(game::LaunchType::AroundSelf)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
def[kSpellLaunchType] = static_cast<uint8_t>(game::LaunchType::Ground);
|
||||||
|
*reinterpret_cast<int16_t*>(def + kSpellDurationBase) = kBarrierSeconds;
|
||||||
|
*reinterpret_cast<int16_t*>(def + kSpellDurationPerLevel) = 0;
|
||||||
|
*reinterpret_cast<uint16_t*>(def + kSpellDurationMax) = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void Startup() {
|
void Startup() {
|
||||||
|
const bool aim = AimEingraben();
|
||||||
|
re::Log("dwarfarts: Eingraben aimed at a point, barrier %d s%s", kBarrierSeconds, aim ? "" : " (patch FAILED)");
|
||||||
|
const bool fade = re::RedirectCall(kShieldAlphaToInt, kFloatToInt, reinterpret_cast<const void*>(&ShieldAlphaThunk));
|
||||||
|
re::Log("dwarfarts: Eingraben barrier turns solid%s", fade ? "" : " (patch FAILED)");
|
||||||
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
|
const bool impact = re::RedirectCall(kImpactCreate, kCreateAt, reinterpret_cast<const void*>(&CreateImpactThunk));
|
||||||
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
|
re::Log("dwarfarts: Kanonenschuss impact only on stuns%s", impact ? "" : " (patch FAILED)");
|
||||||
uint8_t call[sizeof(kPushStepCode)];
|
uint8_t call[sizeof(kPushStepCode)];
|
||||||
@@ -432,12 +612,19 @@ RE_STARTUP(DwarfArtsStartup, Startup);
|
|||||||
struct Registration {
|
struct Registration {
|
||||||
Registration() {
|
Registration() {
|
||||||
game::OnCreatureOrder(&OnOrder);
|
game::OnCreatureOrder(&OnOrder);
|
||||||
|
game::FilterCreatureOrders(&AcceptOrder);
|
||||||
|
game::OnGameEnd(&OnGameEnd);
|
||||||
|
game::OnEngineTick(&OnTick);
|
||||||
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
|
game::AddTooltipFilter(game::kArtMoerser, &RewriteTooltip);
|
||||||
}
|
}
|
||||||
} g_registration;
|
} g_registration;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
bool KeepEffect(uint32_t kind, uint32_t art) {
|
||||||
|
return art != game::kArtEingraben || kind != kEffectDugIn;
|
||||||
|
}
|
||||||
|
|
||||||
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
|
uint32_t* __fastcall GatherTargets(void* manager, void*, uint32_t* out, const void* position, uint32_t type,
|
||||||
uint32_t only, int32_t radius, const uint8_t* fx) {
|
uint32_t only, int32_t radius, const uint8_t* fx) {
|
||||||
if (type == kGrenadeImpactType && radius < 0 && fx) {
|
if (type == kGrenadeImpactType && radius < 0 && fx) {
|
||||||
@@ -481,4 +668,17 @@ void __fastcall WorldView_DrawGroundQuads(uint8_t* self, void* edx, void* render
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
} // 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
|
||||||
+182
-11
@@ -22,12 +22,24 @@
|
|||||||
// compass on the giver, or is removed if the giver is not known. Accepting it
|
// compass on the giver, or is removed if the giver is not known. Accepting it
|
||||||
// again removes the failed entry. Quests that failed before the mod are reset
|
// again removes the failed entry. Quests that failed before the mod are reset
|
||||||
// the same way once after loading.
|
// the same way once after loading.
|
||||||
|
//
|
||||||
|
// Quest audit (2026-10-30, docs/quest-audit.md): a restart also puts back what
|
||||||
|
// the quest's script needs to be offered again (questscript.cpp): its variables
|
||||||
|
// as they were when it became available (kept at Quest_Trigger, saved with the
|
||||||
|
// hero; for quests triggered earlier: 1 for the one named after the quest, else
|
||||||
|
// 0), the giver's accept dialog, the giver and the NPCs it needs from their
|
||||||
|
// spawn instructions; what its QIS_OnEnter created is deleted (accepting again
|
||||||
|
// creates it anew). A quest stays failed when the game repeats it itself
|
||||||
|
// (generic quests), when it has no giver (trigger areas), when its failure ends
|
||||||
|
// the story (2402 sets up 2403, 5003's shaman turns hostile, ...) or when an NPC
|
||||||
|
// it needs is gone for good.
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
@@ -37,6 +49,7 @@
|
|||||||
#include "game/engine/quests.h"
|
#include "game/engine/quests.h"
|
||||||
#include "mods/inventory_save.h"
|
#include "mods/inventory_save.h"
|
||||||
#include "mods/questfail.h"
|
#include "mods/questfail.h"
|
||||||
|
#include "mods/questscript.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
@@ -63,7 +76,10 @@ bool g_resetAfterLoad = false;
|
|||||||
bool g_inDialogAnswer = false;
|
bool g_inDialogAnswer = false;
|
||||||
|
|
||||||
// Quest id -> the giver's map cell.
|
// Quest id -> the giver's map cell.
|
||||||
std::map<int32_t, std::pair<int32_t, int32_t>> g_givers;
|
std::map<int32_t, std::pair<int32_t, int32_t>> g_giverCells;
|
||||||
|
|
||||||
|
// Quest id -> its variables when it became available.
|
||||||
|
std::map<int32_t, std::map<std::string, int32_t>> g_freshVariables;
|
||||||
|
|
||||||
// The nearest living, not hostile, non-hero creature near the hero, or 0. All
|
// The nearest living, not hostile, non-hero creature near the hero, or 0. All
|
||||||
// objects are searched (the nearby-creature list 0xad1c60 did not hold the
|
// objects are searched (the nearby-creature list 0xad1c60 did not hold the
|
||||||
@@ -105,15 +121,15 @@ void RememberGiver(int32_t questId) {
|
|||||||
const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) +
|
const auto* position = reinterpret_cast<const game::MapPosition*>(static_cast<uint8_t*>(creature) +
|
||||||
game::kObjectPosition);
|
game::kObjectPosition);
|
||||||
const game::MapPosition cell = game::CellAt(creature, position->x, position->y);
|
const game::MapPosition cell = game::CellAt(creature, position->x, position->y);
|
||||||
g_givers[questId] = {cell.x, cell.y};
|
g_giverCells[questId] = {cell.x, cell.y};
|
||||||
re::Log("questfail: quest %d accepted from creature %u at cell %d,%d", questId, npc, cell.x, cell.y);
|
re::Log("questfail: quest %d accepted from creature %u at cell %d,%d", questId, npc, cell.x, cell.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The entry's compass on its quest's giver (a cell target, no radius). False if
|
// The entry's compass on its quest's giver (a cell target, no radius). False if
|
||||||
// the giver is not known.
|
// the giver is not known.
|
||||||
bool PointAtGiver(uint8_t* entry) {
|
bool PointAtGiver(uint8_t* entry) {
|
||||||
const auto giver = g_givers.find(game::BookQuestId(entry));
|
const auto giver = g_giverCells.find(game::BookQuestId(entry));
|
||||||
if (giver == g_givers.end()) {
|
if (giver == g_giverCells.end()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
|
auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast<int32_t*>(entry + offset); };
|
||||||
@@ -123,6 +139,63 @@ bool PointAtGiver(uint8_t* entry) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The variable named after the quest keeps bit 0: "available", which the
|
||||||
|
// giver's offer tests (auftrag402: IsVarBit 402 0; measured 2026-10-28: all 87
|
||||||
|
// quests not yet accepted had value 1).
|
||||||
|
int32_t FreshValue(int32_t questId, const std::string& name) {
|
||||||
|
const int32_t available = name == std::to_string(questId) ? 1 : 0;
|
||||||
|
const auto quest = g_freshVariables.find(questId);
|
||||||
|
if (quest == g_freshVariables.end()) {
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
const auto value = quest->second.find(name);
|
||||||
|
return (value != quest->second.end() ? value->second : 0) | available;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResetVariables(int32_t questId, const questscript::Quest& quest) {
|
||||||
|
for (const std::string& name : quest.variables) {
|
||||||
|
int32_t* value = game::ScriptVariable(name.c_str());
|
||||||
|
const int32_t fresh = FreshValue(questId, name);
|
||||||
|
if (!value || *value == fresh) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
re::Log("questfail: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh);
|
||||||
|
*value = fresh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why failed quest `index` cannot be offered again, or null. `afterLoad`: the
|
||||||
|
// failure is not running now, so any failure of the quest that ends its story
|
||||||
|
// counts.
|
||||||
|
const char* StaysFailed(int index, bool afterLoad) {
|
||||||
|
const int32_t id = game::QuestId(game::Quest(index));
|
||||||
|
const questscript::Quest* quest = questscript::Find(id);
|
||||||
|
if (!quest || (id >= game::kGenericQuestFirst && id <= game::kGenericQuestLast)) {
|
||||||
|
return "the game repeats it itself";
|
||||||
|
}
|
||||||
|
if (!quest->hasGiver && !g_giverCells.count(id)) {
|
||||||
|
return "no giver";
|
||||||
|
}
|
||||||
|
if (afterLoad ? quest->hasFinalFailure : questscript::InFinalFailure()) {
|
||||||
|
return "its failure ends the story";
|
||||||
|
}
|
||||||
|
if (quest->hasGiver && questscript::IsGone(quest->giver) && quest->giver.spawn.empty() &&
|
||||||
|
!questscript::SpawnOf(quest->giver.scriptId, id)) {
|
||||||
|
return "its giver is gone";
|
||||||
|
}
|
||||||
|
for (uint32_t npc : quest->needed) {
|
||||||
|
if (!questscript::LivingNpc(npc) && !questscript::SpawnOf(npc, id)) {
|
||||||
|
re::Log("questfail: quest %d needs Res:%u, gone without a spawn instruction", id, npc);
|
||||||
|
return "an NPC it needs is gone";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed quests in the book: those failed before the mod are restarted unless
|
||||||
|
// they stay failed; those restarted before the variables were reset get their
|
||||||
|
// variables back (their giver may meanwhile offer a later quest, so the
|
||||||
|
// dialog stays).
|
||||||
void RestartFailedInBook() {
|
void RestartFailedInBook() {
|
||||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
for (size_t i = game::BookCount(); i-- > 0;) {
|
||||||
const uint8_t* entry = game::BookEntry(i);
|
const uint8_t* entry = game::BookEntry(i);
|
||||||
@@ -130,12 +203,21 @@ void RestartFailedInBook() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const int index = game::QuestIndex(game::BookQuestId(entry));
|
const int index = game::QuestIndex(game::BookQuestId(entry));
|
||||||
if (index >= 0) {
|
const uint32_t flags = index >= 0 ? game::QuestFlags(game::Quest(index)) : 0;
|
||||||
Restart(index);
|
if (index < 0 || ((flags & game::kQuestInProgress) && !(flags & game::kQuestDone))) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!(flags & game::kQuestDone)) {
|
||||||
|
const int32_t id = game::QuestId(game::Quest(index));
|
||||||
|
if (const questscript::Quest* quest = questscript::Find(id)) {
|
||||||
|
ResetVariables(id, *quest);
|
||||||
|
}
|
||||||
|
} else if (!StaysFailed(index, true)) {
|
||||||
|
Restart(index);
|
||||||
i = std::min(i, game::BookCount());
|
i = std::min(i, game::BookCount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void EraseFailedEntries(int32_t questId) {
|
void EraseFailedEntries(int32_t questId) {
|
||||||
for (size_t i = game::BookCount(); i-- > 0;) {
|
for (size_t i = game::BookCount(); i-- > 0;) {
|
||||||
@@ -160,11 +242,11 @@ void OnLoaded() {
|
|||||||
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------
|
// --- saved with the hero: {"quest id": [x, y]} ------------------------------------
|
||||||
|
|
||||||
nlohmann::json WriteGivers(unsigned) {
|
nlohmann::json WriteGivers(unsigned) {
|
||||||
if (g_givers.empty()) {
|
if (g_giverCells.empty()) {
|
||||||
return nlohmann::json();
|
return nlohmann::json();
|
||||||
}
|
}
|
||||||
nlohmann::json all = nlohmann::json::object();
|
nlohmann::json all = nlohmann::json::object();
|
||||||
for (const auto& [id, cell] : g_givers) {
|
for (const auto& [id, cell] : g_giverCells) {
|
||||||
all[std::to_string(id)] = {cell.first, cell.second};
|
all[std::to_string(id)] = {cell.first, cell.second};
|
||||||
}
|
}
|
||||||
return all;
|
return all;
|
||||||
@@ -177,7 +259,41 @@ void ReadGivers(unsigned, const nlohmann::json& data) {
|
|||||||
}
|
}
|
||||||
for (const auto& [key, cell] : data.items()) {
|
for (const auto& [key, cell] : data.items()) {
|
||||||
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
|
if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) {
|
||||||
g_givers[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
|
g_giverCells[std::stoi(key)] = {cell[0].get<int32_t>(), cell[1].get<int32_t>()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- saved with the hero: {"quest id": {"variable": value}} ------------------------
|
||||||
|
|
||||||
|
nlohmann::json WriteFreshVariables(unsigned) {
|
||||||
|
if (g_freshVariables.empty()) {
|
||||||
|
return nlohmann::json();
|
||||||
|
}
|
||||||
|
nlohmann::json all = nlohmann::json::object();
|
||||||
|
for (const auto& [id, variables] : g_freshVariables) {
|
||||||
|
nlohmann::json& quest = all[std::to_string(id)] = nlohmann::json::object();
|
||||||
|
for (const auto& [name, value] : variables) {
|
||||||
|
quest[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merged, as the givers.
|
||||||
|
void ReadFreshVariables(unsigned, const nlohmann::json& data) {
|
||||||
|
if (!data.is_object()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const auto& [key, variables] : data.items()) {
|
||||||
|
if (!variables.is_object()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto& quest = g_freshVariables[std::stoi(key)];
|
||||||
|
for (const auto& [name, value] : variables.items()) {
|
||||||
|
if (value.is_number_integer()) {
|
||||||
|
quest[name] = value.get<int32_t>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +313,31 @@ void Restart(int index) {
|
|||||||
game::EraseBookEntry(i);
|
game::EraseBookEntry(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const questscript::Quest* script = questscript::Find(id);
|
||||||
|
if (!script) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ResetVariables(id, *script);
|
||||||
|
for (uint32_t npc : script->createdOnEnter) {
|
||||||
|
questscript::Delete(npc);
|
||||||
|
}
|
||||||
|
for (uint32_t npc : script->needed) {
|
||||||
|
const std::vector<uint8_t>* spawn = questscript::LivingNpc(npc) ? nullptr : questscript::SpawnOf(npc, id);
|
||||||
|
if (spawn) {
|
||||||
|
questscript::SpawnWhenGone(npc, *spawn, std::string(), "needed by its quest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!script->hasGiver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const questscript::Giver& giver = script->giver;
|
||||||
|
if (!questscript::IsGone(giver)) {
|
||||||
|
questscript::SetDialog(giver.res, giver.dialog);
|
||||||
|
} else if (!giver.spawn.empty()) {
|
||||||
|
questscript::SpawnWhenGone(giver.scriptId, giver.spawn, std::string(), "quest giver");
|
||||||
|
} else if (const std::vector<uint8_t>* spawn = questscript::SpawnOf(giver.scriptId, id)) {
|
||||||
|
questscript::SpawnWhenGone(giver.scriptId, *spawn, giver.dialog, "quest giver");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc):
|
// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc):
|
||||||
@@ -204,11 +345,40 @@ void Restart(int index) {
|
|||||||
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
|
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success);
|
||||||
RE_HOOK(0x0046ced0, Quest_End);
|
RE_HOOK(0x0046ced0, Quest_End);
|
||||||
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success) {
|
void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success) {
|
||||||
|
const bool known = index >= 0 && static_cast<size_t>(index) < game::QuestCount();
|
||||||
|
const char* staysFailed = !success && known ? StaysFailed(index, false) : nullptr;
|
||||||
Quest_End_original(self, edx, index, a2, success);
|
Quest_End_original(self, edx, index, a2, success);
|
||||||
if (!success && index >= 0 && static_cast<size_t>(index) < game::QuestCount()) {
|
if (!success && known) {
|
||||||
|
if (staysFailed) {
|
||||||
|
re::Log("questfail: quest %d '%s' stays failed: %s", game::QuestId(game::Quest(index)),
|
||||||
|
game::QuestName(game::Quest(index)), staysFailed);
|
||||||
|
} else {
|
||||||
Restart(index);
|
Restart(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quest_Trigger (0x46de10, cdecl (index, ?, ?, ?), plain ret): makes the quest
|
||||||
|
// available (flag 1) the first time, running its trigger scripts. Its
|
||||||
|
// variables are kept as they are then: the state a restart goes back to.
|
||||||
|
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4);
|
||||||
|
RE_HOOK(0x0046de10, Quest_Trigger);
|
||||||
|
int __cdecl Quest_Trigger(int index, int a2, int a3, int a4) {
|
||||||
|
if (index >= 0 && static_cast<size_t>(index) < game::QuestCount() &&
|
||||||
|
!(game::QuestFlags(game::Quest(index)) & game::kQuestTriggered)) {
|
||||||
|
const int32_t id = game::QuestId(game::Quest(index));
|
||||||
|
if (const questscript::Quest* quest = questscript::Find(id)) {
|
||||||
|
auto& fresh = g_freshVariables[id];
|
||||||
|
fresh.clear();
|
||||||
|
for (const std::string& name : quest->variables) {
|
||||||
|
if (const int32_t* value = game::ScriptVariable(name.c_str())) {
|
||||||
|
fresh[name] = *value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Quest_Trigger_original(index, a2, a3, a4);
|
||||||
|
}
|
||||||
|
|
||||||
// cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
|
// cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the
|
||||||
// quest script for the answer.
|
// quest script for the answer.
|
||||||
@@ -244,7 +414,7 @@ uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t
|
|||||||
if (index < game::BookCount() && (kind == 0 || kind == 1)) {
|
if (index < game::BookCount() && (kind == 0 || kind == 1)) {
|
||||||
const uint8_t* entry = game::BookEntry(index);
|
const uint8_t* entry = game::BookEntry(index);
|
||||||
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
|
if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide &&
|
||||||
g_givers.count(game::BookQuestId(entry))) {
|
g_giverCells.count(game::BookQuestId(entry))) {
|
||||||
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
|
return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +427,7 @@ struct Register {
|
|||||||
game::OnEngineTick(&OnTick);
|
game::OnEngineTick(&OnTick);
|
||||||
game::OnSaveGameLoaded(&OnLoaded);
|
game::OnSaveGameLoaded(&OnLoaded);
|
||||||
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
|
inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers);
|
||||||
|
inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables);
|
||||||
}
|
}
|
||||||
} g_register;
|
} g_register;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
+42
-245
@@ -5,238 +5,48 @@
|
|||||||
// completed or correctly running").
|
// completed or correctly running").
|
||||||
//
|
//
|
||||||
// Measured: the quest's script (FunkCode.bin) gives the giver a death label
|
// Measured: the quest's script (FunkCode.bin) gives the giver a death label
|
||||||
// when she starts following ("ex1501": quest variable "1501" = 1, then the
|
// when she starts following ("ex1501": bit 1 of quest variable "1501", then the
|
||||||
// poachers' death label "tot1501", which ends the quest without a reward once
|
// poachers' death label "tot1501", which ends the quest without a reward once
|
||||||
// all poachers are dead; until then it stays in progress with no giver). The
|
// all poachers are dead; until then it stays in progress with no giver). The
|
||||||
// body is removed. The giver is placed by her region's start script
|
// body is removed. The giver is placed by her region's start script
|
||||||
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at
|
// (Region15Init: spawn instruction 1 "Res:17013", type 134, at
|
||||||
// pos_auftrag1501, dialog auftrag1501), which the game runs once.
|
// pos_auftrag1501, dialog auftrag1501), which the game runs once.
|
||||||
//
|
//
|
||||||
// The mod finds each quest's giver in the script code: the NPC whose spawn
|
// The giver is the NPC whose spawn instruction carries the quest's accept
|
||||||
// instruction carries a dialog that has a "Dialog:<name>" label of the quest.
|
// dialog (questscript.cpp). When a label of that quest runs for its dead giver
|
||||||
// When a label of that quest runs for its dead giver (her death label), the
|
// (her death label), the label is skipped and the quest restarts
|
||||||
// label is skipped; the quest restarts (questfail::Restart, its book entry
|
// (questfail::Restart: available again, its book entry failed and pointing at
|
||||||
// failed and pointing at the giver), the variables only this quest's labels
|
// the giver, its variables as before, the giver spawned again once the body is
|
||||||
// set go back to 0, and once the body is gone the game runs the giver's
|
// gone). After a load, a quest in progress whose giver no longer exists is
|
||||||
// spawn instruction again: she stands at her start, offering the quest.
|
// repaired the same way, unless the quest itself turns the giver into an enemy
|
||||||
// After a load, a quest in progress whose giver no longer exists is repaired
|
// or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose).
|
||||||
// the same way.
|
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <map>
|
|
||||||
#include <set>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <windows.h>
|
|
||||||
|
|
||||||
#include "game/engine/events.h"
|
#include "game/engine/events.h"
|
||||||
#include "game/engine/objects.h"
|
#include "game/engine/objects.h"
|
||||||
#include "game/engine/orders.h"
|
#include "game/engine/orders.h"
|
||||||
#include "game/engine/quests.h"
|
#include "game/engine/quests.h"
|
||||||
#include "mods/questfail.h"
|
#include "mods/questfail.h"
|
||||||
|
#include "mods/questscript.h"
|
||||||
#include "re/hook.h"
|
#include "re/hook.h"
|
||||||
#include "re/log.h"
|
#include "re/log.h"
|
||||||
|
|
||||||
namespace mods::questgiver {
|
namespace mods::questgiver {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr size_t kInstructionHeader = 4;
|
|
||||||
constexpr size_t kMaxInstruction = 0x800; // 0x475970 refuses larger ones
|
|
||||||
constexpr size_t kReadChunk = 1 << 20;
|
|
||||||
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
|
|
||||||
constexpr uint16_t kScriptStartQuest = 0x14; // {0x0b quest id}
|
|
||||||
constexpr size_t kStartQuestSize = 9;
|
|
||||||
constexpr uint16_t kScriptAnswer = 0x3c; // dialog answer {0x01 text, 0x01 label}
|
|
||||||
|
|
||||||
struct Giver {
|
|
||||||
uint32_t scriptId = 0;
|
|
||||||
std::vector<uint8_t> spawn; // the spawn instruction
|
|
||||||
};
|
|
||||||
|
|
||||||
struct Pending {
|
|
||||||
int questIndex;
|
|
||||||
void* body; // the dead giver's object
|
|
||||||
uint32_t bodyId;
|
|
||||||
DWORD since;
|
|
||||||
};
|
|
||||||
|
|
||||||
bool g_scanned = false;
|
|
||||||
std::vector<uint8_t> g_code; // FunkCode.bin
|
|
||||||
std::map<int32_t, Giver> g_givers; // quest id -> giver
|
|
||||||
std::map<int32_t, std::vector<std::string>> g_variables; // quest id -> variables only it sets
|
|
||||||
std::vector<Pending> g_pending;
|
|
||||||
bool g_repairAfterLoad = false;
|
bool g_repairAfterLoad = false;
|
||||||
|
|
||||||
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
|
// The quest's giver if M2 can bring her back: a numbered script id spawned with
|
||||||
|
// the accept dialog.
|
||||||
bool ReadCode() {
|
const questscript::Giver* RespawnableGiver(int32_t questId) {
|
||||||
g_code.clear();
|
const questscript::Quest* quest = questscript::Find(questId);
|
||||||
for (uint32_t offset = 0;; offset += kReadChunk) {
|
return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver
|
||||||
g_code.resize(offset + kReadChunk);
|
|
||||||
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
|
|
||||||
g_code.resize(offset + read);
|
|
||||||
if (read < kReadChunk) {
|
|
||||||
return !g_code.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calls `fn(at, opcode, size)` for each instruction of a label's code.
|
|
||||||
template <typename Fn>
|
|
||||||
void ForEachInstruction(const uint8_t* label, Fn fn) {
|
|
||||||
const size_t begin = game::ScriptLabelCode(label);
|
|
||||||
const size_t end = begin + game::ScriptLabelCodeSize(label);
|
|
||||||
for (size_t at = begin; at + kInstructionHeader <= end && end <= g_code.size();) {
|
|
||||||
const uint16_t size = U16(at + 2);
|
|
||||||
if (size < kInstructionHeader || at + size > end) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fn(at, U16(at), size);
|
|
||||||
at += size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The spawn instructions in the code: "Res:<number>" with a dialog.
|
|
||||||
void ScanSpawns(const std::map<std::string, int32_t>& dialogs) {
|
|
||||||
static const char kRes[] = "res:";
|
|
||||||
for (size_t at = 0; at + 9 < g_code.size(); ++at) {
|
|
||||||
if (U16(at) != game::kScriptSpawn || g_code[at + 4] != game::kScriptArgString ||
|
|
||||||
_strnicmp(reinterpret_cast<const char*>(&g_code[at + 5]), kRes, 4) != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const uint16_t size = U16(at + 2);
|
|
||||||
if (size <= 9 || size > kMaxInstruction || at + size > g_code.size()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
char* numberEnd = nullptr;
|
|
||||||
const unsigned long scriptId = std::strtoul(reinterpret_cast<const char*>(&g_code[at + 9]), &numberEnd, 10);
|
|
||||||
if (scriptId == 0 || *numberEnd != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (size_t tag = at + 9; tag + 1 < at + size; ++tag) {
|
|
||||||
if (g_code[tag] != game::kScriptArgDialog) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const char* name = reinterpret_cast<const char*>(&g_code[tag + 1]);
|
|
||||||
if (!memchr(name, 0, at + size - tag - 1)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const auto dialog = dialogs.find(name);
|
|
||||||
if (dialog != dialogs.end() && !g_givers.count(dialog->second)) {
|
|
||||||
g_givers[dialog->second] = {static_cast<uint32_t>(scriptId),
|
|
||||||
std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size)};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The variables each quest's labels set; a variable set by several quests
|
|
||||||
// (or by labels of none) is not any one quest's.
|
|
||||||
void ScanVariables() {
|
|
||||||
std::map<std::string, std::set<int32_t>> setters;
|
|
||||||
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
|
|
||||||
label += game::kScriptLabelSize) {
|
|
||||||
const int32_t quest = game::ScriptLabelQuest(label);
|
|
||||||
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
|
|
||||||
if (opcode == game::kScriptSetVariable && g_code[at + 4] == game::kScriptArgString &&
|
|
||||||
memchr(&g_code[at + 5], 0, size - 5)) {
|
|
||||||
setters[reinterpret_cast<const char*>(&g_code[at + 5])].insert(quest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const auto& [name, quests] : setters) {
|
|
||||||
if (quests.size() == 1 && *quests.begin() >= 0 && g_givers.count(*quests.begin())) {
|
|
||||||
g_variables[*quests.begin()].push_back(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void Scan() {
|
|
||||||
if (g_scanned) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
g_scanned = true;
|
|
||||||
if (!ReadCode()) {
|
|
||||||
re::Log("questgiver: script code not readable");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Labels by name, and the accept labels: code starting with opcode 0x14
|
|
||||||
// (start quest #id, 0x48f320; e.g. "trigger1501").
|
|
||||||
std::map<std::string, const uint8_t*> byName;
|
|
||||||
std::map<std::string, int32_t> accepts; // label name -> quest it starts
|
|
||||||
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
|
|
||||||
label += game::kScriptLabelSize) {
|
|
||||||
const std::string name(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
|
|
||||||
byName.emplace(name, label);
|
|
||||||
const size_t at = game::ScriptLabelCode(label);
|
|
||||||
if (game::ScriptLabelCodeSize(label) >= kStartQuestSize && at + kStartQuestSize <= g_code.size() &&
|
|
||||||
U16(at) == kScriptStartQuest && g_code[at + 4] == game::kScriptArgInt) {
|
|
||||||
int32_t quest;
|
|
||||||
std::memcpy(&quest, &g_code[at + 5], sizeof(quest));
|
|
||||||
accepts.emplace(name, quest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The givers' dialogs: "Dialog:<name>" labels with an answer (opcode
|
|
||||||
// 0x3c, {0x01 text, 0x01 label}) that accepts their quest.
|
|
||||||
std::map<std::string, int32_t> dialogs; // dialog name -> quest
|
|
||||||
static const char kDialog[] = "Dialog:";
|
|
||||||
for (const auto& [name, label] : byName) {
|
|
||||||
const int32_t quest = game::ScriptLabelQuest(label);
|
|
||||||
if (quest < 0 || name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
bool offers = false;
|
|
||||||
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
|
|
||||||
if (opcode != kScriptAnswer || g_code[at + 4] != game::kScriptArgString) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const char* text = reinterpret_cast<const char*>(&g_code[at + 5]);
|
|
||||||
const size_t textEnd = 5 + strnlen(text, size - 5) + 1;
|
|
||||||
if (textEnd + 1 < size && g_code[at + textEnd] == game::kScriptArgString) {
|
|
||||||
const auto accept = accepts.find(reinterpret_cast<const char*>(&g_code[at + textEnd + 1]));
|
|
||||||
offers = offers || (accept != accepts.end() && accept->second == quest);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (offers) {
|
|
||||||
dialogs.emplace(name.substr(sizeof(kDialog) - 1), quest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ScanSpawns(dialogs);
|
|
||||||
ScanVariables();
|
|
||||||
re::Log("questgiver: %u quest givers in %u bytes of script code", static_cast<unsigned>(g_givers.size()),
|
|
||||||
static_cast<unsigned>(g_code.size()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The living giver of `giver`, or null.
|
|
||||||
void* LivingGiver(const Giver& giver) {
|
|
||||||
const uint32_t id = game::ScriptObjectId(giver.scriptId);
|
|
||||||
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
|
|
||||||
return creature && game::ObjectScriptId(creature) == giver.scriptId && game::IsAlive(creature) ? creature
|
|
||||||
: nullptr;
|
: nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ResetVariables(int32_t questId) {
|
// Restarts quest `index` (questfail::Restart, which also brings the giver
|
||||||
for (const std::string& name : g_variables[questId]) {
|
// back); its book entry is failed.
|
||||||
std::vector<uint8_t> code(kInstructionHeader);
|
|
||||||
code.push_back(game::kScriptArgString);
|
|
||||||
code.insert(code.end(), name.begin(), name.end());
|
|
||||||
code.push_back(0);
|
|
||||||
code.push_back(game::kScriptArgInt);
|
|
||||||
code.insert(code.end(), 4, 0);
|
|
||||||
code[0] = static_cast<uint8_t>(game::kScriptSetVariable);
|
|
||||||
code[1] = 0;
|
|
||||||
code[2] = static_cast<uint8_t>(code.size());
|
|
||||||
code[3] = static_cast<uint8_t>(code.size() >> 8);
|
|
||||||
game::RunScriptInstruction(code.data());
|
|
||||||
re::Log("questgiver: quest %d variable '%s' = 0", questId, name.c_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restarts quest `index`: flags (available again), its book entry (failed,
|
|
||||||
// at the giver) and its variables.
|
|
||||||
void ResetQuest(int index) {
|
void ResetQuest(int index) {
|
||||||
const int32_t id = game::QuestId(game::Quest(index));
|
const int32_t id = game::QuestId(game::Quest(index));
|
||||||
questfail::Restart(index);
|
questfail::Restart(index);
|
||||||
@@ -246,16 +56,6 @@ void ResetQuest(int index) {
|
|||||||
*reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed;
|
*reinterpret_cast<int32_t*>(entry + game::kBookState) = game::kBookFailed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResetVariables(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Spawn(int index) {
|
|
||||||
const int32_t id = game::QuestId(game::Quest(index));
|
|
||||||
const Giver& giver = g_givers[id];
|
|
||||||
const int result = game::RunScriptInstruction(giver.spawn.data());
|
|
||||||
void* creature = LivingGiver(giver);
|
|
||||||
re::Log("questgiver: quest %d giver Res:%u spawned again (%d): %s", id, giver.scriptId, result,
|
|
||||||
creature ? "back" : "NOT FOUND");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepted and not done (kQuestTriggered alone: available, set when the hero
|
// Accepted and not done (kQuestTriggered alone: available, set when the hero
|
||||||
@@ -265,40 +65,30 @@ bool IsRunning(int index) {
|
|||||||
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone);
|
return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quests whose giver turns into an enemy are left alone: a missing giver may
|
||||||
|
// be the one the hero killed as the quest asks; so are those deleting her.
|
||||||
void RepairAfterLoad() {
|
void RepairAfterLoad() {
|
||||||
for (const auto& [id, giver] : g_givers) {
|
for (int index = 0, count = static_cast<int>(game::QuestCount()); index < count; ++index) {
|
||||||
const int index = game::QuestIndex(id);
|
const int32_t id = game::QuestId(game::Quest(index));
|
||||||
if (index < 0 || !IsRunning(index) || LivingGiver(giver)) {
|
const questscript::Giver* giver = IsRunning(index) ? RespawnableGiver(id) : nullptr;
|
||||||
|
const questscript::Quest* quest = giver ? questscript::Find(id) : nullptr;
|
||||||
|
if (!quest || quest->turnsGiver || quest->deletesGiver || questscript::LivingNpc(giver->scriptId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
|
re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id,
|
||||||
game::QuestName(game::Quest(index)), giver.scriptId);
|
game::QuestName(game::Quest(index)), giver->scriptId);
|
||||||
ResetQuest(index);
|
ResetQuest(index);
|
||||||
Spawn(index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnTick(uint8_t*) {
|
void OnTick(uint8_t*) {
|
||||||
if (g_repairAfterLoad) {
|
if (g_repairAfterLoad) {
|
||||||
g_repairAfterLoad = false;
|
g_repairAfterLoad = false;
|
||||||
Scan();
|
|
||||||
RepairAfterLoad();
|
RepairAfterLoad();
|
||||||
}
|
}
|
||||||
for (auto it = g_pending.begin(); it != g_pending.end();) {
|
|
||||||
const bool gone = game::GetObjectById(it->bodyId) != it->body;
|
|
||||||
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
|
|
||||||
++it;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Spawn(it->questIndex);
|
|
||||||
it = g_pending.erase(it);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnLoaded() {
|
void OnLoaded() { g_repairAfterLoad = true; }
|
||||||
g_repairAfterLoad = true;
|
|
||||||
g_pending.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Registration {
|
struct Registration {
|
||||||
Registration() {
|
Registration() {
|
||||||
@@ -311,25 +101,32 @@ struct Registration {
|
|||||||
|
|
||||||
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label
|
// Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label
|
||||||
// of a running quest run for its dead giver (her death label) is skipped and
|
// of a running quest run for its dead giver (her death label) is skipped and
|
||||||
// the quest restarted.
|
// the quest restarted. Not when the giver died as an enemy: some quests turn
|
||||||
|
// the giver hostile and their death label goes on ("Familienbande", 702:
|
||||||
|
// answer change702 turns the giver Res:17061 and two relatives against the
|
||||||
|
// hero, their death label tot702 sends him back to the girl Res:17060). The
|
||||||
|
// running labels are kept for questfail (failures that end a story).
|
||||||
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
|
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag);
|
||||||
RE_HOOK(0x0046bd80, Script_RunLabel);
|
RE_HOOK(0x0046bd80, Script_RunLabel);
|
||||||
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
|
uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) {
|
||||||
|
const questscript::LabelScope running(label);
|
||||||
const uint8_t* labels = game::ScriptLabelsBegin();
|
const uint8_t* labels = game::ScriptLabelsBegin();
|
||||||
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
|
if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) {
|
||||||
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
|
const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize);
|
||||||
const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
|
const int index = questId >= 0 ? game::QuestIndex(questId) : -1;
|
||||||
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
|
void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr;
|
||||||
if (creature && IsRunning(index) && !game::IsAlive(creature)) {
|
if (creature && IsRunning(index) && !game::IsAlive(creature)) {
|
||||||
Scan();
|
const questscript::Giver* giver = RespawnableGiver(questId);
|
||||||
const auto giver = g_givers.find(questId);
|
if (giver && game::ObjectScriptId(creature) == giver->scriptId) {
|
||||||
if (giver != g_givers.end() && game::ObjectScriptId(creature) == giver->second.scriptId) {
|
const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize);
|
||||||
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts",
|
if (game::IsHostileTo(creature, game::GetPlayerHero())) {
|
||||||
giver->second.scriptId, questId,
|
re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId,
|
||||||
game::ScriptLabelName(labels + label * game::kScriptLabelSize));
|
questId, name);
|
||||||
|
return Script_RunLabel_original(label, object, flag);
|
||||||
|
}
|
||||||
|
re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", giver->scriptId,
|
||||||
|
questId, name);
|
||||||
ResetQuest(index);
|
ResetQuest(index);
|
||||||
const uint32_t bodyId = *reinterpret_cast<uint32_t*>(object + game::kCreatureId);
|
|
||||||
g_pending.push_back({index, game::GetObjectById(bodyId), bodyId, GetTickCount()});
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\]/`) |
|
| `find-imm.mjs <exe> /regex/` | every instruction whose text matches (e.g. `/96AB0h\]/`) |
|
||||||
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
|
| `rtti.mjs <exe> <Class>` | vtables of an MSVC class via RTTI |
|
||||||
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
|
| `decompile.ps1 <addr...>` | decompile from the persistent Ghidra project `ghidra/` |
|
||||||
|
| `questscript.mjs <dir> <quest id...>` | decode a quest script folder (`bin\TYPE_NPC_<class>`, `bin\Addon\NetScriptCamp`): a quest's labels and where other labels use its NPCs/variables; `--list`, `--facts [ids]` (giver, death labels), `--label <name>`, `--grep <regex>` (matching instructions in all labels) |
|
||||||
| `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) |
|
| `rename.ps1` | apply the function names in `names.txt` to the Ghidra project (add names there as functions are identified) |
|
||||||
|
|
||||||
## Persistent Ghidra project (`ghidra/`, not in git)
|
## Persistent Ghidra project (`ghidra/`, not in git)
|
||||||
|
|||||||
@@ -444,3 +444,14 @@
|
|||||||
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
|
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
|
||||||
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
|
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
|
||||||
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68
|
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