diff --git a/CMakeLists.txt b/CMakeLists.txt index a01c5e2..37c3ad9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ add_library(sacred_re SHARED src/game/engine/cmdline.cpp src/mods/borderless.cpp src/mods/widescreen.cpp + src/mods/hud.cpp ) target_include_directories(sacred_re PRIVATE src) target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) diff --git a/src/mods/hud.cpp b/src/mods/hud.cpp new file mode 100644 index 0000000..4a43ee6 --- /dev/null +++ b/src/mods/hud.cpp @@ -0,0 +1,213 @@ +// HUD layout for widened views. +// +// The UI is laid out for 1024x768 and drawn into the top-left of the widened +// render target. Selected windows are moved as a whole: +// - cUI_Taskbar2 (orbs, belt, skills at the bottom): centred horizontally +// - cUI_Mercenary (party panel: portrait and health at the top): right edge +// +// A window is moved by transforming what it draws and what it receives, so +// positions it computes itself (e.g. the potion arc) follow as well: +// - while its render method runs, every screen-space vertex (XYZRHW) passed +// to DrawPrimitive / DrawIndexedPrimitive is shifted by dx (3D items such +// as belt contents already line up: their projection is screen-centred); +// - while its event handler runs, the point of UI events (category 5, +// point at +0x48) is shifted by -dx. Children are handled inside both. +// The world never sees these offsets: they are only active inside the hooks. +// +// sacred_re.ini: +// [hud] +// Enabled=1 + +#include +#include +#include + +#include + +#include "game/render/dx_driver7.h" +#include "mods/widescreen.h" +#include "re/config.h" +#include "re/hook.h" +#include "re/log.h" + +namespace mods::hud { +namespace { + +constexpr int kLayoutWidth = 1024; + +// Horizontal offset applied to the current thread's UI drawing/input. +thread_local int g_offsetX = 0; + +enum class Anchor { Center, Right }; + +int OffsetFor(Anchor anchor) { + const int width = mods::widescreen::RenderWidth(); + if (width <= kLayoutWidth || mods::widescreen::IsClassicFrame() || !re::ConfigInt("hud", "Enabled", 1)) { + return 0; + } + return anchor == Anchor::Center ? (width - kLayoutWidth) / 2 : width - kLayoutWidth; +} + +// RAII: shift drawing by dx for the scope. +class DrawOffset { +public: + explicit DrawOffset(int dx) : previous_(g_offsetX) { g_offsetX = dx; } + ~DrawOffset() { g_offsetX = previous_; } + +private: + int previous_; +}; + +// UI events: vf04 returns the category, 5 = UI event with its point at +0x48. +bool IsUiPointEvent(void* event) { + using CategoryFn = int(__fastcall*)(void* self, void* edx); + auto category = reinterpret_cast((*static_cast(event))[1]); + return category(event, nullptr) == 5; +} + +// RAII: shift the event point by -dx for the scope. +class EventOffset { +public: + EventOffset(void* event, int dx) { + if (dx != 0 && IsUiPointEvent(event)) { + x_ = reinterpret_cast(static_cast(event) + 0x48); + dx_ = dx; + *x_ -= dx_; + } + } + ~EventOffset() { + if (x_) { + *x_ += dx_; + } + } + +private: + int32_t* x_ = nullptr; + int dx_ = 0; +}; + +// --- Direct3D 7 device hooks ------------------------------------------------- + +constexpr DWORD kFvfPositionMask = 0x00e; +constexpr DWORD kFvfXyzRhw = 0x004; + +size_t VertexSize(DWORD fvf) { + size_t size = 0; + switch (fvf & kFvfPositionMask) { + case 0x002: size = 12; break; // XYZ + case 0x004: size = 16; break; // XYZRHW + case 0x006: size = 16; break; // XYZB1 + case 0x008: size = 20; break; // XYZB2 + case 0x00a: size = 24; break; // XYZB3 + case 0x00c: size = 28; break; // XYZB4 + case 0x00e: size = 32; break; // XYZB5 + } + if (fvf & 0x010) size += 12; // normal + if (fvf & 0x040) size += 4; // diffuse + if (fvf & 0x080) size += 4; // specular + const DWORD textures = (fvf >> 8) & 0xf; + for (DWORD i = 0; i < textures; ++i) { + static const size_t kCoordinates[] = {2, 3, 4, 1}; + size += 4 * kCoordinates[(fvf >> (16 + 2 * i)) & 3]; + } + return size; +} + +// Returns vertices shifted by g_offsetX, or the input when nothing to do. +void* ShiftVertices(DWORD fvf, void* vertices, DWORD count) { + if (g_offsetX == 0 || (fvf & kFvfPositionMask) != kFvfXyzRhw || !vertices || count == 0) { + return vertices; + } + thread_local std::vector buffer; + const size_t stride = VertexSize(fvf); + buffer.assign(static_cast(vertices), static_cast(vertices) + stride * count); + for (DWORD i = 0; i < count; ++i) { + auto* x = reinterpret_cast(buffer.data() + stride * i); + *x += static_cast(g_offsetX); + } + return buffer.data(); +} + +using DrawPrimitiveFn = HRESULT(__stdcall*)(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count, + DWORD flags); +using DrawIndexedPrimitiveFn = HRESULT(__stdcall*)(void* device, DWORD type, DWORD fvf, void* vertices, + DWORD count, WORD* indices, DWORD indexCount, DWORD flags); +DrawPrimitiveFn g_drawPrimitive = nullptr; +DrawIndexedPrimitiveFn g_drawIndexedPrimitive = nullptr; + +HRESULT __stdcall DrawPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count, + DWORD flags) { + return g_drawPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, flags); +} + +HRESULT __stdcall DrawIndexedPrimitiveShifted(void* device, DWORD type, DWORD fvf, void* vertices, DWORD count, + WORD* indices, DWORD indexCount, DWORD flags) { + return g_drawIndexedPrimitive(device, type, fvf, ShiftVertices(fvf, vertices, count), count, indices, + indexCount, flags); +} + +} // namespace + +void OnDevice(void* device) { + static void* hooked = nullptr; + if (!device || device == hooked) { + return; + } + hooked = device; + void** vtable = *static_cast(device); + // IDirect3DDevice7: DrawPrimitive 25, DrawIndexedPrimitive 26. (3D items in + // the HUD need no help: their ortho projection is centred on the screen, + // which lines up with a centred taskbar.) + if (!g_drawPrimitive) { + g_drawPrimitive = reinterpret_cast(vtable[25]); + re::AttachRuntimeHook("D3D7 DrawPrimitive", reinterpret_cast(&g_drawPrimitive), + reinterpret_cast(&DrawPrimitiveShifted)); + g_drawIndexedPrimitive = reinterpret_cast(vtable[26]); + re::AttachRuntimeHook("D3D7 DrawIndexedPrimitive", reinterpret_cast(&g_drawIndexedPrimitive), + reinterpret_cast(&DrawIndexedPrimitiveShifted)); + } +} + +} // namespace mods::hud + +// --- HUD windows --------------------------------------------------------------- + +namespace game { + +using mods::hud::Anchor; + +// cUI_Taskbar2 (vtable 0x00894ac0): vf10 events, vf14 render. +int __fastcall UiTaskbar_HandleEvent(void* self, void* edx, void* event); +void __fastcall UiTaskbar_Render(void* self, void* edx, void* device); +// Portrait/health panel (cUI_Mercenary). +int __fastcall UiPortrait_HandleEvent(void* self, void* edx, void* event); +void __fastcall UiPortrait_Render(void* self, void* edx, void* device); + +RE_HOOK(0x006e1970, UiTaskbar_HandleEvent); +RE_HOOK(0x006e3ac0, UiTaskbar_Render); +// The portrait with health at the top is drawn by cUI_Mercenary (party panel, +// vtable 0x008948cc), not cUI_NetPortrait. +RE_HOOK(0x006d69f0, UiPortrait_HandleEvent); +RE_HOOK(0x006d6e70, UiPortrait_Render); + +int __fastcall UiTaskbar_HandleEvent(void* self, void* edx, void* event) { + mods::hud::EventOffset offset(event, mods::hud::OffsetFor(Anchor::Center)); + return UiTaskbar_HandleEvent_original(self, edx, event); +} + +void __fastcall UiTaskbar_Render(void* self, void* edx, void* device) { + mods::hud::DrawOffset offset(mods::hud::OffsetFor(Anchor::Center)); + UiTaskbar_Render_original(self, edx, device); +} + +int __fastcall UiPortrait_HandleEvent(void* self, void* edx, void* event) { + mods::hud::EventOffset offset(event, mods::hud::OffsetFor(Anchor::Right)); + return UiPortrait_HandleEvent_original(self, edx, event); +} + +void __fastcall UiPortrait_Render(void* self, void* edx, void* device) { + mods::hud::DrawOffset offset(mods::hud::OffsetFor(Anchor::Right)); + UiPortrait_Render_original(self, edx, device); +} + +} // namespace game diff --git a/src/mods/hud.h b/src/mods/hud.h new file mode 100644 index 0000000..20b87d0 --- /dev/null +++ b/src/mods/hud.h @@ -0,0 +1,8 @@ +#pragma once + +namespace mods::hud { + +// Installs the Direct3D device hooks the HUD layout needs (once per device). +void OnDevice(void* device); + +} // namespace mods::hud diff --git a/src/mods/widescreen.cpp b/src/mods/widescreen.cpp index 371cfb9..7861920 100644 --- a/src/mods/widescreen.cpp +++ b/src/mods/widescreen.cpp @@ -23,6 +23,8 @@ #include "mods/widescreen.h" +#include "mods/hud.h" + #include #include #include @@ -284,6 +286,10 @@ void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) { re::Log("widescreen: render size %dx%d, ortho x +-%.1f y +-%.1f", width, height, g_orthoRight, g_orthoTop); } +int RenderWidth() { + return g_renderWidth; +} + bool IsClassicFrame() { if (g_renderWidth == 0) { return false; @@ -317,6 +323,7 @@ void __fastcall DxDriver7_LoadingScreen(DxDriver7* self, void* edx, uint32_t pro // viewport (the borderless mod then presents just that area, scaled), and the // world the full render size. int __fastcall DxDriver7_BeginScene(DxDriver7* self, void* edx) { + mods::hud::OnDevice(self->device); if (g_renderWidth != 0 && self->sceneDepth == 0 && self->device) { const bool classic = IsClassicFrame(); Viewport7 viewport = {0, 0, classic ? 1024u : static_cast(g_renderWidth), diff --git a/src/mods/widescreen.h b/src/mods/widescreen.h index 15c277d..563783c 100644 --- a/src/mods/widescreen.h +++ b/src/mods/widescreen.h @@ -15,4 +15,7 @@ void ApplyRenderSize(game::DxDisplayMode& mode, HWND window); // menu, loading screen); the borderless mod then presents only that area. bool IsClassicFrame(); +// Applied render width (0 while the original 1024x768 is in use). +int RenderWidth(); + } // namespace mods::widescreen diff --git a/src/re/hook.cpp b/src/re/hook.cpp index 34ffa81..854bb50 100644 --- a/src/re/hook.cpp +++ b/src/re/hook.cpp @@ -59,4 +59,22 @@ int InstallHooks() { return failed; } +bool AttachRuntimeHook(const char* name, void** original, void* replacement) { + const void* target = *original; + DetourTransactionBegin(); + DetourUpdateThread(GetCurrentThread()); + LONG error = DetourAttach(original, replacement); + if (error == NO_ERROR) { + error = DetourTransactionCommit(); + } else { + DetourTransactionAbort(); + } + if (error != NO_ERROR) { + Log("hook %-24s %p FAILED (error %ld)", name, target, error); + return false; + } + Log("hook %-24s %p attached at runtime", name, target); + return true; +} + } // namespace re diff --git a/src/re/hook.h b/src/re/hook.h index 3b2a37b..b944c97 100644 --- a/src/re/hook.h +++ b/src/re/hook.h @@ -31,6 +31,11 @@ public: // number of hooks that failed to attach. int InstallHooks(); +// Hooks a function only known at runtime (e.g. a COM method read from a +// vtable). `*original` must hold the function's address; it receives the +// trampoline. Returns false (and logs) on failure. +bool AttachRuntimeHook(const char* name, void** original, void* replacement); + } // namespace re // Hooks a Windows API function for the whole process (game and DLLs alike). diff --git a/tools/autotest-load.ps1 b/tools/autotest-load.ps1 index 07c9e91..ca76ea4 100644 --- a/tools/autotest-load.ps1 +++ b/tools/autotest-load.ps1 @@ -10,7 +10,9 @@ param( [string]$Name = "autotest", [string]$Ini = "", - [int]$LoadSeconds = 30 + [int]$LoadSeconds = 30, + # Runs in-game after loading, before the screenshots (e.g. clicks). + [scriptblock]$InGame = $null ) $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "game-input.ps1") @@ -26,6 +28,7 @@ try { Click-At 1720 779 -AfterMs 4000 # main menu: "Spiel laden" Click-At 2382 568 -AfterMs 2000 # load screen: "Laden" (first save selected) Click-At 1551 826 -AfterMs ($LoadSeconds * 1000) # confirm: "Ok" + if ($InGame) { & $InGame } Save-Screen "screenshots\${Name}_ingame.png" | Out-Null Press-Key 0x1B -AfterMs 2500 # ESC menu Save-Screen "screenshots\${Name}_esc.png" | Out-Null