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>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-31 02:03:20 +01:00
1 parent 7ec0fa2b8e
commit e13b82f040
10 files changed
+1557 -293

No files matched your search

+341
View File
@@ -0,0 +1,341 @@
// Decode the compiled quest scripts (docs/quests.md, "Quest scripts").
// node tools/questscript.mjs <script dir> <quest id...> one quest: its labels and every
// reference to it from other labels
// node tools/questscript.mjs <script dir> --list all quests of the quest table
// node tools/questscript.mjs <script dir> --label <name> one label
// <script dir>: the folder with FunkCode.bin + Vectoren.bin, e.g.
// "<Sacred Gold>\bin\TYPE_NPC_GLADIATOR" (Ancaria, one per hero class; the game
// writes the one in use to bin\sgf.bin) or "<Sacred Gold>\bin\Addon\NetScriptCamp"
// (Underworld).
//
// Formats: Vectoren.bin = u32 n, n labels of 0x54 bytes (name, +0x40 code offset,
// +0x44 size, +0x48 quest), then u32 count, count quest records of 0x124 bytes
// (id, name). FunkCode.bin = instructions {u16 opcode, u16 size with the header,
// tagged args}. Opcode names from the game's keyword tables (0x451db0, 0x452300,
// 0x4523d0, 0x452490, 0x452540); argument sizes from the reader 0x472eb0.
// Conditions (IF 0x3a / ELSEIF 0x42) have their own tags, decoded by shape.
import { readFileSync } from "node:fs";
import { join } from "node:path";
const OPS = {
0x1: "CreateNPC", 0x2: "SetObjState", 0x3: "SetNPCState", 0x4: "SetBaseTrigger", 0x5: "DelBaseTrigger",
0x6: "TalkTo", 0x7: "EquipNPC", 0x8: "CreateObj", 0x9: "StartScript", 0xa: "QuestDone", 0xb: "QuestNotDone",
0xc: "QuestInProgress", 0xd: "QuestNotInProgress", 0xe: "DeleteFunktionBlock", 0xf: "ExitQuest", 0x10: "MoveOver",
0x11: "AddExp", 0x12: "AddGold", 0x13: "AddGems", 0x14: "TriggerQuest", 0x15: "SetUpQuest", 0x16: "CallFunktion",
0x17: "DefPos", 0x18: "GroupIsDead", 0x19: "CheckForQuestPool", 0x1a: "Text", 0x1b: "MaxOffen:", 0x1c: "Delay:",
0x1d: "Rettungsmission:", 0x1e: "ToDoBloecke:", 0x1f: "Dialog:", 0x20: "Belohnungen:", 0x21: "HideTmpToDo:",
0x22: "Schatzsuche:", 0x23: "FillRegion", 0x24: "AddHandel", 0x25: "SetRG", 0x26: "UnsetRG", 0x2b: "ExitDlg",
0x2c: "DelDlg", 0x2d: "StartPosition", 0x2e: "Teleport", 0x30: "CreateTrigger", 0x31: "SetTriggerState",
0x32: "TriggerPatch", 0x33: "FillSector", 0x34: "SetHP", 0x35: "QuestBook", 0x36: "LoseQuest", 0x37: "DelNPC",
0x38: "SetTimer", 0x39: "DelTimer", 0x3a: "IF", 0x3b: "ELSE", 0x3c: "SetButton", 0x3d: "ChDlgText", 0x3e: "ENDIF",
0x3f: "QuestKompassPos", 0x40: "QuestKompassObj", 0x41: "DefNum", 0x42: "ELSEIF", 0x43: "SetVar", 0x44: "SetVarBit",
0x45: "UnsetVarBit", 0x46: "SetFocus", 0x47: "NPC_TalkTo", 0x48: "NPC_Goto", 0x49: "SetGroupState", 0x4a: "GroupGoto",
0x4b: "IncVar", 0x4c: "DecVar", 0x4d: "UnTriggerQuest", 0x4e: "FillChest", 0x4f: "Morph", 0x50: "SetST",
0x51: "SetGS", 0x52: "SetWI", 0x53: "SetRP", 0x54: "SetRM", 0x55: "SetCH", 0x56: "SetIcon", 0x57: "SetQuestInfo",
0x58: "SetAnimMode", 0x59: "SetPlayMode", 0x5a: "Wait", 0x5b: "PlayAnim", 0x5c: "Attack", 0x5d: "ActObj",
0x5e: "MouseEvent", 0x5f: "Popup", 0x60: "ReplaceSpawn", 0x61: "SetMapIcon", 0x62: "SecInfo", 0x63: "Partikel",
0x64: "SpawnValues", 0x65: "Toete_NPC", 0x66: "Begleite", 0x67: "SetCV", 0x68: "PlaySound", 0x69: "RndVar",
0x6a: "SetRgnDialog", 0x6b: "BalanceSpawnItem", 0x6c: "AddPoolPos", 0x6d: "GetPoolPos", 0x6e: "RenPos",
0x6f: "ToBegin", 0x70: "AddPoolRgn", 0x71: "GetPoolRgn", 0x72: "Waffenpool", 0x73: "MakeGroup",
0x74: "RegionChange", 0x75: "ActivateQuest", 0x76: "SetClientVars", 0x77: "GetClientVars", 0x78: "GameEnd",
0x79: "AutoSave", 0x7a: "SolveTime:", 0x7b: "Strafen:", 0x7c: "RegionFrei", 0x7d: "Gewinn", 0x7e: "RejectQuest",
0x7f: "PlayMusic", 0x80: "PlayJingle", 0x81: "SetScriptSoundControle", 0x82: "Teleporter", 0x83: "GameActive",
0x84: "InfoPlayer", 0x85: "GetPoolPosition", 0x86: "SetCinemaMode", 0x87: "SetOnKill", 0x88: "SetOnCollect",
0x89: "SetDrop", 0x8a: "GiveStat", 0x8b: "GiveSkill", 0x8c: "MachVersteck",
};
// Argument tags (reader 0x472eb0): payload kinds.
const STR = new Set([0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71, 0x83, 0x9d]);
const U16 = new Set([0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c]);
const I32 = new Set([0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f, 0x73, 0x75, 0x84, 0x86]);
const BYTES = { 0x15: 8, 0x20: 1, 0x28: 1, 0x2a: 1, 0x33: 8, 0x34: 8, 0x35: 12, 0x37: 1, 0x4b: 1, 0x4d: 1, 0x79: 8, 0x87: 8, 0x88: 8, 0x89: 8, 0x8b: 1 };
const FLAG = new Set([0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2b,
0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c, 0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59,
0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72, 0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96,
0x97, 0x98, 0x99, 0x9a, 0x9e, 0xa0, 0xa1]);
// Condition tags with {int32, variable}: 0x49 bit set, 0x4a bit clear (Script_Condition
// 0x498aa0), 0x47 / 0x48 / 0x6e compare values.
const COND_VAR = new Set([0x47, 0x48, 0x49, 0x4a, 0x6e]);
// Opcodes whose first argument is an NPC ("Res:<id>").
const NPC_OPS = new Set([0x01, 0x03, 0x06, 0x07, 0x1d, 0x2e, 0x34, 0x37, 0x40, 0x46, 0x47, 0x48, 0x4f, 0x5b, 0x5c, 0x65, 0x66]);
const TAG_NAMES = { 0x05: "death", 0x09: "dialog", 0x02: "type" };
const hex = (n) => n.toString(16).padStart(2, "0");
function cString(b, p) {
const e = b.indexOf(0, p);
return [b.toString("latin1", p, e), e + 1];
}
function printableAt(b, p, end) {
const e = b.indexOf(0, p);
if (e < 0 || e >= end) return false;
for (let i = p; i < e; i++) if (b[i] < 0x20) return false;
return true;
}
// Decoded arguments: [{tag, kind, value}], ok when the instruction was consumed exactly.
function decodeArgs(b, p, end, op) {
const args = [];
const cond = op === 0x3a || op === 0x42;
while (p < end) {
const tag = b[p++];
let s;
if (cond) {
if (COND_VAR.has(tag) && p + 4 < end) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (p + 4 < end && !printableAt(b, p, end) && printableAt(b, p + 4, end) && Math.abs(b.readInt32LE(p)) < 1e6) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (printableAt(b, p, end)) {
[s, p] = cString(b, p);
args.push({ tag, kind: "str", value: s });
} else if (p + 4 <= end) {
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
p += 4;
} else {
args.push({ tag, kind: "flag" });
}
continue;
}
if (STR.has(tag)) {
[s, p] = cString(b, p);
args.push({ tag, kind: "str", value: s });
} else if (U16.has(tag)) {
args.push({ tag, kind: "int", value: b.readUInt16LE(p) });
p += 2;
} else if (tag === 0x04 || tag === 0x0c || tag === 0x0d) {
const v = b.readInt32LE(p);
p += 4;
if (v === -2) {
[s, p] = cString(b, p);
args.push({ tag, kind: "pos", value: s });
} else {
args.push({ tag, kind: "pos", value: `${v},${b.readInt32LE(p)}` });
p += 8;
}
} else if (tag === 0x0b && b.readInt32LE(p) === -0x12d687) {
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "num", value: s });
} else if (I32.has(tag)) {
args.push({ tag, kind: "int", value: b.readInt32LE(p) });
p += 4;
} else if (BYTES[tag] !== undefined) {
args.push({ tag, kind: "bytes", value: [...b.slice(p, p + BYTES[tag])].map(hex).join("") });
p += BYTES[tag];
} else if (tag === 0x47 || tag === 0x48 || tag === 0x49 || tag === 0x4a) {
const n = b.readInt32LE(p);
[s, p] = cString(b, p + 4);
args.push({ tag, kind: "cond", value: [n, s] });
} else if (FLAG.has(tag)) {
args.push({ tag, kind: "flag" });
} else {
args.push({ tag, kind: "raw", value: [...b.slice(p, end)].map(hex).join("") });
return { args, ok: false };
}
}
return { args, ok: p === end };
}
function formatArg(a) {
const t = TAG_NAMES[a.tag] ?? hex(a.tag);
switch (a.kind) {
case "str": return `${t}"${a.value}"`;
case "int": return `${t}#${a.value}`;
case "num": return `${t}#"${a.value}"`;
case "pos": return `${t}@${a.value}`;
case "cond": return `${t}(${a.value[0]},"${a.value[1]}")`;
case "bytes": return `${t}[${a.value}]`;
case "flag": return t;
default: return `?${t}[${a.value}]`;
}
}
export function load(dir) {
const code = readFileSync(join(dir, "FunkCode.bin"));
const vec = readFileSync(join(dir, "Vectoren.bin"));
const n = vec.readUInt32LE(0);
const labels = [];
for (let i = 0; i < n; i++) {
const o = 4 + i * 0x54;
const label = {
name: vec.toString("latin1", o, vec.indexOf(0, o)),
quest: vec.readInt32LE(o + 0x48),
instructions: [],
};
let p = vec.readUInt32LE(o + 0x40);
const end = p + vec.readUInt32LE(o + 0x44);
while (p + 4 <= end && end <= code.length) {
const op = code.readUInt16LE(p);
const size = code.readUInt16LE(p + 2);
if (size < 4) break;
label.instructions.push({ op, ...decodeArgs(code, p + 4, p + size, op) });
p += size;
}
labels.push(label);
}
// startcode.bin: the world's start script (initial spawns), one instruction stream.
try {
const start = readFileSync(join(dir, "startcode.bin"));
const label = { name: "startcode", quest: -1, instructions: [] };
for (let p = 0; p + 4 <= start.length;) {
const op = start.readUInt16LE(p);
const size = start.readUInt16LE(p + 2);
if (size < 4) break;
label.instructions.push({ op, ...decodeArgs(start, p + 4, Math.min(p + size, start.length), op) });
p += size;
}
labels.push(label);
} catch {
// no start script in this folder
}
const quests = new Map();
let o = 4 + n * 0x54;
const count = vec.readUInt32LE(o);
o += 4;
for (let i = 0; i < count && o + 0x124 <= vec.length; i++, o += 0x124) {
quests.set(vec.readInt32LE(o), vec.toString("latin1", o + 4, vec.indexOf(0, o + 4)).trim());
}
return { labels, quests };
}
export function formatInstruction(ins) {
const name = OPS[ins.op] ?? `op${hex(ins.op)}`;
return `${name} ${ins.args.map(formatArg).join(" ")}${ins.ok ? "" : " (!)"}`.trimEnd();
}
const strings = (ins) => ins.args.flatMap((a) => (a.kind === "str" ? [a.value] : a.kind === "cond" ? [a.value[1]] : []));
const VAR_WRITES = new Set([0x43, 0x44, 0x45, 0x4b, 0x4c, 0x69]);
function printLabel(label, indent = " ") {
console.log(`${indent}${label.name}${label.quest >= 0 ? "" : " (no quest)"}`);
let depth = 0;
for (const ins of label.instructions) {
if (ins.op === 0x3b || ins.op === 0x42 || ins.op === 0x3e) depth = Math.max(0, depth - 1);
console.log(`${indent} ${" ".repeat(depth)}${formatInstruction(ins)}`);
if (ins.op === 0x3a || ins.op === 0x3b || ins.op === 0x42) depth++;
}
}
function printQuest({ labels, quests }, id) {
const own = labels.filter((l) => l.quest === id);
console.log(`== quest ${id} ${quests.get(id) ?? "(not in the quest table)"}: ${own.length} labels`);
for (const label of own) printLabel(label);
// Names other labels may use: own labels, dialogs, NPCs, variables.
const names = new Set(own.map((l) => l.name));
for (const l of own) if (l.name.startsWith("Dialog:")) names.add(l.name.slice(7));
const npcs = new Set();
const vars = new Set();
for (const l of own) {
for (const ins of l.instructions) {
for (const a of ins.args) if (a.kind === "cond") vars.add(a.value[1]);
if (NPC_OPS.has(ins.op) && ins.args[0]?.kind === "str" && ins.args[0].value && ins.args[0].value.toLowerCase() !== "hero") npcs.add(ins.args[0].value.toLowerCase());
if (VAR_WRITES.has(ins.op) && ins.args[0]?.kind === "str") vars.add(ins.args[0].value);
}
}
vars.delete("");
// Variables many quests use (region moods, "goodies") are only named.
const shared = [];
for (const v of vars) {
const users = new Set(labels.filter((l) => l.quest !== id && l.instructions.some((ins) => strings(ins).includes(v))).map((l) => l.quest));
if (users.size > 2) {
vars.delete(v);
shared.push(`${v} (${users.size} quests)`);
}
}
console.log(` -- references from other labels (NPCs ${[...npcs].join(" ") || "-"}; variables ${[...vars].join(" ") || "-"}; shared ${shared.join(" ") || "-"})`);
for (const l of labels) {
if (l.quest === id) continue;
const hits = l.instructions.filter((ins) =>
strings(ins).some((s) => names.has(s) || (vars.has(s) && (VAR_WRITES.has(ins.op) || ins.op === 0x3a || ins.op === 0x42))) ||
(NPC_OPS.has(ins.op) && npcs.has(String(ins.args[0]?.value).toLowerCase())) ||
((ins.op === 0x14 || ins.op === 0x0a || ins.op === 0x0b || ins.op === 0x0c || ins.op === 0x0d || ins.op === 0x4d || ins.op === 0x75) &&
ins.args.some((a) => a.kind === "int" && a.value === id)));
if (hits.length) {
console.log(` ${l.name} [quest ${l.quest}]`);
for (const ins of hits) console.log(` ${formatInstruction(ins)}`);
}
}
}
// Where a label is used: "<op>:<tag> in <label>" for every argument naming it
// (dialog buttons, death labels, CallFunktion, timers, triggers, ...).
function labelUses(labels, name) {
const uses = [];
for (const l of labels) {
for (const ins of l.instructions) {
for (const a of ins.args) {
if (a.kind === "str" && a.value === name && !(ins.op === 0x01 && a.tag === 0x01)) {
uses.push(`${OPS[ins.op] ?? hex(ins.op)}${a.tag === 0x05 ? "(death of " + ins.args[0]?.value + ")" : ""} in ${l.name}`);
}
}
}
}
return uses;
}
// Compact facts for the audit: giver, how the quest ends or fails, NPC deaths.
function printFacts({ labels, quests }, id) {
const own = labels.filter((l) => l.quest === id);
const ownNames = new Set(own.map((l) => l.name));
console.log(`== ${id} ${quests.get(id) ?? "(not in the quest table)"} (${own.length} labels)`);
// Givers: NPCs whose dialog has a button starting the quest.
const accepts = new Set(own.filter((l) => l.instructions[0]?.op === 0x14).map((l) => l.name));
const giverDialogs = own.filter((l) => l.name.startsWith("Dialog:") &&
l.instructions.some((ins) => ins.op === 0x3c && accepts.has(ins.args[1]?.value))).map((l) => l.name.slice(7));
for (const l of labels) {
for (const ins of l.instructions) {
if ((ins.op === 0x01 || ins.op === 0x03) && ins.args.some((a) => a.tag === 0x09 && giverDialogs.includes(a.value))) {
console.log(` giver ${ins.args[0]?.value} (${OPS[ins.op]} in ${l.name}${l.quest === id ? ", own label" : ""}): ${formatInstruction(ins)}`);
}
}
}
if (!giverDialogs.length) console.log(" giver: none found (accept labels: " + ([...accepts].join(" ") || "-") + ")");
for (const l of own) {
const ends = l.instructions.filter((ins) => ins.op === 0x0f || ins.op === 0x36).map((ins) => OPS[ins.op]);
const morph = l.instructions.filter((ins) => ins.op === 0x4f || ins.op === 0x37 || ins.op === 0x2e || ins.op === 0x38).map(formatInstruction);
if (!ends.length && !morph.length) continue;
const uses = labelUses(labels, l.name).filter((u) => !u.endsWith(` in ${l.name}`));
console.log(` ${l.name}: ${[...ends, ...morph].join("; ")} <- ${uses.join("; ") || (l.name.startsWith("QIS_") ? "quest end script" : "?")}`);
}
// Death labels anywhere that run this quest's labels.
for (const l of labels) {
for (const ins of l.instructions) {
const death = ins.args.find((a) => a.tag === 0x05 && ownNames.has(a.value));
if (death) console.log(` death ${ins.args[0]?.value} -> ${death.value} (${OPS[ins.op]} in ${l.name})`);
}
}
}
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop());
if (isMain) {
const [dir, ...rest] = process.argv.slice(2);
if (!dir || !rest.length) {
console.error("usage: node questscript.mjs <script dir> <quest id...> | --list | --facts [ids] | --label <name> | --grep <regex>");
process.exit(1);
}
const script = load(dir);
if (rest[0] === "--list") {
for (const [id, name] of script.quests) {
console.log(`${id}\t${script.labels.filter((l) => l.quest === id).length}\t${name}`);
}
} else if (rest[0] === "--facts") {
const ids = rest.length > 1 ? rest.slice(1).map(Number) : [...script.quests.keys()];
for (const id of ids) printFacts(script, id);
} else if (rest[0] === "--grep") {
// Instructions matching a regular expression, in all labels.
const re = new RegExp(rest[1]);
for (const l of script.labels) {
for (const ins of l.instructions) {
const text = formatInstruction(ins);
if (re.test(text)) console.log(`${l.name} [quest ${l.quest}]: ${text}`);
}
}
} else if (rest[0] === "--label") {
for (const l of script.labels.filter((l) => l.name === rest[1])) printLabel(l, "");
} else {
for (const id of rest) printQuest(script, Number(id));
}
}