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) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-20 21:56:11 +02:00
1 parent 618288e310
commit e0d8d0ce7e
9 files changed
+105 -195

No files matched your search

-1
View File
@@ -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
-48
View File
@@ -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.
+8 -1
View File
@@ -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`).
+10 -5
View File
@@ -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).
+41
View File
@@ -2,13 +2,54 @@
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
// renders but handles no events).
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
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<const char*>(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<uint8_t*> ControlsIn(void* window, size_t offset) {
auto* field = static_cast<uint8_t*>(window) + offset;
auto** begin = *reinterpret_cast<uint8_t***>(field);
auto** end = *reinterpret_cast<uint8_t***>(field + 4);
return std::vector<uint8_t*>(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<uint8_t*>(window) + offset;
auto** begin = *reinterpret_cast<uint8_t***>(field);
auto**& end = *reinterpret_cast<uint8_t***>(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<uint8_t***>(static_cast<uint8_t*>(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<DeleteFn>((*static_cast<void***>(control))[0])(control, nullptr, 1);
}
inline bool ControlShown(const void* control) {
return (static_cast<const uint8_t*>(control)[kControlFlagsOffset] & kControlFlagVisible) != 0;
+37
View File
@@ -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<game::UiRect*>(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<game::UiRect*>(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,
-104
View File
@@ -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 <cstdint>
#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 <typename T>
T& Field(void* window, size_t offset) {
return *reinterpret_cast<T*>(static_cast<uint8_t*>(window) + offset);
}
bool SelectedSlotOccupied(void* window) {
const int slot = Field<int8_t>(window, kSelectedSlot);
if (slot < 0 || slot >= kSlotCount) {
return false;
}
void* control = Field<void*>(window, kSlots + slot * sizeof(void*));
return control && Field<uint32_t>(control, kSlotHeroId) != 0;
}
void UpdateEraseButton(void* window) {
void* button = Field<void*>(window, kEraseButton);
if (button && Field<int>(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<int>(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
+8 -35
View File
@@ -16,9 +16,9 @@
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <vector>
#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<uint8_t*> Entries(uint8_t* owner, size_t offset) {
auto** begin = *reinterpret_cast<uint8_t***>(owner + offset);
auto** end = *reinterpret_cast<uint8_t***>(owner + offset + 4);
return std::vector<uint8_t*>(begin, end);
}
void Erase(uint8_t* owner, size_t offset, const uint8_t* item) {
auto** begin = *reinterpret_cast<uint8_t***>(owner + offset);
auto**& end = *reinterpret_cast<uint8_t***>(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<uint8_t***>(owner + offset))[index] = item;
}
const char* Name(const uint8_t* entry) {
return reinterpret_cast<const char*>(entry + kEntryName);
}
int32_t& EntryY(uint8_t* entry) {
return reinterpret_cast<game::UiRect*>(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<uint8_t*> original = Entries(self, kMenuMainPage);
const std::vector<uint8_t*> 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<DeleteFn>((*reinterpret_cast<void***>(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;
+1 -1
View File
@@ -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)