Quests: main quest items no longer refused, restart resets bit-field variables

AddMainQuestItem refused every new main quest item once the list held an
entry with script id 0 (scripts give such items); the Goldbeeren of quest
402 were picked up and lost. The script id test is patched out.

Quest variables are bit fields (op 0x44 sets a bit): the quest giver
restart now writes the values directly, 0 and 1 (available) for the
variable named after the quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-28 00:07:06 +01:00
1 parent e539cab604
commit 55e48dd302
6 files changed
+126 -20

No files matched your search

+1
View File
@@ -82,6 +82,7 @@ add_library(sacredK SHARED
src/mods/followers.cpp src/mods/followers.cpp
src/mods/questfail.cpp src/mods/questfail.cpp
src/mods/questgiver.cpp src/mods/questgiver.cpp
src/mods/questitems.cpp
src/mods/questnpc.cpp src/mods/questnpc.cpp
src/mods/anticheat.cpp src/mods/anticheat.cpp
src/mods/loadingscreen.cpp src/mods/loadingscreen.cpp
+38 -3
View File
@@ -196,7 +196,8 @@ Dialogs without a quest (healers, closed shops: `geschlossen101`,
4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named 4 trigger area, 0x0f end quest, 0x14 start quest (#id), 0x17 named
position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if position, 0x1a dialog text, 0x35 book text, 0x3a / 0x42 if / else if
(0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f (0x49 var == n, 0x40 "@npc"), 0x3c dialog answer (text, label), 0x3f
compass, 0x44 set variable, 0x48 move NPC, 0x56 dialog state. compass, 0x43 / 0x44 / 0x45 / 0x4b variables (see below), 0x48 move NPC,
0x56 dialog state.
- Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the - Script NPC names: "Res:<n>" is a script id (object +0x3c, `0x44a390`); the
quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets quest manager maps it to the object (`0x464ec0`, map +0x7668); a spawn sets
the entry. the entry.
@@ -227,8 +228,8 @@ load we need a repair ... if not completed or correctly running".
`mods/questgiver.cpp`: a quest's giver is the NPC whose spawn instruction `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 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 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 to restarts (failed entry at the giver, the variables only its labels set back:
0), and once the body is gone the spawn instruction runs again. After a load, 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 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 the game (the escort of "Hetzjagd" arrived alive); see `docs/todo.md` for the
audit of how quests can fail. audit of how quests can fail.
@@ -237,6 +238,40 @@ 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 took it for running and reset 16 available quests and spawned their NPCs on
Borf (repaired once, then saved). Borf (repaired once, then saved).
## 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) ## Escorts never left behind (2026-10-26)
"I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no "I have lost a follower NPC from the quest 'Die Sklaven der Orks', it's no
+32 -3
View File
@@ -77,10 +77,20 @@ inline uint32_t ScriptLabelCodeSize(const uint8_t* label) {
// args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel // args are tagged (0x01 a string, 0x0b an int32, ...). Script_RunLabel
// (0x46bd80) reads a label's code and runs it instruction by instruction with // (0x46bd80) reads a label's code and runs it instruction by instruction with
// 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type, // 0x475970. Opcodes used here: 1 spawns a creature ("Res:<script id>", type,
// position, 0x09 + its dialog, ...; e.g. Region15Init), 0x44 sets a script // position, 0x09 + its dialog, ...; e.g. Region15Init). Script variables are
// variable ({0x01 name, 0x0b value}). // bit fields ({0x01 name, 0x0b n}; measured 2026-10-27): 0x43 sets the value,
// 0x44 sets bit n (0x49bb30), 0x45 clears bit n (0x49c450), 0x4b adds n
// (0x49cc20); conditions 0x49 / 0x4a test bit n set / clear, 0x48 the value
// (0x498aa0; a missing variable is false).
constexpr uint16_t kScriptSpawn = 0x01; constexpr uint16_t kScriptSpawn = 0x01;
constexpr uint16_t kScriptSetVariable = 0x44; constexpr uint16_t kScriptSetVarValue = 0x43;
constexpr uint16_t kScriptSetVarBit = 0x44;
constexpr uint16_t kScriptClearVarBit = 0x45;
constexpr uint16_t kScriptAddToVar = 0x4b;
inline bool IsScriptVariableWrite(uint16_t opcode) {
return opcode == kScriptSetVarValue || opcode == kScriptSetVarBit || opcode == kScriptClearVarBit ||
opcode == kScriptAddToVar;
}
constexpr uint8_t kScriptArgString = 0x01; constexpr uint8_t kScriptArgString = 0x01;
constexpr uint8_t kScriptArgInt = 0x0b; constexpr uint8_t kScriptArgInt = 0x0b;
constexpr uint8_t kScriptArgDialog = 0x09; constexpr uint8_t kScriptArgDialog = 0x09;
@@ -138,6 +148,25 @@ inline uint8_t* QuestManager() { return reinterpret_cast<uint8_t*>(0x00aaaf00);
constexpr size_t kBookBegin = 0x424; constexpr size_t kBookBegin = 0x424;
constexpr size_t kBookEnd = 0x428; constexpr size_t kBookEnd = 0x428;
constexpr size_t kBookEntrySize = 0x174; constexpr size_t kBookEntrySize = 0x174;
// The script variables: a vector at quest manager +0x7550 / +0x7554 of 0x24-byte
// entries {name[0x20], value} (read by the conditions, 0x498aa0 case 0x49).
constexpr size_t kVariablesBegin = 0x7550;
constexpr size_t kVariablesEnd = 0x7554;
constexpr size_t kVariableSize = 0x24;
constexpr size_t kVariableValue = 0x20;
// The value slot of script variable `name`, nullptr if it does not exist yet.
inline int32_t* ScriptVariable(const char* name) {
uint8_t* begin = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesBegin);
uint8_t* end = *reinterpret_cast<uint8_t**>(QuestManager() + kVariablesEnd);
for (uint8_t* entry = begin; entry && entry < end; entry += kVariableSize) {
if (strncmp(reinterpret_cast<const char*>(entry), name, kVariableValue) == 0) {
return reinterpret_cast<int32_t*>(entry + kVariableValue);
}
}
return nullptr;
}
constexpr size_t kBookCapacity = 0x42c; constexpr size_t kBookCapacity = 0x42c;
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450) constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
constexpr size_t kBookState = 0x04; constexpr size_t kBookState = 0x04;
+14 -14
View File
@@ -5,7 +5,7 @@
// completed or correctly running"). // completed or correctly running").
// //
// Measured: the quest's script (FunkCode.bin) gives the giver a death label // Measured: the quest's script (FunkCode.bin) gives the giver a death label
// when she starts following ("ex1501": quest variable "1501" = 1, then the // when she starts following ("ex1501": bit 1 of quest variable "1501", then the
// poachers' death label "tot1501", which ends the quest without a reward once // poachers' death label "tot1501", which ends the quest without a reward once
// all poachers are dead; until then it stays in progress with no giver). The // all poachers are dead; until then it stays in progress with no giver). The
// body is removed. The giver is placed by her region's start script // body is removed. The giver is placed by her region's start script
@@ -141,7 +141,7 @@ void ScanVariables() {
label += game::kScriptLabelSize) { label += game::kScriptLabelSize) {
const int32_t quest = game::ScriptLabelQuest(label); const int32_t quest = game::ScriptLabelQuest(label);
ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) { ForEachInstruction(label, [&](size_t at, uint16_t opcode, uint16_t size) {
if (opcode == game::kScriptSetVariable && g_code[at + 4] == game::kScriptArgString && if (game::IsScriptVariableWrite(opcode) && g_code[at + 4] == game::kScriptArgString &&
memchr(&g_code[at + 5], 0, size - 5)) { memchr(&g_code[at + 5], 0, size - 5)) {
setters[reinterpret_cast<const char*>(&g_code[at + 5])].insert(quest); setters[reinterpret_cast<const char*>(&g_code[at + 5])].insert(quest);
} }
@@ -218,20 +218,20 @@ void* LivingGiver(const Giver& giver) {
: nullptr; : 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) { void ResetVariables(int32_t questId) {
for (const std::string& name : g_variables[questId]) { for (const std::string& name : g_variables[questId]) {
std::vector<uint8_t> code(kInstructionHeader); int32_t* value = game::ScriptVariable(name.c_str());
code.push_back(game::kScriptArgString); // The variable named after the quest keeps bit 0: "available", which the
code.insert(code.end(), name.begin(), name.end()); // giver's offer tests (auftrag402: IsVarBit 402 0; measured 2026-10-28:
code.push_back(0); // all 87 quests not yet accepted had value 1).
code.push_back(game::kScriptArgInt); const int32_t fresh = name == std::to_string(questId) ? 1 : 0;
code.insert(code.end(), 4, 0); if (!value || *value == fresh) {
code[0] = static_cast<uint8_t>(game::kScriptSetVariable); continue;
code[1] = 0; }
code[2] = static_cast<uint8_t>(code.size()); re::Log("questgiver: quest %d variable '%s' %d -> %d", questId, name.c_str(), *value, fresh);
code[3] = static_cast<uint8_t>(code.size() >> 8); *value = fresh;
game::RunScriptInstruction(code.data());
re::Log("questgiver: quest %d variable '%s' = 0", questId, name.c_str());
} }
} }
+37
View File
@@ -0,0 +1,37 @@
// Main quest items blocked by an entry without a script id (request of
// 2026-10-27: "I cannot finish the Quest Windgeflüster").
//
// Measured: picking at the Goldbeerenbusch runs take402, which gives the hero
// the berries (item type 6029) and sets the quest's variable; the giver's
// dialog then asks for type 6029 in the hero's main quest items. The game
// logged "addMainQuestItem() Already has mainQuestItem(6029)" and the berries
// never arrived: the dwarf's list held {4609, 0}.
//
// cInventoryEntry::AddMainQuestItem (0x5d8240, thiscall (type, script id,
// notify), list of {type, script id} at +0/+4) refuses a new item when an entry
// has the same type or a script id of 0 (0x5d83c6: cmp [edx+4], ebx (0)).
// Scripts that hand out an item without a "Res:" id add it with script id 0
// (0x486bcb..0x486bd8), so after such an item every later main quest item was
// refused. The mod drops the script id test; the same type still blocks.
#include <cstdint>
#include "re/log.h"
#include "re/patch.h"
namespace mods::questitems {
namespace {
// AddMainQuestItem's duplicate test: `cmp [edx+4], ebx; je 0x5d8418` (ebx 0).
constexpr uint32_t kScriptIdTest = 0x005d83c6;
void Startup() {
const uint8_t from[5] = {0x39, 0x5a, 0x04, 0x74, 0x4d};
const uint8_t to[5] = {0x90, 0x90, 0x90, 0x90, 0x90};
const bool ok = re::ReplaceCode(kScriptIdTest, from, to, sizeof(to));
re::Log("questitems: main quest items without a script id no longer block others%s", ok ? "" : " (patch FAILED)");
}
RE_STARTUP(QuestItemsStartup, Startup);
} // namespace
} // namespace mods::questitems
+4
View File
@@ -444,3 +444,7 @@
0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9 0x007b64c0 cParticleSystem_grenadeimpact::Update # vf08 (dt), ret 4; phase +0x20a9
0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c) 0x007613e0 cParticleSystem::DrawParticles # colour table row (+0x3d) * 256 + life (+0x2c)
0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68 0x00763ea0 cParticleSystem::UpdateParticles # thiscall (dt, emitter rows, mode), particles 0x40 bytes at +0x68
0x005d8240 cInventoryEntry::AddMainQuestItem # thiscall (type, script id, notify): {type, script id} pairs; refused if same type (script id 0 test patched out)
0x0049bb30 QuestManager_SetVarBit # script op 0x44: variable |= 1 << n
0x0049c450 QuestManager_ClearVarBit # script op 0x45: variable &= ~(1 << n)
0x0049cc20 QuestManager_AddToVar # script op 0x4b: variable += n