Fix terrain height and native frame capture
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s

Use raw world-space terrain heights throughout mesh, camera-floor, shadow, and sun-ray paths while preserving the projection far-plane conversion. Complete the verified selected-camera readback flow with frozen phase sampling, capture tooling, and documentation.
This commit is contained in:
2026-10-11 17:37:48 +04:00
parent 7b7324a413
commit 100f656b08
9 changed files with 5409 additions and 196 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
# Native frame capture
This x86 helper reads one live frame from the isolated GOG-compatible scratch copy of `Parkan - Iron Strategy`. It writes a PNG and a `fparkan-legacy-camera-v1` JSON file. The 64-byte camera matrix is sampled at the World3D render entry and reread at the projection-building boundary; only a same-thread, byte-compared snapshot paired with perspective values can produce usable renderer input. A passive `iron3d.dll` callsite probe records a path only when the active call arguments and path string are verified. It also samples `Terrain+0x421DC` after verifying the GOG module hash and the `EBP - [ESI+0x144]`, remainder by `[ESI+0x150]` instruction bytes. `atmosphere_seconds` is emitted only if the sampled `EDX` phase matches that arithmetic, the raw clock matches `World3D+0x32A38`, and sample, camera, and projection share the same render thread and generation. Otherwise it is `null`. This phase does not establish a general simulation-time, weather, or RNG value.
The capture helper expects an isolated copy at `target\shadow-probe\Parkan - Iron Strategy`; the original GOG installation stays untouched. For the default local install path, create that scratch copy from the repository root with:
```powershell
$originalGame = 'C:\GOG Games\Parkan - Iron Strategy'
$scratchGame = 'target\shadow-probe\Parkan - Iron Strategy'
New-Item -ItemType Directory -Force -Path (Split-Path $scratchGame) | Out-Null
robocopy $originalGame $scratchGame /E /COPY:DAT /R:1 /W:1
if ($LASTEXITCODE -ge 8) { throw 'Scratch copy failed; inspect robocopy output.' }
```
Build the viewer from the repository root with `cargo build --release -p fparkan-game`. No package installation is needed.
Build and run the ABI self-check from the repository root:
```powershell
& 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe' /nologo /platform:x86 /r:System.Drawing.dll /r:System.Web.Extensions.dll /out:target\native-frame-capture.exe tools\native-frame-capture\NativeFrameCapture.cs tools\native-frame-capture\NativeFrameCapture.Readback.cs
if ($LASTEXITCODE -ne 0) { throw 'Native capture helper build failed; inspect compiler output.' }
& 'target\native-frame-capture.exe' --self-check
if ($LASTEXITCODE -ne 0) { throw 'Native capture helper self-check failed; inspect its output.' }
```
Start the scratch executable from its own directory, then pass its PID and UTC start ticks to the helper. Keep the game visible while it waits; the helper does not synthesize input. It waits up to five minutes for a live world to reach the verified camera, projection, and Present boundaries. The default startup can remain at the shell until a mission begins, so a timeout is not a successful capture. The helper rejects other executable paths, stale PIDs, and mismatched game DLLs. Output must remain under `target`.
```powershell
$game = (Resolve-Path 'target\shadow-probe\Parkan - Iron Strategy').Path
$process = Start-Process -FilePath (Join-Path $game 'iron_3d.exe') -WorkingDirectory $game -PassThru
& 'target\native-frame-capture.exe' --capture $process.Id $process.StartTime.ToUniversalTime().Ticks target\native-frame-capture\frame.json
if ($LASTEXITCODE -ne 0) { throw 'Native frame capture failed; inspect its output and choose fresh output paths before retrying.' }
```
After a passive capture succeeds, its JSON can serve as the optional selected-camera input. The following example uses the matrix and projection values from `frame.json` and writes a new basename:
```powershell
$cameraInput = 'target\native-frame-capture\frame.json'
$selectedFrame = 'target\native-frame-capture\selected-frame.json'
& 'target\native-frame-capture.exe' --validate-camera-input $cameraInput
if ($LASTEXITCODE -ne 0) { throw 'Camera JSON is not a usable fparkan-legacy-camera-v1 input.' }
& 'target\native-frame-capture.exe' --capture $process.Id $process.StartTime.ToUniversalTime().Ticks $selectedFrame --camera-input $cameraInput
if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its log before retrying.' }
```
`--validate-camera-input` parses and checks the JSON before opening a game process. The input must contain a valid `fparkan-legacy-camera-v1` matrix and projection values. At runtime the helper first observes an unmodified camera render and requires its viewport, near/far planes, and FOV to match the input. It applies only the 64-byte camera matrix to that same camera on the next same-thread render and reads the resulting surface at the verified Present boundary while the selected matrix is still active. At the projection boundary it verifies that the active matrix still exactly matches the requested input, as well as the projection values. It then restores the original matrix through the verified setter at that render invocation's return boundary. Pixels remain in memory until the original Present context, camera matrix, armed breakpoints, and debugger attachment have all been restored; the helper creates the PNG and JSON only after confirmed detach. It rejects output if the projection or requested matrix no longer matches, the selected matrix changes before return, or another render invalidates pixel attribution. Two repeated captures verified the selected matrix, projection, exact restoration, readback, and detach.
This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`.
For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. If recovery requires termination, it stops only the exact path/tick/hash-verified scratch process.
To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG.
```powershell
$cameraJson = 'target\native-frame-capture\frame.json'
$viewerRaw = 'target\native-frame-capture\viewer.bin'
$viewerPng = 'target\native-frame-capture\viewer.png'
cargo build --release -p fparkan-game
if ($LASTEXITCODE -ne 0) { throw 'Viewer build failed; inspect cargo output.' }
& '.\tools\native-frame-capture\convert-viewer-readback.ps1' -SelfTest
& 'target\release\fparkan-game.exe' `
--root 'target\shadow-probe\Parkan - Iron Strategy' `
--mission 'MISSIONS\Autodemo.00\data.tma' `
--legacy-camera-capture $cameraJson `
--frames 3 `
--readback-out $viewerRaw
if ($LASTEXITCODE -ne 0) { throw 'Viewer readback failed; inspect its output before converting any raw file.' }
& '.\tools\native-frame-capture\convert-viewer-readback.ps1' `
-InputRaw $viewerRaw `
-CameraJson $cameraJson `
-VkFormat 37 `
-OutputPng $viewerPng
```
The converter derives raw image dimensions from the `viewport` rectangle. If
`pixel_capture` is present, its width and height describe the full native PNG;
the viewport must fit within those bounds, but can be a smaller crop with a
nonzero origin. The viewer readback itself contains only the viewport extent.
The converter checks the exact raw byte length (up to 64 MiB), refuses outputs
outside `target` or existing PNG paths, and accepts VkFormat `37`/`43` for RGBA
or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is
the verified value for the run above, not a universal surface format. The
converter uses the installed `System.Drawing` PNG encoder and adds no package
dependency.
@@ -0,0 +1,346 @@
[CmdletBinding(DefaultParameterSetName = 'Convert')]
param(
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
[string]$InputRaw,
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
[string]$CameraJson,
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
[ValidateSet('37', '43', '44', '50')]
[int]$VkFormat,
[Parameter(Mandatory = $true, ParameterSetName = 'Convert')]
[string]$OutputPng,
[Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')]
[switch]$SelfTest
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
function Assert-ReadbackByteLength {
param(
[long]$ActualLength,
[int]$Width,
[int]$Height
)
$maximumLength = [long]64 * 1024 * 1024
if ($Width -le 0 -or $Height -le 0) {
throw [System.IO.InvalidDataException]::new('Readback dimensions must be positive.')
}
$expectedLength = [long]$Width * [long]$Height * 4
if ($expectedLength -gt $maximumLength) {
throw [System.IO.InvalidDataException]::new("Readback exceeds the 64 MiB limit: $expectedLength bytes.")
}
if ($ActualLength -ne $expectedLength) {
throw [System.IO.InvalidDataException]::new(
"Readback must contain exactly width * height * 4 bytes; got $ActualLength for ${Width}x${Height} (expected $expectedLength)."
)
}
}
function ConvertTo-ViewportCoordinate {
param(
[object]$Value,
[string]$Name
)
try {
$number = [System.Convert]::ToDecimal($Value, [System.Globalization.CultureInfo]::InvariantCulture)
}
catch {
throw "Camera JSON viewport $Name must be an integer."
}
if ($number -ne [decimal]::Truncate($number) -or
$number -lt [int]::MinValue -or $number -gt [int]::MaxValue) {
throw "Camera JSON viewport $Name must be an in-range 32-bit integer."
}
return [int]$number
}
function Convert-ReadbackToBgra {
param(
[byte[]]$Source,
[int]$Format,
[int]$Width,
[int]$Height
)
Assert-ReadbackByteLength -ActualLength $Source.Length -Width $Width -Height $Height
# VK_FORMAT_R8G8B8A8_* (37, 43) needs R/B swapped for System.Drawing's
# little-endian Format32bppArgb storage. VK_FORMAT_B8G8R8A8_* (44, 50)
# already has the byte order expected by that bitmap format.
if ($Format -eq 44 -or $Format -eq 50) {
return ,$Source
}
$converted = New-Object byte[] $Source.Length
for ($offset = 0; $offset -lt $Source.Length; $offset += 4) {
$converted[$offset] = $Source[$offset + 2]
$converted[$offset + 1] = $Source[$offset + 1]
$converted[$offset + 2] = $Source[$offset]
$converted[$offset + 3] = $Source[$offset + 3]
}
return ,$converted
}
function Assert-ViewportWithinCapture {
param(
[int]$Left,
[int]$Top,
[int]$Right,
[int]$Bottom,
[int]$CaptureWidth,
[int]$CaptureHeight
)
if ($CaptureWidth -le 0 -or $CaptureHeight -le 0) {
throw [System.IO.InvalidDataException]::new('Camera JSON pixel_capture dimensions must be positive.')
}
if ($Left -lt 0 -or $Top -lt 0 -or $Right -gt $CaptureWidth -or $Bottom -gt $CaptureHeight) {
throw [System.IO.InvalidDataException]::new('Camera JSON viewport must fit within the full pixel_capture image bounds.')
}
}
function Save-BgraPngToStream {
param(
[byte[]]$Pixels,
[int]$Width,
[int]$Height,
[System.IO.Stream]$Stream
)
$bitmap = New-Object System.Drawing.Bitmap(
$Width,
$Height,
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb
)
$rectangle = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height)
$bitmapData = $null
try {
$bitmapData = $bitmap.LockBits(
$rectangle,
[System.Drawing.Imaging.ImageLockMode]::WriteOnly,
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb
)
$rowBytes = $Width * 4
if ([Math]::Abs($bitmapData.Stride) -lt $rowBytes) {
throw 'Bitmap stride is shorter than a pixel row.'
}
for ($row = 0; $row -lt $Height; $row++) {
$destination = [IntPtr]::Add($bitmapData.Scan0, $row * $bitmapData.Stride)
[System.Runtime.InteropServices.Marshal]::Copy(
$Pixels,
$row * $rowBytes,
$destination,
$rowBytes
)
}
$bitmap.UnlockBits($bitmapData)
$bitmapData = $null
$bitmap.Save($Stream, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
if ($null -ne $bitmapData) {
$bitmap.UnlockBits($bitmapData)
}
$bitmap.Dispose()
}
}
function Get-CameraExtentFromObject {
param([object]$Camera)
$camera = $Camera
if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $null -eq $camera.viewport -or $camera.viewport.Count -ne 4) {
throw 'Camera JSON must be fparkan-legacy-camera-v1 and contain a four-value viewport.'
}
$left = ConvertTo-ViewportCoordinate -Value $camera.viewport[0] -Name 'left'
$top = ConvertTo-ViewportCoordinate -Value $camera.viewport[1] -Name 'top'
$right = ConvertTo-ViewportCoordinate -Value $camera.viewport[2] -Name 'right'
$bottom = ConvertTo-ViewportCoordinate -Value $camera.viewport[3] -Name 'bottom'
$widthLong = [long]$right - [long]$left
$heightLong = [long]$bottom - [long]$top
if ($widthLong -gt [int]::MaxValue -or $heightLong -gt [int]::MaxValue) {
throw 'Camera JSON viewport extent exceeds 32-bit dimensions.'
}
$width = [int]$widthLong
$height = [int]$heightLong
if ($width -le 0 -or $height -le 0) {
throw 'Camera JSON viewport has an empty or inverted extent.'
}
$expectedBytes = [long]$width * [long]$height * 4
if ($expectedBytes -gt ([long]64 * 1024 * 1024)) {
throw "Camera JSON viewport exceeds the 64 MiB readback limit: $expectedBytes bytes."
}
if ($null -ne $camera.pixel_capture) {
$captureWidth = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.width -Name 'pixel_capture.width'
$captureHeight = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.height -Name 'pixel_capture.height'
Assert-ViewportWithinCapture -Left $left -Top $top -Right $right -Bottom $bottom -CaptureWidth $captureWidth -CaptureHeight $captureHeight
}
return [pscustomobject]@{ Width = $width; Height = $height }
}
function Get-CameraExtent {
param([string]$Path)
$camera = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
return Get-CameraExtentFromObject -Camera $camera
}
function Invoke-SelfTest {
$rgba = [byte[]]@(
255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 255, 255, 255, 255, 255
)
$expectedBgra = [byte[]]@(
0, 0, 255, 255, 0, 255, 0, 255,
255, 0, 0, 255, 255, 255, 255, 255
)
$rgbaResult = Convert-ReadbackToBgra -Source $rgba -Format 37 -Width 2 -Height 2
$bgraResult = Convert-ReadbackToBgra -Source $expectedBgra -Format 44 -Width 2 -Height 2
for ($index = 0; $index -lt $expectedBgra.Length; $index++) {
if ($rgbaResult[$index] -ne $expectedBgra[$index] -or $bgraResult[$index] -ne $expectedBgra[$index]) {
throw 'Self-check failed: RGBA/BGRA channel mapping.'
}
}
$mismatchRejected = $false
try {
Assert-ReadbackByteLength -ActualLength 15 -Width 2 -Height 2
}
catch [System.IO.InvalidDataException] {
$mismatchRejected = $true
}
if (-not $mismatchRejected) {
throw 'Self-check failed: wrong byte length was accepted.'
}
Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4096 -Height 4096
$oversizeRejected = $false
try {
Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4097 -Height 4096
}
catch [System.IO.InvalidDataException] {
$oversizeRejected = $true
}
if (-not $oversizeRejected) {
throw 'Self-check failed: readback over the 64 MiB boundary was accepted.'
}
foreach ($invalidCoordinate in @([double]1.5, [long]2147483648)) {
$coordinateRejected = $false
try {
ConvertTo-ViewportCoordinate -Value $invalidCoordinate -Name 'self-check'
}
catch {
$coordinateRejected = $true
}
if (-not $coordinateRejected) {
throw 'Self-check failed: fractional or out-of-range viewport coordinate was accepted.'
}
}
# A crop can be smaller than the full native PNG and start away from (0,0).
# Raw viewer readback is the crop only, so its dimensions stay 4x4 here.
$cropCamera = [pscustomobject]@{
schema = 'fparkan-legacy-camera-v1'
viewport = @(4, 6, 8, 10)
pixel_capture = [pscustomobject]@{ width = 12; height = 16 }
}
$cropExtent = Get-CameraExtentFromObject -Camera $cropCamera
if ($cropExtent.Width -ne 4 -or $cropExtent.Height -ne 4) {
throw 'Self-check failed: cropped readback extent was not derived from the viewport rectangle.'
}
Assert-ReadbackByteLength -ActualLength 64 -Width 4 -Height 4
$cropOutsideCaptureRejected = $false
try {
$cropCamera.viewport = @(4, 6, 13, 10)
Get-CameraExtentFromObject -Camera $cropCamera | Out-Null
}
catch [System.IO.InvalidDataException] {
$cropOutsideCaptureRejected = $true
}
if (-not $cropOutsideCaptureRejected) {
throw 'Self-check failed: viewport extending beyond the full pixel_capture image was accepted.'
}
$stream = New-Object System.IO.MemoryStream
$decoded = $null
try {
Save-BgraPngToStream -Pixels $expectedBgra -Width 2 -Height 2 -Stream $stream
$stream.Position = 0
$decoded = [System.Drawing.Bitmap]::new($stream)
$expectedColors = @(
[System.Drawing.Color]::Red,
[System.Drawing.Color]::FromArgb(255, 0, 255, 0),
[System.Drawing.Color]::Blue,
[System.Drawing.Color]::White
)
$points = @(@(0, 0), @(1, 0), @(0, 1), @(1, 1))
for ($index = 0; $index -lt $points.Count; $index++) {
$point = $points[$index]
$actualColor = $decoded.GetPixel($point[0], $point[1])
if ($actualColor.ToArgb() -ne $expectedColors[$index].ToArgb()) {
throw "Self-check failed: PNG pixel $index expected $($expectedColors[$index]) but read $actualColor."
}
}
}
finally {
if ($null -ne $decoded) { $decoded.Dispose() }
$stream.Dispose()
}
'Self-check passed (2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).'
}
if ($SelfTest) {
Invoke-SelfTest
exit 0
}
$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$targetRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'target'))
$rawPath = (Resolve-Path -LiteralPath $InputRaw).Path
$cameraPath = (Resolve-Path -LiteralPath $CameraJson).Path
if (-not (Test-Path -LiteralPath $rawPath -PathType Leaf)) { throw "Raw readback is not a file: $rawPath" }
if (-not (Test-Path -LiteralPath $cameraPath -PathType Leaf)) { throw "Camera JSON is not a file: $cameraPath" }
$outputPath = [System.IO.Path]::GetFullPath($OutputPng)
$targetPrefix = $targetRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
if (-not $outputPath.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Output must be beneath the repository target directory: $targetRoot"
}
if ([System.IO.Path]::GetExtension($outputPath) -ine '.png') { throw 'Output filename must end in .png.' }
if (Test-Path -LiteralPath $outputPath) { throw "Refusing to overwrite existing output: $outputPath" }
$outputDirectory = [System.IO.Path]::GetDirectoryName($outputPath)
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
throw "Output directory does not exist: $outputDirectory"
}
$extent = Get-CameraExtent -Path $cameraPath
$rawInfo = Get-Item -LiteralPath $rawPath
Assert-ReadbackByteLength -ActualLength $rawInfo.Length -Width $extent.Width -Height $extent.Height
$source = [System.IO.File]::ReadAllBytes($rawPath)
$pixels = Convert-ReadbackToBgra -Source $source -Format $VkFormat -Width $extent.Width -Height $extent.Height
$stream = [System.IO.File]::Open($outputPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
try {
Save-BgraPngToStream -Pixels $pixels -Width $extent.Width -Height $extent.Height -Stream $stream
}
finally {
$stream.Dispose()
}
"Wrote $($extent.Width)x$($extent.Height) PNG to $outputPath (VkFormat $VkFormat)."