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) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-18 23:26:53 +02:00
1 parent c875d7d6b9
commit 72525cd5ba
9 files changed
+272 -13

No files matched your search

+1
View File
@@ -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})
+48
View File
@@ -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.
+16
View File
@@ -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).
+24
View File
@@ -0,0 +1,24 @@
#pragma once
// cUI_Control2: flags at +0x10 (bit 0: visible; a hidden control still
// renders but handles no events).
#include <cstddef>
#include <cstdint>
namespace game {
constexpr size_t kControlFlagsOffset = 0x10;
constexpr uint32_t kControlFlagVisible = 1;
inline bool ControlShown(const void* control) {
return (static_cast<const uint8_t*>(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<FlagFn>(shown ? 0x00731e70 : 0x00731ee0)(control, nullptr, kControlFlagVisible);
}
} // namespace game
+104
View File
@@ -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 <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
+2 -3
View File
@@ -12,6 +12,7 @@
#include <cstdint>
#include <cstring>
#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<HideFn>(0x00731ee0)(tooltip, nullptr, kHideVisible);
game::SetControlShown(tooltip, false);
using AnswerFn = void(__fastcall*)(void*, void*, uint32_t owner, uint32_t answer);
reinterpret_cast<AnswerFn>(0x0049a090)(reinterpret_cast<void*>(kDialogManager), nullptr,
*reinterpret_cast<uint32_t*>(tooltip + kTooltipDialogOwner), kNoAnswer);
+66 -2
View File
@@ -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<const uint8_t*>(window);
if (dx == 0 || g_drawScoped || !device || !g_setViewport || !g_clear || (w[kControlFlags] & 1) == 0) {
return;
}
auto getViewport = reinterpret_cast<GetViewportFn>((*static_cast<void***>(device))[kVtableGetViewport]);
if (FAILED(getViewport(device, &previous_))) {
return;
}
const LONG height = static_cast<LONG>(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<DWORD>(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)
+3 -8
View File
@@ -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<SelectFn>(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<FlagFn>(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<uint32_t>(window, kChosenRune) == 0) {
Select(window, kSelectKeep);
ChooseRemembered(window);
+8
View File
@@ -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)