diff --git a/CMakeLists.txt b/CMakeLists.txt index 0eb028f..37399ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/quests.md b/docs/quests.md index a2c0f67..259a4fc 100644 --- a/docs/quests.md +++ b/docs/quests.md @@ -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 +" (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). diff --git a/docs/todo.md b/docs/todo.md index ac001b4..b5d62d3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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) diff --git a/src/game/engine/quests.h b/src/game/engine/quests.h index 27bf8b9..52e211e 100644 --- a/src/game/engine/quests.h +++ b/src/game/engine/quests.h @@ -54,12 +54,34 @@ 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 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(QuestManager() + kBookBegin); } inline uint8_t*& BookEnd() { return *reinterpret_cast(QuestManager() + kBookEnd); } +inline uint8_t*& BookCapacityEnd() { return *reinterpret_cast(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(entry + kBookState); } inline int32_t BookQuestId(const uint8_t* entry) { return *reinterpret_cast(entry + kBookQuestId); } +inline int32_t BookKind(const uint8_t* entry) { return *reinterpret_cast(entry + kBookKind); } +inline uint16_t BookChapter(const uint8_t* entry) { return *reinterpret_cast(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(reinterpret_cast(0x007d7c40)() + 0x14); + return player >= 1 && player <= kBookPlayers ? player - 1 : 0; +} +inline uint32_t& BookSelected(int slot) { + return *reinterpret_cast(QuestManager() + kBookSelected + slot * kBookSelectedStride); +} +inline uint32_t& BookMainIndex(int slot) { + return *reinterpret_cast(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(QuestManager() + kBookSelected + player * kBookSelectedStride); - if (selected == index || selected >= count) { - selected = static_cast(count - 1); - } else if (selected > index) { - --selected; + for (uint32_t* kept : {&BookSelected(player), &BookMainIndex(player)}) { + if (*kept == index || *kept >= count) { + *kept = static_cast(count - 1); + } else if (*kept > index) { + --*kept; + } } } } diff --git a/src/game/engine/regions.h b/src/game/engine/regions.h new file mode 100644 index 0000000..3fe023c --- /dev/null +++ b/src/game/engine/regions.h @@ -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 + +#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(manager) : nullptr; + if (!world) { + return 0; + } + MapPosition position; + position.x = static_cast(x * kMapCellSize); + position.y = static_cast(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(0x00634970)(world, nullptr, &position); + return sector ? static_cast(reinterpret_cast(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(0x00425370)(static_cast(region)) : nullptr; +} + +} // namespace game diff --git a/src/mods/questbook.cpp b/src/mods/questbook.cpp new file mode 100644 index 0000000..2ab750b --- /dev/null +++ b/src/mods/questbook.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +#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; + +template +T& Field(uint8_t* p, size_t offset) { + return *reinterpret_cast(p + offset); +} + +// Our texts, registered once each (RegisterText keeps the pointer). +uint32_t TextId(const std::wstring& text) { + static std::map ids; + static std::deque 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 CountedQuests() { + std::map result; + std::map names; + for (size_t i = 0, n = game::QuestCount(); i < n; ++i) { + const char* name = game::QuestName(game::Quest(static_cast(i))); + if (*name) { + ++names[name]; + } + } + for (size_t i = 0, n = game::QuestCount(); i < n; ++i) { + uint8_t* quest = game::Quest(static_cast(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 known; // quest ids, in book order + double x = 0, y = 0; + int positions = 0; + std::map 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 entries; + std::vector real; + + void Add(const Entry& entry, uint32_t realIndex) { + entries.push_back(entry); + Field(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(entry.data(), game::kBookKind) = kind; + Field(entry.data(), game::kBookState) = state; + Field(entry.data(), game::kBookTitle) = TextId(title); + Field(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(entries.back().data(), game::kBookKind) == game::kBookMain) { + AddOwn(game::kBookSide, L" "); + } + AddOwn(game::kBookMain, title); + } +}; + +Order BuildOrder() { + const std::map counted = CountedQuests(); + const size_t count = game::BookCount(); + std::map areas; + for (const auto& [id, area] : counted) { + if (area) { + ++areas[area].total; + } + } + + // Known side quests, in book order, with their area. + std::map questArea; + std::vector 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(entry, game::kBookX); + const int32_t y = Field(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(entry, game::kBookX); + const double y = Field(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(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(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 sectionNames; + std::map 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(a.known.size()) >= a.total && + std::all_of(a.known.begin(), a.known.end(), hidden)) { + continue; + } + const int knownCount = static_cast(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(book, kBookTab); + uint8_t* pages = Field(book, kBookPages + tab * kTabStride); + uint8_t* pagesEnd = Field(book, kBookPages + tab * kTabStride + 4); + uint8_t* page = pages + Field(book, kBookPage) * kPageSize; + return pages && page < pagesEnd ? page : nullptr; +} + +uint8_t* RowsBegin(uint8_t* page) { return Field(page, kPageRows); } +uint8_t* RowsEnd(uint8_t* page) { return Field(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(r, 0) & kRowSpacer) == 0 ? Field(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(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(0x006b1670)(book, nullptr); + const size_t tab = Field(book, kBookTab); + uint8_t* pages = Field(book, kBookPages + tab * kTabStride); + uint8_t* pagesEnd = Field(book, kBookPages + tab * kTabStride + 4); + if (!CurrentPage(book) && pages < pagesEnd) { + Field(book, kBookPage) = static_cast((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(book, kBookPages + tab * kTabStride); + uint8_t* pagesEnd = Field(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(row, kRowEntry); + if ((Field(row, 0) & kRowSpacer) == 0 && index < order.real.size()) { + index = order.real[index]; + } + } + if (tab != 0) { + continue; + } + Field(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(order.entries.size()); + for (size_t i = 0; i < order.real.size(); ++i) { + if (order.real[i] == realSelected) { + reorderedSelected = static_cast(i); + break; + } + } + + auto* copy = reinterpret_cast(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(book, kBookMode) == kModeQuests ? CurrentPage(book) : nullptr; + const uint16_t selected = page ? Field(page, kPageSelected) : kNoRow; + const bool handled = Book_HandleEvent_original(book, edx, event); + if (page && page == CurrentPage(book) && !IsQuestRow(page, Field(page, kPageSelected))) { + const bool toggle = RowEntry(page, Field(page, kPageSelected)) == kToggleEntry; + Field(page, kPageSelected) = selected; + if (toggle) { + ShowCompleted() = !ShowCompleted(); + re::SetConfigString(kConfigSection, kConfigShowCompleted, ShowCompleted() ? "1" : "0"); + RebuildBook(book); + } + } + return handled; +} diff --git a/tools/names.txt b/tools/names.txt index 73874cd..a563332 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -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