fix(tools): reject non-world camera captures

This commit is contained in:
2026-07-18 18:55:00 +04:00
parent fd9c04cade
commit e96444459b
2 changed files with 88 additions and 29 deletions
+26 -20
View File
@@ -1421,14 +1421,15 @@ proves ownership and the D3D7 boundary, while keeping the earlier
`CBufferingCamera` FOV=`1.04` as a distinct upstream interface value. `CBufferingCamera` FOV=`1.04` as a distinct upstream interface value.
The live GOG layout is now independently repeatable without invoking any game The live GOG layout is now independently repeatable without invoking any game
method: `Terrain.dll + 0x7355c` contains the current camera interface pointer, method: `Terrain.dll + 0x7355c` contains the current outer camera-object
whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Its selector-0 block pointer, whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Selector 0
starts with one 32-bit internal tag at interface `+28`; the actual sixteen returns the affine block beginning at outer `+0x20`; its sixteen row-major
row-major matrix words begin at `+32`. Captures for `RawCameraTransform` must words have translations at indices 3, 7 and 11. The public `LoadCamera`
therefore contain those sixteen payload words, not the tag. A passive AutoDemo interface at outer `+0x134` is a different view and must not be substituted
sample produced a finite affine camera matrix and passed through the offline for that block. Captures for `RawCameraTransform` therefore contain the sixteen
Vulkan adapter as source-world geometry, rather than relying on a guessed selector-0 words from outer `+0x20`. A passive AutoDemo sample produced a
identity view. 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 The Vulkan static path now accepts this recovered camera as
`VulkanStaticCamera`. It composes the row-major D3D7 result 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 `tools/capture-original-camera.ps1` makes that handoff repeatable for a live
GOG process. It finds the actual `Terrain.dll` base with Toolhelp module GOG process. It finds the actual `Terrain.dll` base with Toolhelp module
enumeration, opens the chosen PID with only enumeration, opens the chosen PID with only
`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, reads the camera global at its `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, verifies the expected outer
RVA and then exactly 64 matrix bytes at interface `+32`. It emits the 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 `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 to the process, send window messages, inject code, suspend threads, or invoke
an original method. PowerShell execution policy may require the explicit 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 --readback-out target\fparkan\autodemo-live-camera.raw
``` ```
On the unattended live AutoDemo the tool produced a new finite matrix and the On the unattended live AutoDemo the accepted world-space capture completed the
same all-root static bridge completed with 46 clip-visible vertices, 71 same all-root static bridge with 10,152 clip-visible vertices, 71 descriptors,
descriptors, a 7,372,800-byte format-50 readback, FNV-1a a 7,372,800-byte format-50 readback, and zero Vulkan validation
`11081931966175262997`, and zero Vulkan validation warnings/errors. A passive warnings/errors. A passive desktop-surface capture of that original window also
desktop-surface capture of that original window also proved that the selected proved that the selected instant contains the textured terrain, atmospheric
instant contains the textured terrain, atmospheric sky, HUD and weapon FX; sky, HUD and weapon FX; `PrintWindow` itself returns a black Direct3D buffer,
`PrintWindow` itself returns a black Direct3D buffer, so it must not be treated so it must not be treated as an original-frame oracle. The paired images expose
as an original-frame oracle. Neither screenshot establishes pixel parity: HUD, the next real renderer gap: sharing the raw pose and Ngi32 projection inputs is
FX, lighting, terrain layer composition and the remaining scene objects are not yet pixel parity, because the static bridge still lacks the full runtime
not yet represented by the static bridge. camera-selection timing, terrain composition, culling, lighting, UI and FX.
For visual regression work, `fparkan-game --backend static-vulkan` also accepts For visual regression work, `fparkan-game --backend static-vulkan` also accepts
`--readback-out <path>`. It writes the final synchronized Vulkan readback bytes `--readback-out <path>`. It writes the final synchronized Vulkan readback bytes
+62 -9
View File
@@ -11,7 +11,13 @@ param(
[ValidateRange(0.01, 100000.0)] [ValidateRange(0.01, 100000.0)]
[double]$FarPlane = 700.0, [double]$FarPlane = 700.0,
[ValidateRange(0.01, 3.13)] [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 Set-StrictMode -Version Latest
@@ -131,15 +137,60 @@ if ($process -eq [IntPtr]::Zero) {
} }
try { try {
# Terrain.dll image RVA 0x7355c -> active camera interface pointer. # Terrain.dll image RVA 0x7355c -> active 0x1a4-byte outer camera object.
# Selector 0's matrix is prefixed by a four-byte internal tag, so the # Its selector-0 affine block begins at outer + 0x20. Words 3/7/11 are
# 16 IEEE-754 row-major words begin at interface + 32, not + 28. # the proven world-space translation components. AutoDemo also briefly
$cameraPointerBytes = Read-OriginalBytes $process ($terrainBase + 0x7355c) 4 # selects normalized/reflection-like camera objects; a finite matrix alone
$cameraInterface = [BitConverter]::ToUInt32($cameraPointerBytes, 0) # is not sufficient evidence that it can project the mission world.
if ($cameraInterface -lt 0x10000) { $expectedOuterVtable = [uint32]($terrainBase + 0x665b4)
throw "Terrain camera pointer is unavailable or invalid: 0x$('{0:X8}' -f $cameraInterface)" $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++) { $words = for ($index = 0; $index -lt 16; $index++) {
[BitConverter]::ToUInt32($matrixBytes, $index * 4) [BitConverter]::ToUInt32($matrixBytes, $index * 4)
} }
@@ -148,7 +199,9 @@ try {
process_id = $ProcessId process_id = $ProcessId
terrain_module_base = ('0x{0:X8}' -f $terrainBase) terrain_module_base = ('0x{0:X8}' -f $terrainBase)
terrain_camera_global_rva = '0x7355c' terrain_camera_global_rva = '0x7355c'
terrain_camera_outer = ('0x{0:X8}' -f $cameraOuter)
selector0_words = @($words) selector0_words = @($words)
selector0_translation = @($translation)
# LegacyD3d7Projection carries a D3D7 RECT: left, top, right, bottom. # LegacyD3d7Projection carries a D3D7 RECT: left, top, right, bottom.
viewport = @(0, 0, $ViewportWidth, $ViewportHeight) viewport = @(0, 0, $ViewportWidth, $ViewportHeight)
near_plane = $NearPlane near_plane = $NearPlane