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).
308 lines
13 KiB
C#
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);
|
|
}
|
|
}
|