From e13b82f0400625285ca857a7c10f7fc59518bc39 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sat, 31 Oct 2026 02:03:20 +0100 Subject: [PATCH] Quests: restart resets variables, respawns giver and NPCs; final failures Quest audit (docs/quest-audit.md, tools/questscript.mjs), fixes 2-8. New mods/questscript.cpp scans the script code once (giver, own variables, needed NPCs, QIS_OnEnter creations, story-ending failure labels). questfail: generic, giverless, story-ending and NPC-gone failures stay final; a restart restores the variables snapshot taken at Quest_Trigger, deletes OnEnter leftovers, respawns the giver and needed NPCs and gives the accept dialog back. questgiver slimmed on top of questscript. Not yet tested in game. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + docs/quest-audit.md | 278 ++++++++++++++++++ docs/quests.md | 6 + src/game/engine/quests.h | 27 ++ src/mods/questfail.cpp | 195 +++++++++++- src/mods/questgiver.cpp | 316 +++----------------- src/mods/questscript.cpp | 619 +++++++++++++++++++++++++++++++++++++++ src/mods/questscript.h | 66 +++++ tools/README.md | 1 + tools/questscript.mjs | 341 +++++++++++++++++++++ 10 files changed, 1557 insertions(+), 293 deletions(-) create mode 100644 docs/quest-audit.md create mode 100644 src/mods/questscript.cpp create mode 100644 src/mods/questscript.h create mode 100644 tools/questscript.mjs diff --git a/CMakeLists.txt b/CMakeLists.txt index 701b4b1..b23fc08 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ add_library(sacredK SHARED src/mods/questgiver.cpp src/mods/questitems.cpp src/mods/questnpc.cpp + src/mods/questscript.cpp src/mods/anticheat.cpp src/mods/loadingscreen.cpp ) diff --git a/docs/quest-audit.md b/docs/quest-audit.md new file mode 100644 index 0000000..4ef9cfc --- /dev/null +++ b/docs/quest-audit.md @@ -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: +` `; compact: ` --facts `). + +Scripts: Ancaria per hero class `bin\TYPE_NPC_\` (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`), 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, "")`); 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 `RegionInit`, 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 / 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`), 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_..._LOSE_` (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___"`), rarely in variables, and most givers are +spawned by `QIS_OnSetUp` (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` 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`), its variables (set + only by its labels, plus the one named after it: G9), what `QIS_OnEnter` + 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). diff --git a/docs/quests.md b/docs/quests.md index 886c675..86bc682 100644 --- a/docs/quests.md +++ b/docs/quests.md @@ -48,6 +48,12 @@ accepting it (opcode 0x14, `0x48f320`) needs it too; the game sets it when the hero enters the quest's region (`Quest_Trigger`). A restarted quest is now "available": flags 1. +2026-10-30 (quest audit, `docs/quest-audit.md`): a restart also resets the +quest's variables, deletes what its `QIS_OnEnter` created, brings back the +giver and the NPCs it needs, and gives the giver the accept dialog again; some +failures stay final (generic quests, no giver, story endings, NPCs gone for +good). The script facts come from `mods/questscript.cpp`. + Marker back to the giver (request 2026-10-25: "quests that failed, should have the blue marker point back to the quest giver"): diff --git a/src/game/engine/quests.h b/src/game/engine/quests.h index 15fa44c..1d7ecbc 100644 --- a/src/game/engine/quests.h +++ b/src/game/engine/quests.h @@ -83,6 +83,28 @@ inline uint32_t ScriptLabelCodeSize(const uint8_t* label) { // (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value // (0x498aa0; a missing variable is false). constexpr uint16_t kScriptSpawn = 0x01; +// More opcodes (keyword table 0x452540, name i <-> opcode; docs/quest-audit.md): +// 3 NPC state ("Res:", 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:", 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; @@ -94,6 +116,11 @@ inline bool IsScriptVariableWrite(uint16_t opcode) { constexpr uint8_t kScriptArgString = 0x01; constexpr uint8_t kScriptArgInt = 0x0b; constexpr uint8_t kScriptArgDialog = 0x09; +constexpr uint8_t kScriptArgAttitude = 0x08; // flag, followed by 0x12 (hostile) or 0x14 + +// The generic quests (Quest_End copies them under a new id; docs/quests.md). +constexpr int32_t kGenericQuestFirst = 2600; +constexpr int32_t kGenericQuestLast = 3599; // CRT fseek (0x84866d) / fread (0x848726) of the game, on its own FILE*. inline void* ScriptFile() { return *reinterpret_cast(0x00aaaf04); } diff --git a/src/mods/questfail.cpp b/src/mods/questfail.cpp index fa8abb3..a3f968e 100644 --- a/src/mods/questfail.cpp +++ b/src/mods/questfail.cpp @@ -22,12 +22,24 @@ // compass on the giver, or is removed if the giver is not known. Accepting it // again removes the failed entry. Quests that failed before the mod are reset // the same way once after loading. +// +// Quest audit (2026-10-30, docs/quest-audit.md): a restart also puts back what +// the quest's script needs to be offered again (questscript.cpp): its variables +// as they were when it became available (kept at Quest_Trigger, saved with the +// hero; for quests triggered earlier: 1 for the one named after the quest, else +// 0), the giver's accept dialog, the giver and the NPCs it needs from their +// spawn instructions; what its QIS_OnEnter created is deleted (accepting again +// creates it anew). A quest stays failed when the game repeats it itself +// (generic quests), when it has no giver (trigger areas), when its failure ends +// the story (2402 sets up 2403, 5003's shaman turns hostile, ...) or when an NPC +// it needs is gone for good. #include #include #include #include #include +#include #include @@ -37,6 +49,7 @@ #include "game/engine/quests.h" #include "mods/inventory_save.h" #include "mods/questfail.h" +#include "mods/questscript.h" #include "re/hook.h" #include "re/log.h" @@ -63,7 +76,10 @@ bool g_resetAfterLoad = false; bool g_inDialogAnswer = false; // Quest id -> the giver's map cell. -std::map> g_givers; +std::map> g_giverCells; + +// Quest id -> its variables when it became available. +std::map> g_freshVariables; // The nearest living, not hostile, non-hero creature near the hero, or 0. All // objects are searched (the nearby-creature list 0xad1c60 did not hold the @@ -105,15 +121,15 @@ void RememberGiver(int32_t questId) { const auto* position = reinterpret_cast(static_cast(creature) + game::kObjectPosition); const game::MapPosition cell = game::CellAt(creature, position->x, position->y); - g_givers[questId] = {cell.x, cell.y}; + g_giverCells[questId] = {cell.x, cell.y}; re::Log("questfail: quest %d accepted from creature %u at cell %d,%d", questId, npc, cell.x, cell.y); } // The entry's compass on its quest's giver (a cell target, no radius). False if // the giver is not known. bool PointAtGiver(uint8_t* entry) { - const auto giver = g_givers.find(game::BookQuestId(entry)); - if (giver == g_givers.end()) { + const auto giver = g_giverCells.find(game::BookQuestId(entry)); + if (giver == g_giverCells.end()) { return false; } auto field = [entry](size_t offset) -> int32_t& { return *reinterpret_cast(entry + offset); }; @@ -123,6 +139,63 @@ bool PointAtGiver(uint8_t* entry) { return true; } +// The variable named after the quest keeps bit 0: "available", which the +// giver's offer tests (auftrag402: IsVarBit 402 0; measured 2026-10-28: all 87 +// quests not yet accepted had value 1). +int32_t FreshValue(int32_t questId, const std::string& name) { + const int32_t available = name == std::to_string(questId) ? 1 : 0; + const auto quest = g_freshVariables.find(questId); + if (quest == g_freshVariables.end()) { + return available; + } + const auto value = quest->second.find(name); + return (value != quest->second.end() ? value->second : 0) | available; +} + +void ResetVariables(int32_t questId, const questscript::Quest& quest) { + for (const std::string& name : quest.variables) { + int32_t* value = game::ScriptVariable(name.c_str()); + const int32_t fresh = FreshValue(questId, name); + if (!value || *value == fresh) { + continue; + } + re::Log("questfail: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh); + *value = fresh; + } +} + +// Why failed quest `index` cannot be offered again, or null. `afterLoad`: the +// failure is not running now, so any failure of the quest that ends its story +// counts. +const char* StaysFailed(int index, bool afterLoad) { + const int32_t id = game::QuestId(game::Quest(index)); + const questscript::Quest* quest = questscript::Find(id); + if (!quest || (id >= game::kGenericQuestFirst && id <= game::kGenericQuestLast)) { + return "the game repeats it itself"; + } + if (!quest->hasGiver && !g_giverCells.count(id)) { + return "no giver"; + } + if (afterLoad ? quest->hasFinalFailure : questscript::InFinalFailure()) { + return "its failure ends the story"; + } + if (quest->hasGiver && questscript::IsGone(quest->giver) && quest->giver.spawn.empty() && + !questscript::SpawnOf(quest->giver.scriptId, id)) { + return "its giver is gone"; + } + for (uint32_t npc : quest->needed) { + if (!questscript::LivingNpc(npc) && !questscript::SpawnOf(npc, id)) { + re::Log("questfail: quest %d needs Res:%u, gone without a spawn instruction", id, npc); + return "an NPC it needs is gone"; + } + } + return nullptr; +} + +// Failed quests in the book: those failed before the mod are restarted unless +// they stay failed; those restarted before the variables were reset get their +// variables back (their giver may meanwhile offer a later quest, so the +// dialog stays). void RestartFailedInBook() { for (size_t i = game::BookCount(); i-- > 0;) { const uint8_t* entry = game::BookEntry(i); @@ -130,10 +203,19 @@ void RestartFailedInBook() { continue; } const int index = game::QuestIndex(game::BookQuestId(entry)); - if (index >= 0) { + const uint32_t flags = index >= 0 ? game::QuestFlags(game::Quest(index)) : 0; + if (index < 0 || ((flags & game::kQuestInProgress) && !(flags & game::kQuestDone))) { + continue; + } + if (!(flags & game::kQuestDone)) { + const int32_t id = game::QuestId(game::Quest(index)); + if (const questscript::Quest* quest = questscript::Find(id)) { + ResetVariables(id, *quest); + } + } else if (!StaysFailed(index, true)) { Restart(index); + i = std::min(i, game::BookCount()); } - i = std::min(i, game::BookCount()); } } @@ -160,11 +242,11 @@ void OnLoaded() { // --- saved with the hero: {"quest id": [x, y]} ------------------------------------ nlohmann::json WriteGivers(unsigned) { - if (g_givers.empty()) { + if (g_giverCells.empty()) { return nlohmann::json(); } nlohmann::json all = nlohmann::json::object(); - for (const auto& [id, cell] : g_givers) { + for (const auto& [id, cell] : g_giverCells) { all[std::to_string(id)] = {cell.first, cell.second}; } return all; @@ -177,7 +259,41 @@ void ReadGivers(unsigned, const nlohmann::json& data) { } for (const auto& [key, cell] : data.items()) { if (cell.is_array() && cell.size() == 2 && cell[0].is_number_integer() && cell[1].is_number_integer()) { - g_givers[std::stoi(key)] = {cell[0].get(), cell[1].get()}; + g_giverCells[std::stoi(key)] = {cell[0].get(), cell[1].get()}; + } + } +} + +// --- 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(); + } } } } @@ -197,6 +313,31 @@ void Restart(int index) { game::EraseBookEntry(i); } } + const questscript::Quest* script = questscript::Find(id); + if (!script) { + return; + } + ResetVariables(id, *script); + for (uint32_t npc : script->createdOnEnter) { + questscript::Delete(npc); + } + for (uint32_t npc : script->needed) { + const std::vector* 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* spawn = questscript::SpawnOf(giver.scriptId, id)) { + questscript::SpawnWhenGone(giver.scriptId, *spawn, giver.dialog, "quest giver"); + } } // Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc): @@ -204,12 +345,41 @@ void Restart(int index) { void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success); RE_HOOK(0x0046ced0, Quest_End); void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success) { + const bool known = index >= 0 && static_cast(index) < game::QuestCount(); + const char* staysFailed = !success && known ? StaysFailed(index, false) : nullptr; Quest_End_original(self, edx, index, a2, success); - if (!success && index >= 0 && static_cast(index) < game::QuestCount()) { - Restart(index); + if (!success && known) { + if (staysFailed) { + re::Log("questfail: quest %d '%s' stays failed: %s", game::QuestId(game::Quest(index)), + game::QuestName(game::Quest(index)), staysFailed); + } else { + Restart(index); + } } } +// Quest_Trigger (0x46de10, cdecl (index, ?, ?, ?), plain ret): makes the quest +// available (flag 1) the first time, running its trigger scripts. Its +// variables are kept as they are then: the state a restart goes back to. +int __cdecl Quest_Trigger(int index, int a2, int a3, int a4); +RE_HOOK(0x0046de10, Quest_Trigger); +int __cdecl Quest_Trigger(int index, int a2, int a3, int a4) { + if (index >= 0 && static_cast(index) < game::QuestCount() && + !(game::QuestFlags(game::Quest(index)) & game::kQuestTriggered)) { + const int32_t id = game::QuestId(game::Quest(index)); + if (const questscript::Quest* quest = questscript::Find(id)) { + auto& fresh = g_freshVariables[id]; + fresh.clear(); + for (const std::string& name : quest->variables) { + if (const int32_t* value = game::ScriptVariable(name.c_str())) { + fresh[name] = *value; + } + } + } + } + return Quest_Trigger_original(index, a2, a3, a4); +} + // cDialogManager::Answer (0x49a090, thiscall (owner, answer), ret 8): runs the // quest script for the answer. void __fastcall Dialog_Answer(uint8_t* self, void* edx, uint32_t owner, uint32_t answer); @@ -244,7 +414,7 @@ uint32_t __fastcall QuestManager_MarkerColour(uint8_t* self, void* edx, uint32_t if (index < game::BookCount() && (kind == 0 || kind == 1)) { const uint8_t* entry = game::BookEntry(index); if (game::BookState(entry) == game::kBookFailed && game::BookKind(entry) == game::kBookSide && - g_givers.count(game::BookQuestId(entry))) { + g_giverCells.count(game::BookQuestId(entry))) { return kind == 0 ? kOpenSideQuestColour : kOpenQuestColour2; } } @@ -257,6 +427,7 @@ struct Register { game::OnEngineTick(&OnTick); game::OnSaveGameLoaded(&OnLoaded); inventory_save::RegisterHeroData("questGivers", &WriteGivers, &ReadGivers); + inventory_save::RegisterHeroData("questVariables", &WriteFreshVariables, &ReadFreshVariables); } } g_register; } // namespace diff --git a/src/mods/questgiver.cpp b/src/mods/questgiver.cpp index d59bdcf..d928f97 100644 --- a/src/mods/questgiver.cpp +++ b/src/mods/questgiver.cpp @@ -12,263 +12,41 @@ // (Region15Init: spawn instruction 1 "Res:17013", type 134, at // pos_auftrag1501, dialog auftrag1501), which the game runs once. // -// The mod finds each quest's giver in the script code: the NPC whose spawn -// instruction carries a dialog that has a "Dialog:" label of the quest. -// When a label of that quest runs for its dead giver (her death label), the -// label is skipped; the quest restarts (questfail::Restart, its book entry -// failed and pointing at the giver), the variables only this quest's labels -// set go back to 0, and once the body is gone the game runs the giver's -// spawn instruction again: she stands at her start, offering the quest. -// After a load, a quest in progress whose giver no longer exists is repaired -// the same way. +// The giver is the NPC whose spawn instruction carries the quest's accept +// dialog (questscript.cpp). When a label of that quest runs for its dead giver +// (her death label), the label is skipped and the quest restarts +// (questfail::Restart: available again, its book entry failed and pointing at +// the giver, its variables as before, the giver spawned again once the body is +// gone). After a load, a quest in progress whose giver no longer exists is +// repaired the same way, unless the quest itself turns the giver into an enemy +// or deletes her (quest audit 2026-10-30: 154 deletes its giver on purpose). #include -#include -#include -#include -#include -#include -#include - -#include #include "game/engine/events.h" #include "game/engine/objects.h" #include "game/engine/orders.h" #include "game/engine/quests.h" #include "mods/questfail.h" +#include "mods/questscript.h" #include "re/hook.h" #include "re/log.h" namespace mods::questgiver { namespace { -constexpr size_t kInstructionHeader = 4; -constexpr size_t kMaxInstruction = 0x800; // 0x475970 refuses larger ones -constexpr size_t kReadChunk = 1 << 20; -constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there -constexpr uint16_t kScriptStartQuest = 0x14; // {0x0b quest id} -constexpr size_t kStartQuestSize = 9; -constexpr uint16_t kScriptAnswer = 0x3c; // dialog answer {0x01 text, 0x01 label} -constexpr uint16_t kScriptChangeType = 0x4f; // {0x01 "Res:", 0x02 creature type} - -struct Giver { - uint32_t scriptId = 0; - std::vector 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 g_code; // FunkCode.bin -std::map g_givers; // quest id -> giver -std::map> g_variables; // quest id -> variables only it sets -std::set g_giverTurns; // quests that turn their giver into an enemy -std::vector g_pending; bool g_repairAfterLoad = false; -uint16_t U16(size_t at) { return static_cast(g_code[at] | (g_code[at + 1] << 8)); } - -bool ReadCode() { - g_code.clear(); - for (uint32_t offset = 0;; offset += kReadChunk) { - g_code.resize(offset + kReadChunk); - const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk); - g_code.resize(offset + read); - if (read < kReadChunk) { - return !g_code.empty(); - } - } +// The quest's giver if M2 can bring her back: a numbered script id spawned with +// the accept dialog. +const questscript::Giver* RespawnableGiver(int32_t questId) { + const questscript::Quest* quest = questscript::Find(questId); + return quest && quest->hasGiver && quest->giver.scriptId && !quest->giver.spawn.empty() ? &quest->giver + : nullptr; } -// Calls `fn(at, opcode, size)` for each instruction of a label's code. -template -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 script id of an instruction's first argument "Res:", or 0. -uint32_t InstructionScriptId(size_t at, uint16_t size) { - static const char kRes[] = "res:"; - if (size <= 9 || g_code[at + 4] != game::kScriptArgString || - _strnicmp(reinterpret_cast(&g_code[at + 5]), kRes, 4) != 0 || - !memchr(&g_code[at + 9], 0, size - 9)) { - return 0; - } - char* numberEnd = nullptr; - const unsigned long scriptId = std::strtoul(reinterpret_cast(&g_code[at + 9]), &numberEnd, 10); - return *numberEnd == 0 ? static_cast(scriptId) : 0; -} - -// The spawn instructions in the code: "Res:" with a dialog. -void ScanSpawns(const std::map& dialogs) { - for (size_t at = 0; at + 9 < g_code.size(); ++at) { - if (U16(at) != game::kScriptSpawn) { - continue; - } - const uint16_t size = U16(at + 2); - if (size > kMaxInstruction || at + size > g_code.size()) { - continue; - } - const uint32_t scriptId = InstructionScriptId(at, size); - if (scriptId == 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(&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] = {scriptId, - std::vector(g_code.begin() + at, g_code.begin() + at + size)}; - } - } - } -} - -// The quests that change their giver's creature type (opcode 0x4f): as -// "Familienbande" (702, change702: Res:17061 type 668 -> 262), they turn the -// giver into an enemy the hero is meant to kill. -void ScanGiverTurns() { - for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd(); - label += game::kScriptLabelSize) { - const auto giver = g_givers.find(game::ScriptLabelQuest(label)); - if (giver == g_givers.end()) { - continue; - } - ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) { - if (opcode == kScriptChangeType && InstructionScriptId(at, size) == giver->second.scriptId) { - g_giverTurns.insert(giver->first); - } - }); - } -} - -// 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> 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 (game::IsScriptVariableWrite(opcode) && g_code[at + 4] == game::kScriptArgString && - memchr(&g_code[at + 5], 0, size - 5)) { - setters[reinterpret_cast(&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 byName; - std::map 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:" labels with an answer (opcode - // 0x3c, {0x01 text, 0x01 label}) that accepts their quest. - std::map 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(&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(&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(); - ScanGiverTurns(); - re::Log("questgiver: %u quest givers (%u turn into enemies) in %u bytes of script code", - static_cast(g_givers.size()), static_cast(g_giverTurns.size()), - static_cast(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; -} - -// The variables are bit fields; the scripts' own writes only set or clear bits -// (a quest's progress builds up bit by bit), so the value is cleared directly. -void ResetVariables(int32_t questId) { - for (const std::string& name : g_variables[questId]) { - int32_t* value = game::ScriptVariable(name.c_str()); - // 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). - const int32_t fresh = name == std::to_string(questId) ? 1 : 0; - if (!value || *value == fresh) { - continue; - } - re::Log("questgiver: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh); - *value = fresh; - } -} - -// Restarts quest `index`: flags (available again), its book entry (failed, -// at the giver) and its variables. +// Restarts quest `index` (questfail::Restart, which also brings the giver +// back); its book entry is failed. void ResetQuest(int index) { const int32_t id = game::QuestId(game::Quest(index)); questfail::Restart(index); @@ -278,16 +56,6 @@ void ResetQuest(int index) { *reinterpret_cast(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 @@ -298,41 +66,29 @@ bool IsRunning(int index) { } // Quests whose giver turns into an enemy are left alone: a missing giver may -// be the one the hero killed as the quest asks. +// be the one the hero killed as the quest asks; so are those deleting her. void RepairAfterLoad() { - for (const auto& [id, giver] : g_givers) { - const int index = game::QuestIndex(id); - if (index < 0 || !IsRunning(index) || g_giverTurns.count(id) || LivingGiver(giver)) { + for (int index = 0, count = static_cast(game::QuestCount()); index < count; ++index) { + const int32_t id = game::QuestId(game::Quest(index)); + const questscript::Giver* giver = IsRunning(index) ? RespawnableGiver(id) : nullptr; + const questscript::Quest* quest = giver ? questscript::Find(id) : nullptr; + if (!quest || quest->turnsGiver || quest->deletesGiver || questscript::LivingNpc(giver->scriptId)) { continue; } re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id, - game::QuestName(game::Quest(index)), giver.scriptId); + game::QuestName(game::Quest(index)), giver->scriptId); ResetQuest(index); - Spawn(index); } } void OnTick(uint8_t*) { if (g_repairAfterLoad) { g_repairAfterLoad = false; - Scan(); RepairAfterLoad(); } - for (auto it = g_pending.begin(); it != g_pending.end();) { - const bool gone = game::GetObjectById(it->bodyId) != it->body; - if (!gone && GetTickCount() - it->since < kBodyWaitMs) { - ++it; - continue; - } - Spawn(it->questIndex); - it = g_pending.erase(it); - } } -void OnLoaded() { - g_repairAfterLoad = true; - g_pending.clear(); -} +void OnLoaded() { g_repairAfterLoad = true; } struct Registration { Registration() { @@ -348,31 +104,29 @@ struct Registration { // 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). +// hero, their death label tot702 sends him back to the girl Res:17060). The +// running labels are kept for questfail (failures that end a story). uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag); RE_HOOK(0x0046bd80, Script_RunLabel); uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) { + const questscript::LabelScope running(label); const uint8_t* labels = game::ScriptLabelsBegin(); if (object && labels && label < (game::ScriptLabelsEnd() - labels) / game::kScriptLabelSize) { const int32_t questId = game::ScriptLabelQuest(labels + label * game::kScriptLabelSize); const int index = questId >= 0 ? game::QuestIndex(questId) : -1; void* creature = index >= 0 ? game::DynamicCast(object, game::kTypeObject, game::kTypeCreature) : nullptr; if (creature && IsRunning(index) && !game::IsAlive(creature)) { - Scan(); - const auto giver = g_givers.find(questId); - if (giver != g_givers.end() && game::ObjectScriptId(creature) == giver->second.scriptId) { + const questscript::Giver* giver = RespawnableGiver(questId); + if (giver && game::ObjectScriptId(creature) == giver->scriptId) { + const char* name = game::ScriptLabelName(labels + label * game::kScriptLabelSize); if (game::IsHostileTo(creature, game::GetPlayerHero())) { - re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", - giver->second.scriptId, questId, - game::ScriptLabelName(labels + label * game::kScriptLabelSize)); + re::Log("questgiver: giver Res:%u of quest %d died as an enemy; '%s' runs", giver->scriptId, + questId, name); return Script_RunLabel_original(label, object, flag); } - re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", - giver->second.scriptId, questId, - game::ScriptLabelName(labels + label * game::kScriptLabelSize)); + re::Log("questgiver: giver Res:%u of quest %d died; '%s' skipped, quest restarts", giver->scriptId, + questId, name); ResetQuest(index); - const uint32_t bodyId = *reinterpret_cast(object + game::kCreatureId); - g_pending.push_back({index, game::GetObjectById(bodyId), bodyId, GetTickCount()}); return 0; } } diff --git a/src/mods/questscript.cpp b/src/mods/questscript.cpp new file mode 100644 index 0000000..2aabbd4 --- /dev/null +++ b/src/mods/questscript.cpp @@ -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 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 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 +#include +#include +#include +#include +#include +#include + +#include + +#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{}; + std::array 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>{ + {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(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 code; +}; + +struct Pending { + uint32_t scriptId; + std::vector spawn; + std::string dialog; + void* body; + uint32_t bodyId; + DWORD since; + std::string why; +}; + +bool g_scanned = false; +std::vector g_code; +std::vector