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:
khallaandClaude Opus 5.5 committed 2026-10-25 01:44:01 +02:00
1 parent 91bbc52cf0
commit 3f03228c29
4 files changed
+229

No files matched your search

+96
View File
@@ -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
+90
View File
@@ -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