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

@ -1085,34 +1085,28 @@ public static class BSPQuery
/// BSPTree.step_sphere_up — attempt to step over a low obstacle.
///
/// <para>
/// Sets the StepUp flag on SpherePath with the collision normal.
/// The Transition's outer loop will pick this up and attempt the step.
/// If StepUp is already pending, falls back to setting the collision normal
/// directly (StepUpSlide equivalent).
/// Calls <see cref="Transition.DoStepUp"/> which probes upward then steps
/// down to find a walkable landing surface. If the step-up succeeds the
/// sphere's CheckPos is already updated and we return OK. If it fails we
/// fall back to StepUpSlide: clear the contact plane and slide along the
/// collision normal.
/// </para>
///
/// <para>ACE: BSPTree.cs step_sphere_up.</para>
/// <para>
/// ACE: BSPTree.step_sphere_up calls transition.StepUp(globNormal);
/// on false → SpherePath.StepUpSlide(transition).
/// Named-retail: BSPTREE::step_sphere_up.
/// </para>
/// </summary>
private static TransitionState StepSphereUp(
Transition transition,
Vector3 collisionNormal)
Transition transition,
Vector3 collisionNormal,
PhysicsEngine engine)
{
var path = transition.SpherePath;
var ci = transition.CollisionInfo;
// ACE calls transition.StepUp(globNormal); if false -> path.StepUpSlide(transition).
// In acdream, StepUp is a flag field on SpherePath.
// If no StepUp is pending yet, request one.
if (!path.StepUp)
{
path.StepUp = true;
path.StepUpNormal = collisionNormal;
if (transition.DoStepUp(collisionNormal, engine!))
return TransitionState.OK;
}
// StepUpSlide: can't step up, set collision normal and report adjusted.
ci.SetCollisionNormal(collisionNormal);
return TransitionState.Adjusted;
return transition.SpherePath.StepUpSlide(transition.CollisionInfo);
}
// -------------------------------------------------------------------------
@ -1364,7 +1358,8 @@ public static class BSPQuery
Vector3 localCurrCenter,
Vector3 localSpaceZ,
float scale,
Quaternion localToWorld = default)
Quaternion localToWorld = default,
PhysicsEngine? engine = null)
{
if (root is null) return TransitionState.OK;
// Default quaternion (0,0,0,0) → treat as identity
@ -1453,12 +1448,15 @@ public static class BSPQuery
}
// ----------------------------------------------------------------
// Path 5: Contact — sphere_intersects_poly + wall-slide
// ACE retail uses StepSphereUp here, deferring to a retry loop that
// executes the step-up motion. We haven't ported that execution, so
// we apply the same wall-slide response as Path 6 — this at least
// gives correct blocking + sliding behavior for walls, buildings,
// and tree trunks while the player is on the ground.
// Path 5: Contact (grounded) — sphere_intersects_poly + step_sphere_up
//
// A grounded mover hits a polygon. Retail calls BSPTREE::step_sphere_up,
// which runs CTransition::step_up (upward probe + step-down scan). If the
// obstacle is short enough the sphere climbs it; if too tall, it falls back
// to StepUpSlide (clear contact-plane, slide along StepUpNormal).
//
// ACE: BSPTree.find_collisions → step_sphere_up (BSPTree.cs, path 5 branch).
// Named-retail: BSPTREE::find_collisions Contact branch → step_sphere_up.
// ----------------------------------------------------------------
if (obj.State.HasFlag(ObjectInfoState.Contact))
{
@ -1470,26 +1468,12 @@ public static class BSPQuery
if (hit0 || hitPoly0 is not null)
{
// Wall-slide response (same as Path 6 below).
var localNormal = hitPoly0!.Plane.Normal;
var localMovement = sphere0.Center - localCurrCenter;
var worldNormal = L2W(hitPoly0!.Plane.Normal);
if (engine is not null)
return StepSphereUp(transition, worldNormal, engine);
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
Vector3 slidPos = localCurrCenter + projectedMovement;
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
float minDist = sphere0.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
Vector3 localDelta = slidPos - sphere0.Center;
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
// No engine available (env-cell path without engine param) —
// fall back to wall-slide so existing indoor geometry still blocks.
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
@ -1505,25 +1489,10 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
var localNormal = hitPoly1!.Plane.Normal;
var localMovement = sphere1.Center - localCurrCenter;
var worldNormal = L2W(hitPoly1!.Plane.Normal);
if (engine is not null)
return StepSphereUp(transition, worldNormal, engine);
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
Vector3 slidPos = localCurrCenter + projectedMovement;
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
float minDist = sphere1.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
Vector3 localDelta = slidPos - sphere1.Center;
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
@ -1553,50 +1522,19 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
// ─── Wall-slide response ─────────────────────────────────
// Instead of just pushing the sphere out of penetration
// (which undoes the whole step), compute the wall-slide
// position: where the sphere WOULD be if the movement had
// been projected along the wall tangent.
// ─── SetCollide response ─────────────────────────────────
// Airborne sphere hits a polygon. Per retail, call SetCollide
// which saves backup position, records StepUpNormal = worldNormal,
// and sets WalkInterp=1. TransitionalInsert's Collide branch will
// then re-test as Placement to confirm we can land on the surface.
//
// In local space:
// curr = localCurrCenter
// target = sphere0.Center
// movement = target - curr
// normal = polygon plane normal (outward)
// projectedMovement = movement - (movement · normal) * normal
// slidPos = curr + projectedMovement
//
// Then ensure slidPos is outside the plane by at least radius+eps.
var localNormal = hitPoly0!.Plane.Normal;
var localMovement = sphere0.Center - localCurrCenter;
// Project movement along wall tangent
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
// Slid position in local space
Vector3 slidPos = localCurrCenter + projectedMovement;
// Ensure slid position is OUTSIDE the plane by radius + epsilon
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
float minDist = sphere0.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
// Delta from current CheckPos sphere center to slid position (local)
Vector3 localDelta = slidPos - sphere0.Center;
// Transform to world and apply
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
// ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide
// + return Adjusted.
// Named-retail: BSPTREE::find_collisions airborne branch → set_collide.
var worldNormal0 = L2W(hitPoly0!.Plane.Normal);
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
return TransitionState.Adjusted;
}
if (sphere1 is not null)
@ -1609,29 +1547,11 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
// Head sphere hit: apply the same wall-slide as above.
var localNormal = hitPoly1!.Plane.Normal;
var localMovement = sphere1.Center - localCurrCenter;
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
Vector3 slidPos = localCurrCenter + projectedMovement;
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
float minDist = sphere1.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
Vector3 localDelta = slidPos - sphere1.Center;
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
// Head sphere hit: same SetCollide response.
var worldNormal1 = L2W(hitPoly1!.Plane.Normal);
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
return TransitionState.Adjusted;
}
}
}

View file

@ -64,6 +64,27 @@ public sealed class ObjectInfo
public bool EdgeSlide => State.HasFlag(ObjectInfoState.EdgeSlide);
public bool PathClipped => State.HasFlag(ObjectInfoState.PathClipped);
public bool FreeRotate => State.HasFlag(ObjectInfoState.FreeRotate);
/// <summary>
/// Return the Z threshold for a walkable surface appropriate to the
/// current movement context.
///
/// <para>
/// Retail: OBJECTINFO::get_walkable_z — returns FloorZ when the mover
/// is on a walkable surface (Contact+OnWalkable), LandingZ otherwise.
/// ACE: ObjectInfo.GetWalkableZ (Transition.cs:760).
/// </para>
/// </summary>
public float GetWalkableZ()
=> OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
/// <summary>
/// Stop any accumulated velocity on this object info.
/// ACE: ObjectInfo.StopVelocity — clears Velocity on the physics body.
/// acdream: velocity is tracked on PhysicsBody, not here. No-op for now;
/// will be wired when velocity is threaded through TransitionalInsert.
/// </summary>
public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
}
/// <summary>
@ -210,6 +231,34 @@ public sealed class SpherePath
SetCheckPos(BackupCheckPos, BackupCheckCellId);
}
/// <summary>
/// Called when an airborne sphere hits a polygon but the polygon is not yet
/// walkable — save backup, record the collision normal in StepUpNormal, and
/// flag Collide so TransitionalInsert can re-test as Placement.
/// ACE: SpherePath.SetCollide (acclient_2013_pseudo_c.txt ~321594, ACE SpherePath.cs:279-286).
/// </summary>
public void SetCollide(Vector3 collisionNormal)
{
Collide = true;
BackupCheckPos = CheckPos;
BackupCheckCellId = CheckCellId;
StepUpNormal = collisionNormal;
WalkInterp = 1.0f;
}
/// <summary>
/// Slide fallback when step-up fails. Clears the contact-plane state that
/// caused the step-up attempt and issues a slide along StepUpNormal.
/// ACE: SpherePath.StepUpSlide (ACE SpherePath.cs:309-317).
/// </summary>
public TransitionState StepUpSlide(CollisionInfo collisions)
{
collisions.ContactPlaneValid = false;
collisions.ContactPlaneIsWater = false;
collisions.SetSlidingNormal(StepUpNormal);
return TransitionState.Slid;
}
/// <summary>
/// Initialize the path for a simple point-to-point movement.
/// </summary>
@ -491,11 +540,57 @@ public sealed class Transition
// ── Phase 3: both env and objects returned OK ──────────────
// Handle Collide flag (BSP path 6 set it on a non-contact hit).
// ACE: if Collide is set, re-test as Placement to confirm position.
// Simplified: just clear it and accept.
// ACE: Transition.TransitionalInsert Collide branch (Transition.cs:891-930).
// Named-retail: CTransition::transitional_insert Collide branch.
if (sp.Collide)
{
sp.Collide = false;
bool reset = false;
if (ci.ContactPlaneValid && DoCheckWalkable(PhysicsGlobals.LandingZ, engine))
{
// CheckPos is walkable — re-test as Placement to snap/validate.
var savedInsert = sp.InsertType;
sp.InsertType = InsertType.Placement;
var placeState = TransitionalInsert(numAttempts, engine);
sp.InsertType = savedInsert;
if (placeState != TransitionState.OK)
{
// Placement rejected — fall through to restore.
placeState = TransitionState.OK;
reset = true;
}
else if (!reset)
{
// Placement accepted — return current state.
sp.WalkableValid = false;
return placeState;
}
}
else
reset = true;
sp.WalkableValid = false;
if (reset)
{
sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
if (ci.LastKnownContactPlaneValid)
{
ci.LastKnownContactPlaneValid = false;
oi.StopVelocity();
}
else
ci.SetCollisionNormal(sp.StepUpNormal);
return TransitionState.Collided;
}
}
// Handle neg-poly hit (backward-facing polygon contact).
@ -614,7 +709,9 @@ public sealed class Transition
localSphere1,
localCurrCenter,
Vector3.UnitZ, // local space Z is up
1.0f); // scale = 1.0 for cell geometry
1.0f, // scale = 1.0 for cell geometry
Quaternion.Identity,
engine); // engine needed for Path 5 step-up
if (cellState != TransitionState.OK)
{
@ -744,11 +841,6 @@ public sealed class Transition
// Object collision — static BSP objects
// -----------------------------------------------------------------------
// Reused per-call to avoid per-step allocation; safe because Transition
// is single-threaded per movement resolve.
private readonly List<ShadowEntry> _nearbyObjs = new();
private static int _debugQueryCount = 0;
/// <summary>
/// Query the ShadowObjectRegistry for nearby static objects and run
/// collision against each using the retail BSPTree.find_collisions 6-path
@ -778,23 +870,17 @@ public sealed class Transition
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK;
// Use a local list: DoStepUp calls TransitionalInsert → FindObjCollisions
// recursively, so reusing a single field list would corrupt the outer
// iteration. Allocate per call (cheap — typically 0-5 entries).
var nearbyObjs = new List<ShadowEntry>();
float queryRadius = sphereRadius + movement.Length() + 5f;
engine.ShadowObjects.GetNearbyObjects(
currPos, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
_nearbyObjs);
nearbyObjs);
// Log every 120 frames — tracks player position over time.
_debugQueryCount++;
if (movement.LengthSquared() > 0.0001f && _debugQueryCount % 120 == 0)
{
Console.WriteLine(
$"ObjColl @({currPos.X:F1},{currPos.Y:F1},{currPos.Z:F1}) " +
$"lb=0x{landblockId:X8} nearby={_nearbyObjs.Count}/{engine.ShadowObjects.TotalRegistered}");
}
foreach (var obj in _nearbyObjs)
foreach (var obj in nearbyObjs)
{
// Broad-phase: can the moving sphere reach this object?
Vector3 deltaToCurr = currPos - obj.Position;
@ -868,7 +954,8 @@ public sealed class Transition
localCurrCenter,
localSpaceZ,
obj.Scale, // scale for local→world offsets
obj.Rotation); // local→world rotation
obj.Rotation, // local→world rotation
engine); // engine needed for Path 5 step-up
}
else
{
@ -1218,16 +1305,145 @@ public sealed class Transition
// 1. Collision detection returned OK
// 2. A valid contact plane was found
// 3. The contact plane is walkable (Normal.Z >= walkableZ)
//
// ACE StepDown then runs a Placement insertion to confirm the sphere
// can actually be placed at the candidate position — it must not be
// inside any solid geometry (wall, BSP object, etc.).
// Named-retail: CTransition::step_down, acclient_2013_pseudo_c.txt:273069.
// ACE: Transition.cs:731-741.
if (transitState == TransitionState.OK
&& CollisionInfo.ContactPlaneValid
&& CollisionInfo.ContactPlane.Normal.Z >= walkableZ)
{
return true;
// Placement validation: can we actually stand here?
var savedInsert = sp.InsertType;
sp.InsertType = InsertType.Placement;
var placeState = TransitionalInsert(1, engine);
sp.InsertType = savedInsert;
return placeState == TransitionState.OK;
}
return false;
}
// -----------------------------------------------------------------------
// Step-up
// -----------------------------------------------------------------------
/// <summary>
/// Attempt to step over a low obstacle by probing upward then stepping down.
///
/// <para>
/// Retail flow (CTransition::step_up, named-retail ~273099):
/// 1. Clear ContactPlane so the step-down probe is unbiased.
/// 2. Set StepUp flag so DoStepDown skips the downward offset (we start
/// from the sphere's current position and scan down from there).
/// 3. Pick stepDownHeight / walkable-Z from ObjectInfo (if OnWalkable,
/// use StepUpHeight + FloorZ; else 0.04 + LandingZ).
/// 4. Save backup, run DoStepDown, then clear StepUp.
/// 5. Return true on success; the caller commits the new CheckPos.
/// On failure, RestoreCheckPos and return false.
/// </para>
///
/// ACE: Transition.StepUp (Transition.cs:746-777).
/// Named-retail: CTransition::step_up (~273099-273133).
/// </summary>
internal bool DoStepUp(Vector3 collisionNormal, PhysicsEngine engine)
{
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.StepUp = true;
sp.StepUpNormal = collisionNormal;
// Default values (not on walkable): small step, LandingZ threshold.
float stepDownHeight = 0.04f;
float zLandingValue = PhysicsGlobals.LandingZ;
if (oi.State.HasFlag(ObjectInfoState.OnWalkable))
{
zLandingValue = oi.GetWalkableZ();
stepDownHeight = oi.StepUpHeight;
}
sp.WalkableAllowance = zLandingValue;
sp.SaveCheckPos();
bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine);
sp.StepUp = false;
sp.WalkableValid = false;
if (!stepDown)
sp.RestoreCheckPos();
return stepDown;
}
// -----------------------------------------------------------------------
// Walkable check
// -----------------------------------------------------------------------
/// <summary>
/// Probe downward by StepDownHeight to confirm a walkable surface is within
/// reach of the current CheckPos — used by the Collide branch in
/// TransitionalInsert before re-testing as Placement.
///
/// <para>
/// Returns true if a walkable surface was found within reach (i.e. the
/// sphere can land here). Returns false if:
/// - ObjectInfo.OnWalkable is NOT set (always walkable by convention).
/// - CheckWalkables() already confirmed a walkable (skip the probe).
/// - The downward probe returned OK (meaning: no walkable was found
/// within reach, so we CANNOT land → transitState == OK → return false).
/// </para>
///
/// ACE: Transition.CheckWalkable (Transition.cs:206-235).
/// Named-retail: CTransition::check_walkable.
/// </summary>
internal bool DoCheckWalkable(float zCheck, PhysicsEngine engine)
{
var sp = SpherePath;
var oi = ObjectInfo;
if (!oi.State.HasFlag(ObjectInfoState.OnWalkable))
return true;
// If the current walkable entry is still valid, skip the probe.
if (sp.WalkableValid)
return true;
sp.SaveCheckPos();
float stepHeight = oi.StepDownHeight;
var globSphere = sp.GlobalSphere[0];
if (sp.NumSphere < 2 && stepHeight > globSphere.Radius * 2f)
stepHeight = globSphere.Radius * 0.5f;
if (stepHeight > globSphere.Radius * 2f)
stepHeight *= 0.5f;
sp.WalkableAllowance = zCheck;
sp.CheckWalkable = true;
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -stepHeight));
var transitState = TransitionalInsert(1, engine);
sp.CheckWalkable = false;
sp.RestoreCheckPos();
// ACE returns (transitState != OK) — i.e. true when we DID find a
// walkable (collision probe returned Adjusted/Collided).
return transitState != TransitionState.OK;
}
// -----------------------------------------------------------------------
// Post-step validation
// -----------------------------------------------------------------------