acdream/tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs
Erik 856aa78ec1 test(phys): A6.P3 #98 Step 3 — deterministic replay harness
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).
2026-05-23 15:25:40 +02:00

308 lines
13 KiB
C#

using System;
using System.IO;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// 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
/// <c>3f56915 — cellar/cottage cell fixtures from live capture</c>) and
/// drives the failing-frame sphere through the same nearest-walkable
/// algorithm that the production
/// <c>Transition.LogNearestWalkableCandidate</c> diagnostic uses.
///
/// <para>
/// The failing frame is anchored at sphere world position
/// <c>(141.7164, 8.3937, 92.0093)</c> with radius <c>0.4800</c> — the
/// position the live client reports immediately before
/// <c>stepup: FAILED — sliding back along normal</c>. Equivalent
/// log lines are in
/// <c>a6-issue98-negpoly-20260523-135032.out.log</c> around line 11338
/// (<c>[walkable-nearest]</c>) + 11339 (<c>[issue98-walkable-detail]</c>).
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
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.");
}
/// <summary>
/// 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 <c>0xA9B40143</c> the nearest candidate is poly
/// <c>0x0004</c> (a flat Z=0 triangle = world Z=94, i.e. cottage
/// floor). At the failing-frame sphere position our predicate
/// reports:
/// - <c>insideEdges = false</c> (sphere XY is beyond the triangle
/// edge)
/// - <c>overlapsSphere = false</c> (sphere is too far below the
/// plane)
/// → no walkable accepted → step-up fails.
/// </summary>
[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.");
}
/// <summary>
/// The slice 5 handoff named <c>0xA9B40146</c> 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.
/// </summary>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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);
/// <summary>
/// Replay of <c>Transition.LogNearestWalkableCandidate</c>'s
/// algorithm. Returns the nearest candidate by absolute distance to
/// the polygon plane, along with the diagnostic fields the live
/// probe emits.
/// </summary>
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);
}
}