aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-18 13:29:24 +0300
committerValentin Popov <valentin@popov.link>2026-10-18 13:29:24 +0300
commit76e5737b1dd047aede7712a6143fef61654df669 (patch)
tree77a897f25316b6cf434f0aa5bc46dd609a60e7b4
parent13f563bb79228fdb075698c4ed79deea3cc65301 (diff)
downloadfparkan-76e5737b1dd047aede7712a6143fef61654df669.tar.xz
fparkan-76e5737b1dd047aede7712a6143fef61654df669.zip
Support parented camera capture matrices
-rw-r--r--apps/fparkan-game/src/main.rs98
-rw-r--r--apps/fparkan-game/testdata/legacy-camera-parented.json14
-rw-r--r--tools/native-frame-capture/NativeFrameCapture.Readback.cs563
-rw-r--r--tools/native-frame-capture/NativeFrameCapture.cs1107
-rw-r--r--tools/native-frame-capture/README.md31
5 files changed, 1706 insertions, 107 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 0c4c7eb..e25ee38 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -6599,6 +6599,9 @@ struct LegacyCameraCapture {
schema: String,
render_input_usable: Option<bool>,
selector0_words: [u32; 16],
+ parented_camera: Option<bool>,
+ effective_world_matrix_space: Option<String>,
+ effective_world_matrix_words: Option<[u32; 16]>,
viewport: [i32; 4],
near_plane: f32,
far_plane: f32,
@@ -6614,6 +6617,26 @@ fn selected_atmosphere_seconds(
}
impl LegacyCameraCapture {
+ fn preview_matrix_words(&self) -> Result<[u32; 16], String> {
+ match (
+ self.effective_world_matrix_space.as_deref(),
+ self.effective_world_matrix_words,
+ ) {
+ (Some("world"), Some(words)) => Ok(words),
+ (Some(_), _) => Err(
+ "legacy camera effective_world_matrix_space must be 'world'".to_string(),
+ ),
+ (None, Some(_)) => Err(
+ "legacy camera effective_world_matrix_words requires an explicit world-space marker"
+ .to_string(),
+ ),
+ (None, None) if self.parented_camera == Some(true) => Err(
+ "parented legacy camera requires an effective world-space matrix".to_string(),
+ ),
+ (None, None) => Ok(self.selector0_words),
+ }
+ }
+
fn captured_viewport(&self) -> Result<CapturedViewport, String> {
let width = self.viewport[2]
.checked_sub(self.viewport[0])
@@ -6637,7 +6660,7 @@ impl LegacyCameraCapture {
fn preview_camera_frame(&self) -> Result<PreviewCameraFrame, String> {
let frame = PreviewCameraFrame::from_legacy_d3d7(
RawCameraTransform {
- words: self.selector0_words,
+ words: self.preview_matrix_words()?,
},
LegacyD3d7Projection {
viewport: self.viewport,
@@ -6668,6 +6691,14 @@ fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, Stri
if capture.schema != "fparkan-legacy-camera-v1" {
return Err("unsupported legacy camera capture schema".to_string());
}
+ if capture.effective_world_matrix_space.is_some()
+ != capture.effective_world_matrix_words.is_some()
+ {
+ return Err(
+ "effective_world_matrix_space and effective_world_matrix_words must be supplied together"
+ .to_string(),
+ );
+ }
if capture.render_input_usable == Some(false) {
return Err("legacy camera capture is marked render_input_usable=false".to_string());
}
@@ -8269,11 +8300,76 @@ mod tests {
}
#[test]
+ fn parented_camera_preview_uses_effective_world_matrix_fixture() {
+ let fixture = include_bytes!("../testdata/legacy-camera-parented.json");
+ let capture = parse_legacy_camera_capture(fixture).expect("valid parented camera fixture");
+ let parse_value = |value: serde_json::Value| {
+ let bytes = serde_json::to_vec(&value).expect("serialize camera fixture");
+ parse_legacy_camera_capture(&bytes)
+ };
+ assert_eq!(capture.parented_camera, Some(true));
+ assert_eq!(
+ capture.preview_camera_frame().unwrap().position,
+ [10.0, 20.0, 30.0]
+ );
+ assert_eq!(
+ capture.preview_matrix_words().unwrap(),
+ capture.effective_world_matrix_words.unwrap()
+ );
+
+ let mut missing_world: serde_json::Value =
+ serde_json::from_slice(fixture).expect("valid fixture JSON");
+ missing_world
+ .as_object_mut()
+ .unwrap()
+ .remove("effective_world_matrix_words");
+ let missing_world = serde_json::to_vec(&missing_world).unwrap();
+ assert!(parse_legacy_camera_capture(&missing_world)
+ .expect_err("incomplete world matrix pair")
+ .contains("effective_world_matrix_space and effective_world_matrix_words"));
+
+ let mut parented_without_world: serde_json::Value =
+ serde_json::from_slice(fixture).expect("valid fixture JSON");
+ let parented_without_world = parented_without_world.as_object_mut().unwrap();
+ parented_without_world.remove("effective_world_matrix_space");
+ parented_without_world.remove("effective_world_matrix_words");
+ assert!(
+ parse_value(serde_json::Value::Object(parented_without_world.clone()))
+ .expect_err("parented camera without its world matrix")
+ .contains("parented legacy camera requires an effective world-space matrix")
+ );
+
+ let mut invalid_marker: serde_json::Value =
+ serde_json::from_slice(fixture).expect("valid fixture JSON");
+ invalid_marker["effective_world_matrix_space"] =
+ serde_json::Value::String("local".to_string());
+ assert!(parse_value(invalid_marker)
+ .expect_err("unknown matrix space")
+ .contains("effective_world_matrix_space must be 'world'"));
+
+ let mut legacy: serde_json::Value =
+ serde_json::from_slice(fixture).expect("valid fixture JSON");
+ let legacy = legacy.as_object_mut().unwrap();
+ legacy.remove("parented_camera");
+ legacy.remove("effective_world_matrix_space");
+ legacy.remove("effective_world_matrix_words");
+ let legacy = parse_value(serde_json::Value::Object(legacy.clone()))
+ .expect("older local-only camera capture");
+ assert_eq!(
+ legacy.preview_camera_frame().unwrap().position,
+ [1.0, 2.0, 3.0]
+ );
+ }
+
+ #[test]
fn captured_atmosphere_is_default_and_cli_time_takes_precedence() {
let capture = LegacyCameraCapture {
schema: "fparkan-legacy-camera-v1".to_string(),
render_input_usable: None,
selector0_words: [0; 16],
+ parented_camera: None,
+ effective_world_matrix_space: None,
+ effective_world_matrix_words: None,
viewport: [0, 0, 1, 1],
near_plane: 0.1,
far_plane: 1.0,
diff --git a/apps/fparkan-game/testdata/legacy-camera-parented.json b/apps/fparkan-game/testdata/legacy-camera-parented.json
new file mode 100644
index 0000000..c852de6
--- /dev/null
+++ b/apps/fparkan-game/testdata/legacy-camera-parented.json
@@ -0,0 +1,14 @@
+{
+ "schema": "fparkan-legacy-camera-v1",
+ "capture_status": "native-frame-captured",
+ "render_input_usable": true,
+ "parented_camera": true,
+ "selector0_words": [1065353216, 0, 0, 1065353216, 0, 1065353216, 0, 1073741824, 0, 0, 1065353216, 1077936128, 0, 0, 0, 1065353216],
+ "effective_world_matrix_space": "world",
+ "effective_world_matrix_words": [1065353216, 0, 0, 1092616192, 0, 1065353216, 0, 1101004800, 0, 0, 1065353216, 1106247680, 0, 0, 0, 1065353216],
+ "viewport": [0, 0, 1280, 1024],
+ "near_plane": 0.5,
+ "far_plane": 700.0,
+ "field_of_view_radians": 1.04,
+ "projection_mode_byte": 1
+}
diff --git a/tools/native-frame-capture/NativeFrameCapture.Readback.cs b/tools/native-frame-capture/NativeFrameCapture.Readback.cs
index 872487b..35b1844 100644
--- a/tools/native-frame-capture/NativeFrameCapture.Readback.cs
+++ b/tools/native-frame-capture/NativeFrameCapture.Readback.cs
@@ -14,6 +14,7 @@ using System.Drawing.Imaging;
internal static partial class NativeFrameCapture
{
private const uint NgiRendererGlobalRva = 0x3A460;
+ private const uint NgiLastErrorGlobalRva = 0x3A464;
private const uint NgiDeviceGlobalRva = 0x3A488;
private const uint NgiRendererVtableRva = 0x315E0;
private const uint NgiWindowedPresentRva = 0x6E1B;
@@ -55,9 +56,12 @@ internal static partial class NativeFrameCapture
private const uint CameraSetterMatrixOffset = 64;
private const uint CameraTransformInterfaceVtableRva = 0x66558;
private const uint CameraTransformSetterRva = 0x54C70;
+ private const uint CameraTransformGetterRva = 0x55110;
private const uint CameraTransformInvalidatorRva = 0x55280;
private const uint CameraTransformSetterSlotOffset = 0x1C;
+ private const uint CameraTransformGetterSlotOffset = 0x20;
private const uint CameraTransformInvalidatorSlotOffset = 0x30;
+ private const uint CameraSetterWorldMatrixPointerOffset = 128;
private const uint RenderFunctionStackFrameBytes = 0x70;
[StructLayout(LayoutKind.Sequential)]
@@ -125,6 +129,8 @@ internal static partial class NativeFrameCapture
private sealed class CameraReadback
{
public uint Camera;
+ public uint Parent;
+ public bool Parented;
public uint Vtable;
public uint TransformInterface;
public uint TransformVtable;
@@ -133,13 +139,21 @@ internal static partial class NativeFrameCapture
public uint ReturnAddress;
public uint SelectorField;
public uint[] Words;
+ public uint[] EffectiveWorldWords;
public byte[] EntryMatrixBytes;
public byte[] ProjectionMatrixBytes;
+ public byte[] EntryEffectiveWorldMatrixBytes;
+ public byte[] ProjectionEffectiveWorldMatrixBytes;
public bool CurrentAtProjectionVerified;
+ public bool EffectiveWorldAtProjectionVerified;
public bool WordsChangedAtProjection;
+ public bool EffectiveWorldChangedAtProjection;
public string EntryMatrixSha256;
public string ProjectionMatrixSha256;
+ public string EntryEffectiveWorldMatrixSha256;
+ public string ProjectionEffectiveWorldMatrixSha256;
public bool MatrixFinite;
+ public bool EffectiveWorldMatrixFinite;
public bool LayoutVerified;
public string Failure;
}
@@ -147,8 +161,13 @@ internal static partial class NativeFrameCapture
private sealed class SelectedCameraInput
{
public string Path;
+ public bool Synthetic;
+ public bool ParentedCamera;
+ public bool UsedEffectiveWorldMatrix;
public byte[] MatrixBytes;
public uint[] MatrixWords;
+ public byte[] Selector0MatrixBytes;
+ public uint[] Selector0MatrixWords;
public string MatrixSha256;
public int[] Viewport;
public float Near;
@@ -539,17 +558,92 @@ internal static partial class NativeFrameCapture
return result;
}
- private static RemoteCode BuildCameraSetterStub(uint allocationBase, uint transformInterface)
+ // Boundary tracing only asks the already-active D3D7 device for its current
+ // render target, records the returned interface pointer, and balances the
+ // COM AddRef with Release. It deliberately never locks or reads pixels.
+ private static RemoteCode BuildSurfaceProbeStub(uint allocationBase, uint device)
{
+ uint data = unchecked(allocationBase + RemoteDataOffset);
+ uint statusAddress = unchecked(data + RemoteDataStatusOffset);
+ uint getHrAddress = unchecked(data + RemoteDataGetHrOffset);
+ uint releaseAddress = unchecked(data + RemoteDataReleaseCountOffset);
+ uint surfaceAddress = unchecked(data + RemoteDataSurfaceOffset);
+ X86CodeBuilder code = new X86CodeBuilder();
+ code.Emit(0xBE); code.EmitU32(device); // mov esi, device
+ code.Emit(0x8B, 0x06); // mov eax, [esi]
+ code.Emit(0x8D, 0x3D); code.EmitU32(surfaceAddress); // lea edi, [surface]
+ code.PushReg(0x57); code.PushReg(0x56); // push output; push this
+ code.CallStdCall(8, 0xFF, 0x50, (byte)D3dDeviceGetRenderTargetOffset);
+ code.Emit(0xA3); code.EmitU32(getHrAddress); // mov [getHr], eax
+ code.Emit(0x85, 0xC0); // test eax,eax
+ code.JumpIf(0x89, "get_succeeded"); // jns
+ code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(9);
+ code.Jump("release_surface");
+ code.Mark("get_succeeded");
+ code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress); // mov esi,[surface]
+ code.Emit(0x85, 0xF6); // test esi,esi
+ code.JumpIf(0x84, "no_surface");
+ code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(8);
+ code.Jump("release_surface");
+ code.Mark("no_surface");
+ code.Emit(0xC7, 0x05); code.EmitU32(statusAddress); code.EmitU32(10);
+ code.Jump("stop");
+ code.Mark("release_surface");
+ code.Emit(0x8B, 0x35); code.EmitU32(surfaceAddress);
+ code.Emit(0x85, 0xF6);
+ code.JumpIf(0x84, "stop");
+ code.Emit(0x8B, 0x06);
+ code.PushReg(0x56);
+ code.CallStdCall(4, 0xFF, 0x50, (byte)DdSurfaceReleaseOffset);
+ code.Emit(0xA3); code.EmitU32(releaseAddress);
+ code.Mark("stop");
+ code.EmitTerminalInt3();
+ byte[] bytes = code.Finish();
+ code.AssertTerminalControlFlow(bytes, 6);
+ if (code.MaxCallStackBytes != 12 || bytes.Length > RemoteCodePage)
+ throw new InvalidOperationException("Surface probe stub has an unexpected stack footprint or size.");
+ RemoteCode result = new RemoteCode();
+ result.Base = allocationBase;
+ result.DataBase = data;
+ result.Bytes = bytes;
+ result.CallInstructionOffsets = code.CallInstructionOffsets;
+ result.MaxCallStackBytes = code.MaxCallStackBytes;
+ result.TrapAddress = unchecked(allocationBase + (uint)code.TerminalOffset);
+ return result;
+ }
+
+ private static byte CameraSetterMode(CameraSetterPhase phase)
+ {
+ // Native setter mode 2 treats the supplied matrix as world space and
+ // converts it relative to camera+0x0C. Restoring a captured +0x20
+ // selector-0 value must use local mode 1.
+ return phase == CameraSetterPhase.Apply ? (byte)2 : (byte)1;
+ }
+
+ private static RemoteCode BuildCameraSetterStub(uint allocationBase, uint transformInterface, byte mode)
+ {
+ if (mode != 1 && mode != 2)
+ throw new ArgumentOutOfRangeException("mode", "Only verified local and world camera setter modes are supported.");
uint dataBase = unchecked(allocationBase + RemoteDataOffset);
uint matrixAddress = unchecked(dataBase + CameraSetterMatrixOffset);
X86CodeBuilder code = new X86CodeBuilder();
code.Emit(0xBE); code.EmitU32(transformInterface); // mov esi, transform interface
code.Emit(0x8B, 0x0E); // mov ecx, [esi] (verified vtable)
code.PushImm32(matrixAddress); // argument 3: 64-byte transform
- code.PushImm8(1); // argument 2: mode 1
+ code.PushImm8(mode); // argument 2: native transform space
code.PushReg(0x56); // argument 1: this
code.CallStdCall(12, 0xFF, 0x51, (byte)CameraTransformSetterSlotOffset);
+ if (mode == 2)
+ {
+ // Mode 2 stores a parent-relative local matrix and invalidates the
+ // effective-world cache at transformInterface+0x11C (camera+0x120). The selector-2 getter
+ // recomputes camera+0x60 before the caller verifies the new pose.
+ code.Emit(0x8B, 0x0E); // mov ecx, [esi] (verified vtable)
+ code.PushImm32(unchecked(dataBase + CameraSetterWorldMatrixPointerOffset));
+ code.PushImm8(2); // selector 2: effective world matrix
+ code.PushReg(0x56); // argument 1: transform interface
+ code.CallStdCall(12, 0xFF, 0x51, (byte)CameraTransformGetterSlotOffset);
+ }
code.EmitTerminalInt3();
byte[] bytes = code.Finish();
code.AssertTerminalControlFlow(bytes, 0);
@@ -575,16 +669,19 @@ internal static partial class NativeFrameCapture
bool modeRead = ReadExact(process, unchecked(camera + 0x1A0), modeBytes);
uint transformVtable = ReadU32(process, transformInterface);
uint setter = ReadU32(process, unchecked(transformVtable + CameraTransformSetterSlotOffset));
+ uint getter = ReadU32(process, unchecked(transformVtable + CameraTransformGetterSlotOffset));
uint invalidate = ReadU32(process, unchecked(transformVtable + CameraTransformInvalidatorSlotOffset));
bool valid = terrainBase != 0
&& primaryVtable == unchecked(terrainBase + ExternalCameraVtableRva)
&& transformVtable == unchecked(terrainBase + CameraTransformInterfaceVtableRva)
&& setter == unchecked(terrainBase + CameraTransformSetterRva)
+ && getter == unchecked(terrainBase + CameraTransformGetterRva)
&& invalidate == unchecked(terrainBase + CameraTransformInvalidatorRva)
&& selector == 0xFFFFFFFF && modeRead && modeBytes[0] == 0;
evidence = "camera=0x" + camera.ToString("X8") + " primaryVtable=0x" + primaryVtable.ToString("X8")
+ " transform=0x" + transformInterface.ToString("X8") + " transformVtable=0x" + transformVtable.ToString("X8")
- + " setter=0x" + setter.ToString("X8") + " invalidator=0x" + invalidate.ToString("X8")
+ + " setter=0x" + setter.ToString("X8") + " getter=0x" + getter.ToString("X8")
+ + " invalidator=0x" + invalidate.ToString("X8")
+ " selector=0x" + selector.ToString("X8")
+ " mode=" + (modeRead ? modeBytes[0].ToString(CultureInfo.InvariantCulture) : "unreadable")
+ " valid=" + valid;
@@ -603,8 +700,11 @@ internal static partial class NativeFrameCapture
uint transformInterface;
if (!VerifyCameraSetterAbi(process, terrainBase, camera, out transformInterface, out abiEvidence))
throw new InvalidOperationException("Camera setter ABI refused: " + abiEvidence);
+ byte setterMode = CameraSetterMode(phase);
Log("verified native camera setter ABI " + abiEvidence + " slot=+0x1C this=0x"
- + transformInterface.ToString("X8") + " mode=1 matrixBytes=64 ret=0x0C");
+ + transformInterface.ToString("X8") + " mode=" + setterMode
+ + " matrixSpace=" + (phase == CameraSetterPhase.Apply ? "world" : "local")
+ + " matrixBytes=64 ret=0x0C");
uint expectedEip = unchecked(resumeBreakpoint.Address + 1);
uint originalEip = BitConverter.ToUInt32(originalContext, ContextEipOffset);
@@ -639,7 +739,7 @@ internal static partial class NativeFrameCapture
{
uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext);
region = AllocateReadbackRegion(process);
- RemoteCode code = BuildCameraSetterStub(region, transformInterface);
+ RemoteCode code = BuildCameraSetterStub(region, transformInterface, setterMode);
WriteRemoteStub(process, code, false);
UIntPtr written;
if (!WriteProcessMemory(process, Ptr(unchecked(code.DataBase + CameraSetterMatrixOffset)), matrixBytes,
@@ -732,6 +832,46 @@ internal static partial class NativeFrameCapture
return matrix;
}
+ private static uint ReadCameraParent(IntPtr process, uint camera)
+ {
+ if (camera == 0 || camera > UInt32.MaxValue - 0x0C)
+ throw new InvalidOperationException("Camera pointer is outside the verified parent-field range.");
+ return ReadU32(process, unchecked(camera + 0x0C));
+ }
+
+ private static byte[] ReadEffectiveWorldCameraMatrix(IntPtr process, uint camera)
+ {
+ byte[] matrix = new byte[64];
+ if (camera == 0 || camera > UInt32.MaxValue - 0x60
+ || !ReadExact(process, unchecked(camera + 0x60), matrix))
+ throw new InvalidOperationException("Could not read the native effective-world camera matrix at camera+0x60.");
+ return matrix;
+ }
+
+ private static uint[] MatrixWords(byte[] matrixBytes)
+ {
+ if (matrixBytes == null || matrixBytes.Length != 64)
+ throw new ArgumentException("A camera matrix must contain exactly 64 bytes.", "matrixBytes");
+ uint[] words = new uint[16];
+ for (int i = 0; i < words.Length; i++)
+ words[i] = BitConverter.ToUInt32(matrixBytes, i * 4);
+ return words;
+ }
+
+ private static bool CameraMatricesNearlyEqual(byte[] left, byte[] right)
+ {
+ if (left == null || right == null || left.Length != 64 || right.Length != 64) return false;
+ const double absoluteTolerance = 0.001;
+ for (int i = 0; i < 16; i++)
+ {
+ float a = BitConverter.ToSingle(left, i * 4);
+ float b = BitConverter.ToSingle(right, i * 4);
+ if (!IsFinite(a) || !IsFinite(b) || Math.Abs((double)a - b) > absoluteTolerance)
+ return false;
+ }
+ return true;
+ }
+
private static bool CameraFunctionStackMatches(CameraSetterPhase phase, byte[] stackWords,
uint expectedReturnAddress, uint camera)
{
@@ -891,6 +1031,7 @@ internal static partial class NativeFrameCapture
session.OriginalContext = originalContext;
session.PresentStackArguments = presentStackArguments;
session.PresentStackArgumentsIntact = true;
+ session.ValidatePresentStackArguments = true;
session.RemoteContextIntact = true;
session.PresentBreakpoint = present;
session.Frame = frame;
@@ -915,6 +1056,188 @@ internal static partial class NativeFrameCapture
finally { CloseHandle(thread); }
}
+ private static RemoteReadbackSession StartRemoteBoundaryReadback(IntPtr process, uint threadId,
+ uint ngiBase, BreakpointInfo boundary, FrameSnapshot frame, string outputJson,
+ BreakpointInfo[] allBreakpoints)
+ {
+ uint device;
+ uint getRenderTarget;
+ string evidence;
+ if (!VerifyD3d7GetRenderTarget(process, ngiBase, out device, out getRenderTarget, out evidence))
+ throw new InvalidOperationException("D3D7 EndScene render-target readback was refused: " + evidence);
+ uint renderer = ReadU32Exact(process, unchecked(ngiBase + NgiRendererGlobalRva));
+ if (frame == null || frame.Projection == null || !frame.Projection.Verified
+ || frame.Projection.Renderer != renderer
+ || ReadU32Exact(process, renderer) != unchecked(ngiBase + NgiRendererVtableRva))
+ throw new InvalidOperationException("EndScene readback renderer changed since the selected projection snapshot.");
+ if (ReadU32Exact(process, unchecked(renderer + 0x114)) == 0)
+ throw new InvalidOperationException("Ngi32 is not in the verified windowed render-target path.");
+
+ IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext | ThreadQueryInformation, false, threadId);
+ if (thread == IntPtr.Zero)
+ throw new InvalidOperationException("OpenThread for selected EndScene readback failed: " + Marshal.GetLastWin32Error());
+ uint region = 0;
+ try
+ {
+ byte[] originalContext = GetFullX86Context(thread);
+ if (boundary == null || BitConverter.ToUInt32(originalContext, ContextEipOffset)
+ != unchecked(boundary.Address + 1))
+ throw new InvalidOperationException("Selected EndScene EIP did not match its verified post-call boundary.");
+ uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext);
+ region = AllocateReadbackRegion(process);
+ RemoteCode acquire = BuildReadbackStub(region, device, false);
+ WriteRemoteStub(process, acquire, true);
+ BreakpointInfo[] suspended;
+ if (!SuspendBreakpointsWhileStopped(process, allBreakpoints, out suspended))
+ throw new InvalidOperationException("Could not suspend every armed breakpoint before EndScene readback.");
+ RemoteReadbackSession session = new RemoteReadbackSession();
+ session.Acquire = acquire;
+ session.Region = region;
+ session.ThreadId = threadId;
+ session.SafeEsp = safeEsp;
+ session.OriginalContext = originalContext;
+ session.PresentStackArgumentsIntact = true;
+ session.ValidatePresentStackArguments = false;
+ session.RemoteContextIntact = true;
+ session.PresentBreakpoint = boundary;
+ session.ResumeBreakpoint = boundary;
+ session.SuspendedBreakpoints = suspended;
+ session.Frame = frame;
+ session.Generation = frame.CameraGeneration;
+ session.OutputJson = outputJson;
+ session.OutputPng = Path.ChangeExtension(outputJson, ".png");
+ session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5);
+ session.Phase = RemoteReadbackPhase.Acquire;
+ session.PixelBoundary = "Ngi32 EndScene after selected World3D projection, before Present";
+ Log("BOUNDARY_READBACK_STARTED kind=" + boundary.Name + " tid=" + threadId
+ + " device=0x" + device.ToString("X8") + " renderer=0x" + renderer.ToString("X8")
+ + " generation=" + session.Generation + " stub=0x" + acquire.Base.ToString("X8")
+ + " suspendedBreakpoints=" + suspended.Length + " presentStackArgs=not-applicable");
+ SetFullX86Context(thread, ContextForRemoteCode(originalContext, acquire.Base, safeEsp));
+ return session;
+ }
+ catch
+ {
+ if (region != 0) VirtualFreeEx(process, Ptr(region), UIntPtr.Zero, 0x8000);
+ throw;
+ }
+ finally { CloseHandle(thread); }
+ }
+
+ private static bool SuspendBreakpointsWhileStopped(IntPtr process, BreakpointInfo[] candidates,
+ out BreakpointInfo[] suspended)
+ {
+ List<BreakpointInfo> active = new List<BreakpointInfo>();
+ if (candidates != null)
+ for (int i = 0; i < candidates.Length; i++)
+ {
+ BreakpointInfo breakpoint = candidates[i];
+ if (breakpoint == null || !breakpoint.Armed || active.Contains(breakpoint)) continue;
+ active.Add(breakpoint);
+ if (!WriteByte(process, breakpoint.Address, breakpoint.OriginalByte))
+ {
+ bool rollbackSucceeded = true;
+ foreach (BreakpointInfo suspendedBreakpoint in active)
+ {
+ if (!WriteByte(process, suspendedBreakpoint.Address, 0xCC))
+ {
+ rollbackSucceeded = false;
+ Log("FATAL boundary probe could not roll back breakpoint suspension for "
+ + suspendedBreakpoint.Name + " error=" + Marshal.GetLastWin32Error());
+ }
+ // Leave this true even after a failed write so final cleanup retries
+ // restoring the original byte before any detach.
+ suspendedBreakpoint.Armed = true;
+ }
+ suspended = active.ToArray();
+ Log("BOUNDARY_PROBE_SUSPEND_FAILED name=" + breakpoint.Name
+ + " rollbackSucceeded=" + rollbackSucceeded);
+ return false;
+ }
+ breakpoint.Armed = false;
+ Log("BOUNDARY_PROBE_BREAKPOINT_SUSPENDED " + breakpoint.Name);
+ }
+ suspended = active.ToArray();
+ return true;
+ }
+
+ private static void RearmSuspendedBreakpoints(IntPtr process, RemoteReadbackSession session)
+ {
+ if (session == null || session.SuspendedBreakpoints == null) return;
+ foreach (BreakpointInfo breakpoint in session.SuspendedBreakpoints)
+ {
+ if (breakpoint == null || breakpoint == session.ResumeBreakpoint) continue;
+ if (!WriteByte(process, breakpoint.Address, 0xCC))
+ throw new InvalidOperationException("Could not re-arm suspended boundary probe breakpoint " + breakpoint.Name);
+ breakpoint.Armed = true;
+ Log("BOUNDARY_PROBE_BREAKPOINT_REARMED " + breakpoint.Name);
+ }
+ if (session.ResumeBreakpoint != null) session.ResumeBreakpoint.Armed = true;
+ }
+
+ private static RemoteReadbackSession StartRemoteSurfaceProbe(IntPtr process, uint threadId,
+ uint ngiBase, BreakpointInfo resumeBreakpoint, FrameSnapshot frame,
+ BreakpointInfo[] allBreakpoints)
+ {
+ uint device;
+ uint getRenderTarget;
+ string evidence;
+ if (!VerifyD3d7GetRenderTarget(process, ngiBase, out device, out getRenderTarget, out evidence))
+ throw new InvalidOperationException("D3D7 surface probe refused: " + evidence);
+ uint renderer = ReadU32Exact(process, unchecked(ngiBase + NgiRendererGlobalRva));
+ if (frame == null || frame.Projection == null || !frame.Projection.Verified
+ || frame.Projection.Renderer != renderer
+ || ReadU32Exact(process, renderer) != unchecked(ngiBase + NgiRendererVtableRva))
+ throw new InvalidOperationException("Surface probe renderer did not match the verified active projection snapshot.");
+ Log("BOUNDARY_PROBE_GET_RENDER_TARGET_ABI " + evidence + " renderer=0x" + renderer.ToString("X8")
+ + " beginScene=" + (ReadU32Exact(process, unchecked(renderer + 0x118)) >> 8 & 0xFF));
+
+ IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext | ThreadQueryInformation, false, threadId);
+ if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread for D3D7 boundary probe failed: " + Marshal.GetLastWin32Error());
+ uint region = 0;
+ try
+ {
+ byte[] originalContext = GetFullX86Context(thread);
+ if (resumeBreakpoint == null || BitConverter.ToUInt32(originalContext, ContextEipOffset)
+ != unchecked(resumeBreakpoint.Address + 1))
+ throw new InvalidOperationException("Boundary probe EIP did not match the verified breakpoint.");
+ uint safeEsp = ComputeSafeNativeStackPointer(process, thread, originalContext);
+ region = AllocateReadbackRegion(process);
+ RemoteCode code = BuildSurfaceProbeStub(region, device);
+ WriteRemoteStub(process, code, true);
+ BreakpointInfo[] suspended;
+ if (!SuspendBreakpointsWhileStopped(process, allBreakpoints, out suspended))
+ throw new InvalidOperationException("Could not suspend every armed breakpoint before the read-only surface query.");
+ RemoteReadbackSession session = new RemoteReadbackSession();
+ session.Acquire = code;
+ session.Region = region;
+ session.ThreadId = threadId;
+ session.SafeEsp = safeEsp;
+ session.OriginalContext = originalContext;
+ session.RemoteContextIntact = true;
+ session.PresentBreakpoint = resumeBreakpoint;
+ session.ResumeBreakpoint = resumeBreakpoint;
+ session.SuspendedBreakpoints = suspended;
+ session.SurfaceProbeOnly = true;
+ session.Frame = frame;
+ session.Generation = frame.CameraGeneration;
+ session.DeadlineUtc = DateTime.UtcNow.AddSeconds(5);
+ session.Phase = RemoteReadbackPhase.Acquire;
+ SetFullX86Context(thread, ContextForRemoteCode(originalContext, code.Base, safeEsp));
+ Log("BOUNDARY_PROBE_STARTED kind=" + resumeBreakpoint.Name + " tid=" + threadId
+ + " device=0x" + device.ToString("X8") + " renderer=0x" + renderer.ToString("X8")
+ + " generation=" + session.Generation + " stub=0x" + code.Base.ToString("X8")
+ + " lockCalled=false");
+ return session;
+ }
+ catch
+ {
+ if (region != 0) VirtualFreeEx(process, Ptr(region), UIntPtr.Zero, 0x8000);
+ throw;
+ }
+ finally { CloseHandle(thread); }
+ }
+
private static void BeginRemoteCleanup(IntPtr process, RemoteReadbackSession session)
{
session.Cleanup = BuildReadbackStub(session.Region, 0, true);
@@ -1337,15 +1660,18 @@ internal static partial class NativeFrameCapture
uint returnAddress = ReadU32(process, esp);
uint camera = ReadU32(process, unchecked(esp + 4));
uint vtable = ReadU32(process, camera);
+ uint parent = ReadCameraParent(process, camera);
uint transformInterface = unchecked(camera + 4);
uint transformVtable = ReadU32(process, transformInterface);
uint selectorField = ReadU32(process, unchecked(camera + 0x10));
byte[] modeBytes = new byte[1];
byte cameraMode = ReadExact(process, unchecked(camera + 0x1A0), modeBytes) ? modeBytes[0] : (byte)0xFF;
byte[] matrixBytes = new byte[64];
- if (camera == 0 || !ReadExact(process, unchecked(camera + 0x20), matrixBytes))
+ byte[] effectiveWorldMatrixBytes = new byte[64];
+ if (camera == 0 || !ReadExact(process, unchecked(camera + 0x20), matrixBytes)
+ || !ReadExact(process, unchecked(camera + 0x60), effectiveWorldMatrixBytes))
{
- result.Failure = "World3D camera argument or 64-byte selector matrix was unreadable";
+ result.Failure = "World3D camera argument, selector-0 matrix, or effective-world matrix was unreadable";
return result;
}
uint[] words = new uint[16];
@@ -1355,6 +1681,15 @@ internal static partial class NativeFrameCapture
words[i] = BitConverter.ToUInt32(matrixBytes, i * 4);
if (!IsFinite(BitConverter.ToSingle(matrixBytes, i * 4))) matrixFinite = false;
}
+ uint[] effectiveWorldWords = MatrixWords(effectiveWorldMatrixBytes);
+ bool effectiveWorldMatrixFinite = true;
+ for (int i = 0; i < effectiveWorldWords.Length; i++)
+ if (!IsFinite(BitConverter.ToSingle(effectiveWorldMatrixBytes, i * 4)))
+ effectiveWorldMatrixFinite = false;
+ string worldMatrixFailure = null;
+ bool effectiveWorldMatrixRigid = effectiveWorldMatrixFinite
+ && IsOrthonormalAffineCamera(effectiveWorldMatrixBytes, out worldMatrixFailure);
+ if (!effectiveWorldMatrixFinite) worldMatrixFailure = "effective-world matrix contains a nonfinite value";
uint expectedVtable = unchecked(terrainBase + ExternalCameraVtableRva);
uint expectedTransformVtable = unchecked(terrainBase + 0x66558);
uint setterSlot = ReadU32(process, unchecked(transformVtable + 0x1C));
@@ -1363,6 +1698,8 @@ internal static partial class NativeFrameCapture
&& setterSlot == unchecked(terrainBase + 0x54C70)
&& invalidateSlot == unchecked(terrainBase + 0x55280);
result.Camera = camera;
+ result.Parent = parent;
+ result.Parented = parent != 0;
result.Vtable = vtable;
result.TransformInterface = transformInterface;
result.TransformVtable = transformVtable;
@@ -1371,14 +1708,19 @@ internal static partial class NativeFrameCapture
result.ReturnAddress = returnAddress;
result.SelectorField = selectorField;
result.Words = words;
+ result.EffectiveWorldWords = effectiveWorldWords;
result.EntryMatrixBytes = (byte[])matrixBytes.Clone();
+ result.EntryEffectiveWorldMatrixBytes = (byte[])effectiveWorldMatrixBytes.Clone();
result.EntryMatrixSha256 = HashBytes(matrixBytes);
+ result.EntryEffectiveWorldMatrixSha256 = HashBytes(effectiveWorldMatrixBytes);
result.MatrixFinite = matrixFinite;
+ result.EffectiveWorldMatrixFinite = effectiveWorldMatrixRigid;
result.LayoutVerified = terrainBase != 0 && vtable == expectedVtable
- && setterAbiVerified && cameraMode == 0
+ && setterAbiVerified && cameraMode == 0 && effectiveWorldMatrixRigid
&& selectorField == 0xFFFFFFFF && matrixFinite;
if (!result.LayoutVerified)
- result.Failure = "external camera primary/transform vtables, setter slots, normal mode, selector field, or matrix finite check failed";
+ result.Failure = "external camera primary/transform vtables, setter slots, normal mode, selector field, local matrix, or effective-world matrix check failed"
+ + (effectiveWorldMatrixRigid ? "" : ": " + worldMatrixFailure);
return result;
}
finally
@@ -1537,26 +1879,75 @@ internal static partial class NativeFrameCapture
private static SelectedCameraInput ParseSelectedCameraJson(string json, string sourcePath)
{
Dictionary<string, object> root = ParseJsonObject(json);
- bool renderInputUsable = RequiredJsonField(root, "render_input_usable") is bool
- && (bool)RequiredJsonField(root, "render_input_usable");
+ object renderInputUsableValue;
+ bool renderInputUsable = root.TryGetValue("render_input_usable", out renderInputUsableValue)
+ && renderInputUsableValue is bool && (bool)renderInputUsableValue;
+ object syntheticCameraValue;
+ bool syntheticCamera = root.TryGetValue("synthetic_camera", out syntheticCameraValue)
+ && syntheticCameraValue is bool && (bool)syntheticCameraValue;
+ bool nativeCapture = String.Equals(JsonStringField(root, "capture_status"),
+ "native-frame-captured", StringComparison.Ordinal) && renderInputUsable;
if (!String.Equals(JsonStringField(root, "schema"), "fparkan-legacy-camera-v1", StringComparison.Ordinal)
- || !String.Equals(JsonStringField(root, "capture_status"), "native-frame-captured", StringComparison.Ordinal)
- || !renderInputUsable)
- throw new InvalidDataException("Camera input must be a usable fparkan-legacy-camera-v1 native capture.");
+ || (!nativeCapture && !syntheticCamera))
+ throw new InvalidDataException("Camera input must be a usable native capture or an explicitly marked synthetic camera with a verified projection tuple.");
SelectedCameraInput input = new SelectedCameraInput();
input.Path = Path.GetFullPath(sourcePath);
- object[] words = JsonArrayField(root, "selector0_words");
- if (words.Length != 16) throw new InvalidDataException("selector0_words must contain exactly 16 uint32 values.");
- input.MatrixWords = new uint[16];
- input.MatrixBytes = new byte[64];
- for (int i = 0; i < words.Length; i++)
+ input.Synthetic = syntheticCamera;
+ object parentedValue;
+ if (root.TryGetValue("parented_camera", out parentedValue))
+ {
+ if (!(parentedValue is bool))
+ throw new InvalidDataException("parented_camera must be a boolean when present.");
+ input.ParentedCamera = (bool)parentedValue;
+ }
+
+ object[] localWords = JsonArrayField(root, "selector0_words");
+ if (localWords.Length != 16) throw new InvalidDataException("selector0_words must contain exactly 16 uint32 values.");
+ input.Selector0MatrixWords = new uint[16];
+ input.Selector0MatrixBytes = new byte[64];
+ for (int i = 0; i < localWords.Length; i++)
{
uint word;
- if (!TryJsonUInt32(words[i], out word))
+ if (!TryJsonUInt32(localWords[i], out word))
throw new InvalidDataException("selector0_words contains a value outside uint32 range.");
- input.MatrixWords[i] = word;
- Buffer.BlockCopy(BitConverter.GetBytes(word), 0, input.MatrixBytes, i * 4, 4);
+ input.Selector0MatrixWords[i] = word;
+ Buffer.BlockCopy(BitConverter.GetBytes(word), 0, input.Selector0MatrixBytes, i * 4, 4);
+ }
+
+ object worldSpaceValue;
+ object worldWordsValue;
+ bool hasWorldSpace = root.TryGetValue("effective_world_matrix_space", out worldSpaceValue);
+ bool hasWorldWords = root.TryGetValue("effective_world_matrix_words", out worldWordsValue);
+ if (hasWorldSpace != hasWorldWords)
+ throw new InvalidDataException("effective_world_matrix_space and effective_world_matrix_words must be supplied together.");
+ if (hasWorldSpace)
+ {
+ string worldSpace = worldSpaceValue as string;
+ if (!String.Equals(worldSpace, "world", StringComparison.Ordinal))
+ throw new InvalidDataException("effective_world_matrix_space must be 'world'.");
+ IList worldWordList = worldWordsValue as IList;
+ if (worldWordList == null || worldWordList.Count != 16)
+ throw new InvalidDataException("effective_world_matrix_words must contain exactly 16 uint32 values.");
+ input.MatrixWords = new uint[16];
+ input.MatrixBytes = new byte[64];
+ for (int i = 0; i < 16; i++)
+ {
+ uint word;
+ if (!TryJsonUInt32(worldWordList[i], out word))
+ throw new InvalidDataException("effective_world_matrix_words contains a value outside uint32 range.");
+ input.MatrixWords[i] = word;
+ Buffer.BlockCopy(BitConverter.GetBytes(word), 0, input.MatrixBytes, i * 4, 4);
+ }
+ input.UsedEffectiveWorldMatrix = true;
+ }
+ else
+ {
+ if (input.ParentedCamera)
+ throw new InvalidDataException("A parented camera requires its effective world matrix; selector0_words is local space.");
+ input.MatrixWords = (uint[])input.Selector0MatrixWords.Clone();
+ input.MatrixBytes = (byte[])input.Selector0MatrixBytes.Clone();
+ input.UsedEffectiveWorldMatrix = false;
}
string matrixFailure;
if (!IsOrthonormalAffineCamera(input.MatrixBytes, out matrixFailure))
@@ -1628,11 +2019,49 @@ internal static partial class NativeFrameCapture
+ "\"viewport\":[0,0,1280,1024],\"near_plane\":0.5,\"far_plane\":700,"
+ "\"field_of_view_radians\":1.04,\"projection_mode_byte\":1}";
SelectedCameraInput parsed = ParseSelectedCameraJson(valid, "input.json");
- if (parsed.MatrixBytes.Length != 64 || parsed.Viewport[2] != 1280 || parsed.MatrixSha256.Length != 64)
+ if (parsed.MatrixBytes.Length != 64 || parsed.Selector0MatrixBytes.Length != 64
+ || parsed.Viewport[2] != 1280 || parsed.MatrixSha256.Length != 64
+ || parsed.UsedEffectiveWorldMatrix || parsed.MatrixWords[3] != parsed.Selector0MatrixWords[3])
throw new InvalidOperationException("Camera JSON parser self-check failed on a valid rigid transform.");
+ string parentedWorld = valid.Replace("\"viewport\":",
+ "\"parented_camera\":true,\"effective_world_matrix_space\":\"world\","
+ + "\"effective_world_matrix_words\":[1065353216,0,0,1092616192,0,1065353216,0,1101004800,"
+ + "0,0,1065353216,1106247680,0,0,0,1065353216],\"viewport\":");
+ SelectedCameraInput parsedParentedWorld = ParseSelectedCameraJson(parentedWorld, "parented.json");
+ if (!parsedParentedWorld.ParentedCamera || !parsedParentedWorld.UsedEffectiveWorldMatrix
+ || parsedParentedWorld.MatrixWords[3] != 1092616192
+ || parsedParentedWorld.Selector0MatrixWords[3] != 1065353216)
+ throw new InvalidOperationException("Camera JSON parser did not prefer the effective world matrix while preserving selector-0 local words.");
+ if (CameraMatricesNearlyEqual(parsedParentedWorld.MatrixBytes,
+ parsedParentedWorld.Selector0MatrixBytes)
+ || parsedParentedWorld.Selector0MatrixWords[3] != 1065353216)
+ throw new InvalidOperationException("Camera setter self-check did not keep the original local matrix distinct for restore.");
+ bool rejected;
+ string parentedWithoutWorld = valid.Replace("\"viewport\":", "\"parented_camera\":true,\"viewport\":");
+ rejected = false;
+ try { ParseSelectedCameraJson(parentedWithoutWorld, "bad-parented.json"); }
+ catch (InvalidDataException) { rejected = true; }
+ if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a parented camera without an effective world matrix.");
+ string wrongWorldSpace = parentedWorld.Replace("\"effective_world_matrix_space\":\"world\"",
+ "\"effective_world_matrix_space\":\"local\"");
+ rejected = false;
+ try { ParseSelectedCameraJson(wrongWorldSpace, "bad-space.json"); }
+ catch (InvalidDataException) { rejected = true; }
+ if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a non-world effective matrix marker.");
+ string synthetic = valid.Replace("\"capture_status\":\"native-frame-captured\",",
+ "\"capture_status\":\"synthetic-camera-plan\",\"synthetic_camera\":true,")
+ .Replace("\"render_input_usable\":true,", "");
+ SelectedCameraInput parsedSynthetic = ParseSelectedCameraJson(synthetic, "synthetic-input.json");
+ if (!parsedSynthetic.Synthetic || parsedSynthetic.ProjectionMode != 1)
+ throw new InvalidOperationException("Camera JSON parser failed to preserve explicit synthetic-camera provenance.");
+ string unmarkedSynthetic = synthetic.Replace("\"synthetic_camera\":true", "\"synthetic_camera\":false");
+ rejected = false;
+ try { ParseSelectedCameraJson(unmarkedSynthetic, "bad.json"); }
+ catch (InvalidDataException) { rejected = true; }
+ if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted an unmarked synthetic camera plan.");
string zeroMatrix = valid.Replace("1065353216,0,0,1065353216,0,1065353216,0,1073741824,0,0,1065353216,1077936128,0,0,0,1065353216",
"0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0");
- bool rejected = false;
+ rejected = false;
try { ParseSelectedCameraJson(zeroMatrix, "bad.json"); }
catch (InvalidDataException) { rejected = true; }
if (!rejected) throw new InvalidOperationException("Camera JSON parser accepted a singular all-zero matrix.");
@@ -1664,15 +2093,20 @@ internal static partial class NativeFrameCapture
uint vtable = ReadU32(process, camera.Camera);
uint selectorField = ReadU32(process, unchecked(camera.Camera + 0x10));
+ uint parent = ReadCameraParent(process, camera.Camera);
uint expectedVtable = unchecked(terrainBase + ExternalCameraVtableRva);
byte[] current = new byte[64];
- if (vtable != expectedVtable || selectorField != 0xFFFFFFFF
- || !ReadExact(process, unchecked(camera.Camera + 0x20), current))
+ byte[] effectiveWorldCurrent = new byte[64];
+ if (vtable != expectedVtable || selectorField != 0xFFFFFFFF || parent != camera.Parent
+ || !ReadExact(process, unchecked(camera.Camera + 0x20), current)
+ || !ReadExact(process, unchecked(camera.Camera + 0x60), effectiveWorldCurrent))
{
camera.CurrentAtProjectionVerified = false;
- camera.Failure = "camera pointer, vtable, selector, or matrix changed before projection";
+ camera.EffectiveWorldAtProjectionVerified = false;
+ camera.Failure = "camera pointer, parent, vtable, selector, local matrix, or world matrix changed before projection";
Log("CAMERA_MATRIX_AT_PROJECTION_REJECTED generation=" + generation
+ " vtable=0x" + vtable.ToString("X8") + " selector=0x" + selectorField.ToString("X8")
+ + " parent=0x" + parent.ToString("X8") + " expectedParent=0x" + camera.Parent.ToString("X8")
+ " expectedVtable=0x" + expectedVtable.ToString("X8"));
return false;
}
@@ -1687,22 +2121,54 @@ internal static partial class NativeFrameCapture
if (!finite)
{
camera.CurrentAtProjectionVerified = false;
+ camera.EffectiveWorldAtProjectionVerified = false;
camera.Failure = "camera matrix contains a non-finite value at projection boundary";
Log("CAMERA_MATRIX_AT_PROJECTION_REJECTED generation=" + generation + " nonFinite=true");
return false;
}
+ uint[] effectiveWorldWords = MatrixWords(effectiveWorldCurrent);
+ bool effectiveWorldFinite = true;
+ for (int i = 0; i < effectiveWorldWords.Length; i++)
+ if (!IsFinite(BitConverter.ToSingle(effectiveWorldCurrent, i * 4)))
+ effectiveWorldFinite = false;
+ string worldFailure = null;
+ bool effectiveWorldRigid = effectiveWorldFinite
+ && IsOrthonormalAffineCamera(effectiveWorldCurrent, out worldFailure);
+ if (!effectiveWorldFinite) worldFailure = "effective-world matrix contains a nonfinite value";
+ if (!effectiveWorldRigid)
+ {
+ camera.CurrentAtProjectionVerified = false;
+ camera.EffectiveWorldAtProjectionVerified = false;
+ camera.Failure = "effective-world matrix failed validation at projection: " + worldFailure;
+ Log("CAMERA_WORLD_MATRIX_AT_PROJECTION_REJECTED generation=" + generation + " reason=" + worldFailure);
+ return false;
+ }
+
bool changed = !ByteArraysEqual(camera.EntryMatrixBytes, current);
+ bool effectiveWorldChanged = !ByteArraysEqual(camera.EntryEffectiveWorldMatrixBytes, effectiveWorldCurrent);
camera.Vtable = vtable;
camera.SelectorField = selectorField;
+ camera.Parent = parent;
+ camera.Parented = parent != 0;
camera.Words = words;
+ camera.EffectiveWorldWords = effectiveWorldWords;
camera.ProjectionMatrixBytes = (byte[])current.Clone();
+ camera.ProjectionEffectiveWorldMatrixBytes = (byte[])effectiveWorldCurrent.Clone();
camera.MatrixFinite = true;
+ camera.EffectiveWorldMatrixFinite = true;
camera.CurrentAtProjectionVerified = true;
+ camera.EffectiveWorldAtProjectionVerified = true;
camera.WordsChangedAtProjection = changed;
+ camera.EffectiveWorldChangedAtProjection = effectiveWorldChanged;
camera.ProjectionMatrixSha256 = HashBytes(current);
+ camera.ProjectionEffectiveWorldMatrixSha256 = HashBytes(effectiveWorldCurrent);
Log("CAMERA_MATRIX_AT_PROJECTION generation=" + generation + " verified=true changedSinceEntry=" + changed
+ " entrySha256=" + camera.EntryMatrixSha256 + " projectionSha256=" + camera.ProjectionMatrixSha256);
+ Log("CAMERA_WORLD_MATRIX_AT_PROJECTION generation=" + generation + " verified=true"
+ + " parent=0x" + parent.ToString("X8") + " changedSinceEntry=" + effectiveWorldChanged
+ + " entrySha256=" + camera.EntryEffectiveWorldMatrixSha256
+ + " projectionSha256=" + camera.ProjectionEffectiveWorldMatrixSha256);
return true;
}
@@ -1725,7 +2191,20 @@ internal static partial class NativeFrameCapture
throw new InvalidOperationException("DDLOCK flag self-check failed.");
RemoteCode acquire = BuildReadbackStub(0x10000000, 0x20000000, false);
RemoteCode cleanup = BuildReadbackStub(0x10000000, 0, true);
- RemoteCode cameraSetter = BuildCameraSetterStub(0x30000000, 0x40000000);
+ RemoteCode surfaceProbe = BuildSurfaceProbeStub(0x18000000, 0x28000000);
+ RemoteCode cameraSetterApply = BuildCameraSetterStub(0x30000000, 0x40000000, 2);
+ RemoteCode cameraSetterRestore = BuildCameraSetterStub(0x31000000, 0x40000000, 1);
+ int[] cameraApplyCalls = cameraSetterApply.CallInstructionOffsets;
+ int[] cameraRestoreCalls = cameraSetterRestore.CallInstructionOffsets;
+ int effectiveWorldGetterCall = cameraApplyCalls != null && cameraApplyCalls.Length > 1
+ ? cameraApplyCalls[1] : -1;
+ bool effectiveWorldGetterArgumentsMatch = effectiveWorldGetterCall >= 8
+ && cameraSetterApply.Bytes[effectiveWorldGetterCall - 1] == 0x56
+ && cameraSetterApply.Bytes[effectiveWorldGetterCall - 2] == 2
+ && cameraSetterApply.Bytes[effectiveWorldGetterCall - 3] == 0x6A
+ && cameraSetterApply.Bytes[effectiveWorldGetterCall - 8] == 0x68
+ && BitConverter.ToUInt32(cameraSetterApply.Bytes, effectiveWorldGetterCall - 7)
+ == 0x30000000 + RemoteDataOffset + CameraSetterWorldMatrixPointerOffset;
string[] d3d7DeviceVtable = new string[] {
"QueryInterface", "AddRef", "Release", "GetCaps", "EnumTextureFormats", "BeginScene", "EndScene",
"GetDirect3D", "SetRenderTarget", "GetRenderTarget", "Clear", "SetTransform", "GetTransform",
@@ -1759,11 +2238,28 @@ internal static partial class NativeFrameCapture
|| Marshal.OffsetOf(typeof(X86ContextLayout), "Esp").ToInt32() != ContextEspOffset
|| ContextAllX86 != 0x0001003F || acquire.Bytes.Length == 0 || cleanup.Bytes.Length == 0
|| acquire.MaxCallStackBytes != RemoteStubMaxCallStackBytes || cleanup.MaxCallStackBytes != 12
- || cameraSetter.Bytes.Length == 0 || cameraSetter.MaxCallStackBytes != 16
- || cameraSetter.CallInstructionOffsets == null || cameraSetter.CallInstructionOffsets.Length != 1
- || DecodeIndirectCallDisplacement(cameraSetter.Bytes, cameraSetter.CallInstructionOffsets[0])
+ || cameraSetterApply.Bytes.Length == 0 || cameraSetterApply.MaxCallStackBytes != 16
+ || cameraSetterApply.Bytes[12] != 0x6A || cameraSetterApply.Bytes[13] != 2
+ || cameraSetterRestore.Bytes[12] != 0x6A || cameraSetterRestore.Bytes[13] != 1
+ || CameraSetterMode(CameraSetterPhase.Apply) != 2 || CameraSetterMode(CameraSetterPhase.Restore) != 1
+ || cameraApplyCalls == null || cameraApplyCalls.Length != 2
+ || cameraRestoreCalls == null || cameraRestoreCalls.Length != 1
+ || DecodeIndirectCallDisplacement(cameraSetterApply.Bytes, cameraApplyCalls[0])
+ != CameraTransformSetterSlotOffset
+ || DecodeIndirectCallDisplacement(cameraSetterApply.Bytes, effectiveWorldGetterCall)
+ != CameraTransformGetterSlotOffset
+ || DecodeIndirectCallDisplacement(cameraSetterRestore.Bytes, cameraRestoreCalls[0])
!= CameraTransformSetterSlotOffset
- || cameraSetter.TrapAddress != 0x30000000 + cameraSetter.Bytes.Length - 1
+ || !effectiveWorldGetterArgumentsMatch
+ || cameraSetterApply.TrapAddress - cameraSetterApply.Base != (uint)(effectiveWorldGetterCall + 3)
+ || cameraSetterApply.TrapAddress != 0x30000000 + cameraSetterApply.Bytes.Length - 1
+ || surfaceProbe.Bytes.Length == 0 || surfaceProbe.MaxCallStackBytes != 12
+ || surfaceProbe.CallInstructionOffsets == null || surfaceProbe.CallInstructionOffsets.Length != 2
+ || DecodeIndirectCallDisplacement(surfaceProbe.Bytes, surfaceProbe.CallInstructionOffsets[0])
+ != D3dDeviceGetRenderTargetOffset
+ || DecodeIndirectCallDisplacement(surfaceProbe.Bytes, surfaceProbe.CallInstructionOffsets[1])
+ != DdSurfaceReleaseOffset
+ || surfaceProbe.TrapAddress != 0x18000000 + surfaceProbe.Bytes.Length - 1
|| VtableOffset(d3d7DeviceVtable, "GetRenderTarget") != D3dDeviceGetRenderTargetOffset
|| VtableOffset(surface7Vtable, "Lock") != DdSurfaceLockOffset
|| VtableOffset(surface7Vtable, "SetOverlayPosition") != 0x78
@@ -1824,8 +2320,9 @@ internal static partial class NativeFrameCapture
Rows = new byte[] { 1, 2, 3, 4, 5, 6 }, RgbMasksVerified = true
}), 4, 5, 6, 255, 1, 2, 3, 255);
Console.WriteLine("self-check: D3D7 GetRenderTarget and Surface7 Lock/Unlock/Release SDK vtable ordinals OK");
+ Console.WriteLine("self-check: boundary surface probe calls GetRenderTarget/Release only; no Lock; 12-byte max stack OK");
Console.WriteLine("self-check: caller stack footprint (24/12 bytes), original ESP, committed writable range and guard rejection OK");
- Console.WriteLine("self-check: selected-camera setter stub uses verified interface slot +0x1C and 16-byte maximum caller stack OK");
+ Console.WriteLine("self-check: selected-camera mode-2 setter refreshes selector-2 world cache before trap; local restore uses verified setter slot +0x1C; 16-byte maximum caller stack OK");
Console.WriteLine("self-check: 24-byte present call arguments compare unchanged and detect a changed byte");
Console.WriteLine("self-check: return context logging verifies remote EIP/ESP before restoring the original present call");
Console.WriteLine("self-check: viewport bounds fit the captured pixel surface OK");
diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs
index 7546831..6371ae6 100644
--- a/tools/native-frame-capture/NativeFrameCapture.cs
+++ b/tools/native-frame-capture/NativeFrameCapture.cs
@@ -24,6 +24,7 @@ internal static partial class NativeFrameCapture
private const uint PageExecuteReadWrite = 0x40;
private const uint ThreadGetContext = 0x0008;
private const uint ThreadSetContext = 0x0010;
+ private const int ContextEaxOffset = 176;
private const int ContextEipOffset = 184;
private const int ContextEbxOffset = 164;
private const int ContextEdxOffset = 168;
@@ -56,6 +57,9 @@ internal static partial class NativeFrameCapture
private const uint FogGeometryVtableRva = 0x64E48;
private const uint FogGeometryDistanceMethodRva = 0x246A0;
private const uint PresentBoundaryRva = 0x6E1B;
+ private const uint PresentCompletionRva = 0x6E2C;
+ private const uint BeginSceneStateRva = 0x6FB8;
+ private const uint EndSceneStateRva = 0x7008;
private const uint RelocatedGlobalRva = 0x79518C;
private const uint ExternalCameraVtableRva = 0x665B4;
private const uint MissionFactoryRva = 0xA1FE1;
@@ -63,6 +67,7 @@ internal static partial class NativeFrameCapture
private const uint MissionImportThunkRva = 0xCD01C;
private const int MissionProbeAttemptLimit = 8;
private const int FrameCaptureTimeoutSeconds = 300;
+ private const int BoundaryTraceTimeoutSeconds = 60;
private const int NativeFogSampleLimit = 512;
private static readonly byte[] FogConsumerFileSignature = new byte[]
{ 0xD9, 0x42, 0x14, 0xD8, 0x15, 0x7C, 0x41, 0x06, 0x10 };
@@ -218,6 +223,129 @@ internal static partial class NativeFrameCapture
public uint ReturnAddress;
public byte[] OriginalMatrixBytes;
public string OriginalMatrixSha256;
+ public uint OriginalParentPointer;
+ }
+
+ private sealed class BoundaryTraceProgress
+ {
+ public bool BeginSceneSeen;
+ public uint BeginSceneGeneration;
+ public uint BeginSceneThread;
+ public bool ProjectionSelected;
+ public uint SelectedGeneration;
+ public uint SelectedThread;
+ public bool EndSceneSeen;
+ public bool PresentSeen;
+ public bool WorldReturnSeen;
+ public bool AttributionInvalidated;
+
+ public bool ObserveBeginScene(uint generation, uint cameraThreadId, uint eventThreadId,
+ uint rendererState, bool succeeded)
+ {
+ if (AttributionInvalidated || ProjectionSelected || !succeeded || rendererState != 1 || generation == 0
+ || cameraThreadId == 0 || eventThreadId != cameraThreadId) return false;
+ BeginSceneSeen = true;
+ BeginSceneGeneration = generation;
+ BeginSceneThread = eventThreadId;
+ return true;
+ }
+
+ public bool ObserveProjection(uint generation, uint cameraThreadId, uint eventThreadId,
+ bool verifiedPair, bool selectorMatch)
+ {
+ if (ProjectionSelected)
+ {
+ if (!verifiedPair || !selectorMatch || generation == 0 || cameraThreadId == 0
+ || eventThreadId != cameraThreadId
+ || !IsMatchingBoundaryTracePair(SelectedGeneration, SelectedThread,
+ generation, eventThreadId))
+ AttributionInvalidated = true;
+ return false;
+ }
+ if (AttributionInvalidated || !verifiedPair || !selectorMatch || generation == 0
+ || cameraThreadId == 0 || eventThreadId != cameraThreadId) return false;
+ if (!BeginSceneSeen || !IsMatchingBoundaryTracePair(
+ BeginSceneGeneration, BeginSceneThread, generation, eventThreadId)) return false;
+ ProjectionSelected = true;
+ SelectedGeneration = generation;
+ SelectedThread = eventThreadId;
+ return true;
+ }
+
+ public bool ObserveEndScene(uint generation, uint cameraThreadId, uint eventThreadId,
+ uint rendererState, bool succeeded)
+ {
+ if (!ProjectionSelected || !BeginSceneSeen || EndSceneSeen || AttributionInvalidated
+ || !succeeded || rendererState != 0 || cameraThreadId != eventThreadId
+ || !IsMatchingBoundaryTracePair(SelectedGeneration, SelectedThread,
+ generation, eventThreadId)) return false;
+ EndSceneSeen = true;
+ return true;
+ }
+
+ public bool ObservePresent(uint generation, uint cameraThreadId, uint eventThreadId)
+ {
+ if (!ProjectionSelected || !EndSceneSeen || PresentSeen || AttributionInvalidated
+ || cameraThreadId != eventThreadId
+ || !IsMatchingBoundaryTracePair(SelectedGeneration, SelectedThread,
+ generation, eventThreadId)) return false;
+ PresentSeen = true;
+ return true;
+ }
+
+ public bool ObserveWorldReturn(uint generation, uint cameraThreadId, uint eventThreadId)
+ {
+ if (!ProjectionSelected || !PresentSeen || WorldReturnSeen || AttributionInvalidated
+ || cameraThreadId != eventThreadId
+ || !IsMatchingBoundaryTracePair(SelectedGeneration, SelectedThread,
+ generation, eventThreadId)) return false;
+ WorldReturnSeen = true;
+ return true;
+ }
+
+ public void ResetBeginScene()
+ {
+ if (ProjectionSelected) return;
+ BeginSceneSeen = false;
+ BeginSceneGeneration = 0;
+ BeginSceneThread = 0;
+ }
+
+ public bool ObserveCameraEntry()
+ {
+ if (ProjectionSelected && !WorldReturnSeen)
+ {
+ Invalidate();
+ return true;
+ }
+ if (!ProjectionSelected) ResetBeginScene();
+ return false;
+ }
+
+ public void Invalidate()
+ {
+ AttributionInvalidated = true;
+ }
+
+ public bool IsSequenceComplete()
+ {
+ return BeginSceneSeen && ProjectionSelected && EndSceneSeen && PresentSeen
+ && WorldReturnSeen && !AttributionInvalidated;
+ }
+
+ public bool IsComplete(bool breakpointsRestored, bool detached, bool fatal,
+ bool processExited, bool probesSucceeded)
+ {
+ return IsSequenceComplete() && breakpointsRestored && detached
+ && !fatal && !processExited && probesSucceeded;
+ }
+ }
+
+ private static bool IsMatchingBoundaryTracePair(uint expectedGeneration, uint expectedThread,
+ uint generation, uint threadId)
+ {
+ return expectedGeneration != 0 && expectedThread != 0
+ && generation == expectedGeneration && threadId == expectedThread;
}
private enum RemoteReadbackPhase { Acquire, Cleanup }
@@ -232,6 +360,7 @@ internal static partial class NativeFrameCapture
public byte[] OriginalContext;
public byte[] PresentStackArguments;
public bool PresentStackArgumentsIntact;
+ public bool ValidatePresentStackArguments;
public bool RemoteContextIntact;
public BreakpointInfo PresentBreakpoint;
public FrameSnapshot Frame;
@@ -247,11 +376,17 @@ internal static partial class NativeFrameCapture
public uint GetHr;
public uint LockHr;
public uint UnlockHr;
+ public bool SurfaceProbeOnly;
+ public string PixelBoundary;
+ public BreakpointInfo ResumeBreakpoint;
+ public BreakpointInfo[] SuspendedBreakpoints;
+ public uint ProbeSurfacePointer;
}
[StructLayout(LayoutKind.Explicit, Size = X86ContextSize)]
private struct X86ContextLayout
{
+ [FieldOffset(ContextEaxOffset)] public uint Eax;
[FieldOffset(ContextEipOffset)] public uint Eip;
[FieldOffset(ContextEsiOffset)] public uint Esi;
[FieldOffset(ContextEbpOffset)] public uint Ebp;
@@ -948,6 +1083,51 @@ internal static partial class NativeFrameCapture
return match;
}
+ private static bool VerifyPresentCompletionBoundary(IntPtr process, uint ngiBase,
+ out byte firstByte, out string evidence)
+ {
+ byte[] expected = new byte[] { 0x5E, 0x83, 0xC4, 0x14, 0xC3 };
+ byte[] actual = new byte[expected.Length];
+ bool readable = ReadExact(process, unchecked(ngiBase + PresentCompletionRva), actual);
+ firstByte = readable ? actual[0] : (byte)0;
+ bool match = readable && ByteArraysEqual(expected, actual);
+ evidence = "base=0x" + ngiBase.ToString("X8") + " RVA=0x" + PresentCompletionRva.ToString("X")
+ + " bytes=" + BitConverter.ToString(actual)
+ + " post-call=0x6E1E pop ebx/pop edi; HRESULT stored at +0x6E27; post-store boundary=0x6E2C";
+ return match;
+ }
+
+ private static bool VerifySceneStateBoundaries(IntPtr process, uint ngiBase,
+ out byte beginByte, out byte endByte, out string evidence)
+ {
+ byte[] beginExpected = new byte[] {
+ 0xFF, 0x51, 0x14, 0xA3, 0, 0, 0, 0,
+ 0xC6, 0x86, 0x19, 0x01, 0x00, 0x00, 0x01, 0x5E
+ };
+ byte[] endExpected = new byte[] {
+ 0xFF, 0x51, 0x18, 0xA3, 0, 0, 0, 0,
+ 0xC6, 0x86, 0x19, 0x01, 0x00, 0x00, 0x00,
+ 0xC6, 0x86, 0x1A, 0x01, 0x00, 0x00, 0x01
+ };
+ byte[] lastErrorAddress = BitConverter.GetBytes(unchecked(ngiBase + NgiLastErrorGlobalRva));
+ Buffer.BlockCopy(lastErrorAddress, 0, beginExpected, 4, 4);
+ Buffer.BlockCopy(lastErrorAddress, 0, endExpected, 4, 4);
+ byte[] beginActual = new byte[beginExpected.Length];
+ byte[] endActual = new byte[endExpected.Length];
+ bool beginRead = ReadExact(process, unchecked(ngiBase + BeginSceneStateRva - 15), beginActual);
+ bool endRead = ReadExact(process, unchecked(ngiBase + EndSceneStateRva - 15), endActual);
+ beginByte = beginRead ? beginActual[15] : (byte)0;
+ endByte = endRead ? endActual[15] : (byte)0;
+ bool match = beginRead && endRead && ByteArraysEqual(beginExpected, beginActual)
+ && ByteArraysEqual(endExpected, endActual);
+ evidence = "base=0x" + ngiBase.ToString("X8") + " relocatedLastError=0x"
+ + unchecked(ngiBase + NgiLastErrorGlobalRva).ToString("X8")
+ + " begin=RVA0x" + BeginSceneStateRva.ToString("X") + " bytes=" + BitConverter.ToString(beginActual)
+ + " end=RVA0x" + EndSceneStateRva.ToString("X") + " bytes=" + BitConverter.ToString(endActual)
+ + " beginFlagOffset=renderer+0x119 endFlagOffset=renderer+0x119";
+ return match;
+ }
+
private static byte[] ContextAtBreakpoint(byte[] original, uint address, bool singleStep)
{
if (original == null || original.Length != X86ContextSize)
@@ -999,17 +1179,18 @@ internal static partial class NativeFrameCapture
breakpoint.Armed = pending.RearmOnComplete;
}
- private static void ResumeOriginalPresent(IntPtr process, RemoteReadbackSession session)
+ private static void ResumeReadbackBoundary(IntPtr process, RemoteReadbackSession session)
{
BreakpointInfo breakpoint = session.PresentBreakpoint;
+ if (breakpoint == null) throw new InvalidOperationException("Readback resume boundary is missing.");
if (breakpoint.Armed)
{
if (!WriteByte(process, breakpoint.Address, breakpoint.OriginalByte))
- throw new InvalidOperationException("Could not restore Ngi32 present call after readback.");
+ throw new InvalidOperationException("Could not restore " + breakpoint.Name + " after readback.");
breakpoint.Armed = false;
}
IntPtr thread = OpenThread(ThreadGetContext | ThreadSetContext, false, session.ThreadId);
- if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread to resume Ngi32 present failed: " + Marshal.GetLastWin32Error());
+ if (thread == IntPtr.Zero) throw new InvalidOperationException("OpenThread to resume " + breakpoint.Name + " failed: " + Marshal.GetLastWin32Error());
try
{
byte[] resumeContext = ContextAtBreakpoint(session.OriginalContext, breakpoint.Address, false);
@@ -1018,16 +1199,31 @@ internal static partial class NativeFrameCapture
uint expectedEsp = BitConverter.ToUInt32(session.OriginalContext, ContextEspOffset);
uint restoredEip = BitConverter.ToUInt32(restoredContext, ContextEipOffset);
uint restoredEsp = BitConverter.ToUInt32(restoredContext, ContextEspOffset);
- Log("PRESENT_RESUME_CONTEXT expectedEip=0x" + breakpoint.Address.ToString("X8")
+ Log("READBACK_RESUME_CONTEXT boundary=" + breakpoint.Name + " expectedEip=0x" + breakpoint.Address.ToString("X8")
+ " actualEip=0x" + restoredEip.ToString("X8")
+ " expectedEsp=0x" + expectedEsp.ToString("X8")
+ " actualEsp=0x" + restoredEsp.ToString("X8"));
if (restoredEip != breakpoint.Address || restoredEsp != expectedEsp)
- throw new InvalidOperationException("Could not verify restored Ngi32 present EIP/ESP.");
+ throw new InvalidOperationException("Could not verify restored " + breakpoint.Name + " EIP/ESP.");
}
finally { CloseHandle(thread); }
}
+ private static PendingStep ResumeReadbackAfterRemote(IntPtr process, RemoteReadbackSession session)
+ {
+ if (session.SuspendedBreakpoints == null)
+ {
+ ResumeReadbackBoundary(process, session);
+ return null;
+ }
+ RearmSuspendedBreakpoints(process, session);
+ PendingStep pending;
+ BeginSingleStep(process, session.ThreadId, session.ResumeBreakpoint,
+ session.OriginalContext, out pending, true);
+ Log("SINGLE_STEP_STARTED boundary=" + session.ResumeBreakpoint.Name + " tid=" + session.ThreadId);
+ return pending;
+ }
+
private static bool CanStopCapture(bool captured, bool stopAfterAttempt,
bool remoteInFlight, bool singleStepInFlight, bool cameraRestorePending)
{
@@ -1047,13 +1243,28 @@ internal static partial class NativeFrameCapture
&& SelectedMatrixMatchesProjection(frame)));
}
+ private static bool CanReadSelectedBoundaryPixels(bool selected, bool invalidated,
+ bool sameThread, bool sameGeneration, bool sameRenderer, uint sceneFlag, uint endSceneHresult)
+ {
+ return selected && !invalidated && sameThread && sameGeneration && sameRenderer
+ && sceneFlag == 0 && endSceneHresult == 0;
+ }
+
+ private static bool CanAcceptBoundaryPresent(bool pixelsStaged, bool invalidated,
+ bool sameThread, bool sameGeneration, uint presentHresult)
+ {
+ return pixelsStaged && !invalidated && sameThread && sameGeneration && presentHresult == 0;
+ }
+
private static bool SelectedMatrixMatchesProjection(FrameSnapshot frame)
{
if (frame == null || !frame.SelectedCameraRequested) return true;
return frame.SelectedCamera != null && frame.SelectedCamera.Input != null
&& frame.Camera != null && frame.Camera.CurrentAtProjectionVerified
- && !frame.Camera.WordsChangedAtProjection
- && ByteArraysEqual(frame.Camera.ProjectionMatrixBytes, frame.SelectedCamera.Input.MatrixBytes);
+ && frame.Camera.EffectiveWorldAtProjectionVerified
+ && !frame.Camera.EffectiveWorldChangedAtProjection
+ && CameraMatricesNearlyEqual(frame.Camera.ProjectionEffectiveWorldMatrixBytes,
+ frame.SelectedCamera.Input.MatrixBytes);
}
private static bool CanArmPresentForFrame(FrameSnapshot frame, uint threadId)
@@ -1100,7 +1311,8 @@ internal static partial class NativeFrameCapture
return selected != null && selected.Applied && selected.CameraPointer == frame.Camera.Camera
&& selected.ThreadId == threadId && selected.Generation == frame.CameraGeneration
&& selected.Input != null && selected.Input.MatrixBytes != null
- && ByteArraysEqual(frame.Camera.EntryMatrixBytes, selected.Input.MatrixBytes)
+ && frame.Camera.EffectiveWorldMatrixFinite
+ && CameraMatricesNearlyEqual(frame.Camera.EntryEffectiveWorldMatrixBytes, selected.Input.MatrixBytes)
&& !selected.PixelAttributionInvalidated;
}
@@ -1581,6 +1793,7 @@ internal static partial class NativeFrameCapture
CameraReadback camera = frame.Camera;
ProjectionReadback projection = frame.Projection;
if (!camera.LayoutVerified || !camera.MatrixFinite || !camera.CurrentAtProjectionVerified
+ || !camera.EffectiveWorldMatrixFinite || !camera.EffectiveWorldAtProjectionVerified
|| !projection.Verified || projection.Mode == 0)
throw new InvalidOperationException("Refusing to emit app-compatible JSON from an unverified camera or non-perspective projection.");
if (!IsViewportInsideSurface(projection.Viewport, surface.Width, surface.Height))
@@ -1590,18 +1803,27 @@ internal static partial class NativeFrameCapture
json.AppendLine(" \"schema\": \"fparkan-legacy-camera-v1\",");
json.AppendLine(" \"capture_status\": \"native-frame-captured\",");
json.AppendLine(" \"render_input_usable\": true,");
+ json.AppendLine(" \"pixel_capture_boundary\": " + JsonString(remote == null || String.IsNullOrEmpty(remote.PixelBoundary)
+ ? "Ngi32 Present" : remote.PixelBoundary) + ",");
+ json.AppendLine(" \"pixel_review_status\": \"candidate-awaiting-visual-review\",");
json.AppendLine(" \"source\": \"GOG World3D stdRenderGame + Ngi32 D3D7 render target\",");
json.AppendLine(" \"scratch_executable\": " + JsonString(gamePath) + ",");
json.AppendLine(" \"world3d_module_base\": " + JsonString("0x" + world3dBase.ToString("X8")) + ",");
json.AppendLine(" \"ngi32_module_base\": " + JsonString("0x" + ngiBase.ToString("X8")) + ",");
json.AppendLine(" \"frame_generation\": " + frame.CameraGeneration.ToString(CultureInfo.InvariantCulture) + ",");
json.AppendLine(" \"camera_argument\": " + JsonString("0x" + camera.Camera.ToString("X8")) + ",");
+ json.AppendLine(" \"camera_parent\": " + JsonString("0x" + camera.Parent.ToString("X8")) + ",");
+ json.AppendLine(" \"parented_camera\": " + (camera.Parented ? "true" : "false") + ",");
json.AppendLine(" \"camera_vtable\": " + JsonString("0x" + camera.Vtable.ToString("X8")) + ",");
json.AppendLine(" \"selector_field_plus_0x10\": " + JsonString("0x" + camera.SelectorField.ToString("X8")) + ",");
json.AppendLine(" \"camera_words_sampled_at\": \"World3D+0x13CE4\",");
json.AppendLine(" \"camera_words_changed_since_render_entry\": " + (camera.WordsChangedAtProjection ? "true" : "false") + ",");
json.AppendLine(" \"camera_words_render_entry_sha256\": " + JsonString(camera.EntryMatrixSha256) + ",");
json.AppendLine(" \"camera_words_projection_boundary_sha256\": " + JsonString(camera.ProjectionMatrixSha256) + ",");
+ json.AppendLine(" \"effective_world_matrix_space\": \"world\",");
+ json.AppendLine(" \"effective_world_matrix_render_entry_sha256\": " + JsonString(camera.EntryEffectiveWorldMatrixSha256) + ",");
+ json.AppendLine(" \"effective_world_matrix_projection_boundary_sha256\": " + JsonString(camera.ProjectionEffectiveWorldMatrixSha256) + ",");
+ json.AppendLine(" \"effective_world_matrix_changed_since_render_entry\": " + (camera.EffectiveWorldChangedAtProjection ? "true" : "false") + ",");
json.Append(" \"selector0_words\": [");
for (int i = 0; i < camera.Words.Length; i++)
{
@@ -1609,6 +1831,13 @@ internal static partial class NativeFrameCapture
json.Append(camera.Words[i].ToString(CultureInfo.InvariantCulture));
}
json.AppendLine("],");
+ json.Append(" \"effective_world_matrix_words\": [");
+ for (int i = 0; i < camera.EffectiveWorldWords.Length; i++)
+ {
+ if (i != 0) json.Append(", ");
+ json.Append(camera.EffectiveWorldWords[i].ToString(CultureInfo.InvariantCulture));
+ }
+ json.AppendLine("],");
json.AppendLine(" \"viewport\": [" + String.Join(", ", projection.Viewport) + "],");
json.AppendLine(" \"near_plane\": " + projection.Near.ToString("R", CultureInfo.InvariantCulture) + ",");
json.AppendLine(" \"far_plane\": " + projection.Far.ToString("R", CultureInfo.InvariantCulture) + ",");
@@ -1620,6 +1849,8 @@ internal static partial class NativeFrameCapture
SelectedCameraState selected = frame.SelectedCamera;
json.AppendLine(" \"selected_camera\": {");
json.AppendLine(" \"input_json\": " + JsonString(selected.Input.Path) + ",");
+ json.AppendLine(" \"input_kind\": " + JsonString(selected.Input.Synthetic
+ ? "synthetic-camera-plan" : "native-capture") + ",");
json.AppendLine(" \"requested_matrix_sha256\": " + JsonString(selected.Input.MatrixSha256) + ",");
json.AppendLine(" \"preflight_camera_argument\": " + JsonString("0x" + selected.CandidateCameraPointer.ToString("X8")) + ",");
json.AppendLine(" \"preflight_thread_id\": " + selected.CandidateThreadId.ToString(CultureInfo.InvariantCulture) + ",");
@@ -1729,19 +1960,23 @@ internal static partial class NativeFrameCapture
&& Marshal.OffsetOf(typeof(X86ContextLayout), "Ebp").ToInt32() == ContextEbpOffset
&& Marshal.OffsetOf(typeof(X86ContextLayout), "Esp").ToInt32() == ContextEspOffset;
FrameSnapshot matched = new FrameSnapshot { CameraGeneration = 7, CameraThreadId = 11,
- Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true },
+ Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true,
+ EffectiveWorldMatrixFinite = true, EffectiveWorldAtProjectionVerified = true },
ProjectionGeneration = 7, ProjectionThreadId = 11,
Projection = new ProjectionReadback { Verified = true, Mode = 1 } };
byte[] selectedMatrixBytes = new byte[64];
- selectedMatrixBytes[0] = 1;
+ Buffer.BlockCopy(BitConverter.GetBytes(1.0f), 0, selectedMatrixBytes, 0, 4);
byte[] changedSelectedMatrixBytes = (byte[])selectedMatrixBytes.Clone();
- changedSelectedMatrixBytes[0] = 2;
+ Buffer.BlockCopy(BitConverter.GetBytes(2.0f), 0, changedSelectedMatrixBytes, 0, 4);
SelectedCameraInput selectedInput = new SelectedCameraInput { MatrixBytes = selectedMatrixBytes };
FrameSnapshot selectedMatched = new FrameSnapshot
{
CameraGeneration = 8, CameraThreadId = 11,
Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true,
- EntryMatrixBytes = (byte[])selectedMatrixBytes.Clone(), ProjectionMatrixBytes = (byte[])selectedMatrixBytes.Clone() },
+ EffectiveWorldMatrixFinite = true, EffectiveWorldAtProjectionVerified = true,
+ EntryMatrixBytes = (byte[])selectedMatrixBytes.Clone(), ProjectionMatrixBytes = (byte[])selectedMatrixBytes.Clone(),
+ EntryEffectiveWorldMatrixBytes = (byte[])selectedMatrixBytes.Clone(),
+ ProjectionEffectiveWorldMatrixBytes = (byte[])selectedMatrixBytes.Clone() },
ProjectionGeneration = 8, ProjectionThreadId = 11,
Projection = new ProjectionReadback { Verified = true, Mode = 1 },
SelectedCameraRequested = true,
@@ -1778,7 +2013,10 @@ internal static partial class NativeFrameCapture
{
CameraGeneration = 8, CameraThreadId = 11,
Camera = new CameraReadback { LayoutVerified = true, MatrixFinite = true, CurrentAtProjectionVerified = true,
- EntryMatrixBytes = (byte[])selectedMatrixBytes.Clone(), ProjectionMatrixBytes = changedSelectedMatrixBytes },
+ EffectiveWorldMatrixFinite = true, EffectiveWorldAtProjectionVerified = true,
+ EntryMatrixBytes = (byte[])selectedMatrixBytes.Clone(), ProjectionMatrixBytes = (byte[])changedSelectedMatrixBytes.Clone(),
+ EntryEffectiveWorldMatrixBytes = (byte[])selectedMatrixBytes.Clone(),
+ ProjectionEffectiveWorldMatrixBytes = changedSelectedMatrixBytes },
ProjectionGeneration = 8, ProjectionThreadId = 11,
Projection = new ProjectionReadback { Verified = true, Mode = 1 },
SelectedCameraRequested = true,
@@ -1856,6 +2094,86 @@ internal static partial class NativeFrameCapture
bool selectedIntervenedRejected = !IsMatchingPerspectiveFrame(selectedIntervened, 11);
bool selectedChangedMatrixRejected = !CanArmPresentForFrame(selectedMatrixChanged, 11)
&& !IsMatchingPerspectiveFrame(selectedMatrixChanged, 11);
+ bool selectedBoundaryReadAllowed = CanReadSelectedBoundaryPixels(true, false,
+ true, true, true, 0, 0);
+ bool boundaryReadRejectsUnselected = !CanReadSelectedBoundaryPixels(false, false,
+ true, true, true, 0, 0);
+ bool boundaryReadRejectsInvalidated = !CanReadSelectedBoundaryPixels(true, true,
+ true, true, true, 0, 0);
+ bool boundaryReadRejectsWrongThread = !CanReadSelectedBoundaryPixels(true, false,
+ false, true, true, 0, 0);
+ bool boundaryReadRejectsWrongGeneration = !CanReadSelectedBoundaryPixels(true, false,
+ true, false, true, 0, 0);
+ bool boundaryReadRejectsWrongRenderer = !CanReadSelectedBoundaryPixels(true, false,
+ true, true, false, 0, 0);
+ bool boundaryReadRejectsActiveScene = !CanReadSelectedBoundaryPixels(true, false,
+ true, true, true, 1, 0);
+ bool boundaryReadRejectsFailedEndScene = !CanReadSelectedBoundaryPixels(true, false,
+ true, true, true, 0, 0x80004005);
+ bool boundaryPresentAccepted = CanAcceptBoundaryPresent(true, false,
+ true, true, 0);
+ bool boundaryPresentRejectsChangedFrame = !CanAcceptBoundaryPresent(true, false,
+ true, false, 0);
+ bool boundaryPresentRejectsFailed = !CanAcceptBoundaryPresent(true, false,
+ true, true, 0x80004005);
+ bool boundaryPresentRejectsInvalidated = !CanAcceptBoundaryPresent(true, true,
+ true, true, 0);
+ BoundaryTraceProgress traceHappyPath = new BoundaryTraceProgress();
+ bool traceBeginAccepted = traceHappyPath.ObserveBeginScene(7, 11, 11, 1, true);
+ bool traceProjectionAccepted = traceHappyPath.ObserveProjection(7, 11, 11, true, true);
+ bool traceEndAccepted = traceHappyPath.ObserveEndScene(7, 11, 11, 0, true);
+ bool tracePresentAccepted = traceHappyPath.ObservePresent(7, 11, 11);
+ bool traceReturnAccepted = traceHappyPath.ObserveWorldReturn(7, 11, 11);
+ bool traceSequenceComplete = traceHappyPath.IsSequenceComplete();
+ bool traceProbeFailureRejected = !traceHappyPath.IsComplete(true, true, false, false, false);
+ bool traceFatalRejected = !traceHappyPath.IsComplete(true, true, true, false, true);
+ bool traceExitedRejected = !traceHappyPath.IsComplete(true, true, false, true, true);
+ bool traceCleanupRequired = !traceHappyPath.IsComplete(false, true, false, false, true)
+ && !traceHappyPath.IsComplete(true, false, false, false, true)
+ && traceHappyPath.IsComplete(true, true, false, false, true);
+ BoundaryTraceProgress traceFailedBegin = new BoundaryTraceProgress();
+ bool traceFailedBeginRejected = !traceFailedBegin.ObserveBeginScene(7, 11, 11, 1, false)
+ && !traceFailedBegin.ObserveBeginScene(7, 11, 11, 0, true)
+ && !traceFailedBegin.ObserveProjection(7, 11, 11, true, true);
+ BoundaryTraceProgress traceOutOfOrder = new BoundaryTraceProgress();
+ bool traceOutOfOrderRejected = traceOutOfOrder.ObserveBeginScene(7, 11, 11, 1, true)
+ && !traceOutOfOrder.ObserveEndScene(7, 11, 11, 0, true)
+ && !traceOutOfOrder.ObservePresent(7, 11, 11)
+ && !traceOutOfOrder.ObserveWorldReturn(7, 11, 11)
+ && !traceOutOfOrder.IsSequenceComplete();
+ BoundaryTraceProgress traceWrongPairs = new BoundaryTraceProgress();
+ bool traceWrongPairBegin = traceWrongPairs.ObserveBeginScene(7, 11, 11, 1, true);
+ bool traceWrongPairsRejected = !traceWrongPairs.ObserveProjection(8, 11, 11, true, true)
+ && !traceWrongPairs.ObserveProjection(7, 11, 12, true, true)
+ && traceWrongPairs.ObserveProjection(7, 11, 11, true, true)
+ && !traceWrongPairs.ObserveEndScene(8, 11, 11, 0, true)
+ && !traceWrongPairs.ObserveEndScene(7, 11, 12, 0, true)
+ && traceWrongPairs.ObserveEndScene(7, 11, 11, 0, true)
+ && !traceWrongPairs.ObservePresent(8, 11, 11)
+ && !traceWrongPairs.ObservePresent(7, 11, 12)
+ && traceWrongPairs.ObservePresent(7, 11, 11)
+ && !traceWrongPairs.ObserveWorldReturn(8, 11, 11)
+ && !traceWrongPairs.ObserveWorldReturn(7, 11, 12)
+ && traceWrongPairs.ObserveWorldReturn(7, 11, 11);
+ BoundaryTraceProgress traceReplacement = new BoundaryTraceProgress();
+ bool traceReplacementInvalidated = traceReplacement.ObserveBeginScene(7, 11, 11, 1, true)
+ && traceReplacement.ObserveProjection(7, 11, 11, true, true)
+ && !traceReplacement.ObserveProjection(8, 11, 11, true, true)
+ && traceReplacement.AttributionInvalidated && !traceReplacement.IsSequenceComplete();
+ BoundaryTraceProgress traceProjectionChanged = new BoundaryTraceProgress();
+ bool traceProjectionChangeInvalidated = traceProjectionChanged.ObserveBeginScene(7, 11, 11, 1, true)
+ && traceProjectionChanged.ObserveProjection(7, 11, 11, true, true)
+ && !traceProjectionChanged.ObserveProjection(7, 11, 11, true, false)
+ && traceProjectionChanged.AttributionInvalidated && !traceProjectionChanged.IsSequenceComplete();
+ BoundaryTraceProgress traceCameraChanged = new BoundaryTraceProgress();
+ bool traceCameraEntryInvalidated = traceCameraChanged.ObserveBeginScene(7, 11, 11, 1, true)
+ && traceCameraChanged.ObserveProjection(7, 11, 11, true, true)
+ && traceCameraChanged.ObserveCameraEntry() && traceCameraChanged.AttributionInvalidated
+ && !traceCameraChanged.IsSequenceComplete();
+ BoundaryTraceProgress traceReset = new BoundaryTraceProgress();
+ bool traceResetDropsOldBegin = traceReset.ObserveBeginScene(6, 11, 11, 1, true);
+ traceResetDropsOldBegin = traceReset.ObserveCameraEntry() == false && traceResetDropsOldBegin
+ && !traceReset.ObserveProjection(7, 11, 11, true, true);
bool stopAfterCapture = CanStopCapture(true, false, false, false, false);
bool stopAfterFailure = CanStopCapture(false, true, false, false, false);
bool waitsForRemote = !CanStopCapture(true, false, true, false, false);
@@ -1874,6 +2192,19 @@ internal static partial class NativeFrameCapture
|| !selectedFrameValid || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible
|| !selectedIntervenedCaptureRejected || !selectedIntervenedRejected
|| !selectedChangedMatrixRejected
+ || !selectedBoundaryReadAllowed || !boundaryReadRejectsUnselected
+ || !boundaryReadRejectsInvalidated || !boundaryReadRejectsWrongThread
+ || !boundaryReadRejectsWrongGeneration || !boundaryReadRejectsWrongRenderer
+ || !boundaryReadRejectsActiveScene || !boundaryReadRejectsFailedEndScene
+ || !boundaryPresentAccepted || !boundaryPresentRejectsChangedFrame
+ || !boundaryPresentRejectsFailed || !boundaryPresentRejectsInvalidated
+ || !traceBeginAccepted || !traceProjectionAccepted || !traceEndAccepted
+ || !tracePresentAccepted || !traceReturnAccepted || !traceSequenceComplete
+ || !traceCleanupRequired || !traceProbeFailureRejected || !traceFatalRejected
+ || !traceExitedRejected || !traceFailedBeginRejected || !traceOutOfOrderRejected
+ || !traceWrongPairBegin || !traceWrongPairsRejected || !traceWrongPairs.IsSequenceComplete()
+ || !traceReplacementInvalidated || !traceProjectionChangeInvalidated
+ || !traceCameraEntryInvalidated || !traceResetDropsOldBegin
|| !stopAfterCapture || !stopAfterFailure || !waitsForRemote || !waitsForStep || !waitsForCameraRestore
|| !resumedEipOk || !resumedEspOk || !resumedTrapCleared)
throw new InvalidOperationException("Self-check failed: offsets=" + offsetsMatch
@@ -1887,6 +2218,31 @@ internal static partial class NativeFrameCapture
+ " selectedIntervenedCaptureRejected=" + selectedIntervenedCaptureRejected
+ " selectedIntervenedRejected=" + selectedIntervenedRejected
+ " selectedChangedMatrixRejected=" + selectedChangedMatrixRejected
+ + " selectedBoundaryReadAllowed=" + selectedBoundaryReadAllowed
+ + " boundaryReadRejectsUnselected=" + boundaryReadRejectsUnselected
+ + " boundaryReadRejectsInvalidated=" + boundaryReadRejectsInvalidated
+ + " boundaryReadRejectsWrongThread=" + boundaryReadRejectsWrongThread
+ + " boundaryReadRejectsWrongGeneration=" + boundaryReadRejectsWrongGeneration
+ + " boundaryReadRejectsWrongRenderer=" + boundaryReadRejectsWrongRenderer
+ + " boundaryReadRejectsActiveScene=" + boundaryReadRejectsActiveScene
+ + " boundaryReadRejectsFailedEndScene=" + boundaryReadRejectsFailedEndScene
+ + " boundaryPresentAccepted=" + boundaryPresentAccepted
+ + " boundaryPresentRejectsChangedFrame=" + boundaryPresentRejectsChangedFrame
+ + " boundaryPresentRejectsFailed=" + boundaryPresentRejectsFailed
+ + " boundaryPresentRejectsInvalidated=" + boundaryPresentRejectsInvalidated
+ + " traceHappy=" + (traceBeginAccepted && traceProjectionAccepted && traceEndAccepted
+ && tracePresentAccepted && traceReturnAccepted && traceSequenceComplete)
+ + " traceCleanupRequired=" + traceCleanupRequired
+ + " traceProbeFailureRejected=" + traceProbeFailureRejected
+ + " traceFatalRejected=" + traceFatalRejected
+ + " traceExitedRejected=" + traceExitedRejected
+ + " traceFailedBeginRejected=" + traceFailedBeginRejected
+ + " traceOutOfOrderRejected=" + traceOutOfOrderRejected
+ + " traceWrongPairsRejected=" + traceWrongPairsRejected
+ + " traceReplacementInvalidated=" + traceReplacementInvalidated
+ + " traceProjectionChangeInvalidated=" + traceProjectionChangeInvalidated
+ + " traceCameraEntryInvalidated=" + traceCameraEntryInvalidated
+ + " traceResetDropsOldBegin=" + traceResetDropsOldBegin
+ " stopAfterFailure=" + stopAfterFailure + " waitsForRemote=" + waitsForRemote
+ " waitsForStep=" + waitsForStep + " waitsForCameraRestore=" + waitsForCameraRestore
+ " resumedEipOk=" + resumedEipOk
@@ -1894,6 +2250,8 @@ internal static partial class NativeFrameCapture
+ " ThreadQueryInformation=" + ThreadQueryInformation + " Ebx=" + ContextEbxOffset
+ " Edx=" + ContextEdxOffset + " Ecx=" + ContextEcxOffset);
Console.WriteLine("self-check: x86 CONTEXT layout, relocation RVA, legacy camera JSON compatibility, native fog pairing/settings consistency, geometry caller/layout gates, low-byte bypass, nullable fog JSON, 512-call bound, camera/projection gates, wrapping atmosphere phase arithmetic/clock pairing, and recovery states OK");
+ Console.WriteLine("self-check: selected EndScene readback requires same-thread/generation/renderer, idle scene state and successful HRESULT; Present confirms staged pixels without intervening render OK");
+ Console.WriteLine("self-check: boundary trace requires matching BeginScene/projection/EndScene/Present/World3D-return order, successful probes, and verified cleanup; probe failure, process exit, fatal state, and invalid event sequences cannot report success");
return SelfCheckReadback();
}
@@ -2387,11 +2745,23 @@ internal static partial class NativeFrameCapture
+ " mode=" + validated.ProjectionMode);
return 0;
}
- bool hasCameraInput = args.Length == 6 && args[4] == "--camera-input";
- if ((args.Length != 4 && !hasCameraInput) || args[0] != "--capture")
+ bool hasCameraInput = args.Length == 6 && args[0] == "--capture" && args[4] == "--camera-input";
+ bool boundaryTrace = args.Length == 6 && args[0] == "--trace-boundaries" && args[4] == "--camera-input";
+ bool boundaryCameraSetter = args.Length == 7 && args[0] == "--capture-world-boundary"
+ && args[4] == "--camera-input" && args[6] == "--set-camera";
+ bool boundaryCapture = (args.Length == 6 || boundaryCameraSetter)
+ && args[0] == "--capture-world-boundary" && args[4] == "--camera-input";
+ if (args.Length == 0
+ || (!boundaryTrace && !boundaryCapture && args[0] != "--capture")
+ || (!hasCameraInput && !boundaryTrace && !boundaryCapture && args.Length != 4)
+ || ((hasCameraInput || boundaryTrace) && args.Length != 6)
+ || (boundaryCapture && args.Length != 6 && !boundaryCameraSetter))
{
Console.Error.WriteLine(" NativeFrameCapture.exe --capture <pid> <expected-start-utc-ticks> <output.json>");
Console.Error.WriteLine(" [--camera-input <native-frame.json>]");
+ Console.Error.WriteLine(" NativeFrameCapture.exe --trace-boundaries <pid> <expected-start-utc-ticks> <output.log> --camera-input <native-frame.json>");
+ Console.Error.WriteLine(" NativeFrameCapture.exe --capture-world-boundary <pid> <expected-start-utc-ticks> <output.json> --camera-input <world-frame.json>");
+ Console.Error.WriteLine(" [--set-camera] applies the selected camera and verifies its restoration after Present");
Console.Error.WriteLine(" NativeFrameCapture.exe --validate-camera-input <native-frame.json>");
Console.Error.WriteLine(" NativeFrameCapture.exe --self-check");
return 2;
@@ -2399,26 +2769,46 @@ internal static partial class NativeFrameCapture
if (IntPtr.Size != 4) throw new InvalidOperationException("Run the x86 build only.");
uint processId = UInt32.Parse(args[1], CultureInfo.InvariantCulture);
long expectedStartTicks = Int64.Parse(args[2], CultureInfo.InvariantCulture);
- SelectedCameraInput selectedCameraInput = hasCameraInput ? LoadSelectedCameraInput(args[5]) : null;
+ SelectedCameraInput traceCameraInput = boundaryTrace || boundaryCapture ? LoadSelectedCameraInput(args[5]) : null;
+ SelectedCameraInput selectedCameraInput = hasCameraInput ? LoadSelectedCameraInput(args[5])
+ : boundaryCameraSetter ? traceCameraInput : null;
if (selectedCameraInput != null)
Log("validated selected camera input path=" + selectedCameraInput.Path
+ + " inputKind=" + (selectedCameraInput.Synthetic ? "synthetic-camera-plan" : "native-capture")
+ " matrixSha256=" + selectedCameraInput.MatrixSha256
+ " viewport=" + String.Join(",", selectedCameraInput.Viewport)
+ " near=" + selectedCameraInput.Near.ToString("R", CultureInfo.InvariantCulture)
+ " far=" + selectedCameraInput.Far.ToString("R", CultureInfo.InvariantCulture)
+ " fov=" + selectedCameraInput.FieldOfView.ToString("R", CultureInfo.InvariantCulture)
+ " mode=" + selectedCameraInput.ProjectionMode);
+ if (traceCameraInput != null)
+ Log("validated boundary " + (boundaryCapture ? "capture" : "trace") + " projection selector path=" + traceCameraInput.Path
+ + " inputKind=" + (traceCameraInput.Synthetic ? "synthetic-camera-plan" : "native-capture")
+ + " matrixSha256=" + traceCameraInput.MatrixSha256
+ + " viewport=" + String.Join(",", traceCameraInput.Viewport)
+ + " near=" + traceCameraInput.Near.ToString("R", CultureInfo.InvariantCulture)
+ + " far=" + traceCameraInput.Far.ToString("R", CultureInfo.InvariantCulture)
+ + " fov=" + traceCameraInput.FieldOfView.ToString("R", CultureInfo.InvariantCulture)
+ + " mode=" + traceCameraInput.ProjectionMode
+ + " cameraMutation=" + boundaryCameraSetter);
string repositoryRoot = FindRepositoryRoot();
string expectedPath = Path.GetFullPath(VerifyScratchFiles(repositoryRoot));
string outputPath = Path.GetFullPath(args[3]);
string targetRoot = Path.GetFullPath(Path.Combine(repositoryRoot, "target")) + Path.DirectorySeparatorChar;
if (!outputPath.StartsWith(targetRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Output must be beneath the repository target directory: " + targetRoot);
- if (!String.Equals(Path.GetExtension(outputPath), ".json", StringComparison.OrdinalIgnoreCase))
+ bool logOnly = boundaryTrace;
+ if (!String.Equals(Path.GetExtension(outputPath), logOnly ? ".log" : ".json", StringComparison.OrdinalIgnoreCase))
+ {
+ string expectedExtension = logOnly ? ".log" : ".json";
+ throw new InvalidOperationException("Output must use the " + expectedExtension + " extension for this mode.");
+ }
+ if (!boundaryTrace && !String.Equals(Path.GetExtension(outputPath), ".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Capture output must use a .json extension so PNG/log paths cannot overlap it.");
string outputPng = Path.ChangeExtension(outputPath, ".png");
- string outputLog = Path.ChangeExtension(outputPath, ".log");
- if (File.Exists(outputPath) || File.Exists(outputPng) || File.Exists(outputLog))
+ string outputLog = boundaryTrace ? outputPath : Path.ChangeExtension(outputPath, ".log");
+ if (File.Exists(outputPath) || (!logOnly && File.Exists(outputPng))
+ || (!boundaryTrace && File.Exists(outputLog)))
throw new InvalidOperationException("Output JSON, PNG, or log already exists; choose a fresh basename: " + outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
_logPath = outputLog;
@@ -2441,7 +2831,8 @@ internal static partial class NativeFrameCapture
foreach (KeyValuePair<string, string> expected in ExpectedSha256)
Log("verified sha256 " + expected.Key + "=" + expected.Value);
return AttachForFrame(processId, expectedPath, expectedStartTicks, outputPath,
- selectedCameraInput, target, FrameCaptureTimeoutSeconds);
+ selectedCameraInput, traceCameraInput, boundaryTrace, boundaryCapture, target,
+ boundaryTrace ? BoundaryTraceTimeoutSeconds : boundaryCapture ? BoundaryTraceTimeoutSeconds : FrameCaptureTimeoutSeconds);
}
}
catch (Exception exception)
@@ -2453,7 +2844,8 @@ internal static partial class NativeFrameCapture
}
private static int AttachForFrame(uint processId, string expectedPath, long expectedStartTicks,
- string outputPath, SelectedCameraInput selectedCameraInput, Process identityProcess, int timeoutSeconds)
+ string outputPath, SelectedCameraInput selectedCameraInput, SelectedCameraInput traceCameraInput,
+ bool boundaryTrace, bool boundaryCapture, Process identityProcess, int timeoutSeconds)
{
const uint ProcessTerminate = 0x0001;
const uint ProcessVmRead = 0x0010;
@@ -2478,6 +2870,15 @@ internal static partial class NativeFrameCapture
bool processExitUnconfirmed = false;
int detachError = 0;
bool captured = false;
+ bool boundaryCaptureComplete = false;
+ bool boundaryCaptureSelected = false;
+ bool boundaryCaptureInvalidated = false;
+ bool boundaryCapturePixelsStaged = false;
+ bool boundaryCapturePresentVerified = false;
+ uint boundaryCaptureGeneration = 0;
+ uint boundaryCaptureThread = 0;
+ BoundaryTraceProgress trace = new BoundaryTraceProgress();
+ uint traceEventSequence = 0;
SurfaceReadback completedSurface = null;
FrameSnapshot completedFrame = null;
RemoteReadbackSession completedReadback = null;
@@ -2495,8 +2896,11 @@ internal static partial class NativeFrameCapture
BreakpointInfo projectionBreakpoint = null;
BreakpointInfo returnBreakpoint = null;
BreakpointInfo atmosphereBreakpoint = null;
+ BreakpointInfo beginSceneBreakpoint = null;
+ BreakpointInfo endSceneBreakpoint = null;
BreakpointInfo fogConsumerBreakpoint = null;
BreakpointInfo presentBreakpoint = null;
+ BreakpointInfo presentCompletionBreakpoint = null;
BreakpointInfo missionBreakpoint = null;
int missionProbeAttempts = 0;
bool missionProbeFinished = false;
@@ -2512,6 +2916,7 @@ internal static partial class NativeFrameCapture
const uint ProcessAccessForFrameCapture = ProcessTerminate | ProcessVmOperation | ProcessVmRead
| ProcessVmWrite | ProcessQueryInformation | Synchronize;
FrameSnapshot frame = new FrameSnapshot();
+ bool projectionSelectorMode = boundaryTrace || boundaryCapture;
frame.SelectedCameraRequested = selectedCameraInput != null;
SelectedCameraState selectedCamera = selectedCameraInput == null ? null
: new SelectedCameraState { Input = selectedCameraInput };
@@ -2543,11 +2948,15 @@ internal static partial class NativeFrameCapture
Log("DEBUG_ACCESS processMask=0x" + ProcessAccessForFrameCapture.ToString("X8")
+ " threadContextMask=0x" + (ThreadGetContext | ThreadSetContext).ToString("X8")
+ " remoteThreadCreation=false");
- Log("frame capture attached; waiting for verified camera/projection/present boundaries for "
+ Log((boundaryTrace ? "boundary trace attached; waiting for verified world/cockpit/scene/Present events for "
+ : boundaryCapture ? "boundary capture attached; waiting for selected projection/EndScene/Present for "
+ : "frame capture attached; waiting for verified camera/projection/present boundaries for ")
+ timeoutSeconds + " seconds");
- while (!fatal && !CanStopCapture(captured, stopAfterAttempt, remote != null || cameraSetter != null,
- pendingStep != null, selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified)
+ while (!fatal && (boundaryTrace || boundaryCapture
+ ? (!(trace.WorldReturnSeen || boundaryCaptureComplete) || remote != null || pendingStep != null || cameraSetter != null)
+ : !CanStopCapture(captured, stopAfterAttempt, remote != null || cameraSetter != null,
+ pendingStep != null, selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified))
&& (DateTime.UtcNow < deadline || remote != null || cameraSetter != null || pendingStep != null
|| selectedCamera != null && selectedCamera.Applied && !selectedCamera.RestoreVerified))
{
@@ -2673,14 +3082,18 @@ internal static partial class NativeFrameCapture
throw new InvalidOperationException("Terrain atmosphere phase boundary signature failed: " + atmosphereEvidence);
atmosphereBreakpoint = ArmBreakpoint(process, "Terrain.atmosphere-phase",
unchecked(terrainBase + AtmospherePhaseRva), atmosphereByte);
- string fogConsumerEvidence;
- if (!VerifyNativeFogConsumerBoundary(process, imagePath, terrainBase,
- out fogConsumerOriginalByte, out fogConsumerEvidence))
- throw new InvalidOperationException("Terrain native fog consumer boundary signature failed: "
- + fogConsumerEvidence);
- fogConsumerBoundaryVerified = true;
Log("Terrain verified base=0x" + terrainBase.ToString("X8") + " sha256=" + hash
- + " " + atmosphereEvidence + " nativeFogConsumer=" + fogConsumerEvidence);
+ + " " + atmosphereEvidence + " phasePairingEnabled=" + projectionSelectorMode);
+ if (!projectionSelectorMode)
+ {
+ string fogConsumerEvidence;
+ if (!VerifyNativeFogConsumerBoundary(process, imagePath, terrainBase,
+ out fogConsumerOriginalByte, out fogConsumerEvidence))
+ throw new InvalidOperationException("Terrain native fog consumer boundary signature failed: "
+ + fogConsumerEvidence);
+ fogConsumerBoundaryVerified = true;
+ Log("Terrain nativeFogConsumer=" + fogConsumerEvidence);
+ }
}
if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedIron3d), StringComparison.OrdinalIgnoreCase))
{
@@ -2688,6 +3101,13 @@ internal static partial class NativeFrameCapture
string hash = HashFile(loadedPath);
if (!String.Equals(hash, ExpectedSha256[Iron3dName], StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Loaded iron3d hash mismatch: " + hash);
+ if (projectionSelectorMode)
+ {
+ iron3dBase = imageBase;
+ Log("iron3d verified sha256=" + hash + " mission-path probe disabled in boundary-trace mode");
+ }
+ else
+ {
byte missionByte;
string missionEvidence;
if (!VerifyMissionIdentityBoundary(process, imageBase, out missionByte, out missionEvidence))
@@ -2696,6 +3116,7 @@ internal static partial class NativeFrameCapture
missionBreakpoint = ArmBreakpoint(process, "iron3d.mission-path-vtable-call",
unchecked(imageBase + MissionVtableCallRva), missionByte);
Log("iron3d verified sha256=" + hash + " " + missionEvidence);
+ }
}
if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedWorld3D), StringComparison.OrdinalIgnoreCase))
{
@@ -2713,17 +3134,17 @@ internal static partial class NativeFrameCapture
throw new InvalidOperationException("World3D render-entry signature failed: " + renderEvidence);
if (!VerifyProjectionBoundary(process, imageBase, out projectionByte, out projectionEvidence))
throw new InvalidOperationException("World3D projection signature failed: " + projectionEvidence);
- if (selectedCameraInput != null
+ if ((selectedCameraInput != null || boundaryTrace)
&& !VerifyRenderReturnBoundary(process, imageBase, out returnByte, out returnEvidence))
throw new InvalidOperationException("World3D selected-camera restore boundary failed: " + returnEvidence);
world3dBase = imageBase;
cameraBreakpoint = ArmBreakpoint(process, "World3D.stdRenderGame", imageBase + RenderGameRva, renderByte);
projectionBreakpoint = ArmBreakpoint(process, "World3D.projection-snapshot", imageBase + ProjectionSnapshotRva, projectionByte);
- if (selectedCameraInput != null)
+ if (selectedCameraInput != null || boundaryTrace)
returnBreakpoint = ArmBreakpoint(process, "World3D.stdRenderGame-restore-return",
imageBase + RenderReturnRva, returnByte);
Log("World3D verified sha256=" + hash + " " + renderEvidence + " " + projectionEvidence
- + (selectedCameraInput == null ? "" : " " + returnEvidence));
+ + (selectedCameraInput == null && !boundaryTrace ? "" : " " + returnEvidence));
}
if (imagePath.Length != 0 && String.Equals(imagePath, NormalizePath(expectedNgi32), StringComparison.OrdinalIgnoreCase))
{
@@ -2739,6 +3160,32 @@ internal static partial class NativeFrameCapture
presentOriginalByte = presentByte;
presentBoundaryVerified = true;
Log("Ngi32 verified sha256=" + hash + " " + presentEvidence);
+ if (boundaryCapture)
+ {
+ byte completionByte;
+ string completionEvidence;
+ if (!VerifyPresentCompletionBoundary(process, imageBase,
+ out completionByte, out completionEvidence))
+ throw new InvalidOperationException("Ngi32 Present completion signature failed: " + completionEvidence);
+ presentCompletionBreakpoint = ArmBreakpoint(process, "Ngi32.present-completion",
+ unchecked(imageBase + PresentCompletionRva), completionByte);
+ Log("Ngi32 Present completion verified " + completionEvidence);
+ }
+ if (projectionSelectorMode)
+ {
+ byte beginByte;
+ byte endByte;
+ string sceneEvidence;
+ if (!VerifySceneStateBoundaries(process, imageBase, out beginByte, out endByte, out sceneEvidence))
+ throw new InvalidOperationException("Ngi32 BeginScene/EndScene state signatures failed: " + sceneEvidence);
+ beginSceneBreakpoint = ArmBreakpoint(process, "Ngi32.begin-scene-state",
+ unchecked(imageBase + BeginSceneStateRva), beginByte);
+ endSceneBreakpoint = ArmBreakpoint(process, "Ngi32.end-scene-state",
+ unchecked(imageBase + EndSceneStateRva), endByte);
+ Log("Ngi32 scene boundaries verified " + sceneEvidence
+ + " beginPostCall=0x" + (imageBase + BeginSceneStateRva).ToString("X8")
+ + " endPostCall=0x" + (imageBase + EndSceneStateRva).ToString("X8"));
+ }
if (IsMatchingPerspectiveFrame(frame, frame.CameraThreadId))
EnsurePresentBreakpoint(process, ngiBase, frame, frame.CameraThreadId,
presentOriginalByte, ref presentBreakpoint);
@@ -2772,16 +3219,24 @@ internal static partial class NativeFrameCapture
{
string returnEvidence;
bool returnContextOk = VerifyCameraSetterReturnContext(process, cameraSetter, out returnEvidence);
- byte[] actualMatrix = ReadCameraMatrix(process, cameraSetter.Camera);
- bool matrixMatches = ByteArraysEqual(actualMatrix, cameraSetter.MatrixBytes);
+ byte[] actualMatrix = cameraSetter.Phase == CameraSetterPhase.Apply
+ ? ReadEffectiveWorldCameraMatrix(process, cameraSetter.Camera)
+ : ReadCameraMatrix(process, cameraSetter.Camera);
+ bool matrixMatches = cameraSetter.Phase == CameraSetterPhase.Apply
+ ? CameraMatricesNearlyEqual(actualMatrix, cameraSetter.MatrixBytes)
+ : ByteArraysEqual(actualMatrix, cameraSetter.MatrixBytes);
+ uint actualParent = ReadCameraParent(process, cameraSetter.Camera);
+ bool parentMatches = selectedCamera != null
+ && actualParent == selectedCamera.OriginalParentPointer;
string abiEvidence;
uint transformInterface;
bool abiStillValid = VerifyCameraSetterAbi(process, terrainBase,
cameraSetter.Camera, out transformInterface, out abiEvidence);
Log("CAMERA_SETTER_RETURN phase=" + cameraSetter.Phase
+ " contextOk=" + returnContextOk + " matrixMatches=" + matrixMatches
+ + " parent=0x" + actualParent.ToString("X8") + " parentMatches=" + parentMatches
+ " abiStillValid=" + abiStillValid + " " + returnEvidence + " " + abiEvidence);
- if (!returnContextOk || !matrixMatches || !abiStillValid)
+ if (!returnContextOk || !matrixMatches || !parentMatches || !abiStillValid)
{
captureFailure = "Native camera setter could not be verified; the isolated scratch process will be stopped.";
Log("CAMERA_SETTER_UNVERIFIED; terminating the verified scratch process before detach");
@@ -2793,7 +3248,7 @@ internal static partial class NativeFrameCapture
CameraReadback appliedCamera = frame.Camera;
if (selectedCamera == null || appliedCamera == null || !appliedCamera.LayoutVerified
|| appliedCamera.Camera != selectedCamera.CameraPointer
- || !ByteArraysEqual(actualMatrix, selectedCamera.Input.MatrixBytes))
+ || !CameraMatricesNearlyEqual(actualMatrix, selectedCamera.Input.MatrixBytes))
{
captureFailure = "Native setter did not leave the requested selected-camera matrix in place.";
Log("CAMERA_SETTER_APPLY_READBACK_FAILED; terminating the verified scratch process");
@@ -2802,13 +3257,23 @@ internal static partial class NativeFrameCapture
}
else
{
- appliedCamera.EntryMatrixBytes = (byte[])selectedCamera.Input.MatrixBytes.Clone();
- appliedCamera.EntryMatrixSha256 = selectedCamera.Input.MatrixSha256;
+ byte[] appliedLocalMatrix = ReadCameraMatrix(process, cameraSetter.Camera);
+ appliedCamera.Parent = actualParent;
+ appliedCamera.Parented = actualParent != 0;
+ appliedCamera.EntryMatrixBytes = (byte[])appliedLocalMatrix.Clone();
+ appliedCamera.EntryMatrixSha256 = HashBytes(appliedLocalMatrix);
+ appliedCamera.Words = MatrixWords(appliedLocalMatrix);
+ appliedCamera.EntryEffectiveWorldMatrixBytes = (byte[])actualMatrix.Clone();
+ appliedCamera.EntryEffectiveWorldMatrixSha256 = HashBytes(actualMatrix);
+ appliedCamera.EffectiveWorldWords = MatrixWords(actualMatrix);
appliedCamera.ProjectionMatrixSha256 = null;
+ appliedCamera.ProjectionEffectiveWorldMatrixSha256 = null;
appliedCamera.CurrentAtProjectionVerified = false;
+ appliedCamera.EffectiveWorldAtProjectionVerified = false;
appliedCamera.WordsChangedAtProjection = false;
- appliedCamera.Words = (uint[])selectedCamera.Input.MatrixWords.Clone();
+ appliedCamera.EffectiveWorldChangedAtProjection = false;
appliedCamera.MatrixFinite = true;
+ appliedCamera.EffectiveWorldMatrixFinite = true;
selectedCamera.Applied = true;
selectedCamera.RestoreDeadlineUtc = DateTime.UtcNow.AddSeconds(5);
frame.SelectedCamera = selectedCamera;
@@ -2846,6 +3311,18 @@ internal static partial class NativeFrameCapture
Log("CAMERA_SETTER_RESTORE_VERIFIED camera=0x" + selectedCamera.CameraPointer.ToString("X8")
+ " matrixSha256=" + selectedCamera.OriginalMatrixSha256
+ " returnBoundary=World3D+0x13D7B");
+ if (boundaryCapture && boundaryCapturePixelsStaged
+ && boundaryCapturePresentVerified
+ && !boundaryCaptureInvalidated
+ && frame.CameraGeneration == boundaryCaptureGeneration
+ && frame.CameraThreadId == boundaryCaptureThread)
+ {
+ boundaryCaptureComplete = true;
+ captured = true;
+ Log("BOUNDARY_CAPTURE_COMPLETE after verified selected-camera restore"
+ + " generation=" + boundaryCaptureGeneration
+ + " tid=" + boundaryCaptureThread);
+ }
BeginSingleStep(process, eventTid, cameraSetter.ResumeBreakpoint,
cameraSetter.OriginalContext, out pendingStep, cameraSetter.RearmOnStep);
Log("SINGLE_STEP_STARTED boundary=" + cameraSetter.ResumeBreakpoint.Name + " tid=" + eventTid);
@@ -2873,12 +3350,54 @@ internal static partial class NativeFrameCapture
uint data = remote.Acquire.DataBase;
remote.Status = ReadU32Exact(process, data + RemoteDataStatusOffset);
remote.GetHr = ReadU32Exact(process, data + RemoteDataGetHrOffset);
- remote.LockHr = ReadU32Exact(process, data + RemoteDataLockHrOffset);
remote.ReleaseResult = ReadU32Exact(process, data + RemoteDataReleaseCountOffset);
+ if (remote.SurfaceProbeOnly)
+ {
+ remote.ProbeSurfacePointer = ReadU32Exact(process, data + RemoteDataSurfaceOffset);
+ remote.RemoteContextIntact = VerifyRemoteStubContext(process, remote,
+ unchecked(remote.Acquire.TrapAddress + 1), "after-get-render-target-and-release");
+ bool surfaceProbeSucceeded = remote.Status == 8
+ && unchecked((int)remote.GetHr) >= 0 && remote.ProbeSurfacePointer != 0;
+ Log("BOUNDARY_PROBE_DONE kind=" + (remote.ResumeBreakpoint == null ? "unknown" : remote.ResumeBreakpoint.Name)
+ + " status=" + remote.Status + " getHR=0x" + remote.GetHr.ToString("X8")
+ + " surface=0x" + remote.ProbeSurfacePointer.ToString("X8")
+ + " releaseResult=0x" + remote.ReleaseResult.ToString("X8")
+ + " device=0x" + ReadU32(process, unchecked(ngiBase + NgiDeviceGlobalRva)).ToString("X8")
+ + " lockCalled=false probeSucceeded=" + surfaceProbeSucceeded
+ + " contextIntact=" + remote.RemoteContextIntact);
+ if (!surfaceProbeSucceeded)
+ {
+ captureFailure = "D3D7 boundary surface probe did not return a valid render target (status "
+ + remote.Status + ", HRESULT 0x" + remote.GetHr.ToString("X8")
+ + ", surface 0x" + remote.ProbeSurfacePointer.ToString("X8") + ").";
+ Log("BOUNDARY_PROBE_FAILED " + captureFailure);
+ }
+ if (!remote.RemoteContextIntact)
+ {
+ captureFailure = "The read-only surface probe did not return with the original render-thread stack/context.";
+ Log("BOUNDARY_PROBE_CONTEXT_CORRUPTION; terminating the verified scratch process before detach");
+ fatal = true;
+ }
+ else
+ {
+ RearmSuspendedBreakpoints(process, remote);
+ BeginSingleStep(process, remote.ThreadId, remote.ResumeBreakpoint,
+ remote.OriginalContext, out pendingStep, true);
+ Log("SINGLE_STEP_STARTED boundary=" + remote.ResumeBreakpoint.Name + " tid=" + remote.ThreadId);
+ if (VirtualFreeEx(process, Ptr(remote.Region), UIntPtr.Zero, 0x8000))
+ Log("BOUNDARY_PROBE_REGION_FREED");
+ else Log("BOUNDARY_PROBE_REGION_FREE_FAILED error=" + Marshal.GetLastWin32Error());
+ remote = null;
+ }
+ }
+ else
+ {
+ remote.LockHr = ReadU32Exact(process, data + RemoteDataLockHrOffset);
Log("REMOTE_ACQUIRE_DONE status=" + remote.Status + " getHR=0x" + remote.GetHr.ToString("X8")
+ " lockHR=0x" + remote.LockHr.ToString("X8") + " release=0x" + remote.ReleaseResult.ToString("X8"));
remote.PresentStackArgumentsIntact = remote.PresentStackArgumentsIntact
- && VerifyPresentStackArguments(process, remote, "after-get-render-target-and-lock");
+ && (!remote.ValidatePresentStackArguments
+ || VerifyPresentStackArguments(process, remote, "after-get-render-target-and-lock"));
remote.RemoteContextIntact = remote.RemoteContextIntact
&& VerifyRemoteStubContext(process, remote, unchecked(remote.Acquire.TrapAddress + 1),
"after-get-render-target-and-lock");
@@ -2890,8 +3409,10 @@ internal static partial class NativeFrameCapture
}
else if (!remote.PresentStackArgumentsIntact || !remote.RemoteContextIntact)
{
- captureFailure = "Present call stack arguments changed during D3D7 readback.";
- Log("PRESENT_STACK_CORRUPTION_UNRECOVERED; terminating the verified scratch process before detach");
+ captureFailure = remote.ValidatePresentStackArguments
+ ? "Present call stack arguments changed during D3D7 readback."
+ : "The EndScene readback changed render-thread context or stack state.";
+ Log("REMOTE_READBACK_CONTEXT_CORRUPTION_UNRECOVERED; terminating the verified scratch process before detach");
fatal = true;
}
else
@@ -2900,11 +3421,17 @@ internal static partial class NativeFrameCapture
+ " (GetRenderTarget 0x" + remote.GetHr.ToString("X8")
+ ", Lock 0x" + remote.LockHr.ToString("X8") + ").";
Log("REMOTE_CAPTURE_RETRY " + captureFailure);
- ResumeOriginalPresent(process, remote);
+ if (remote.SuspendedBreakpoints != null)
+ {
+ pendingStep = ResumeReadbackAfterRemote(process, remote);
+ Log("BOUNDARY_READBACK_REJECTED; resumed original boundary and will retry on a later selected frame");
+ }
+ else ResumeReadbackBoundary(process, remote);
VirtualFreeEx(process, Ptr(remote.Region), UIntPtr.Zero, 0x8000);
remote = null;
}
}
+ }
else
{
uint data = remote.Cleanup.DataBase;
@@ -2915,7 +3442,8 @@ internal static partial class NativeFrameCapture
+ " release=0x" + remote.ReleaseResult.ToString("X8"));
bool unlocked = remote.Status == 6 && unchecked((int)remote.UnlockHr) >= 0;
remote.PresentStackArgumentsIntact = remote.PresentStackArgumentsIntact
- && VerifyPresentStackArguments(process, remote, "after-unlock-and-release");
+ && (!remote.ValidatePresentStackArguments
+ || VerifyPresentStackArguments(process, remote, "after-unlock-and-release"));
remote.RemoteContextIntact = remote.RemoteContextIntact
&& VerifyRemoteStubContext(process, remote, unchecked(remote.Cleanup.TrapAddress + 1),
"after-unlock-and-release");
@@ -2932,8 +3460,10 @@ internal static partial class NativeFrameCapture
}
else if (!remote.PresentStackArgumentsIntact || !remote.RemoteContextIntact)
{
- captureFailure = "Present call stack arguments changed during D3D7 readback.";
- Log("PRESENT_STACK_CORRUPTION_UNRECOVERED; terminating the verified scratch process before detach");
+ captureFailure = remote.ValidatePresentStackArguments
+ ? "Present call stack arguments changed during D3D7 readback."
+ : "The EndScene readback changed render-thread context or stack state.";
+ Log("REMOTE_READBACK_CONTEXT_CORRUPTION_UNRECOVERED; terminating the verified scratch process before detach");
fatal = true;
}
else
@@ -2961,15 +3491,31 @@ internal static partial class NativeFrameCapture
{
captureFailure = remote.PixelFailure ?? "D3D7 Lock succeeded, but pixel rows were not available.";
Log("FRAME_CAPTURE_NOT_USABLE " + captureFailure);
- stopAfterAttempt = true;
+ if (!boundaryCapture) stopAfterAttempt = true;
}
- ResumeOriginalPresent(process, remote);
+ if (remote.SuspendedBreakpoints != null)
+ {
+ pendingStep = ResumeReadbackAfterRemote(process, remote);
+ if (outputStaged)
+ {
+ boundaryCapturePixelsStaged = true;
+ Log("BOUNDARY_CAPTURE_PIXELS_STAGED generation=" + remote.Generation
+ + " beforePresent=true rowsSha256=" + remote.Surface.Sha256);
+ }
+ else
+ {
+ boundaryCaptureSelected = false;
+ boundaryCaptureInvalidated = true;
+ if (!boundaryCapture) stopAfterAttempt = true;
+ }
+ }
+ else ResumeReadbackBoundary(process, remote);
if (VirtualFreeEx(process, Ptr(remote.Region), UIntPtr.Zero, 0x8000))
Log("REMOTE_READBACK_REGION_FREED");
else Log("REMOTE_READBACK_REGION_FREE_FAILED error=" + Marshal.GetLastWin32Error());
remote = null;
- if (outputStaged)
+ if (outputStaged && !boundaryCapture)
{
captured = true;
Log("FRAME_CAPTURE_CONTEXT_RESTORED; awaiting camera restore and safe detach before writing outputs");
@@ -2993,12 +3539,19 @@ internal static partial class NativeFrameCapture
hitBreakpoint = returnBreakpoint;
else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address)
hitBreakpoint = atmosphereBreakpoint;
+ else if (beginSceneBreakpoint != null && beginSceneBreakpoint.Armed && exceptionAddress == beginSceneBreakpoint.Address)
+ hitBreakpoint = beginSceneBreakpoint;
+ else if (endSceneBreakpoint != null && endSceneBreakpoint.Armed && exceptionAddress == endSceneBreakpoint.Address)
+ hitBreakpoint = endSceneBreakpoint;
else if (fogConsumerBreakpoint != null && fogConsumerBreakpoint.Armed && exceptionAddress == fogConsumerBreakpoint.Address)
hitBreakpoint = fogConsumerBreakpoint;
else if (missionBreakpoint != null && missionBreakpoint.Armed && exceptionAddress == missionBreakpoint.Address)
hitBreakpoint = missionBreakpoint;
else if (presentBreakpoint != null && presentBreakpoint.Armed && exceptionAddress == presentBreakpoint.Address)
hitBreakpoint = presentBreakpoint;
+ else if (presentCompletionBreakpoint != null && presentCompletionBreakpoint.Armed
+ && exceptionAddress == presentCompletionBreakpoint.Address)
+ hitBreakpoint = presentCompletionBreakpoint;
if (hitBreakpoint != null)
{
@@ -3011,6 +3564,29 @@ internal static partial class NativeFrameCapture
if (hitBreakpoint == cameraBreakpoint)
{
+ if (boundaryCapture && boundaryCaptureSelected && !boundaryCaptureComplete)
+ {
+ boundaryCaptureInvalidated = true;
+ boundaryCaptureSelected = false;
+ if (boundaryCapturePixelsStaged)
+ {
+ completedSurface = null;
+ completedFrame = null;
+ completedReadback = null;
+ boundaryCapturePixelsStaged = false;
+ }
+ Log("BOUNDARY_CAPTURE_ATTRIBUTION_INVALIDATED additional World3D.stdRenderGame entry"
+ + " selectedGeneration=" + boundaryCaptureGeneration
+ + " selectedTid=" + boundaryCaptureThread
+ + " nextGeneration=" + (frame.CameraGeneration + 1)
+ + " tid=" + eventTid);
+ }
+ if (boundaryTrace && trace.ObserveCameraEntry())
+ {
+ Log("TRACE_ATTRIBUTION_INVALIDATED additional World3D.stdRenderGame entry after selected world generation="
+ + trace.SelectedGeneration + " tid=" + eventTid
+ + " nextGeneration=" + (frame.CameraGeneration + 1));
+ }
if (selectedCamera != null && selectedCamera.Applied)
{
if (!selectedCamera.PixelAttributionInvalidated)
@@ -3076,6 +3652,7 @@ internal static partial class NativeFrameCapture
selectedCamera.ReturnAddress = frame.Camera.ReturnAddress;
selectedCamera.OriginalMatrixBytes = (byte[])frame.Camera.EntryMatrixBytes.Clone();
selectedCamera.OriginalMatrixSha256 = frame.Camera.EntryMatrixSha256;
+ selectedCamera.OriginalParentPointer = frame.Camera.Parent;
frame.SelectedCamera = selectedCamera;
cameraSetter = StartCameraSetter(process, eventTid, terrainBase,
selectedCamera.CameraPointer, selectedCamera.Input.MatrixBytes,
@@ -3166,6 +3743,19 @@ internal static partial class NativeFrameCapture
{
frame.Projection = null;
frame.ProjectionGeneration = 0;
+ if (boundaryTrace && trace.ProjectionSelected
+ && (frame.Camera == null || frame.CameraThreadId != eventTid
+ || frame.CameraGeneration != trace.SelectedGeneration
+ || eventTid != trace.SelectedThread))
+ {
+ trace.Invalidate();
+ Log("TRACE_ATTRIBUTION_INVALIDATED projection snapshot replaced selected camera pair"
+ + " selectedGeneration=" + trace.SelectedGeneration
+ + " selectedTid=" + trace.SelectedThread
+ + " currentGeneration=" + frame.CameraGeneration
+ + " currentCameraTid=" + frame.CameraThreadId
+ + " projectionTid=" + eventTid);
+ }
if (frame.Camera != null && frame.CameraThreadId == eventTid && ngiBase != 0)
{
bool cameraWordsCurrent = RefreshCameraWordsAtProjection(process, frame.Camera,
@@ -3189,6 +3779,62 @@ internal static partial class NativeFrameCapture
bool projectionMatchesInput = selectedCamera == null
|| ProjectionMatchesCameraInput(frame.Projection, selectedCamera.Input)
&& cameraMatrixMatchesInput;
+ bool traceProjectionMatchesInput = projectionSelectorMode && traceCameraInput != null
+ && ProjectionMatchesCameraInput(frame.Projection, traceCameraInput);
+ bool targetProjectionMatchesInput = projectionSelectorMode
+ ? traceProjectionMatchesInput : projectionMatchesInput;
+ if (boundaryTrace)
+ {
+ bool verifiedTracePair = frame.Camera != null
+ && frame.CameraThreadId == eventTid
+ && frame.ProjectionThreadId == eventTid
+ && frame.CameraGeneration != 0
+ && frame.ProjectionGeneration == frame.CameraGeneration
+ && cameraWordsCurrent && frame.Projection.Verified
+ && frame.Projection.Mode != 0;
+ bool accepted = trace.ObserveProjection(frame.CameraGeneration,
+ frame.CameraThreadId, eventTid, verifiedTracePair,
+ traceProjectionMatchesInput);
+ traceEventSequence++;
+ Log("TRACE_EVENT order=" + traceEventSequence
+ + " kind=projection-snapshot tid=" + eventTid
+ + " cameraGeneration=" + frame.CameraGeneration
+ + " projectionGeneration=" + frame.ProjectionGeneration
+ + " selectorMatch=" + traceProjectionMatchesInput
+ + " verifiedPair=" + verifiedTracePair
+ + " beginSceneSeen=" + trace.BeginSceneSeen
+ + " accepted=" + accepted);
+ }
+ if (boundaryCapture)
+ {
+ bool sameProjectionInvocation = frame.Camera != null
+ && frame.CameraThreadId == eventTid
+ && frame.ProjectionThreadId == eventTid
+ && frame.ProjectionGeneration == frame.CameraGeneration;
+ boundaryCaptureSelected = sameProjectionInvocation
+ && cameraWordsCurrent && frame.Projection.Verified
+ && frame.Projection.Mode != 0 && targetProjectionMatchesInput;
+ boundaryCaptureInvalidated = false;
+ boundaryCapturePixelsStaged = false;
+ boundaryCapturePresentVerified = false;
+ if (completedSurface != null)
+ {
+ completedSurface = null;
+ completedFrame = null;
+ completedReadback = null;
+ }
+ if (boundaryCaptureSelected)
+ {
+ boundaryCaptureGeneration = frame.CameraGeneration;
+ boundaryCaptureThread = eventTid;
+ Log("BOUNDARY_CAPTURE_PROJECTION_SELECTED generation=" + boundaryCaptureGeneration
+ + " tid=" + boundaryCaptureThread
+ + " renderer=0x" + frame.Projection.Renderer.ToString("X8")
+ + " near=" + frame.Projection.Near.ToString("R", CultureInfo.InvariantCulture)
+ + " far=" + frame.Projection.Far.ToString("R", CultureInfo.InvariantCulture)
+ + " fov=" + frame.Projection.Fov.ToString("R", CultureInfo.InvariantCulture));
+ }
+ }
if (selectedCamera != null)
{
if (selectedCamera.Applied)
@@ -3221,10 +3867,10 @@ internal static partial class NativeFrameCapture
+ " viewport=" + (frame.Projection.Viewport == null ? "unreadable" : String.Join(",", frame.Projection.Viewport))
+ " mode=" + frame.Projection.Mode + " cameraWordsCurrent=" + cameraWordsCurrent
+ " usable=" + (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0
- && projectionMatchesInput)
+ && targetProjectionMatchesInput)
+ " rawWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8"));
if (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0
- && projectionMatchesInput && ngiBase != 0 && presentBoundaryVerified)
+ && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified)
EnsurePresentBreakpoint(process, ngiBase, frame, eventTid,
presentOriginalByte, ref presentBreakpoint);
}
@@ -3234,7 +3880,62 @@ internal static partial class NativeFrameCapture
}
else if (hitBreakpoint == returnBreakpoint)
{
- if (selectedCamera != null && selectedCamera.Applied && !selectedCamera.Restored)
+ if (boundaryTrace)
+ {
+ bool paired = frame.Camera != null && frame.Projection != null
+ && frame.CameraThreadId == eventTid && frame.ProjectionThreadId == eventTid
+ && frame.CameraGeneration != 0 && frame.ProjectionGeneration == frame.CameraGeneration
+ && frame.Projection.Verified && frame.Projection.Mode != 0;
+ bool selectedWorld = paired && traceCameraInput != null
+ && ProjectionMatchesCameraInput(frame.Projection, traceCameraInput);
+ uint renderer = frame.Projection == null ? 0 : frame.Projection.Renderer;
+ uint device = ngiBase == 0 ? 0 : ReadU32(process, unchecked(ngiBase + NgiDeviceGlobalRva));
+ uint sceneState = renderer == 0 ? 0
+ : (ReadU32(process, unchecked(renderer + 0x118)) >> 8) & 0xFF;
+ bool acceptedReturn = selectedWorld
+ && trace.ObserveWorldReturn(frame.CameraGeneration,
+ frame.CameraThreadId, eventTid);
+ traceEventSequence++;
+ Log("TRACE_EVENT order=" + traceEventSequence
+ + " kind=" + (selectedWorld ? "world-return" : "cockpit-candidate-return")
+ + " tid=" + eventTid + " cameraGeneration=" + frame.CameraGeneration
+ + " projectionGeneration=" + frame.ProjectionGeneration
+ + " paired=" + paired + " camera=0x"
+ + (frame.Camera == null ? 0 : frame.Camera.Camera).ToString("X8")
+ + " viewport=" + (frame.Projection == null || frame.Projection.Viewport == null
+ ? "unreadable" : String.Join(",", frame.Projection.Viewport))
+ + " near=" + (frame.Projection == null ? "unreadable" : frame.Projection.Near.ToString("R", CultureInfo.InvariantCulture))
+ + " far=" + (frame.Projection == null ? "unreadable" : frame.Projection.Far.ToString("R", CultureInfo.InvariantCulture))
+ + " fov=" + (frame.Projection == null ? "unreadable" : frame.Projection.Fov.ToString("R", CultureInfo.InvariantCulture))
+ + " renderer=0x" + renderer.ToString("X8")
+ + " beginSceneFlag=" + sceneState + " device=0x" + device.ToString("X8")
+ + " projectionSelectorMatch=" + selectedWorld
+ + " selectedGeneration=" + trace.SelectedGeneration
+ + " selectedThread=" + trace.SelectedThread
+ + " accepted=" + acceptedReturn);
+ if (acceptedReturn)
+ {
+ try
+ {
+ remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
+ returnBreakpoint, frame,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
+ returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
+ fogConsumerBreakpoint,
+ endSceneBreakpoint, presentBreakpoint,
+ presentCompletionBreakpoint, missionBreakpoint });
+ }
+ catch (Exception probeError)
+ {
+ captureFailure = "Could not query the active render target at the selected world return: "
+ + probeError.Message;
+ Log("BOUNDARY_PROBE_START_FAILED kind=world-return " + probeError);
+ stopAfterAttempt = true;
+ }
+ stopAfterAttempt = true;
+ }
+ }
+ else if (selectedCamera != null && selectedCamera.Applied && !selectedCamera.Restored)
{
string returnEvidence;
// World3D+0x13D47 repurposes ESI for registry traversal before this epilogue.
@@ -3253,9 +3954,11 @@ internal static partial class NativeFrameCapture
{
try
{
- byte[] selectedAtReturn = ReadCameraMatrix(process, selectedCamera.CameraPointer);
- selectedCamera.MatrixIntactAtReturn = ByteArraysEqual(selectedAtReturn,
- selectedCamera.Input.MatrixBytes);
+ byte[] selectedAtReturn = ReadEffectiveWorldCameraMatrix(process,
+ selectedCamera.CameraPointer);
+ uint parentAtReturn = ReadCameraParent(process, selectedCamera.CameraPointer);
+ selectedCamera.MatrixIntactAtReturn = parentAtReturn == selectedCamera.OriginalParentPointer
+ && CameraMatricesNearlyEqual(selectedAtReturn, selectedCamera.Input.MatrixBytes);
}
catch (Exception matrixReadError)
{
@@ -3265,7 +3968,7 @@ internal static partial class NativeFrameCapture
if (!selectedCamera.MatrixIntactAtReturn)
{
selectedCamera.PixelAttributionInvalidated = true;
- selectedCamera.PixelAttributionFailure = "Selected camera matrix changed or became unreadable before its verified render return.";
+ selectedCamera.PixelAttributionFailure = "Selected camera effective-world matrix or parent changed before its verified render return.";
captureFailure = selectedCamera.PixelAttributionFailure;
stopAfterAttempt = true;
Log("SELECTED_CAMERA_CHANGED_BEFORE_RETURN; restoring the original native matrix but rejecting pixels");
@@ -3307,6 +4010,128 @@ internal static partial class NativeFrameCapture
Log("ATMOSPHERE_PHASE_SAMPLE_REJECTED tid=" + eventTid + " " + atmosphereError.Message);
}
}
+ else if (hitBreakpoint == beginSceneBreakpoint)
+ {
+ if (projectionSelectorMode)
+ {
+ uint renderer = ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva));
+ uint state = renderer == 0 ? 0
+ : (ReadU32(process, unchecked(renderer + 0x118)) >> 8) & 0xFF;
+ uint device = ReadU32(process, unchecked(ngiBase + NgiDeviceGlobalRva));
+ uint beginHresult = BitConverter.ToUInt32(stoppedContext, ContextEaxOffset);
+ bool accepted = false;
+ if (boundaryTrace)
+ {
+ accepted = trace.ObserveBeginScene(frame.CameraGeneration,
+ frame.CameraThreadId, eventTid, state, beginHresult == 0);
+ traceEventSequence++;
+ Log("TRACE_EVENT order=" + traceEventSequence + " kind=begin-scene"
+ + " tid=" + eventTid + " cameraGeneration=" + frame.CameraGeneration
+ + " renderer=0x" + renderer.ToString("X8") + " beginSceneFlag=" + state
+ + " device=0x" + device.ToString("X8")
+ + " hresult=0x" + beginHresult.ToString("X8")
+ + " accepted=" + accepted);
+ }
+ }
+ }
+ else if (hitBreakpoint == endSceneBreakpoint)
+ {
+ if (boundaryTrace)
+ {
+ uint renderer = ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva));
+ uint state = renderer == 0 ? 0
+ : (ReadU32(process, unchecked(renderer + 0x118)) >> 8) & 0xFF;
+ uint device = ReadU32(process, unchecked(ngiBase + NgiDeviceGlobalRva));
+ bool sameThread = eventTid == trace.SelectedThread
+ && frame.CameraThreadId == trace.SelectedThread;
+ bool sameGeneration = frame.CameraGeneration == trace.SelectedGeneration
+ && frame.CameraGeneration != 0;
+ bool sameRenderer = frame.Projection != null
+ && frame.Projection.Renderer == renderer;
+ uint endHresult = BitConverter.ToUInt32(stoppedContext, ContextEaxOffset);
+ bool accepted = trace.ObserveEndScene(frame.CameraGeneration,
+ frame.CameraThreadId, eventTid, state,
+ endHresult == 0 && sameRenderer);
+ traceEventSequence++;
+ Log("TRACE_EVENT order=" + traceEventSequence + " kind=end-scene"
+ + " tid=" + eventTid + " cameraGeneration=" + frame.CameraGeneration
+ + " renderer=0x" + renderer.ToString("X8") + " beginSceneFlag=" + state
+ + " device=0x" + device.ToString("X8")
+ + " hresult=0x" + endHresult.ToString("X8")
+ + " selectedProjectionSeen=" + trace.ProjectionSelected
+ + " sameSelectedThread=" + sameThread + " sameSelectedGeneration=" + sameGeneration
+ + " sameRenderer=" + sameRenderer + " accepted=" + accepted);
+ if (accepted)
+ {
+ try
+ {
+ remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase,
+ endSceneBreakpoint, frame,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
+ returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
+ endSceneBreakpoint,
+ fogConsumerBreakpoint, presentBreakpoint,
+ presentCompletionBreakpoint, missionBreakpoint });
+ }
+ catch (Exception probeError)
+ {
+ captureFailure = "Could not query the active render target at the first same-thread EndScene: "
+ + probeError.Message;
+ Log("BOUNDARY_PROBE_START_FAILED kind=end-scene " + probeError);
+ stopAfterAttempt = true;
+ }
+ }
+ }
+ else if (boundaryCapture)
+ {
+ uint renderer = ngiBase == 0 ? 0
+ : ReadU32(process, unchecked(ngiBase + NgiRendererGlobalRva));
+ uint state = renderer == 0 ? UInt32.MaxValue
+ : (ReadU32(process, unchecked(renderer + 0x118)) >> 8) & 0xFF;
+ uint endHresult = BitConverter.ToUInt32(stoppedContext, ContextEaxOffset);
+ bool sameThread = eventTid == boundaryCaptureThread
+ && frame.CameraThreadId == boundaryCaptureThread
+ && frame.ProjectionThreadId == boundaryCaptureThread;
+ bool sameGeneration = frame.CameraGeneration == boundaryCaptureGeneration
+ && frame.ProjectionGeneration == boundaryCaptureGeneration;
+ bool sameRenderer = frame.Projection != null && frame.Projection.Renderer == renderer;
+ bool canRead = !boundaryCapturePixelsStaged
+ && CanReadSelectedBoundaryPixels(boundaryCaptureSelected,
+ boundaryCaptureInvalidated, sameThread, sameGeneration, sameRenderer,
+ state, endHresult);
+ Log("BOUNDARY_CAPTURE_ENDSCENE tid=" + eventTid
+ + " selected=" + boundaryCaptureSelected
+ + " invalidated=" + boundaryCaptureInvalidated
+ + " selectedGeneration=" + boundaryCaptureGeneration
+ + " cameraGeneration=" + frame.CameraGeneration
+ + " projectionGeneration=" + frame.ProjectionGeneration
+ + " sameThread=" + sameThread + " sameGeneration=" + sameGeneration
+ + " sameRenderer=" + sameRenderer + " beginSceneFlag=" + state
+ + " hresult=0x" + endHresult.ToString("X8")
+ + " readbackEligible=" + canRead);
+ if (canRead)
+ {
+ try
+ {
+ remote = StartRemoteBoundaryReadback(process, eventTid, ngiBase,
+ endSceneBreakpoint, frame, outputPath,
+ new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint,
+ returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
+ endSceneBreakpoint,
+ fogConsumerBreakpoint, presentBreakpoint,
+ presentCompletionBreakpoint, missionBreakpoint });
+ }
+ catch (Exception readbackError)
+ {
+ captureFailure = "Could not read back the selected frame after EndScene: "
+ + readbackError.Message;
+ Log("BOUNDARY_READBACK_START_FAILED " + readbackError);
+ boundaryCaptureInvalidated = true;
+ boundaryCaptureSelected = false;
+ }
+ }
+ }
+ }
else if (hitBreakpoint == fogConsumerBreakpoint)
{
if (!TryCountNativeFogCall(frame))
@@ -3395,8 +4220,85 @@ internal static partial class NativeFrameCapture
+ MissionProbeAttemptLimit + " found=" + found
+ " path=" + (missionPath ?? "unknown") + " evidence=" + missionEvidence);
}
+ else if (hitBreakpoint == presentCompletionBreakpoint)
+ {
+ if (boundaryCapture)
+ {
+ bool sameThread = eventTid == boundaryCaptureThread
+ && frame.CameraThreadId == boundaryCaptureThread
+ && frame.ProjectionThreadId == boundaryCaptureThread;
+ bool sameGeneration = frame.CameraGeneration == boundaryCaptureGeneration
+ && frame.ProjectionGeneration == boundaryCaptureGeneration;
+ uint presentHresult = BitConverter.ToUInt32(stoppedContext, ContextEaxOffset);
+ bool validPresent = CanAcceptBoundaryPresent(boundaryCapturePixelsStaged,
+ boundaryCaptureInvalidated, sameThread, sameGeneration, presentHresult);
+ Log("BOUNDARY_CAPTURE_PRESENT_COMPLETE tid=" + eventTid
+ + " selectedGeneration=" + boundaryCaptureGeneration
+ + " cameraGeneration=" + frame.CameraGeneration
+ + " projectionGeneration=" + frame.ProjectionGeneration
+ + " pixelsStaged=" + boundaryCapturePixelsStaged
+ + " invalidated=" + boundaryCaptureInvalidated
+ + " sameThread=" + sameThread + " sameGeneration=" + sameGeneration
+ + " hresult=0x" + presentHresult.ToString("X8")
+ + " valid=" + validPresent);
+ if (validPresent)
+ {
+ boundaryCapturePresentVerified = true;
+ if (selectedCamera == null)
+ {
+ boundaryCaptureComplete = true;
+ captured = true;
+ }
+ }
+ else if (boundaryCapturePixelsStaged)
+ {
+ completedSurface = null;
+ completedFrame = null;
+ completedReadback = null;
+ boundaryCapturePixelsStaged = false;
+ boundaryCaptureSelected = false;
+ boundaryCaptureInvalidated = true;
+ captureFailure = "The readback did not reach a same-thread, same-generation successful Present.";
+ }
+ }
+ }
else if (hitBreakpoint == presentBreakpoint)
{
+ if (boundaryTrace)
+ {
+ bool accepted = trace.ObservePresent(frame.CameraGeneration,
+ frame.CameraThreadId, eventTid);
+ traceEventSequence++;
+ bool sameGeneration = frame.CameraGeneration == trace.SelectedGeneration
+ && frame.CameraGeneration != 0;
+ Log("TRACE_EVENT order=" + traceEventSequence + " kind=present"
+ + " tid=" + eventTid + " cameraGeneration=" + frame.CameraGeneration
+ + " selectedGeneration=" + trace.SelectedGeneration
+ + " sameSelectedThread=" + (eventTid == trace.SelectedThread
+ && frame.CameraThreadId == trace.SelectedThread)
+ + " sameGeneration=" + sameGeneration
+ + " endSceneSeen=" + trace.EndSceneSeen
+ + " attributionInvalidated=" + trace.AttributionInvalidated
+ + " accepted=" + accepted);
+ }
+ else if (boundaryCapture)
+ {
+ bool sameThread = eventTid == boundaryCaptureThread
+ && frame.CameraThreadId == boundaryCaptureThread
+ && frame.ProjectionThreadId == boundaryCaptureThread;
+ bool sameGeneration = frame.CameraGeneration == boundaryCaptureGeneration
+ && frame.ProjectionGeneration == boundaryCaptureGeneration;
+ Log("BOUNDARY_CAPTURE_PRESENT_CALL tid=" + eventTid
+ + " selectedGeneration=" + boundaryCaptureGeneration
+ + " cameraGeneration=" + frame.CameraGeneration
+ + " projectionGeneration=" + frame.ProjectionGeneration
+ + " pixelsStaged=" + boundaryCapturePixelsStaged
+ + " invalidated=" + boundaryCaptureInvalidated
+ + " sameThread=" + sameThread + " sameGeneration=" + sameGeneration
+ + " resultPending=true");
+ }
+ else
+ {
FinalizeNativeFogAtPresent(frame, eventTid);
if (!RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint))
throw new InvalidOperationException("Could not restore the native fog consumer breakpoint before Present readback.");
@@ -3418,11 +4320,15 @@ internal static partial class NativeFrameCapture
}
}
else Log("PRESENT_SKIPPED without matching verified perspective camera/projection");
+ }
}
if (remote == null && cameraSetter == null)
{
bool rearmAfterStep = hitBreakpoint != presentBreakpoint
+ || boundaryTrace && !trace.WorldReturnSeen
+ || boundaryCapture && !boundaryCaptureComplete;
+ rearmAfterStep = rearmAfterStep
&& !(hitBreakpoint == missionBreakpoint && missionProbeFinished);
if (hitBreakpoint == fogConsumerBreakpoint)
rearmAfterStep = !frame.NativeFogSamplingStopped
@@ -3500,7 +4406,9 @@ internal static partial class NativeFrameCapture
Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event");
if (!RestoreArmedBreakpointsWhileStopped(process,
cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
- fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint))
+ beginSceneBreakpoint,
+ endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
+ presentCompletionBreakpoint, missionBreakpoint))
{
Log("FATAL_TIMEOUT_BREAKPOINT_RESTORE_FAILED; terminating own verified scratch process");
TerminateProcess(process, 0xE007);
@@ -3519,13 +4427,18 @@ internal static partial class NativeFrameCapture
|| projectionBreakpoint != null && projectionBreakpoint.Armed
|| returnBreakpoint != null && returnBreakpoint.Armed
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
+ || beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
+ || endSceneBreakpoint != null && endSceneBreakpoint.Armed
|| fogConsumerBreakpoint != null && fogConsumerBreakpoint.Armed
|| presentBreakpoint != null && presentBreakpoint.Armed
+ || presentCompletionBreakpoint != null && presentCompletionBreakpoint.Armed
|| missionBreakpoint != null && missionBreakpoint.Armed))
{
if (!RestoreArmedBreakpointsWhileStopped(process,
cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
- fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint))
+ beginSceneBreakpoint,
+ endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
+ presentCompletionBreakpoint, missionBreakpoint))
{
Log("FATAL_STOP_BREAKPOINT_RESTORE_FAILED; terminating own verified scratch process");
TerminateProcess(process, 0xE008);
@@ -3573,8 +4486,11 @@ internal static partial class NativeFrameCapture
|| projectionBreakpoint != null && projectionBreakpoint.Armed
|| returnBreakpoint != null && returnBreakpoint.Armed
|| atmosphereBreakpoint != null && atmosphereBreakpoint.Armed
+ || beginSceneBreakpoint != null && beginSceneBreakpoint.Armed
+ || endSceneBreakpoint != null && endSceneBreakpoint.Armed
|| fogConsumerBreakpoint != null && fogConsumerBreakpoint.Armed
|| presentBreakpoint != null && presentBreakpoint.Armed
+ || presentCompletionBreakpoint != null && presentCompletionBreakpoint.Armed
|| missionBreakpoint != null && missionBreakpoint.Armed))
{
string state = remote != null ? "REMOTE_STUB_IN_FLIGHT" : (cameraSetter != null ? "CAMERA_SETTER_IN_FLIGHT"
@@ -3598,7 +4514,8 @@ internal static partial class NativeFrameCapture
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint,
- atmosphereBreakpoint, fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint },
+ atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
+ presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
out drainEvidence))
{
processExited = true;
@@ -3666,7 +4583,8 @@ internal static partial class NativeFrameCapture
ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode,
ref outstandingContinueStatus, ref outstandingFileHandleClosed,
new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint,
- atmosphereBreakpoint, fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint },
+ atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint,
+ presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint },
out drainEvidence))
{
processExited = true;
@@ -3702,15 +4620,57 @@ internal static partial class NativeFrameCapture
else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error());
}
breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint,
- projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint,
- fogConsumerBreakpoint, presentBreakpoint, missionBreakpoint);
+ projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint,
+ endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint,
+ presentCompletionBreakpoint, missionBreakpoint);
Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited);
if (eventBuffer != IntPtr.Zero) Marshal.FreeHGlobal(eventBuffer);
if (process != IntPtr.Zero) CloseHandle(process);
}
+ if (boundaryTrace)
+ {
+ bool boundaryTraceComplete = trace.IsComplete(breakpointsRestored, detached,
+ fatal, processExited, captureFailure == null);
+ bool traceSequenceComplete = trace.IsSequenceComplete();
+ Log("BOUNDARY_TRACE_RESULT complete=" + boundaryTraceComplete
+ + " sequenceComplete=" + traceSequenceComplete
+ + " beginSceneSeen=" + trace.BeginSceneSeen
+ + " projectionSelected=" + trace.ProjectionSelected
+ + " endSceneSeen=" + trace.EndSceneSeen
+ + " presentSeen=" + trace.PresentSeen
+ + " worldReturnSeen=" + trace.WorldReturnSeen
+ + " attributionInvalidated=" + trace.AttributionInvalidated
+ + " breakpointsRestored=" + breakpointsRestored + " detached=" + detached);
+ if (!breakpointsRestored)
+ throw new InvalidOperationException("Boundary trace ended without confirmed breakpoint restoration; inspect its log.");
+ if (boundaryTraceComplete)
+ {
+ Console.WriteLine("boundary trace complete (no pixels captured): " + outputPath);
+ return 0;
+ }
+ if (captureFailure == null)
+ captureFailure = "Boundary trace did not reach the ordered BeginScene/projection/EndScene/Present/World3D-return sequence before its timeout.";
+ }
if (!detached) throw new InvalidOperationException("Debug detach not confirmed; inspect the frame log before reuse.");
if (fatal) throw new InvalidOperationException("The native frame capture could not recover safely; inspect the frame log.");
+ if (boundaryCapture)
+ {
+ Log("BOUNDARY_CAPTURE_RESULT complete=" + boundaryCaptureComplete
+ + " projectionSelected=" + boundaryCaptureSelected
+ + " pixelsStaged=" + boundaryCapturePixelsStaged
+ + " presentVerified=" + boundaryCapturePresentVerified
+ + " invalidated=" + boundaryCaptureInvalidated
+ + " generation=" + boundaryCaptureGeneration
+ + " tid=" + boundaryCaptureThread
+ + " breakpointsRestored=" + breakpointsRestored + " detached=" + detached);
+ if (!breakpointsRestored)
+ throw new InvalidOperationException("Boundary capture ended without confirmed breakpoint restoration; inspect its log.");
+ if (boundaryCaptureComplete && (!captured || !boundaryCapturePixelsStaged || !boundaryCapturePresentVerified))
+ throw new InvalidOperationException("Boundary capture completed without staged pixels and a verified Present boundary.");
+ if (!boundaryCaptureComplete && captureFailure == null)
+ captureFailure = "Boundary capture did not reach a matching successful EndScene/Present before timeout.";
+ }
if (captured)
{
bool pngCreated = false;
@@ -3793,30 +4753,39 @@ internal static partial class NativeFrameCapture
private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process,
BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint,
- BreakpointInfo atmosphereBreakpoint, BreakpointInfo fogConsumerBreakpoint,
- BreakpointInfo presentBreakpoint, BreakpointInfo missionBreakpoint)
+ BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
+ BreakpointInfo fogConsumerBreakpoint,
+ BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
+ BreakpointInfo missionBreakpoint)
{
return RestoreBreakpointWhileStopped(process, cameraBreakpoint)
&& RestoreBreakpointWhileStopped(process, projectionBreakpoint)
&& RestoreBreakpointWhileStopped(process, returnBreakpoint)
&& RestoreBreakpointWhileStopped(process, atmosphereBreakpoint)
+ && RestoreBreakpointWhileStopped(process, beginSceneBreakpoint)
+ && RestoreBreakpointWhileStopped(process, endSceneBreakpoint)
&& RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint)
&& RestoreBreakpointWhileStopped(process, presentBreakpoint)
+ && RestoreBreakpointWhileStopped(process, presentCompletionBreakpoint)
&& RestoreBreakpointWhileStopped(process, missionBreakpoint);
}
private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint,
BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint,
- BreakpointInfo atmosphereBreakpoint, BreakpointInfo fogConsumerBreakpoint,
- BreakpointInfo presentBreakpoint,
+ BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint,
+ BreakpointInfo fogConsumerBreakpoint,
+ BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint,
BreakpointInfo missionBreakpoint)
{
return (cameraBreakpoint == null || !cameraBreakpoint.Armed)
&& (projectionBreakpoint == null || !projectionBreakpoint.Armed)
&& (returnBreakpoint == null || !returnBreakpoint.Armed)
&& (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed)
+ && (beginSceneBreakpoint == null || !beginSceneBreakpoint.Armed)
+ && (endSceneBreakpoint == null || !endSceneBreakpoint.Armed)
&& (fogConsumerBreakpoint == null || !fogConsumerBreakpoint.Armed)
&& (presentBreakpoint == null || !presentBreakpoint.Armed)
+ && (presentCompletionBreakpoint == null || !presentCompletionBreakpoint.Armed)
&& (missionBreakpoint == null || !missionBreakpoint.Armed);
}
diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md
index 802e2f7..428f8c2 100644
--- a/tools/native-frame-capture/README.md
+++ b/tools/native-frame-capture/README.md
@@ -1,6 +1,6 @@
# 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.
+Capture modes attach only to the isolated GOG-compatible scratch copy of `Parkan - Iron Strategy`. Capture writes a PNG and a `fparkan-legacy-camera-v1` JSON file; boundary-trace mode writes a log and captures no pixels. `selector0_words` remains the local camera matrix at `camera+0x20`; the capture also records `effective_world_matrix_words` from `camera+0x60` with `effective_world_matrix_space: "world"` and a `parented_camera` marker. Both local and world matrices are 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 JSON keeps the `fparkan-legacy-camera-v1` schema and adds `native_fog_status`, diagnostic counts, and `native_fog`. The latter is an optional passive CPU-side snapshot of the effective sink flags/RGB/range plus the paired calls to the `Terrain+0x2F230` consumer. The flags byte is raw and not a decoded fog mode. Each draw record preserves the distance argument, raw bypass dword and effective low byte, source color, output pointers, and return address. Geometry at `EBP` is included only for the verified `Terrain+0x18A6A` caller with the expected geometry vtable and slot+0x14 target; other callers have `distance_geometry: null`. The fog object is `null` unless the samples match the same render thread, camera, generation, and projection matrix and agree on sink settings. `native_fog_status` is `paired`, `conflict`, `unpaired`, or `incomplete`; `native_fog_status_reason` explains an unpaired, conflicting, or incomplete result. The counts include processed, overflow, paired, rejected, other-thread, and matrix-mismatch calls. At most 512 calls are processed per camera generation; reaching the limit stops sampling and reports an incomplete result. These fields are additive: camera-input validation continues to accept older JSON without them. This probe observes CPU consumer state; it does not set fog or change GPU state.
@@ -34,6 +34,31 @@ $process = Start-Process -FilePath (Join-Path $game 'iron_3d.exe') -WorkingDirec
if ($LASTEXITCODE -ne 0) { throw 'Native frame capture failed; inspect its output and choose fresh output paths before retrying.' }
```
+To inspect render-pass order without capturing pixels or changing the camera, use a previously verified world-view JSON as a projection selector:
+
+```powershell
+$worldReference = 'target\native-frame-capture\world-reference.json'
+$traceLog = 'target\native-frame-capture\boundary-trace.log'
+& 'target\native-frame-capture.exe' --trace-boundaries $process.Id $process.StartTime.ToUniversalTime().Ticks $traceLog --camera-input $worldReference
+if ($LASTEXITCODE -ne 0) { throw 'Boundary trace did not complete cleanly; inspect its log and process state.' }
+```
+
+Boundary-trace mode waits up to 60 seconds. On the current observed path, its event order is `BeginScene → ProjectionSnapshot → EndScene → Present → World3D return`; this describes the observed build, not a rendering guarantee for other versions or paths. Completion requires successful BeginScene and EndScene events, then the same selected camera generation and thread at EndScene, the Ngi32 Present boundary, and the matching World3D return. A later World3D render invalidates that attribution. The projection tuple selects matching world-view candidates but does not prove render-pass identity by itself. The helper briefly calls D3D7 `GetRenderTarget` at the selected World3D return and matching same-thread EndScene, then balances the returned COM reference with `Release`. It does not call `Lock`, read pixels, apply a camera matrix, or write GPU state. That pairing does not certify that EndScene is a HUD-free pixel-readback point. A zero exit code means the ordered trace completed, both render-target probes returned a valid surface, breakpoints were restored, the debugger detached, and the scratch process remained alive; it does not mean the helper captured a frame.
+
+To read pixels at the selected world render's EndScene boundary, use a fresh JSON/PNG/log basename:
+
+```powershell
+$candidate = 'target\native-frame-capture\world-boundary-candidate.json'
+& 'target/native-frame-capture.exe' --capture-world-boundary $process.Id $process.StartTime.ToUniversalTime().Ticks $candidate --camera-input $worldReference
+if ($LASTEXITCODE -ne 0) { throw 'World-boundary capture did not complete; inspect its log and process state.' }
+```
+
+The optional `--set-camera` switch temporarily applies the input camera matrix on the verified render thread and restores the original matrix after that frame returns. Capture waits for a projection match, then requires the matching thread/generation/renderer at successful EndScene with Ngi32's scene-active flag clear; it stages pixels only after D3D7 Unlock/Release cleanup and accepts them only if that same frame reaches a successful Present without another World3D render. JSON marks the image `candidate-awaiting-visual-review`; the first PNG must be inspected before treating it as a useful world-only view.
+
+Boundary capture keeps the Terrain atmosphere-phase probe separate from its Ngi32 BeginScene/EndScene probes. It records `atmosphere_seconds` only when the Terrain sample's arithmetic, world-clock value, and render-thread/generation pairing all verify. This is an observed phase for comparison; it does not freeze atmosphere or simulation state.
+
+Camera inputs may be a usable native capture or an explicitly marked `synthetic_camera: true` plan. Synthetic plans still need a rigid finite matrix and an explicit verified projection tuple/mode; they are requests only, never evidence that the game observed those values. When `effective_world_matrix_space` is `world`, the helper uses `effective_world_matrix_words` as the requested pose and applies it through native setter mode 2. Since that setter invalidates the effective-world cache, the remote stub then calls the verified transform-interface selector-2 getter before the return trap; the helper can safely compare the refreshed world matrix at the breakpoint. It keeps `selector0_words` as the local matrix and restores that exact local matrix through mode 1. Older or explicitly unparented inputs without world fields fall back to `selector0_words`; an explicitly parented input without an effective world matrix is rejected. For an oblique plan intended to use an existing render's projection, derive a target-only input with the plan's matrix and the verified reference's viewport, near/far, FOV, and mode, then pass `--set-camera`.
+
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
@@ -45,14 +70,12 @@ if ($LASTEXITCODE -ne 0) { throw 'Camera JSON is not a usable fparkan-legacy-cam
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.
+`--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 each active world-matrix component is within an absolute tolerance of `0.001` from the requested input, as well as checking 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 viewport or projection mode differs, the projection values exceed their configured tolerances, a requested matrix component differs by more than `0.001`, the selected matrix changes by more than that tolerance before return, or another render invalidates pixel attribution.
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. 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.
-Live verification on 2026-10-12 produced two sequential AutoDemo captures on PID 15792. Both detached successfully and left the same process running. The first paired 157 of 157 native fog calls; the second paired 250 of 250. Both had zero rejected, cross-thread, or camera/projection-mismatch samples, and both verified the Terrain module hash and same-generation camera/projection pairing.
-
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