From c6c5530709aca0002816ae638c09e8f5777c9a15 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Fri, 16 Oct 2026 01:12:34 +0200 Subject: [PATCH] Maps in widened views: TAB overlay in place, M map across the full width - hud: shift SetViewport/Clear with moved windows (TAB overview map) - megamap: view constants patched to the render width (clip, scroll limit, edge scrolling, centre, dark band, zoomed view and its markers); frame stretched to the screen edges, zoom controls and their click area centred - hud: Anchor::Screen windows are drawn and clicked unshifted but still own the event scope - autotest waits for log markers instead of fixed delays (~18 s per run) Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 1 + docs/hud.md | 29 ++++++ docs/todo.md | 6 +- src/mods/hud.cpp | 62 +++++++++++-- src/mods/megamap.cpp | 197 ++++++++++++++++++++++++++++++++++++++++ src/mods/megamap.h | 9 ++ src/mods/widescreen.cpp | 2 + tools/autotest-load.ps1 | 7 +- tools/game-input.ps1 | 32 ++++++- 9 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 src/mods/megamap.cpp create mode 100644 src/mods/megamap.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ec45fb3..4919bcd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,7 @@ add_library(sacred_re SHARED src/mods/chainlightning.cpp src/mods/escape.cpp src/mods/recordcache.cpp + src/mods/megamap.cpp ) target_include_directories(sacred_re PRIVATE src) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) diff --git a/docs/hud.md b/docs/hud.md index ec88243..be83f9d 100644 --- a/docs/hud.md +++ b/docs/hud.md @@ -86,3 +86,32 @@ tests. Visible dialog tooltips: `mods::hud::VisibleDialogTooltips`. The standalone control hooks (buttons, texts) above turned out not to be what the quest dialog is made of; they stay, for top-level controls in general. + +## Large map (M) across the render width + +`mods/megamap.cpp`. cUI_Megamap (vtable 0x894714) is not moved like the other +windows (`Anchor::Screen`: drawn and clicked unshifted); its 1024x768 view is +widened instead, when the render size is applied (`PatchLayout`): + +| What | Where | Game | Now | +|---|---|---|---| +| cursor at the right edge (edge scrolling, cursor shape) | 9 × `cmp reg, 3FFh` 0x6c7625..0x6c9e32 | 1023 | width − 1 | +| tiles right of the view skipped; labels flipped left | `cmp reg, 400h` 0x6c9aa3, 0x6ca2c6, 0x6ca2e5 | 1024 | width | +| largest scroll x = map width (+0xc0160, 2048) − view | `add eax, -400h` 0x6c9edd | −1024 | −width | +| view centre (zoom frame, centred image) | 0x6ca53f, 0x6ca637, 0x6ca645 | 0x200 | width / 2 | +| scroll centred on the hero; zoomed view; its quest markers | `[0x88e6fc]` (512.0) at 0x6c7bfe, 0x6cb27a, 0x6ccb7f, 0x6cf782 | 512 | own float | +| zoomed view: other markers at 768 − d (centre + 256) | `[0x890040]` (768.0) at 0x6ce156 | 768 | width / 2 + 256 | +| dark band, right outer / inner edge | vertex immediates 0x6ca774, 0x6ca808 / 0x6ca7bc, 0x6ca84c | 1024 / 954 | width / width − 70 | + +The frame is child statics of the window (vector at +0x78..+0x7c; measured: +corners 41×44, 32 px tiles, left side x < 48, right side from x 976). On +Show (vf24 0x6c7930) the right side moves to the screen edge, the top and +bottom tiles stretch over the wider span, and the four controls the map holds +itself (+0xc0440, +0xc04bc statics, +0xc0544, +0xc0600 buttons: the zoom +controls, absolute positions) stay centred, and so does the rectangle around +them at +0xc0538: the map passes only clicks inside it to the buttons +(0x6c7f32). Each control is placed once (the +game lays them out at creation only). + +A window anchored `Screen` still counts as the outermost scope for events, so +its child buttons (hooked as standalone controls, `Center`) don't shift again. diff --git a/docs/todo.md b/docs/todo.md index 5946c8d..ebb2588 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -56,9 +56,9 @@ follow. ## Map and map zoom in widened views Reported 2026-10-15: the map (and zooming it) does not support the widened -render sizes yet (docs/widescreen.md, docs/hud.md: windows are laid out for -1024 and centred). To check: the map window's draw area and the zoom's -hardcoded 1024/768 (or 512/384 centre) values. +render sizes yet. 2026-10-16: TAB map fixed (viewport shifted), M map spans +the render width (docs/hud.md, "Large map"), zoomed view and its markers included. +Confirmed in-game 2026-10-16. ## Quest dialog glitches in widened views diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp index b7a7b12..c235943 100644 --- a/src/mods/hud.cpp +++ b/src/mods/hud.cpp @@ -45,13 +45,14 @@ thread_local int g_offsetX = 0; thread_local bool g_eventShifted = false; -enum class Anchor { Center, Right }; +// Screen: laid out across the whole render width by its mod (drawn unshifted). +enum class Anchor { Center, Right, Screen }; int OffsetFor(Anchor anchor) { static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0; const int width = mods::widescreen::RenderWidth(); if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || - mods::widescreen::MenuIsShowing()) { + mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) { return 0; } return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth; @@ -283,11 +284,12 @@ int MouseEventXOffsets(void* event, int (&offsets)[2]) { } } -// RAII: shift the mouse position by -dx for the scope (outermost window only). +// RAII: shift the mouse position by -dx for the scope (outermost window only; +// one with dx 0, e.g. Anchor::Screen, still keeps its children from shifting). class EventOffset { public: - EventOffset(void* event, int dx) : cursor_(dx) { - if (dx == 0 || g_eventShifted) { + EventOffset(void* event, int dx) : outermost_(!g_eventShifted), cursor_(outermost_ ? dx : 0) { + if (!outermost_) { return; } int offsets[2]; @@ -309,6 +311,7 @@ public: } private: + bool outermost_; int count_ = 0; int32_t* x_[2] = {}; int32_t original_[2] = {}; @@ -387,6 +390,45 @@ HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fv indexCount, flags); } +// Viewports and cleared rectangles in screen space (the overview map, TAB, +// sets a viewport at its frame, 162,120 700x525, and draws the map inside with +// its own projection): moved like the vertices while a window draws. +struct Viewport7 { + DWORD x, y, width, height; + float minZ, maxZ; +}; +struct Rect7 { + LONG x1, y1, x2, y2; +}; +using SetViewportFn = HRESULT(__stdcall*)(void* device, Viewport7* viewport); +using ClearFn = HRESULT(__stdcall*)(void* device, DWORD count, Rect7* rects, DWORD flags, DWORD color, float z, + DWORD stencil); +SetViewportFn g_setViewport = nullptr; +ClearFn g_clear = nullptr; + +HRESULT __stdcall SetViewportShifted(void* device, Viewport7* viewport) { + if (g_offsetX == 0 || !viewport) { + return g_setViewport(device, viewport); + } + Viewport7 shifted = *viewport; + shifted.x = static_cast(static_cast(shifted.x) + g_offsetX); + return g_setViewport(device, &shifted); +} + +HRESULT __stdcall ClearShifted(void* device, DWORD count, Rect7* rects, DWORD flags, DWORD color, float z, + DWORD stencil) { + if (g_offsetX == 0 || count == 0 || !rects) { + return g_clear(device, count, rects, flags, color, z, stencil); + } + thread_local std::vector shifted; + shifted.assign(rects, rects + count); + for (Rect7& r : shifted) { + r.x1 += g_offsetX; + r.x2 += g_offsetX; + } + return g_clear(device, count, shifted.data(), flags, color, z, stencil); +} + } // namespace int CursorOffsetX() { @@ -414,6 +456,12 @@ void OnDevice(void* device) { g_drawIndexedPrimitive = reinterpret_cast(vtable[26]); re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast(&g_drawIndexedPrimitive), reinterpret_cast(&DrawIndexedPrimitiveShifted)); + // Clear 10, SetViewport 13. + g_clear = reinterpret_cast(vtable[10]); + re::AttachRuntimeHook("D3D7 Clear", reinterpret_cast(&g_clear), reinterpret_cast(&ClearShifted)); + g_setViewport = reinterpret_cast(vtable[13]); + re::AttachRuntimeHook("D3D7 SetViewport", reinterpret_cast(&g_setViewport), + reinterpret_cast(&SetViewportShifted)); } } @@ -545,8 +593,8 @@ HUD_EVENT(0x006c5f40, UiInventory_HandleEvent, Anchor::Center) HUD_RENDER(0x006c6140, UiInventory_Render, Anchor::Center) HUD_EVENT(0x006c70e0, UiOverviewMap_HandleEvent, Anchor::Center) HUD_RENDER(0x006c69d0, UiOverviewMap_Render, Anchor::Center) -HUD_EVENT(0x006c7ec0, UiMegamap_HandleEvent, Anchor::Center) -HUD_RENDER(0x006c9ce0, UiMegamap_Render, Anchor::Center) +HUD_EVENT(0x006c7ec0, UiMegamap_HandleEvent, Anchor::Screen) +HUD_RENDER(0x006c9ce0, UiMegamap_Render, Anchor::Screen) // laid out by mods/megamap.cpp HUD_EVENT(0x006d1080, UiMaster_HandleEvent, Anchor::Center) HUD_RENDER(0x006d0f00, UiMaster_Render, Anchor::Center) HUD_EVENT(0x006d2450, UiMasterCombo_HandleEvent, Anchor::Center) diff --git a/src/mods/megamap.cpp b/src/mods/megamap.cpp new file mode 100644 index 0000000..4cbc4e2 --- /dev/null +++ b/src/mods/megamap.cpp @@ -0,0 +1,197 @@ +// The large map (M, cUI_Megamap) spans the whole render width. +// +// The game lays the map out for a 1024x768 screen: a scrollable view of the +// 2048x2816 world map (256 px tiles, scroll position at +0xc016c / +0xc0170), +// clipped and edge-scrolled at x 1024, centred at x 512, under a dark band +// (inner edge 70 px) and a frame of child statics (corners, 32 px edge tiles). +// Moving it as a centred window left the world visible at the sides; instead +// the view constants are set to the render width, the frame's right side moves +// to the right screen edge and its top and bottom tiles stretch across, and +// the zoom buttons at the bottom stay centred. The window is drawn and clicked +// unshifted (mods/hud.cpp, Anchor::Screen). See docs/hud.md. + +#include +#include +#include + +#include "game/addresses.h" +#include "mods/megamap.h" +#include "mods/widescreen.h" +#include "re/hook.h" +#include "re/log.h" +#include "re/patch.h" + +namespace mods::megamap { +namespace { + +constexpr int kLayoutWidth = 1024; + +// cmp reg, 3FFh: cursor at the right edge (edge scrolling, cursor shape). +constexpr uint32_t kRightEdgeCompares[] = {0x006c7625, 0x006c764c, 0x006c768c, 0x006c76d9, 0x006c76fe, + 0x006c773a, 0x006c9dce, 0x006c9df4, 0x006c9e32}; +// cmp reg, 400h: tiles right of the view are skipped (0x6c9aa3); a label that +// would end right of it is placed left of its point (0x6ca2c6, 0x6ca2e5). +constexpr uint32_t kWidthCompares[] = {0x006c9aa3, 0x006ca2c6, 0x006ca2e5}; +// add eax, -400h: the largest scroll x is the map width minus the view. +constexpr uint32_t kScrollLimit = 0x006c9edd; +// The view centre 200h: the zoom-in frame (0x6ca53f) and the image centred +// on it (0x6ca637, 0x6ca645). +constexpr uint32_t kCentreImmediates[] = {0x006ca53f, 0x006ca637, 0x006ca645}; +// fsub/fld/fadd [512.0]: centring the scroll on the hero (opening the map +// 0x6c7bfe, 0x6cb27a), the zoomed view (0x6ccb7f) and its quest markers +// (0x6cf782). +constexpr uint32_t kCentreFloatUses[] = {0x006c7bfe, 0x006cb27a, 0x006ccb7f, 0x006cf782}; +// fld [768.0] as an x: markers in the zoomed view at 768 - d (0x6ce156), the +// centre plus 256. +constexpr uint32_t kZoomMarkerX = 0x006ce156; +constexpr float kZoomMarkerFromCentre = 256.0f; +// The dark band around the view (10 vertices, mov [esp+..], imm32): its right +// outer edge x 1024 and inner edge x 954. +constexpr uint32_t kBandRightOuter[] = {0x006ca774, 0x006ca808}; +constexpr uint32_t kBandRightInner[] = {0x006ca7bc, 0x006ca84c}; +constexpr int kBandWidth = 70; + +float g_halfWidth = 512.0f; +float g_zoomMarkerX = 768.0f; + +uint32_t FloatBits(float value) { + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +} + +// UI control layout (relative to the parent at +0x50, if any). +// Rectangles: x, y (int32), width, height (int16); a control's at +0x24. +constexpr size_t kControlRect = 0x24; +// cUI_Window2: child controls, a vector of pointers. +constexpr size_t kChildrenBegin = 0x78; +constexpr size_t kChildrenEnd = 0x7c; +// The frame: left side x < 48, right side from x 976, top tiles above y 48, +// bottom tiles from y 720 (measured). +constexpr int kFrameLeft = 48; +constexpr int kFrameRight = 976; +constexpr int kFrameTop = 48; +constexpr int kFrameBottom = 720; +// Controls the map holds itself (absolute positions, drawn by its render): +// two statics and two buttons, the zoom controls at the bottom centre. +constexpr size_t kOwnControls[] = {0xc0440, 0xc04bc, 0xc0544, 0xc0600}; +// Rectangle around them: the map passes only clicks inside it to the buttons +// (its event handler, 0x6c7f32). +constexpr size_t kZoomControlsRect = 0xc0538; + +int& RectX(uint8_t* rect) { + return *reinterpret_cast(rect); +} +int RectY(uint8_t* rect) { + return *reinterpret_cast(rect + 4); +} +int16_t& RectWidth(uint8_t* rect) { + return *reinterpret_cast(rect + 8); +} + +// The game lays the controls out once, when it creates the map; they are +// placed for the render width once, too (the map is opened many times). +struct Placement { + int x; + int16_t width; +}; +std::unordered_map g_placed; + +void Place(uint8_t* rect, int x, int width) { + RectX(rect) = x; + RectWidth(rect) = static_cast(width); + g_placed[rect] = {x, static_cast(width)}; +} + +bool AlreadyPlaced(uint8_t* rect) { + const auto it = g_placed.find(rect); + return it != g_placed.end() && it->second.x == RectX(rect) && it->second.width == RectWidth(rect); +} + +// Frame pieces: the right side moves by `extra`, the top and bottom tiles +// between the corners stretch over the wider span; anything else stays +// centred. +void PlaceFramePiece(uint8_t* rect, int extra) { + const int x = RectX(rect); + const int width = RectWidth(rect); + const int y = RectY(rect); + if (x + width <= kFrameLeft) { + return; + } + if (x >= kFrameRight) { + Place(rect, x + extra, width); + return; + } + if (y < kFrameTop || y >= kFrameBottom) { + const double scale = static_cast(kFrameRight - kFrameLeft + extra) / (kFrameRight - kFrameLeft); + const int left = kFrameLeft + static_cast((x - kFrameLeft) * scale + 0.5); + const int right = kFrameLeft + static_cast((x + width - kFrameLeft) * scale + 0.5); + Place(rect, left, right - left); + return; + } + Place(rect, x + extra / 2, width); +} + +void PlaceCentred(uint8_t* rect, int extra) { + if (!AlreadyPlaced(rect)) { + Place(rect, RectX(rect) + extra / 2, RectWidth(rect)); + } +} + +void Layout(uint8_t* megamap) { + const int extra = mods::widescreen::RenderWidth() - kLayoutWidth; + if (extra <= 0) { + return; + } + auto** child = *reinterpret_cast(megamap + kChildrenBegin); + auto** end = *reinterpret_cast(megamap + kChildrenEnd); + for (; child && child != end; ++child) { + if (*child && !AlreadyPlaced(*child + kControlRect)) { + PlaceFramePiece(*child + kControlRect, extra); + } + } + for (size_t offset : kOwnControls) { + PlaceCentred(megamap + offset + kControlRect, extra); + } + PlaceCentred(megamap + kZoomControlsRect, extra); + uint8_t* window = megamap + kControlRect; + if (!AlreadyPlaced(window) && RectX(window) == 0 && RectWidth(window) == kLayoutWidth) { + Place(window, 0, kLayoutWidth + extra); + } +} + +} // namespace + +void PatchLayout(int width) { + g_halfWidth = width / 2.0f; + int failed = re::PatchAll(kRightEdgeCompares, 0x3ff, static_cast(width - 1)); + failed += re::PatchAll(kWidthCompares, 0x400, static_cast(width)); + failed += !re::PatchInstructionDword(kScrollLimit, static_cast(-kLayoutWidth), + static_cast(-width)); + failed += re::PatchAll(kCentreImmediates, 0x200, static_cast(width / 2)); + for (uint32_t instruction : kCentreFloatUses) { + failed += !re::RedirectOperand(instruction, game::addr::kScreenHalfWidthF, &g_halfWidth); + } + g_zoomMarkerX = g_halfWidth + kZoomMarkerFromCentre; + failed += !re::RedirectOperand(kZoomMarkerX, game::addr::kScreenHeightF, &g_zoomMarkerX); + failed += re::PatchAll(kBandRightOuter, FloatBits(1024.0f), FloatBits(static_cast(width))); + failed += re::PatchAll(kBandRightInner, FloatBits(1024.0f - kBandWidth), + FloatBits(static_cast(width - kBandWidth))); + re::Log("megamap: view width %d%s", width, failed ? " (some patches FAILED)" : ""); +} + +} // namespace mods::megamap + +namespace game { + +// cUI_Megamap::Show (vf24 0x6c7930, the argument: show / hide). +void __fastcall UiMegamap_Show(void* self, void* edx, int show); +RE_HOOK(0x006c7930, UiMegamap_Show); +void __fastcall UiMegamap_Show(void* self, void* edx, int show) { + if (self && (show & 0xff)) { + mods::megamap::Layout(static_cast(self)); + } + UiMegamap_Show_original(self, edx, show); +} + +} // namespace game diff --git a/src/mods/megamap.h b/src/mods/megamap.h new file mode 100644 index 0000000..dc0d627 --- /dev/null +++ b/src/mods/megamap.h @@ -0,0 +1,9 @@ +#pragma once + +namespace mods::megamap { + +// Sets the large map's view constants to the render width (once, when the +// render size is applied). +void PatchLayout(int width); + +} // namespace mods::megamap diff --git a/src/mods/widescreen.cpp b/src/mods/widescreen.cpp index 8083013..2bd92d9 100644 --- a/src/mods/widescreen.cpp +++ b/src/mods/widescreen.cpp @@ -24,6 +24,7 @@ #include "mods/widescreen.h" #include "mods/hud.h" +#include "mods/megamap.h" #include #include @@ -318,6 +319,7 @@ void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) { PatchGrannyOrthoPushes(width, height); PatchLabelArea(width, height); PatchLetterbox(width); + mods::megamap::PatchLayout(width); } const double scaleX = width / kOriginalWidth; const double scaleY = height / kOriginalHeight; diff --git a/tools/autotest-load.ps1 b/tools/autotest-load.ps1 index c533b5d..9e8427f 100644 --- a/tools/autotest-load.ps1 +++ b/tools/autotest-load.ps1 @@ -11,7 +11,7 @@ param( [string]$Name = "autotest", [string]$Ini = "", - [int]$LoadSeconds = 30, + [int]$LoadTimeoutSeconds = 60, # Row of the save game in the load screen's list (1-3 visible rows). [int]$Save = 1, # Runs in-game after loading, before the screenshots (e.g. clicks). @@ -28,12 +28,13 @@ if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding $game = $null try { $game = Start-Sacred - Click-At 1720 779 -AfterMs 4000 # main menu: "Spiel laden" + Click-At 1720 779 -AfterMs 1500 # main menu: "Spiel laden" if ($Save -gt 1) { Click-At 1342 (609 + ($Save - 1) * 254) -AfterMs 800 # load screen: the save's name (rows 254 px apart) } Click-At 2382 568 -AfterMs 2000 # load screen: "Laden" - Click-At 1551 826 -AfterMs ($LoadSeconds * 1000) # confirm: "Ok" + Click-At 1551 826 -AfterMs 0 # confirm: "Ok" + Wait-Log "cEngine End and exit constructor" -TimeoutSeconds $LoadTimeoutSeconds -AfterMs 1500 -Game $game if ($InGame) { & $InGame } Save-Screen "screenshots\${Name}_ingame.png" | Out-Null Press-Key 0x1B -AfterMs 2500 # ESC menu diff --git a/tools/game-input.ps1 b/tools/game-input.ps1 index a7cf4ca..4dac3ff 100644 --- a/tools/game-input.ps1 +++ b/tools/game-input.ps1 @@ -31,16 +31,38 @@ function Focus-Sacred($Game) { [SacredRe.Input]::SetForegroundWindow($Game.MainWindowHandle) } -function Start-Sacred([string]$Arguments = "log", [int]$WaitSeconds = 18) { +$script:LogPath = Join-Path $script:Root "build\x86-debug\sacred_re.log" + +# Waits until sacred_re.log (written anew at every start) has at least $Count +# lines containing $Pattern, focusing the game meanwhile; then waits $AfterMs. +function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60, [int]$AfterMs = 500, $Game = $null) { + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if ($Game) { Focus-Sacred $Game | Out-Null } + $found = 0 + if (Test-Path $script:LogPath) { + $found = @(Select-String -Path $script:LogPath -Pattern $Pattern -SimpleMatch).Count + } + if ($found -ge $Count) { Start-Sleep -Milliseconds $AfterMs; return } + Start-Sleep -Milliseconds 200 + } + throw "timeout waiting for '$Pattern' in sacred_re.log" +} + +# Starts the game and returns once its main menu is up (the UI manager +# starts its menu thread a second time when the menu appears). +function Start-Sacred([string]$Arguments = "log") { if (Get-Process sacred -ErrorAction SilentlyContinue) { throw "sacred.exe is already running" } + if (Test-Path $script:LogPath) { Remove-Item $script:LogPath -ErrorAction SilentlyContinue } $null = Start-Process (Join-Path $script:Root "build\x86-debug\sacred_re.exe") -ArgumentList $Arguments $game = $null - for ($i = 0; $i -lt $WaitSeconds * 4; $i++) { + for ($i = 0; $i -lt 80 -and -not $game; $i++) { Start-Sleep -Milliseconds 250 - if (-not $game) { $game = Get-Process sacred -ErrorAction SilentlyContinue | Select-Object -First 1 } - if ($game) { Focus-Sacred $game | Out-Null } + $game = Get-Process sacred -ErrorAction SilentlyContinue | Select-Object -First 1 } - if (-not $game -or $game.HasExited) { throw "sacred.exe did not start (or crashed)" } + if (-not $game) { throw "sacred.exe did not start" } + Wait-Log "starting menu thread!" -Count 2 -AfterMs 1500 -Game $game + if ($game.HasExited) { throw "sacred.exe crashed" } $game }