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 // ───────────────────────────────────────────────────────────────