using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Numerics; using System.Text.Json; using System.Threading; namespace AcDream.Core.Physics; // ──────────────────────────────────────────────────────────────────────────── // A6.P3 issue #98 (2026-05-23) — live capture of every player-side // PhysicsEngine.ResolveWithTransition call so the trajectory replay harness // (tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs) can // load the captured fixture and replay the EXACT inputs deterministically. // The captured outputs become the diff target: any per-field divergence // between live and replay pinpoints harness apparatus that is missing, // short-circuiting six rounds of guessing what state the harness lacks. // // Enabled by setting ACDREAM_CAPTURE_RESOLVE=. Each call appends one // JSON Lines record (self-contained — no cross-line dependencies). Filtered // to IsPlayer mover flag so NPC / remote ResolveWithTransition calls don't // pollute the capture. // ──────────────────────────────────────────────────────────────────────────── /// /// A6.P3 issue #98 live capture of player ResolveWithTransition calls. /// Pairs with 's replay harness: /// the capture is the test's input fixture AND the diff-target output. /// Off unless ACDREAM_CAPTURE_RESOLVE=<path> is set. /// public static class PhysicsResolveCapture { /// /// Output path for captured records. Each call appends one JSON Lines /// record. When null/empty, capture is off (one null-check cost per /// call, no allocations). Initialized from /// ACDREAM_CAPTURE_RESOLVE at process start; runtime mutation /// allowed for tests. /// public static string? CapturePath { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_CAPTURE_RESOLVE"); /// True when is non-empty. public static bool IsEnabled => !string.IsNullOrWhiteSpace(CapturePath); private static int _tickCounter; private static StreamWriter? _writer; private static readonly object _writerLock = new(); // System.Numerics.Vector3 / Quaternion / Plane expose their components // as public FIELDS (X/Y/Z[/W], Normal/D), not properties. Without // IncludeFields=true the resulting JSON serializes them as empty // objects ({}) — verified by smoke-test failure 2026-05-23 evening. private static readonly JsonSerializerOptions s_jsonOptions = new() { WriteIndented = false, IncludeFields = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; /// /// Captures every PhysicsBody field that /// reads or writes. /// Copies value-type fields directly (Vector3 / Plane / Quaternion /// are structs — no aliasing) and clones WalkableVertices /// since the engine may replace the array. /// public static PhysicsBodySnapshot Snapshot(PhysicsBody body) => new( Position: body.Position, Orientation: body.Orientation, Velocity: body.Velocity, Acceleration: body.Acceleration, Omega: body.Omega, GroundNormal: body.GroundNormal, SlidingNormal: body.SlidingNormal, ContactPlaneValid: body.ContactPlaneValid, ContactPlane: body.ContactPlane, ContactPlaneCellId: body.ContactPlaneCellId, ContactPlaneIsWater: body.ContactPlaneIsWater, WalkablePolygonValid: body.WalkablePolygonValid, WalkablePlane: body.WalkablePlane, WalkableVertices: body.WalkableVertices is null ? null : (Vector3[])body.WalkableVertices.Clone(), WalkableUp: body.WalkableUp, Elasticity: body.Elasticity, Friction: body.Friction, State: (uint)body.State, TransientState: (uint)body.TransientState, LastUpdateTime: body.LastUpdateTime); /// /// Write one JSON-Lines record for a player ResolveWithTransition call. /// Caller MUST guard with if (!IsEnabled) return; before snapshotting /// the body — this method assumes the caller paid the snapshot cost. /// public static void LogCall( ResolveCallInputs input, PhysicsBodySnapshot? bodyBefore, ResolveCallResult result, PhysicsBodySnapshot? bodyAfter) { if (string.IsNullOrWhiteSpace(CapturePath)) return; var record = new ResolveCaptureRecord( Tick: Interlocked.Increment(ref _tickCounter) - 1, TimestampMs: (long)(System.Diagnostics.Stopwatch.GetTimestamp() * 1000.0 / System.Diagnostics.Stopwatch.Frequency), Input: input, BodyBefore: bodyBefore, Result: result, BodyAfter: bodyAfter); string json = JsonSerializer.Serialize(record, s_jsonOptions); lock (_writerLock) { EnsureWriter_NoLock(); _writer!.WriteLine(json); _writer.Flush(); } } /// /// Close the capture file. Called by the writer's process-exit hook /// in ; tests may also call it /// manually to release the file before re-opening. /// public static void Close() { lock (_writerLock) { if (_writer is not null) { _writer.Flush(); _writer.Dispose(); _writer = null; } } } /// /// Reset the per-process tick counter. Tests call this between /// fixtures so the JSON tick field starts at zero. /// public static void ResetTickCounter() => Interlocked.Exchange(ref _tickCounter, 0); private static void EnsureWriter_NoLock() { if (_writer is not null) return; var path = CapturePath!; var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); // Append mode — multiple sessions can accumulate in the same file // (the user can split by Tick=0 boundaries later). var stream = new FileStream( path, FileMode.Append, FileAccess.Write, FileShare.Read); _writer = new StreamWriter(stream) { AutoFlush = false, }; // Best-effort: flush + close on process exit so a hard-kill doesn't // truncate the last record. AppDomain.CurrentDomain.ProcessExit += static (_, _) => Close(); } } // ──────────────────────────────────────────────────────────────────────────── // Serializable records. System.Text.Json serializes Vector3/Quaternion/Plane // natively as {X,Y,Z[,W]} objects — no custom converters needed. Numeric // types and bools are camelCased per the global JsonSerializerOptions above. // ──────────────────────────────────────────────────────────────────────────── /// /// One captured player-side ResolveWithTransition call. Self-contained: /// every field needed to replay the call is here. /// public sealed record ResolveCaptureRecord( int Tick, long TimestampMs, ResolveCallInputs Input, PhysicsBodySnapshot? BodyBefore, ResolveCallResult Result, PhysicsBodySnapshot? BodyAfter); /// /// Every parameter to /// , in capture form. /// public sealed record ResolveCallInputs( Vector3 CurrentPos, Vector3 TargetPos, uint CellId, float SphereRadius, float SphereHeight, float StepUpHeight, float StepDownHeight, bool IsOnGround, uint MoverFlags, uint MovingEntityId); /// /// Every field of , in capture form. /// public sealed record ResolveCallResult( Vector3 Position, uint CellId, bool IsOnGround, bool CollisionNormalValid, Vector3 CollisionNormal); /// /// Snapshot of every field that /// reads or writes. /// Captured both before and after the call so the comparison harness /// can verify that engine mutation matches between live and replay. /// public sealed record PhysicsBodySnapshot( Vector3 Position, Quaternion Orientation, Vector3 Velocity, Vector3 Acceleration, Vector3 Omega, Vector3 GroundNormal, Vector3 SlidingNormal, bool ContactPlaneValid, Plane ContactPlane, uint ContactPlaneCellId, bool ContactPlaneIsWater, bool WalkablePolygonValid, Plane WalkablePlane, Vector3[]? WalkableVertices, Vector3 WalkableUp, float Elasticity, float Friction, uint State, uint TransientState, double LastUpdateTime);