diff options
Diffstat (limited to 'tools/native-frame-capture')
| -rw-r--r-- | tools/native-frame-capture/NativeFrameCapture.cs | 324 | ||||
| -rw-r--r-- | tools/native-frame-capture/README.md | 45 | ||||
| -rw-r--r-- | tools/native-frame-capture/compare-frames.ps1 | 106 |
3 files changed, 461 insertions, 14 deletions
diff --git a/tools/native-frame-capture/NativeFrameCapture.cs b/tools/native-frame-capture/NativeFrameCapture.cs index 6371ae6..9721049 100644 --- a/tools/native-frame-capture/NativeFrameCapture.cs +++ b/tools/native-frame-capture/NativeFrameCapture.cs @@ -49,6 +49,8 @@ internal static partial class NativeFrameCapture private const string Iron3dName = "iron3d.dll"; private const uint RenderGameRva = 0x13BD0; private const uint ProjectionSnapshotRva = 0x13CE4; + private const uint GeometryProjectionRva = 0x1D110; + private const uint SkyRenderRva = 0x48110; private const uint RenderReturnRva = 0x13D7B; private const uint AtmospherePhaseRva = 0x421DC; private const uint FogConsumerRva = 0x2F230; @@ -101,6 +103,10 @@ internal static partial class NativeFrameCapture public uint ProjectionGeneration; public uint ProjectionThreadId; public ProjectionReadback Projection; + public ProjectionReadback GeometryProjection; + public uint GeometryProjectionGeneration; + public uint GeometryProjectionThreadId; + public SkyUvReadback SkyUv; public uint CameraWorldGameTimeWord; public bool CameraWorldGameTimeWordReadable; public uint WorldGameTimeWord; @@ -129,6 +135,11 @@ internal static partial class NativeFrameCapture public string NativeFogStatus; } + private sealed class SkyUvReadback + { + public uint Object, Origin, RawClock, ElapsedMilliseconds, Generation, ThreadId; + } + private sealed class AtmosphereReadback { public uint SampleThreadId; @@ -201,6 +212,8 @@ internal static partial class NativeFrameCapture private sealed class SelectedCameraState { public SelectedCameraInput Input; + public int PrecedingFramesRemaining; + public int PrecedingFramesCompleted; public uint CandidateCameraPointer; public uint CandidateThreadId; public uint CandidateGeneration; @@ -226,6 +239,47 @@ internal static partial class NativeFrameCapture public uint OriginalParentPointer; } + private static SelectedCameraState NextSelectedWarmupFrame(SelectedCameraState previous) + { + if (previous.PrecedingFramesRemaining <= 0 || !previous.RestoreVerified + || !previous.Restored || !previous.MatrixIntactAtReturn + || !previous.ProjectionMatchesInput || previous.PixelAttributionInvalidated) + throw new InvalidOperationException("Cannot advance a selected camera before a verified frame and exact restoration."); + return new SelectedCameraState + { + Input = previous.Input, + PrecedingFramesRemaining = previous.PrecedingFramesRemaining - 1, + PrecedingFramesCompleted = previous.PrecedingFramesCompleted + 1, + CandidateCameraPointer = previous.CameraPointer, + CandidateThreadId = previous.ThreadId, + CandidateGeneration = previous.Generation, + CandidateProjectionVerified = true + }; + } + + private static void SelfCheckSelectedCameraWarmup() + { + SelectedCameraState first = new SelectedCameraState + { + PrecedingFramesRemaining = 2, CameraPointer = 123, ThreadId = 11, Generation = 8, + Restored = true, RestoreVerified = true, MatrixIntactAtReturn = true, + ProjectionMatchesInput = true + }; + SelectedCameraState second = NextSelectedWarmupFrame(first); + if (second.PrecedingFramesRemaining != 1 || second.PrecedingFramesCompleted != 1 + || second.CandidateCameraPointer != 123 || second.CandidateThreadId != 11 + || second.CandidateGeneration != 8 || second.Applied || second.RestoreVerified) + throw new InvalidOperationException("Selected camera warmup transition self-check failed."); + bool unrestoredRejected = false; + try { NextSelectedWarmupFrame(second); } + catch (InvalidOperationException) { unrestoredRejected = true; } + if (!unrestoredRejected) throw new InvalidOperationException("Unrestored warmup frame was accepted."); + second.Restored = second.RestoreVerified = second.MatrixIntactAtReturn = second.ProjectionMatchesInput = true; + SelectedCameraState final = NextSelectedWarmupFrame(second); + if (final.PrecedingFramesRemaining != 0 || final.PrecedingFramesCompleted != 2) + throw new InvalidOperationException("Selected camera final frame self-check failed."); + } + private sealed class BoundaryTraceProgress { public bool BeginSceneSeen; @@ -695,6 +749,104 @@ internal static partial class NativeFrameCapture return match; } + private static bool GeometryProjectionMatchesFrame(FrameSnapshot frame) + { + ProjectionReadback geometry = frame.GeometryProjection; + ProjectionReadback boundary = frame.Projection; + if (geometry == null || boundary == null || !geometry.Verified || !boundary.Verified || geometry.Mode == 0 + || frame.GeometryProjectionGeneration == 0 + || frame.GeometryProjectionGeneration != frame.CameraGeneration + || frame.GeometryProjectionGeneration != frame.ProjectionGeneration + || frame.GeometryProjectionThreadId != frame.CameraThreadId + || frame.GeometryProjectionThreadId != frame.ProjectionThreadId + || geometry.Renderer != boundary.Renderer || geometry.Far != boundary.Far + || geometry.Fov != boundary.Fov) return false; + for (int i = 0; i < 4; i++) + if (geometry.Viewport[i] != boundary.Viewport[i]) return false; + return true; + } + + private static void AppendGeometryProjectionJson(StringBuilder json, FrameSnapshot frame) + { + if (!GeometryProjectionMatchesFrame(frame)) + { + json.AppendLine(" \"geometry_projection\": null,"); + return; + } + ProjectionReadback p = frame.GeometryProjection; + json.AppendLine(" \"geometry_projection\": {"); + json.AppendLine(" \"verified\": true,"); + json.AppendLine(" \"sample_boundary\": \"Terrain+0x1D110, before indexed geometry draw\","); + json.AppendLine(" \"terrain_module_sha256\": " + JsonString(ExpectedSha256[TerrainName]) + ","); + json.AppendLine(" \"frame_generation\": " + frame.GeometryProjectionGeneration + ","); + json.AppendLine(" \"thread_id\": " + frame.GeometryProjectionThreadId + ","); + json.AppendLine(" \"viewport\": [" + String.Join(", ", p.Viewport) + "],"); + json.AppendLine(" \"near_plane\": " + p.Near.ToString("R", CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"far_plane\": " + p.Far.ToString("R", CultureInfo.InvariantCulture) + ","); + json.AppendLine(" \"field_of_view_radians\": " + p.Fov.ToString("R", CultureInfo.InvariantCulture)); + json.AppendLine(" },"); + } + + private static SkyUvReadback CaptureSkyUv(IntPtr process, uint terrainBase, uint worldBase, + byte[] context, FrameSnapshot frame, uint threadId) + { + uint obj = BitConverter.ToUInt32(context, ContextEcxOffset); + uint stack = BitConverter.ToUInt32(context, ContextEspOffset); + if (frame.Camera == null || frame.CameraThreadId != threadId || frame.CameraGeneration == 0 + || ReadU32Exact(process, stack + 4) != frame.Camera.Camera + || ReadU32Exact(process, obj) != unchecked(terrainBase + 0x65F98)) return null; + uint origin = ReadU32Exact(process, obj + 8); + uint rawClock = ReadU32Exact(process, unchecked(worldBase + 0x32A38)); + uint elapsed = unchecked(rawClock - origin); + float[] expected = new float[] { + (elapsed % 300000) * 3.3333333e-6f, (elapsed % 300000) * 3.3333333e-6f, + (elapsed % 100000) * 1e-5f, (elapsed % 100000) * 1e-5f, + (elapsed % 50000) * 2e-5f, 0 }; + uint[] pointers = new uint[] { ReadU32Exact(process, obj + 0x48), + ReadU32Exact(process, obj + 0x40), ReadU32Exact(process, obj + 0x44) }; + for (int i = 0; i < expected.Length; i++) + { + float actual = BitConverter.ToSingle(BitConverter.GetBytes( + ReadU32Exact(process, pointers[i / 2] + (uint)(i % 2) * 4)), 0); + if (Single.IsNaN(actual) || Single.IsInfinity(actual) + || Math.Abs(actual - expected[i]) > 1e-6f) + { + Log("SKY_UV_CLOCK_REJECTED zenith coordinate does not match the current world clock."); + return null; + } + } + Log("SKY_UV_CLOCK generation=" + frame.CameraGeneration + " tid=" + threadId + + " raw=" + rawClock + " origin=" + origin + " elapsed_ms=" + elapsed); + return new SkyUvReadback { Object = obj, Origin = origin, RawClock = rawClock, + ElapsedMilliseconds = elapsed, Generation = frame.CameraGeneration, ThreadId = threadId }; + } + + private static bool SkyUvMatchesFrame(FrameSnapshot frame) + { + SkyUvReadback sky = frame.SkyUv; + return sky != null && sky.Generation == frame.CameraGeneration + && sky.Generation == frame.ProjectionGeneration && sky.ThreadId == frame.CameraThreadId + && sky.ThreadId == frame.ProjectionThreadId && frame.WorldGameTimeWordReadable + && sky.RawClock == frame.WorldGameTimeWord && frame.CameraWorldGameTimeWordReadable + && sky.RawClock == frame.CameraWorldGameTimeWord; + } + + private static void AppendSkyUvJson(StringBuilder json, FrameSnapshot frame) + { + if (!SkyUvMatchesFrame(frame)) { json.AppendLine(" \"sky_uv_clock\": null,"); return; } + SkyUvReadback sky = frame.SkyUv; + json.AppendLine(" \"sky_uv_clock\": {"); + json.AppendLine(" \"verified\": true,"); + json.AppendLine(" \"sample_boundary\": \"Terrain+0x48110, sky render\","); + json.AppendLine(" \"terrain_module_sha256\": " + JsonString(ExpectedSha256[TerrainName]) + ","); + json.AppendLine(" \"raw_clock\": " + sky.RawClock + ","); + json.AppendLine(" \"origin_raw_clock\": " + sky.Origin + ","); + json.AppendLine(" \"frame_generation\": " + sky.Generation + ","); + json.AppendLine(" \"thread_id\": " + sky.ThreadId + ","); + json.AppendLine(" \"elapsed_milliseconds\": " + sky.ElapsedMilliseconds); + json.AppendLine(" },"); + } + private static bool VerifyAtmospherePhaseBoundary(IntPtr process, uint terrainBase, out byte firstByte, out string evidence) { @@ -1236,6 +1388,7 @@ internal static partial class NativeFrameCapture return CanArmPresentForFrame(frame, threadId) && (!frame.SelectedCameraRequested || (frame.SelectedCamera != null && frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput + && frame.SelectedCamera.PrecedingFramesRemaining == 0 && frame.SelectedCamera.CandidateProjectionVerified && frame.SelectedCamera.Restored && frame.SelectedCamera.RestoreVerified && frame.SelectedCamera.MatrixIntactAtReturn @@ -1276,6 +1429,7 @@ internal static partial class NativeFrameCapture && frame.CameraThreadId == threadId && frame.ProjectionThreadId == threadId && (!frame.SelectedCameraRequested || (frame.SelectedCamera != null && frame.SelectedCamera.Applied && frame.SelectedCamera.ProjectionMatchesInput + && frame.SelectedCamera.PrecedingFramesRemaining == 0 && frame.SelectedCamera.CandidateProjectionVerified && !frame.SelectedCamera.PixelAttributionInvalidated && SelectedMatrixMatchesProjection(frame))); @@ -1843,11 +1997,14 @@ internal static partial class NativeFrameCapture json.AppendLine(" \"far_plane\": " + projection.Far.ToString("R", CultureInfo.InvariantCulture) + ","); json.AppendLine(" \"field_of_view_radians\": " + projection.Fov.ToString("R", CultureInfo.InvariantCulture) + ","); json.AppendLine(" \"projection_mode_byte\": " + projection.Mode.ToString(CultureInfo.InvariantCulture) + ","); + AppendGeometryProjectionJson(json, frame); + AppendSkyUvJson(json, frame); if (frame.SelectedCamera == null) json.AppendLine(" \"selected_camera\": null,"); else { SelectedCameraState selected = frame.SelectedCamera; json.AppendLine(" \"selected_camera\": {"); + json.AppendLine(" \"preceding_selected_world_frames\": " + selected.PrecedingFramesCompleted + ","); json.AppendLine(" \"input_json\": " + JsonString(selected.Input.Path) + ","); json.AppendLine(" \"input_kind\": " + JsonString(selected.Input.Synthetic ? "synthetic-camera-plan" : "native-capture") + ","); @@ -1949,6 +2106,9 @@ internal static partial class NativeFrameCapture { if (IntPtr.Size != 4) throw new InvalidOperationException("This helper must be compiled and run as x86."); SelfCheckCameraInput(); + SelfCheckSelectedCameraWarmup(); + SelfCheckGeometryProjection(); + SelfCheckSkyUv(); SelfCheckNativeFogPairing(); SelfCheckRenderInvocationGates(); SelfCheckDebugEventHandleOwnership(); @@ -2088,6 +2248,10 @@ internal static partial class NativeFrameCapture bool wrongCameraThreadRejected = !IsMatchingPerspectiveFrame(matched, 12); bool emptyCameraFrameRejected = !IsMatchingPerspectiveFrame(new FrameSnapshot(), 11); bool selectedFrameValid = IsMatchingPerspectiveFrame(selectedMatched, 11); + selectedMatched.SelectedCamera.PrecedingFramesRemaining = 1; + bool selectedWarmupRejected = !CanArmPresentForFrame(selectedMatched, 11) + && !IsMatchingPerspectiveFrame(selectedMatched, 11); + selectedMatched.SelectedCamera.PrecedingFramesRemaining = 0; bool selectedUnrestoredRejected = !IsMatchingPerspectiveFrame(selectedUnrestored, 11); bool selectedUnrestoredCaptureEligible = CanArmPresentForFrame(selectedUnrestored, 11); bool selectedIntervenedCaptureRejected = !CanArmPresentForFrame(selectedIntervened, 11); @@ -2189,7 +2353,7 @@ internal static partial class NativeFrameCapture || !atmosphereFrameValid || !atmosphereClockMismatchRejected || !cameraFrameValid || !wrongCameraThreadRejected || !emptyCameraFrameRejected - || !selectedFrameValid || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible + || !selectedFrameValid || !selectedWarmupRejected || !selectedUnrestoredRejected || !selectedUnrestoredCaptureEligible || !selectedIntervenedCaptureRejected || !selectedIntervenedRejected || !selectedChangedMatrixRejected || !selectedBoundaryReadAllowed || !boundaryReadRejectsUnselected @@ -2255,6 +2419,60 @@ internal static partial class NativeFrameCapture return SelfCheckReadback(); } + private static void SelfCheckSkyUv() + { + FrameSnapshot frame = new FrameSnapshot { CameraGeneration = 7, ProjectionGeneration = 7, + CameraThreadId = 11, ProjectionThreadId = 11, WorldGameTimeWordReadable = true, + CameraWorldGameTimeWordReadable = true, CameraWorldGameTimeWord = 265584022, + WorldGameTimeWord = 265584022, + SkyUv = new SkyUvReadback { Generation = 7, ThreadId = 11, RawClock = 265584022, + Origin = 509280060, ElapsedMilliseconds = unchecked(265584022U - 509280060U) } }; + StringBuilder json = new StringBuilder(); + AppendSkyUvJson(json, frame); + if (!SkyUvMatchesFrame(frame) || json.ToString().IndexOf("4051271258", StringComparison.Ordinal) < 0) + throw new InvalidOperationException("Verified sky UV wrapping clock was rejected."); + frame.SkyUv.ThreadId++; + if (SkyUvMatchesFrame(frame)) throw new InvalidOperationException("Sky UV thread mismatch accepted."); + frame.SkyUv.ThreadId--; + frame.SkyUv.Generation++; + if (SkyUvMatchesFrame(frame)) throw new InvalidOperationException("Sky UV generation mismatch accepted."); + frame.SkyUv.Generation--; + frame.SkyUv.RawClock++; + json.Length = 0; + AppendSkyUvJson(json, frame); + if (SkyUvMatchesFrame(frame) || json.ToString().IndexOf("null", StringComparison.Ordinal) < 0) + throw new InvalidOperationException("Sky UV clock mismatch accepted."); + Console.WriteLine("self-check: sky UV clock requires matching thread/generation/world clock and preserves u32 wrap OK"); + } + + private static void SelfCheckGeometryProjection() + { + FrameSnapshot frame = new FrameSnapshot { + CameraGeneration = 7, ProjectionGeneration = 7, GeometryProjectionGeneration = 7, + CameraThreadId = 11, ProjectionThreadId = 11, GeometryProjectionThreadId = 11, + Projection = new ProjectionReadback { Renderer = 100, Viewport = new int[] { 0, 0, 1280, 1024 }, + Near = 5, Far = 700, Fov = 1.3f, Mode = 1, Verified = true }, + GeometryProjection = new ProjectionReadback { Renderer = 100, Viewport = new int[] { 0, 0, 1280, 1024 }, + Near = 0.5f, Far = 700, Fov = 1.3f, Mode = 1, Verified = true } + }; + StringBuilder json = new StringBuilder("{\n"); + AppendGeometryProjectionJson(json, frame); + json.Append("\"end\":true}"); + Dictionary<string, object> parsed = new System.Web.Script.Serialization.JavaScriptSerializer() + .Deserialize<Dictionary<string, object>>(json.ToString()); + Dictionary<string, object> projection = (Dictionary<string, object>)parsed["geometry_projection"]; + if (Convert.ToSingle(projection["near_plane"]) != 0.5f) + throw new InvalidOperationException("Geometry projection must preserve draw near, not restored camera near."); + frame.GeometryProjectionThreadId++; + if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection thread mismatch accepted."); + frame.GeometryProjectionThreadId--; + frame.GeometryProjectionGeneration--; + if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection generation mismatch accepted."); + frame.GeometryProjectionGeneration++; + frame.GeometryProjection.Viewport[2]--; + if (GeometryProjectionMatchesFrame(frame)) throw new InvalidOperationException("Geometry projection viewport mismatch accepted."); + } + private static void SelfCheckNativeFogPairing() { if (FogConsumerRva != 0x2F230 || FogConsumerVtableRva != 0x6527C @@ -2894,6 +3112,8 @@ internal static partial class NativeFrameCapture string terrainModuleSha256 = null; BreakpointInfo cameraBreakpoint = null; BreakpointInfo projectionBreakpoint = null; + BreakpointInfo geometryProjectionBreakpoint = null; + BreakpointInfo skyUvBreakpoint = null; BreakpointInfo returnBreakpoint = null; BreakpointInfo atmosphereBreakpoint = null; BreakpointInfo beginSceneBreakpoint = null; @@ -2919,7 +3139,8 @@ internal static partial class NativeFrameCapture bool projectionSelectorMode = boundaryTrace || boundaryCapture; frame.SelectedCameraRequested = selectedCameraInput != null; SelectedCameraState selectedCamera = selectedCameraInput == null ? null - : new SelectedCameraState { Input = selectedCameraInput }; + : new SelectedCameraState { Input = selectedCameraInput, + PrecedingFramesRemaining = boundaryCapture ? 0 : 2 }; try { if (identityProcess == null || identityProcess.HasExited @@ -3076,6 +3297,21 @@ internal static partial class NativeFrameCapture throw new InvalidOperationException("Loaded Terrain hash mismatch: " + hash); terrainBase = imageBase; terrainModuleSha256 = hash; + byte[] geometryPrefix = new byte[] { 0x81, 0xEC, 0xBC, 0, 0, 0, 0x53, 0x55, 0x8B, 0xE9 }; + byte[] geometryCode = new byte[geometryPrefix.Length]; + if (!ReadExact(process, unchecked(terrainBase + GeometryProjectionRva), geometryCode) + || !ByteArraysEqual(geometryCode, geometryPrefix)) + throw new InvalidOperationException("Terrain geometry projection boundary signature failed."); + geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection", + unchecked(terrainBase + GeometryProjectionRva), geometryPrefix[0]); + byte[] skyPrefix = new byte[] { 0x6A, 0xFF, 0x68, 0, 0, 0, 0, 0x64, 0xA1, 0, 0, 0, 0 }; + Array.Copy(BitConverter.GetBytes(unchecked(terrainBase + 0x637E6)), 0, skyPrefix, 3, 4); + byte[] skyCode = new byte[skyPrefix.Length]; + if (!ReadExact(process, unchecked(terrainBase + SkyRenderRva), skyCode) + || !ByteArraysEqual(skyCode, skyPrefix)) + throw new InvalidOperationException("Terrain sky render boundary signature failed."); + skyUvBreakpoint = ArmBreakpoint(process, "Terrain.sky-uv-clock", + unchecked(terrainBase + SkyRenderRva), skyPrefix[0]); byte atmosphereByte; string atmosphereEvidence; if (!VerifyAtmospherePhaseBoundary(process, terrainBase, out atmosphereByte, out atmosphereEvidence)) @@ -3330,6 +3566,12 @@ internal static partial class NativeFrameCapture Log("CAMERA_SETTER_REGION_FREED phase=Restore"); else Log("CAMERA_SETTER_REGION_FREE_FAILED phase=Restore error=" + Marshal.GetLastWin32Error()); cameraSetter = null; + if (!stopAfterAttempt && selectedCamera.PrecedingFramesRemaining > 0) + { + selectedCamera = NextSelectedWarmupFrame(selectedCamera); + Log("SELECTED_CAMERA_WARMUP_COMPLETE completed=" + selectedCamera.PrecedingFramesCompleted + + " remaining=" + selectedCamera.PrecedingFramesRemaining); + } } } } @@ -3535,6 +3777,12 @@ internal static partial class NativeFrameCapture hitBreakpoint = cameraBreakpoint; else if (projectionBreakpoint != null && projectionBreakpoint.Armed && exceptionAddress == projectionBreakpoint.Address) hitBreakpoint = projectionBreakpoint; + else if (geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed + && exceptionAddress == geometryProjectionBreakpoint.Address) + hitBreakpoint = geometryProjectionBreakpoint; + else if (skyUvBreakpoint != null && skyUvBreakpoint.Armed + && exceptionAddress == skyUvBreakpoint.Address) + hitBreakpoint = skyUvBreakpoint; else if (returnBreakpoint != null && returnBreakpoint.Armed && exceptionAddress == returnBreakpoint.Address) hitBreakpoint = returnBreakpoint; else if (atmosphereBreakpoint != null && atmosphereBreakpoint.Armed && exceptionAddress == atmosphereBreakpoint.Address) @@ -3608,6 +3856,16 @@ internal static partial class NativeFrameCapture && !RestoreBreakpointWhileStopped(process, fogConsumerBreakpoint)) throw new InvalidOperationException("Could not disarm the previous generation's native fog boundary."); frame.CameraGeneration++; + frame.GeometryProjection = null; + frame.SkyUv = null; + if (skyUvBreakpoint != null && !skyUvBreakpoint.Armed) + skyUvBreakpoint = ArmBreakpoint(process, "Terrain.sky-uv-clock", + skyUvBreakpoint.Address, skyUvBreakpoint.OriginalByte); + frame.GeometryProjectionGeneration = 0; + frame.GeometryProjectionThreadId = 0; + if (geometryProjectionBreakpoint != null && !geometryProjectionBreakpoint.Armed) + geometryProjectionBreakpoint = ArmBreakpoint(process, "Terrain.geometry-projection", + geometryProjectionBreakpoint.Address, geometryProjectionBreakpoint.OriginalByte); ResetNativeFogForGeneration(frame); frame.CameraThreadId = eventTid; frame.CameraWorldGameTimeWordReadable = false; @@ -3696,6 +3954,31 @@ internal static partial class NativeFrameCapture frame, eventTid, ref fogConsumerBreakpoint); } } + else if (hitBreakpoint == skyUvBreakpoint) + { + if (frame.SkyUv == null && world3dBase != 0) + frame.SkyUv = CaptureSkyUv(process, terrainBase, world3dBase, + stoppedContext, frame, eventTid); + } + else if (hitBreakpoint == geometryProjectionBreakpoint) + { + uint esp = BitConverter.ToUInt32(stoppedContext, ContextEspOffset); + if (frame.Camera != null && frame.CameraThreadId == eventTid && ngiBase != 0 + && ReadU32Exact(process, esp + 8) == frame.Camera.Camera + 0x60 + && ReadU32Exact(process, esp + 4) == ReadU32Exact(process, ngiBase + NgiRendererGlobalRva)) + { + ProjectionReadback p = ReadProjection(process, ngiBase); + if (p.Verified && p.Mode != 0) + { + frame.GeometryProjection = p; + frame.GeometryProjectionGeneration = frame.CameraGeneration; + frame.GeometryProjectionThreadId = eventTid; + Log("GEOMETRY_PROJECTION generation=" + frame.CameraGeneration + " tid=" + eventTid + + " near=" + p.Near.ToString("R", CultureInfo.InvariantCulture) + + " far=" + p.Far.ToString("R", CultureInfo.InvariantCulture)); + } + } + } else if (hitBreakpoint == projectionBreakpoint) { string candidateProjectionEvidence = "candidate preconditions not met"; @@ -3870,7 +4153,8 @@ internal static partial class NativeFrameCapture && targetProjectionMatchesInput) + " rawWorldTime=0x" + frame.WorldGameTimeWord.ToString("X8")); if (cameraWordsCurrent && frame.Projection.Verified && frame.Projection.Mode != 0 - && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified) + && targetProjectionMatchesInput && ngiBase != 0 && presentBoundaryVerified + && (selectedCamera == null || selectedCamera.PrecedingFramesRemaining == 0)) EnsurePresentBreakpoint(process, ngiBase, frame, eventTid, presentOriginalByte, ref presentBreakpoint); } @@ -3919,7 +4203,7 @@ internal static partial class NativeFrameCapture { remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase, returnBreakpoint, frame, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, fogConsumerBreakpoint, endSceneBreakpoint, presentBreakpoint, @@ -3975,7 +4259,7 @@ internal static partial class NativeFrameCapture } cameraSetter = StartCameraSetter(process, eventTid, terrainBase, selectedCamera.CameraPointer, selectedCamera.OriginalMatrixBytes, - stoppedContext, returnBreakpoint, false, CameraSetterPhase.Restore, + stoppedContext, returnBreakpoint, selectedCamera.PrecedingFramesRemaining > 0, CameraSetterPhase.Restore, selectedCamera.EntryEsp, selectedCamera.ReturnAddress); Log("SELECTED_CAMERA_RESTORE_STARTED generation=" + selectedCamera.Generation + " camera=0x" + selectedCamera.CameraPointer.ToString("X8") @@ -4067,7 +4351,7 @@ internal static partial class NativeFrameCapture { remote = StartRemoteSurfaceProbe(process, eventTid, ngiBase, endSceneBreakpoint, frame, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, @@ -4115,7 +4399,7 @@ internal static partial class NativeFrameCapture { remote = StartRemoteBoundaryReadback(process, eventTid, ngiBase, endSceneBreakpoint, frame, outputPath, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, @@ -4333,6 +4617,10 @@ internal static partial class NativeFrameCapture if (hitBreakpoint == fogConsumerBreakpoint) rearmAfterStep = !frame.NativeFogSamplingStopped && !frame.NativeFogSampleLimitExceeded; + if (hitBreakpoint == geometryProjectionBreakpoint) + rearmAfterStep = frame.GeometryProjection == null; + if (hitBreakpoint == skyUvBreakpoint) + rearmAfterStep = frame.SkyUv == null; BeginSingleStep(process, eventTid, hitBreakpoint, stoppedContext, out pendingStep, rearmAfterStep); Log("SINGLE_STEP_STARTED boundary=" + hitBreakpoint.Name + " tid=" + eventTid); @@ -4405,7 +4693,7 @@ internal static partial class NativeFrameCapture captureFailure = captureFailure ?? "No matching native camera/projection/present frame completed before the bounded timeout."; Log("NO_FRAME_CAPTURE bounded timeout at stopped debug event"); if (!RestoreArmedBreakpointsWhileStopped(process, - cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, + cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint)) @@ -4425,6 +4713,8 @@ internal static partial class NativeFrameCapture && !fatal && !processExited && (cameraBreakpoint != null && cameraBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed + || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed + || skyUvBreakpoint != null && skyUvBreakpoint.Armed || returnBreakpoint != null && returnBreakpoint.Armed || atmosphereBreakpoint != null && atmosphereBreakpoint.Armed || beginSceneBreakpoint != null && beginSceneBreakpoint.Armed @@ -4435,7 +4725,7 @@ internal static partial class NativeFrameCapture || missionBreakpoint != null && missionBreakpoint.Armed)) { if (!RestoreArmedBreakpointsWhileStopped(process, - cameraBreakpoint, projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, + cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint)) @@ -4484,6 +4774,8 @@ internal static partial class NativeFrameCapture || cameraMutationUnrestored || eventOutstanding || cameraBreakpoint != null && cameraBreakpoint.Armed || projectionBreakpoint != null && projectionBreakpoint.Armed + || geometryProjectionBreakpoint != null && geometryProjectionBreakpoint.Armed + || skyUvBreakpoint != null && skyUvBreakpoint.Armed || returnBreakpoint != null && returnBreakpoint.Armed || atmosphereBreakpoint != null && atmosphereBreakpoint.Armed || beginSceneBreakpoint != null && beginSceneBreakpoint.Armed @@ -4513,7 +4805,7 @@ internal static partial class NativeFrameCapture if (DrainFrameTerminationEvents(process, eventBuffer, processId, ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode, ref outstandingContinueStatus, ref outstandingFileHandleClosed, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint }, out drainEvidence)) @@ -4582,7 +4874,7 @@ internal static partial class NativeFrameCapture if (DrainFrameTerminationEvents(process, eventBuffer, processId, ref eventOutstanding, ref outstandingPid, ref outstandingTid, ref outstandingCode, ref outstandingContinueStatus, ref outstandingFileHandleClosed, - new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, returnBreakpoint, + new BreakpointInfo[] { cameraBreakpoint, projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint }, out drainEvidence)) @@ -4620,7 +4912,7 @@ internal static partial class NativeFrameCapture else Log("PROCESS_STATE_AFTER_DETACH unreadable error=" + Marshal.GetLastWin32Error()); } breakpointsRestored = processExited || AreBreakpointsRestored(cameraBreakpoint, - projectionBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, + projectionBreakpoint, geometryProjectionBreakpoint, skyUvBreakpoint, returnBreakpoint, atmosphereBreakpoint, beginSceneBreakpoint, endSceneBreakpoint, fogConsumerBreakpoint, presentBreakpoint, presentCompletionBreakpoint, missionBreakpoint); Log("FINAL_BREAKPOINT_STATE restored=" + breakpointsRestored + " processExited=" + processExited); @@ -4752,7 +5044,7 @@ internal static partial class NativeFrameCapture } private static bool RestoreArmedBreakpointsWhileStopped(IntPtr process, - BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint, + BreakpointInfo cameraBreakpoint, BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo skyUvBreakpoint, BreakpointInfo returnBreakpoint, BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint, BreakpointInfo fogConsumerBreakpoint, BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint, @@ -4760,6 +5052,8 @@ internal static partial class NativeFrameCapture { return RestoreBreakpointWhileStopped(process, cameraBreakpoint) && RestoreBreakpointWhileStopped(process, projectionBreakpoint) + && RestoreBreakpointWhileStopped(process, geometryProjectionBreakpoint) + && RestoreBreakpointWhileStopped(process, skyUvBreakpoint) && RestoreBreakpointWhileStopped(process, returnBreakpoint) && RestoreBreakpointWhileStopped(process, atmosphereBreakpoint) && RestoreBreakpointWhileStopped(process, beginSceneBreakpoint) @@ -4771,7 +5065,7 @@ internal static partial class NativeFrameCapture } private static bool AreBreakpointsRestored(BreakpointInfo cameraBreakpoint, - BreakpointInfo projectionBreakpoint, BreakpointInfo returnBreakpoint, + BreakpointInfo projectionBreakpoint, BreakpointInfo geometryProjectionBreakpoint, BreakpointInfo skyUvBreakpoint, BreakpointInfo returnBreakpoint, BreakpointInfo atmosphereBreakpoint, BreakpointInfo beginSceneBreakpoint, BreakpointInfo endSceneBreakpoint, BreakpointInfo fogConsumerBreakpoint, BreakpointInfo presentBreakpoint, BreakpointInfo presentCompletionBreakpoint, @@ -4779,6 +5073,8 @@ internal static partial class NativeFrameCapture { return (cameraBreakpoint == null || !cameraBreakpoint.Armed) && (projectionBreakpoint == null || !projectionBreakpoint.Armed) + && (geometryProjectionBreakpoint == null || !geometryProjectionBreakpoint.Armed) + && (skyUvBreakpoint == null || !skyUvBreakpoint.Armed) && (returnBreakpoint == null || !returnBreakpoint.Armed) && (atmosphereBreakpoint == null || !atmosphereBreakpoint.Armed) && (beginSceneBreakpoint == null || !beginSceneBreakpoint.Armed) diff --git a/tools/native-frame-capture/README.md b/tools/native-frame-capture/README.md index 428f8c2..d8f621c 100644 --- a/tools/native-frame-capture/README.md +++ b/tools/native-frame-capture/README.md @@ -2,6 +2,10 @@ 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. +`geometry_projection` records the renderer's perspective values at the first indexed geometry draw (`Terrain+0x1D110`) using that world's camera matrix and renderer. It is emitted only when thread, generation, renderer, viewport, far plane and FOV match the later World3D boundary. The near plane may differ: the observed world draw uses `0.5`, while the restored camera boundary contains `5`. FParkan prefers this verified object when replaying the capture. The top-level projection fields remain the camera-boundary tuple used for selected-camera preflight. Older captures without this object still load, with `render_coverage captured_projection_unverified=1`; nearby surfaces in those replays can be clipped incorrectly. Capture a fresh frame to verify them. + +`sky_uv_clock` records the independent scrolling-texture clock at sky render entry (`Terrain+0x48110`). It requires a verified module/prologue, the selected camera, matching render thread/generation/world clock, and zenith UV values matching all three native scroll formulas. It preserves wrapping `u32` subtraction from the sky object's origin clock. FParkan holds this phase during exact capture replay; ordinary application frames animate the cloud, star and nebula UVs with periods of 50, 100 and 300 seconds. Older captures without this object still load and report `render_coverage captured_sky_phase_unverified=1`; their sky texture phase is not a native reference. + 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. The capture helper expects an isolated copy at `target\shadow-probe\Parkan - Iron Strategy`; the original GOG installation stays untouched. For the default local install path, create that scratch copy from the repository root with: @@ -74,6 +78,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Selected-camera capture failed; inspect its lo This verifies repeatable camera handling, not deterministic whole-scene pixels. The helper has no setter for FOV, near/far planes, or fog, and does not freeze simulation state; `atmosphere_seconds` remains an observed phase sample rather than a restored simulation clock. If the callsite probe cannot verify a mission path, `mission_path` remains `null` and `mission_identity` remains `unknown`. +For `--capture --camera-input`, the requested pose is rendered on three consecutive world frames. The first two are discarded so portal visibility and other render caches can settle. The original local matrix is restored and checked after every frame; the third frame alone supplies the pixels, projection and atmosphere sample. `selected_camera.preceding_selected_world_frames` records the two preceding verified frames. A failed matrix or restoration check stops the sequence. Passive and EndScene-boundary capture keep their single-frame behavior. + For a second capture, reuse the still-running PID only after the first run detached successfully, and choose a new output basename. Reuse the same validated camera input if comparing the same pose. The helper rejects any existing JSON, PNG, or log with the chosen basename and never overwrites those files. After a successful one-shot capture it detaches and leaves the scratch game running. It explicitly closes only image-file handles from `CREATE_PROCESS` and `LOAD_DLL`; the system owns the process/thread handles supplied by `CREATE_PROCESS` and `CREATE_THREAD` until the corresponding exit event is continued. If guarded recovery must terminate the scratch process, the helper drains its outstanding and queued debug events through `EXIT_PROCESS` within five seconds, then verifies the process handle before detaching. It withholds output when process exit cannot be confirmed. To compare a viewer frame with a native capture, use the same phase JSON for the camera and the PNG conversion. When `atmosphere_seconds` is present, the viewer uses it unless `--atmosphere-seconds` is given, and holds the atmosphere schedule at that sample for the fixed-camera readback. Choose fresh `viewer.bin` and `viewer.png` paths because the viewer writes the raw file and conversion refuses to overwrite an existing PNG. @@ -110,3 +116,42 @@ or `44`/`50` for BGRA. Pass the viewer log's `readback_format` value; `37` is the verified value for the run above, not a universal surface format. The converter uses the installed `System.Drawing` PNG encoder and adds no package dependency. + +## Сравнение областей кадра + +`compare-frames.ps1` собирает пару «оригинал / FParkan» без масштабирования и +считает средний RGB каждой указанной области. Координаты областей относятся к +viewport, то есть к PNG просмотрщика. Оригинальный PNG сначала обрезается по +`viewport` из JSON камеры. Имена выбираются для конкретной сцены: например, +`sky`, `terrain_near`, `terrain_far`, `facade`, `opening`; невидимые в этом +ракурсе области добавлять не нужно. Панели HUD и движущиеся объекты следует +оставлять вне измеряемых прямоугольников. + +Пример `regions.json` для интерьера 1280 × 1024: + +```json +[ + {"name":"ceiling_near","rect":[250,50,550,170]}, + {"name":"wall","rect":[120,400,200,150]}, + {"name":"opening","rect":[650,550,220,100]} +] +``` + +```powershell +& '.\tools\native-frame-capture\compare-frames.ps1' -SelfTest +& '.\tools\native-frame-capture\compare-frames.ps1' ` + -NativePng 'target\captures\r1\constructor\interior.png' ` + -ViewerPng 'target\captures\r1\constructor\interior-after.png' ` + -CameraJson 'target\captures\r1\constructor\interior.json' ` + -RegionsJson 'target\captures\r1\constructor\interior-regions.json' ` + -OutputPrefix 'target\captures\r1\constructor\interior-comparison' +``` + +Результат — PNG с парой и отмеченными областями, а также JSON со средними +значениями каналов в диапазоне 0–255. Это значения сохранённого изображения, +без перевода в линейное цветовое пространство. Скрипт отклоняет несовпадающие +размеры, области за границей кадра и существующие выходные файлы. Совпадение +средних цветов не доказывает совпадение геометрии, поэтому пару нужно также +просмотреть. Камера и фаза должны происходить из одного нативного захвата. +Для интерьера нужно проверить наличие комнат в PNG: выбранная камера проходит +два предварительных мировых кадра, а при пассивном захвате игрок входит в здание. diff --git a/tools/native-frame-capture/compare-frames.ps1 b/tools/native-frame-capture/compare-frames.ps1 new file mode 100644 index 0000000..2a83bf0 --- /dev/null +++ b/tools/native-frame-capture/compare-frames.ps1 @@ -0,0 +1,106 @@ +[CmdletBinding(DefaultParameterSetName = 'Compare')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$NativePng, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$ViewerPng, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$CameraJson, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$RegionsJson, + [Parameter(Mandatory = $true, ParameterSetName = 'Compare')][string]$OutputPrefix, + [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')][switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +function Get-MeanRgb { + param([System.Drawing.Bitmap]$Bitmap, [System.Drawing.Rectangle]$Region) + if ($Region.X -lt 0 -or $Region.Y -lt 0 -or $Region.Width -le 0 -or $Region.Height -le 0 -or + $Region.Right -gt $Bitmap.Width -or $Region.Bottom -gt $Bitmap.Height) { + throw 'Region must be a nonempty rectangle within the image.' + } + $sum = [long[]]@(0, 0, 0) + for ($y = $Region.Top; $y -lt $Region.Bottom; $y++) { + for ($x = $Region.Left; $x -lt $Region.Right; $x++) { + $pixel = $Bitmap.GetPixel($x, $y) + $sum[0] += $pixel.R + $sum[1] += $pixel.G + $sum[2] += $pixel.B + } + } + $count = [long]$Region.Width * $Region.Height + return ,@($sum | ForEach-Object { [Math]::Round($_ / $count, 4) }) +} + +if ($SelfTest) { + $test = [System.Drawing.Bitmap]::new(2, 1) + try { + $test.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(10, 20, 30)) + $test.SetPixel(1, 0, [System.Drawing.Color]::FromArgb(30, 40, 50)) + $mean = Get-MeanRgb $test ([System.Drawing.Rectangle]::new(0, 0, 2, 1)) + if (($mean -join ',') -ne '20,30,40') { throw 'Mean RGB self-test failed.' } + $rejected = $false + try { Get-MeanRgb $test ([System.Drawing.Rectangle]::new(1, 0, 2, 1)) | Out-Null } + catch { $rejected = $true } + if (-not $rejected) { throw 'Out-of-bounds region was accepted.' } + Write-Output 'self-test: exact region RGB means and bounds rejection OK' + } finally { $test.Dispose() } + return +} + +$pngPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.png') +$jsonPath = [System.IO.Path]::GetFullPath($OutputPrefix + '.json') +if ((Test-Path -LiteralPath $pngPath) -or (Test-Path -LiteralPath $jsonPath)) { + throw 'Comparison output already exists; choose a new prefix.' +} +$camera = Get-Content -LiteralPath $CameraJson -Raw | ConvertFrom-Json +if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $camera.viewport.Count -ne 4) { + throw 'Expected a legacy camera JSON with a four-value viewport.' +} +$viewport = @($camera.viewport) +foreach ($value in $viewport) { + if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { + throw 'Viewport coordinates must be nonnegative integers.' + } +} +$crop = [System.Drawing.Rectangle]::new($viewport[0], $viewport[1], $viewport[2] - $viewport[0], $viewport[3] - $viewport[1]) +$native = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $NativePng).Path) +$viewer = $nativeCrop = $pair = $graphics = $null +try { + $viewer = [System.Drawing.Bitmap]::new((Resolve-Path -LiteralPath $ViewerPng).Path) + if ($crop.Width -le 0 -or $crop.Height -le 0 -or $crop.Right -gt $native.Width -or $crop.Bottom -gt $native.Height -or + $viewer.Width -ne $crop.Width -or $viewer.Height -ne $crop.Height) { + throw 'Native crop and viewer dimensions must match without scaling.' + } + $nativeCrop = $native.Clone($crop, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $pair = [System.Drawing.Bitmap]::new($viewer.Width * 2, $viewer.Height + 24) + $graphics = [System.Drawing.Graphics]::FromImage($pair) + $graphics.Clear([System.Drawing.Color]::Black) + $graphics.DrawImageUnscaled($nativeCrop, 0, 24) + $graphics.DrawImageUnscaled($viewer, $viewer.Width, 24) + $font = [System.Drawing.SystemFonts]::DefaultFont + $graphics.DrawString('Original', $font, [System.Drawing.Brushes]::White, 4, 4) + $graphics.DrawString('FParkan', $font, [System.Drawing.Brushes]::White, $viewer.Width + 4, 4) + $regions = @(Get-Content -LiteralPath $RegionsJson -Raw | ConvertFrom-Json) + if ($regions.Count -eq 0) { throw 'At least one named region is required.' } + $report = foreach ($region in $regions) { + if ([string]::IsNullOrWhiteSpace($region.name) -or $region.rect.Count -ne 4) { throw 'Each region needs name and rect [x,y,width,height].' } + foreach ($value in $region.rect) { + if ($value -lt 0 -or $value -gt [int]::MaxValue -or [Math]::Truncate($value) -ne $value) { throw 'Region coordinates must be nonnegative integers.' } + } + $rect = [System.Drawing.Rectangle]::new($region.rect[0], $region.rect[1], $region.rect[2], $region.rect[3]) + $originalMean = Get-MeanRgb $nativeCrop $rect + $viewerMean = Get-MeanRgb $viewer $rect + foreach ($offset in @(0, $viewer.Width)) { + $graphics.DrawRectangle([System.Drawing.Pens]::Yellow, $rect.X + $offset, $rect.Y + 24, $rect.Width, $rect.Height) + $graphics.DrawString($region.name, $font, [System.Drawing.Brushes]::Yellow, $rect.X + $offset, $rect.Y + 24) + } + [pscustomobject]@{ name = $region.name; rect = $region.rect; original_rgb = $originalMean; fparkan_rgb = $viewerMean } + } + $pair.Save($pngPath, [System.Drawing.Imaging.ImageFormat]::Png) + [pscustomobject]@{ native = $NativePng; viewer = $ViewerPng; camera = $CameraJson; rgb_encoding = 'stored image bytes, 0..255'; regions = @($report) } | + ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 + Write-Output "Wrote $pngPath and $jsonPath" +} finally { + foreach ($resource in @($graphics, $pair, $nativeCrop, $viewer, $native)) { + if ($null -ne $resource) { $resource.Dispose() } + } +} |
