From b0c29454d0f71f6da5519568f71778c9fb248e89 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 15:44:16 +0200 Subject: [PATCH] test(physics): conformance fixtures for BSP step-up + roof-landing (Phase L.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two files under tests/: BSPStepUpFixtures.cs — synthetic PhysicsBSPNode trees for four canonical collision shapes: low step (25 cm), too-tall wall (5 m), flat roof (3 m), and steep slope (60deg). Pre-builds ResolvedPolygon dicts with correct polygon_hits_sphere_precise winding (CCW relative to outward normal). BSPStepUpTests.cs — 11 conformance tests: A1-A6: baselines that pass before and after implementation (no-hit, geometry fixture sanity checks). B1-B3: Phase L.2.1 targets, currently RED (Path 5 wall-slides). C1-C3: Phase L.2.2 targets, currently RED (Path 6 wall-slides). Retail refs in test docstrings: BSPTREE::find_collisions Path 5 acclient_2013_pseudo_c.txt:323849 / ACE BSPTree.cs:192-196. CTransition::step_up acclient_2013_pseudo_c.txt:273099-273133 / ACE Transition.cs:746-777. SPHEREPATH::set_collide acclient_2013_pseudo_c.txt:321594-321607 / ACE SpherePath.cs:279-286. CTransition::transitional_insert Collide branch acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930. Also adds PhysicsDataCache.RegisterGfxObjForTest() for test-only GfxObjPhysics injection without real DAT content. Test delta: 811 -> 823 (+12). 6 passing (A1-A6 + B2), 5 intentionally failing. Pre-flight: object-translation plane D is in object-local space. Bug is dormant for outdoor movement where terrain sets the world-space ContactPlane. Tagged TODO. Co-Authored-By: Claude Sonnet 4.6 --- src/AcDream.Core/Physics/PhysicsDataCache.cs | 8 + .../Physics/BSPStepUpFixtures.cs | 363 +++++++++++++ .../Physics/BSPStepUpTests.cs | 475 ++++++++++++++++++ 3 files changed, 846 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs create mode 100644 tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 225bf3f..771f208 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -209,6 +209,14 @@ public sealed class PhysicsDataCache public int GfxObjCount => _gfxObj.Count; public int SetupCount => _setup.Count; public int CellStructCount => _cellStruct.Count; + + /// + /// Register a pre-built directly. + /// Intended for unit-test fixtures that construct synthetic BSP trees + /// without needing real DAT content. + /// + public void RegisterGfxObjForTest(uint gfxObjId, GfxObjPhysics physics) + => _gfxObj[gfxObjId] = physics; } /// diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs new file mode 100644 index 0000000..fb06b18 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs @@ -0,0 +1,363 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Synthetic BSP tree fixtures for step-up and roof-landing conformance tests. +/// +/// +/// These fixtures construct minimal trees plus +/// matching dictionaries that represent canonical +/// AC collision shapes without needing real DAT content. The shapes cover every +/// interesting branch in Path 5 and Path 6. +/// +/// +/// +/// Coordinate convention: +Z is up, all geometry is expressed in object-local +/// space (identity rotation, scale = 1.0) with objects at world origin so that +/// localSphere.Origin == worldPosition. +/// +/// +/// +/// Retail references: +/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 / +/// ACE BSPTree.cs:192-196. +/// BSPTREE::find_collisions Path 6 / set_collide — +/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219. +/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 / +/// ACE Transition.cs:746-777. +/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 / +/// ACE SpherePath.cs:279-286. +/// +/// +public static class BSPStepUpFixtures +{ + // ------------------------------------------------------------------------- + // Polygon ID constants — each fixture uses a distinct range so the + // resolved-polygon dictionary is unambiguous when fixtures are composed. + // ------------------------------------------------------------------------- + public const ushort LowStep_FloorId = 10; + public const ushort LowStep_WallId = 11; + public const ushort LowStep_UpperFloorId = 12; + + public const ushort TallWall_FloorId = 20; + public const ushort TallWall_WallId = 21; + + public const ushort FlatRoof_FloorId = 30; + public const ushort FlatRoof_RoofId = 31; + + public const ushort SlopedUnwalkable_FloorId = 40; + public const ushort SlopedUnwalkable_SlopeId = 41; + + // ------------------------------------------------------------------------- + // Sphere radius used in every test. + // ------------------------------------------------------------------------- + public const float SphereRadius = 0.2f; + + // ========================================================================= + // Fixture 1 — Low step (25 cm) + // + // Schema (side view, XZ plane): + // + // +X ──────────────────► + // Z + // 0.5 ┆ ┌─────── ← UpperFloor at z=0.25 (vert 8..11) + // 0.25├───────────┤ + // ┆ Wall ┆ (x=0.5, z=[0,0.25]) + // 0.0 ┆═══════════┘ + // ← Floor at z=0 (vert 0..3) + // + // The mover starts grounded at x=-0.5, z=SphereRadius and walks toward +X. + // Expected: step-up succeeds when Contact is set; sphere lifts to z=0.25+eps. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a 25 cm step. + /// + /// Geometry (object-local space): + /// + /// Floor polygon at z = 0, x ∈ [-2, 0.5], y ∈ [-1, 1]. + /// Vertical wall polygon at x = 0.5, z ∈ [0, 0.25], y ∈ [-1, 1], facing -X. + /// Upper floor polygon at z = 0.25, x ∈ [0.5, 2], y ∈ [-1, 1]. + /// + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + LowStep() + { + var resolved = new Dictionary(); + + // Lower floor: z=0, x∈[-2,0.5], y∈[-1,1], normal = +Z + resolved[LowStep_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Vertical wall facing -X at x=0.5, z∈[0,0.25], normal = -X + // For normal=(-1,0,0), the winding that makes cross(normal,edge)·disp > 0 + // for interior points is: (y=-1,z=0)→(y=-1,z=0.25)→(y=1,z=0.25)→(y=1,z=0). + resolved[LowStep_WallId] = MakeQuad( + new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, -1f, 0.25f), + new Vector3(0.5f, 1f, 0.25f), + new Vector3(0.5f, 1f, 0f), + expectedNormal: new Vector3(-1f, 0f, 0f)); + + // Upper floor at z=0.25, x∈[0.5,2], y∈[-1,1], normal = +Z + resolved[LowStep_UpperFloorId] = MakeFloor( + new Vector3(0.5f, -1f, 0.25f), new Vector3(2f, -1f, 0.25f), + new Vector3(2f, 1f, 0.25f), new Vector3(0.5f, 1f, 0.25f)); + + // Build a flat BSP tree: one internal node with all three polys in a leaf. + // The bounding sphere covers everything. + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 10f }, + }; + leaf.Polygons.Add(LowStep_FloorId); + leaf.Polygons.Add(LowStep_WallId); + leaf.Polygons.Add(LowStep_UpperFloorId); + + return (leaf, resolved); + } + + // ========================================================================= + // Fixture 2 — Too-tall wall (5 m) + // + // A floor at z=0 and a 5 m wall at x=0.5 with no floor on the other side. + // Expected: step-up fails (wall too tall), mover slides along wall. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a wall that + /// is too tall to step over (5 m), so step-up should fail. + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + TallWall() + { + var resolved = new Dictionary(); + + // Floor at z=0 + resolved[TallWall_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Tall wall at x=0.5, z∈[0,5], normal = -X + // Winding for normal=(-1,0,0): (y=-1,z=0)→(y=-1,z=5)→(y=1,z=5)→(y=1,z=0). + resolved[TallWall_WallId] = MakeQuad( + new Vector3(0.5f, -1f, 0f), + new Vector3(0.5f, -1f, 5f), + new Vector3(0.5f, 1f, 5f), + new Vector3(0.5f, 1f, 0f), + expectedNormal: new Vector3(-1f, 0f, 0f)); + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f }, + }; + leaf.Polygons.Add(TallWall_FloorId); + leaf.Polygons.Add(TallWall_WallId); + + return (leaf, resolved); + } + + // ========================================================================= + // Fixture 3 — Flat roof (3 m) + // + // A horizontal polygon at z=3 representing a building rooftop. + // The mover is airborne (no Contact flag) descending toward the roof. + // Expected (after L.2.2): Path 6 sets Collide flag; the Collide-flag handler + // re-tests as Placement; ContactPlane is set; OnWalkable is established. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a 3 m flat roof. + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + FlatRoof() + { + var resolved = new Dictionary(); + + // Ground floor for reference (not involved in landing test) + resolved[FlatRoof_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f), + new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Roof at z=3.0, x∈[-2,2], y∈[-1,1], normal = +Z + resolved[FlatRoof_RoofId] = MakeFloor( + new Vector3(-2f, -1f, 3f), new Vector3(2f, -1f, 3f), + new Vector3(2f, 1f, 3f), new Vector3(-2f, 1f, 3f)); + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0f, 0f, 1.5f), Radius = 10f }, + }; + leaf.Polygons.Add(FlatRoof_FloorId); + leaf.Polygons.Add(FlatRoof_RoofId); + + return (leaf, resolved); + } + + // ========================================================================= + // Fixture 4 — Sloped unwalkable surface (60°) + // + // A flat reference floor plus an angled slope at ~60° from horizontal. + // normal.Z = cos(60°) ≈ 0.5 < PhysicsGlobals.FloorZ (0.6642). + // Expected: no contact plane set; mover slides off. + // ========================================================================= + + /// + /// Constructs a BSP tree and resolved-polygon dict representing a steep (60°) + /// slope whose normal.Z is below the walkable threshold. + /// + public static (PhysicsBSPNode Root, Dictionary Resolved) + SlopedUnwalkable() + { + var resolved = new Dictionary(); + + // Reference floor at z=0 + resolved[SlopedUnwalkable_FloorId] = MakeFloor( + new Vector3(-2f, -1f, 0f), new Vector3(0f, -1f, 0f), + new Vector3(0f, 1f, 0f), new Vector3(-2f, 1f, 0f)); + + // Steep slope: rises 2 m over 1 m horizontal run (63.4° from horizontal). + // Vertices: (0,-1,0), (1,-1,2), (1,1,2), (0,1,0) + // Normal direction: cross((1,0,2)-(0,0,0), (0,1,0)-(0,0,0)) ∝ (-2,0,1) normalised + // After normalisation: (-0.894, 0, 0.447) — normal.Z ≈ 0.447 < FloorZ. + // We point the normal outward (-X side) so it represents a wall-like slope. + var v0 = new Vector3(0f, -1f, 0f); + var v1 = new Vector3(1f, -1f, 2f); + var v2 = new Vector3(1f, 1f, 2f); + var v3 = new Vector3(0f, 1f, 0f); + var raw = Vector3.Cross(v1 - v0, v3 - v0); + var slopeNormal = Vector3.Normalize(raw); + // Ensure the normal faces away from the approach side (-X direction). + if (slopeNormal.X > 0) slopeNormal = -slopeNormal; + + var vertices = new[] { v0, v1, v2, v3 }; + float dotSum = 0f; + foreach (var v in vertices) dotSum += Vector3.Dot(slopeNormal, v); + float d = -(dotSum / vertices.Length); + + resolved[SlopedUnwalkable_SlopeId] = new ResolvedPolygon + { + Vertices = vertices, + Plane = new Plane(slopeNormal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = new Vector3(0.5f, 0f, 1f), Radius = 10f }, + }; + leaf.Polygons.Add(SlopedUnwalkable_FloorId); + leaf.Polygons.Add(SlopedUnwalkable_SlopeId); + + return (leaf, resolved); + } + + // ========================================================================= + // Transition builder helpers + // ========================================================================= + + /// + /// Build a for a grounded mover (Contact + OnWalkable set). + /// + /// + /// The mover's foot sphere starts at and is headed + /// toward . is + /// set to so the test can control which step + /// heights succeed. + /// + /// + public static Transition MakeGroundedTransition( + Vector3 from, + Vector3 to, + float stepUpHeight = 0.30f, + uint cellId = 0xA9B40001u) + { + var t = new Transition(); + t.SpherePath.InitPath(from, to, cellId, SphereRadius); + t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + t.ObjectInfo.StepUpHeight = stepUpHeight; + t.ObjectInfo.StepDownHeight = 0.04f; + t.ObjectInfo.StepDown = true; + // Seed LastKnownContactPlane so the mover is "on the floor". + t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + t.CollisionInfo.LastKnownContactPlaneValid = true; + return t; + } + + /// + /// Build a for an airborne mover (no Contact, no OnWalkable). + /// + /// + /// Represents a character that has just jumped or fallen and is now moving + /// downward to land on a surface. + /// + /// + public static Transition MakeAirborneTransition( + Vector3 from, + Vector3 to, + uint cellId = 0xA9B40001u) + { + var t = new Transition(); + t.SpherePath.InitPath(from, to, cellId, SphereRadius); + t.ObjectInfo.State = ObjectInfoState.None; + t.ObjectInfo.StepUpHeight = 0.04f; + t.ObjectInfo.StepDownHeight = 0.04f; + t.ObjectInfo.StepDown = false; + return t; + } + + // ========================================================================= + // Internal polygon builders + // ========================================================================= + + // Build a horizontal floor polygon (normal = +Z) from four CCW vertices + // (as viewed from above). + private static ResolvedPolygon MakeFloor( + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3) + { + var verts = new[] { v0, v1, v2, v3 }; + var normal = Vector3.UnitZ; + float dotSum = 0f; + foreach (var v in verts) dotSum += Vector3.Dot(normal, v); + float d = -(dotSum / verts.Length); + return new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + } + + // Build a quad polygon with a specified outward normal. + // Vertices should be ordered so that the cross-product of two edges aligns + // with expectedNormal; we explicitly override the computed plane so the test + // is deterministic regardless of winding order. + private static ResolvedPolygon MakeQuad( + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, + Vector3 expectedNormal) + { + var verts = new[] { v0, v1, v2, v3 }; + float dotSum = 0f; + foreach (var v in verts) dotSum += Vector3.Dot(expectedNormal, v); + float d = -(dotSum / verts.Length); + return new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(expectedNormal, d), + NumPoints = 4, + SidesType = CullMode.None, + }; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs new file mode 100644 index 0000000..2333e70 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in +/// . +/// +/// +/// Tests are organised in three groups corresponding to the three commits: +/// +/// +/// Group A — Baselines: behaviours that should pass both before +/// and after the implementation (no-hit returns OK, fixture geometry checks). +/// Group B — Phase L.2.1 (Path 5 step-up): tests that are RED +/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these +/// GREEN. +/// Group C — Phase L.2.2 (Path 6 SetCollide): tests that are RED +/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2 +/// flips these GREEN. +/// +/// +/// +/// Retail references: +/// BSPTREE::find_collisions Path 5 — acclient_2013_pseudo_c.txt:323849 / +/// ACE BSPTree.cs:192-196. +/// CTransition::step_up — acclient_2013_pseudo_c.txt:273099-273133 / +/// ACE Transition.cs:746-777. +/// BSPTREE::find_collisions Path 6 / SPHEREPATH::set_collide — +/// acclient_2013_pseudo_c.txt:323819 / ACE BSPTree.cs:210-219. +/// SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594-321607 / +/// ACE SpherePath.cs:279-286. +/// CTransition::transitional_insert Collide branch — +/// acclient_2013_pseudo_c.txt:273193-273239 / ACE Transition.cs:891-930. +/// +/// +public class BSPStepUpTests +{ + // ========================================================================= + // Group A — Baselines (pass before AND after the implementation) + // ========================================================================= + + /// + /// No BSP geometry → FindCollisions returns OK with no state changes. + /// + [Fact] + public void A1_NullRoot_ReturnsOK() + { + var from = new Vector3(0f, 0f, BSPStepUpFixtures.SphereRadius); + var to = new Vector3(0.1f, 0f, BSPStepUpFixtures.SphereRadius); + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = to, + Radius = BSPStepUpFixtures.SphereRadius, + }; + + var result = BSPQuery.FindCollisions( + null, + new Dictionary(), + t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.Equal(TransitionState.OK, result); + } + + /// + /// Grounded mover far from the wall → no collision → OK. + /// + [Fact] + public void A2_GroundedMover_NoWallNear_ReturnsOK() + { + var (root, resolved) = BSPStepUpFixtures.LowStep(); + + // Moving in -X, away from the wall at x=0.5. + var from = new Vector3(-1f, 0f, BSPStepUpFixtures.SphereRadius); + var to = new Vector3(-1.5f, 0f, BSPStepUpFixtures.SphereRadius); + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.Equal(TransitionState.OK, result); + } + + /// + /// Airborne mover well above the roof → no collision → OK. + /// + [Fact] + public void A3_AirborneMover_AboveRoof_ReturnsOK() + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + + // Mover at z=6 (well above the roof at z=3) with tiny downward step. + float highZ = 6f; + var from = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius); + var to = new Vector3(0f, 0f, highZ + BSPStepUpFixtures.SphereRadius - 0.01f); + var t = BSPStepUpFixtures.MakeAirborneTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = to, Radius = BSPStepUpFixtures.SphereRadius }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.Equal(TransitionState.OK, result); + } + + /// + /// The slope fixture's polygon must have normal.Z below FloorZ (confirms + /// the fixture geometry is set up correctly as a non-walkable surface). + /// + [Fact] + public void A4_SlopedFixture_NormalBelowFloorZ() + { + var (_, resolved) = BSPStepUpFixtures.SlopedUnwalkable(); + var slope = resolved[BSPStepUpFixtures.SlopedUnwalkable_SlopeId]; + + Assert.True(slope.Plane.Normal.Z < PhysicsGlobals.FloorZ, + $"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be < FloorZ ({PhysicsGlobals.FloorZ:F4})"); + Assert.True(slope.Plane.Normal.Z > 0f, + $"Slope normal.Z ({slope.Plane.Normal.Z:F4}) must be > 0 (upward-facing)"); + } + + /// + /// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable). + /// + [Fact] + public void A5_LowStepUpperFloor_NormalAboveFloorZ() + { + var (_, resolved) = BSPStepUpFixtures.LowStep(); + var upper = resolved[BSPStepUpFixtures.LowStep_UpperFloorId]; + + Assert.True(upper.Plane.Normal.Z >= PhysicsGlobals.FloorZ, + $"Upper floor normal.Z ({upper.Plane.Normal.Z:F4}) must be >= FloorZ ({PhysicsGlobals.FloorZ:F4})"); + } + + /// + /// Roof polygon has normal.Z >= LandingZ (it can be landed on). + /// + [Fact] + public void A6_FlatRoofPolygon_NormalAboveLandingZ() + { + var (_, resolved) = BSPStepUpFixtures.FlatRoof(); + var roof = resolved[BSPStepUpFixtures.FlatRoof_RoofId]; + + Assert.True(roof.Plane.Normal.Z >= PhysicsGlobals.LandingZ, + $"Roof normal.Z ({roof.Plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})"); + } + + // ========================================================================= + // Group B — Phase L.2.1 (Path 5 step-up) + // + // RED before L.2.1, GREEN after. + // Each test documents the CURRENT wrong behaviour and EXPECTED correct one. + // ========================================================================= + + /// + /// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm): + /// should step up onto the upper floor, not slide sideways. + /// + /// + /// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall; + /// Z stays at floor level. + /// + /// + /// Expected after L.2.1: Path 5 calls StepUp → DoStepDown finds upper floor + /// → sphere lifts to z ≥ 0.25 + SphereRadius and X advances past the wall. + /// + /// + /// Retail: BSPTREE::step_sphere_up / CTransition::step_up + /// acclient_2013_pseudo_c.txt:323849, 273099. + /// + [Fact] + public void B1_GroundedMover_LowStep_StepsUp() + { + var (root, resolved) = BSPStepUpFixtures.LowStep(); + const float stepUpHeight = 0.30f; // larger than step (0.25), so step-up succeeds + + float startZ = BSPStepUpFixtures.SphereRadius; + var from = new Vector3(0.1f, 0f, startZ); + var to = new Vector3(0.7f, 0f, startZ); // crosses the wall at x=0.5 + + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight); + var engine = MakeTestEngine(root, resolved); + + bool ok = t.FindTransitionalPosition(engine); + + // After step-up, the character's Z must be at or above the upper floor + radius. + float expectedMinZ = 0.25f + BSPStepUpFixtures.SphereRadius - PhysicsGlobals.EPSILON * 10f; + Assert.True(t.SpherePath.CurPos.Z >= expectedMinZ, + $"Expected Z >= {expectedMinZ:F4} (stepped up to upper floor at z=0.25), " + + $"got CurPos.Z = {t.SpherePath.CurPos.Z:F4}. " + + "Path 5 must call StepUp (L.2.1) instead of wall-sliding."); + } + + /// + /// Grounded mover walking into the too-tall wall (5 m) should NOT step up — + /// the wall is taller than StepUpHeight. + /// + /// + /// Expected: StepUp is called, DoStepDown finds no walkable surface within + /// 0.04 m (no upper floor exists), StepUpSlide applies → mover stays + /// left of the wall. + /// + /// + /// Retail: SPHEREPATH::step_up_slide + /// ACE SpherePath.cs:309-316. + /// + [Fact] + public void B2_GroundedMover_TallWall_BlockedOrSlides() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + const float stepUpHeight = 0.04f; // default — cannot scale 5 m wall + + float startZ = BSPStepUpFixtures.SphereRadius; + var from = new Vector3(0.1f, 0f, startZ); + var to = new Vector3(0.7f, 0f, startZ); + + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight); + var engine = MakeTestEngine(root, resolved); + + t.FindTransitionalPosition(engine); + + // The mover should NOT have crossed the wall at x=0.5. + float wallFace = 0.5f - BSPStepUpFixtures.SphereRadius; + Assert.True(t.SpherePath.CurPos.X <= wallFace + PhysicsGlobals.EPSILON * 20f, + $"Expected mover blocked before wall (x <= {wallFace:F3}), " + + $"got CurPos.X = {t.SpherePath.CurPos.X:F4}"); + } + + /// + /// Direct Path 5 invocation: Contact mover sphere just overlapping the low + /// wall should NOT return Slid after L.2.1. + /// + /// + /// Current: returns Slid (wall-slide). + /// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted. + /// + /// + [Fact] + public void B3_Path5_DirectCall_ContactHitsLowWall_NotSlid() + { + var (root, resolved) = BSPStepUpFixtures.LowStep(); + + // Sphere center overlaps the wall (x=0.5) by half-radius. + float r = BSPStepUpFixtures.SphereRadius; + var checkPos = new Vector3(0.5f - r * 0.5f, 0f, r); + var currPos = new Vector3(0.1f, 0f, r); + + var t = new Transition(); + t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r); + t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u); + t.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable; + t.ObjectInfo.StepUpHeight = 0.30f; + t.ObjectInfo.StepDownHeight = 0.04f; + t.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f); + t.CollisionInfo.LastKnownContactPlaneValid = true; + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r }; + + // NOTE: After L.2.1 this call gains an optional PhysicsEngine + // parameter. Until then, the step-up flag is set but DoStepDown + // cannot recurse (returns Slid). After L.2.1 result should be OK. + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + currPos, Vector3.UnitZ, 1.0f); + + // After L.2.1 this assertion flips from failing (Slid) to passing. + Assert.NotEqual(TransitionState.Slid, result); + } + + // ========================================================================= + // Group C — Phase L.2.2 (Path 6 SetCollide) + // + // RED before L.2.2, GREEN after. + // ========================================================================= + + /// + /// Airborne mover hitting the flat roof from above should set Collide flag + /// and return Adjusted (not Slid with wall-slide offset). + /// + /// + /// Current (wrong): Path 6 computes a wall-slide offset and returns Slid. + /// + /// + /// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets + /// WalkableAllowance = LandingZ, returns Adjusted. + /// + /// + /// Retail: SPHEREPATH::set_collide + /// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219. + /// + [Fact] + public void C1_Path6_AirborneMoverHitsRoof_SetsCollideFlagAndAdjusted() + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + + // Sphere center just penetrating the roof polygon (z=3) from above. + float r = BSPStepUpFixtures.SphereRadius; + var checkPos = new Vector3(0f, 0f, 3f + r * 0.5f); // half-radius above roof + var currPos = new Vector3(0f, 0f, 3f + r + 0.1f); // clearly above + + var t = new Transition(); + t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r); + t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u); + t.ObjectInfo.State = ObjectInfoState.None; // airborne — no Contact + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + currPos, Vector3.UnitZ, 1.0f); + + // After L.2.2: result = Adjusted, Collide = true, WalkableAllowance = LandingZ. + // Currently: result = Slid (wall-slide path). + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(t.SpherePath.Collide, + "Expected SpherePath.Collide = true after Path 6 hit (L.2.2)"); + Assert.Equal(PhysicsGlobals.LandingZ, t.SpherePath.WalkableAllowance, + precision: 5); + } + + /// + /// Full integration: airborne mover drops onto the 3 m flat roof. + /// + /// + /// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement, + /// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1. + /// + /// + /// Current: mover slides sideways off the roof (never lands). + /// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ. + /// + /// + [Fact] + public void C2_AirborneMover_LandsOnFlatRoof_ContactPlaneSet() + { + var (root, resolved) = BSPStepUpFixtures.FlatRoof(); + + float roofZ = 3f; + float r = BSPStepUpFixtures.SphereRadius; + var from = new Vector3(0f, 0f, roofZ + r + 0.1f); + var to = new Vector3(0f, 0f, roofZ + r - 0.05f); // sphere foot at z~3.0 + + var t = BSPStepUpFixtures.MakeAirborneTransition(from, to); + var engine = MakeTestEngine(root, resolved); + + t.FindTransitionalPosition(engine); + + // After L.2.2: at least one of ContactPlane / LastKnownContactPlane is set. + bool planeSet = t.CollisionInfo.ContactPlaneValid + || t.CollisionInfo.LastKnownContactPlaneValid; + + Assert.True(planeSet, + "Expected a contact plane after landing on roof (L.2.2). " + + "Currently Path 6 wall-slides and never sets ContactPlane."); + + if (planeSet) + { + var plane = t.CollisionInfo.ContactPlaneValid + ? t.CollisionInfo.ContactPlane + : t.CollisionInfo.LastKnownContactPlane; + + Assert.True(plane.Normal.Z >= PhysicsGlobals.LandingZ, + $"Contact plane normal.Z ({plane.Normal.Z:F4}) must be >= LandingZ ({PhysicsGlobals.LandingZ:F4})"); + } + } + + /// + /// Airborne mover descending toward a steep slope (normal.Z < FloorZ): + /// Path 6 should still set the Collide flag (it fires for any polygon hit, + /// walkable or not). + /// + /// Retail: set_collide fires unconditionally when sphere_intersects_poly + /// hits; the walkable check happens later in the Collide-flag handler. + /// + [Fact] + public void C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide() + { + var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable(); + + float r = BSPStepUpFixtures.SphereRadius; + // Approach the slope mid-face from above. + var checkPos = new Vector3(0.5f, 0f, 1.0f + r * 0.5f); + var currPos = new Vector3(0.5f, 0f, 1.0f + r + 0.1f); + + var t = new Transition(); + t.SpherePath.InitPath(currPos, checkPos, 0xA9B40001u, r); + t.SpherePath.SetCheckPos(checkPos, 0xA9B40001u); + t.ObjectInfo.State = ObjectInfoState.None; // airborne + + var localSphere = new DatReaderWriter.Types.Sphere { Origin = checkPos, Radius = r }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + currPos, Vector3.UnitZ, 1.0f); + + // After L.2.2: Collide flag set, Adjusted returned. + // Currently: Slid (wall-slide). + Assert.Equal(TransitionState.Adjusted, result); + Assert.True(t.SpherePath.Collide, + "Expected Collide flag set when airborne sphere hits slope (L.2.2)"); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /// + /// Build a that serves one synthetic BSP object + /// without any interfering terrain. The terrain is set 50 m underground + /// so it never fires during test geometry at z ≥ 0. + /// + private static PhysicsEngine MakeTestEngine( + PhysicsBSPNode root, + Dictionary resolved, + Vector3? objectPosition = null) + { + const uint LandblockId = 0xA9B4FFFFu; + const uint SyntheticGfxId = 0xDEADBEEFu; + + // Terrain 50 m underground so FindEnvCollisions never fires push-ups. + var heights = new byte[81]; // all zero → uses index 0 from heightTable + var heightTab = new float[256]; + for (int i = 0; i < 256; i++) heightTab[i] = -50f; + + var engine = new PhysicsEngine(); + engine.AddLandblock( + LandblockId, + new TerrainSurface(heights, heightTab), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + // Register the BSP physics into the data cache. + var cache = new PhysicsDataCache(); + var bspTree = new DatReaderWriter.Types.PhysicsBSPTree { Root = root }; + var physics = new GfxObjPhysics + { + BSP = bspTree, + PhysicsPolygons = new Dictionary(), + Vertices = new DatReaderWriter.Types.VertexArray(), + Resolved = resolved, + BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = Vector3.Zero, Radius = 15f }, + }; + cache.RegisterGfxObjForTest(SyntheticGfxId, physics); + engine.DataCache = cache; + + // Register the object in the shadow registry so FindObjCollisions picks it up. + Vector3 pos = objectPosition ?? Vector3.Zero; + engine.ShadowObjects.Register( + entityId: SyntheticGfxId, + gfxObjId: SyntheticGfxId, + worldPos: pos, + rotation: Quaternion.Identity, + radius: 15f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + scale: 1.0f); + + return engine; + } +}