From 3f03228c29d4c3c4c6e1af3c0d430a2d2f6875fb Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sun, 25 Oct 2026 01:44:01 +0200 Subject: [PATCH] Failed quests restartable A failed quest gets its flags cleared and its quest book entries removed after its failure script ran, so the quest giver can offer it again; quests failed before the mod are reset once after loading. Quest table and quest book in game/engine/quests.h, measurements in docs/quests.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + docs/quests.md | 42 ++++++++++++++++++ src/game/engine/quests.h | 96 ++++++++++++++++++++++++++++++++++++++++ src/mods/questfail.cpp | 90 +++++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 docs/quests.md create mode 100644 src/game/engine/quests.h create mode 100644 src/mods/questfail.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ee3f6c3..b957f46 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ add_library(sacredK SHARED src/mods/screen.cpp src/mods/characters.cpp src/mods/mainmenu.cpp + src/mods/questfail.cpp src/mods/anticheat.cpp ) target_include_directories(sacredK PRIVATE src) diff --git a/docs/quests.md b/docs/quests.md new file mode 100644 index 0000000..a2c0f67 --- /dev/null +++ b/docs/quests.md @@ -0,0 +1,42 @@ +# 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 1/2/4/8 are cleared and its book entries +removed, as if never triggered; 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. diff --git a/src/game/engine/quests.h b/src/game/engine/quests.h new file mode 100644 index 0000000..27bf8b9 --- /dev/null +++ b/src/game/engine/quests.h @@ -0,0 +1,96 @@ +#pragma once +// Quests: the quest table (one entry per quest, from the scripts) and the quest +// manager's quest book (the hero's accepted quests). docs/quests.md. + +#include +#include +#include + +namespace game { + +// Quest table g_quests: vector begin/end (0xaa9e98 / 0xaa9e9c), 0x124 bytes per quest. +constexpr uintptr_t kQuestsBegin = 0x00aa9e98; +constexpr uintptr_t kQuestsEnd = 0x00aa9e9c; +constexpr size_t kQuestSize = 0x124; +constexpr size_t kQuestId = 0x00; +constexpr size_t kQuestName = 0x04; // internal name, ANSI +constexpr size_t kQuestTriggerScript = 0x10c; // run by Quest_Trigger 0x46de10 +constexpr size_t kQuestSuccessScript = 0x118; // run by Quest_End on success +constexpr size_t kQuestFailureScript = 0x11c; // run by Quest_End on failure +constexpr size_t kQuestFlags = 0x120; + +// Quest flags (+0x120); QuestDone tests 2, QuestInProgress 4. +constexpr uint32_t kQuestTriggered = 0x1; +constexpr uint32_t kQuestDone = 0x2; +constexpr uint32_t kQuestInProgress = 0x4; +constexpr uint32_t kQuestBusy = 0x8; // while Quest_Trigger runs the scripts + +inline uint8_t* QuestsBegin() { return *reinterpret_cast(kQuestsBegin); } +inline uint8_t* QuestsEnd() { return *reinterpret_cast(kQuestsEnd); } +inline size_t QuestCount() { + uint8_t* begin = QuestsBegin(); + return begin && QuestsEnd() > begin ? (QuestsEnd() - begin) / kQuestSize : 0; +} +inline uint8_t* Quest(int index) { return QuestsBegin() + index * kQuestSize; } +inline int32_t& QuestId(uint8_t* quest) { return *reinterpret_cast(quest + kQuestId); } +inline uint32_t& QuestFlags(uint8_t* quest) { return *reinterpret_cast(quest + kQuestFlags); } +inline const char* QuestName(const uint8_t* quest) { return reinterpret_cast(quest + kQuestName); } + +// Table index of quest `id`, -1 if unknown. +inline int QuestIndex(int32_t id) { + for (size_t i = 0, n = QuestCount(); i < n; ++i) { + if (QuestId(Quest(static_cast(i))) == id) { + return static_cast(i); + } + } + return -1; +} + +// The quest manager, a static object (script interpreter; ecx of 0x475970 and +// of Quest_End 0x46ced0). Its quest book is a vector of plain 0x174-byte entries +// (serialized field by field, Quest_End 0x46d9d7..); per player a selected +// entry index at +0x3a8 + player * 8 (Quest_End sets it to the count = none). +inline uint8_t* QuestManager() { return reinterpret_cast(0x00aaaf00); } +constexpr size_t kBookBegin = 0x424; +constexpr size_t kBookEnd = 0x428; +constexpr size_t kBookEntrySize = 0x174; +constexpr size_t kBookState = 0x04; +constexpr size_t kBookQuestId = 0x08; +constexpr size_t kBookSelected = 0x3a8; +constexpr size_t kBookSelectedStride = 8; +constexpr int kBookPlayers = 16; // +0x3a4.. 16 pairs up to the book at +0x424 + +// Book entry states (+4); Quest_End writes 100 / 101. +constexpr int32_t kBookActive = 2; +constexpr int32_t kBookSolved = 100; +constexpr int32_t kBookFailed = 101; + +inline uint8_t*& BookBegin() { return *reinterpret_cast(QuestManager() + kBookBegin); } +inline uint8_t*& BookEnd() { return *reinterpret_cast(QuestManager() + kBookEnd); } +inline size_t BookCount() { + return BookBegin() && BookEnd() > BookBegin() ? (BookEnd() - BookBegin()) / kBookEntrySize : 0; +} +inline uint8_t* BookEntry(size_t index) { return BookBegin() + index * kBookEntrySize; } +inline int32_t BookState(const uint8_t* entry) { return *reinterpret_cast(entry + kBookState); } +inline int32_t BookQuestId(const uint8_t* entry) { return *reinterpret_cast(entry + kBookQuestId); } + +// Removes book entry `index`; the players' selected indices are kept on their +// entries (or "none" = the new count). +inline void EraseBookEntry(size_t index) { + const size_t count = BookCount(); + if (index >= count) { + return; + } + std::memmove(BookEntry(index), BookEntry(index + 1), (count - index - 1) * kBookEntrySize); + BookEnd() -= kBookEntrySize; + for (int player = 0; player < kBookPlayers; ++player) { + auto& selected = *reinterpret_cast(QuestManager() + kBookSelected + player * kBookSelectedStride); + if (selected == index || selected >= count) { + selected = static_cast(count - 1); + } else if (selected > index) { + --selected; + } + } +} + +} // namespace game diff --git a/src/mods/questfail.cpp b/src/mods/questfail.cpp new file mode 100644 index 0000000..1a54904 --- /dev/null +++ b/src/mods/questfail.cpp @@ -0,0 +1,90 @@ +// Failed quests can be restarted (request of 2026-10-17): a failed quest is not +// final, the quest giver offers it again. +// +// Measured (docs/quests.md): a failed quest keeps flags 3 (triggered + done) in +// the quest table and its quest book entry gets state 101; Quest_Trigger +// (0x46de10) does nothing for a quest with flag 1, so the giver never offers it +// again. The game itself repeats only the generic quests (ids 2600..3599, +// Quest_End copies them under a new id). +// +// The mod: after Quest_End failed a quest (its failure script has run), the +// quest's flags are cleared and its book entries removed, as if it had never +// been triggered. Quests that failed before the mod are reset the same way once +// after loading. + +#include +#include + +#include "game/engine/events.h" +#include "game/engine/quests.h" +#include "re/hook.h" +#include "re/log.h" + +namespace mods::questfail { +namespace { + +constexpr uint32_t kResetFlags = game::kQuestTriggered | game::kQuestDone | game::kQuestInProgress | game::kQuestBusy; + +bool g_resetAfterLoad = false; + +// The quest at table `index` as if it had never been triggered. +void Restart(int index) { + uint8_t* quest = game::Quest(index); + const int32_t id = game::QuestId(quest); + re::Log("questfail: restarting quest %d '%s' (flags %x)", id, game::QuestName(quest), game::QuestFlags(quest)); + game::QuestFlags(quest) &= ~kResetFlags; + for (size_t i = game::BookCount(); i-- > 0;) { + if (game::BookQuestId(game::BookEntry(i)) == id) { + game::EraseBookEntry(i); + } + } +} + +void RestartFailedInBook() { + for (size_t i = game::BookCount(); i-- > 0;) { + const uint8_t* entry = game::BookEntry(i); + if (game::BookState(entry) != game::kBookFailed) { + continue; + } + const int index = game::QuestIndex(game::BookQuestId(entry)); + if (index >= 0) { + Restart(index); + } + i = std::min(i, game::BookCount()); + } +} + +void OnTick(uint8_t*) { + if (g_resetAfterLoad) { + g_resetAfterLoad = false; + RestartFailedInBook(); + } +} + +void OnLoaded() { + g_resetAfterLoad = true; +} + +} // namespace + +// Quest_End (0x46ced0, quest manager thiscall (index, ?, success), ret 0xc): +// LoseQuest passes success 0, ExitQuest 1. +void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success); +RE_HOOK(0x0046ced0, Quest_End); +void __fastcall Quest_End(uint8_t* self, void* edx, int index, int a2, char success) { + Quest_End_original(self, edx, index, a2, success); + if (!success && index >= 0 && static_cast(index) < game::QuestCount()) { + Restart(index); + } +} + +namespace { +struct Register { + Register() { + game::OnEngineTick(&OnTick); + game::OnSaveGameLoaded(&OnLoaded); + } +} g_register; +} // namespace + +} // namespace mods::questfail