diff --git a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs new file mode 100644 index 0000000..fadfc7a --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Regression test for A6.P3 slice 1 — Finding 2 (ContactPlane resynthesis blowup). +/// +/// +/// Pre-fix behavior: enters the +/// indoor BSP branch every frame, calls +/// on every OK-result frame, which calls +/// which calls 1–2 times per frame. +/// Over 60 frames of flat-floor walking: ~60–120 additional ContactPlane writes. +/// +/// +/// +/// Post-fix behavior: the contact plane is carried forward from LKCP (Last Known +/// ContactPlane) when already grounded on the same surface; synthesis fires at most +/// once on entry. Over 60 flat-floor frames: ≤5 additional CP writes. +/// +/// +/// +/// Template: fixture pattern matches +/// (engine + DataCache + RegisterCellStructForTest + AddLandblock + MakeGroundedTransition). +/// Tested cell cell ID 0xA9B40166 (low 16 bits = 0x0166 ≥ 0x0100 → indoor branch fires). +/// +/// +/// +/// Retail oracle: A6.P2 findings doc +/// (docs/research/2026-05-21-a6-cdb-capture-findings.md), Finding 2. +/// +/// +public class IndoorContactPlaneRetentionTests +{ + // ── Cell ID ─────────────────────────────────────────────────────────────── + // Low 16 bits = 0x0166 ≥ 0x0100 → indoor branch of FindEnvCollisions fires. + // This is a real Holtburg inn cell from A6.P1 scen3 captures. + private const uint IndoorCellId = 0xA9B40166u; + + // ── Sphere radius matching BSPStepUpFixtures convention ────────────────── + private const float SphereRadius = 0.48f; + + // ── Maximum allowed additional CP writes in 60 frames (post-fix budget) ── + private const int MaxAdditionalCpWrites = 5; + + // ── Number of simulated frames ──────────────────────────────────────────── + private const int SimulatedFrames = 60; + + // ========================================================================= + // Helpers + // ========================================================================= + + /// + /// Build a BSP leaf node that contains the given polygon ids. + /// + /// The bounding sphere must cover the test sphere position so that + /// NodeIntersects passes and the BSP traversal reaches the leaf. + /// We parameterise the center so the caller can align it with the test geometry. + /// + private static PhysicsBSPTree BuildLeafBsp( + IEnumerable polyIds, + Vector3 bspCenter, + float bspRadius = 10f) + { + var node = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = bspCenter, Radius = bspRadius }, + }; + foreach (var id in polyIds) + node.Polygons.Add(id); + return new PhysicsBSPTree { Root = node }; + } + + /// + /// Build a CellPhysics with a single large upward-facing floor polygon + /// (a 20×20 square in the XY plane at local/world Z = ), + /// identity transforms, and a BSP leaf whose bounding sphere is centered at + /// so that NodeIntersects always passes. + /// + /// The floor is large enough (±10 in both X and Y) that any test position + /// within 10m of the origin in XY is always over the floor — no XY misses. + /// + /// Using identity transforms means local space == world space, which keeps + /// the geometry traceable without transform arithmetic. + /// + /// IMPORTANT — sphere center computation: + /// places LocalSphere[0].Origin = (0,0,sphereRadius). + /// After SetCheckPos(worldPos, cellId), the global sphere center is + /// worldPos + (0,0,sphereRadius). The BSP bounding sphere must be + /// centered near this sphere center (NOT at worldPos) so that NodeIntersects + /// passes during TryFindIndoorWalkablePlane → FindWalkableSphere → FindWalkableInternal. + /// + private static CellPhysics BuildCellWithFloor(float floorZ, float bspCenterZ) + { + // 20×20 upward-facing floor at local Z = floorZ. + var verts = new[] + { + new Vector3(-10f, -10f, floorZ), + new Vector3( 10f, -10f, floorZ), + new Vector3( 10f, 10f, floorZ), + new Vector3(-10f, 10f, floorZ), + }; + var normal = Vector3.UnitZ; // straight up + float d = -floorZ; // N·p + D = 0 → D = -floorZ + + var floorPoly = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + + // BSP leaf bounding sphere centered at the actual sphere center + // (worldPos + (0,0,sphereRadius)). Radius 10 covers the 20×20 floor + // polygon and the test sphere. + var bspCenter = new Vector3(0f, 0f, bspCenterZ); + + return new CellPhysics + { + BSP = BuildLeafBsp(new ushort[] { 0 }, bspCenter, bspRadius: 10f), + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = new Dictionary { [0] = floorPoly }, + // CellBSP intentionally left null: ResolveCellId sees + // indoorCell.CellBSP?.Root == null → returns the indoor id unchanged + // (the "can't verify; trust FindCellList" branch), so the cell ID + // stays indoor throughout every loop iteration. + CellBSP = null, + }; + } + + /// + /// Build a minimal PhysicsEngine with a flat landblock registered so that + /// FindEnvCollisions's AddLandblock / SampleTerrainWalkable / ResolveCellId + /// paths all have a coherent landblock context if they ever reach outdoors. + /// + private static PhysicsEngine BuildEngine(uint indoorCellId, CellPhysics cell) + { + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + // Flat terrain height table: all zeroes. + var heights = new byte[81]; + Array.Fill(heights, (byte)0); + var ht = new float[256]; + for (int i = 0; i < 256; i++) ht[i] = (float)i; + + // Use the high 16 bits of IndoorCellId as the landblock prefix. + // The landblock key is constructed with 0xFFFF suffix (the "all cells" + // landblock sentinel used throughout the test suite). + uint landblockKey = (indoorCellId & 0xFFFF0000u) | 0xFFFFu; + engine.AddLandblock(landblockKey, + new TerrainSurface(heights, ht), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + engine.DataCache.RegisterCellStructForTest(indoorCellId, cell); + return engine; + } + + /// + /// Build a grounded Transition positioned on the floor at + /// and headed to . + /// + /// Sets Contact + OnWalkable (grounded mover), seeds LastKnownContactPlane + /// with the floor plane, and calls + /// once so the CP is considered "already established." + /// + /// The initial SetContactPlane call is factored into + /// (which the test snapshots before the loop). + /// + private static Transition BuildGroundedTransition( + Vector3 worldPos, + Vector3 worldTarget, + uint cellId, + Plane floorPlane) + { + var t = new Transition(); + t.SpherePath.InitPath(worldPos, worldTarget, cellId, SphereRadius); + t.SpherePath.SetCheckPos(worldPos, cellId); + + // Grounded state — mirrors BSPStepUpFixtures.MakeGroundedTransition. + t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + t.ObjectInfo.StepUpHeight = 0.04f; + t.ObjectInfo.StepDownHeight = 0.04f; + t.ObjectInfo.StepDown = true; + + // Seed LastKnownContactPlane — the resolver's "we were on this floor last frame" memory. + t.CollisionInfo.LastKnownContactPlane = floorPlane; + t.CollisionInfo.LastKnownContactPlaneValid = true; + + // Seed ContactPlane so ValidateWalkable sees us as already grounded. + // This call increments ContactPlaneWriteCount to 1 (the "seeded" count). + t.CollisionInfo.SetContactPlane(floorPlane, cellId, isWater: false); + + return t; + } + + // ========================================================================= + // Tests + // ========================================================================= + + /// + /// Sixty frames of indoor flat-floor walking must not produce more than + /// additional ContactPlane writes + /// beyond the initial seeding write. + /// + /// + /// Pre-fix (broken): TryFindIndoorWalkablePlane fires + ValidateWalkable + /// calls SetContactPlane 1–2×/frame → ~60–120 additional writes → FAIL. + /// Post-fix: LKCP is restored early; synthesis skipped for already-grounded + /// movers → ≤5 additional writes → PASS. + /// + /// + [Fact] + public void IndoorFlatFloorWalking_60Frames_ProducesAtMost5ExtraCpWrites() + { + // ── Arrange ─────────────────────────────────────────────────────────── + // SpherePath.InitPath sets LocalSphere[0].Origin = (0,0,sphereRadius). + // After SetCheckPos(worldPos), the global sphere CENTER is at + // worldPos + (0,0,sphereRadius) = worldPos + (0,0,0.48). + // + // For TryFindIndoorWalkablePlane's probe to reach the floor: + // sphereCenter.Z - PROBE_DIST (0.5m) < floorZ + // (worldPos.Z + 0.48) - 0.5 < floorZ + // worldPos.Z < floorZ + 0.02 + // + // And for ValidateWalkable to call SetContactPlane (below-surface): + // sphereBottom = sphereCenter.Z - radius = worldPos.Z < floorZ + // i.e. worldPos.Z < floorZ + // + // Choose worldPos.Z = floorZ - 0.05 so sphere bottom is 0.05m below the + // floor. Sphere center is at floorZ + 0.43. Probe reaches floorZ + 0.43 - + // 0.5 = floorZ - 0.07 which is below the floor, so AdjustSphereToPlane + // returns true (dpPos = 0.43, dist = 0.43 - 0.48 = -0.05, iDist = 0.1). + // + // ValidateWalkable: lowPoint.Z = floorZ + 0.43 - 0.48 = floorZ - 0.05 + // dist = -0.05 < -EPSILON → SetContactPlane fires every frame. + const float floorZ = 0f; + const float worldPosZ = floorZ - 0.05f; // character "foot" position (begin param) + // Sphere center in world space = worldPosZ + SphereRadius = -0.05 + 0.48 = 0.43 + const float sphereCenterZ = worldPosZ + SphereRadius; // = 0.43 + + var floorPlane = new Plane(Vector3.UnitZ, -floorZ); // N·p + D = 0 → D = 0 + var worldPos = new Vector3(0f, 0f, worldPosZ); + + // BSP bounding sphere centered at the actual sphere center (not worldPos), + // so NodeIntersects passes in FindWalkableInternal. + var cell = BuildCellWithFloor(floorZ, bspCenterZ: sphereCenterZ); + var engine = BuildEngine(IndoorCellId, cell); + var t = BuildGroundedTransition( + worldPos, + worldPos + new Vector3(0.001f, 0f, 0f), // tiny horizontal target + IndoorCellId, + floorPlane); + + // Snapshot the write count right after seeding (should be exactly 1). + int seededWrites = t.CollisionInfo.ContactPlaneWriteCount; + Assert.Equal(1, seededWrites); + + // ── Act — 60 frames of flat-floor walking ───────────────────────────── + // Each iteration: nudge CheckPos a tiny bit forward in X (stays on the + // same floor), then call FindEnvCollisions as the indoor branch would + // call it each physics frame. + for (int frame = 0; frame < SimulatedFrames; frame++) + { + // Advance position by 1 mm forward — same Z, still over the floor. + var newPos = new Vector3(frame * 0.001f, 0f, worldPosZ); + t.SpherePath.SetCheckPos(newPos, IndoorCellId); + + // Simulate FindEnvCollisions as the physics loop calls it. + t.FindEnvCollisions(engine); + } + + // ── Assert ──────────────────────────────────────────────────────────── + int totalWrites = t.CollisionInfo.ContactPlaneWriteCount; + int additionalWrites = totalWrites - seededWrites; + + Assert.True( + additionalWrites <= MaxAdditionalCpWrites, + $"Expected ≤{MaxAdditionalCpWrites} additional CP writes across " + + $"{SimulatedFrames} flat-floor frames, got {additionalWrites}. " + + "Finding 2 fix not complete."); + } +}