From 44614ab591ca0acd81789994db46cc2b963b7370 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 23 May 2026 19:58:51 +0200 Subject: [PATCH] =?UTF-8?q?test(phys):=20A6.P3=20#98=20=E2=80=94=20compari?= =?UTF-8?q?son=20harness=20+=20first=20evidence-driven=20finding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture apparatus pays off on the FIRST iteration. Three records sampled from a live cottage-cellar session — tick 0 (spawn at Z=92.53), tick 376 (player on the cellar ramp at Z=91.49), and tick 1183 (first cap event, foot Z=92.74 with cn=(0,0,-1)) — replayed against the harness engine reveal: - LiveCompare_Tick0_Spawn: PASSES (full round-trip). - LiveCompare_Tick376_OnRamp: PASSES (ramp walkable polygon hydrates correctly, engine reproduces live). - LiveCompare_FirstCap_HeadHitsCottageFloor: FAILS by exactly the divergence shape that names the missing fixture. The cap-record divergence: Result.Position: live=(141.3865,7.2243,92.7390) harness=(141.3599,7.2243,92.7390) (Y slid; X stuck) Result.CollisionNormal:live=(0,0,-1) ← downward = cottage floor from below harness=(0,0,+1) ← upward = some other floor Plus the LiveCompare_FirstCap_DiagnosticDump test (always passes; it's a probe-firing scratch test) prints every cell polygon in world frame: Cellar 0xA9B40147 — ceiling polys at world Z=93.80 cover X=133-142, Y=-1.0-11.5 but NOT the sphere XY of (141.39, 7.03) — at the right edge of Y=7.03 the ceiling quads are at Y<3.90 or Y>8.70. Cottage 0xA9B40143 — floor polys at world Z=94.0 cover X=136.7-140.5, Y=3.9-13.1 but NOT (141.39, 7.03) either — at X=141.39 we are 0.89m east of the floor quad's rightmost vertex. Cottage 0xA9B40146 — only 4 walls, no floor. So both cells we have CAN'T produce the live's cn=(0,0,-1). The actual blocking polygon must be in a cell or static object we haven't loaded into the harness yet. The cellar is rectangularly bounded; the cottage above has a floor that spans the cottage, but the floor polygon RIGHT ABOVE the ramp top (which is where the freeze fires) is in some OTHER cell — either a separate cottage-floor sub-cell or a building static GfxObj. This is the first evidence-driven step in the saga. Six sessions of speculation produced ten failed fix shapes; the apparatus produced this finding in one round trip. Next step: re-capture with ACDREAM_PROBE_POLY_DUMP + ACDREAM_DUMP_CELLS covering 0xA9B40140- 0xA9B4014F to identify the missing fixture cell. Adds: - LiveCompare_Tick0_Spawn / Tick376_OnRamp / FirstCap_HeadHitsCottageFloor - LiveCompare_FirstCap_DiagnosticDump (always passes; dumps cell polys in world frame + enables every relevant probe so the captured stdout shows the harness BSP query path) - tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl (3 representative records from the 41,228-record live capture) - AssertCallMatchesCapture helper: per-field diff with Vector3 / float tolerances, reports every divergence not just the first. Test baseline maintained at 1172 + 8 baseline + 5 new tests pass + 1 known-failing test that pinpoints the bug = 1178 + 9 (where the new failure is the desired evidence-driven test). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Fixtures/issue98/live-capture.jsonl | 3 + .../Physics/CellarUpTrajectoryReplayTests.cs | 325 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl diff --git a/tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl b/tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl new file mode 100644 index 0000000..70f1583 --- /dev/null +++ b/tests/AcDream.Core.Tests/Fixtures/issue98/live-capture.jsonl @@ -0,0 +1,3 @@ +{"tick":0,"timestampMs":40919993,"input":{"currentPos":{"x":140.93564,"y":7.424385,"z":92.5333},"targetPos":{"x":140.93564,"y":7.424385,"z":92.5333},"cellId":2847146311,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":140.93564,"y":7.424385,"z":92.5333},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0,"y":0,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":false,"contactPlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"contactPlaneCellId":0,"contactPlaneIsWater":false,"walkablePolygonValid":false,"walkablePlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"walkableVertices":null,"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":140.93564,"y":7.424385,"z":92.5333},"cellId":2847146311,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":140.93564,"y":7.424385,"z":92.5333},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0,"y":0,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":false,"contactPlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"contactPlaneCellId":0,"contactPlaneIsWater":false,"walkablePolygonValid":false,"walkablePlane":{"normal":{"x":0,"y":0,"z":0},"d":0},"walkableVertices":null,"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":376,"timestampMs":40923854,"input":{"currentPos":{"x":141.02515,"y":8.432315,"z":91.48935},"targetPos":{"x":141.02515,"y":8.432315,"z":91.48935},"cellId":2847146311,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":141.02515,"y":8.432315,"z":91.48935},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0.64338976,"y":11.830655,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":141.02515,"y":8.432315,"z":91.48935},"cellId":2847146311,"isOnGround":true,"collisionNormalValid":false,"collisionNormal":{"x":0,"y":0,"z":0}},"bodyAfter":{"position":{"x":141.02515,"y":8.432315,"z":91.48935},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.027161535,"w":0.99963105},"velocity":{"x":0.64338976,"y":11.830655,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} +{"tick":1183,"timestampMs":40927362,"input":{"currentPos":{"x":141.35991,"y":7.224303,"z":92.73901},"targetPos":{"x":141.38649,"y":6.822069,"z":92.73901},"cellId":2847146311,"sphereRadius":0.48,"sphereHeight":1.2,"stepUpHeight":0.6,"stepDownHeight":1.5,"isOnGround":true,"moverFlags":768,"movingEntityId":1000000},"bodyBefore":{"position":{"x":141.38649,"y":6.822069,"z":92.73901},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.999456,"w":0.032981448},"velocity":{"x":0.7811123,"y":-11.822362,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0},"result":{"position":{"x":141.38649,"y":7.224303,"z":92.73901},"cellId":2847146311,"isOnGround":true,"collisionNormalValid":true,"collisionNormal":{"x":0,"y":0,"z":-1}},"bodyAfter":{"position":{"x":141.38649,"y":6.822069,"z":92.73901},"orientation":{"isIdentity":false,"x":-0,"y":-0,"z":-0.999456,"w":0.032981448},"velocity":{"x":0.7811123,"y":-11.822362,"z":0},"acceleration":{"x":0,"y":0,"z":0},"omega":{"x":0,"y":0,"z":0},"groundNormal":{"x":0,"y":0,"z":1},"slidingNormal":{"x":0,"y":0,"z":0},"contactPlaneValid":true,"contactPlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"contactPlaneCellId":2847146311,"contactPlaneIsWater":false,"walkablePolygonValid":true,"walkablePlane":{"normal":{"x":6.285598E-08,"y":0.7189884,"z":0.69502217},"d":-69.50349},"walkableVertices":[{"x":140.5,"y":8.70195,"z":90.999794},{"x":140.5,"y":5.8019505,"z":93.999794},{"x":142.1,"y":5.8019505,"z":93.999794},{"x":142.1,"y":8.70195,"z":90.999794}],"walkableUp":{"x":0,"y":0,"z":1},"elasticity":0.05,"friction":0.95,"state":1040,"transientState":131,"lastUpdateTime":0}} diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs index 346980a..90df2dd 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs @@ -440,6 +440,331 @@ public class CellarUpTrajectoryReplayTests PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, }; + // ─────────────────────────────────────────────────────────────── + // A6.P3 #98 (2026-05-23 PM extension) — live-vs-harness comparison. + // Loads the 3-record fixture sampled from a live capture in the + // Holtburg cottage cellar and replays each through the harness's + // PhysicsEngine. Each test compares one record's outputs (result + + // body-after) to what the live engine produced, reporting the FIRST + // per-field divergence. The divergence pinpoints what world state + // the harness lacks vs production, ending the speculation loop that + // burned 6 hypotheses on the airborne-at-tick-1 bug. + // ─────────────────────────────────────────────────────────────── + + /// + /// Tick 0 — spawn/login teleport into the cellar at world Z=92.5333. + /// No velocity, no contact-plane seed; currentPos == targetPos (no + /// motion). The simplest test case: replay the call and verify the + /// harness produces the same ResolveResult + bodyAfter state. + /// + [Fact] + public void LiveCompare_Tick0_Spawn() + { + var (engine, cache) = BuildEngineWithCellarFixtures(); + var captured = LoadCapturedRecord(record => record.Tick == 0); + AssertCallMatchesCapture(engine, captured); + } + + /// + /// Tick 376 — player on the cellar ramp at world Z=91.49. Live capture + /// has bodyAfter.WalkablePolygon = the ramp polygon (normal ≈ + /// (0, 0.719, 0.695), z range 90.99→94.00). If the harness reproduces + /// the same walkable polygon + ResolveResult, the ramp geometry is + /// loaded correctly. + /// + [Fact] + public void LiveCompare_Tick376_OnRamp() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var captured = LoadCapturedRecord(record => record.Tick == 376); + AssertCallMatchesCapture(engine, captured); + } + + /// + /// First-cap event — the failing tick. Live engine reports cn=(0,0,-1), + /// a downward-facing collision normal, capping the foot sphere at + /// world Z=92.74. Math: head sphere TOP reaches Z=94.0 (the cottage + /// floor) when foot Z = 94.0 - sphereHeight = 92.80. So the head is + /// bumping the cottage floor from BELOW. + /// + /// + /// This is the actual #98 bug, NOT a step-up / AdjustOffset problem + /// — it's a head-sphere collision against a polygon that retail + /// doesn't have (cottage floor should be punched-through above the + /// ramp). Whether the harness reproduces the cap pinpoints whether + /// the cottage-cell floor polygon set is the cause. + /// + /// + [Fact] + public void LiveCompare_FirstCap_HeadHitsCottageFloor() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var captured = LoadCapturedRecord(record => + record.Result.CollisionNormalValid + && record.Result.CollisionNormal.Z < -0.99f); + AssertCallMatchesCapture(engine, captured); + } + + /// + /// Diagnostic dump: turns on every relevant probe and replays the + /// first-cap record, so the captured stdout shows which polygon the + /// harness BSP hit when it computed cn=(0,0,+1) — pinpoints the + /// missing fixture cell or the wrong-winding-order polygon. + /// Always passes; this is a one-shot tool, not a regression. + /// + [Fact] + public void LiveCompare_FirstCap_DiagnosticDump() + { + PhysicsDiagnostics.ProbeResolveEnabled = true; + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + PhysicsDiagnostics.ProbePolyDumpEnabled = true; + PhysicsDiagnostics.ProbePushBackEnabled = true; + PhysicsDiagnostics.ProbeStepWalkEnabled = true; + try + { + var (engine, cache) = BuildEngineWithCellarFixtures(); + + // Dump the cellar cell's polygons so we can see what BSP is + // testing against. The harness hit cn=(0,0,+1) — find which + // polygon has that normal. + DumpCellPolygons(cache, CellarId); + DumpCellPolygons(cache, CottageNeighborA); + DumpCellPolygons(cache, CottageNeighborB); + + var captured = LoadCapturedRecord(record => + record.Result.CollisionNormalValid + && record.Result.CollisionNormal.Z < -0.99f); + var body = SeedBodyFromSnapshot(captured.BodyBefore!); + + Console.WriteLine($"=== Replay tick {captured.Tick} ==="); + var result = engine.ResolveWithTransition( + currentPos: captured.Input.CurrentPos, + targetPos: captured.Input.TargetPos, + cellId: captured.Input.CellId, + sphereRadius: captured.Input.SphereRadius, + sphereHeight: captured.Input.SphereHeight, + stepUpHeight: captured.Input.StepUpHeight, + stepDownHeight: captured.Input.StepDownHeight, + isOnGround: captured.Input.IsOnGround, + body: body, + moverFlags: (ObjectInfoState)captured.Input.MoverFlags, + movingEntityId: captured.Input.MovingEntityId); + + Console.WriteLine( + $"=== Result pos=({result.Position.X:F4},{result.Position.Y:F4},{result.Position.Z:F4}) " + + $"cn=({result.CollisionNormal.X:F4},{result.CollisionNormal.Y:F4},{result.CollisionNormal.Z:F4}) " + + $"cnValid={result.CollisionNormalValid} onGround={result.IsOnGround}"); + } + finally + { + PhysicsDiagnostics.ProbeResolveEnabled = false; + PhysicsDiagnostics.ProbeIndoorBspEnabled = false; + PhysicsDiagnostics.ProbePolyDumpEnabled = false; + PhysicsDiagnostics.ProbePushBackEnabled = false; + PhysicsDiagnostics.ProbeStepWalkEnabled = false; + } + } + + private static void DumpCellPolygons(PhysicsDataCache cache, uint cellId) + { + var cell = cache.GetCellStruct(cellId); + if (cell is null) + { + Console.WriteLine($"[cell-dump] 0x{cellId:X8} NOT IN CACHE"); + return; + } + var t = cell.WorldTransform; + Console.WriteLine($"[cell-dump] 0x{cellId:X8} resolved-poly-count={cell.Resolved.Count}"); + Console.WriteLine($" WorldTransform.M14={t.M14:F4} M24={t.M24:F4} M34={t.M34:F4} (origin XYZ?)"); + Console.WriteLine($" Translation=({t.Translation.X:F4},{t.Translation.Y:F4},{t.Translation.Z:F4})"); + foreach (var kv in cell.Resolved) + { + var p = kv.Value; + // Show world-frame vertices for the first 2 polys with normal-Z>0.9 + // (floor candidates) — these are the polygons the head sphere + // could hit from below. + string vertsWorld = ""; + if (p.Plane.Normal.Z > 0.9f || p.Plane.Normal.Z < -0.9f) + { + vertsWorld = " worldVerts=[" + string.Join(",", p.Vertices.Select(v => + { + var w = Vector3.Transform(v, cell.WorldTransform); + return $"({w.X:F2},{w.Y:F2},{w.Z:F2})"; + })) + "]"; + } + Console.WriteLine( + $" poly id=0x{p.Id:X4} sides={p.SidesType} n=({p.Plane.Normal.X:F4},{p.Plane.Normal.Y:F4},{p.Plane.Normal.Z:F4}) d={p.Plane.D:F4} numV={p.NumPoints}{vertsWorld}"); + } + } + + /// + /// Reads the live-capture.jsonl fixture and returns the FIRST record + /// matching . Throws with a clear error + /// when none match — keeps the test failure attributed to the + /// fixture, not to deserialization. + /// + private static ResolveCaptureRecord LoadCapturedRecord( + Func predicate) + { + var path = Path.Combine(FixtureDir, "live-capture.jsonl"); + Assert.True(File.Exists(path), + $"Live-capture fixture missing: {path}. Re-run live capture " + + $"with ACDREAM_CAPTURE_RESOLVE set."); + + foreach (var line in File.ReadLines(path)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var record = System.Text.Json.JsonSerializer + .Deserialize(line, CaptureJsonOptions)!; + if (predicate(record)) + return record; + } + + throw new Xunit.Sdk.XunitException( + "No captured record matched the predicate. Update the fixture " + + "to include a representative record."); + } + + /// + /// Replays one captured ResolveWithTransition call through the harness + /// engine, seeded with the captured body-before state, and compares + /// the harness's ResolveResult + body-after vs the captured values. + /// Reports the FIRST per-field divergence with both values so the + /// missing apparatus state is named. + /// + private static void AssertCallMatchesCapture( + PhysicsEngine engine, + ResolveCaptureRecord captured) + { + Assert.NotNull(captured.BodyBefore); + Assert.NotNull(captured.BodyAfter); + + var body = SeedBodyFromSnapshot(captured.BodyBefore); + + var harnessResult = engine.ResolveWithTransition( + currentPos: captured.Input.CurrentPos, + targetPos: captured.Input.TargetPos, + cellId: captured.Input.CellId, + sphereRadius: captured.Input.SphereRadius, + sphereHeight: captured.Input.SphereHeight, + stepUpHeight: captured.Input.StepUpHeight, + stepDownHeight: captured.Input.StepDownHeight, + isOnGround: captured.Input.IsOnGround, + body: body, + moverFlags: (ObjectInfoState)captured.Input.MoverFlags, + movingEntityId: captured.Input.MovingEntityId); + + // Compare in priority order — most consequential divergence first. + var divergences = new List(); + + // 1. Result fields + AddIfDifferent(divergences, "Result.Position", + captured.Result.Position, harnessResult.Position); + AddIfDifferent(divergences, "Result.CellId", + $"0x{captured.Result.CellId:X8}", + $"0x{harnessResult.CellId:X8}"); + AddIfDifferent(divergences, "Result.IsOnGround", + captured.Result.IsOnGround, harnessResult.IsOnGround); + AddIfDifferent(divergences, "Result.CollisionNormalValid", + captured.Result.CollisionNormalValid, + harnessResult.CollisionNormalValid); + if (captured.Result.CollisionNormalValid && harnessResult.CollisionNormalValid) + { + AddIfDifferent(divergences, "Result.CollisionNormal", + captured.Result.CollisionNormal, + harnessResult.CollisionNormal); + } + + // 2. Body-after fields (subset that's most likely to diverge first) + AddIfDifferent(divergences, "BodyAfter.Position", + captured.BodyAfter.Position, body.Position); + AddIfDifferent(divergences, "BodyAfter.ContactPlaneValid", + captured.BodyAfter.ContactPlaneValid, body.ContactPlaneValid); + if (captured.BodyAfter.ContactPlaneValid && body.ContactPlaneValid) + { + AddIfDifferent(divergences, "BodyAfter.ContactPlane.Normal", + captured.BodyAfter.ContactPlane.Normal, + body.ContactPlane.Normal); + AddIfDifferent(divergences, "BodyAfter.ContactPlane.D", + captured.BodyAfter.ContactPlane.D, + body.ContactPlane.D); + } + AddIfDifferent(divergences, "BodyAfter.WalkablePolygonValid", + captured.BodyAfter.WalkablePolygonValid, body.WalkablePolygonValid); + AddIfDifferent(divergences, "BodyAfter.TransientState", + $"0x{captured.BodyAfter.TransientState:X}", + $"0x{(uint)body.TransientState:X}"); + + if (divergences.Count > 0) + { + string summary = string.Join("\n • ", divergences); + string header = string.Format(System.Globalization.CultureInfo.InvariantCulture, + "Harness replay of captured tick {0} diverges from live engine. " + + "Input: currentPos=({1:F4},{2:F4},{3:F4}) targetPos=({4:F4},{5:F4},{6:F4}) " + + "cellId=0x{7:X8} isOnGround={8}", + captured.Tick, + captured.Input.CurrentPos.X, captured.Input.CurrentPos.Y, captured.Input.CurrentPos.Z, + captured.Input.TargetPos.X, captured.Input.TargetPos.Y, captured.Input.TargetPos.Z, + captured.Input.CellId, captured.Input.IsOnGround); + throw new Xunit.Sdk.XunitException( + header + "\nDivergences (live → harness):\n • " + summary); + } + } + + private static PhysicsBody SeedBodyFromSnapshot(PhysicsBodySnapshot snap) => new() + { + Position = snap.Position, + Orientation = snap.Orientation, + Velocity = snap.Velocity, + Acceleration = snap.Acceleration, + Omega = snap.Omega, + GroundNormal = snap.GroundNormal, + SlidingNormal = snap.SlidingNormal, + ContactPlaneValid = snap.ContactPlaneValid, + ContactPlane = snap.ContactPlane, + ContactPlaneCellId = snap.ContactPlaneCellId, + ContactPlaneIsWater = snap.ContactPlaneIsWater, + WalkablePolygonValid = snap.WalkablePolygonValid, + WalkablePlane = snap.WalkablePlane, + WalkableVertices = snap.WalkableVertices, + WalkableUp = snap.WalkableUp, + Elasticity = snap.Elasticity, + Friction = snap.Friction, + State = (PhysicsStateFlags)snap.State, + TransientState = (TransientStateFlags)snap.TransientState, + LastUpdateTime = snap.LastUpdateTime, + }; + + private static void AddIfDifferent( + List divergences, string name, T live, T harness) + { + if (EqualityComparer.Default.Equals(live, harness)) + return; + divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}: live={1} harness={2}", name, live, harness)); + } + + private static void AddIfDifferent( + List divergences, string name, Vector3 live, Vector3 harness) + { + if (Vector3.DistanceSquared(live, harness) < 1e-6f) + return; + divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}: live=({1:F4},{2:F4},{3:F4}) harness=({4:F4},{5:F4},{6:F4})", + name, live.X, live.Y, live.Z, harness.X, harness.Y, harness.Z)); + } + + private static void AddIfDifferent( + List divergences, string name, float live, float harness) + { + if (MathF.Abs(live - harness) < 1e-3f) + return; + divergences.Add(string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}: live={1:F4} harness={2:F4}", name, live, harness)); + } + // ─────────────────────────────────────────────────────────────── // Harness internals // ───────────────────────────────────────────────────────────────