diff --git a/tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs
new file mode 100644
index 0000000..ee1006b
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs
@@ -0,0 +1,308 @@
+using System;
+using System.IO;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// A6.P3 issue #98 (2026-05-23) — deterministic replay harness for the
+/// cellar-ascent failure. Loads the three real-geometry cell fixtures
+/// captured from the live client (commit
+/// 3f56915 — cellar/cottage cell fixtures from live capture) and
+/// drives the failing-frame sphere through the same nearest-walkable
+/// algorithm that the production
+/// Transition.LogNearestWalkableCandidate diagnostic uses.
+///
+///
+/// The failing frame is anchored at sphere world position
+/// (141.7164, 8.3937, 92.0093) with radius 0.4800 — the
+/// position the live client reports immediately before
+/// stepup: FAILED — sliding back along normal. Equivalent
+/// log lines are in
+/// a6-issue98-negpoly-20260523-135032.out.log around line 11338
+/// ([walkable-nearest]) + 11339 ([issue98-walkable-detail]).
+///
+///
+///
+/// What this test asserts is the CURRENT (failing) behavior — it
+/// documents the bug. The retail comparison (Step 4 of the plan) will
+/// add assertions that name what retail does at the same sphere
+/// position; those assertions will fail until the underlying
+/// predicate / transform divergence is fixed.
+///
+///
+public class Issue98CellarUpReplayTests
+{
+ // ── Failing-frame anchor data (from the negpoly capture log) ──
+ private static readonly Vector3 FailingFrameSphereWorld =
+ new(141.7164f, 8.3937f, 92.0093f);
+ private const float FailingFrameSphereRadius = 0.4800f;
+ private const float WalkableAllowance = 0.6642f; // PhysicsGlobals.FloorZ
+ private const float StepSearch = 0.6000f;
+
+ private const uint CellarId = 0xA9B40147u;
+ private const uint CottageNeighborA = 0xA9B40143u;
+ private const uint CottageNeighborB = 0xA9B40146u;
+
+ private static readonly string FixtureDir =
+ Path.Combine(SolutionRoot(), "tests", "AcDream.Core.Tests",
+ "Fixtures", "issue98");
+
+ [Fact]
+ public void Fixtures_AllThreeCellsLoadAndShareOrigin()
+ {
+ var cells = LoadAllThreeCells();
+
+ Assert.Equal(3, cells.Length);
+ foreach (var (id, cell) in cells)
+ {
+ Assert.NotEmpty(cell.Resolved);
+ // All three cells should sit at world origin (130.5, 11.5, 94.0).
+ var origin = Vector3.Transform(Vector3.Zero, cell.WorldTransform);
+ Assert.InRange(origin.X, 130.49f, 130.51f);
+ Assert.InRange(origin.Y, 11.49f, 11.51f);
+ Assert.InRange(origin.Z, 93.99f, 94.01f);
+ }
+ }
+
+ [Fact]
+ public void Cellar_HasMostPolygons_CottageNeighborBIsSparse()
+ {
+ var cellar = LoadCell(CellarId);
+ var nbrA = LoadCell(CottageNeighborA);
+ var nbrB = LoadCell(CottageNeighborB);
+
+ // Cellar is the densest cell — runtime captured 37 physics polys.
+ Assert.InRange(cellar.Resolved.Count, 30, 50);
+ // Cottage neighbor A is mid-density (~14 polys).
+ Assert.InRange(nbrA.Resolved.Count, 10, 20);
+ // Cottage neighbor B is sparse (~4 polys) — slice 5 handoff's
+ // "cottage main floor cell 0xA9B40146" inference was wrong;
+ // 0x0146 is a transition cell with no real floor coverage.
+ Assert.InRange(nbrB.Resolved.Count, 1, 8);
+ }
+
+ [Fact]
+ public void FailingFrame_CellarPrimary_HasCellarRampAsNearestWalkable()
+ {
+ var cell = LoadCell(CellarId);
+ var local = ToCellLocal(FailingFrameSphereWorld, cell);
+ var nearest = FindNearestWalkable(cell, local, FailingFrameSphereRadius);
+
+ // We are physically on the cellar ramp. The nearest walkable
+ // should be the ramp polygon itself (poly 0x0008 in retail's
+ // numbering — verified via the polydump capture). Normal Z
+ // ≈ 0.695 → walkable per FloorZ=0.6642.
+ Assert.True(nearest.Found,
+ "Cellar should always have at least one walkable candidate " +
+ "at the failing frame; the ramp's foot is right under the sphere.");
+ Assert.True(nearest.Polygon!.Plane.Normal.Z > WalkableAllowance,
+ "Nearest walkable normal must be above WalkableAllowance.");
+ }
+
+ ///
+ /// The cellar-up failure: the step-up probe looks for a walkable
+ /// polygon in the cottage neighbour cells that the player can step
+ /// up onto. In 0xA9B40143 the nearest candidate is poly
+ /// 0x0004 (a flat Z=0 triangle = world Z=94, i.e. cottage
+ /// floor). At the failing-frame sphere position our predicate
+ /// reports:
+ /// - insideEdges = false (sphere XY is beyond the triangle
+ /// edge)
+ /// - overlapsSphere = false (sphere is too far below the
+ /// plane)
+ /// → no walkable accepted → step-up fails.
+ ///
+ [Fact]
+ public void FailingFrame_CottageNeighborA_NearestWalkableIsOutsideSphereAndEdges()
+ {
+ var cell = LoadCell(CottageNeighborA);
+ var local = ToCellLocal(FailingFrameSphereWorld, cell);
+ var nearest = FindNearestWalkable(cell, local, FailingFrameSphereRadius);
+
+ Assert.True(nearest.Found,
+ "0xA9B40143 must have at least one walkable candidate " +
+ "(otherwise the nearest-walkable diagnostic itself would be wrong).");
+
+ // The CURRENT (failing) behavior — exposed precisely.
+ Assert.False(nearest.OverlapsSphere,
+ "Failing frame: sphere center is too far below the cottage floor " +
+ "plane in 0xA9B40143 — overlapsSphere is false.");
+ Assert.False(nearest.InsideEdges,
+ "Failing frame: sphere XY is beyond the cottage floor triangle's " +
+ "edge in 0xA9B40143 — insideEdges is false.");
+ }
+
+ ///
+ /// The slice 5 handoff named 0xA9B40146 as "cottage main
+ /// floor cell" but the live capture shows it has only ~4 polygons —
+ /// far too sparse to be a real interior floor. At the failing-frame
+ /// sphere, the nearest-walkable query returns NO candidate at all.
+ ///
+ [Fact]
+ public void FailingFrame_CottageNeighborB_HasNoWalkableCandidate()
+ {
+ var cell = LoadCell(CottageNeighborB);
+ var local = ToCellLocal(FailingFrameSphereWorld, cell);
+ var nearest = FindNearestWalkable(cell, local, FailingFrameSphereRadius);
+
+ // The CURRENT (failing) behavior — this cell offers nothing.
+ // Step 4's retail comparison will tell us whether retail also
+ // sees nothing in 0x0146, or whether retail's walkable query
+ // reaches a polygon ours doesn't.
+ Assert.False(nearest.Found,
+ "0xA9B40146 has no walkable polygon close enough to the failing-frame " +
+ "sphere for the nearest-walkable diagnostic to select.");
+ }
+
+ ///
+ /// Composite assertion: across the two cottage neighbour cells,
+ /// NO walkable is accepted at the failing-frame sphere. This is the
+ /// bug. Step-up runs out of options → the player gets stuck on the
+ /// cellar ramp top.
+ ///
+ [Fact]
+ public void FailingFrame_NoCottageNeighbourYieldsAcceptedWalkable()
+ {
+ var nbrA = LoadCell(CottageNeighborA);
+ var nbrB = LoadCell(CottageNeighborB);
+
+ var localA = ToCellLocal(FailingFrameSphereWorld, nbrA);
+ var localB = ToCellLocal(FailingFrameSphereWorld, nbrB);
+
+ var resultA = FindNearestWalkable(nbrA, localA, FailingFrameSphereRadius);
+ var resultB = FindNearestWalkable(nbrB, localB, FailingFrameSphereRadius);
+
+ // Either cell's nearest walkable could in principle be accepted
+ // (insideEdges + overlapsSphere). At the failing frame, neither
+ // is.
+ bool nbrAAccepted = resultA.Found && resultA.InsideEdges && resultA.OverlapsSphere;
+ bool nbrBAccepted = resultB.Found && resultB.InsideEdges && resultB.OverlapsSphere;
+
+ Assert.False(nbrAAccepted || nbrBAccepted,
+ "Failing frame: no cottage neighbour cell yields a walkable " +
+ "that passes both insideEdges and overlapsSphere — this is the " +
+ "issue #98 reproduction. Step 4's retail capture will tell us " +
+ "which polygon retail accepts in this scenario.");
+ }
+
+ ///
+ /// Surface the EXACT geometry we're testing against — this assertion
+ /// makes the test self-documenting and will fail loudly if a future
+ /// fixture re-capture changes the polygon set.
+ ///
+ [Fact]
+ public void FailingFrame_CottageNeighborA_Poly0x0004_HasExpectedShape()
+ {
+ var cell = LoadCell(CottageNeighborA);
+
+ Assert.True(cell.Resolved.TryGetValue(0x0004, out var poly),
+ "Poly 0x0004 must exist in 0xA9B40143 — it's the nearest-walkable " +
+ "candidate per the negpoly capture.");
+ Assert.NotNull(poly);
+ Assert.Equal(3, poly!.NumPoints);
+ Assert.Equal(0f, poly.Plane.Normal.X, precision: 4);
+ Assert.Equal(0f, poly.Plane.Normal.Y, precision: 4);
+ Assert.Equal(1f, poly.Plane.Normal.Z, precision: 4);
+ Assert.Equal(0f, poly.Plane.D, precision: 4);
+ }
+
+ // ─────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────
+
+ private static (uint Id, CellPhysics Cell)[] LoadAllThreeCells() => new[]
+ {
+ (CellarId, LoadCell(CellarId)),
+ (CottageNeighborA, LoadCell(CottageNeighborA)),
+ (CottageNeighborB, LoadCell(CottageNeighborB)),
+ };
+
+ private static CellPhysics LoadCell(uint cellId)
+ {
+ var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
+ Assert.True(File.Exists(path),
+ $"Fixture missing: {path}. Re-run cell-dump capture (Step 2 of plan).");
+ var dump = CellDumpSerializer.Read(path);
+ return CellDumpSerializer.Hydrate(dump);
+ }
+
+ private static Vector3 ToCellLocal(Vector3 worldPos, CellPhysics cell)
+ => Vector3.Transform(worldPos, cell.InverseWorldTransform);
+
+ ///
+ /// Replay of Transition.LogNearestWalkableCandidate's
+ /// algorithm. Returns the nearest candidate by absolute distance to
+ /// the polygon plane, along with the diagnostic fields the live
+ /// probe emits.
+ ///
+ private static NearestWalkableResult FindNearestWalkable(
+ CellPhysics cell, Vector3 localCenter, float sphereRadius)
+ {
+ ResolvedPolygon? nearest = null;
+ ushort nearestId = 0;
+ float nearestAbs = float.MaxValue;
+ float nearestSigned = 0f;
+ bool overlaps = false;
+ bool insideEdges = false;
+
+ foreach (var (id, poly) in cell.Resolved)
+ {
+ float normalDotUp = Vector3.Dot(Vector3.UnitZ, poly.Plane.Normal);
+ if (normalDotUp <= WalkableAllowance)
+ continue;
+
+ float signed = Vector3.Dot(poly.Plane.Normal, localCenter) + poly.Plane.D;
+ float abs = MathF.Abs(signed);
+ if (abs >= nearestAbs)
+ continue;
+
+ nearest = poly;
+ nearestId = id;
+ nearestAbs = abs;
+ nearestSigned = signed;
+ // PhysicsGlobals.EPSILON ≈ 0.0001f; the production code subtracts it.
+ overlaps = abs <= sphereRadius - 1e-4f;
+ insideEdges = !BSPQuery.FindCrossedEdge(
+ poly.Plane, poly.Vertices, localCenter, Vector3.UnitZ, out _);
+ }
+
+ return new NearestWalkableResult
+ {
+ Found = nearest is not null,
+ Polygon = nearest,
+ PolygonId = nearestId,
+ SignedDistance = nearestSigned,
+ AbsDistance = nearestAbs,
+ OverlapsSphere = overlaps,
+ InsideEdges = insideEdges,
+ };
+ }
+
+ private sealed class NearestWalkableResult
+ {
+ public bool Found { get; init; }
+ public ResolvedPolygon? Polygon { get; init; }
+ public ushort PolygonId { get; init; }
+ public float SignedDistance { get; init; }
+ public float AbsDistance { get; init; }
+ public bool OverlapsSphere { get; init; }
+ public bool InsideEdges { get; init; }
+ }
+
+ private static string SolutionRoot()
+ {
+ // Walk up from the test binary until we find AcDream.slnx.
+ 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);
+ }
+}