diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
index 059af68..ba91315 100644
--- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs
@@ -96,23 +96,14 @@ public class CellarUpTrajectoryReplayTests
private const float StepDownHeight = 0.04f;
///
- /// 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.
- ///
- ///
+ /// 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: 0.719·y + 0.695·z = 69.5035 → y=8.75 at z=90.95).
/// X=141.5 matches the live capture's X.
- ///
///
- 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);
///
/// 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
}
///
- /// 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.
+ ///
+ ///
+ /// Investigation excluded:
+ ///
+ /// - Stub landblock terrain (removed; same hit)
+ /// - Synthetic stair GfxObj (removed; same hit)
+ /// - Cell BSP=null on Hydrate (attached synthetic BSP; same hit)
+ /// - WalkablePolygon NOT seeded vs seeded (seeded now: walkable=True survives, but (0,1,0) hit remains)
+ /// - Initial sphere Z lift 0.0 vs 0.05 m (same hit)
+ /// - PhysicsBody seeded vs body=null (same hit)
+ ///
+ ///
+ ///
+ ///
+ /// 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.
+ ///
///
[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.");
}
///
@@ -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(),
- portals: Array.Empty(),
- 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);
}
+ ///
+ /// Wraps a hydrated with a synthetic
+ /// single-leaf that references every
+ /// polygon in cell.Resolved. CellDumpSerializer.Hydrate
+ /// 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).
+ ///
+ 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,
+ };
+ }
+
///
/// 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
}
///
- /// Sphere on the cellar floor with a seeded flat-floor ContactPlane.
- /// Mirrors the production pattern in PlayerMovementController:
- /// 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:
+ ///
+ /// - ContactPlaneValid + ContactPlane: copied into
+ /// CollisionInfo.ContactPlane via the body parameter
+ /// seeding in .
+ /// - WalkablePolygonValid + WalkablePlane +
+ /// WalkableVertices: read by
+ /// lines
+ /// 665-673 to call SpherePath.SetWalkable(...), which
+ /// sets HasWalkablePolygon=true. 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.
+ ///
///
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,
};
///