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:
parent
227a77522a
commit
5c6bdbe30d
1 changed files with 163 additions and 54 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue