Widescreen: keep original size, extend view horizontally; fix player/camera

- Default render size is 768 high and as wide as the monitor aspect
  (1835x768 on 3440x1440), scaled up: everything keeps the size players
  know and the view only grows sideways. Height=0 keeps native 1:1.
- Ground: 818/888 clip limits, screen centre floats and integer
  immediates, ground quad list (capped at 1749) moved with the tile list.
- 3D: cGranny ortho (+-512 x +-384, incl. HUD items) scaled; inverse
  projection 0x623a20 (1/1024, 1/768) follows the render size, which fixes
  the camera following the player ("outside world") and the missing
  player / ESC menu.
- tools: game-input.ps1 + autotest-load.ps1 drive the game (load the first
  save game, screenshots in-game and with the ESC menu).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-14 01:48:08 +02:00
1 parent 795fe8f087
commit df31c9c105
6 files changed
+324 -45

No files matched your search

+25
View File
@@ -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
+58 -1
View File
@@ -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
+127 -44
View File
@@ -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() {
using namespace game::addr;
const uint32_t newOffset = kWorldViewSize;
const uint32_t newSize = kWorldViewSize + kTileListCapacity * kTileListEntrySize;
if (!re::PatchPushImm32(kWorldViewAllocSizePush, kWorldViewSize, newSize)) {
re::Log("widescreen: world view allocation not patched, tile list stays at 1750 entries");
return;
}
int patched = 0;
for (uint32_t instruction : kTileListUsers) {
// The displacement is kTileListOffset + field; find it in the instruction.
// 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<const uint8_t*>(static_cast<uintptr_t>(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;
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 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, lists keep their 1024x768 capacity");
return;
}
re::Log("widescreen: tile draw list moved to +0x%x (%u entries), %d/%d instructions patched", newOffset,
kTileListCapacity, patched, static_cast<int>(std::size(kTileListUsers)));
int patched = 0;
int total = 0;
for (uint32_t instruction : kTileListUsers) {
patched += MoveDisplacement(instruction, kTileListOffset, kTileListEntrySize, tileList);
++total;
}
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 <size_t N>
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<uint32_t>(width / 2));
patched += re::PatchInstructionDword(pair[1], 0x180, static_cast<uint32_t>(height / 2));
}
re::Log("widescreen: %d/%d screen centre immediates patched", patched,
static_cast<int>(std::size(game::addr::kCenterImmediates)) * 2);
}
uint32_t FloatBits(float value) {
return std::bit_cast<uint32_t>(value);
}
@@ -153,6 +206,21 @@ void PatchOrthoPushes(float right, float top) {
static_cast<int>(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<int>(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<int>(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<float>(width);
g_viewHeight = static_cast<float>(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;
+15
View File
@@ -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.
+36
View File
@@ -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 }
}
+63
View File
@@ -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
}