test(phys): A6.P3 #98 — harness deep investigation; airborne-at-tick-1 root cause not yet isolated

Multi-step investigation of the airborne-at-tick-1 bug per the
systematic-debugging skill. Several hypotheses tested via the
harness, each producing the same (0,1,0) hit normal at tick 1:

1. WalkablePolygon seeding ADDED to BuildInitialBody (was missing).
   PhysicsEngine.cs:665-673 requires body.WalkablePolygonValid +
   WalkableVertices to call SpherePath.SetWalkable. With seeded
   walkable poly: walkPoly=True survives tick 1 (was False before).
   BUT engine still reports hit=(0,1,0) and body goes airborne.
2. Initial Z lift removed (back to 0): same airborne behavior.
3. Synthetic stair GfxObj DISABLED: same (0,1,0) hit. Hit is not
   from FindObjCollisions.
4. Stub landblock REMOVED: same (0,1,0). FindObjCollisions early-
   returns without landblock context, FindEnvCollisions's outdoor
   terrain returns null. Hit is not from terrain.
5. SYNTHETIC BSP attached to cell fixtures (Hydrate sets BSP=null
   per its xmldoc; without BSP the indoor branch is skipped, falls
   through to outdoor terrain). One-leaf BSP referencing every poly
   in cell.Resolved. Indoor BSP path now runs. Same (0,1,0) hit.

Trace timeline at tick 1:
  find-start: walkPoly=True, CP valid, oi=0x303 (Contact+OnWalkable)
  after-adjust: req=(0,-0.1,0) adj=(0,-0.1,0) — no projection change
  before-insert: check=(141.5, 9.4, 91.43)
  stepdown-enter (Contact-recovery): stepDown=True, height=0.04
  stepdown-after-offset: check=(141.5, 9.4, 91.39) — moved DOWN 0.04
  stepdown-after-insert: state=OK, cp=n/a (no walkable found)
  stepdown-reject
  (second stepdown attempt — same outcome)
  after-insert: state=Collided, hit=n/a, walkPoly=False
  after-validate: state=OK, hit=(0,1,0), slide=(0,1,0)
                   oi=0x300 (Contact+OnWalkable CLEARED)

The (0,1,0) hit is set by ValidateTransition between after-insert
and after-validate. ValidateTransition's default-push-up code path
sets UnitZ=(0,0,1), NOT UnitY=(0,1,0). So something INSIDE
TransitionalInsert sets ci.CollisionNormal=(0,1,0) before
ValidateTransition runs (12 SetCollisionNormal call sites in
TransitionTypes.cs — root cause not isolated to one).

Per systematic-debugging skill: 5+ hypotheses tested without
convergence = "question architecture". The bug is hidden deeper
than a single misconfigured init field.

Next session pickup: build a side-by-side instrumentation harness
that mimics PlayerMovementController's EXACT call sequence
(PhysicsBody field state, ResolveWithTransition args, frame
ordering) and compare per-tick divergence against a live capture.
The harness is missing some piece of state production carries
across ticks — find what piece.

Apparatus progress (committed):
- Harness with synthetic stair GfxObj registration (Issue #98 ramp polygon now constructable programmatically)
- Synthetic cell-BSP attachment (AttachSyntheticBsp) — unlocks indoor
  BSP collision path for hydrated cell fixtures
- WalkablePolygon seeding in BuildInitialBody (PhysicsBody seeding pattern documented)
- Three diagnostic dump tests for tick-by-tick traces

Test baseline: 1167 + 5 (harness) = 1172 + 8 pre-existing failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-23 19:04:36 +02:00
parent 227a77522a
commit 5c6bdbe30d

View file

@ -96,23 +96,14 @@ public class CellarUpTrajectoryReplayTests
private const float StepDownHeight = 0.04f;
/// <summary>
/// Sphere center starts slightly ABOVE its resting position on the
/// cellar floor (offset by an additional 0.05 m above sphere-bottom-
/// on-floor) to avoid the BSP query's floating-point boundary at
/// exact contact. With sphere center at exactly Z=floor+radius, the
/// engine reports hit=yes (back-face contact) and the body goes
/// airborne; with a 0.05 m lift, step-down on tick 1 should snap
/// the sphere cleanly to the floor.
///
/// <para>
/// Sphere center starts exactly at its natural resting position on
/// the cellar floor: bottom on floor, center at Z = floor + radius.
/// Y=9.5 is ~0.75 m before the ramp foot at Y=8.75 (live-capture
/// ramp plane: <c>0.719·y + 0.695·z = 69.5035</c> → y=8.75 at z=90.95).
/// X=141.5 matches the live capture's X.
/// </para>
/// </summary>
private const float InitialZLift = 0.05f;
private static readonly Vector3 InitialSphereWorld =
new(141.5f, 9.5f, CellarFloorZ + SphereRadius + InitialZLift);
new(141.5f, 9.5f, CellarFloorZ + SphereRadius);
/// <summary>
/// Per-tick forward offset (Y direction toward the ramp).
@ -154,26 +145,32 @@ public class CellarUpTrajectoryReplayTests
[Fact]
public void Harness_DiagnosticDump_FirstTenTicks()
{
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeResolveEnabled = true;
PhysicsDiagnostics.ProbeStepWalkEnabled = true;
PhysicsDiagnostics.ProbeIndoorBspEnabled = true;
PhysicsDiagnostics.ProbePolyDumpEnabled = true;
try
{
var (engine, _) = BuildEngineWithCellarFixtures();
var body = BuildInitialBody();
var trajectory = SimulateTicks(engine, body, CellarId, 10);
var trajectory = SimulateTicks(engine, body, CellarId, 2);
var msg = "Trajectory (10 ticks):\n " +
var msg = "Trajectory (2 ticks):\n " +
string.Join("\n ", trajectory.Select(p =>
$"tick={p.Tick} pos=({p.Position.X:F4},{p.Position.Y:F4},{p.Position.Z:F4}) " +
$"cell=0x{p.CellId:X8} onGround={p.IsOnGround} cpValid={p.CpValid}"));
// Always pass — this is a diagnostic test; the resolve
// probe output appears in the test runner's captured stdout
// Always pass — this is a diagnostic test; the probe
// output appears in the test runner's captured stdout
// and the trajectory in the assertion message on failure.
Assert.True(true, msg);
}
finally
{
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeResolveEnabled = false;
PhysicsDiagnostics.ProbeStepWalkEnabled = false;
PhysicsDiagnostics.ProbeIndoorBspEnabled = false;
PhysicsDiagnostics.ProbePolyDumpEnabled = false;
}
}
@ -232,11 +229,35 @@ public class CellarUpTrajectoryReplayTests
}
/// <summary>
/// Documents finding #2: at the initial grounded position, the
/// engine reports the cellar floor as a non-walkable collision
/// and the body goes airborne at tick 1. Whether this is an
/// engine bug or a fixture issue is unclear; the harness exposes
/// it deterministically.
/// Documents the deep-investigation finding (2026-05-23 evening
/// extension): the seeded grounded sphere still goes airborne at
/// tick 1 with hit=(0,1,0) — a +Y wall normal that doesn't match
/// any registered geometry. The hit is set by ValidateTransition
/// after the inner TransitionalInsert returns Collided, but the
/// source of the (0,1,0) inside TransitionalInsert is not yet
/// isolated.
///
/// <para>
/// Investigation excluded:
/// <list type="bullet">
/// <item>Stub landblock terrain (removed; same hit)</item>
/// <item>Synthetic stair GfxObj (removed; same hit)</item>
/// <item>Cell BSP=null on Hydrate (attached synthetic BSP; same hit)</item>
/// <item>WalkablePolygon NOT seeded vs seeded (seeded now: walkable=True survives, but (0,1,0) hit remains)</item>
/// <item>Initial sphere Z lift 0.0 vs 0.05 m (same hit)</item>
/// <item>PhysicsBody seeded vs body=null (same hit)</item>
/// </list>
/// </para>
///
/// <para>
/// Next session's investigation move: build a side-by-side
/// instrumentation harness that calls the EXACT same
/// ResolveWithTransition invocation as production's
/// PlayerMovementController, with identical body state, and
/// compare per-tick state divergence. The harness setup must be
/// missing some piece of state that production carries from a
/// prior live tick — find what piece.
/// </para>
/// </summary>
[Fact]
public void Harness_Finding_SphereGoesAirborneAtTick1()
@ -248,13 +269,13 @@ public class CellarUpTrajectoryReplayTests
Assert.True(trajectory[0].IsOnGround,
"Tick 0 is the seeded starting state and must report grounded.");
Assert.False(trajectory[1].IsOnGround,
"Finding #2: at tick 1 the engine reports the sphere is NOT " +
"grounded, even though it started seeded on the cellar floor " +
"with a flat-floor ContactPlane. Investigate whether the " +
"cellar floor polygon's containment test is mis-firing or " +
"whether the engine genuinely treats floor contact as a " +
"non-walkable collision. If/when this is fixed, the assertion " +
"should be flipped to require continuous grounded state.");
"Open finding: at tick 1 the engine reports the sphere is NOT " +
"grounded, even though it started seeded with ContactPlane + " +
"WalkablePolygon on the cellar floor and the cell has a " +
"synthetic BSP wrapping every polygon. Hit normal is (0,1,0) — " +
"doesn't match any registered geometry. Source of (0,1,0) " +
"inside TransitionalInsert is not yet isolated. See the class " +
"doc for the exclusion list and next investigation move.");
}
/// <summary>
@ -314,7 +335,14 @@ public class CellarUpTrajectoryReplayTests
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
// ── 1. Cell fixtures (existing) ─────────────────────────────
// ── 1. Cell fixtures (existing) + synthetic BSP ──────────────
// CellDumpSerializer.Hydrate intentionally sets BSP=null (the DAT
// PhysicsBSPTree isn't in the dump format). Without a non-null BSP,
// FindEnvCollisions's indoor branch (TransitionTypes.cs:1840) is
// skipped — the engine falls through to outdoor terrain queries
// that produce spurious wall hits. Construct a single-leaf BSP
// wrapping the cell's Resolved polygons, so the indoor path fires
// like production.
foreach (var cellId in new[] { CellarId, CottageNeighborA, CottageNeighborB })
{
var path = Path.Combine(FixtureDir, $"0x{cellId:X8}.json");
@ -323,32 +351,80 @@ public class CellarUpTrajectoryReplayTests
$"(commit 3f56915 captured the originals).");
var dump = CellDumpSerializer.Read(path);
var cell = CellDumpSerializer.Hydrate(dump);
cache.RegisterCellStructForTest(cellId, cell);
var cellWithBsp = AttachSyntheticBsp(cell);
cache.RegisterCellStructForTest(cellId, cellWithBsp);
}
// ── 2. Stub landblock so TryGetLandblockContext succeeds ───
// FindObjCollisions early-returns if no landblock covers the
// sphere's XY. The cellar is in the world's first landblock
// (worldOffset 0,0 covers 0..192m). We don't need real terrain
// for indoor BSP collision — minimal heights array suffices.
var heights = new byte[81];
Array.Fill(heights, (byte)0);
var heightTab = new float[256];
for (int i = 0; i < 256; i++) heightTab[i] = i * 1.0f;
engine.AddLandblock(
landblockId: 0xA9B40000u,
terrain: new TerrainSurface(heights, heightTab),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
// ── 2. NO landblock registered ──────────────────────────────
// Without a landblock, SampleTerrainWalkable returns null and
// FindEnvCollisions's outdoor-fallback path returns OK without
// running ValidateWalkable on stub terrain. This is the right
// shape for indoor-only tests — the cell's BSP would handle
// collision if hydrated, and falling through to stub terrain
// produces spurious (0,1,0) wall hits. FindObjCollisions also
// early-returns without landblock context (line 2153 of
// TransitionTypes.cs), so the synthetic stair GfxObj is also
// skipped — fine for the airborne-at-tick-1 isolation.
// ── 3. Synthetic stair-piece GfxObj + ShadowEntry ──────────
RegisterStairRampGfxObj(engine, cache);
// Temporarily disabled while debugging the airborne-at-tick-1
// issue. Re-enable once the cell-BSP-is-null + landblock-stub
// interaction is understood, AND we have a way to register
// the stair without needing a landblock (e.g., extend
// FindObjCollisions to query cellScope-only shadows without
// landblock context).
// RegisterStairRampGfxObj(engine, cache);
return (engine, cache);
}
/// <summary>
/// Wraps a hydrated <see cref="CellPhysics"/> with a synthetic
/// single-leaf <see cref="PhysicsBSPTree"/> that references every
/// polygon in <c>cell.Resolved</c>. <c>CellDumpSerializer.Hydrate</c>
/// intentionally sets BSP=null (per its xmldoc) because the dump
/// format doesn't capture the DAT BSP tree. Without a non-null BSP,
/// FindEnvCollisions's indoor branch is skipped — the engine then
/// falls through to outdoor terrain queries that misfire. A flat
/// single-leaf BSP is sufficient for the BSP query to find every
/// polygon by exhaustive iteration (slower than a real BSP but
/// correct).
/// </summary>
private static CellPhysics AttachSyntheticBsp(CellPhysics cell)
{
// Compute a bounding sphere that encompasses every polygon in the
// cell — center at the origin of the cell's WORLD transform plus
// a margin radius. The cellar fixture is ~12 m × 12 m × 3 m.
var bsphereCenter = new Vector3(0f, 0f, 0f); // cell local
var bsphereRadius = 15f;
var leaf = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere { Origin = bsphereCenter, Radius = bsphereRadius },
};
foreach (var kv in cell.Resolved)
leaf.Polygons.Add(kv.Key);
var bspTree = new PhysicsBSPTree { Root = leaf };
// CellPhysics has init-only properties; rebuild a new instance
// with BSP set, copying every other field unchanged.
return new CellPhysics
{
BSP = bspTree,
PhysicsPolygons = cell.PhysicsPolygons,
Vertices = cell.Vertices,
WorldTransform = cell.WorldTransform,
InverseWorldTransform = cell.InverseWorldTransform,
Resolved = cell.Resolved,
CellBSP = cell.CellBSP,
Portals = cell.Portals,
PortalPolygons = cell.PortalPolygons,
VisibleCellIds = cell.VisibleCellIds,
};
}
/// <summary>
/// Constructs a synthetic GfxObj containing the cellar ramp polygon
/// in WORLD coordinates and registers it as a ShadowEntry scoped to
@ -447,19 +523,52 @@ public class CellarUpTrajectoryReplayTests
}
/// <summary>
/// Sphere on the cellar floor with a seeded flat-floor ContactPlane.
/// Mirrors the production pattern in <c>PlayerMovementController</c>:
/// a grounded body carries its last ContactPlane forward across ticks.
/// Sphere on the cellar floor with BOTH a seeded ContactPlane AND a
/// seeded WalkablePolygon. Both are required by the engine to treat
/// the body as truly grounded:
/// <list type="bullet">
/// <item><c>ContactPlaneValid</c> + <c>ContactPlane</c>: copied into
/// <c>CollisionInfo.ContactPlane</c> via the body parameter
/// seeding in <see cref="PhysicsEngine.ResolveWithTransition"/>.</item>
/// <item><c>WalkablePolygonValid</c> + <c>WalkablePlane</c> +
/// <c>WalkableVertices</c>: read by
/// <see cref="PhysicsEngine.ResolveWithTransition"/> lines
/// 665-673 to call <c>SpherePath.SetWalkable(...)</c>, which
/// sets <c>HasWalkablePolygon=true</c>. Without this, the
/// engine treats the sphere as "grounded but with no walkable
/// polygon anchor" — a contradictory state that fires step-down
/// probes which reject and clear the grounded flag.</item>
/// </list>
/// </summary>
private static PhysicsBody BuildInitialBody() => new()
{
Position = InitialSphereWorld,
Orientation = Quaternion.Identity,
// ContactPlane: cellar floor at world Z=90.95.
ContactPlaneValid = true,
ContactPlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
ContactPlaneCellId = CellarId,
TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable,
// WalkablePolygon: cellar floor poly 24 (the cellar quad under
// sphere XY=(141.5, 9.5)), transformed to world coordinates via
// the cell's 180° yaw + origin (130.5, 11.5, 94.0). Local verts
// [(-11.6, 0, -3.05), (-11.6, 3.1, -3.05), (-9.6, 3.1, -3.05),
// (-9.6, 0, -3.05)] → world [(142.1, 11.5, 90.95),
// (142.1, 8.4, 90.95), (140.1, 8.4, 90.95), (140.1, 11.5, 90.95)].
WalkablePolygonValid = true,
WalkablePlane = new System.Numerics.Plane(0f, 0f, 1f, -CellarFloorZ),
WalkableVertices = new[]
{
new Vector3(142.1f, 11.5f, 90.95f),
new Vector3(142.1f, 8.4f, 90.95f),
new Vector3(140.1f, 8.4f, 90.95f),
new Vector3(140.1f, 11.5f, 90.95f),
},
WalkableUp = Vector3.UnitZ,
TransientState = TransientStateFlags.Contact
| TransientStateFlags.OnWalkable,
};
/// <summary>