From e0d8d0ce7e1b0e9dc1e49fa3dc13b12fda4713d9 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 20 Oct 2026 21:56:11 +0200 Subject: [PATCH] Esc menu without "Exportieren"; export screen mod removed - cUI_EscMenu::BuildLines hook: the UI_ESC_EXPORT line is removed, the lines below move up, the window shrinks by one line, centred. - game/ui/control.h: control vector helpers (ControlsIn, RemoveControl, SetControlAt, DeleteControl, ControlNamed), used by the main menu too. - The export screen is unreachable now: charexport.cpp and docs/char-export.md deleted. Spielen screen redesign noted in todo.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 - docs/char-export.md | 48 ------------------- docs/characters.md | 9 +++- docs/todo.md | 15 ++++-- src/game/ui/control.h | 41 ++++++++++++++++ src/mods/characters.cpp | 37 ++++++++++++++ src/mods/charexport.cpp | 104 ---------------------------------------- src/mods/mainmenu.cpp | 43 ++++------------- tools/names.txt | 2 +- 9 files changed, 105 insertions(+), 195 deletions(-) delete mode 100644 docs/char-export.md delete mode 100644 src/mods/charexport.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e42996..6b77165 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,7 +67,6 @@ add_library(sacredK SHARED src/mods/runetrade.cpp src/mods/teleportpotion.cpp src/mods/guards.cpp - src/mods/charexport.cpp src/mods/options.cpp src/mods/lootfilter.cpp src/mods/userfolders.cpp diff --git a/docs/char-export.md b/docs/char-export.md deleted file mode 100644 index 4e7933a..0000000 --- a/docs/char-export.md +++ /dev/null @@ -1,48 +0,0 @@ -# Character export screen (`src/mods/charexport.cpp`) - -Request (2026-10-18): the export screen (Esc menu "Exportieren") was not -adapted to widescreen (layout and clicks shifted), slots could not be -cleared or overwritten; the screen and the save game window need black -beside the background picture. - -## Layout - -See `hud.md`, "Full-screen windows": the 3D scene was stretched across the -wide viewport; the window now draws with a viewport on the centred frame. - -## cUI_Character - -One window for the main menu's character selection / import and the export -screen (vtable 0x894fa8, render vf44 0x70f160, events vf10 0x70e860). - -| Field | Meaning | -|---|---| -| +0x390 | mode: 4 selection, 5 export, 6 overwrite confirmation (7 import) | -| +0x394 | previous mode | -| +0x1b4 | 8 slot controls; hero id at +0x2f8 (0: empty) | -| +0x1d6 | selected slot (char, -1 none) | -| +0x3b4 | erase button ("UI_CHAR_ERASE_TOOLTIP") | - -| Address | Function | -|---|---| -| 0x70c760 | SetMode (mode), ret 4; entering mode 5 reloads the heroes | -| 0x70c440 | SelectSlot (char slot), ret 4; shows the erase button in modes 4 / 7 only | -| 0x70d0f0 | ExecuteAction (action, slot, extra), ret 0xc: 6 export into slot (`Save\Hero%.2d.pax`), 9 export with confirmation if occupied | -| 0x70e650 | right-hand button: in mode 5 action 6 (empty slot) or 9 (occupied) | -| 0x7f01a0 | HeroSlots_DeleteHero (slot + 1), thiscall on 0x7d7c40(), ret 4 | - -## Overwrite - -Measured with a log: exporting to an occupied slot was already the game's own -path (action 9 -> mode 6 confirmation -> action 6). It only failed because -clicks hit the wrong place before the layout fix. - -## Clear - -The erase button is created for every mode, and its click handler (vf10) -does not check the mode: it opens the game's confirmation -(UI_CHAR_ERASE_CONFIRM, cUI_BusyDlg), whose answer (0x72087b) calls -DeleteHero. The mod shows the button in export mode whenever an occupied slot -is selected (after SetMode / SelectSlot). After DeleteHero, if the screen is -in export mode, it enters export mode again (reloads the heroes) and clears -the selection; otherwise the erased hero stayed until the next export. diff --git a/docs/characters.md b/docs/characters.md index fda1537..735fe88 100644 --- a/docs/characters.md +++ b/docs/characters.md @@ -258,6 +258,14 @@ flag +0x54 |= 0x40. The Esc menu line keeps its action (Show direct save while a game runs. Its text is patched at 0x6bc1f7 (id 0x41b is used by other windows too). Loading is from the main menu only. +The Esc menu has no "Exportieren" (request 2026-10-20): `cUI_EscMenu::BuildLines` +(0x6bb870, called by the constructor and on an event) makes five +cUI_StaticText64 lines in the vector +0x158, 0x24 apart; each handles its own +events. After it the UI_ESC_EXPORT line is removed and deleted, the lines +below move up, and the window (0x108 x 0xc8) loses 0x24 of height, staying +centred. With it the export screen (cUI_Character mode 5) is unreachable; +its mod (charexport.cpp, an erase button for export slots) was deleted. + ### Migration On first start (no `characters\` folder): after a backup in @@ -268,7 +276,6 @@ kept, the newest file overall is the shared hero. ## Open - Reset a difficulty. -- Remove the export / import UI. - Silent autosave every 10 minutes. - Quickload (row 0) does not know characters. - Multiplayer (see `todo.md`). diff --git a/docs/todo.md b/docs/todo.md index b31634b..abc4799 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -182,13 +182,10 @@ From the characters rework (`docs/characters.md`, request of 2026-10-19): - **Unlock difficulties by story completion.** Bronze and Silber stay open; difficulty d >= 2 opens when the story on d - 1 is completed. The counters are one byte per difficulty in cStatsManager +0x5704 (read 0x424be0) and - travel in the save header (+0x88..+0x98) and the hero file. Today every - difficulty up to the highest played one is offered (and Silber). + travel in the save header (+0x88..+0x98) and the hero file. Done + 2026-10-20 (docs/characters.md). - **Reset a difficulty.** Remove one world from a character (the shared hero stays); a button / dialog entry in the load window. -- **Remove the export / import UI.** The Esc menu's "Exportieren" and the - character screen's import (cUI_Character mode 5, `docs/char-export.md`): - characters replace exported heroes. - **Silent autosave every 10 minutes** (a setting later) into the autosave file, without the busy dialog, like the direct save (`SaveOnTick`) but marked as an autosave. @@ -196,3 +193,11 @@ From the characters rework (`docs/characters.md`, request of 2026-10-19): current character, but the quickload event (0x116, engine +0x68 = row, flag 0x200) loads `GAME00.PAK`, which is not listed; make it reload the current character's world (or its newer autosave). + +## Savegames screen redesign ("Spielen") + +Asked 2026-10-20: redesign the load window (cUI_Savegame, the "Spielen" +screen, characters.cpp): show a render of the selected character, use the +space far better. The user looks for 16:9 artwork so the screens can be +widened beyond the 1024 frame (now drawn in a centred 1024x768 viewport with +black sides, hud.cpp FrameScope). diff --git a/src/game/ui/control.h b/src/game/ui/control.h index 77f6214..586e5c1 100644 --- a/src/game/ui/control.h +++ b/src/game/ui/control.h @@ -2,13 +2,54 @@ // cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still // renders but handles no events). +#include #include #include +#include +#include namespace game { constexpr size_t kControlFlagsOffset = 0x10; constexpr uint32_t kControlFlagVisible = 1; +constexpr size_t kControlName = 0x30; // char[], set by 0x732090 (e.g. "UI_LABEL_NEWGAME") + +inline bool ControlNamed(const void* control, const char* name) { + return std::strcmp(static_cast(control) + kControlName, name) == 0; +} + +// Windows keep their lines / entries in game vectors of control pointers +// {begin, end, capacity end} at `offset` of the window. +inline std::vector ControlsIn(void* window, size_t offset) { + auto* field = static_cast(window) + offset; + auto** begin = *reinterpret_cast(field); + auto** end = *reinterpret_cast(field + 4); + return std::vector(begin, end); +} + +// Takes `control` out of the vector (the order of the others stays). +inline void RemoveControl(void* window, size_t offset, const void* control) { + auto* field = static_cast(window) + offset; + auto** begin = *reinterpret_cast(field); + auto**& end = *reinterpret_cast(field + 4); + auto** found = std::find(begin, end, control); + if (found != end) { + std::memmove(found, found + 1, (end - found - 1) * sizeof(*found)); + --end; + } +} + +// Replaces the vector's entry `index`. +inline void SetControlAt(void* window, size_t offset, size_t index, uint8_t* control) { + (*reinterpret_cast(static_cast(window) + offset))[index] = control; +} + +// Destroys a control made with the game's allocator (vf00, the scalar deleting +// destructor, e.g. cUI_StaticText64 0x754680); out of every list first. +inline void DeleteControl(void* control) { + using DeleteFn = void*(__fastcall*)(void*, void*, int); + reinterpret_cast((*static_cast(control))[0])(control, nullptr, 1); +} inline bool ControlShown(const void* control) { return (static_cast(control)[kControlFlagsOffset] & kControlFlagVisible) != 0; diff --git a/src/mods/characters.cpp b/src/mods/characters.cpp index 0bcc912..9fd6f38 100644 --- a/src/mods/characters.cpp +++ b/src/mods/characters.cpp @@ -1345,6 +1345,43 @@ int __fastcall UiManager_ReceiveEvent(void* self, void* edx, uint8_t* event) { constexpr uint32_t kEscMenuSaveLineText = 0x006bc1f7; constexpr uint32_t kSavegamesText = 0x41b; +// The Esc menu without "Exportieren" (request of 2026-10-20; exported heroes +// are gone). cUI_EscMenu::BuildLines (0x6bb870, thiscall, plain ret; called +// by the constructor 0x6bae60 and on an event, 0x6bc497) makes five +// cUI_StaticText64 lines (constructor 0x753470) in the vector at +0x158, 0x24 +// apart (y 8 .. 0x98); each line handles its own events. The window is +// 0x108 x 0xc8, centred (0x6bae9b). The export line goes, the lines below it +// move up and the window loses one line's height, staying centred. +constexpr size_t kEscMenuLines = 0x158; +constexpr char kEscExportLine[] = "UI_ESC_EXPORT"; +constexpr int kEscLinePitch = 0x24; +constexpr int16_t kEscMenuHeight = 0xc8; + +void __fastcall EscMenu_BuildLines(uint8_t* self, void* edx); +RE_HOOK(0x006bb870, EscMenu_BuildLines); +void __fastcall EscMenu_BuildLines(uint8_t* self, void* edx) { + EscMenu_BuildLines_original(self, edx); + bool removed = false; + for (uint8_t* line : game::ControlsIn(self, kEscMenuLines)) { + auto& rect = *reinterpret_cast(line + game::kControlRect); + if (removed) { + rect.y -= kEscLinePitch; + } else if (game::ControlNamed(line, kEscExportLine)) { + game::RemoveControl(self, kEscMenuLines, line); + game::DeleteControl(line); + removed = true; + } + } + auto& window = *reinterpret_cast(self + game::kControlRect); + if (removed && window.h == kEscMenuHeight) { + window.h -= kEscLinePitch; + window.y += kEscLinePitch / 2; + } + if (!removed) { + re::Log("characters: Esc menu has no %s line", kEscExportLine); + } +} + // The load window's second line of a row showed the (preview) file name; it // shows the level and the chosen difficulty instead. Row texts are set every // frame (0x71d830): line 1 the save's name (+0x204), line 2 the file name (+4, diff --git a/src/mods/charexport.cpp b/src/mods/charexport.cpp deleted file mode 100644 index 8804b52..0000000 --- a/src/mods/charexport.cpp +++ /dev/null @@ -1,104 +0,0 @@ -// Character export (Esc menu "Exportieren"): slots can be cleared. -// -// Request (2026-10-18): the export screen was not adapted to widescreen, and -// slots could not be cleared or overwritten. The layout is fixed in hud.cpp -// (FrameScope); overwriting turned out to be the game's own path once clicks -// hit the right slot (measured: action 9 on an occupied slot -> mode 6 -// confirmation -> action 6 exports into it). -// -// cUI_Character serves the main menu's character selection and the export -// screen: mode at +0x390 (4 selection, 5 export, 6 overwrite confirmation), -// selected slot (char, -1 none) at +0x1d6, slot controls at +0x1b4 (8, hero -// id at +0x2f8, 0: empty). Its erase button (+0x3b4, "UI_CHAR_ERASE_TOOLTIP") -// is created for both, but SelectSlot (0x70c440) shows it only in modes 4 / 7 -// and SetMode (0x70c760) hides it. The click handler (vf10 0x70e860) does not -// check the mode: it opens the game's confirmation (UI_CHAR_ERASE_CONFIRM, a -// cUI_BusyDlg), whose answer deletes the hero (0x7f01a0, slot + 1). So in -// export mode the button is shown whenever an occupied slot is selected. -// -// The export screen reloads its heroes when it enters export mode (SetMode 5, -// also after an export); after a delete nothing did, so the erased hero stayed -// until the next export (user, 2026-10-18). After a delete from the export -// screen it enters export mode again and the selection is cleared. - -#include - -#include "game/ui/control.h" -#include "re/hook.h" - -namespace mods::charexport { -namespace { - -constexpr size_t kMode = 0x390; -constexpr size_t kSelectedSlot = 0x1d6; -constexpr size_t kSlots = 0x1b4; -constexpr size_t kSlotHeroId = 0x2f8; -constexpr size_t kEraseButton = 0x3b4; -constexpr int kSlotCount = 8; -constexpr int kModeExport = 5; -constexpr int kNoSlot = -1; - -// The cUI_Character instance (one, owned by the UI manager), noted when its -// mode or selection changes. -void* g_window = nullptr; - -template -T& Field(void* window, size_t offset) { - return *reinterpret_cast(static_cast(window) + offset); -} - -bool SelectedSlotOccupied(void* window) { - const int slot = Field(window, kSelectedSlot); - if (slot < 0 || slot >= kSlotCount) { - return false; - } - void* control = Field(window, kSlots + slot * sizeof(void*)); - return control && Field(control, kSlotHeroId) != 0; -} - -void UpdateEraseButton(void* window) { - void* button = Field(window, kEraseButton); - if (button && Field(window, kMode) == kModeExport) { - game::SetControlShown(button, SelectedSlotOccupied(window)); - } -} - -} // namespace -} // namespace mods::charexport - -namespace game { - -// cUI_Character::SetMode (0x70c760, thiscall (mode), ret 4). -void __fastcall UiCharacter_SetMode(void* self, void* edx, int mode); -RE_HOOK(0x0070c760, UiCharacter_SetMode); -void __fastcall UiCharacter_SetMode(void* self, void* edx, int mode) { - mods::charexport::g_window = self; - UiCharacter_SetMode_original(self, edx, mode); - mods::charexport::UpdateEraseButton(self); -} - -// cUI_Character::SelectSlot (0x70c440, thiscall (char slot, -1 none), ret 4). -void __fastcall UiCharacter_SelectSlot(void* self, void* edx, int slot); -RE_HOOK(0x0070c440, UiCharacter_SelectSlot); -void __fastcall UiCharacter_SelectSlot(void* self, void* edx, int slot) { - mods::charexport::g_window = self; - UiCharacter_SelectSlot_original(self, edx, slot); - mods::charexport::UpdateEraseButton(self); -} - -// HeroSlots_DeleteHero (0x7f01a0, thiscall on 0x7d7c40() (slot + 1), ret 4; -// returns a bool in al), called by the erase confirmation's answer. -int __fastcall HeroSlots_DeleteHero(void* self, void* edx, int slotPlusOne); -RE_HOOK(0x007f01a0, HeroSlots_DeleteHero); -int __fastcall HeroSlots_DeleteHero(void* self, void* edx, int slotPlusOne) { - const int result = HeroSlots_DeleteHero_original(self, edx, slotPlusOne); - void* window = mods::charexport::g_window; - using mods::charexport::Field; - if (window && Field(window, mods::charexport::kMode) == mods::charexport::kModeExport) { - UiCharacter_SetMode(window, nullptr, mods::charexport::kModeExport); - UiCharacter_SelectSlot(window, nullptr, mods::charexport::kNoSlot); - } - return result; -} - -} // namespace game diff --git a/src/mods/mainmenu.cpp b/src/mods/mainmenu.cpp index 85b2203..e068690 100644 --- a/src/mods/mainmenu.cpp +++ b/src/mods/mainmenu.cpp @@ -16,9 +16,9 @@ #include #include -#include #include +#include "game/ui/control.h" #include "game/ui/tabs.h" #include "game/ui/text.h" #include "game/ui/texts.h" @@ -30,54 +30,28 @@ namespace { constexpr size_t kMenuChildren = 0x90; // vector of all child controls constexpr size_t kMenuMainPage = 0x158; // vector of the main page's entries -constexpr size_t kEntryName = 0x30; // char[] constexpr size_t kEntryTextId = 0x78; constexpr int kEntryPitch = 0x20; // as 0x712f30 places them constexpr const char* kRemoved[] = {"UI_LABEL_NEWGAME", "UI_LABEL_NEWADDON"}; constexpr char kPlayEntry[] = "UI_LABEL_LOADGAME"; -// A game vector of pointers {begin, end, capacity end}. -std::vector Entries(uint8_t* owner, size_t offset) { - auto** begin = *reinterpret_cast(owner + offset); - auto** end = *reinterpret_cast(owner + offset + 4); - return std::vector(begin, end); -} - -void Erase(uint8_t* owner, size_t offset, const uint8_t* item) { - auto** begin = *reinterpret_cast(owner + offset); - auto**& end = *reinterpret_cast(owner + offset + 4); - auto** found = std::find(begin, end, item); - if (found != end) { - std::memmove(found, found + 1, (end - found - 1) * sizeof(*found)); - --end; - } -} - -void SetAt(uint8_t* owner, size_t offset, size_t index, uint8_t* item) { - (*reinterpret_cast(owner + offset))[index] = item; -} - -const char* Name(const uint8_t* entry) { - return reinterpret_cast(entry + kEntryName); -} - int32_t& EntryY(uint8_t* entry) { return reinterpret_cast(entry + game::kControlRect)->y; } bool IsRemoved(const uint8_t* entry) { return std::any_of(std::begin(kRemoved), std::end(kRemoved), - [&](const char* name) { return std::strcmp(Name(entry), name) == 0; }); + [&](const char* name) { return game::ControlNamed(entry, name); }); } void __fastcall MainMenu_Build(uint8_t* self, void* edx); RE_HOOK(0x00712f30, MainMenu_Build); void __fastcall MainMenu_Build(uint8_t* self, void* edx) { MainMenu_Build_original(self, edx); - const std::vector original = Entries(self, kMenuMainPage); + const std::vector original = game::ControlsIn(self, kMenuMainPage); auto play = std::find_if(original.begin(), original.end(), - [](const uint8_t* entry) { return std::strcmp(Name(entry), kPlayEntry) == 0; }); + [](const uint8_t* entry) { return game::ControlNamed(entry, kPlayEntry); }); if (original.empty() || play == original.end()) { re::Log("mainmenu: no %s entry, menu left as it is", kPlayEntry); return; @@ -99,14 +73,13 @@ void __fastcall MainMenu_Build(uint8_t* self, void* edx) { } for (uint8_t* entry : original) { if (IsRemoved(entry)) { - Erase(self, kMenuMainPage, entry); - Erase(self, kMenuChildren, entry); - using DeleteFn = void*(__fastcall*)(void*, void*, int); // scalar deleting destructor - reinterpret_cast((*reinterpret_cast(entry))[0])(entry, nullptr, 1); + game::RemoveControl(self, kMenuMainPage, entry); + game::RemoveControl(self, kMenuChildren, entry); + game::DeleteControl(entry); } } for (size_t i = 0; i < kept.size(); ++i) { - SetAt(self, kMenuMainPage, i, kept[i]); + game::SetControlAt(self, kMenuMainPage, i, kept[i]); y += keptGap[i]; EntryY(kept[i]) = y; y += kEntryPitch; diff --git a/tools/names.txt b/tools/names.txt index 1e9da97..a2f713b 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -302,7 +302,7 @@ 0x006e7730 cUI_Tooltip::Layout # measures the text, width = longest line + 8 unless flag 0x100 0x0075ac70 cUI_Manager::ShowTooltip # (x, y, text, ?, flags, show) -# Character selection / export screen (src/mods/charexport.cpp) +# Character selection / export screen (cUI_Character) 0x0070c760 cUI_Character::SetMode # (mode), ret 4: +0x390 (4 selection, 5 export, 6 overwrite confirm) 0x0070c440 cUI_Character::SelectSlot # (char slot, -1 none), ret 4 0x0070d0f0 cUI_Character::ExecuteAction # (action, slot, extra), ret 0xc: 6 export into slot, 9 export (confirm if occupied)