acdream/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
Erik 670f892bd3 feat(physics): Phase L.2.1+L.2.2 — BSP step-up and rooftop landing
Port CTransition::step_up (Path 5) and SPHEREPATH::set_collide (Path 6)
from the retail decomp, turning wall-slides into proper step-up climbs
and airborne-to-roof landings.

Path 5 (grounded mover hits polygon):
- StepSphereUp calls DoStepUp which runs DoStepDown with StepUp=true
- DoStepDown now includes the retail Placement validation step
  (ACE Transition.cs:731-741) — sphere must not be inside solid geometry
  after finding a contact plane; this correctly blocks the tall-wall case
- FindObjCollisions now allocates a local ShadowEntry list per call to
  prevent "collection modified" exceptions when DoStepUp recurses back
  through TransitionalInsert → FindObjCollisions
- BSPQuery.FindCollisions passes engine through to StepSphereUp

Path 6 (airborne mover hits polygon):
- SpherePath.SetCollide: saves backup pos, records StepUpNormal, sets
  WalkInterp=1 — then returns Adjusted so TransitionalInsert retries
- SpherePath.StepUpSlide: clears ContactPlane, sets SlidingNormal for
  the tall-wall fallback
- TransitionalInsert Collide branch: re-tests as Placement when
  ContactPlaneValid; on failure restores backup and returns Collided

Test fixes (BSPStepUpTests.cs + BSPStepUpFixtures.cs):
- Tests use foot-position convention (CurPos = foot, sphere center =
  CurPos + (0,0,r)); from/to corrected from sphere-center to foot coords
- MakeTestEngine terrainZ param: 0f for grounded tests (keeps Contact
  state between sub-steps), -50f for airborne/roof tests
- to.X adjusted so sub-steps land sphere inside (not exactly touching)
  the wall, avoiding the EPSILON-shrink false-negative edge case
- All 12 BSPStepUp tests now GREEN; full suite 823/823

Retail refs:
  CTransition::step_up — acclient_2013_pseudo_c.txt:273099 / ACE:746
  CTransition::step_down — acclient_2013_pseudo_c.txt:273069 / ACE:710
  SPHEREPATH::set_collide — acclient_2013_pseudo_c.txt:321594 / ACE:279
  CTransition::transitional_insert Collide — pseudo_c:273193 / ACE:891

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:16:39 +02:00

369 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.2, 2], y ∈ [-1, 1] — extends
/// left of the wall face so the vertical step-down probe finds it when the
/// sphere is at x ≈ 0.30.5 (the wall contact zone).</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.2,2], y∈[-1,1], normal = +Z.
// The upper floor extends slightly left of the wall face (x=0.5)
// so the step-down probe (vertical, from the wall-contact XY) can
// find it when the sphere is at x≈0.3-0.5. Retail BSPs have the
// same overlap because geometry is continuous across the step.
resolved[LowStep_UpperFloorId] = MakeFloor(
new Vector3(0.2f, -1f, 0.25f), new Vector3(2f, -1f, 0.25f),
new Vector3(2f, 1f, 0.25f), new Vector3(0.2f, 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,
};
}
}