diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index a32d68c..25225d8 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1498,6 +1498,37 @@ errors. This validates the offline handoff for one captured instant; it does not claim live camera tracking, material parity, or an original-frame pixel comparison. +`tools/capture-original-camera.ps1` makes that handoff repeatable for a live +GOG process. It finds the actual `Terrain.dll` base with Toolhelp module +enumeration, opens the chosen PID with only +`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, reads the camera global at its +RVA and then exactly 64 matrix bytes at interface `+32`. It emits the +`fparkan-legacy-camera-v1` JSON accepted by `fparkan-game`; it does not write +to the process, send window messages, inject code, suspend threads, or invoke +an original method. PowerShell execution policy may require the explicit +one-shot bypass below; that changes neither the script nor the game. + +```powershell +$capture = powershell -NoProfile -ExecutionPolicy Bypass -File ` + .\tools\capture-original-camera.ps1 -ProcessId 5632 +$capture | Set-Content -NoNewline target\fparkan\live-camera.json +cargo run -q -p fparkan-game -- --root 'C:\GOG Games\Parkan - Iron Strategy' ` + --mission 'MISSIONS\Autodemo.00\data.tma' --backend static-vulkan --frames 3 ` + --legacy-camera-capture target\fparkan\live-camera.json ` + --readback-out target\fparkan\autodemo-live-camera.raw +``` + +On the unattended live AutoDemo the tool produced a new finite matrix and the +same all-root static bridge completed with 46 clip-visible vertices, 71 +descriptors, a 7,372,800-byte format-50 readback, FNV-1a +`11081931966175262997`, and zero Vulkan validation warnings/errors. A passive +desktop-surface capture of that original window also proved that the selected +instant contains the textured terrain, atmospheric sky, HUD and weapon FX; +`PrintWindow` itself returns a black Direct3D buffer, so it must not be treated +as an original-frame oracle. Neither screenshot establishes pixel parity: HUD, +FX, lighting, terrain layer composition and the remaining scene objects are +not yet represented by the static bridge. + For visual regression work, `fparkan-game --backend static-vulkan` also accepts `--readback-out `. It writes the final synchronized Vulkan readback bytes only after the renderer has completed its normal teardown evidence; the JSON diff --git a/tools/capture-original-camera.ps1 b/tools/capture-original-camera.ps1 new file mode 100644 index 0000000..62b5680 --- /dev/null +++ b/tools/capture-original-camera.ps1 @@ -0,0 +1,160 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [int]$ProcessId, + [ValidateRange(1, 16384)] + [int]$ViewportWidth = 1024, + [ValidateRange(1, 16384)] + [int]$ViewportHeight = 768, + [ValidateRange(0.001, 1000.0)] + [double]$NearPlane = 0.5, + [ValidateRange(0.01, 100000.0)] + [double]$FarPlane = 700.0, + [ValidateRange(0.01, 3.13)] + [double]$FieldOfViewRadians = 1.3 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# This observer deliberately opens only PROCESS_QUERY_INFORMATION | PROCESS_VM_READ. +# It neither sends input nor writes, suspends, injects into, or calls the original game. +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class FparkanOriginalCamera { + public const uint TH32CS_SNAPMODULE = 0x00000008; + public const uint TH32CS_SNAPMODULE32 = 0x00000010; + public const uint PROCESS_QUERY_INFORMATION = 0x00000400; + public const uint PROCESS_VM_READ = 0x00000010; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct MODULEENTRY32 { + public uint dwSize; + public uint th32ModuleID; + public uint th32ProcessID; + public uint GlblcntUsage; + public uint ProccntUsage; + public IntPtr modBaseAddr; + public uint modBaseSize; + public IntPtr hModule; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath; + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool ReadProcessMemory( + IntPtr process, + IntPtr address, + [Out] byte[] buffer, + IntPtr size, + out IntPtr bytesRead); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); +} +'@ + +function Get-LastWin32ErrorText { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + "$code ($([ComponentModel.Win32Exception]::new($code).Message))" +} + +function Read-OriginalBytes { + param( + [IntPtr]$Process, + [Int64]$Address, + [int]$Length + ) + $buffer = [byte[]]::new($Length) + $read = [IntPtr]::Zero + if (-not [FparkanOriginalCamera]::ReadProcessMemory( + $Process, + [IntPtr]$Address, + $buffer, + [IntPtr]$Length, + [ref]$read)) { + throw "ReadProcessMemory at 0x$('{0:X8}' -f $Address) failed: $(Get-LastWin32ErrorText)" + } + if ($read.ToInt64() -ne $Length) { + throw "ReadProcessMemory at 0x$('{0:X8}' -f $Address) returned $($read.ToInt64()) of $Length bytes" + } + $buffer +} + +$snapshot = [FparkanOriginalCamera]::CreateToolhelp32Snapshot( + [FparkanOriginalCamera]::TH32CS_SNAPMODULE -bor [FparkanOriginalCamera]::TH32CS_SNAPMODULE32, + [uint32]$ProcessId +) +if ($snapshot -eq [IntPtr]::Zero -or $snapshot.ToInt64() -eq -1) { + throw "CreateToolhelp32Snapshot failed: $(Get-LastWin32ErrorText)" +} + +$terrainBase = $null +try { + $module = [FparkanOriginalCamera+MODULEENTRY32]::new() + $module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanOriginalCamera+MODULEENTRY32]) + if (-not [FparkanOriginalCamera]::Module32First($snapshot, [ref]$module)) { + throw "Module32First failed: $(Get-LastWin32ErrorText)" + } + do { + if ($module.szModule -ieq 'Terrain.dll') { + $terrainBase = $module.modBaseAddr.ToInt64() + break + } + $module = [FparkanOriginalCamera+MODULEENTRY32]::new() + $module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanOriginalCamera+MODULEENTRY32]) + } while ([FparkanOriginalCamera]::Module32Next($snapshot, [ref]$module)) +} finally { + [void][FparkanOriginalCamera]::CloseHandle($snapshot) +} + +if ($null -eq $terrainBase) { + throw "Terrain.dll is not loaded by process $ProcessId" +} + +$process = [FparkanOriginalCamera]::OpenProcess( + [FparkanOriginalCamera]::PROCESS_QUERY_INFORMATION -bor [FparkanOriginalCamera]::PROCESS_VM_READ, + $false, + [uint32]$ProcessId +) +if ($process -eq [IntPtr]::Zero) { + throw "OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) failed: $(Get-LastWin32ErrorText)" +} + +try { + # Terrain.dll image RVA 0x7355c -> active camera interface pointer. + # Selector 0's matrix is prefixed by a four-byte internal tag, so the + # 16 IEEE-754 row-major words begin at interface + 32, not + 28. + $cameraPointerBytes = Read-OriginalBytes $process ($terrainBase + 0x7355c) 4 + $cameraInterface = [BitConverter]::ToUInt32($cameraPointerBytes, 0) + if ($cameraInterface -lt 0x10000) { + throw "Terrain camera pointer is unavailable or invalid: 0x$('{0:X8}' -f $cameraInterface)" + } + $matrixBytes = Read-OriginalBytes $process ([int64]$cameraInterface + 32) 64 + $words = for ($index = 0; $index -lt 16; $index++) { + [BitConverter]::ToUInt32($matrixBytes, $index * 4) + } + [ordered]@{ + schema = 'fparkan-legacy-camera-v1' + process_id = $ProcessId + terrain_module_base = ('0x{0:X8}' -f $terrainBase) + terrain_camera_global_rva = '0x7355c' + selector0_words = @($words) + # LegacyD3d7Projection carries a D3D7 RECT: left, top, right, bottom. + viewport = @(0, 0, $ViewportWidth, $ViewportHeight) + near_plane = $NearPlane + far_plane = $FarPlane + field_of_view_radians = $FieldOfViewRadians + } | ConvertTo-Json -Depth 4 -Compress +} finally { + [void][FparkanOriginalCamera]::CloseHandle($process) +}