diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs new file mode 100644 index 0000000..f681707 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// A6.P3 issue #98 (2026-05-23) — deterministic TRAJECTORY replay +/// harness for the cottage cellar-ascent failure. Drives +/// through N physics +/// ticks against pre-loaded cell fixtures, capturing a per-tick +/// trajectory record. +/// +/// +/// Unlike (which tests a SINGLE +/// failing-frame's geometry against our walkable predicates), this +/// harness drives MANY ticks through the full engine to reproduce the +/// trajectory itself — once the fixtures support it (see below). +/// +/// +///

Status as of 2026-05-23 evening: harness mechanics WORK, fixtures +/// INCOMPLETE.

+/// +/// +/// The harness compiles and runs the engine through N ticks in +/// < 100 ms total. Two findings during commissioning: +/// +/// +/// +/// The three issue-#98 cell fixtures +/// (tests/AcDream.Core.Tests/Fixtures/issue98/0xA9B40*.json) +/// contain ONLY axis-aligned polygons — cellar floor, cellar +/// ceiling, four cellar walls, cottage floor, cottage walls. The +/// live capture's CELLAR RAMP polygon +/// (normal ≈ (0, ±0.719, 0.695)) is NOT in any of the +/// fixtures. Without it the harness can't reproduce the climb +/// trajectory — the sphere walks across the cellar floor +/// horizontally and never encounters a slope. +/// Independently: at the sphere's initial position resting on +/// the cellar floor, the engine reports +/// hit=yes n=(0,0,1) walkable=False on tick 1 and rejects +/// the forward move. The grounded state flips off and subsequent +/// ticks proceed as airborne (no Z change). This may be a real +/// engine bug (touching the floor classified as non-walkable +/// collision) or a fixture issue (cellar floor poly's +/// containment test mis-firing). Either way, the harness +/// exposes it deterministically — that's the point. +/// +/// +/// +/// Before this harness can drive issue-#98 trajectory fix attempts, +/// the fixtures need a re-capture that includes: +/// +/// +/// +/// The cellar ramp polygon (whichever cell it actually lives +/// in — the live capture said cellar cell 0xA9B40147, +/// but our dump doesn't have it; investigate +/// to see whether some +/// polygons are being skipped during capture). +/// Any neighboring cells the sphere may transit into during +/// the climb (the live capture's +/// [cell-set-summary] showed overlap with +/// 0xA9B40143 and 0xA9B40146, both already in +/// the fixture set — but additional cells beyond these may +/// appear at tick boundaries we haven't observed). +/// +/// +/// +/// The current tests document the harness mechanics + the two +/// findings above. When fixtures are re-captured, flip +/// 's assertion +/// to require a successful climb and add additional tests for the +/// trajectory shape. +/// +///
+public class CellarUpTrajectoryReplayTests +{ + // ── Cellar / cottage geometry constants ──────────────────────── + private const uint CellarId = 0xA9B40147u; + private const uint CottageNeighborA = 0xA9B40143u; + private const uint CottageNeighborB = 0xA9B40146u; + + private const float CellarFloorZ = 90.95f; + private const float CottageFloorZ = 94.00f; + + private const float SphereRadius = 0.48f; + private const float SphereHeight = 1.20f; + private const float StepUpHeight = 0.60f; + private const float StepDownHeight = 0.04f; + + /// + /// Sphere center starts above cellar floor by exactly the radius + /// (bottom resting on floor). Y=9.5 is ~0.75 m before the ramp foot + /// at Y=8.75 (live-capture ramp plane equation: + /// 0.719·y + 0.695·z = 69.5035 → y=8.75 at z=90.95). + /// X=141.5 matches the live capture's X. + /// + private static readonly Vector3 InitialSphereWorld = + new(141.5f, 9.5f, CellarFloorZ + SphereRadius); + + /// + /// Per-tick forward offset (−Y direction toward the ramp). + /// Magnitude (~0.10 m) matches the live capture's observed per-tick + /// requested offset. + /// + private static readonly Vector3 PerTickOffset = + new(0f, -0.10f, 0f); + + private const int SimulationTicks = 200; + + // ─────────────────────────────────────────────────────────────── + // Tests + // ─────────────────────────────────────────────────────────────── + + /// + /// Confirms the harness compiles, the engine runs the simulation, + /// and a trajectory comes back with the expected number of points. + /// Does NOT assert on trajectory CONTENT — fixture limitations + /// (see class summary) make content-level assertions premature. + /// + [Fact] + public void Harness_CompilesAndRunsSimulation() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var body = BuildInitialBody(); + var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks); + + Assert.Equal(SimulationTicks + 1, trajectory.Count); + Assert.Equal(0, trajectory[0].Tick); + Assert.Equal(SimulationTicks, trajectory[^1].Tick); + } + + /// + /// Documents finding #1: cellar fixture is missing the ramp + /// polygon. With only axis-aligned cellar/cottage geometry, the + /// sphere walks horizontally and the trajectory's max-Z equals + /// the starting Z. When fixtures are re-captured with the ramp, + /// flip this assertion (and rename the test). + /// + [Fact] + public void Harness_FixtureLimitation_NoRampPolygon() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var body = BuildInitialBody(); + var trajectory = SimulateTicks(engine, body, CellarId, SimulationTicks); + + var maxZ = trajectory.Max(t => t.Position.Z); + var startZ = InitialSphereWorld.Z; + + // CURRENT behavior: maxZ == startZ because there's no ramp + // polygon in the fixtures. When the fixtures are re-captured + // and include the ramp, this assertion must be flipped to + // require maxZ >= 93.5f (sphere reaches cottage floor). + Assert.True( + MathF.Abs(maxZ - startZ) < 0.01f, + $"Harness limitation documented: cellar fixture has no ramp " + + $"polygon, so the sphere should not gain altitude. If this " + + $"fails, the fixture was re-captured — flip this test to " + + $"require a successful climb. " + + $"maxZ={maxZ:F4}, startZ={startZ:F4}, Δ={maxZ - startZ:F4}."); + } + + /// + /// Documents finding #2: at the initial grounded position, the + /// engine reports the cellar floor as a non-walkable collision + /// and the body goes airborne at tick 1. Whether this is an + /// engine bug or a fixture issue is unclear; the harness exposes + /// it deterministically. + /// + [Fact] + public void Harness_Finding_SphereGoesAirborneAtTick1() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var body = BuildInitialBody(); + var trajectory = SimulateTicks(engine, body, CellarId, 3); + + Assert.True(trajectory[0].IsOnGround, + "Tick 0 is the seeded starting state and must report grounded."); + Assert.False(trajectory[1].IsOnGround, + "Finding #2: at tick 1 the engine reports the sphere is NOT " + + "grounded, even though it started seeded on the cellar floor " + + "with a flat-floor ContactPlane. Investigate whether the " + + "cellar floor polygon's containment test is mis-firing or " + + "whether the engine genuinely treats floor contact as a " + + "non-walkable collision. If/when this is fixed, the assertion " + + "should be flipped to require continuous grounded state."); + } + + /// + /// Perf budget for the harness: 200 ticks must complete in well + /// under 500 ms. If this ever fails, the inner loop has regressed + /// and the whole point of the harness — fast iteration on physics + /// fixes — is at risk. + /// + [Fact] + public void Harness_SimulationRunsInUnder500ms() + { + var (engine, _) = BuildEngineWithCellarFixtures(); + var body = BuildInitialBody(); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + _ = SimulateTicks(engine, body, CellarId, SimulationTicks); + sw.Stop(); + + Assert.True(sw.ElapsedMilliseconds < 500, + $"200-tick simulation should complete in under 500 ms. " + + $"Took: {sw.ElapsedMilliseconds} ms."); + } + + // ─────────────────────────────────────────────────────────────── + // Harness internals + // ─────────────────────────────────────────────────────────────── + + /// + /// One point in the simulated trajectory. Captured per tick. + /// + public sealed record TrajectoryPoint( + int Tick, + Vector3 Position, + uint CellId, + bool IsOnGround, + bool CpValid); + + /// + /// Builds a with the three issue-#98 + /// cottage/cellar cell fixtures registered. No landblock is + /// registered — the indoor BSP path takes over because the cell + /// IDs have low byte ≥ 0x100. + /// + private static (PhysicsEngine engine, PhysicsDataCache cache) + BuildEngineWithCellarFixtures() + { + var cache = new PhysicsDataCache(); + foreach (var cellId in new[] { CellarId, CottageNeighborA, CottageNeighborB }) + { + var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json"); + Assert.True(File.Exists(path), + $"Fixture missing: {path}. Re-run cell-dump capture " + + $"(commit 3f56915 captured the originals)."); + var dump = CellDumpSerializer.Read(path); + var cell = CellDumpSerializer.Hydrate(dump); + cache.RegisterCellStructForTest(cellId, cell); + } + + return (new PhysicsEngine { DataCache = cache }, cache); + } + + /// + /// Sphere on the cellar floor with a seeded flat-floor ContactPlane. + /// Mirrors the production pattern in PlayerMovementController: + /// a grounded body carries its last ContactPlane forward across ticks. + /// + private static PhysicsBody BuildInitialBody() => new() + { + Position = InitialSphereWorld, + Orientation = Quaternion.Identity, + ContactPlaneValid = true, + ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ), + ContactPlaneCellId = CellarId, + TransientState = TransientStateFlags.Contact + | TransientStateFlags.OnWalkable, + }; + + /// + /// Drives physics ticks. Each tick + /// applies as the requested forward + /// motion, calls , + /// writes the result back to , and records + /// a . + /// + /// + /// Cross-tick ContactPlane persistence is via + /// — the engine writes its final CP back to the body, then reads + /// it as the seed for the next tick. This mirrors the production + /// pattern in PlayerMovementController. + /// + /// + private static List SimulateTicks( + PhysicsEngine engine, + PhysicsBody body, + uint initialCellId, + int tickCount) + { + uint cellId = initialCellId; + bool isOnGround = true; + + var trajectory = new List(tickCount + 1) + { + new(0, body.Position, cellId, isOnGround, body.ContactPlaneValid), + }; + + for (int tick = 1; tick <= tickCount; tick++) + { + Vector3 target = body.Position + PerTickOffset; + + var result = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: target, + cellId: cellId, + sphereRadius: SphereRadius, + sphereHeight: SphereHeight, + stepUpHeight: StepUpHeight, + stepDownHeight: StepDownHeight, + isOnGround: isOnGround, + body: body, + moverFlags: ObjectInfoState.IsPlayer + | ObjectInfoState.EdgeSlide, + movingEntityId: 0); + + body.Position = result.Position; + cellId = result.CellId; + isOnGround = result.IsOnGround; + + trajectory.Add(new( + tick, + body.Position, + cellId, + isOnGround, + body.ContactPlaneValid)); + } + + return trajectory; + } + + private static string FixtureDir => + Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests", + "Fixtures", "issue98"); + + private static string SolutionRoot() + { + var dir = AppContext.BaseDirectory; + while (!string.IsNullOrEmpty(dir)) + { + if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) + return dir; + dir = Path.GetDirectoryName(dir); + } + throw new InvalidOperationException( + "Could not locate AcDream.slnx from " + AppContext.BaseDirectory); + } +}