From fb5fba6229abe73d1121c63cd336bd018b0b90ba Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 23 May 2026 19:41:11 +0200 Subject: [PATCH] =?UTF-8?q?test(phys):=20A6.P3=20#98=20=E2=80=94=20live=20?= =?UTF-8?q?ResolveWithTransition=20capture=20apparatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apparatus only — no fix attempt. Per the systematic-debugging skill's "3+ failures = question architecture" rule, the 6 hypotheses we tested speculatively on the harness's airborne-at-tick-1 bug all failed because we kept guessing what state the harness lacks. This commit ships the evidence-driven path: capture the EXACT player ResolveWithTransition call (every input + body-before + body-after + result) into a JSON Lines fixture, then a comparison test loads the fixture and replays it against the test engine. The first per-field divergence pinpoints the missing apparatus state — no more guessing. Adds: - src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot record, JSON Lines writer (thread-safe, flushes per record), process- exit hook for clean shutdown. - PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at method entry, snapshot again before return, refactor the two returns into one path so the capture call site is single. Filtered to IsPlayer mover flag so NPC/remote DR calls don't pollute. - CellarUpTrajectoryReplayTests.cs: • Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3 ticks with capture on, reads file back, verifies round-trip of inputs + body-before/after snapshots. • Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no IsPlayer flag), confirms the file is not created. Off by default. Set ACDREAM_CAPTURE_RESOLVE= to a writable file path; capture starts on the next player ResolveWithTransition call. Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests that pass = 1174 + 8. Verified by stashed-baseline comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 10 + src/AcDream.Core/Physics/PhysicsEngine.cs | 77 ++++-- .../Physics/PhysicsResolveCapture.cs | 249 ++++++++++++++++++ .../Physics/CellarUpTrajectoryReplayTests.cs | 141 ++++++++++ 4 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 src/AcDream.Core/Physics/PhysicsResolveCapture.cs diff --git a/CLAUDE.md b/CLAUDE.md index d29197d..22208cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1314,6 +1314,16 @@ via `PlayerMovementController.ApplyServerRunRate`) or from (~100–500 lines/sec). Pair with retail's cdb breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture protocol. Runtime-toggleable via the DebugPanel "Diagnostics" section. +- `ACDREAM_CAPTURE_RESOLVE=` — A6.P3 #98 live capture of every + player-side `PhysicsEngine.ResolveWithTransition` call (2026-05-23 PM + apparatus). Each call appends one JSON Lines record with full inputs, + PhysicsBody snapshot before AND after, plus the `ResolveResult`. + Filtered to `IsPlayer` mover flag — NPC / remote DR calls don't + pollute. Pairs with the trajectory replay harness comparison test + (`CellarUpTrajectoryReplayTests.Capture_*`) to diff captured vs harness + state per field — the first divergence pinpoints missing apparatus + state. Capture is OFF when the env var is unset (one null-check + cost per call). - *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an env-var gate on an experimental per-tick remote motion path. L.3 M2 (commit 40d88b9) replaced both gates (`OnLivePositionUpdated` + diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 5414f2a..f2f3b86 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -595,6 +595,17 @@ public sealed class PhysicsEngine ObjectInfoState moverFlags = ObjectInfoState.None, uint movingEntityId = 0) { + // A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC / + // remote ResolveWithTransition calls don't pollute the capture. Snapshot + // the body BEFORE the engine mutates it so the replay test can seed its + // PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs. + bool captureEnabled = PhysicsResolveCapture.IsEnabled + && moverFlags.HasFlag(ObjectInfoState.IsPlayer); + PhysicsBodySnapshot? bodyBeforeSnap = + captureEnabled && body is not null + ? PhysicsResolveCapture.Snapshot(body) + : null; + var transition = new Transition(); transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; @@ -825,34 +836,68 @@ public sealed class PhysicsEngine $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); } + ResolveResult resolveResult; if (ok) { bool onGround = ci.ContactPlaneValid || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable); - return new ResolveResult( + resolveResult = new ResolveResult( sp.CheckPos, ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId), onGround, collisionNormalValid, collisionNormal); } + else + { + // Transition failed (e.g., stuck in corner, too many steps). + // Use whatever position the transition reached (partial movement) + // instead of falling back to the no-collision Resolve. + // If CheckPos hasn't moved from CurPos, the player stays put — + // this is correct behavior when completely blocked. + bool partialOnGround = ci.ContactPlaneValid + || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable) + || isOnGround; - // Transition failed (e.g., stuck in corner, too many steps). - // Use whatever position the transition reached (partial movement) - // instead of falling back to the no-collision Resolve. - // If CheckPos hasn't moved from CurPos, the player stays put — - // this is correct behavior when completely blocked. - bool partialOnGround = ci.ContactPlaneValid - || transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable) - || isOnGround; + uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; + resolveResult = new ResolveResult( + sp.CheckPos, + ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), + partialOnGround, + collisionNormalValid, + collisionNormal); + } - uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId; - return new ResolveResult( - sp.CheckPos, - ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId), - partialOnGround, - collisionNormalValid, - collisionNormal); + // A6.P3 #98 capture: emit one JSON Lines record per player call, + // with bodyBefore snapshot (taken at method entry, before any + // engine mutation) + bodyAfter snapshot (taken now, after the + // engine wrote back the contact plane / walkable / sliding state + // to the body). Loaded by CellarUpTrajectoryReplayTests.cs. + if (captureEnabled) + { + PhysicsResolveCapture.LogCall( + new ResolveCallInputs( + CurrentPos: currentPos, + TargetPos: targetPos, + CellId: cellId, + SphereRadius: sphereRadius, + SphereHeight: sphereHeight, + StepUpHeight: stepUpHeight, + StepDownHeight: stepDownHeight, + IsOnGround: isOnGround, + MoverFlags: (uint)moverFlags, + MovingEntityId: movingEntityId), + bodyBeforeSnap, + new ResolveCallResult( + Position: resolveResult.Position, + CellId: resolveResult.CellId, + IsOnGround: resolveResult.IsOnGround, + CollisionNormalValid: resolveResult.CollisionNormalValid, + CollisionNormal: resolveResult.CollisionNormal), + body is not null ? PhysicsResolveCapture.Snapshot(body) : null); + } + + return resolveResult; } } diff --git a/src/AcDream.Core/Physics/PhysicsResolveCapture.cs b/src/AcDream.Core/Physics/PhysicsResolveCapture.cs new file mode 100644 index 0000000..ad514cd --- /dev/null +++ b/src/AcDream.Core/Physics/PhysicsResolveCapture.cs @@ -0,0 +1,249 @@ +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); diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs index ba91315..346980a 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs @@ -299,6 +299,147 @@ public class CellarUpTrajectoryReplayTests $"Took: {sw.ElapsedMilliseconds} ms."); } + /// + /// A6.P3 #98 (2026-05-23 evening apparatus extension) — smoke-tests + /// the probe. Drives 3 ticks with + /// capture enabled, then reads the JSON-Lines file back and verifies: + /// + /// One record per call. + /// Inputs round-trip (currentPos, targetPos, cellId, flags). + /// Body-before and body-after snapshots are present. + /// + /// This proves the production probe is wire-correct before we ask the + /// user to run a live capture in the cellar — if this test passes, the + /// only variable left is what's different about the live run. + /// + [Fact] + public void Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled() + { + string capturePath = Path.Combine( + Path.GetTempPath(), + $"acdream_capture_{Guid.NewGuid():N}.jsonl"); + + try + { + PhysicsResolveCapture.CapturePath = capturePath; + PhysicsResolveCapture.ResetTickCounter(); + + var (engine, _) = BuildEngineWithCellarFixtures(); + var body = BuildInitialBody(); + _ = SimulateTicks(engine, body, CellarId, 3); + + PhysicsResolveCapture.Close(); + + Assert.True(File.Exists(capturePath), "Capture file should exist."); + var lines = File.ReadAllLines(capturePath); + Assert.Equal(3, lines.Length); + + var records = lines + .Select(static l => System.Text.Json.JsonSerializer.Deserialize( + l, CaptureJsonOptions)) + .ToList(); + + // Tick monotonic 0,1,2. + Assert.Equal(0, records[0]!.Tick); + Assert.Equal(1, records[1]!.Tick); + Assert.Equal(2, records[2]!.Tick); + + // Inputs at tick 0 must match the harness's initial position. + var firstInput = records[0]!.Input; + Assert.Equal(InitialSphereWorld.X, firstInput.CurrentPos.X, 4); + Assert.Equal(InitialSphereWorld.Y, firstInput.CurrentPos.Y, 4); + Assert.Equal(InitialSphereWorld.Z, firstInput.CurrentPos.Z, 4); + Assert.Equal(CellarId, firstInput.CellId); + Assert.True(firstInput.IsOnGround, + "First tick is seeded grounded."); + + // Body before + after snapshots present. + Assert.NotNull(records[0]!.BodyBefore); + Assert.NotNull(records[0]!.BodyAfter); + + // Body-before's ContactPlane should match the seeded floor plane. + var cpBefore = records[0]!.BodyBefore!.ContactPlane; + Assert.Equal(0f, cpBefore.Normal.X, 5); + Assert.Equal(0f, cpBefore.Normal.Y, 5); + Assert.Equal(1f, cpBefore.Normal.Z, 5); + Assert.Equal(-CellarFloorZ, cpBefore.D, 3); + } + finally + { + PhysicsResolveCapture.CapturePath = null; + PhysicsResolveCapture.Close(); + if (File.Exists(capturePath)) + File.Delete(capturePath); + } + } + + /// + /// Capture is filtered to IsPlayer mover flag. Calls without + /// that flag (NPC, remote dead-reckoning) must NOT pollute the + /// capture file. + /// + [Fact] + public void Capture_SkipsNonPlayerCalls() + { + string capturePath = Path.Combine( + Path.GetTempPath(), + $"acdream_capture_npc_{Guid.NewGuid():N}.jsonl"); + + try + { + PhysicsResolveCapture.CapturePath = capturePath; + PhysicsResolveCapture.ResetTickCounter(); + + var (engine, _) = BuildEngineWithCellarFixtures(); + var body = BuildInitialBody(); + + // Drive 3 ticks WITHOUT IsPlayer flag — simulates an NPC path. + uint cellId = CellarId; + bool isOnGround = true; + for (int i = 0; i < 3; i++) + { + Vector3 target = body.Position + PerTickOffset; + var result = engine.ResolveWithTransition( + body.Position, target, cellId, + SphereRadius, SphereHeight, + StepUpHeight, StepDownHeight, + isOnGround, + body: body, + moverFlags: ObjectInfoState.EdgeSlide, // ← no IsPlayer + movingEntityId: 0); + body.Position = result.Position; + cellId = result.CellId; + isOnGround = result.IsOnGround; + } + + PhysicsResolveCapture.Close(); + + // No records written because no IsPlayer call ran. + Assert.False(File.Exists(capturePath), + "Capture file should NOT exist when only non-player calls ran."); + } + finally + { + PhysicsResolveCapture.CapturePath = null; + PhysicsResolveCapture.Close(); + if (File.Exists(capturePath)) + File.Delete(capturePath); + } + } + + /// + /// Shared deserialization options matching + /// 's serializer. IncludeFields + /// is required because Vector3/Quaternion/Plane store components as + /// fields, not properties. + /// + public static readonly System.Text.Json.JsonSerializerOptions CaptureJsonOptions = + new() + { + IncludeFields = true, + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + }; + // ─────────────────────────────────────────────────────────────── // Harness internals // ───────────────────────────────────────────────────────────────