From 7ec0fa2b8e6159284394ddc80ed59bdb94014b94 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Fri, 30 Oct 2026 18:57:43 +0100 Subject: [PATCH] Quest givers: givers killed as enemies keep their death script "Familienbande" (702) turns its giver hostile and wants him killed; the giver death rule restarted it instead. A giver hostile to the hero at death now runs the game's label, and the load repair skips quests whose script changes their giver's creature type (opcode 0x4f). Deferred notes: frame drops while walking, Eingraben clearing slows. Co-Authored-By: Claude Opus 5.5 --- docs/quests.md | 11 +++++++ docs/todo.md | 18 ++++++++++++ src/mods/questgiver.cpp | 65 ++++++++++++++++++++++++++++++++++------- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/docs/quests.md b/docs/quests.md index a79ca1f..886c675 100644 --- a/docs/quests.md +++ b/docs/quests.md @@ -238,6 +238,17 @@ 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 diff --git a/docs/todo.md b/docs/todo.md index ddcee32..e521e76 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -266,3 +266,21 @@ Possibly related (2026-10-25, once): right after a game start the main menu texts were drawn as solid gold blocks (the gold text texture without the font's letter mask). Gone after a restart; Alt+Tab does not cause it. The log showed nothing unusual (all hooks and patches applied). + +## Frame drops while walking (2026-10-27, deferred 2026-10-30) + +Reported 2026-10-27. Measured 2026-10-29 (about 54 min, temporary timing in +the Flip, engine tick and map border, since removed): 549 frames over 40 ms, +mostly 0-1 per 10 s, worst 833 ms. Present (Flip) was slow only once; the +time goes to the game's own engine tick (227 ticks of 18-70 ms). Our tick +callbacks were slow only at the 10-min autosave. The map border is drawn only +while the map is open, so it is not the cause. Next step if resumed: time the +parts of the game tick (sector streaming while walking is the main suspect). +Deferred by the user 2026-10-30. + +## Eingraben clears slows, roots and stuns (deferred 2026-10-30) + +Step 3 of Eingraben (docs/dwarf-arts.md): cast clears slowing, rooting and +stunning effects on the hero. The effect kinds are unknown; plan: temporary +log of the effects added to the hero while the user gets slowed or frozen, +then remove those kinds on cast. Deferred by the user 2026-10-30. diff --git a/src/mods/questgiver.cpp b/src/mods/questgiver.cpp index fc754c9..d59bdcf 100644 --- a/src/mods/questgiver.cpp +++ b/src/mods/questgiver.cpp @@ -50,6 +50,7 @@ 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; @@ -67,6 +68,7 @@ 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; @@ -99,21 +101,31 @@ void ForEachInstruction(const uint8_t* label, Fn fn) { } } +// 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) { - static const char kRes[] = "res:"; for (size_t at = 0; at + 9 < g_code.size(); ++at) { - if (U16(at) != game::kScriptSpawn || g_code[at + 4] != game::kScriptArgString || - _strnicmp(reinterpret_cast(&g_code[at + 5]), kRes, 4) != 0) { + if (U16(at) != game::kScriptSpawn) { continue; } const uint16_t size = U16(at + 2); - if (size <= 9 || size > kMaxInstruction || at + size > g_code.size()) { + if (size > kMaxInstruction || at + size > g_code.size()) { continue; } - char* numberEnd = nullptr; - const unsigned long scriptId = std::strtoul(reinterpret_cast(&g_code[at + 9]), &numberEnd, 10); - if (scriptId == 0 || *numberEnd != 0) { + const uint32_t scriptId = InstructionScriptId(at, size); + if (scriptId == 0) { continue; } for (size_t tag = at + 9; tag + 1 < at + size; ++tag) { @@ -126,13 +138,31 @@ void ScanSpawns(const std::map& dialogs) { } const auto dialog = dialogs.find(name); if (dialog != dialogs.end() && !g_givers.count(dialog->second)) { - g_givers[dialog->second] = {static_cast(scriptId), + 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() { @@ -206,7 +236,9 @@ void Scan() { } ScanSpawns(dialogs); ScanVariables(); - re::Log("questgiver: %u quest givers in %u bytes of script code", static_cast(g_givers.size()), + 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())); } @@ -265,10 +297,12 @@ bool IsRunning(int index) { return (flags & game::kQuestInProgress) && !(flags & game::kQuestDone); } +// Quests whose giver turns into an enemy are left alone: a missing giver may +// be the one the hero killed as the quest asks. void RepairAfterLoad() { for (const auto& [id, giver] : g_givers) { const int index = game::QuestIndex(id); - if (index < 0 || !IsRunning(index) || LivingGiver(giver)) { + if (index < 0 || !IsRunning(index) || g_giverTurns.count(id) || LivingGiver(giver)) { continue; } re::Log("questgiver: quest %d '%s' in progress without its giver Res:%u", id, @@ -311,7 +345,10 @@ struct Registration { // Script_RunLabel (0x46bd80, cdecl (label, object, flag), plain ret): a label // of a running quest run for its dead giver (her death label) is skipped and -// the quest restarted. +// the quest restarted. Not when the giver died as an enemy: some quests turn +// the giver hostile and their death label goes on ("Familienbande", 702: +// answer change702 turns the giver Res:17061 and two relatives against the +// hero, their death label tot702 sends him back to the girl Res:17060). 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) { @@ -324,6 +361,12 @@ uint32_t __cdecl Script_RunLabel(uint32_t label, uint8_t* object, int flag) { Scan(); const auto giver = g_givers.find(questId); if (giver != g_givers.end() && game::ObjectScriptId(creature) == giver->second.scriptId) { + 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)); + 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));