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

Not yet tested in game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:03:20 +01:00

18 KiB

Quests

Quest data (measured 2026-10-17 / 2026-10-25)

  • Quest table g_quests (vector begin/end 0xaa9e98 / 0xaa9e9c), 0x124 bytes per quest: id +0, internal name +4 (ANSI), scripts +0x10c (run on trigger), +0x114 (one-time, cleared after it ran), +0x118 success end, +0x11c failure end (only quests with one can fail), flags +0x120: 1 triggered, 2 done, 4 in progress, 8 busy (while the trigger runs its scripts). game/engine/quests.h.
  • Quest manager: static object 0xaaaf00 (script interpreter). Quest book vector +0x424..+0x428, plain 0x174-byte entries (serialized field by field): state +4 (2 active, 100 solved, 101 failed), quest id +8, map position +0x14..+0x20; per player (16) a selected entry index at +0x3a8 + player * 8.
  • Script opcodes (keyword table 0x452540): LoseQuest 0x36 and ExitQuest 0x0f -> 0x48f110 -> Quest_End 0x46ced0 (thiscall (index, ?, success), ret 0xc; LoseQuest success 0, ExitQuest 1). QuestDone 0x0a tests flag 2, QuestInProgress 0x0c flag 4.
  • Quest_Trigger 0x46de10 (cdecl (index, ?, ?, ?), plain ret): does nothing if flag 1 is set; else sets 1 | 8, runs +0x114 (then cleared) and +0x10c, clears 8. Talking to a giver triggered a quest (flags 0 -> 1, no book entry yet); accepting it set flag 4 and added a book entry, state 2.
  • Quest_End: if the quest is in progress and not done, runs the end script (success / failure) and sets done (flags 3); the book entry's state becomes 100 / 101. Quests with ids 2600..3599 (the generic "Bringe NPC zu ...", "Eskortiere ...", ...) are copied to the end of the table under a new id (largest + 1, at least 10000): the game repeats those itself.
  • Compiled quest scripts: bin\sgf.bin (labels such as LoseQuest_10255, texts res:...; random side quests use DQ_ names, time limits DQ_ZEITFENSTER_TAGE).
  • Example (Gladiator, Silber, 2026-10-25): failed Hetzjagd 1502, Pony Express 5005, Rette Jungfrau 2504, Eskortiere Händler zu geheimen Treffen 1403, all flags 3, book state 101.

Failed quests restartable (request 2026-10-17)

A failed quest is not final: talking to the quest giver again offers it again. mods/questfail.cpp: after Quest_End with success 0 (its failure script has run) the quest's flags 2/4/8 are cleared and flag 1 is kept (book entry: see below); quests failed before the mod are reset once after loading. Confirmed in the log (the four quests above reset, 2026-10-25); the giver offering them again is still to be tested in the game.

2026-10-26: the restart first cleared flag 1 as well. Measured: a quest's script labels, its giver's dialog too, run only while flag 1 is set (Script_RunLabel 0x46bf11; "Dialog faild" in the game log otherwise), and accepting it (opcode 0x14, 0x48f320) needs it too; the game sets it when the hero enters the quest's region (Quest_Trigger). A restarted quest is now "available": flags 1.

2026-10-30 (quest audit, docs/quest-audit.md): a restart also resets the quest's variables, deletes what its QIS_OnEnter created, brings back the giver and the NPCs it needs, and gives the giver the accept dialog again; some failures stay final (generic quests, no giver, story endings, NPCs gone for good). The script facts come from mods/questscript.cpp.

Marker back to the giver (request 2026-10-25: "quests that failed, should have the blue marker point back to the quest giver"):

  • Measured: the maps draw a marker for every book entry at its compass cell +0x18/+0x1c, colour from 0x4975d0 (thiscall (entry, kind), ret 8): open side quest ff006cff (blue), main ffffff80, solved green, failed red. The compass target +0x10/+0x14 is set by the scripts' QuestkompassPos (0x49a7a0: a cell, radius +0x20 "rad:") and QuestkompassOBJ (0x49af70: 0xffffffff + object id, or 0xeeeeeeee + script NPC id); 0x4a6740 resolves it into +0x18/+0x1c. The game keeps no giver per quest.
  • A quest is accepted in a dialog answer (cDialogManager::Answer 0x49a090 -> quest script -> QuestManager_AddBookEntry 0x46c450). The mod keeps the map cell of the NPC nearest to the hero then (within 400) as the giver, saved with the hero data (questGivers: quest id -> cell, merged from save game and hero file; quest ids and givers are the same in every world).
  • On failure the book entry stays (state 101) with its compass on the giver and is drawn blue; without a known giver it is removed as before (all quests that failed before this change). Accepting the quest again removes the failed entry.

Quest book (measured 2026-10-25)

  • Book entries (quest manager +0x424, 0x174 bytes): kind +0 (3 main quest, 4 side quest: ids 1..99 are main quests, 0x46c450), state +4, quest id +8, title / text ids +0x24 / +0x28, progress log text ids +0x2c.., map position +0x18 / +0x1c (0x497830), chapter +0x16c (u16). A new entry (0x46c450, thiscall (quest index, ?), ret 8, called by the script interpreter 0x48f602) copies its chapter from the player's current main quest entry (+0x3a4 + slot * 8; selected entry +0x3a8 + slot * 8, read by 0x49dd40).
  • cUI_Book (one per book mode, 0x7a8[mode] of the book window; quest mode +0x166 = 0): list builder 0x6b04c0 (thiscall, from the rebuild 0x6b1670), page-full check 0x6b1a70 (thiscall (tab, page), ret 8), render 0x6b27f0 (thiscall (renderer, bookmarks only), ret 8: the left page's rows, then the selected row's detail record on the right, 0x6b1cd0 thiscall (renderer, record, sub-page), ret 0xc), events 0x6b3620 (thiscall (event), ret 4). Current tab +0x15e, page +0x162; per tab pages +0x97c + tab * 0xc (0x1c bytes: title text +0, selected row +4, rows +0x10) and detail records +0x9d0 + tab * 0xc (0x40 bytes). Row (0x14 bytes): u16 flags (4 spacer, 0x40 done, 0x80 entry flag 1), title +4, detail record +8, book entry +0xc, icon +0x10. Act tabs: chapter text table 0x9de7e4 (9000 "Pfade des Schicksals", 9001 "Unter blutigen Bannern", 9002 "Die rastlosen Toten", 9003 "Zeitenwende"); bookmarks: 7 controls at +0x168, 0xbc apart.
  • The book's list and the quest scripts run on the same thread (logged).
  • Quest table (Gladiator, Silber): 28 main quests, 87 side quests in id blocks of 100 (1xx .. 25xx, 2 to 7 each; their accepted quests lie together on the map), about 120 other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx), 352 generic ones (2600..3599) and bookkeeping entries (0, 99 "MP-Start", 5081..5089 "Region 1 befreit").

Quest book as one list (request 2026-10-25)

"Show all quests, unknown ones as ???, so a player knows how many quests an area holds"; then: "get rid of the tabs ... a section for story, and then a section per region", the game's page turning kept. mods/questbook.cpp: the game's builder runs on a reordered copy of the book (story, then a section " (known/all)" with "???" rows, other side quests joined to the nearest area, "Unentdeckt" counts), all in the first tab; rows are mapped back to the real entries afterwards (ours to none, which clicks ignore). The bookmarks are hidden, pages titled "Quests", each page selects its first quest, a click on a heading keeps the selection. Counted: side quests whose name is unique in the table.

Area names (measured 2026-10-25): an area is an id block (1xx .. 25xx); its name is the game's name of the region most of its known quests lie in, as the megamap names the hero's region: book cell +0x18/+0x1c (x 53.66563 = world units) -> World_SectorAt 0x634970 -> Sector_Region 0x622e30 -> Region_NameKey 0x425370 -> text (game/engine/regions.h). Several blocks lie in one named region (regions 1, 2, 3 are all UI_REGION_SOUTHKERN), so blocks with the same name make one section; blocks without a known position are "Gebiet N".

"Erledigte anzeigen" (request 2026-10-25: "some kind of 'Show completed' checkmark. If unchecked, completed quests are not in the list (as well as completed regions)"): the list's first row, an own entry whose state draws the solved mark (100) when on and no icon (0) when off; its row carries the marker entry 0xfffffffe. A click toggles it (sacredK.ini [questbook] ShowCompleted, default 1) and calls cUI_Book::Rebuild 0x6b1670 (thiscall, no arguments; keeps tab and page if the page still exists, else ours clamps it). Off leaves out solved entries (main and side) and areas whose quests are all known and solved; failed quests stay (restartable).

Quest NPCs after the quest (request 2026-10-25)

Request: NPCs related to a quest are drawn white on the minimaps and turn towards the player; once their quest is finished they should be drawn blue and stop turning, like other NPCs. src/mods/questnpc.cpp.

Measured:

  • The zoomed map view (0x6cc950, call at 0x6cec6b) draws a creature white when cCreature::IsQuestNpc (0x54fa30) is true: creature flags +0x200 bit 0x100 / 0x200 (escorted NPCs, set by the quest manager 0x46ae54..), or a dialog +0x245 in 1..59999 whose type (cDialogManager::DialogType 0x49a180, record +0x48 of the 0x50-byte records at manager 0xaaaf00 +0x755c, name at record +4) is below 50.

  • Some quests move their giver on to dialog 0 at the end (DLG_15074_START type 10 → _OFFEN 126 → _ZIEL 11 → 0); many leave it on the quest's dialog: auftrag10, auftrag2411, AuftragEnde202, auftrag303 kept their dialog with the quest done (flags 3, book entry solved).

  • The turning, found with a hardware write watch (DR0) on the facing +0x70: the idle action (cCreature::IdleAction 0x544700, Think's action 0 handler) turns every creature with a dialog +0x245 in 1..99999 towards the local hero each Think step (0x546334..0x5463d5, cCreature::SetDirection 0x44df60 → 0x44ddc0), unless its type is of kind 4 in the type table (TypeTable_IsKind4 0x4265c0, call at 0x546357) or object flag +0x14 bit 2 is set. So every NPC with a dialog turns, not only quest NPCs.

  • Script labels (vector 0xaa9688..0xaa968c, 0x54 bytes each; quest scripts and dialog answers are indices into it, run by Script_RunLabel 0x46bd80): name +0, the quest the label belongs to +0x48 (-1 none; the label only runs once that quest is triggered, 0x46bf11). Each dialog has a label Dialog:<dialog name> (logged 2026-10-25: Dialog:rg1_goodie1_auftrag 9004 Vermisstes Kind, the Aufgeregter Bauer; Dialog:auftrag_wueste 9000; Dialog:auftrag11a1 1101; Dialog:DLG_15003_ZIEL 15003; Dialog:geschlossen101 none).

The quest of a dialog is its label's quest (game/engine/quests.h, script labels), looked up by the dialog record's name; cached per dialog. A first version read the quest id from the numbers in the dialog's name; that missed dialogs without one (the Aufgeregter Bauer's rg1_goodie1_auftrag, user report 2026-10-25) and misread others (auftrag11a1). When that quest exists, is done (flag 0x2) and not in progress (0x4):

  • the hook on IsQuestNpc returns false (blue on the minimap);
  • the type check call at 0x546357 is redirected to a thunk that also answers true, so the idle action skips the turn.

Dialogs without a quest (healers, closed shops: geschlossen101, heilen_global) stay as the game has them.

Quest scripts (measured 2026-10-26)

  • Code: binNetScriptFunkCode.bin (binAddonNetScript differs only past 0x9a653), open as the quest manager's FILE* +4 (0xaaaf04). Instructions {u16 opcode, u16 size with the header, args}; args tagged: 0x01 string, 0x0b int32, 0x09 dialog, 0x05 label (e.g. a death label), ... Jump table of the executor 0x475970 at 0x4787b0 (opcode * 4).
  • Labels: the script label table (0xaa9688, 0x54 bytes; from Vectoren.bin): name, +0x40 code offset, +0x44 code size, +0x48 quest. Script_RunLabel 0x46bd80 (cdecl (label, object, flag)) reads the code and runs it with 0x475970 (quest manager thiscall (code, &offset, quest, object, flag, 0), ret 0x18; any memory buffer with offset 0 works).
  • Opcodes seen: 1 spawn ("Res:<script id>", type, position, 0x09 dialog, 0x05 death label), 3 NPC mode (0x09 dialog, 0x0a follow + death label), 4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if (0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f compass, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 0x48 move NPC, 0x56 dialog state.
  • Script NPC names: "Res:" is a script id (object +0x3c, 0x44a390); the quest manager maps it to the object (0x464ec0, map +0x7668); a spawn sets the entry.
  • Example "Jagd auf die Wilderer" (1501): Region15Init spawns the elf (Res:17013, type 134, dialog auftrag1501); accepting (trigger1501: 0x14) runs QIS_OnEnter1501: poachers with death label tot1501, the elf follows with death label ex1501 (variable "1501" = 1, then tot1501); tot1501 once all poachers are dead: variable 0 -> back to the elf, 1 -> quest ended without the reward. "Hetzjagd" (1502): the daughter (Res:17015, the giver) is spawned only when the hero walks into trigger area hetz1_1502, with three cultists; her father (Res:17016) only asks for her.

Escorts on the follower list (request 2026-10-26)

"Furchtbares Verbrechen" (9008) did not go on at its target point: the script's test there ("@npc", 0x498aa0 case 0x40) looks the NPC up in the hero's follower list (+0x39c, 9-byte entries), which was empty after a load, while the NPC still had its leader (+0x251) and follow bit (+0x1f4: 4 for the follow order, 0x100 for script escorts). mods/followers.cpp adds such NPCs back with cCreature::AddFollower 0x54b070 once after loading. Town NPCs that run after the hero for a few seconds (status 0x404) are left out. Confirmed 2026-10-26.

Quest givers come back (request 2026-10-26)

"Change the death script to spawn her back to her starting point", and "on load we need a repair ... if not completed or correctly running". mods/questgiver.cpp: a quest's giver is the NPC whose spawn instruction carries a dialog with an answer that accepts the quest (85 givers). When a label of a running quest runs for its dead giver, it is skipped, the quest restarts (failed entry at the giver, the variables only its labels set back: 0, the one named after the quest 1), and once the body is gone the spawn instruction runs again. After a load, an accepted quest (flag 4) without its giver gets the same. Not yet seen in the game (the escort of "Hetzjagd" arrived alive); see docs/todo.md for the audit of how quests can fail.

Lesson (2026-10-26): flag 1 means available, not running; a first version took it for running and reset 16 available quests and spawned their NPCs on Borf (repaired once, then saved).

Givers killed as enemies (2026-10-30): "Familienbande" (702, book title "Mädchen bei Ausritt verschwunden") was restarted twice when the hero killed its giver. The quest wants that: back at the giver with the girl (Res:17060), answer change702 changes the creature type of the giver Res:17061 (668 -> 262) and two relatives (opcode 0x4f {"Res:", 0x02 type}), they attack, and their death label tot702 sends the hero back to the girl (ziel702, exit702 ends the quest). Now a giver hostile to the hero at death runs the label as the game has it, and the load repair skips quests whose labels change their giver's type (0x4f on the giver; 2 of the 85 known givers, 702 among them). Confirmed in game 2026-10-30.

Quest variables are bit fields (measured 2026-10-28)

Found while repairing "Kräuter für Kräuterhexe" (402; the user knows it as Windgeflüster). Variables live in the quest manager's vector +0x7550 / +0x7554 (0x24-byte entries: name[0x20], value +0x20; game::ScriptVariable). Script writes: 0x43 sets the value, 0x44 sets bit n (0x49bb30: value |= 1 << n), 0x45 clears bit n (0x49c450), 0x4b adds n (0x49cc20). Conditions (Script_Condition 0x498aa0): 0x49 bit n set, 0x4a bit n clear (logged "if IsNotVarBit"), 0x48 value == n; a missing variable is false. The variable named after a quest has bit 0 while the quest is available: all 87 quests not yet accepted in a dwarf save had value 1, and nothing in the quest's own labels sets it. Quest 402: accepting sets bit 1, the berries bit 2, the reward bit 3; the giver's dialog offers the quest on bit 0.

The quest giver restart first ran 0x44 with 0 ("variable = 0"), which set bit 0 and cleared nothing: the giver kept saying "Zögerst du in deinen Taten?". A reset to 0 then matched no dialog branch at all. Now the value is written directly: 0, or 1 for the quest's own variable. Confirmed 2026-10-28.

Main quest items refused (request 2026-10-28)

"I cannot finish the Quest Windgeflüster." The berries (type 6029) are an item Region4Init places once on the ground near the Goldbeerenbusch (op 0x5e, pickup label take402); picking it up logged "cInventoryEntry::addMainQuestItem() Already has mainQuestItem(6029)" and the item was gone. cInventoryEntry::AddMainQuestItem (0x5d8240, thiscall (type, script id, notify)) keeps {type, script id} pairs and refuses a new item when an entry has the same type or script id 0 (0x5d83c6); scripts that give an item without a "Res:" id add it with 0 (0x486bd8). The dwarf held {4609, 0}, so every later main quest item was refused (a bug of the original). mods/questitems.cpp removes the script id test. The save was repaired once by running take402 for the hero after the load (quest accepted, bit 2 missing); the quest then finished. Confirmed 2026-10-28.

Escorts never left behind (2026-10-26)

"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no longer following me" (Borf). Measured in the log: the husband (Res:17584, type 86; spawned in startcode.bin with death label drop2412) fell behind, the game's own catch-up teleport moved him once (type 86 line), then the hero walked on, the sectors behind were switched off, and the game removed him and ran drop2412 (quest variable 5, a token item res:17585 dropped where he was; picking it up runs take2412, the "husband lost" branch). His object id was reused right after: removed, not dead. The teleport potion came 50 s later and was not involved; portals and the potion take followers along.

mods/followers.cpp: every 500 ms each follower of the local hero more than 40 cells away (sectors are switched off at 65 and more) on the same level is teleported to the hero with cCreature::Teleport 0x54d840 (thiscall (cell x, cell y, 0, 1), ret 0x10). Borf's husband was respawned once by a temporary repair (his spawn instruction, variable 1, token removed, follow2412); the quest then finished with him alive.