aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/native-frame-capture/NativeFrameCapture.cs67
-rw-r--r--tools/native-frame-capture/README.md41
-rw-r--r--tools/native-frame-capture/compare-frames.ps1106
3 files changed, 210 insertions, 4 deletions
diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs
index 6371ae6..f41fb42 100644
--- a/tools/native-frame-capture/NativeFrameCapture.cs
+++ b/tools/native-frame-capture/NativeFrameCapture.cs
@@ -201,6 +201,8 @@ internal static partial class NativeFrameCapture
private sealed class SelectedCameraState
{
public SelectedCameraInput Input;
+ public int PrecedingFramesRemaining;
+ public int PrecedingFramesCompleted;
public uint CandidateCameraPointer;
public uint CandidateThreadId;
public uint CandidateGeneration;
@@ -226,6 +228,47 @@ internal static partial class NativeFrameCapture
public uint OriginalParentPointer;
}
+ private static SelectedCameraState NextSelectedWarmupFrame(SelectedCameraState previous)
+ {
+ if (previous.PrecedingFramesRemaining <= 0 || !previous.RestoreVerified
+ || !previous.Restored || !previous.MatrixIntactAtReturn
+ || !previous.ProjectionMatchesInput || previous.PixelAttributionInvalidated)
+ throw new InvalidOperationException("Cannot advance a selected camera before a verified frame and exact restoration.");
+ return new SelectedCameraState
+ {
+ Input = previous.Input,
+ PrecedingFramesRemaining = previous.PrecedingFramesRemaining - 1,
+ PrecedingFramesCompleted = previous.PrecedingFramesCompleted + 1,
+ CandidateCameraPointer = previous.CameraPointer,
+ CandidateThreadId = previous.ThreadId,
+ CandidateGeneration = previous.Generation,
+ CandidateProjectionVerified = true
+ };
+ }
+
+ private static void SelfCheckSelectedCameraWarmup()
+ {
+ SelectedCameraState first = new SelectedCameraState
+ {
+ PrecedingFramesRemaining = 2, CameraPointer = 123, ThreadId = 11, Generation = 8,
+ Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true,
+ ProjectionMatchesInput = true
+ };
+ SelectedCameraState second = NextSelectedWarmupFrame(first);
+ if (second.PrecedingFramesRemaining != 1 || second.PrecedingFramesCompleted != 1
+ || second.CandidateCameraPointer != 123 || second.CandidateThreadId != 11
+ || second.CandidateGeneration != 8 || second.Applied || second.RestoreVerified)
+ throw new InvalidOperationException("Selected camera warmup transition self-check failed.");
+ bool unrestoredRejected = false;
+ try { NextSelectedWarmupFrame(second); }
+ catch (InvalidOperationException) { unrestoredRejected = true; }
+ if (!unrestoredRejected) throw new InvalidOperationException("Unrestored warmup frame was accepted.");
+ second.Restored = second.RestoreVerified = second.MatrixIntactAtReturn = second.ProjectionMatchesInput = true;
+ SelectedCameraState final = NextSelectedWarmupFrame(second);
+ if (final.PrecedingFramesRemaining != 0 || final.PrecedingFramesCompleted != 2)
+ throw new InvalidOperationException("Selected camera final frame self-check failed.");
+ }
+
private sealed class BoundaryTraceProgress
{
public bool BeginSceneSeen;
@@ -1236,6 +1279,7 @@ internal static partial class NativeFrameCapture
return CanArmPresentForFrame(frame, threadId)
&& (!frame.SelectedCameraRequested || (frame.SelectedCamera != null
&& frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput
+ && frame.SelectedCamera.PrecedingFramesRemaining == 0
&& frame.SelectedCamera.CandidateProjectionVerified
&& frame.SelectedCamera.Restored && frame.SelectedCamera.RestoreVerified
&& frame.SelectedCamera.MatrixIntactAtReturn
@@ -1276,6 +1320,7 @@ internal static partial class NativeFrameCapture
&& frame.CameraThreadId == threadId && frame.ProjectionThreadId == threadId
&& (!frame.SelectedCameraRequested || (frame.SelectedCamera != null
&& frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput
+ && frame.SelectedCamera.PrecedingFramesRemaining == 0
&& frame.SelectedCamera.CandidateProjectionVerified
&& !frame.SelectedCamera.PixelAttributionInvalidated
&& SelectedMatrixMatchesProjection(frame)));
@@ -1848,6 +1893,7 @@ internal static partial class NativeFrameCapture
{
SelectedCameraState selected = frame.SelectedCamera;
json.AppendLine(" \"selected_camera\": {");
+ json.AppendLine(" \"preceding_selected_world_frames\": " + selected.PrecedingFramesCompleted + ",");
json.AppendLine(" \"input_json\": " + JsonString(selected.Input.Path) + ",");
json.AppendLine(" \"input_kind\": " + JsonString(selected.Input.Synthetic
? "synthetic-camera-plan" : "native-capture") + ",");
@@ -1949,6 +1995,7 @@ internal static partial class NativeFrameCapture
{
if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86.");
SelfCheckCameraInput();
+ SelfCheckSelectedCameraWarmup();
SelfCheckNativeFogPairing();
SelfCheckRenderInvocationGates();
SelfCheckDebugEventHandleOwnership();
@@ -2088,6 +2135,10 @@ internal static partial class NativeFrameCapture
bool wrongCameraThreadRejected = !IsMatchingPerspectiveFrame(matched, 12);
bool emptyCameraFrameRejected = !IsMatchingPerspectiveFrame(new FrameSnapshot(), 11);
bool selectedFrameValid = IsMatchingPerspectiveFrame(selectedMatched, 11);
+ selectedMatched.SelectedCamera.PrecedingFramesRemaining = 1;
+ bool selectedWarmupRejected = !CanArmPresentForFrame(selectedMatched, 11)
+ && !IsMatchingPerspectiveFrame(selectedMatched, 11);
+ selectedMatched.SelectedCamera.PrecedingFramesRemaining = 0;
bool selectedUnrestoredRejected = !IsMatchingPerspectiveFrame(selectedUnrestored, 11);
bool selectedUnrestoredCaptureEligible = CanArmPresentForFrame(selectedUnrestored, 11);
bool selectedIntervenedCaptureRejected = !CanArmPresentForFrame(selectedIntervened, 11);
@@ -2189,7 +2240,7 @@ internal static partial class NativeFrameCapture
|| !atmosphereFrameValid
|| !atmosphereClockMismatchRejected
|| !cameraFrameValid || !wrongCameraThreadRejected || !emptyCameraFrameRejected
- || !selectedFrameValid || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
+ || !selectedFrameValid || !selectedWarmupRejected || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
|| !selectedIntervenedCaptureRejected || !selectedIntervenedRejected
|| !selectedChangedMatrixRejected
|| !selectedBoundaryReadAllowed || !boundaryReadRejectsUnselected
@@ -2919,7 +2970,8 @@ internal static partial class NativeFrameCapture
bool projectionSelectorMode = boundaryTrace || boundaryCapture;
frame.SelectedCameraRequested = selectedCameraInput != null;
SelectedCameraState selectedCamera = selectedCameraInput == null ? null
- : new SelectedCameraState { Input = selectedCameraInput };
+ : new SelectedCameraState { Input = selectedCameraInput,
+ PrecedingFramesRemaining = boundaryCapture ? 0 : 2 };
try
{
if (identityProcess == null || identityProcess.HasExited
@@ -3330,6 +3382,12 @@ internal static partial class NativeFrameCapture
Log("CAMERA_SETTER_REGION_FREED phase=Restore");
else Log("CAMERA_SETTER_REGION_FREE_FAILED phase=Restore error=" + Marshal.GetLastWin32Error());
cameraSetter = null;
+ if (!stopAfterAttempt && selectedCamera.PrecedingFramesRemaining > 0)
+ {
+ selectedCamera = NextSelectedWarmupFrame(selectedCamera);
+ Log("SELECTED_CAMERA_WARMUP_COMPLETE completed=" + selectedCamera.PrecedingFramesCompleted
+ + " remaining=" + selectedCamera.PrecedingFramesRemaining);
+ }
}
}
}
@@ -3870,7 +3928,8 @@ internal static partial class NativeFrameCapture
&& targetProjectionMatchesInput)
+ " rawWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8"));
if (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0
- && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified)
+ && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified
+ && (selectedCamera == null || selectedCamera.PrecedingFramesRemaining == 0))
EnsurePresentBreakpoint(process, ngiBase, frame, eventTid,
presentOriginalByte, ref presentBreakpoint);
}
@@ -3975,7 +4034,7 @@ internal static partial class NativeFrameCapture
}
cameraSetter = StartCameraSetter(process, eventTid, terrainBase,
selectedCamera.CameraPointer, selectedCamera.OriginalMatrixBytes,
- stoppedContext, returnBreakpoint, false, CameraSetterPhase.Restore,
+ stoppedContext, returnBreakpoint, selectedCamera.PrecedingFramesRemaining > 0, CameraSetterPhase.Restore,
selectedCamera.EntryEsp, selectedCamera.ReturnAddress);
Log("SELECTED_CAMERA_RESTORE_STARTED generation=" + selectedCamera.Generation
+ " camera=0x" + selectedCamera.CameraPointer.ToString("X8")
diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md
index 428f8c2..232cc1c 100644
--- a/tools/native-frame-capture/README.md
+++ b/tools/native-frame-capture/README.md
@@ -74,6 +74,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its lo
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 `--capture --camera-input`, the requested pose is rendered on three consecutive world frames. The first two are discarded so portal visibility and other render caches can settle. The original local matrix is restored and checked after every frame; the third frame alone supplies the pixels, projection and atmosphere sample. `selected_camera.preceding_selected_world_frames` records the two preceding verified frames. A failed matrix or restoration check stops the sequence. Passive and EndScene-boundary capture keep their single-frame behavior.
+
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. It explicitly closes only image-file handles from `CREATE_PROCESS` and `LOAD_DLL`; the system owns the process/thread handles supplied by `CREATE_PROCESS` and `CREATE_THREAD` until the corresponding exit event is continued. If guarded recovery must terminate the scratch process, the helper drains its outstanding and queued debug events through `EXIT_PROCESS` within five seconds, then verifies the process handle before detaching. It withholds output when process exit cannot be confirmed.
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.
@@ -110,3 +112,42 @@ 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.
+
+## Сравнение областей кадра
+
+`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и
+считает средний RGB каждой указанной области. Координаты областей относятся к
+viewport, то есть к PNG просмотрщика. Оригинальный PNG сначала обрезается по
+`viewport` из JSON камеры. Имена выбираются для конкретной сцены: например,
+`sky`, `terrain_near`, `terrain_far`, `facade`, `opening`; невидимые в этом
+ракурсе области добавлять не нужно. Панели HUD и движущиеся объекты следует
+оставлять вне измеряемых прямоугольников.
+
+Пример `regions.json` для интерьера 1280 × 1024:
+
+```json
+[
+ {"name":"ceiling_near","rect":[250,50,550,170]},
+ {"name":"wall","rect":[120,400,200,150]},
+ {"name":"opening","rect":[650,550,220,100]}
+]
+```
+
+```powershell
+& '.\tools\native-frame-capture\compare-frames.ps1' -SelfTest
+& '.\tools\native-frame-capture\compare-frames.ps1' `
+ -NativePng 'target\captures\r1\constructor\interior.png' `
+ -ViewerPng 'target\captures\r1\constructor\interior-after.png' `
+ -CameraJson 'target\captures\r1\constructor\interior.json' `
+ -RegionsJson 'target\captures\r1\constructor\interior-regions.json' `
+ -OutputPrefix 'target\captures\r1\constructor\interior-comparison'
+```
+
+Результат — PNG с парой и отмеченными областями, а также JSON со средними
+значениями каналов в диапазоне 0–255. Это значения сохранённого изображения,
+без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие
+размеры, области за границей кадра и существующие выходные файлы. Совпадение
+средних цветов не доказывает совпадение геометрии, поэтому пару нужно также
+просмотреть. Камера и фаза должны происходить из одного нативного захвата.
+Для интерьера нужно проверить наличие комнат в PNG: выбранная камера проходит
+два предварительных мировых кадра, а при пассивном захвате игрок входит в здание.
diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1
new file mode 100644
index 0000000..2a83bf0
--- /dev/null
+++ b/tools/native-frame-capture/compare-frames.ps1
@@ -0,0 +1,106 @@
+[CmdletBinding(DefaultParameterSetName = 'Compare')]
+param(
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$NativePng,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$ViewerPng,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$CameraJson,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$RegionsJson,
+ [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$OutputPrefix,
+ [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest
+)
+
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Drawing
+
+function Get-MeanRgb {
+ param([System.Drawing.Bitmap]$Bitmap, [System.Drawing.Rectangle]$Region)
+ if ($Region.X -lt 0 -or $Region.Y -lt 0 -or $Region.Width -le 0 -or $Region.Height -le 0 -or
+ $Region.Right -gt $Bitmap.Width -or $Region.Bottom -gt $Bitmap.Height) {
+ throw 'Region must be a nonempty rectangle within the image.'
+ }
+ $sum = [long[]]@(0, 0, 0)
+ for ($y = $Region.Top; $y -lt $Region.Bottom; $y++) {
+ for ($x = $Region.Left; $x -lt $Region.Right; $x++) {
+ $pixel = $Bitmap.GetPixel($x, $y)
+ $sum[0] += $pixel.R
+ $sum[1] += $pixel.G
+ $sum[2] += $pixel.B
+ }
+ }
+ $count = [long]$Region.Width * $Region.Height
+ return ,@($sum | ForEach-Object { [Math]::Round($_ / $count, 4) })
+}
+
+if ($SelfTest) {
+ $test = [System.Drawing.Bitmap]::new(2, 1)
+ try {
+ $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(10, 20, 30))
+ $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(30, 40, 50))
+ $mean = Get-MeanRgb $test ([System.Drawing.Rectangle]::new(0, 0, 2, 1))
+ if (($mean -join ',') -ne '20,30,40') { throw 'Mean RGB self-test failed.' }
+ $rejected = $false
+ try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null }
+ catch { $rejected = $true }
+ if (-not $rejected) { throw 'Out-of-bounds region was accepted.' }
+ Write-Output 'self-test: exact region RGB means and bounds rejection OK'
+ } finally { $test.Dispose() }
+ return
+}
+
+$pngPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.png')
+$jsonPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.json')
+if ((Test-Path -LiteralPath $pngPath) -or (Test-Path -LiteralPath $jsonPath)) {
+ throw 'Comparison output already exists; choose a new prefix.'
+}
+$camera = Get-Content -LiteralPath $CameraJson -Raw | ConvertFrom-Json
+if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $camera.viewport.Count -ne 4) {
+ throw 'Expected a legacy camera JSON with a four-value viewport.'
+}
+$viewport = @($camera.viewport)
+foreach ($value in $viewport) {
+ if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) {
+ throw 'Viewport coordinates must be nonnegative integers.'
+ }
+}
+$crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1])
+$native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path)
+$viewer = $nativeCrop = $pair = $graphics = $null
+try {
+ $viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path)
+ if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or
+ $viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) {
+ throw 'Native crop and viewer dimensions must match without scaling.'
+ }
+ $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
+ $pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24)
+ $graphics = [System.Drawing.Graphics]::FromImage($pair)
+ $graphics.Clear([System.Drawing.Color]::Black)
+ $graphics.DrawImageUnscaled($nativeCrop, 0, 24)
+ $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24)
+ $font = [System.Drawing.SystemFonts]::DefaultFont
+ $graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4)
+ $graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4)
+ $regions = @(Get-Content -LiteralPath $RegionsJson -Raw | ConvertFrom-Json)
+ if ($regions.Count -eq 0) { throw 'At least one named region is required.' }
+ $report = foreach ($region in $regions) {
+ if ([string]::IsNullOrWhiteSpace($region.name) -or $region.rect.Count -ne 4) { throw 'Each region needs name and rect [x,y,width,height].' }
+ foreach ($value in $region.rect) {
+ if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { throw 'Region coordinates must be nonnegative integers.' }
+ }
+ $rect = [System.Drawing.Rectangle]::new($region.rect[0], $region.rect[1], $region.rect[2], $region.rect[3])
+ $originalMean = Get-MeanRgb $nativeCrop $rect
+ $viewerMean = Get-MeanRgb $viewer $rect
+ foreach ($offset in @(0, $viewer.Width)) {
+ $graphics.DrawRectangle([System.Drawing.Pens]::Yellow, $rect.X + $offset, $rect.Y + 24, $rect.Width, $rect.Height)
+ $graphics.DrawString($region.name, $font, [System.Drawing.Brushes]::Yellow, $rect.X + $offset, $rect.Y + 24)
+ }
+ [pscustomobject]@{ name = $region.name; rect = $region.rect; original_rgb = $originalMean; fparkan_rgb = $viewerMean }
+ }
+ $pair.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png)
+ [pscustomobject]@{ native = $NativePng; viewer = $ViewerPng; camera = $CameraJson; rgb_encoding = 'stored image bytes, 0..255'; regions = @($report) } |
+ ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
+ Write-Output "Wrote $pngPath and $jsonPath"
+} finally {
+ foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) {
+ if ($null -ne $resource) { $resource.Dispose() }
+ }
+}