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