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