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) <noreply@anthropic.com>
This commit is contained in:
1 parent
91bbc52cf0
commit
3f03228c29
4 files changed
+229
No files matched your search
@@ -74,6 +74,7 @@ add_library(sacredK SHARED
|
|||||||
src/mods/screen.cpp
|
src/mods/screen.cpp
|
||||||
src/mods/characters.cpp
|
src/mods/characters.cpp
|
||||||
src/mods/mainmenu.cpp
|
src/mods/mainmenu.cpp
|
||||||
|
src/mods/questfail.cpp
|
||||||
src/mods/anticheat.cpp
|
src/mods/anticheat.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(sacredK PRIVATE src)
|
target_include_directories(sacredK PRIVATE src)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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<uint8_t**>(kQuestsBegin); }
|
||||||
|
inline uint8_t* QuestsEnd() { return *reinterpret_cast<uint8_t**>(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<int32_t*>(quest + kQuestId); }
|
||||||
|
inline uint32_t& QuestFlags(uint8_t* quest) { return *reinterpret_cast<uint32_t*>(quest + kQuestFlags); }
|
||||||
|
inline const char* QuestName(const uint8_t* quest) { return reinterpret_cast<const char*>(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<int>(i))) == id) {
|
||||||
|
return static_cast<int>(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<uint8_t*>(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<uint8_t**>(QuestManager() + kBookBegin); }
|
||||||
|
inline uint8_t*& BookEnd() { return *reinterpret_cast<uint8_t**>(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<const int32_t*>(entry + kBookState); }
|
||||||
|
inline int32_t BookQuestId(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(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<uint32_t*>(QuestManager() + kBookSelected + player * kBookSelectedStride);
|
||||||
|
if (selected == index || selected >= count) {
|
||||||
|
selected = static_cast<uint32_t>(count - 1);
|
||||||
|
} else if (selected > index) {
|
||||||
|
--selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace game
|
||||||
@@ -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 <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#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<size_t>(index) < game::QuestCount()) {
|
||||||
|
Restart(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct Register {
|
||||||
|
Register() {
|
||||||
|
game::OnEngineTick(&OnTick);
|
||||||
|
game::OnSaveGameLoaded(&OnLoaded);
|
||||||
|
}
|
||||||
|
} g_register;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
} // namespace mods::questfail
|
||||||
Reference in new issue
Block a user