Files
sacred_k/src/mods/questscript.cpp
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

620 lines
23 KiB
C++

// The quest scripts read once (quest audit of 2026-10-30, docs/quest-audit.md):
// what the failure restart (questfail.cpp) and the giver respawn
// (questgiver.cpp) need to know about a quest.
//
// The compiled script code (FunkCode.bin, open as the quest manager's file) is
// a list of labels (the label table, game/engine/quests.h) whose code is a
// sequence of instructions {u16 opcode, u16 size, tagged args}; the argument
// sizes per tag are those of the game's reader (0x472eb0, as tools/
// questscript.mjs decodes them). From it, per quest:
// - the giver: the NPC given a dialog whose answer starts the quest (a spawn
// instruction carrying it, e.g. Region15Init "Res:17013" dialog auftrag1501;
// else an NPC state instruction, as the Underworld's QIS_OnSetUp<id> do);
// - its variables: those only its labels set, and the one named after it;
// - the NPCs it needs: those its labels set states on but don't create, as
// 1302's opponent Res:17173 (Region13Init); they come back from their spawn
// instruction elsewhere when the quest restarts;
// - what QIS_OnEnter<id> creates (deleted on a restart, accepting again
// creates it anew);
// - the failures that end its story: a LoseQuest label that also sets up the
// next quest (2402), calls another quest's label (2502 finalestart), hands
// out a reward (9520), or turns hostile or kills an NPC offering it (5003,
// 1701, 1703).
#include "mods/questscript.h"
#include <algorithm>
#include <array>
#include <cstdlib>
#include <cstring>
#include <map>
#include <set>
#include <utility>
#include <windows.h>
#include "game/engine/events.h"
#include "game/engine/objects.h"
#include "game/engine/orders.h"
#include "game/engine/quests.h"
#include "re/log.h"
namespace mods::questscript {
namespace {
constexpr size_t kInstructionHeader = 4;
constexpr size_t kReadChunk = 1 << 20;
constexpr DWORD kBodyWaitMs = 30000; // spawn anyway if the body is still there
constexpr int32_t kPositionNamed = -2; // position args: -2 + a name, else 3 ints
constexpr int32_t kNumberNamed = -0x12d687; // 0x0b args: this + a name (DefNum)
constexpr uint8_t kArgHostile = 0x12; // flag after kScriptArgAttitude
// Argument payloads by tag (0x472eb0).
enum class Payload : uint8_t { kUnknown, kString, kU16, kI32, kPosition, kCondition, kFlag, kBytes };
struct ArgTable {
std::array<Payload, 256> payload{};
std::array<uint8_t, 256> bytes{};
ArgTable() {
for (int tag : {0x01, 0x05, 0x09, 0x16, 0x1e, 0x29, 0x3a, 0x41, 0x60, 0x63, 0x67, 0x68, 0x69, 0x6a, 0x71,
0x83, 0x9d}) {
payload[tag] = Payload::kString;
}
for (int tag : {0x03, 0x0a, 0x6b, 0x6c, 0x9b, 0x9c}) {
payload[tag] = Payload::kU16;
}
for (int tag : {0x02, 0x0b, 0x11, 0x19, 0x1c, 0x1d, 0x1f, 0x36, 0x38, 0x53, 0x54, 0x55, 0x56, 0x57, 0x5f,
0x73, 0x75, 0x84, 0x86}) {
payload[tag] = Payload::kI32;
}
for (int tag : {0x04, 0x0c, 0x0d}) {
payload[tag] = Payload::kPosition;
}
for (int tag : {0x47, 0x48, 0x49, 0x4a}) {
payload[tag] = Payload::kCondition;
}
for (int tag : {0x06, 0x07, 0x08, 0x0e, 0x0f, 0x10, 0x12, 0x13, 0x14, 0x1a, 0x1b, 0x23, 0x24, 0x25, 0x26, 0x27,
0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x39, 0x3f, 0x42, 0x43, 0x44, 0x45, 0x46, 0x4c,
0x4e, 0x4f, 0x50, 0x51, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x61, 0x62, 0x64, 0x65, 0x66, 0x70, 0x72,
0x74, 0x78, 0x7b, 0x7c, 0x80, 0x85, 0x8a, 0x8d, 0x8e, 0x91, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9e,
0xa0, 0xa1}) {
payload[tag] = Payload::kFlag;
}
for (auto [tag, size] : std::initializer_list<std::pair<int, int>>{
{0x15, 8}, {0x20, 1}, {0x28, 1}, {0x2a, 1}, {0x33, 8}, {0x34, 8}, {0x35, 12}, {0x37, 1},
{0x4b, 1}, {0x4d, 1}, {0x79, 8}, {0x87, 8}, {0x88, 8}, {0x89, 8}, {0x8b, 1}}) {
payload[tag] = Payload::kBytes;
bytes[tag] = static_cast<uint8_t>(size);
}
}
};
const ArgTable kArgs;
struct Arg {
uint8_t tag = 0;
const char* text = nullptr; // string payloads, in g_code
int32_t number = 0;
};
struct Label {
std::string name;
int32_t quest = -1;
size_t code = 0;
size_t size = 0;
};
struct Spawn {
int32_t quest;
const Label* label;
std::vector<uint8_t> code;
};
struct Pending {
uint32_t scriptId;
std::vector<uint8_t> spawn;
std::string dialog;
void* body;
uint32_t bodyId;
DWORD since;
std::string why;
};
bool g_scanned = false;
std::vector<uint8_t> g_code;
std::vector<Label> g_labels;
std::map<int32_t, Quest> g_quests;
std::map<uint32_t, std::vector<Spawn>> g_spawns; // script id -> its spawn instructions
std::set<uint32_t> g_finalLabels; // label indices
std::vector<uint32_t> g_running; // labels running now
std::vector<Pending> g_pending;
uint16_t U16(size_t at) { return static_cast<uint16_t>(g_code[at] | (g_code[at + 1] << 8)); }
int32_t I32(size_t at) {
int32_t value;
std::memcpy(&value, &g_code[at], sizeof(value));
return value;
}
std::string Lower(const char* text) {
std::string lower(text);
for (char& c : lower) {
c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
}
return lower;
}
bool IsRes(const char* text) { return text && _strnicmp(text, "res:", 4) == 0; }
// The number of "Res:<number>", 0 for a named id.
uint32_t ResNumber(const char* text) {
if (!IsRes(text) || !text[4]) {
return 0;
}
char* end = nullptr;
const unsigned long number = std::strtoul(text + 4, &end, 10);
return *end == 0 ? static_cast<uint32_t>(number) : 0;
}
bool ReadCode() {
g_code.clear();
for (uint32_t offset = 0;; offset += kReadChunk) {
g_code.resize(offset + kReadChunk);
const size_t read = game::ReadScriptFile(offset, g_code.data() + offset, kReadChunk);
g_code.resize(offset + read);
if (read < kReadChunk) {
return !g_code.empty();
}
}
}
// The arguments of the instruction at `at`; false if they don't decode exactly
// (conditions are not decoded).
bool Decode(size_t at, uint16_t size, std::vector<Arg>& args) {
args.clear();
const size_t end = at + size;
size_t p = at + kInstructionHeader;
auto text = [&](size_t from, const char*& out) {
const void* zero = from < end ? memchr(&g_code[from], 0, end - from) : nullptr;
if (!zero) {
return false;
}
out = reinterpret_cast<const char*>(&g_code[from]);
p = static_cast<const uint8_t*>(zero) - g_code.data() + 1;
return true;
};
while (p < end) {
Arg arg;
arg.tag = g_code[p++];
switch (kArgs.payload[arg.tag]) {
case Payload::kString:
if (!text(p, arg.text)) {
return false;
}
break;
case Payload::kU16:
if (p + 2 > end) {
return false;
}
arg.number = U16(p);
p += 2;
break;
case Payload::kPosition:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
p += 4;
if (arg.number == kPositionNamed) {
if (!text(p, arg.text)) {
return false;
}
} else {
p += 8;
}
break;
case Payload::kI32:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
p += 4;
if (arg.tag == game::kScriptArgInt && arg.number == kNumberNamed && !text(p, arg.text)) {
return false;
}
break;
case Payload::kCondition:
if (p + 4 > end) {
return false;
}
arg.number = I32(p);
if (!text(p + 4, arg.text)) {
return false;
}
break;
case Payload::kBytes:
p += kArgs.bytes[arg.tag];
break;
case Payload::kFlag:
break;
default:
return false;
}
args.push_back(arg);
}
return p == end;
}
// Calls `fn(at, opcode, args)` for each decoded instruction of a label.
template <typename Fn>
void ForEachInstruction(const Label& label, Fn fn) {
std::vector<Arg> args;
const size_t end = label.code + label.size;
for (size_t at = label.code; at + kInstructionHeader <= end && end <= g_code.size();) {
const uint16_t opcode = U16(at);
const uint16_t size = U16(at + 2);
if (size < kInstructionHeader || at + size > end) {
return;
}
if (opcode != game::kScriptIf && opcode != game::kScriptElseIf && Decode(at, size, args)) {
fn(at, opcode, args);
}
at += size;
}
}
const Arg* FindArg(const std::vector<Arg>& args, uint8_t tag) {
for (const Arg& arg : args) {
if (arg.tag == tag) {
return &arg;
}
}
return nullptr;
}
// The first "Res:..." string argument (spawns may name the type first).
const char* ResArg(const std::vector<Arg>& args) {
for (const Arg& arg : args) {
if (arg.tag == game::kScriptArgString && IsRes(arg.text)) {
return arg.text;
}
}
return nullptr;
}
bool SetsHostile(const std::vector<Arg>& args) {
for (size_t i = 0; i + 1 < args.size(); ++i) {
if (args[i].tag == game::kScriptArgAttitude && args[i + 1].tag == kArgHostile) {
return true;
}
}
return false;
}
std::vector<uint8_t> Code(size_t at, uint16_t size) {
return std::vector<uint8_t>(g_code.begin() + at, g_code.begin() + at + size);
}
// An instruction with string arguments {tag, text}.
std::vector<uint8_t> Instruction(uint16_t opcode, std::initializer_list<std::pair<uint8_t, std::string>> args) {
std::vector<uint8_t> code(kInstructionHeader);
for (const auto& [tag, text] : args) {
code.push_back(tag);
code.insert(code.end(), text.begin(), text.end());
code.push_back(0);
}
code[0] = static_cast<uint8_t>(opcode);
code[1] = static_cast<uint8_t>(opcode >> 8);
code[2] = static_cast<uint8_t>(code.size());
code[3] = static_cast<uint8_t>(code.size() >> 8);
return code;
}
void Scan() {
// Not before the game has the code open and its labels read.
if (g_scanned || !game::ScriptFile() || !game::ScriptLabelsBegin() ||
game::ScriptLabelsEnd() <= game::ScriptLabelsBegin()) {
return;
}
g_scanned = true;
if (!ReadCode()) {
re::Log("questscript: script code not readable");
return;
}
std::map<std::string, size_t> byName;
for (const uint8_t* label = game::ScriptLabelsBegin(); label && label < game::ScriptLabelsEnd();
label += game::kScriptLabelSize) {
Label entry;
entry.name.assign(game::ScriptLabelName(label), strnlen(game::ScriptLabelName(label), game::kScriptLabelNameSize));
entry.quest = game::ScriptLabelQuest(label);
entry.code = game::ScriptLabelCode(label);
entry.size = game::ScriptLabelCodeSize(label);
byName.emplace(entry.name, g_labels.size());
if (entry.quest >= 0) {
g_quests[entry.quest];
}
g_labels.push_back(std::move(entry));
}
// The accept labels: code starting with "start quest #id" (0x48f320; e.g.
// "trigger1501"); the dialogs with an answer (text, label) running one of
// their quest.
std::map<std::string, int32_t> accepts;
for (const Label& label : g_labels) {
const size_t at = label.code;
if (label.size >= kInstructionHeader + 5 && at + kInstructionHeader + 5 <= g_code.size() &&
U16(at) == game::kScriptStartQuest && g_code[at + kInstructionHeader] == game::kScriptArgInt) {
accepts.emplace(label.name, I32(at + kInstructionHeader + 1));
}
}
std::map<std::string, int32_t> dialogs; // lower-case dialog name -> quest
static const char kDialog[] = "Dialog:";
for (const Label& label : g_labels) {
if (label.quest < 0 || label.name.compare(0, sizeof(kDialog) - 1, kDialog) != 0) {
continue;
}
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
if (opcode != game::kScriptAnswer || args.size() < 2 || !args[1].text) {
return;
}
const auto accept = accepts.find(args[1].text);
if (accept != accepts.end() && accept->second == label.quest) {
dialogs.emplace(Lower(label.name.c_str() + sizeof(kDialog) - 1), label.quest);
}
});
}
// Spawns, givers (and every NPC offering a quest), variable writers.
std::map<std::string, std::set<int32_t>> setters;
std::map<int32_t, Giver> stateGivers;
std::map<int32_t, std::set<std::string>> offering; // quest -> NPCs given its accept dialog
for (const Label& label : g_labels) {
ForEachInstruction(label, [&](size_t at, uint16_t opcode, const std::vector<Arg>& args) {
if (game::IsScriptVariableWrite(opcode) && !args.empty() && args[0].tag == game::kScriptArgString) {
setters[args[0].text].insert(label.quest);
}
if (opcode != game::kScriptSpawn && opcode != game::kScriptSetNpcState) {
return;
}
const char* res = opcode == game::kScriptSpawn ? ResArg(args) : (args.empty() ? nullptr : args[0].text);
if (!IsRes(res)) {
return;
}
if (opcode == game::kScriptSpawn && ResNumber(res)) {
g_spawns[ResNumber(res)].push_back({label.quest, &label, Code(at, U16(at + 2))});
}
const Arg* dialog = FindArg(args, game::kScriptArgDialog);
const auto offered = dialog ? dialogs.find(Lower(dialog->text)) : dialogs.end();
if (offered == dialogs.end()) {
return;
}
offering[offered->second].insert(Lower(res));
Giver giver{Lower(res), ResNumber(res), dialog->text, {}};
Quest& quest = g_quests[offered->second];
if (opcode == game::kScriptSpawn && !quest.hasGiver) {
giver.spawn = Code(at, U16(at + 2));
quest.giver = std::move(giver);
quest.hasGiver = true;
} else if (opcode == game::kScriptSetNpcState) {
stateGivers.emplace(offered->second, std::move(giver));
}
});
}
for (auto& [id, giver] : stateGivers) {
Quest& quest = g_quests[id];
if (!quest.hasGiver) {
quest.giver = std::move(giver);
quest.hasGiver = true;
}
}
for (const auto& [name, quests] : setters) {
if (quests.size() == 1 && *quests.begin() >= 0) {
g_quests[*quests.begin()].variables.push_back(name);
}
}
// Per quest: what its labels create and touch, and its failures.
std::map<int32_t, std::set<uint32_t>> created;
std::map<int32_t, std::set<uint32_t>> touched;
for (uint32_t index = 0; index < g_labels.size(); ++index) {
const Label& label = g_labels[index];
if (label.quest < 0) {
continue;
}
Quest& quest = g_quests[label.quest];
const std::string id = std::to_string(label.quest);
const bool setUp = label.name == "QIS_OnSetUp" + id;
const bool onEnter = label.name == "QIS_OnEnter" + id;
const std::set<std::string>& offers = offering[label.quest];
bool loses = false;
bool ends = false;
ForEachInstruction(label, [&](size_t, uint16_t opcode, const std::vector<Arg>& args) {
const char* res = opcode == game::kScriptSpawn || opcode == game::kScriptCreateObject
? ResArg(args)
: (!args.empty() && args[0].tag == game::kScriptArgString ? args[0].text : nullptr);
const uint32_t npc = ResNumber(res);
const std::string lower = IsRes(res) ? Lower(res) : std::string();
const bool isGiver = quest.hasGiver && !lower.empty() && lower == quest.giver.res;
const bool offersIt = !lower.empty() && offers.count(lower);
switch (opcode) {
case game::kScriptSpawn:
case game::kScriptCreateObject:
if (npc && !setUp) {
created[label.quest].insert(npc);
}
if (npc && onEnter && !isGiver) {
quest.createdOnEnter.push_back(npc);
}
break;
case game::kScriptSetNpcState:
if (npc && !setUp) {
touched[label.quest].insert(npc);
}
ends = ends || (offersIt && SetsHostile(args));
break;
case game::kScriptSetHitPoints:
ends = ends || (offersIt && args.size() >= 2 && args[1].number == 0);
break;
case game::kScriptChangeType:
quest.turnsGiver = quest.turnsGiver || isGiver;
break;
case game::kScriptDelete:
quest.deletesGiver = quest.deletesGiver || isGiver;
break;
case game::kScriptLoseQuest:
loses = true;
break;
case game::kScriptSetUpQuest:
case game::kScriptActivateQuest:
case game::kScriptGiveExperience:
case game::kScriptGiveGold:
ends = true;
break;
case game::kScriptCallLabel: {
const auto target = !args.empty() && args[0].text ? byName.find(args[0].text) : byName.end();
ends = ends || target == byName.end() || g_labels[target->second].quest != label.quest;
break;
}
default:
break;
}
});
if (loses && ends) {
g_finalLabels.insert(index);
quest.hasFinalFailure = true;
}
}
unsigned givers = 0;
for (auto& [id, quest] : g_quests) {
const std::string named = std::to_string(id);
if (setters.count(named) && std::find(quest.variables.begin(), quest.variables.end(), named) ==
quest.variables.end()) {
quest.variables.push_back(named);
}
for (uint32_t npc : touched[id]) {
if (!created[id].count(npc) && !(quest.hasGiver && npc == quest.giver.scriptId)) {
quest.needed.push_back(npc);
}
}
givers += quest.hasGiver;
}
re::Log("questscript: %u quests, %u with a giver, %u failure labels ending a story, %u bytes of script code",
static_cast<unsigned>(g_quests.size()), givers, static_cast<unsigned>(g_finalLabels.size()),
static_cast<unsigned>(g_code.size()));
}
void Run(const std::vector<uint8_t>& code) { game::RunScriptInstruction(code.data()); }
void SpawnNow(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog, const std::string& why) {
const int result = game::RunScriptInstruction(spawn.data());
if (!dialog.empty()) {
SetDialog("res:" + std::to_string(scriptId), dialog);
}
re::Log("questscript: Res:%u spawned again (%s, %d): %s", scriptId, why.c_str(), result,
LivingNpc(scriptId) ? "back" : "NOT FOUND");
}
void OnTick(uint8_t*) {
for (auto it = g_pending.begin(); it != g_pending.end();) {
const bool gone = game::GetObjectById(it->bodyId) != it->body;
if (!gone && GetTickCount() - it->since < kBodyWaitMs) {
++it;
continue;
}
SpawnNow(it->scriptId, it->spawn, it->dialog, it->why);
it = g_pending.erase(it);
}
}
void OnLoaded() { g_pending.clear(); }
struct Registration {
Registration() {
game::OnEngineTick(&OnTick);
game::OnSaveGameLoaded(&OnLoaded);
}
} g_registration;
} // namespace
const Quest* Find(int32_t questId) {
Scan();
const auto quest = g_quests.find(questId);
return quest != g_quests.end() ? &quest->second : nullptr;
}
const std::vector<uint8_t>* SpawnOf(uint32_t scriptId, int32_t questId) {
Scan();
const auto spawns = g_spawns.find(scriptId);
if (spawns == g_spawns.end()) {
return nullptr;
}
const std::string setUp = "QIS_OnSetUp" + std::to_string(questId);
const std::vector<uint8_t>* other = nullptr;
const std::vector<uint8_t>* own = nullptr;
for (const Spawn& spawn : spawns->second) {
if (spawn.quest < 0) {
return &spawn.code;
}
if (spawn.quest == questId && spawn.label->name == setUp) {
own = own ? own : &spawn.code;
} else if (spawn.quest != questId) {
other = other ? other : &spawn.code;
}
}
return own ? own : other;
}
void* LivingNpc(uint32_t scriptId) {
const uint32_t id = scriptId ? game::ScriptObjectId(scriptId) : 0;
void* creature = id && game::GetObjectById(id) ? game::GetCreature(id) : nullptr;
return creature && game::ObjectScriptId(creature) == scriptId && game::IsAlive(creature) ? creature : nullptr;
}
bool IsGone(const Giver& giver) { return giver.scriptId && !LivingNpc(giver.scriptId); }
void SpawnWhenGone(uint32_t scriptId, const std::vector<uint8_t>& spawn, const std::string& dialog,
const char* why) {
for (const Pending& pending : g_pending) {
if (pending.scriptId == scriptId) {
return;
}
}
const uint32_t bodyId = game::ScriptObjectId(scriptId);
void* body = bodyId ? game::GetObjectById(bodyId) : nullptr;
if (body && game::ObjectScriptId(body) == scriptId) {
g_pending.push_back({scriptId, spawn, dialog, body, bodyId, GetTickCount(), why});
} else {
SpawnNow(scriptId, spawn, dialog, why);
}
}
void Delete(uint32_t scriptId) {
const uint32_t id = game::ScriptObjectId(scriptId);
void* object = id ? game::GetObjectById(id) : nullptr;
if (!object || game::ObjectScriptId(object) != scriptId) {
return;
}
re::Log("questscript: Res:%u deleted", scriptId);
Run(Instruction(game::kScriptDelete, {{game::kScriptArgString, "res:" + std::to_string(scriptId)}}));
}
void SetDialog(const std::string& res, const std::string& dialog) {
Run(Instruction(game::kScriptSetNpcState,
{{game::kScriptArgString, res}, {game::kScriptArgDialog, dialog}}));
}
LabelScope::LabelScope(uint32_t label) { g_running.push_back(label); }
LabelScope::~LabelScope() { g_running.pop_back(); }
bool InFinalFailure() {
Scan();
for (uint32_t label : g_running) {
if (g_finalLabels.count(label)) {
return true;
}
}
return false;
}
} // namespace mods::questscript