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;
+ }
+}