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>
This commit is contained in:
Erik 2026-04-29 16:16:39 +02:00
parent b0c29454d0
commit 670f892bd3
4 changed files with 341 additions and 179 deletions

View file

@ -186,17 +186,27 @@ public class BSPStepUpTests
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
// CurPos (foot position) starts at z=0 (on the terrain / BSP floor at z=0).
// The sphere center is at CurPos + (0, 0, SphereRadius) = (x, 0, 0.2).
// lowPoint = sphere_center - (0,0,r) = (x, 0, 0) → on terrain → contact.
var from = new Vector3(0.1f, 0f, 0f);
// to.X = 0.6 → offset = (0.5, 0, 0), 3 sub-steps of 0.1667 each.
// Step 2: CurPos ≈ (0.433, 0, 0), sphere center x ≈ 0.433.
// Wall: dist = 0.5 - 0.433 = 0.067 < rad = 0.198 → HIT Path 5 ✓
var to = new Vector3(0.6f, 0f, 0f); // foot stays at z=0, crosses wall at x=0.5
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
var engine = MakeTestEngine(root, resolved);
// terrainZ=0f: terrain at z=0 keeps the step-down probe grounded between
// steps, preserving Contact/OnWalkable across the sub-step boundary.
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
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;
// After step-up, the character's foot (CurPos.Z) must be at or above the
// upper floor (z=0.25). CurPos stores the foot origin; the sphere center is
// CurPos.Z + SphereRadius. The lower bound is the upper-floor Z minus a
// small epsilon to tolerate floating-point rounding in AdjustSphereToPlane.
float expectedMinZ = 0.25f - 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}. " +
@ -222,12 +232,13 @@ public class BSPStepUpTests
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);
// Foot at z=0 (on terrain). Same reasoning as B1.
var from = new Vector3(0.1f, 0f, 0f);
var to = new Vector3(0.6f, 0f, 0f);
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to, stepUpHeight);
var engine = MakeTestEngine(root, resolved);
// terrainZ=0f: keep grounded between steps (same as B1).
var engine = MakeTestEngine(root, resolved, terrainZ: 0f);
t.FindTransitionalPosition(engine);
@ -268,12 +279,13 @@ public class BSPStepUpTests
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.
// Pass engine so Path 5 can call DoStepUp → DoStepDown (L.2.1).
// Without engine the fallback wall-slide would return Slid.
var engine = MakeTestEngine(root, resolved);
var result = BSPQuery.FindCollisions(
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f);
currPos, Vector3.UnitZ, 1.0f, Quaternion.Identity, engine);
// After L.2.1 this assertion flips from failing (Slid) to passing.
Assert.NotEqual(TransitionState.Slid, result);
@ -349,11 +361,17 @@ public class BSPStepUpTests
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
// CurPos = foot position. Sphere center = CurPos + (0,0,r).
// from: foot at z = roofZ - r + 0.3f → sphere center at roofZ + 0.3 = 3.3 (above roof)
// to: foot at z = roofZ - r - 0.05f → sphere center at roofZ - 0.05 = 2.95 (into roof by 0.05)
// Roof polygon at z=roofZ, normal=+Z: dist = sphere_center.z - roofZ.
// At to: dist = -0.05; |dist| = 0.05 < rad=0.198 → roof hit ✓
var from = new Vector3(0f, 0f, roofZ - r + 0.3f);
var to = new Vector3(0f, 0f, roofZ - r - 0.05f); // sphere bottom at z ≈ 2.95 (into roof)
var t = BSPStepUpFixtures.MakeAirborneTransition(from, to);
var engine = MakeTestEngine(root, resolved);
// terrainZ=-50f: airborne mover — terrain must not interfere with roof landing.
var engine = MakeTestEngine(root, resolved, terrainZ: -50f);
t.FindTransitionalPosition(engine);
@ -417,22 +435,24 @@ public class BSPStepUpTests
// =========================================================================
/// <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.
/// Build a <see cref="PhysicsEngine"/> that serves one synthetic BSP object.
/// <paramref name="terrainZ"/> sets every terrain sample to the given height.
/// Use 0f for grounded tests (terrain flush with the BSP floor at z=0, so the
/// step-down probe finds ground and keeps Contact/OnWalkable set between steps).
/// Use -50f for tests where terrain must never interfere (airborne / roof landing).
/// </summary>
private static PhysicsEngine MakeTestEngine(
PhysicsBSPNode root,
Dictionary<ushort, ResolvedPolygon> resolved,
Vector3? objectPosition = null)
Vector3? objectPosition = null,
float terrainZ = 0f)
{
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;
for (int i = 0; i < 256; i++) heightTab[i] = terrainZ;
var engine = new PhysicsEngine();
engine.AddLandblock(