diff --git a/docs/widescreen.md b/docs/widescreen.md index cf205da..49da91b 100644 --- a/docs/widescreen.md +++ b/docs/widescreen.md @@ -93,8 +93,33 @@ in fullscreen). - `0x0060e3f0` GDI BitBlt of a 1024x768 bitmap (startup/movie path) - `0x00627260` name/HP labels while Alt is held (no constants, uses view) +## Render size + +Default: 768 world pixels high (the original vertical view), width from the +monitor aspect (3440x1440 -> 1835x768), scaled to the monitor by the +borderless mod. Everything keeps the on-screen size players know; the view +only grows horizontally. `[widescreen] Height=0` renders natively 1:1 +(everything about half size on a 1440p monitor). + +## Things that had to follow the render size + +- view size / centre / clip floats and their integer immediates (world view) +- tile draw list and ground quad list (fixed 1750 / 1749 entries) moved into + an enlarged view object +- 3D ortho extents: world (+-267 x +-200, zoomed and unzoomed) and cGranny + (+-512 x +-384, incl. cUI_NetGranny for HUD items) +- the inverse projection `0x623a20` (screen -> view) uses 1/1024 and 1/768: + game logic (camera follow `0x44d9f0`) converts world -> screen -> view with + the device's current projection; unpatched it sent the camera off-world + ## Open questions +- Above ~2300 px render width (between 2304 and 2816) the player is not drawn + although monsters are (`cCreature::vf14` still called). Not hit at the + default size; some per-view limit of ~40 tile columns, not found yet. +- HUD: drawn in the top-left 1024x768 of the render target; 3D belt items + use the scaled HUD projection and sit off their slots. + - Activity radius: which creatures/objects are updated and animated (`cWorld` / `cSector`, `0x00603e90` = player?, `0x005fe060` = object by id). - Fixed-size per-view buffers (e.g. `+0x80e40`, 5 vectors at `+0x96ad0` with diff --git a/src/game/addresses.h b/src/game/addresses.h index d88539c..e6effa7 100644 --- a/src/game/addresses.h +++ b/src/game/addresses.h @@ -43,6 +43,42 @@ constexpr uint32_t kViewHeightUsers[] = { 0x0062ea5e, 0x0062f345, 0x0063068a, 0x00630f77, }; +// More screen constants the world view reads (float, `f... dword ptr [addr]`). +constexpr uint32_t kScreenHalfWidthF = 0x0088e6fc; // float 512.0 (screen centre x) +constexpr uint32_t kScreenHalfHeightF = 0x0088e9f0; // float 384.0 (screen centre y) +constexpr uint32_t kGroundClipBottomF = 0x0089011c; // float 818.0 (768 + 50) +constexpr uint32_t kClipBottom888F = 0x00890124; // float 888.0 (768 + 120) +constexpr uint32_t kViewHalfWidthUsers[] = { + 0x0062b485, 0x0062dc83, 0x0062e37c, 0x0062e960, 0x0062e989, 0x0062efd8, 0x0062eff2, + 0x0063058a, 0x006305b5, 0x00630c08, 0x00630c22, 0x00632032, 0x0063205c, +}; +constexpr uint32_t kViewHalfHeightUsers[] = { + 0x006252fe, 0x00625341, 0x0062b4be, 0x0062dcbe, 0x0062e390, 0x0062e996, 0x0062e9a2, 0x0062f002, + 0x0062f00e, 0x006305c2, 0x006305ce, 0x00630c2f, 0x00630c3b, 0x0063206a, 0x0063207a, +}; +// Outside the world view object, but drawing into the world: the zoom +// transform around the screen centre in FUN_0041eaf0. (The 512/1024 in +// cGranny::renderComplex case 9 are an effect animation, not the screen.) +constexpr uint32_t kCreatureHalfWidthUsers[] = {0x0041ed2b, 0x0041ed35}; +constexpr uint32_t kCreatureHalfHeightUsers[] = {0x0041ed41, 0x0041ed4b}; +// Precomputed reciprocals of the screen size. 0x623a20 (screen -> view, +// inverse of 0x623c40) uses 1/1024 and 1/768. Other users of these constants +// are texel/time maths. +constexpr uint32_t kScreenInvWidthF = 0x0088e710; // float 1/1024 +constexpr uint32_t kScreenInvHeightF = 0x0089005c; // float 1/768 +constexpr uint32_t kViewInvWidthUsers[] = {0x00623a49}; +constexpr uint32_t kViewInvHeightUsers[] = {0x00623a5f}; +constexpr uint32_t kGroundClipBottomUsers[] = {0x0062b464}; +constexpr uint32_t kClipBottom888Users[] = {0x0062dc62}; + +// Screen centre as integer immediates (`add reg, 200h` / `mov reg, 200h` and +// the matching 180h), in pairs x, y. +constexpr uint32_t kCenterImmediates[][2] = { + {0x0062b4c6, 0x0062b4e9}, {0x0062cc40, 0x0062cc50}, {0x0062dcc6, 0x0062dce1}, + {0x0062e398, 0x0062e3a9}, {0x0062f967, 0x0062f96d}, {0x0062fe60, 0x0062fe7d}, + {0x006315aa, 0x006315b0}, {0x00631aca, 0x00631ae7}, {0x00631c53, 0x00631c70}, +}; + // cWorldView0 is allocated with `push 970F4h; call operator new` here. constexpr uint32_t kWorldViewAllocSizePush = 0x006101aa; constexpr uint32_t kWorldViewSize = 0x970f4; @@ -53,11 +89,22 @@ constexpr uint32_t kWorldViewSize = 0x970f4; constexpr uint32_t kTileListOffset = 0x3ff2c; constexpr uint32_t kTileListEntrySize = 0x98; constexpr uint32_t kTileListUsers[] = { - 0x0062b6d9, 0x0062b6ec, 0x0062b709, 0x0062b730, 0x0062b74b, 0x0062b763, // writer, row renderer 0x62b000 + 0x0062b6d9, 0x0062b6ec, 0x0062b709, 0x0062b730, 0x0062b74b, 0x0062b763, // writer, row renderer FUN_0062b000 0x0062b777, 0x0062b797, 0x0062b7b7, 0x0062b7d7, 0x0062df47, 0x0062e252, // readers (lea base pointer) }; +// cWorldView0's ground quad list: 0x90-byte entries at +0xb64 plus a parallel +// int array at +0x3e3c4, count at +0x3ff1c, capped at 0x6d5 (1749): beyond +// that the last quad is overwritten, so the ground stops drawing. +constexpr uint32_t kGroundQuadOffset = 0xb64; +constexpr uint32_t kGroundQuadEntrySize = 0x90; +constexpr uint32_t kGroundQuadIndexOffset = 0x3e3c4; +constexpr uint32_t kGroundQuadCap = 0x6d5; +constexpr uint32_t kGroundQuadCapCompare = 0x0062b603; // cmp eax, 6D5h +constexpr uint32_t kGroundQuadUsers[] = {0x0062b5e2, 0x0062d58e}; // lea [..+0B64h] / [..+0B7Ch] +constexpr uint32_t kGroundQuadIndexUsers[] = {0x0062b5f6, 0x0062d588}; // [..+3E3C4h] + // Ortho projection of the 3D models: x -267..267, y -200..200 world units // span the 1024x768 screen (times zoom). Doubles, read with `fmul qword ptr`. constexpr uint32_t kOrthoLeftD = 0x0088ee40; // double -267.0 @@ -68,6 +115,7 @@ constexpr uint32_t kOrthoTopD = 0x0088ee58; // double 200.0 // World view instructions `fmul st, qword ptr [kOrtho*D]` (zoomed projection), // four per site, in the order top, bottom, right, left. constexpr uint32_t kOrthoZoomedUsers[][4] = { + {0x004b19c1, 0x004b19dc, 0x004b19ed, 0x004b19fe}, // FUN_004b1740, outside the view class {0x00624fdb, 0x00624ff3, 0x00625001, 0x0062500f}, {0x00628355, 0x0062836d, 0x0062837b, 0x00628389}, {0x0062852f, 0x00628547, 0x00628555, 0x00628563}, @@ -80,4 +128,13 @@ constexpr uint32_t kOrthoPushSequences[] = { 0x0060c1f7, 0x0060da54, 0x00625039, 0x006283b3, 0x0062858d, 0x00628d3b, }; +// cGranny::render variants (3D creatures) project in screen pixels with an +// ortho camera of x -512..512, y -384..384: 4 consecutive `push imm32` in the +// order top (384), bottom (-384), right (512), left (-512). +// The last one is cUI_NetGranny (3D items in the HUD, e.g. the belt). It must +// be scaled too: game logic (0x44d9f0, camera target) converts world to screen +// with whatever projection the device has, which is usually the HUD's, and +// uses the (redirected) view width with it. +constexpr uint32_t kGrannyOrthoPushSequences[] = {0x0040ebc9, 0x0040f5cf, 0x0040feff, 0x006f1ecb}; + } // namespace game::addr diff --git a/src/mods/widescreen.cpp b/src/mods/widescreen.cpp index a714b97..26b9465 100644 --- a/src/mods/widescreen.cpp +++ b/src/mods/widescreen.cpp @@ -11,8 +11,9 @@ // sacred_re.ini: // [widescreen] // Enabled=1 -// Width=0 ; 0 = monitor width -// Height=0 ; 0 = monitor height +// Height=768 ; world pixels visible vertically (768 = original look); +// ; 0 = monitor height, i.e. native 1:1 (everything smaller) +// Width=0 ; 0 = Height * monitor aspect ratio // // Windows' Direct3D 7 limits the render size to 2048x2048 unless the dxwrapper // mod (Direct3D 9) is active; the borderless mod centres a smaller image 1:1. @@ -75,63 +76,115 @@ bool Enabled() { return re::ConfigInt("widescreen", "Enabled", 1) != 0; } -// The world view's tile draw list only holds ~1750 entries (enough for -// 1024x768, unchecked). Allocate the view larger and move the list to the new -// space at its end, by rewriting the field displacements of every instruction -// that addresses it. -constexpr uint32_t kTileListCapacity = 16384; +// The world view keeps two fixed-size per-frame lists sized for 1024x768: +// the tile draw list (~1750 entries, unchecked) and the ground quad list +// (1749, capped: the ground just stops). Allocate the view larger and move +// both lists into the new space at its end by rewriting the field +// displacements of every instruction that addresses them. +constexpr uint32_t kListCapacity = 16384; -void RelocateTileList() { +// Rewrites the displacement `oldBase + field` in `instruction` to +// `newBase + field`, for any field in [0, fieldRange). +bool MoveDisplacement(uint32_t instruction, uint32_t oldBase, uint32_t fieldRange, uint32_t newBase) { + const auto* code = reinterpret_cast(static_cast(instruction)); + for (int offset = 1; offset <= 7; ++offset) { + uint32_t value; + memcpy(&value, code + offset, 4); + if (value >= oldBase && value < oldBase + fieldRange) { + return re::PatchInstructionDword(instruction, value, newBase + (value - oldBase)); + } + } + re::Log("widescreen: no displacement 0x%x.. in instruction 0x%08x", oldBase, instruction); + return false; +} + +void RelocateViewLists() { using namespace game::addr; - const uint32_t newOffset = kWorldViewSize; - const uint32_t newSize = kWorldViewSize + kTileListCapacity * kTileListEntrySize; + const uint32_t tileList = kWorldViewSize; + const uint32_t groundQuads = tileList + kListCapacity * kTileListEntrySize; + const uint32_t groundIndices = groundQuads + kListCapacity * kGroundQuadEntrySize; + const uint32_t newSize = groundIndices + kListCapacity * 4; if (!re::PatchPushImm32(kWorldViewAllocSizePush, kWorldViewSize, newSize)) { - re::Log("widescreen: world view allocation not patched, tile list stays at 1750 entries"); + re::Log("widescreen: world view allocation not patched, lists keep their 1024x768 capacity"); return; } int patched = 0; + int total = 0; for (uint32_t instruction : kTileListUsers) { - // The displacement is kTileListOffset + field; find it in the instruction. - const auto* code = reinterpret_cast(static_cast(instruction)); - for (int offset = 1; offset <= 7; ++offset) { - uint32_t value; - memcpy(&value, code + offset, 4); - if (value >= kTileListOffset && value < kTileListOffset + kTileListEntrySize) { - patched += re::PatchInstructionDword(instruction, value, newOffset + (value - kTileListOffset)); - break; - } - } + patched += MoveDisplacement(instruction, kTileListOffset, kTileListEntrySize, tileList); + ++total; } - re::Log("widescreen: tile draw list moved to +0x%x (%u entries), %d/%d instructions patched", newOffset, - kTileListCapacity, patched, static_cast(std::size(kTileListUsers))); + for (uint32_t instruction : kGroundQuadUsers) { + patched += MoveDisplacement(instruction, kGroundQuadOffset, kGroundQuadEntrySize, groundQuads); + ++total; + } + for (uint32_t instruction : kGroundQuadIndexUsers) { + patched += MoveDisplacement(instruction, kGroundQuadIndexOffset, 4, groundIndices); + ++total; + } + patched += re::PatchInstructionDword(kGroundQuadCapCompare, kGroundQuadCap, kListCapacity - 1); + ++total; + re::Log("widescreen: view lists moved (%u entries each, view object 0x%x bytes), %d/%d instructions patched", + kListCapacity, newSize, patched, total); +} + +// Screen size-dependent floats read by the patched instructions. +float g_viewHalfWidth = kOriginalWidth / 2; +float g_viewHalfHeight = kOriginalHeight / 2; +float g_groundClipBottom = kOriginalHeight + 50; +float g_clipBottom888 = kOriginalHeight + 120; +float g_viewInvWidth = 1.0f / kOriginalWidth; +float g_viewInvHeight = 1.0f / kOriginalHeight; + +template +int Redirect(const uint32_t (&instructions)[N], uint32_t from, const void* to, int& total) { + int patched = 0; + for (uint32_t instruction : instructions) { + patched += re::RedirectOperand(instruction, from, to); + ++total; + } + return patched; } void RedirectWorldView() { if (!Enabled()) { return; } - int patched = 0; + using namespace game::addr; int total = 0; - for (uint32_t instruction : game::addr::kViewWidthUsers) { - patched += re::RedirectOperand(instruction, game::addr::kScreenWidthF, &g_viewWidth); - ++total; - } - for (uint32_t instruction : game::addr::kViewHeightUsers) { - patched += re::RedirectOperand(instruction, game::addr::kScreenHeightF, &g_viewHeight); - ++total; - } - for (const auto& site : game::addr::kOrthoZoomedUsers) { - patched += re::RedirectOperand(site[0], game::addr::kOrthoTopD, &g_orthoTop); - patched += re::RedirectOperand(site[1], game::addr::kOrthoBottomD, &g_orthoBottom); - patched += re::RedirectOperand(site[2], game::addr::kOrthoRightD, &g_orthoRight); - patched += re::RedirectOperand(site[3], game::addr::kOrthoLeftD, &g_orthoLeft); + int patched = Redirect(kViewWidthUsers, kScreenWidthF, &g_viewWidth, total); + patched += Redirect(kViewHeightUsers, kScreenHeightF, &g_viewHeight, total); + patched += Redirect(kViewHalfWidthUsers, kScreenHalfWidthF, &g_viewHalfWidth, total); + patched += Redirect(kViewHalfHeightUsers, kScreenHalfHeightF, &g_viewHalfHeight, total); + patched += Redirect(kCreatureHalfWidthUsers, kScreenHalfWidthF, &g_viewHalfWidth, total); + patched += Redirect(kCreatureHalfHeightUsers, kScreenHalfHeightF, &g_viewHalfHeight, total); + patched += Redirect(kViewInvWidthUsers, kScreenInvWidthF, &g_viewInvWidth, total); + patched += Redirect(kViewInvHeightUsers, kScreenInvHeightF, &g_viewInvHeight, total); + patched += Redirect(kGroundClipBottomUsers, kGroundClipBottomF, &g_groundClipBottom, total); + patched += Redirect(kClipBottom888Users, kClipBottom888F, &g_clipBottom888, total); + for (const auto& site : kOrthoZoomedUsers) { + patched += re::RedirectOperand(site[0], kOrthoTopD, &g_orthoTop); + patched += re::RedirectOperand(site[1], kOrthoBottomD, &g_orthoBottom); + patched += re::RedirectOperand(site[2], kOrthoRightD, &g_orthoRight); + patched += re::RedirectOperand(site[3], kOrthoLeftD, &g_orthoLeft); total += 4; } re::Log("widescreen: %d/%d world view operands redirected", patched, total); - RelocateTileList(); + RelocateViewLists(); } RE_STARTUP(WidescreenRedirect, RedirectWorldView); +// The screen centre also appears as integer immediates (512 / 384). +void PatchCenterImmediates(int width, int height) { + int patched = 0; + for (const auto& pair : game::addr::kCenterImmediates) { + patched += re::PatchInstructionDword(pair[0], 0x200, static_cast(width / 2)); + patched += re::PatchInstructionDword(pair[1], 0x180, static_cast(height / 2)); + } + re::Log("widescreen: %d/%d screen centre immediates patched", patched, + static_cast(std::size(game::addr::kCenterImmediates)) * 2); +} + uint32_t FloatBits(float value) { return std::bit_cast(value); } @@ -153,6 +206,21 @@ void PatchOrthoPushes(float right, float top) { static_cast(std::size(game::addr::kOrthoPushSequences)) * 4); } +// 3D creatures (cGranny) are projected 1:1 in screen pixels around the centre. +void PatchGrannyOrthoPushes(int width, int height) { + const float right = width / 2.0f; + const float top = height / 2.0f; + int patched = 0; + for (uint32_t site : game::addr::kGrannyOrthoPushSequences) { + patched += re::PatchPushImm32(site + 0, FloatBits(384.0f), FloatBits(top)); + patched += re::PatchPushImm32(site + 5, FloatBits(-384.0f), FloatBits(-top)); + patched += re::PatchPushImm32(site + 10, FloatBits(512.0f), FloatBits(right)); + patched += re::PatchPushImm32(site + 15, FloatBits(-512.0f), FloatBits(-right)); + } + re::Log("widescreen: %d/%d creature projection immediates patched", patched, + static_cast(std::size(game::addr::kGrannyOrthoPushSequences)) * 4); +} + } // namespace void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) { @@ -161,13 +229,18 @@ void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) { } MONITORINFO monitor = {sizeof(monitor)}; GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTOPRIMARY), &monitor); - int width = re::ConfigInt("widescreen", "Width", 0); - int height = re::ConfigInt("widescreen", "Height", 0); - if (width <= 0) { - width = monitor.rcMonitor.right - monitor.rcMonitor.left; - } + // Default: keep the original 768 pixels of world height (so everything + // keeps its on-screen size once scaled to the monitor) and extend the width + // to the monitor's aspect ratio. Height=0 renders at the monitor height 1:1. + const int monitorWidth = monitor.rcMonitor.right - monitor.rcMonitor.left; + const int monitorHeight = monitor.rcMonitor.bottom - monitor.rcMonitor.top; + int height = re::ConfigInt("widescreen", "Height", static_cast(kOriginalHeight)); if (height <= 0) { - height = monitor.rcMonitor.bottom - monitor.rcMonitor.top; + height = monitorHeight; + } + int width = re::ConfigInt("widescreen", "Width", 0); + if (width <= 0) { + width = MulDiv(height, monitorWidth, monitorHeight); } // Windows' Direct3D 7 runtime cannot create a device for a render target // wider or taller than 2048 pixels (CreateDevice fails with @@ -189,6 +262,16 @@ void ApplyRenderSize(game::DxDisplayMode& mode, HWND window) { g_viewWidth = static_cast(width); g_viewHeight = static_cast(height); + g_viewHalfWidth = width / 2.0f; + g_viewHalfHeight = height / 2.0f; + g_groundClipBottom = height + 50.0f; + g_clipBottom888 = height + 120.0f; + g_viewInvWidth = 1.0f / width; + g_viewInvHeight = 1.0f / height; + if (!g_pushesPatched) { + PatchCenterImmediates(width, height); + PatchGrannyOrthoPushes(width, height); + } const double scaleX = width / kOriginalWidth; const double scaleY = height / kOriginalHeight; g_orthoRight = kOriginalOrthoX * scaleX; diff --git a/tools/README.md b/tools/README.md index c5c523e..43f8989 100644 --- a/tools/README.md +++ b/tools/README.md @@ -23,3 +23,18 @@ $env:GHIDRA_HEADLESS_MAXMEM = "8G" `ApplyRtti.java` recovers the MSVC 6 RTTI that Ghidra's own script misses: 410 vtables, ~1200 extra functions, methods named `Class::vfNN`. + +## Automated in-game test + +`autotest-load.ps1` starts the game through the launcher, clicks +"Spiel laden" -> "Laden" -> "Ok" (first save game), takes screenshots in-game +and with the ESC menu open, and stops the game. `-Ini` appends temporary +settings to `build\x86-debug\sacred_re.ini` (restored afterwards), e.g. + +```powershell +.\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0" +``` + +Click positions assume the classic menu layout scaled onto a 3440x1440 +monitor. `game-input.ps1` has the building blocks (start, click, key, +screenshot). Don't touch mouse/keyboard while a test runs. diff --git a/tools/autotest-load.ps1 b/tools/autotest-load.ps1 new file mode 100644 index 0000000..07c9e91 --- /dev/null +++ b/tools/autotest-load.ps1 @@ -0,0 +1,36 @@ +# Automated smoke test: start the game, load the first save game, take +# screenshots in-game and with the ESC menu open, then stop the game. +# +# .\tools\autotest-load.ps1 -Name ws_on # current sacred_re.ini +# .\tools\autotest-load.ps1 -Name ws_off -Ini "[widescreen]`r`nEnabled=0" +# +# -Ini temporarily appends settings to build\x86-debug\sacred_re.ini. +# Click positions are for the classic (menu) layout scaled onto a 3440x1440 +# monitor; adjust for other monitors. Don't touch mouse/keyboard while it runs. +param( + [string]$Name = "autotest", + [string]$Ini = "", + [int]$LoadSeconds = 30 +) +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "game-input.ps1") + +$root = Split-Path $PSScriptRoot +$iniPath = Join-Path $root "build\x86-debug\sacred_re.ini" +$iniBackup = if (Test-Path $iniPath) { Get-Content $iniPath -Raw } else { "" } +if ($Ini) { Set-Content $iniPath ($iniBackup + "`r`n" + $Ini + "`r`n") -Encoding ASCII } + +$game = $null +try { + $game = Start-Sacred + 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" + Save-Screen "screenshots\${Name}_ingame.png" | Out-Null + Press-Key 0x1B -AfterMs 2500 # ESC menu + Save-Screen "screenshots\${Name}_esc.png" | Out-Null + "game alive at end: $(-not $game.HasExited)" +} finally { + Stop-Sacred $game + if ($Ini) { Set-Content $iniPath $iniBackup -Encoding ASCII -NoNewline } +} diff --git a/tools/game-input.ps1 b/tools/game-input.ps1 new file mode 100644 index 0000000..64ee291 --- /dev/null +++ b/tools/game-input.ps1 @@ -0,0 +1,63 @@ +# Drive the game for automated tests: start it, click, press keys, take +# screenshots. Dot-source it: . .\tools\game-input.ps1 +# +# Start-Sacred # launcher, returns the sacred.exe process +# Click-At 1720 779 # screen pixels (left click) +# Press-Key 0x1B # virtual key (ESC) +# Save-Screen screenshots\x.png # primary monitor area of the game +# Stop-Sacred $game # only the given process +# +# Input goes to the foreground window: don't touch mouse/keyboard meanwhile. + +Add-Type -AssemblyName System.Drawing +Add-Type -Namespace SacredRe -Name Input -MemberDefinition @' +[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); +[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, System.UIntPtr extra); +[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, System.UIntPtr extra); +[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr window); +'@ + +$script:Root = Split-Path $PSScriptRoot + +function Start-Sacred([string]$Arguments = "log", [int]$WaitSeconds = 18) { + if (Get-Process sacred -ErrorAction SilentlyContinue) { throw "sacred.exe is already running" } + $null = Start-Process (Join-Path $script:Root "build\x86-debug\sacred_re.exe") -ArgumentList $Arguments + Start-Sleep $WaitSeconds + $game = Get-Process sacred -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $game) { throw "sacred.exe did not start (or crashed)" } + [SacredRe.Input]::SetForegroundWindow($game.MainWindowHandle) | Out-Null + $game +} + +function Stop-Sacred($Game) { + if ($Game -and -not $Game.HasExited) { + Stop-Process -Id $Game.Id -Force + $Game.WaitForExit(10000) | Out-Null + } +} + +function Click-At([int]$X, [int]$Y, [int]$AfterMs = 800) { + [SacredRe.Input]::SetCursorPos($X, $Y) | Out-Null + Start-Sleep -Milliseconds 150 + [SacredRe.Input]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # left down + Start-Sleep -Milliseconds 80 + [SacredRe.Input]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # left up + Start-Sleep -Milliseconds $AfterMs +} + +function Press-Key([byte]$VirtualKey, [int]$AfterMs = 800) { + [SacredRe.Input]::keybd_event($VirtualKey, 0, 0, [UIntPtr]::Zero) + Start-Sleep -Milliseconds 80 + [SacredRe.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero) # key up + Start-Sleep -Milliseconds $AfterMs +} + +function Save-Screen([string]$Path, [int]$Width = 3440, [int]$Height = 1440) { + $bitmap = New-Object System.Drawing.Bitmap $Width, $Height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen(0, 0, 0, 0, $bitmap.Size) + $full = if ([IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $script:Root $Path } + $bitmap.Save($full) + $graphics.Dispose(); $bitmap.Dispose() + $full +}