test(physics): conformance fixtures for BSP step-up + roof-landing (Phase L.2.0)

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-29 15:44:16 +02:00
parent 7d6fe90607
commit b0c29454d0
3 changed files with 846 additions and 0 deletions

View file

@ -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;
/// <summary>
/// Synthetic BSP tree fixtures for step-up and roof-landing conformance tests.
///
/// <para>
/// These fixtures construct minimal <see cref="PhysicsBSPNode"/> trees plus
/// matching <see cref="ResolvedPolygon"/> dictionaries that represent canonical
/// AC collision shapes without needing real DAT content. The shapes cover every
/// interesting branch in <see cref="BSPQuery.FindCollisions"/> Path 5 and Path 6.
/// </para>
///
/// <para>
/// 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
/// <c>localSphere.Origin == worldPosition</c>.
/// </para>
///
/// <para>
/// 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.
/// </para>
/// </summary>
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.
// =========================================================================
/// <summary>
/// Constructs a BSP tree and resolved-polygon dict representing a 25 cm step.
///
/// <para>Geometry (object-local space):</para>
/// <list type="bullet">
/// <item>Floor polygon at z = 0, x ∈ [-2, 0.5], y ∈ [-1, 1].</item>
/// <item>Vertical wall polygon at x = 0.5, z ∈ [0, 0.25], y ∈ [-1, 1], facing -X.</item>
/// <item>Upper floor polygon at z = 0.25, x ∈ [0.5, 2], y ∈ [-1, 1].</item>
/// </list>
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
LowStep()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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.
// =========================================================================
/// <summary>
/// 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.
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
TallWall()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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.
// =========================================================================
/// <summary>
/// Constructs a BSP tree and resolved-polygon dict representing a 3 m flat roof.
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
FlatRoof()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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.
// =========================================================================
/// <summary>
/// Constructs a BSP tree and resolved-polygon dict representing a steep (60°)
/// slope whose normal.Z is below the walkable threshold.
/// </summary>
public static (PhysicsBSPNode Root, Dictionary<ushort, ResolvedPolygon> Resolved)
SlopedUnwalkable()
{
var resolved = new Dictionary<ushort, ResolvedPolygon>();
// 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
// =========================================================================
/// <summary>
/// Build a <see cref="Transition"/> for a grounded mover (Contact + OnWalkable set).
///
/// <para>
/// The mover's foot sphere starts at <paramref name="from"/> and is headed
/// toward <paramref name="to"/>. <see cref="ObjectInfo.StepUpHeight"/> is
/// set to <paramref name="stepUpHeight"/> so the test can control which step
/// heights succeed.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Build a <see cref="Transition"/> for an airborne mover (no Contact, no OnWalkable).
///
/// <para>
/// Represents a character that has just jumped or fallen and is now moving
/// downward to land on a surface.
/// </para>
/// </summary>
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,
};
}
}

View file

@ -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;
/// <summary>
/// Conformance tests for BSP step-up (Path 5) and rooftop landing (Path 6) in
/// <see cref="BSPQuery.FindCollisions"/>.
///
/// <para>
/// Tests are organised in three groups corresponding to the three commits:
/// </para>
/// <list type="bullet">
/// <item><b>Group A — Baselines</b>: behaviours that should pass both before
/// and after the implementation (no-hit returns OK, fixture geometry checks).</item>
/// <item><b>Group B — Phase L.2.1 (Path 5 step-up)</b>: tests that are RED
/// because Path 5 wall-slides instead of stepping up. L.2.1 flips these
/// GREEN.</item>
/// <item><b>Group C — Phase L.2.2 (Path 6 SetCollide)</b>: tests that are RED
/// because Path 6 wall-slides instead of setting the Collide flag. L.2.2
/// flips these GREEN.</item>
/// </list>
///
/// <para>
/// 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.
/// </para>
/// </summary>
public class BSPStepUpTests
{
// =========================================================================
// Group A — Baselines (pass before AND after the implementation)
// =========================================================================
/// <summary>
/// No BSP geometry → FindCollisions returns OK with no state changes.
/// </summary>
[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<ushort, ResolvedPolygon>(),
t, localSphere, null,
from, Vector3.UnitZ, 1.0f);
Assert.Equal(TransitionState.OK, result);
}
/// <summary>
/// Grounded mover far from the wall → no collision → OK.
/// </summary>
[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);
}
/// <summary>
/// Airborne mover well above the roof → no collision → OK.
/// </summary>
[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);
}
/// <summary>
/// The slope fixture's polygon must have normal.Z below FloorZ (confirms
/// the fixture geometry is set up correctly as a non-walkable surface).
/// </summary>
[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)");
}
/// <summary>
/// Low-step upper-floor polygon has normal.Z >= FloorZ (it IS walkable).
/// </summary>
[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})");
}
/// <summary>
/// Roof polygon has normal.Z >= LandingZ (it can be landed on).
/// </summary>
[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.
// =========================================================================
/// <summary>
/// Grounded mover (Contact + OnWalkable) walking toward the low step (25 cm):
/// should step up onto the upper floor, not slide sideways.
///
/// <para>
/// Current (wrong): Path 5 applies wall-slide → CurPos.X stays left of wall;
/// Z stays at floor level.
/// </para>
/// <para>
/// 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.
/// </para>
///
/// <para>Retail: BSPTREE::step_sphere_up / CTransition::step_up
/// acclient_2013_pseudo_c.txt:323849, 273099.</para>
/// </summary>
[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.");
}
/// <summary>
/// Grounded mover walking into the too-tall wall (5 m) should NOT step up —
/// the wall is taller than StepUpHeight.
///
/// <para>
/// 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.
/// </para>
///
/// <para>Retail: SPHEREPATH::step_up_slide
/// ACE SpherePath.cs:309-316.</para>
/// </summary>
[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}");
}
/// <summary>
/// Direct Path 5 invocation: Contact mover sphere just overlapping the low
/// wall should NOT return Slid after L.2.1.
///
/// <para>
/// Current: returns Slid (wall-slide).
/// Expected after L.2.1: returns OK (step-up succeeded) with Z lifted.
/// </para>
/// </summary>
[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.
// =========================================================================
/// <summary>
/// Airborne mover hitting the flat roof from above should set Collide flag
/// and return Adjusted (not Slid with wall-slide offset).
///
/// <para>
/// Current (wrong): Path 6 computes a wall-slide offset and returns Slid.
/// </para>
/// <para>
/// Expected after L.2.2: Path 6 calls path.SetCollide(worldNormal), sets
/// WalkableAllowance = LandingZ, returns Adjusted.
/// </para>
///
/// <para>Retail: SPHEREPATH::set_collide
/// acclient_2013_pseudo_c.txt:321594 / ACE BSPTree.cs:210-219.</para>
/// </summary>
[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);
}
/// <summary>
/// Full integration: airborne mover drops onto the 3 m flat roof.
///
/// <para>
/// After L.2.2: TransitionalInsert sees Collide flag, re-tests as Placement,
/// finds walkable polygon at z=3, sets ContactPlane with normal.Z ≈ 1.
/// </para>
/// <para>
/// Current: mover slides sideways off the roof (never lands).
/// Expected after L.2.2: ContactPlane is set with Normal.Z >= LandingZ.
/// </para>
/// </summary>
[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})");
}
}
/// <summary>
/// Airborne mover descending toward a steep slope (normal.Z &lt; FloorZ):
/// Path 6 should still set the Collide flag (it fires for any polygon hit,
/// walkable or not).
///
/// <para>Retail: set_collide fires unconditionally when sphere_intersects_poly
/// hits; the walkable check happens later in the Collide-flag handler.</para>
/// </summary>
[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
// =========================================================================
/// <summary>
/// Build a <see cref="PhysicsEngine"/> 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.
/// </summary>
private static PhysicsEngine MakeTestEngine(
PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> 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<CellSurface>(),
Array.Empty<PortalPlane>(),
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<ushort, DatReaderWriter.Types.Polygon>(),
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;
}
}