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