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, }; ///