From e96444459b9bcaa8eb055c89c82897494769307c Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 18:55:00 +0400 Subject: [PATCH] fix(tools): reject non-world camera captures --- docs/tomes/05-render.md | 46 +++++++++++--------- tools/capture-original-camera.ps1 | 71 +++++++++++++++++++++++++++---- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 25225d8..520b137 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1421,14 +1421,15 @@ proves ownership and the D3D7 boundary, while keeping the earlier `CBufferingCamera` FOV=`1.04` as a distinct upstream interface value. The live GOG layout is now independently repeatable without invoking any game -method: `Terrain.dll + 0x7355c` contains the current camera interface pointer, -whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Its selector-0 block -starts with one 32-bit internal tag at interface `+28`; the actual sixteen -row-major matrix words begin at `+32`. Captures for `RawCameraTransform` must -therefore contain those sixteen payload words, not the tag. A passive AutoDemo -sample produced a finite affine camera matrix and passed through the offline -Vulkan adapter as source-world geometry, rather than relying on a guessed -identity view. +method: `Terrain.dll + 0x7355c` contains the current outer camera-object +pointer, whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Selector 0 +returns the affine block beginning at outer `+0x20`; its sixteen row-major +words have translations at indices 3, 7 and 11. The public `LoadCamera` +interface at outer `+0x134` is a different view and must not be substituted +for that block. Captures for `RawCameraTransform` therefore contain the sixteen +selector-0 words from outer `+0x20`. A passive AutoDemo sample produced a +finite affine camera matrix and passed through the offline Vulkan adapter as +source-world geometry, rather than relying on a guessed identity view. The Vulkan static path now accepts this recovered camera as `VulkanStaticCamera`. It composes the row-major D3D7 result as @@ -1501,8 +1502,13 @@ 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 +`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, verifies the expected outer +vtable, and reads exactly 64 selector-0 bytes at outer `+0x20`. AutoDemo can +briefly select a normalized/reflection-like camera object whose finite +translation is near the origin. The tool therefore re-reads the global on +each attempt and accepts a sample only when the proven translation length +exceeds its explicit `-MinimumWorldTranslation` threshold (default `100`); +the JSON retains the accepted outer pointer and translation for audit. 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 @@ -1518,16 +1524,16 @@ cargo run -q -p fparkan-game -- --root 'C:\GOG Games\Parkan - Iron Strategy' ` --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. +On the unattended live AutoDemo the accepted world-space capture completed the +same all-root static bridge with 10,152 clip-visible vertices, 71 descriptors, +a 7,372,800-byte format-50 readback, 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. The paired images expose +the next real renderer gap: sharing the raw pose and Ngi32 projection inputs is +not yet pixel parity, because the static bridge still lacks the full runtime +camera-selection timing, terrain composition, culling, lighting, UI and FX. For visual regression work, `fparkan-game --backend static-vulkan` also accepts `--readback-out `. It writes the final synchronized Vulkan readback bytes diff --git a/tools/capture-original-camera.ps1 b/tools/capture-original-camera.ps1 index 62b5680..9e95d91 100644 --- a/tools/capture-original-camera.ps1 +++ b/tools/capture-original-camera.ps1 @@ -11,7 +11,13 @@ param( [ValidateRange(0.01, 100000.0)] [double]$FarPlane = 700.0, [ValidateRange(0.01, 3.13)] - [double]$FieldOfViewRadians = 1.3 + [double]$FieldOfViewRadians = 1.3, + [ValidateRange(1, 600)] + [int]$CaptureAttempts = 60, + [ValidateRange(0, 1000000.0)] + [double]$MinimumWorldTranslation = 100.0, + [ValidateRange(0, 1000)] + [int]$RetryIntervalMilliseconds = 100 ) Set-StrictMode -Version Latest @@ -131,15 +137,60 @@ if ($process -eq [IntPtr]::Zero) { } 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)" + # Terrain.dll image RVA 0x7355c -> active 0x1a4-byte outer camera object. + # Its selector-0 affine block begins at outer + 0x20. Words 3/7/11 are + # the proven world-space translation components. AutoDemo also briefly + # selects normalized/reflection-like camera objects; a finite matrix alone + # is not sufficient evidence that it can project the mission world. + $expectedOuterVtable = [uint32]($terrainBase + 0x665b4) + $matrixBytes = $null + $translation = $null + $cameraOuter = $null + $lastObservation = 'camera pointer unavailable' + for ($attempt = 1; $attempt -le $CaptureAttempts; $attempt++) { + $candidateOuter = [BitConverter]::ToUInt32( + (Read-OriginalBytes $process ($terrainBase + 0x7355c) 4), + 0 + ) + if ($candidateOuter -lt 0x10000) { + $lastObservation = "camera pointer 0x$('{0:X8}' -f $candidateOuter)" + continue + } + $outerVtable = [BitConverter]::ToUInt32( + (Read-OriginalBytes $process ([int64]$candidateOuter) 4), + 0 + ) + if ($outerVtable -ne $expectedOuterVtable) { + $lastObservation = "outer vtable 0x$('{0:X8}' -f $outerVtable)" + continue + } + $matrixBytes = Read-OriginalBytes $process ([int64]$candidateOuter + 0x20) 64 + $candidateTranslation = @( + [BitConverter]::ToSingle($matrixBytes, 12), + [BitConverter]::ToSingle($matrixBytes, 28), + [BitConverter]::ToSingle($matrixBytes, 44) + ) + $nonFinite = @($candidateTranslation | Where-Object { + [Single]::IsNaN($_) -or [Single]::IsInfinity($_) + }) + $length = [Math]::Sqrt( + [double]$candidateTranslation[0] * $candidateTranslation[0] + + [double]$candidateTranslation[1] * $candidateTranslation[1] + + [double]$candidateTranslation[2] * $candidateTranslation[2] + ) + if ($nonFinite.Count -eq 0 -and $length -ge $MinimumWorldTranslation) { + $cameraOuter = $candidateOuter + $translation = $candidateTranslation + break + } + $lastObservation = "translation length $length at 0x$('{0:X8}' -f $candidateOuter)" + if ($attempt -lt $CaptureAttempts -and $RetryIntervalMilliseconds -gt 0) { + Start-Sleep -Milliseconds $RetryIntervalMilliseconds + } + } + if ($null -eq $translation) { + throw "No world-space selector-0 transform after $CaptureAttempts samples (minimum translation length $MinimumWorldTranslation; last observation: $lastObservation)" } - $matrixBytes = Read-OriginalBytes $process ([int64]$cameraInterface + 32) 64 $words = for ($index = 0; $index -lt 16; $index++) { [BitConverter]::ToUInt32($matrixBytes, $index * 4) } @@ -148,7 +199,9 @@ try { process_id = $ProcessId terrain_module_base = ('0x{0:X8}' -f $terrainBase) terrain_camera_global_rva = '0x7355c' + terrain_camera_outer = ('0x{0:X8}' -f $cameraOuter) selector0_words = @($words) + selector0_translation = @($translation) # LegacyD3d7Projection carries a D3D7 RECT: left, top, right, bottom. viewport = @(0, 0, $ViewportWidth, $ViewportHeight) near_plane = $NearPlane