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); } }