Quest book as one list: story, then side quests by region with ??? rows
The game's list builder runs on a reordered copy of the book: the story, then one section per region (id blocks of 100 grouped by the region their quests lie in, named as the megamap names regions), unknown quests as "???", the rest joined to the nearest area, undiscovered counts. Act tabs hidden, page turning kept. "Erledigte anzeigen" row toggles completed quests ([questbook] ShowCompleted). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
34cacefcfc
commit
1d16fc65c4
7 files changed
+675
-14
No files matched your search
@@ -75,6 +75,7 @@ add_library(sacredK SHARED
|
||||
src/mods/characters.cpp
|
||||
src/mods/mainmenu.cpp
|
||||
src/mods/mapreveal.cpp
|
||||
src/mods/questbook.cpp
|
||||
src/mods/questfail.cpp
|
||||
src/mods/anticheat.cpp
|
||||
src/mods/loadingscreen.cpp
|
||||
|
||||
@@ -40,3 +40,64 @@ 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.
|
||||
|
||||
## Quest book (measured 2026-10-25)
|
||||
|
||||
- Book entries (quest manager +0x424, 0x174 bytes): kind +0 (3 main quest,
|
||||
4 side quest: ids 1..99 are main quests, 0x46c450), state +4, quest id +8,
|
||||
title / text ids +0x24 / +0x28, progress log text ids +0x2c.., map position
|
||||
+0x18 / +0x1c (0x497830), chapter +0x16c (u16). A new entry (0x46c450,
|
||||
thiscall (quest index, ?), ret 8, called by the script interpreter
|
||||
0x48f602) copies its chapter from the player's current main quest entry
|
||||
(+0x3a4 + slot * 8; selected entry +0x3a8 + slot * 8, read by 0x49dd40).
|
||||
- cUI_Book (one per book mode, 0x7a8[mode] of the book window; quest mode
|
||||
+0x166 = 0): list builder 0x6b04c0 (thiscall, from the rebuild 0x6b1670),
|
||||
page-full check 0x6b1a70 (thiscall (tab, page), ret 8), render 0x6b27f0
|
||||
(thiscall (renderer, bookmarks only), ret 8: the left page's rows, then the
|
||||
selected row's detail record on the right, 0x6b1cd0 thiscall (renderer,
|
||||
record, sub-page), ret 0xc), events 0x6b3620 (thiscall (event), ret 4).
|
||||
Current tab +0x15e, page +0x162; per tab pages +0x97c + tab * 0xc (0x1c
|
||||
bytes: title text +0, selected row +4, rows +0x10) and detail records
|
||||
+0x9d0 + tab * 0xc (0x40 bytes). Row (0x14 bytes): u16 flags (4 spacer,
|
||||
0x40 done, 0x80 entry flag 1), title +4, detail record +8, book entry +0xc,
|
||||
icon +0x10. Act tabs: chapter text table 0x9de7e4 (9000 "Pfade des
|
||||
Schicksals", 9001 "Unter blutigen Bannern", 9002 "Die rastlosen Toten",
|
||||
9003 "Zeitenwende"); bookmarks: 7 controls at +0x168, 0xbc apart.
|
||||
- The book's list and the quest scripts run on the same thread (logged).
|
||||
- Quest table (Gladiator, Silber): 28 main quests, 87 side quests in id
|
||||
blocks of 100 (1xx .. 25xx, 2 to 7 each; their accepted quests lie together
|
||||
on the map), about 120 other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx),
|
||||
352 generic ones (2600..3599) and bookkeeping entries (0, 99 "MP-Start",
|
||||
5081..5089 "Region 1 befreit").
|
||||
|
||||
## Quest book as one list (request 2026-10-25)
|
||||
|
||||
"Show all quests, unknown ones as ???, so a player knows how many quests an
|
||||
area holds"; then: "get rid of the tabs ... a section for story, and then a
|
||||
section per region", the game's page turning kept. `mods/questbook.cpp`: the
|
||||
game's builder runs on a reordered copy of the book (story, then a section
|
||||
"<region> (known/all)" with "???" rows, other side quests joined to the
|
||||
nearest area, "Unentdeckt" counts), all in the first tab; rows are mapped
|
||||
back to the real entries afterwards (ours to none, which clicks ignore). The
|
||||
bookmarks are hidden, pages titled "Quests", each page selects its first
|
||||
quest, a click on a heading keeps the selection. Counted: side quests whose
|
||||
name is unique in the table.
|
||||
|
||||
Area names (measured 2026-10-25): an area is an id block (1xx .. 25xx); its
|
||||
name is the game's name of the region most of its known quests lie in, as the
|
||||
megamap names the hero's region: book cell +0x18/+0x1c (x 53.66563 = world
|
||||
units) -> `World_SectorAt` 0x634970 -> `Sector_Region` 0x622e30 ->
|
||||
`Region_NameKey` 0x425370 -> text (`game/engine/regions.h`). Several blocks
|
||||
lie in one named region (regions 1, 2, 3 are all `UI_REGION_SOUTHKERN`), so
|
||||
blocks with the same name make one section; blocks without a known position
|
||||
are "Gebiet N".
|
||||
|
||||
"Erledigte anzeigen" (request 2026-10-25: "some kind of 'Show completed'
|
||||
checkmark. If unchecked, completed quests are not in the list (as well as
|
||||
completed regions)"): the list's first row, an own entry whose state draws the
|
||||
solved mark (100) when on and no icon (0) when off; its row carries the marker
|
||||
entry 0xfffffffe. A click toggles it (`sacredK.ini` `[questbook]
|
||||
ShowCompleted`, default 1) and calls `cUI_Book::Rebuild` 0x6b1670 (thiscall,
|
||||
no arguments; keeps tab and page if the page still exists, else ours clamps
|
||||
it). Off leaves out solved entries (main and side) and areas whose quests are
|
||||
all known and solved; failed quests stay (restartable).
|
||||
+4
-8
@@ -224,15 +224,11 @@ of fine-tuning. Details to be collected when we get to it; known so far:
|
||||
list scrolling beyond 9 characters, larger heading font, the options
|
||||
window's layout in the width beyond its 1024 frame.
|
||||
|
||||
## Quest book: all quests, unknown ones as "???" (2026-10-25)
|
||||
## Quest NPCs after the quest (2026-10-25)
|
||||
|
||||
Request: every quest appears in the quest book; quests not yet activated show
|
||||
as "???", so a player knows how many quests an area holds without being told
|
||||
where they are. Notes: the quest table (g_quests 0xaa9e98, 0x124 per quest,
|
||||
game/engine/quests.h) lists all quests of the campaign, including the generic
|
||||
repeatable ones (ids 2600..3599, cloned by Quest_End) and DQ_ dynamic quests;
|
||||
which of them count for an area, and how the book groups by area, is still to
|
||||
be measured.
|
||||
Request: NPCs related to a quest are drawn white on the minimaps and always
|
||||
turn towards the player; once their quest is finished they should stop
|
||||
turning and be drawn blue (like other NPCs).
|
||||
|
||||
## Megamap: zoom not centred on the hero (2026-10-25)
|
||||
|
||||
|
||||
@@ -54,12 +54,34 @@ 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 kBookCapacity = 0x42c;
|
||||
constexpr size_t kBookKind = 0x00; // 3 main quest (ids 1..99), 4 side quest (0x46c450)
|
||||
constexpr size_t kBookState = 0x04;
|
||||
constexpr size_t kBookQuestId = 0x08;
|
||||
constexpr size_t kBookTitle = 0x24; // text ids
|
||||
constexpr size_t kBookText = 0x28;
|
||||
// The quest compass (the quest's marker on the maps, script QuestkompassPos /
|
||||
// QuestkompassOBJ 0x49a7a0 / 0x49af70): target +0x10/+0x14 is a map cell
|
||||
// (x, y), or kCompassObject + object id, or kCompassScriptNpc + script NPC id
|
||||
// (resolved to an object later); radius +0x20 ("rad:"). The marker's cell
|
||||
// +0x18/+0x1c is resolved from the target by 0x4a6740 (thiscall (entry), ret 4).
|
||||
constexpr size_t kBookCompassX = 0x10;
|
||||
constexpr size_t kBookCompassY = 0x14;
|
||||
constexpr size_t kBookCompassRadius = 0x20;
|
||||
constexpr uint32_t kCompassObject = 0xffffffff;
|
||||
constexpr uint32_t kCompassScriptNpc = 0xeeeeeeee;
|
||||
constexpr size_t kBookX = 0x18; // marker cell (0x497830)
|
||||
constexpr size_t kBookY = 0x1c;
|
||||
constexpr size_t kBookChapter = 0x16c; // u16, book tab + 1 (0 = tab 0), copied from the current main quest
|
||||
constexpr size_t kBookMainIndex = 0x3a4; // per player: the current main quest's entry
|
||||
constexpr size_t kBookSelected = 0x3a8;
|
||||
constexpr size_t kBookSelectedStride = 8;
|
||||
constexpr int kBookPlayers = 16; // +0x3a4.. 16 pairs up to the book at +0x424
|
||||
|
||||
constexpr int32_t kBookMain = 3;
|
||||
constexpr int32_t kBookSide = 4;
|
||||
constexpr int kMainQuestLast = 99; // quest ids 1..99 are main quests (0x46c450)
|
||||
|
||||
// Book entry states (+4); Quest_End writes 100 / 101.
|
||||
constexpr int32_t kBookActive = 2;
|
||||
constexpr int32_t kBookSolved = 100;
|
||||
@@ -67,15 +89,32 @@ 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 uint8_t*& BookCapacityEnd() { return *reinterpret_cast<uint8_t**>(QuestManager() + kBookCapacity); }
|
||||
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); }
|
||||
inline int32_t BookKind(const uint8_t* entry) { return *reinterpret_cast<const int32_t*>(entry + kBookKind); }
|
||||
inline uint16_t BookChapter(const uint8_t* entry) { return *reinterpret_cast<const uint16_t*>(entry + kBookChapter); }
|
||||
|
||||
// The local player's slot in the per-player book fields (session +0x14 is the
|
||||
// player number 1..16; 0x46c450, 0x49dd40).
|
||||
inline int LocalPlayerSlot() {
|
||||
using InstanceFn = uint8_t*(__cdecl*)();
|
||||
const int player = *reinterpret_cast<int32_t*>(reinterpret_cast<InstanceFn>(0x007d7c40)() + 0x14);
|
||||
return player >= 1 && player <= kBookPlayers ? player - 1 : 0;
|
||||
}
|
||||
inline uint32_t& BookSelected(int slot) {
|
||||
return *reinterpret_cast<uint32_t*>(QuestManager() + kBookSelected + slot * kBookSelectedStride);
|
||||
}
|
||||
inline uint32_t& BookMainIndex(int slot) {
|
||||
return *reinterpret_cast<uint32_t*>(QuestManager() + kBookMainIndex + slot * kBookSelectedStride);
|
||||
}
|
||||
|
||||
// Removes book entry `index`; the players' selected indices are kept on their
|
||||
// entries (or "none" = the new count).
|
||||
// entries (or "none" = the new count), and so are their main quest indices.
|
||||
inline void EraseBookEntry(size_t index) {
|
||||
const size_t count = BookCount();
|
||||
if (index >= count) {
|
||||
@@ -84,11 +123,12 @@ inline void EraseBookEntry(size_t index) {
|
||||
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;
|
||||
for (uint32_t* kept : {&BookSelected(player), &BookMainIndex(player)}) {
|
||||
if (*kept == index || *kept >= count) {
|
||||
*kept = static_cast<uint32_t>(count - 1);
|
||||
} else if (*kept > index) {
|
||||
--*kept;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
// World regions: each sector carries a region number; the region name table
|
||||
// (from scripts/regionsnamen) maps region numbers to name text keys. The
|
||||
// megamap names the hero's region so (0x6c6d73..): position -> sector
|
||||
// (0x634970) -> region (0x622e30) -> key (0x425370) -> text.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/engine/objects.h"
|
||||
#include "game/engine/orders.h"
|
||||
|
||||
namespace game {
|
||||
|
||||
// The region number at map cell (x, y) (quest book positions +0x18/+0x1c are
|
||||
// cells); 0 if none. World_SectorAt 0x634970 (thiscall (position*), ret 4,
|
||||
// world units) on the object manager's world (manager +0), Sector_Region
|
||||
// 0x622e30 (thiscall, plain ret).
|
||||
inline uint8_t RegionAtCell(int32_t x, int32_t y) {
|
||||
void* manager = ObjectManager();
|
||||
void* world = manager ? *reinterpret_cast<void**>(manager) : nullptr;
|
||||
if (!world) {
|
||||
return 0;
|
||||
}
|
||||
MapPosition position;
|
||||
position.x = static_cast<int32_t>(x * kMapCellSize);
|
||||
position.y = static_cast<int32_t>(y * kMapCellSize);
|
||||
using SectorFn = void*(__fastcall*)(void* world, void* edx, MapPosition* position);
|
||||
using RegionFn = uint32_t(__fastcall*)(void* sector, void* edx);
|
||||
void* sector = reinterpret_cast<SectorFn>(0x00634970)(world, nullptr, &position);
|
||||
return sector ? static_cast<uint8_t>(reinterpret_cast<RegionFn>(0x00622e30)(sector, nullptr)) : 0;
|
||||
}
|
||||
|
||||
// The name text key of region `region` (Region_NameKey 0x425370, cdecl), or
|
||||
// null.
|
||||
inline const char* RegionNameKey(uint8_t region) {
|
||||
using Fn = const char*(__cdecl*)(char region);
|
||||
return region ? reinterpret_cast<Fn>(0x00425370)(static_cast<char>(region)) : nullptr;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
@@ -0,0 +1,499 @@
|
||||
// The quest book as one list: the story, then the side quests by area,
|
||||
// unknown ones as "???" (requests of 2026-10-25: a player sees how many
|
||||
// quests an area holds without being told where they are; then "get rid of
|
||||
// the tabs ... a section for the story and a section per region", the
|
||||
// game's page turning kept: "pagination is already there anyways").
|
||||
//
|
||||
// Measured (docs/quests.md, "Quest book"): the book's quest mode had up to
|
||||
// seven act tabs (bookmarks I, II, ...), an entry's tab being its chapter
|
||||
// (+0x16c), copied from the current main quest when the quest is accepted
|
||||
// (0x46c450); the game knows no area for a quest. The side quests' ids come
|
||||
// in blocks of 100 (1xx .. 25xx) whose accepted quests lie together on the
|
||||
// map: the areas. The other side quests (5xxx, 6xxx, 9xxx, 102xx, 15xxx)
|
||||
// carry no area in their id; once known they join the area nearest to them.
|
||||
//
|
||||
// The book (cUI_Book, one per book mode): the left page shows the current
|
||||
// page's rows, the right page the selected row's detail record, both drawn by
|
||||
// 0x6b27f0 from the same page; a click (0x6b3620) selects the row under the
|
||||
// mouse (page +4).
|
||||
//
|
||||
// The mod: the game's list builder (0x6b04c0) runs on a reordered copy of the
|
||||
// book with our rows (headings, "???") as extra entries, all in the first tab
|
||||
// (the game's page breaks and page turning stay); afterwards each row's entry
|
||||
// index is mapped back to the real book, ours to none (a click on them does
|
||||
// nothing, 0x6b3620 checks the index). The bookmarks are hidden, every page is
|
||||
// titled "Quests" and selects its first quest. Fonts, icons and the detail
|
||||
// page stay the game's.
|
||||
//
|
||||
// "Erledigte anzeigen" (request of 2026-10-25: "some kind of 'Show completed'
|
||||
// checkmark. If unchecked, completed quests are not in the list (as well as
|
||||
// completed regions)"): the first row, its icon the solved mark when on, none
|
||||
// when off; a click toggles it (sacredK.ini [questbook] ShowCompleted) and
|
||||
// rebuilds the book. Off leaves out solved quests and the areas whose quests
|
||||
// are all known and solved; failed quests stay (they can be restarted).
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "game/engine/quests.h"
|
||||
#include "game/engine/regions.h"
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
#include "re/log.h"
|
||||
|
||||
namespace mods::questbook {
|
||||
namespace {
|
||||
|
||||
constexpr int kAreaQuestFirst = 100; // ids 100 .. 2599: area = id / 100
|
||||
constexpr int kAreaQuestEnd = 2600; // 2600 .. 3599: generic quests, cloned by Quest_End
|
||||
constexpr int kGenericQuestEnd = 3600;
|
||||
constexpr int kAreaSize = 100;
|
||||
|
||||
// cUI_Book: the book mode (+0x166, 0 = quests), current tab (+0x15e) and page
|
||||
// (+0x162); per tab a vector of pages (+0x97c + tab * 0xc) and of detail
|
||||
// records (+0x9d0 + tab * 0xc). A page (0x1c bytes): title text +0, selected row +4 (u16), a vector
|
||||
// of rows +0x10. A row (0x14 bytes): u16 flags (4 = spacer), title text +4,
|
||||
// detail record +8, book entry +0xc. The bookmarks: 7 controls at +0x168.
|
||||
constexpr size_t kBookMode = 0x166;
|
||||
constexpr uint16_t kModeQuests = 0;
|
||||
constexpr size_t kBookTab = 0x15e;
|
||||
constexpr size_t kBookPage = 0x162;
|
||||
constexpr size_t kBookPages = 0x97c;
|
||||
constexpr size_t kTabStride = 0xc;
|
||||
constexpr size_t kPageSize = 0x1c;
|
||||
constexpr size_t kPageTitle = 0x0;
|
||||
constexpr size_t kPageSelected = 0x4;
|
||||
constexpr size_t kPageRows = 0x10;
|
||||
constexpr size_t kRowSize = 0x14;
|
||||
constexpr size_t kRowEntry = 0xc;
|
||||
constexpr uint16_t kRowSpacer = 0x4;
|
||||
constexpr size_t kBookmarks = 0x168;
|
||||
constexpr size_t kBookmarkStride = 0xbc;
|
||||
constexpr int kTabs = 7;
|
||||
constexpr uint32_t kNoEntry = 0xffffffff;
|
||||
constexpr uint32_t kToggleEntry = 0xfffffffe; // the "Erledigte anzeigen" row
|
||||
constexpr uint16_t kNoRow = 0xffff;
|
||||
|
||||
constexpr const char* kConfigSection = "questbook";
|
||||
constexpr const char* kConfigShowCompleted = "ShowCompleted";
|
||||
|
||||
// The setting, read from the ini on first use.
|
||||
bool& ShowCompleted() {
|
||||
static bool show = re::ConfigInt(kConfigSection, kConfigShowCompleted, 1) != 0;
|
||||
return show;
|
||||
}
|
||||
|
||||
using Entry = std::array<uint8_t, game::kBookEntrySize>;
|
||||
|
||||
template <typename T>
|
||||
T& Field(uint8_t* p, size_t offset) {
|
||||
return *reinterpret_cast<T*>(p + offset);
|
||||
}
|
||||
|
||||
// Our texts, registered once each (RegisterText keeps the pointer).
|
||||
uint32_t TextId(const std::wstring& text) {
|
||||
static std::map<std::wstring, uint32_t> ids;
|
||||
static std::deque<std::wstring> storage;
|
||||
auto found = ids.find(text);
|
||||
if (found != ids.end()) {
|
||||
return found->second;
|
||||
}
|
||||
storage.push_back(text);
|
||||
return ids[text] = game::RegisterText(storage.back().c_str());
|
||||
}
|
||||
|
||||
std::wstring Format(const wchar_t* format, int a, int b = 0, int c = 0) {
|
||||
wchar_t text[128];
|
||||
swprintf(text, 128, format, a, b, c);
|
||||
return text;
|
||||
}
|
||||
|
||||
// --- the list ------------------------------------------------------------------
|
||||
|
||||
// The quest table's side quests that count, by id: their area (0 = none by
|
||||
// id). Not main quests, and not the ones whose name occurs more than once
|
||||
// (generic quests and their clones, script bookkeeping such as "Region 1
|
||||
// befreit").
|
||||
std::map<int32_t, int> CountedQuests() {
|
||||
std::map<int32_t, int> result;
|
||||
std::map<std::string, int> names;
|
||||
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
|
||||
const char* name = game::QuestName(game::Quest(static_cast<int>(i)));
|
||||
if (*name) {
|
||||
++names[name];
|
||||
}
|
||||
}
|
||||
for (size_t i = 0, n = game::QuestCount(); i < n; ++i) {
|
||||
uint8_t* quest = game::Quest(static_cast<int>(i));
|
||||
const int32_t id = game::QuestId(quest);
|
||||
const char* name = game::QuestName(quest);
|
||||
if (id <= game::kMainQuestLast || (*name && names[name] > 1) || (id >= kAreaQuestEnd && id < kGenericQuestEnd)) {
|
||||
continue;
|
||||
}
|
||||
result[id] = id >= kAreaQuestFirst && id < kAreaQuestEnd ? id / kAreaSize : 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct Area {
|
||||
int total = 0;
|
||||
std::vector<int32_t> known; // quest ids, in book order
|
||||
double x = 0, y = 0;
|
||||
int positions = 0;
|
||||
std::map<uint8_t, int> regions; // region number at its quests: count
|
||||
};
|
||||
|
||||
// The area's name: the game's name of the region most of its quests lie in;
|
||||
// else "Gebiet N".
|
||||
std::wstring AreaName(int number, const Area& area) {
|
||||
uint8_t region = 0;
|
||||
int most = 0;
|
||||
for (const auto& [r, n] : area.regions) {
|
||||
if (n > most) {
|
||||
region = r;
|
||||
most = n;
|
||||
}
|
||||
}
|
||||
const char* key = game::RegionNameKey(region);
|
||||
std::wstring name = key ? game::GameTextByKey(key) : L"";
|
||||
return name.empty() ? Format(L"Gebiet %d", number) : name;
|
||||
}
|
||||
|
||||
// The reordered book: entries (copies of the real ones, or ours), all in the
|
||||
// first tab, and for each the real entry index, kNoEntry for ours.
|
||||
struct Order {
|
||||
std::vector<Entry> entries;
|
||||
std::vector<uint32_t> real;
|
||||
|
||||
void Add(const Entry& entry, uint32_t realIndex) {
|
||||
entries.push_back(entry);
|
||||
Field<uint16_t>(entries.back().data(), game::kBookChapter) = 0;
|
||||
real.push_back(realIndex);
|
||||
}
|
||||
void AddReal(uint32_t index) {
|
||||
Entry entry;
|
||||
std::memcpy(entry.data(), game::BookEntry(index), entry.size());
|
||||
Add(entry, index);
|
||||
}
|
||||
void AddOwn(int32_t kind, const std::wstring& title, int32_t state = 0, uint32_t marker = kNoEntry) {
|
||||
Entry entry = {};
|
||||
Field<int32_t>(entry.data(), game::kBookKind) = kind;
|
||||
Field<int32_t>(entry.data(), game::kBookState) = state;
|
||||
Field<uint32_t>(entry.data(), game::kBookTitle) = TextId(title);
|
||||
Field<uint32_t>(entry.data(), game::kBookText) = TextId(L"");
|
||||
Add(entry, marker);
|
||||
}
|
||||
// A heading: a main-quest-style row; the game puts a spacer before a main
|
||||
// quest row only after a side quest row, so after a main quest an empty
|
||||
// side row goes first.
|
||||
void AddHeading(const std::wstring& title) {
|
||||
if (!entries.empty() && Field<int32_t>(entries.back().data(), game::kBookKind) == game::kBookMain) {
|
||||
AddOwn(game::kBookSide, L" ");
|
||||
}
|
||||
AddOwn(game::kBookMain, title);
|
||||
}
|
||||
};
|
||||
|
||||
Order BuildOrder() {
|
||||
const std::map<int32_t, int> counted = CountedQuests();
|
||||
const size_t count = game::BookCount();
|
||||
std::map<int, Area> areas;
|
||||
for (const auto& [id, area] : counted) {
|
||||
if (area) {
|
||||
++areas[area].total;
|
||||
}
|
||||
}
|
||||
|
||||
// Known side quests, in book order, with their area.
|
||||
std::map<int32_t, int> questArea;
|
||||
std::vector<int32_t> known;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
const int32_t id = game::BookQuestId(entry);
|
||||
if (game::BookKind(entry) == game::kBookMain || questArea.count(id)) {
|
||||
continue;
|
||||
}
|
||||
const auto found = counted.find(id);
|
||||
const int area = found != counted.end() ? found->second : 0;
|
||||
questArea[id] = area;
|
||||
known.push_back(id);
|
||||
if (area) {
|
||||
Area& a = areas[area];
|
||||
a.known.push_back(id);
|
||||
const int32_t x = Field<int32_t>(entry, game::kBookX);
|
||||
const int32_t y = Field<int32_t>(entry, game::kBookY);
|
||||
if (x || y) {
|
||||
a.x += x;
|
||||
a.y += y;
|
||||
++a.positions;
|
||||
++a.regions[game::RegionAtCell(x, y)];
|
||||
}
|
||||
}
|
||||
}
|
||||
// The other known side quests join the area nearest to them (by its known
|
||||
// quests' mean position).
|
||||
int unknownOthers = 0;
|
||||
for (const auto& [id, area] : counted) {
|
||||
unknownOthers += !area && !questArea.count(id);
|
||||
}
|
||||
for (int32_t id : known) {
|
||||
if (questArea[id] || !counted.count(id)) {
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) != id) {
|
||||
continue;
|
||||
}
|
||||
const double x = Field<int32_t>(entry, game::kBookX);
|
||||
const double y = Field<int32_t>(entry, game::kBookY);
|
||||
double bestDistance = 0;
|
||||
int best = 0;
|
||||
for (const auto& [number, a] : areas) {
|
||||
if (!a.positions || (x == 0 && y == 0)) {
|
||||
continue;
|
||||
}
|
||||
const double dx = x - a.x / a.positions;
|
||||
const double dy = y - a.y / a.positions;
|
||||
const double distance = dx * dx + dy * dy;
|
||||
if (!best || distance < bestDistance) {
|
||||
best = number;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
questArea[id] = best;
|
||||
++areas[best].total;
|
||||
areas[best].known.push_back(id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bool showCompleted = ShowCompleted();
|
||||
auto shown = [&](uint8_t* entry) { return showCompleted || game::BookState(entry) != game::kBookSolved; };
|
||||
auto hidden = [&](int32_t id) { // no entry of quest `id` in the list
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// The solved icon when on (the game draws state 100 so), no icon when off.
|
||||
Order order;
|
||||
order.AddOwn(game::kBookSide, L"Erledigte anzeigen", showCompleted ? game::kBookSolved : 0, kToggleEntry);
|
||||
auto addQuest = [&](int32_t id) {
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookQuestId(entry) == id && game::BookKind(entry) != game::kBookMain && shown(entry)) {
|
||||
order.AddReal(static_cast<uint32_t>(i));
|
||||
}
|
||||
}
|
||||
};
|
||||
order.AddHeading(L"Story");
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
uint8_t* entry = game::BookEntry(i);
|
||||
if (game::BookKind(entry) == game::kBookMain && shown(entry)) {
|
||||
order.AddReal(static_cast<uint32_t>(i));
|
||||
}
|
||||
}
|
||||
// Areas with the same name (several id blocks lie in one region) make one
|
||||
// section, in the order of their first area.
|
||||
int undiscovered = 0;
|
||||
std::vector<std::wstring> sectionNames;
|
||||
std::map<std::wstring, Area> sections;
|
||||
for (const auto& [number, a] : areas) {
|
||||
if (a.known.empty()) {
|
||||
undiscovered += a.total > 0;
|
||||
continue;
|
||||
}
|
||||
const std::wstring name = AreaName(number, a);
|
||||
if (!sections.count(name)) {
|
||||
sectionNames.push_back(name);
|
||||
}
|
||||
Area& section = sections[name];
|
||||
section.total += a.total;
|
||||
section.known.insert(section.known.end(), a.known.begin(), a.known.end());
|
||||
}
|
||||
for (const std::wstring& name : sectionNames) {
|
||||
const Area& a = sections[name];
|
||||
if (!showCompleted && static_cast<int>(a.known.size()) >= a.total &&
|
||||
std::all_of(a.known.begin(), a.known.end(), hidden)) {
|
||||
continue;
|
||||
}
|
||||
const int knownCount = static_cast<int>(a.known.size());
|
||||
order.AddHeading(name + Format(L" (%d/%d)", knownCount, a.total));
|
||||
for (int32_t id : a.known) {
|
||||
addQuest(id);
|
||||
}
|
||||
for (int i = knownCount; i < a.total; ++i) {
|
||||
order.AddOwn(game::kBookSide, L"???");
|
||||
}
|
||||
}
|
||||
bool others = false;
|
||||
for (int32_t id : known) {
|
||||
if (!questArea[id] && !hidden(id)) {
|
||||
if (!others) {
|
||||
order.AddHeading(L"Weitere Nebenquests");
|
||||
others = true;
|
||||
}
|
||||
addQuest(id);
|
||||
}
|
||||
}
|
||||
if (undiscovered || unknownOthers) {
|
||||
order.AddHeading(L"Unentdeckt");
|
||||
if (undiscovered) {
|
||||
order.AddOwn(game::kBookSide, Format(undiscovered == 1 ? L"%d Gebiet" : L"%d Gebiete", undiscovered));
|
||||
}
|
||||
if (unknownOthers) {
|
||||
order.AddOwn(game::kBookSide, Format(unknownOthers == 1 ? L"??? (%d weitere Nebenquest)"
|
||||
: L"??? (%d weitere Nebenquests)",
|
||||
unknownOthers));
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// --- the book's pages ------------------------------------------------------------
|
||||
|
||||
uint8_t* CurrentPage(uint8_t* book) {
|
||||
const size_t tab = Field<uint16_t>(book, kBookTab);
|
||||
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||
uint8_t* page = pages + Field<uint16_t>(book, kBookPage) * kPageSize;
|
||||
return pages && page < pagesEnd ? page : nullptr;
|
||||
}
|
||||
|
||||
uint8_t* RowsBegin(uint8_t* page) { return Field<uint8_t*>(page, kPageRows); }
|
||||
uint8_t* RowsEnd(uint8_t* page) { return Field<uint8_t*>(page, kPageRows + 4); }
|
||||
|
||||
// The row's book entry; kNoEntry for spacers and rows past the end.
|
||||
uint32_t RowEntry(uint8_t* page, uint16_t row) {
|
||||
uint8_t* r = RowsBegin(page) + row * kRowSize;
|
||||
return r < RowsEnd(page) && (Field<uint16_t>(r, 0) & kRowSpacer) == 0 ? Field<uint32_t>(r, kRowEntry) : kNoEntry;
|
||||
}
|
||||
|
||||
bool IsQuestRow(uint8_t* page, uint16_t row) { return RowEntry(page, row) < kToggleEntry; }
|
||||
|
||||
void SelectFirstQuest(uint8_t* page) {
|
||||
uint16_t& selected = Field<uint16_t>(page, kPageSelected);
|
||||
if (IsQuestRow(page, selected)) {
|
||||
return;
|
||||
}
|
||||
for (uint16_t row = 0; RowsBegin(page) + row * kRowSize < RowsEnd(page); ++row) {
|
||||
if (IsQuestRow(page, row)) {
|
||||
selected = row;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cUI_Book::Rebuild (0x6b1670, thiscall, no arguments): rebuilds the current
|
||||
// mode's pages, keeping tab and page if the page still exists.
|
||||
void RebuildBook(uint8_t* book) {
|
||||
reinterpret_cast<void(__fastcall*)(uint8_t*, void*)>(0x006b1670)(book, nullptr);
|
||||
const size_t tab = Field<uint16_t>(book, kBookTab);
|
||||
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||
if (!CurrentPage(book) && pages < pagesEnd) {
|
||||
Field<uint16_t>(book, kBookPage) = static_cast<uint16_t>((pagesEnd - pages) / kPageSize - 1);
|
||||
}
|
||||
if (uint8_t* page = CurrentPage(book)) {
|
||||
SelectFirstQuest(page);
|
||||
}
|
||||
}
|
||||
|
||||
// Rows point into the reordered book; point them at the real entries. Every
|
||||
// page titled "Quests", its first quest selected.
|
||||
void FixPages(uint8_t* book, const Order& order) {
|
||||
for (int tab = 0; tab < kTabs; ++tab) {
|
||||
uint8_t* pages = Field<uint8_t*>(book, kBookPages + tab * kTabStride);
|
||||
uint8_t* pagesEnd = Field<uint8_t*>(book, kBookPages + tab * kTabStride + 4);
|
||||
for (uint8_t* page = pages; page && page < pagesEnd; page += kPageSize) {
|
||||
for (uint8_t* row = RowsBegin(page); row && row < RowsEnd(page); row += kRowSize) {
|
||||
uint32_t& index = Field<uint32_t>(row, kRowEntry);
|
||||
if ((Field<uint16_t>(row, 0) & kRowSpacer) == 0 && index < order.real.size()) {
|
||||
index = order.real[index];
|
||||
}
|
||||
}
|
||||
if (tab != 0) {
|
||||
continue;
|
||||
}
|
||||
Field<uint32_t>(page, kPageTitle) = TextId(L"Quests");
|
||||
SelectFirstQuest(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mods::questbook
|
||||
|
||||
using namespace mods::questbook;
|
||||
|
||||
// cUI_Book's quest list (0x6b04c0, thiscall, no arguments; from the book's
|
||||
// rebuild 0x6b1670 in quest mode, on the same thread as the quest scripts).
|
||||
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx);
|
||||
RE_HOOK(0x006b04c0, Book_BuildQuestList);
|
||||
void __fastcall Book_BuildQuestList(uint8_t* book, void* edx) {
|
||||
Order order = BuildOrder();
|
||||
uint8_t* const begin = game::BookBegin();
|
||||
uint8_t* const end = game::BookEnd();
|
||||
uint8_t* const capacity = game::BookCapacityEnd();
|
||||
uint32_t& selected = game::BookSelected(game::LocalPlayerSlot());
|
||||
const uint32_t realSelected = selected;
|
||||
uint32_t reorderedSelected = static_cast<uint32_t>(order.entries.size());
|
||||
for (size_t i = 0; i < order.real.size(); ++i) {
|
||||
if (order.real[i] == realSelected) {
|
||||
reorderedSelected = static_cast<uint32_t>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
auto* copy = reinterpret_cast<uint8_t*>(order.entries.data());
|
||||
game::BookBegin() = copy;
|
||||
game::BookEnd() = game::BookCapacityEnd() = copy + order.entries.size() * game::kBookEntrySize;
|
||||
selected = reorderedSelected;
|
||||
Book_BuildQuestList_original(book, edx);
|
||||
game::BookBegin() = begin;
|
||||
game::BookEnd() = end;
|
||||
game::BookCapacityEnd() = capacity;
|
||||
selected = realSelected;
|
||||
|
||||
FixPages(book, order);
|
||||
for (int tab = 0; tab < kTabs; ++tab) {
|
||||
game::SetControlShown(book + kBookmarks + tab * kBookmarkStride, false);
|
||||
}
|
||||
}
|
||||
|
||||
// cUI_Book's event handler (0x6b3620, thiscall (event), ret 4): a click on a
|
||||
// heading or "???" row keeps the selection, one on "Erledigte anzeigen"
|
||||
// toggles it.
|
||||
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event);
|
||||
RE_HOOK(0x006b3620, Book_HandleEvent);
|
||||
bool __fastcall Book_HandleEvent(uint8_t* book, void* edx, void* event) {
|
||||
uint8_t* page = Field<uint16_t>(book, kBookMode) == kModeQuests ? CurrentPage(book) : nullptr;
|
||||
const uint16_t selected = page ? Field<uint16_t>(page, kPageSelected) : kNoRow;
|
||||
const bool handled = Book_HandleEvent_original(book, edx, event);
|
||||
if (page && page == CurrentPage(book) && !IsQuestRow(page, Field<uint16_t>(page, kPageSelected))) {
|
||||
const bool toggle = RowEntry(page, Field<uint16_t>(page, kPageSelected)) == kToggleEntry;
|
||||
Field<uint16_t>(page, kPageSelected) = selected;
|
||||
if (toggle) {
|
||||
ShowCompleted() = !ShowCompleted();
|
||||
re::SetConfigString(kConfigSection, kConfigShowCompleted, ShowCompleted() ? "1" : "0");
|
||||
RebuildBook(book);
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
@@ -380,3 +380,27 @@
|
||||
0x00622c60 Sector_GetExploredMask # (out 128 bytes), ret 4
|
||||
0x00622c80 Sector_SetExploredMask # (128 bytes), ret 4; from the save (0x606ae0)
|
||||
0x00639030 Sector_BuildFogTexture # FOGOFWAR01.TGA from a mask, for the zoomed map view
|
||||
|
||||
# --- quest book (2026-10-25) -----------------------------------------------------
|
||||
0x0046c450 QuestManager_AddBookEntry # thiscall (quest index, ?), ret 8; kind 3 for ids 1..99, chapter from the current main quest
|
||||
0x00497830 QuestManager_BookEntryPosition # thiscall (entry index, out), ret 8; from entry +0x18 / +0x1c
|
||||
0x0049dd40 QuestManager_SelectedBookEntry # thiscall; +0x3a0 + player * 8
|
||||
0x0049dd70 QuestManager_MainBookEntry # thiscall; +0x39c + player * 8 (= +0x3a4 for player 1)
|
||||
0x004975d0 QuestManager_MarkerColour # thiscall (entry, kind), ret 8; kind 0: side open blue ff006cff, main ffffff80, solved green, failed red
|
||||
0x00497910 QuestManager_EntriesInSectors # thiscall (sectors, out, open only), ret 0xc; book entries whose marker lies in the sectors
|
||||
0x0049a7a0 QuestManager_CompassPos # script QuestkompassPos: entry +0x10/+0x14 = cell, +0x20 radius ("rad:")
|
||||
0x0049af70 QuestManager_CompassObject # script QuestkompassOBJ: +0x10 = 0xffffffff + object id, or 0xeeeeeeee + script NPC id
|
||||
0x004a6740 QuestManager_ResolveCompass # thiscall (entry), ret 4; marker cell +0x18/+0x1c from the compass target
|
||||
0x006b04c0 cUI_Book::BuildQuestList # thiscall; rows per tab (chapter +0x16c), pages via PageFull
|
||||
0x006b1670 cUI_Book::Rebuild # by book mode +0x166: 0 quests 0x6b04c0, 1 0x6b0ae0, 2 0x6b0190
|
||||
0x006b1a70 cUI_Book::PageFull # thiscall (tab, page), ret 8
|
||||
0x006b1cd0 cUI_Book::DrawDetail # thiscall (renderer, record 0x40, sub-page), ret 0xc
|
||||
0x006b27f0 cUI_Book::RenderPage # thiscall (renderer, bookmarks only), ret 8
|
||||
0x006b3620 cUI_Book::HandleEvent # vf10, thiscall (event), ret 4
|
||||
0x0043ad60 cItemTypeTable::IsQuestItem # (type), ret 4: type +0x61 (script word QuestItem)
|
||||
0x0043ad80 cItemTypeTable::GetQuestInfo # (type, &questItem, &mainQuestItem, &quest, &quest+1), ret 0x14: type +0x1c/+0x61/+0x62
|
||||
0x00622640 MapPosition_Set # thiscall (u16 sector, x, y, u8 layer), ret 0x10
|
||||
0x00634970 World_SectorAt # thiscall (MapPosition*), ret 4: the sector object or null
|
||||
0x00622e30 Sector_Region # thiscall: region number (sector +0x17c -> +0xd7)
|
||||
0x00425370 Region_NameKey # cdecl (char region): name text key from the region name table 0xaa8ca8, or null
|
||||
0x00425300 Region_GetInfo # cdecl (char region, out, out): table +0x38 / +0x30 of the region's record
|
||||
Reference in new issue
Block a user