From 72525cd5bae449bbeee74f487f6c56e90075a0df Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Sun, 18 Oct 2026 23:26:53 +0200 Subject: [PATCH] Export screen: widescreen frame, black sides, slots can be cleared - cUI_Character (export) and cUI_Savegame draw inside the centred 1024x768 frame (viewport) with the render target beside it cleared black; the export screen's 3D heroes were stretched across the wide viewport, away from their labels and hit areas. - Export screen shows the game's erase button for an occupied slot and reloads the heroes after a delete. Overwriting was already the game's own path once clicks hit the right slot. - game/ui/control.h: shared show/hide for cUI_Control2. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + docs/char-export.md | 48 +++++++++++++++++++ docs/hud.md | 16 +++++++ src/game/ui/control.h | 24 ++++++++++ src/mods/charexport.cpp | 104 ++++++++++++++++++++++++++++++++++++++++ src/mods/escape.cpp | 5 +- src/mods/hud.cpp | 68 +++++++++++++++++++++++++- src/mods/runetrade.cpp | 11 ++--- tools/names.txt | 8 ++++ 9 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 docs/char-export.md create mode 100644 src/game/ui/control.h create mode 100644 src/mods/charexport.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7437aa6..73c956b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,7 @@ add_library(sacred_re SHARED src/mods/runetrade.cpp src/mods/teleportpotion.cpp src/mods/guards.cpp + src/mods/charexport.cpp ) target_include_directories(sacred_re PRIVATE src) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) diff --git a/docs/char-export.md b/docs/char-export.md new file mode 100644 index 0000000..4e7933a --- /dev/null +++ b/docs/char-export.md @@ -0,0 +1,48 @@ +# 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/hud.md b/docs/hud.md index 690302c..ebbb08b 100644 --- a/docs/hud.md +++ b/docs/hud.md @@ -126,3 +126,19 @@ offset, that reset reached past the render target, `SetViewport` failed and the small viewport stayed set: the cursor (drawn after the UI) was only visible inside it. A viewport at x 0 across the whole render width is now passed unshifted. + +## Full-screen windows: frame viewport and black sides (2026-10-18) + +The character export screen (Esc menu "Exportieren", cUI_Character) and the +save game window (cUI_Savegame) cover the 1024 frame with a picture. Request: +black instead of the world beside it, and the export screen adapted to the +wide view. Measured on a screenshot: the export screen's 3D scene (heroes on +their stones) is projected for a 1024x768 viewport; across the widened one +it was stretched 1.8x wide, away from the slot labels and their hit areas. + +Their render hooks (`HUD_RENDER_FRAMED`, `HUD_RENDER3_FRAMED`) run the window +inside `FrameScope`: while the window is visible, the render target left and +right of the centred frame is cleared black, then the viewport is set to the +frame (dx, 0, 1024x768) and restored afterwards. `Clear` clips to the +viewport, so the sides are cleared first. Both calls go to the unhooked +device methods (`IDirect3DDevice7` 10 Clear, 13 SetViewport, 15 GetViewport). diff --git a/src/game/ui/control.h b/src/game/ui/control.h new file mode 100644 index 0000000..77f6214 --- /dev/null +++ b/src/game/ui/control.h @@ -0,0 +1,24 @@ +#pragma once +// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still +// renders but handles no events). + +#include +#include + +namespace game { + +constexpr size_t kControlFlagsOffset = 0x10; +constexpr uint32_t kControlFlagVisible = 1; + +inline bool ControlShown(const void* control) { + return (static_cast(control)[kControlFlagsOffset] & kControlFlagVisible) != 0; +} + +// cUI_Control2::SetFlags (0x731e70) / ClearFlags (0x731ee0), thiscall (flags), +// ret 4; setting the visible flag calls the control's vf24 (show). +inline void SetControlShown(void* control, bool shown) { + using FlagFn = void(__fastcall*)(void*, void*, uint32_t); + reinterpret_cast(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlFlagVisible); +} + +} // namespace game diff --git a/src/mods/charexport.cpp b/src/mods/charexport.cpp new file mode 100644 index 0000000..8804b52 --- /dev/null +++ b/src/mods/charexport.cpp @@ -0,0 +1,104 @@ +// 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/escape.cpp b/src/mods/escape.cpp index 5beabfc..f30096a 100644 --- a/src/mods/escape.cpp +++ b/src/mods/escape.cpp @@ -12,6 +12,7 @@ #include #include +#include "game/ui/control.h" #include "mods/hud.h" #include "re/hook.h" #include "re/log.h" @@ -39,15 +40,13 @@ constexpr int kEscMenuSlot = 0x2c; // ends the dialog's input lock, and passes answers below 100000 to the quest // script (0x46bd80). Esc does the same with an answer of 100000 or more: the // dialog closes and the heroes are free, nothing is chosen. -constexpr uint32_t kHideVisible = 1; constexpr uint32_t kDialogManager = 0x00aaaf00; constexpr size_t kTooltipDialogOwner = 0x21cc; constexpr uint32_t kNoAnswer = 0xffffffff; // >= 100000: no script callback constexpr uint32_t kDialogPending = 0x00ab5314; void CloseDialog(uint8_t* tooltip) { - using HideFn = void(__fastcall*)(void*, void*, uint32_t); - reinterpret_cast(0x00731ee0)(tooltip, nullptr, kHideVisible); + game::SetControlShown(tooltip, false); using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer); reinterpret_cast(0x0049a090)(reinterpret_cast(kDialogManager), nullptr, *reinterpret_cast(tooltip + kTooltipDialogOwner), kNoAnswer); diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index 202efc2..d2ce431 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -430,6 +430,49 @@ HRESULT __stdcall ClearShifted(void* device, DWORD count, Rect7* rects, DWORD fl return g_clear(device, count, shifted.data(), flags, color, z, stencil); } +using GetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport); +constexpr int kVtableGetViewport = 15; // IDirect3DDevice7 (13 SetViewport, 14 MultiplyTransform) +constexpr DWORD kClearTarget = 1; // D3DCLEAR_TARGET + +// RAII for full-screen windows (character export, save games) that fill the +// 1024 frame with a picture: the render target left and right of the frame is +// cleared black, and the window draws with a viewport on the frame. The +// export screen's 3D scene (heroes, background) uses a projection for a +// 1024x768 viewport; across the widened one it was stretched 1.8x wide, away +// from the slot labels and hit areas (measured on a screenshot, 2026-10-18). +// Clear clips to the viewport, so the sides are cleared before it is set. +class FrameScope { +public: + FrameScope(void* window, void* device, int dx) { + const auto* w = static_cast(window); + if (dx == 0 || g_drawScoped || !device || !g_setViewport || !g_clear || (w[kControlFlags] & 1) == 0) { + return; + } + auto getViewport = reinterpret_cast((*static_cast(device))[kVtableGetViewport]); + if (FAILED(getViewport(device, &previous_))) { + return; + } + const LONG height = static_cast(previous_.height); + Rect7 sides[] = {{0, 0, dx, height}, + {dx + kLayoutWidth, 0, mods::widescreen::RenderWidth(), height}}; + g_clear(device, 2, sides, kClearTarget, 0, 1.0f, 0); + Viewport7 frame = {static_cast(dx), 0, kLayoutWidth, 768, previous_.minZ, previous_.maxZ}; + device_ = device; + g_setViewport(device, &frame); + } + ~FrameScope() { + if (device_) { + g_setViewport(device_, &previous_); + } + } + FrameScope(const FrameScope&) = delete; + FrameScope& operator=(const FrameScope&) = delete; + +private: + void* device_ = nullptr; + Viewport7 previous_ = {}; +}; + } // namespace int CursorOffsetX() { @@ -571,6 +614,27 @@ int TooltipOffset(void* tooltip) { return name##_original(self, edx, device, a, b); \ } +// Centred full-screen windows: black beside the frame, viewport on the frame. +#define HUD_RENDER_FRAMED(address, name) \ + int __fastcall name(void* self, void* edx, void* device); \ + RE_HOOK(address, name); \ + int __fastcall name(void* self, void* edx, void* device) { \ + const int dx = mods::hud::OffsetFor(mods::hud::Anchor::Center); \ + mods::hud::FrameScope frame(self, device, dx); \ + mods::hud::DrawOffset offset(self, dx); \ + return name##_original(self, edx, device); \ + } + +#define HUD_RENDER3_FRAMED(address, name) \ + int __fastcall name(void* self, void* edx, void* device, int a, int b); \ + RE_HOOK(address, name); \ + int __fastcall name(void* self, void* edx, void* device, int a, int b) { \ + const int dx = mods::hud::OffsetFor(mods::hud::Anchor::Center); \ + mods::hud::FrameScope frame(self, device, dx); \ + mods::hud::DrawOffset offset(self, dx); \ + return name##_original(self, edx, device, a, b); \ + } + namespace game { using mods::hud::Anchor; @@ -629,11 +693,11 @@ HUD_RENDER(0x006ea750, UiTrade_Render, Anchor::Center) HUD_EVENT(0x006f8960, UiNetworkInfo_HandleEvent, Anchor::Center) HUD_RENDER(0x006f73f0, UiNetworkInfo_Render, Anchor::Center) HUD_EVENT(0x0070e860, UiCharacter_HandleEvent, Anchor::Center) -HUD_RENDER3(0x0070f160, UiCharacter_Render, Anchor::Center) +HUD_RENDER3_FRAMED(0x0070f160, UiCharacter_Render) // export screen in-game HUD_EVENT(0x00716b30, UiOptions_HandleEvent, Anchor::Center) HUD_RENDER3(0x00716640, UiOptions_Render, Anchor::Center) HUD_EVENT(0x0071d130, UiSavegame_HandleEvent, Anchor::Center) -HUD_RENDER(0x0071dd80, UiSavegame_Render, Anchor::Center) +HUD_RENDER_FRAMED(0x0071dd80, UiSavegame_Render) HUD_EVENT(0x00720c10, UiBusyDlg_HandleEvent, Anchor::Center) HUD_RENDER(0x007213a0, UiBusyDlg_Render, Anchor::Center) diff --git a/src/mods/runetrade.cpp b/src/mods/runetrade.cpp index d45f36a..aa3f61a 100644 --- a/src/mods/runetrade.cpp +++ b/src/mods/runetrade.cpp @@ -37,6 +37,7 @@ #include "game/engine/items.h" #include "game/engine/objects.h" #include "game/engine/orders.h" +#include "game/ui/control.h" #include "game/ui/events.h" #include "game/ui/tabs.h" #include "game/ui/text.h" @@ -184,12 +185,6 @@ void Select(uint8_t* window, uint32_t mode) { reinterpret_cast(0x006d3fd0)(window, nullptr, HeroType(), mode); // doLottoSelect } -// Shows a control (0x731e70) / hides it (0x731ee0); flag 1 = visible. -void SetShown(uint8_t* control, bool shown) { - using FlagFn = void(__fastcall*)(void*, void*, uint32_t); - reinterpret_cast(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlVisible); -} - // Chooses the hero's last bought rune (steps through the choices). void ChooseRemembered(uint8_t* window) { const int hero = HeroIndex(); @@ -373,8 +368,8 @@ void UpdateChoice(uint8_t* window) { return; } updating = true; - SetShown(window + kNextArrow, true); - SetShown(window + kPreviousArrow, true); + game::SetControlShown(window + kNextArrow, true); + game::SetControlShown(window + kPreviousArrow, true); if (Field(window, kChosenRune) == 0) { Select(window, kSelectKeep); ChooseRemembered(window); diff --git a/tools/names.txt b/tools/names.txt index 758dc4f..dac6fa2 100644 --- a/tools/names.txt +++ b/tools/names.txt @@ -301,3 +301,11 @@ 0x006e6ae0 cUI_Tooltip::SetText # (textId, text*, fixedWidth), ret 0xc 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) +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) +0x0070e650 cUI_Character::OnConfirmButton # the right-hand button (Exportieren, ...) +0x007f01a0 HeroSlots_DeleteHero # (slot + 1), thiscall on 0x7d7c40(), ret 4: deletes Save\Hero%02d.pax +0x00731e70 cUI_Control2::SetFlags # (flags), ret 4: bit 0 = visible (show)