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. /// /// /// /// Fixture pattern is a blend of /// (engine + DataCache + RegisterCellStructForTest + AddLandblock setup) /// and (sphere radius 0.48f matching /// TryFindIndoorWalkablePlane's probe thresholds + BuildCellWithFloor /// floor polygon pattern). /// Tested 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 BSPQuery.FindCollisions → BSP traversal → NodeIntersects /// (the indoor cell's primary BSP query in ). /// 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); // InitPath above already set CheckPos = begin; no second SetCheckPos // needed because this test doesn't move the sphere from begin to a // different destination (unlike multi-cell tests that walk a path). // 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 ─────────────────────────────────────────────────────────── // Geometry: sphere center 5 cm BELOW the floor so that the synthesis // path's distance guard passes and TryFindIndoorWalkablePlane would // actually find the floor polygon if synthesis were re-introduced. // // SpherePath.InitPath sets LocalSphere[0].Origin = (0,0,SphereRadius). // After SetCheckPos(worldPos), the global sphere CENTER is at // worldPos + (0,0,SphereRadius) = (worldPosX, 0, -0.05 + 0.48 = 0.43). // // PolygonHitsSpherePrecise distance guard (BSPQuery.cs line ~117): // dist = Dot(normal=(0,0,1), center=(x,0,0.43)) + D(=0) = 0.43 // rad = SphereRadius - EPSILON = 0.48 - 0.002 = 0.478 // |dist| = 0.43 < 0.478 → guard passes → polygon is tested → FOUND. // So TryFindIndoorWalkablePlane WOULD call ValidateWalkable → CP write // if the synthesis call were present. With the strip in place it is never // called → ≤5 additional writes → PASS. // // With the post-5f7722a setup (worldPosZ = floorZ = 0): // dist = 0.48, rad = 0.478, |dist| = 0.48 > 0.478 → guard FIRES → // TryFindIndoorWalkablePlane returns false even WITH synthesis code. // That setup was not a regression sentinel; this one is. // // Path 5 (BSP Contact branch, BSPQuery.cs ~line 1732): // The loop advances only in X, so movement = (0.001, 0, 0). // PosHitsSphere culls hits where Dot(movement, normal) >= 0. // Dot((0.001,0,0), (0,0,1)) = 0 >= 0 → floor polygon is ALWAYS // rejected by the front-face cull, even though the sphere center is // below the floor. Path 5 exits OK with no CP write regardless of // whether the Contact flag is set. Leaving Contact set keeps this // test on the realistic grounded-mover path. const float floorZ = 0f; const float worldPosZ = floorZ - 0.05f; // 5 cm below floor 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 sphere center so NodeIntersects // passes during the BSP traversal. 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 at the grounded Z), 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 (sphere center 5 cm below 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."); } }