diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index dfef08b..2895b20 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -1085,34 +1085,28 @@ public static class BSPQuery
/// BSPTree.step_sphere_up — attempt to step over a low obstacle.
///
///
- /// 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 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.
///
///
- /// ACE: BSPTree.cs step_sphere_up.
+ ///
+ /// ACE: BSPTree.step_sphere_up calls transition.StepUp(globNormal);
+ /// on false → SpherePath.StepUpSlide(transition).
+ /// Named-retail: BSPTREE::step_sphere_up.
+ ///
///
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;
}
}
}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 57d6e6d..e4e784c 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -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);
+
+ ///
+ /// Return the Z threshold for a walkable surface appropriate to the
+ /// current movement context.
+ ///
+ ///
+ /// 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).
+ ///
+ ///
+ public float GetWalkableZ()
+ => OnWalkable ? PhysicsGlobals.FloorZ : PhysicsGlobals.LandingZ;
+
+ ///
+ /// 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.
+ ///
+ public void StopVelocity() { /* velocity lives on PhysicsBody, not here */ }
}
///
@@ -210,6 +231,34 @@ public sealed class SpherePath
SetCheckPos(BackupCheckPos, BackupCheckCellId);
}
+ ///
+ /// 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).
+ ///
+ public void SetCollide(Vector3 collisionNormal)
+ {
+ Collide = true;
+ BackupCheckPos = CheckPos;
+ BackupCheckCellId = CheckCellId;
+ StepUpNormal = collisionNormal;
+ WalkInterp = 1.0f;
+ }
+
+ ///
+ /// 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).
+ ///
+ public TransitionState StepUpSlide(CollisionInfo collisions)
+ {
+ collisions.ContactPlaneValid = false;
+ collisions.ContactPlaneIsWater = false;
+ collisions.SetSlidingNormal(StepUpNormal);
+ return TransitionState.Slid;
+ }
+
///
/// Initialize the path for a simple point-to-point movement.
///
@@ -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 _nearbyObjs = new();
- private static int _debugQueryCount = 0;
-
///
/// 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();
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
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Attempt to step over a low obstacle by probing upward then stepping down.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// ACE: Transition.StepUp (Transition.cs:746-777).
+ /// Named-retail: CTransition::step_up (~273099-273133).
+ ///
+ 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
+ // -----------------------------------------------------------------------
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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).
+ ///
+ ///
+ /// ACE: Transition.CheckWalkable (Transition.cs:206-235).
+ /// Named-retail: CTransition::check_walkable.
+ ///
+ 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
// -----------------------------------------------------------------------
diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
index fb06b18..ac56506 100644
--- a/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
+++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpFixtures.cs
@@ -82,7 +82,9 @@ public static class BSPStepUpFixtures
///
/// - 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].
+ /// - 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.3–0.5 (the wall contact zone).
///
///
public static (PhysicsBSPNode Root, Dictionary Resolved)
@@ -105,10 +107,14 @@ public static class BSPStepUpFixtures
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
+ // 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.5f, -1f, 0.25f), new Vector3(2f, -1f, 0.25f),
- new Vector3(2f, 1f, 0.25f), new Vector3(0.5f, 1f, 0.25f));
+ 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.
diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
index 2333e70..e6f93a2 100644
--- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs
@@ -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
// =========================================================================
///
- /// 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.
+ /// Build a that serves one synthetic BSP object.
+ /// 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).
///
private static PhysicsEngine MakeTestEngine(
PhysicsBSPNode root,
Dictionary 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(