From 856aa78ec18d5826c0619118ef4478f9f68f4793 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 23 May 2026 15:25:40 +0200 Subject: [PATCH] =?UTF-8?q?test(phys):=20A6.P3=20#98=20Step=203=20?= =?UTF-8?q?=E2=80=94=20deterministic=20replay=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the apparatus plan. Adds Issue98CellarUpReplayTests, a 7-test harness that loads the three real-geometry cell fixtures captured in commit 3f56915 and drives the failing-frame sphere through the same nearest-walkable algorithm the live client uses in Transition.LogNearestWalkableCandidate. The tests reproduce the live failure deterministically in under 1ms each — the issue #98 cellar-up bug is now visible to a unit-test run, no client launch required. Tests: - Fixtures_AllThreeCellsLoadAndShareOrigin — sanity check the cells loaded with the expected (130.5, 11.5, 94.0) origin. - Cellar_HasMostPolygons_CottageNeighborBIsSparse — confirms the surprising finding: 0xA9B40146 is too sparse to be a "cottage main floor" cell (slice 5 handoff inference was wrong; 0xA9B40143 with 14 polys is the better candidate). - FailingFrame_CellarPrimary_HasCellarRampAsNearestWalkable — the ramp polygon IS reachable when the player is on top of it (sanity: this should always be true). - FailingFrame_CottageNeighborA_NearestWalkableIsOutsideSphereAndEdges — at the failing-frame sphere position, the nearest walkable in 0xA9B40143 (poly 0x0004, the cottage floor triangle at world Z=94) reports BOTH insideEdges=false AND overlapsSphere=false. The sphere XY is beyond the triangle edge, and the sphere is too far below the plane. THIS IS THE BUG'S SHAPE. - FailingFrame_CottageNeighborB_HasNoWalkableCandidate — 0xA9B40146 has NO walkable polygon close enough to the failing-frame sphere. - FailingFrame_NoCottageNeighbourYieldsAcceptedWalkable — composite: across both cottage cells, no walkable passes both edge + sphere tests → step-up has nothing to step onto → player stuck. - FailingFrame_CottageNeighborA_Poly0x0004_HasExpectedShape — pins the exact polygon shape so a future fixture re-capture failure is loud. What this gives us: 1. The bug is now ALWAYS reproducible in test, no live client iteration. 2. Any fix to BSPQuery.FindCrossedEdge / polygon containment / the cell transform will instantly show whether it changes the failing- frame outcome. 3. Step 4 (retail cdb capture) will tell us what retail finds at the same sphere position; Step 5 (comparison doc) will name the divergence; the eventual fix is then evidence-driven, not a guess. The tests document the CURRENT (failing) behavior. They WILL pass after the fix — at which point they need to flip to assert the retail-correct behavior. This intentional brittleness is the point: the test is the bug's gravestone, and a fix that doesn't match retail should not satisfy the test. Verification: - dotnet build: green, 0 errors. - dotnet test: 1167 passed + 8 pre-existing failed (was 1160+8 before this commit; +7 from the replay tests). Same pre-existing failures, no new regressions. - Each Issue98 test runs in under 1ms; loads JSON, calls one internal predicate per polygon, asserts. Next: tools/cdb/issue98-cellar-up-find-walkable.cdb (Step 4). --- .../Physics/Issue98CellarUpReplayTests.cs | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs 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); + } +}