fix #172: port the retail CCylSphere collision family (platform step-up)

The Holtburg town-network portal platform (stab 0xC0A9B465, Setup
0x020019E3, CylSphere r=2.597m h=0.256m) blocked the player with an
endless rim slide instead of retail's step-up-onto-top — gating the
whole #137 dungeon repro. Surfaced when #149 started registering
BSP-less stab CylSpheres: the collision SHAPE became right while the
RESPONSE was still the hand-rolled AP-6 approximation (step-up gate +
radial wall-slide only).

Root cause: no cylinder-TOP support anywhere. DoStepUp's internal
step-down probe needs retail's step_sphere_down (0x0053a9b0) to land on
the flat top — a cylinder has no polygons for the walkable search — so
every step-up onto a wide cylinder failed into StepUpSlide and the
player orbited the rim (probe-confirmed: [cyl-test] result=Slid with
horizontal rim normals, launch-137-repro.log).

Port the full family verbatim: dispatcher intersects_sphere 0x0053b440
(placement/ethereal detection, step-down cap landing, walkable probe,
grounded step_sphere_up 0x0053b310, PathClipped collide_with_point
0x0053acb0, airborne land_on_cylinder 0x0053b3d0, Collide-flag
exact-TOI cap rest) + collides_with_sphere 0x0053a880 +
normal_of_collision 0x0053ab50 + slide_sphere 0x0053b2a0. Pseudocode +
settled BN x87 ambiguities (via ACE cross-ref) + two ACE bugs found and
NOT copied (head-slide foot-disp; see doc §8):
docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md

Ethereal cylinders now flow through retail's Layer-2 override
(pc:276961) instead of the early-OK consume — same net #150 behavior,
plus retail placement-blocked-by-cylinder semantics. SlideSphere gains
a sphereNum param (retail slides the head sphere by its own
displacement, 0x0053b843).

Register: AP-6 retired; AP-83 added (PerfectClip TOI tail decoded per
ACE, dead code until missiles). Tests: CylSphereFamilyTests (grounded
step-up onto the exact platform shape, tall-cylinder block, airborne
top landing, ethereal guard); the #42 self-shadow control assertion
updated to the retail observable (denied movement — the old ~1m radial
self-push was the approximation's artifact, not retail). Suites: Core
2533 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 14:52:28 +02:00
parent 67c3357246
commit 6ab269894a
6 changed files with 1027 additions and 151 deletions

View file

@ -2811,9 +2811,12 @@ public sealed class Transition
// Effect (pc:276973-276977):
// state_3 = OK_TS ← force passable
// collision_normal_valid = 0 ← clear stale slide normal
// Note: Cylinder and Sphere shapes already return OK from their own
// obstruction_ethereal early-out, so this clause only fires in practice
// for the BSP branch, but is written unconditionally as retail does.
// Note: the BSP branch AND (since the 2026-07-05 CCylSphere family
// port) the Cylinder branch rely on this clause for ethereal
// passability — CylinderCollision's branch 1 returns Collided on
// overlap like retail, and THIS override clears it for non-static
// targets. Only the Sphere branch still early-outs on
// obstruction_ethereal (consume site 1).
if (result != TransitionState.OK
&& sp.ObstructionEthereal
&& !sp.StepDown
@ -3171,168 +3174,453 @@ public sealed class Transition
}
/// <summary>
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
/// door foot-colliders). For Contact-grounded movers, attempts to step over short
/// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers,
/// movers already stepping, or cylinders too tall to step over, applies a
/// horizontal wall-slide response.
/// Retail <c>CCylSphere::intersects_sphere</c> dispatcher — verbatim port
/// of <c>0x0053b440</c> (acclient_2013_pseudo_c.txt:324558). Full family:
/// <c>collides_with_sphere</c> 0x0053a880, <c>normal_of_collision</c>
/// 0x0053ab50, <c>collide_with_point</c> 0x0053acb0, <c>slide_sphere</c>
/// 0x0053b2a0, <c>step_sphere_up</c> 0x0053b310, <c>land_on_cylinder</c>
/// 0x0053b3d0, <c>step_sphere_down</c> 0x0053a9b0. Pseudocode + settled
/// BN ambiguities + ACE cross-reference notes:
/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
///
/// <para>
/// A6.P6 (2026-05-25): the step-over path matches retail's
/// <c>CCylSphere::step_sphere_up</c> at
/// <c>acclient_2013_pseudo_c.txt:324516-324538</c>. The door's foot
/// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to
/// produce smooth sliding along the slab — the radial push-out
/// fires as a "phantom collision" at the door's center when the
/// sphere is touching the slab face and the cyl is just within reach.
/// Retail steps the sphere over the cyl (succeeds when
/// <c>step_up_height &gt;= sphere.radius + cyl.height - offset.z</c>),
/// which lets the sphere walk past the cyl without the radial push.
/// On step-up failure (cyl too tall, no walkable surface beyond),
/// falls back to <c>step_up_slide</c> — the same crease-projection
/// slide the BSP path uses, which produces smoother behavior than
/// the radial push.
/// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up
/// gate + radial wall-slide) which had NO cylinder-TOP support: a
/// grounded mover stepping up onto a WIDE cylinder (the Holtburg
/// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m)
/// could never validate a landing — the step-up's internal step-down
/// probe needs branch 2 (step_sphere_down → contact plane ON the flat
/// top) — so DoStepUp failed into StepUpSlide and the player orbited the
/// rim forever. Airborne landings on tops (land_on_cylinder + the
/// collide-flag exact-TOI branch) were likewise missing.
/// </para>
///
/// <para>
/// The <see cref="ShadowEntry"/> already carries the wrapper overload's
/// (0x0053b8f0) work: Position = globalized low_pt (entity frame applied
/// at registration), Radius/CylHeight pre-scaled; the cylinder axis stays
/// world-Z. Ethereal targets: branch 1 returns Collided on overlap and
/// the caller's Layer-2 override (pc:276961-276989) clears it for
/// non-static targets — the retail passability mechanism (#150); the
/// step-down pass never reaches here for ethereal targets (pc:276799
/// skip).
/// </para>
/// </summary>
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
{
// Consume site 2 — CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
// When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4),
// the retail function is void and all paths in the ethereal branch return
// without producing a COLLIDED/Slid result — the player is fully passable.
// We mirror this by returning OK immediately, skipping all blocking paths.
// Retail ref: acclient_2013_pseudo_c.txt:324573.
if (sp.ObstructionEthereal)
return TransitionState.OK;
// Degenerate dat heights: registration sites apply the same fallback;
// kept for entries registered before it (pre-dates this port).
float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f;
var s0 = sp.GlobalSphere[0];
Vector3 disp0 = s0.Origin - obj.Position;
// radsum: ε shaved ONCE in the dispatcher preamble (0x0053b48e) —
// this is what lets a sphere REST exactly on the top without
// re-colliding every frame.
float radsum = obj.Radius - PhysicsGlobals.EPSILON + s0.Radius;
bool hasHead = sp.NumSphere > 1;
Vector3 disp1 = default;
float headRadius = 0f;
if (hasHead)
{
disp1 = sp.GlobalSphere[1].Origin - obj.Position;
headRadius = sp.GlobalSphere[1].Radius;
}
// ── Branch 1 (0x0053b4a0): placement / ethereal — detection only. ──
if (sp.InsertType == InsertType.Placement || sp.ObstructionEthereal)
{
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return TransitionState.Collided;
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
return TransitionState.Collided;
return TransitionState.OK;
}
// ── Branch 2 (0x0053b4c0): step-down probe — land on the top. ──
if (sp.StepDown)
return CylStepSphereDown(obj, sp, cylHeight, disp0, radsum);
// ── Branch 3 (0x0053b4d7): walkable probe — occupancy blocks. ──
if (sp.CheckWalkable)
{
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return TransitionState.Collided;
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
return TransitionState.Collided;
return TransitionState.OK;
}
var ci = CollisionInfo;
var oi = ObjectInfo;
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
float sphRadius = sp.GlobalSphere[0].Radius;
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
// Vertical check: does sphere reach the cylinder's height range at all?
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
float checkZ = sphereCheckPos.Z;
if (checkZ - sphRadius > obj.Position.Z + cylTop ||
checkZ + sphRadius < obj.Position.Z)
if (!sp.Collide)
{
// ── Branch 4 (0x0053b701): normal transition sweep. ──
if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0)
{
// Grounded mover: foot hit → step over / onto; head hit → slide.
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return CylStepSphereUp(obj, sp, engine, cylHeight, disp0, radsum);
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
// Retail 0x0053b843 passes the HEAD disp (its x_2); ACE's
// port passes the foot disp here — retail wins (pseudocode
// doc §8.2).
return CylSlideSphere(obj, sp, cylHeight, disp1, radsum, 1);
}
else if ((oi.State & ObjectInfoState.PathClipped) != 0)
{
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return CylCollideWithPoint(obj, sp, cylHeight, s0, disp0, radsum, 0);
}
else
{
// Airborne: foot hit → land on the top; head hit → point hit.
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
return CylLandOnCylinder(obj, sp, cylHeight, disp0, radsum);
if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
return CylCollideWithPoint(obj, sp, cylHeight, sp.GlobalSphere[1], disp1, radsum, 1);
}
return TransitionState.OK;
}
// ── Branch 5 (0x0053b525): collide-flag re-test — exact-TOI cap landing. ──
// Runs on the attempt after land_on_cylinder set sp.Collide: rewinds
// the sphere along the REVERSE movement to rest exactly on the top,
// sets the contact plane, and consumes walk_interp.
if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)
|| (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)))
{
// movement = curr check. Retail subtracts the cur→check
// landblock offset (get_curr_pos_check_pos_block_offset); our
// physics frame is continuous world-space → zero (same standing
// adaptation as SlideSphere's gDelta).
Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin;
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
float timecheck = (cylHeight + s0.Radius - disp0.Z) / movement.Z;
Vector3 offset = movement * timecheck;
Vector3 sum = offset + disp0;
if (radsum * radsum < sum.X * sum.X + sum.Y * sum.Y)
return TransitionState.OK; // rewound point is off the cap — not a top landing
float t = (1f - timecheck) * sp.WalkInterp;
if (t >= sp.WalkInterp || t < -0.1f)
return TransitionState.Collided;
Vector3 pt = s0.Origin + offset;
pt.Z -= s0.Radius;
// is_water=1 is verbatim retail (0x0053b6b9 → set_contact_plane
// arg3 = contact_plane_is_water, 0x00509db1). Do not "fix".
var contactPlane = new Plane(Vector3.UnitZ, -pt.Z);
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
sp.WalkInterp = t;
sp.AddOffsetToCheckPos(offset);
return TransitionState.Adjusted;
}
return TransitionState.OK;
}
/// <summary>
/// Retail <c>CCylSphere::collides_with_sphere</c> (0x0053a880,
/// pc:323943): XY overlap against radsum, then Z-band overlap against the
/// cylinder's [0, height] extent. <paramref name="disp"/> = sphere center
/// cylinder base point.
/// </summary>
private static bool CylCollidesWithSphere(Vector3 disp, float radsum, float cylHeight, float sphereRadius)
{
if (disp.X * disp.X + disp.Y * disp.Y <= radsum * radsum)
{
float halfH = cylHeight * 0.5f;
if (sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - disp.Z))
return true;
}
return false;
}
/// <summary>
/// Retail <c>CCylSphere::normal_of_collision</c> (0x0053ab50, pc:324102).
/// Discriminates on where the sphere was at the START of the step
/// (GlobalCurrCenter): XY-outside the combined radius → radial side
/// normal; XY-inside the footprint → cap normal (descending → top +Z,
/// ascending → underside Z; polarity settled by ACE + geometry — BN's
/// x87 branch rendering is untrustworthy here). Returns "definite":
/// false when a radial contact could actually be a diagonal cap hit
/// (curr was outside the Z band AND there is vertical movement) —
/// consumed only by <see cref="CylCollideWithPoint"/>.
/// </summary>
private bool CylNormalOfCollision(ShadowEntry obj, SpherePath sp, float cylHeight,
Vector3 dispCheck, float radsum, float sphereRadius, int sphereNum, out Vector3 normal)
{
Vector3 dispCurr = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position;
if (radsum * radsum < dispCurr.X * dispCurr.X + dispCurr.Y * dispCurr.Y)
{
normal = new Vector3(dispCurr.X, dispCurr.Y, 0f);
float halfH = cylHeight * 0.5f;
bool zBandOverlapAtCurr =
sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - dispCurr.Z);
bool noZMovement = MathF.Abs(dispCurr.Z - dispCheck.Z) <= PhysicsGlobals.EPSILON;
return zBandOverlapAtCurr || noZMovement;
}
normal = new Vector3(0f, 0f, dispCheck.Z - dispCurr.Z <= 0f ? 1f : -1f);
return true;
}
/// <summary>
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c>: returns true
/// (degenerate — caller yields Collided) when |v| &lt; F_EPSILON, else
/// normalizes in place.
/// </summary>
private static bool NormalizeCheckSmall(ref Vector3 v)
{
float mag = v.Length();
if (mag < PhysicsGlobals.EPSILON)
return true;
v /= mag;
return false;
}
/// <summary>
/// Retail <c>CCylSphere::step_sphere_up</c> (0x0053b310, pc:324516).
/// Too-tall cylinders slide; otherwise the generic step-up
/// (<see cref="DoStepUp"/> = CTransition::step_up) runs — its internal
/// step-down probe lands on the cylinder top via
/// <see cref="CylStepSphereDown"/> (branch 2), which is what makes
/// stepping ONTO a wide cylinder possible.
/// </summary>
private TransitionState CylStepSphereUp(ShadowEntry obj, SpherePath sp, PhysicsEngine engine,
float cylHeight, Vector3 disp0, float radsum)
{
var s0 = sp.GlobalSphere[0];
// step_up_height must clear (sphere.radius + height disp.z) — the
// lift needed so the sphere bottom rests on the top (0x0053b323).
if (ObjectInfo.StepUpHeight < s0.Radius + cylHeight - disp0.Z)
return CylSlideSphere(obj, sp, cylHeight, disp0, radsum, 0);
// Retail computes the normal and ignores the definite flag here.
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, s0.Radius, 0, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
// Retail rotates the normal by the target OBJECT's frame
// (localtoglobalvec via the wrapper's cached localspace_pos,
// 0x0053b38d) before CTransition::step_up. Yaw-only AC frames leave
// vertical normals unchanged; radial normals pick up the yaw.
var nWorld = Vector3.Transform(n, obj.Rotation);
// engine==null only in bare unit-test transitions — no step-up
// machinery available; the retail-faithful fallback is the slide.
if (engine is not null && DoStepUp(nWorld, engine))
return TransitionState.OK;
// XY distance from sphere check position to cylinder axis.
float dxCheck = sphereCheckPos.X - obj.Position.X;
float dyCheck = sphereCheckPos.Y - obj.Position.Y;
float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
float combinedR = sphRadius + obj.Radius;
float combinedRSq = combinedR * combinedR;
return sp.StepUpSlide(this);
}
if (distSqCheck >= combinedRSq)
return TransitionState.OK; // not overlapping at check position
/// <summary>
/// Retail <c>CCylSphere::step_sphere_down</c> (0x0053a9b0, pc:324032):
/// during a step-down probe, land the foot sphere ON the cylinder's flat
/// top — contact plane (0,0,1) through the top, walk_interp consumed,
/// CheckPos lifted so the sphere bottom rests exactly on it. THE piece
/// whose absence made every step-up onto a wide cylinder fail (the
/// portal-platform rim orbit).
/// </summary>
private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp,
float cylHeight, Vector3 disp0, float radsum)
{
var s0 = sp.GlobalSphere[0];
// ─── Overlap detected ─────────────────────────────────────
// Horizontal outward normal from the cylinder axis to the sphere
// check position. For the degenerate case where the sphere center
// is exactly on the axis, use the movement direction as a fallback
// (pushes the sphere back out along the way it came in).
float distCheck = MathF.Sqrt(distSqCheck);
Vector3 collisionNormal;
if (distCheck < PhysicsGlobals.EPSILON)
bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius);
if (!hit && sp.NumSphere > 1)
{
// Sphere center on cylinder axis — push along reverse movement.
float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y);
if (mxy > PhysicsGlobals.EPSILON)
collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
else
collisionNormal = Vector3.UnitX;
Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position;
hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius);
}
else
if (!hit)
return TransitionState.OK;
float stepScale = sp.StepDownAmt * sp.WalkInterp;
if (MathF.Abs(stepScale) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
// Lift so the foot sphere's bottom rests on the top disc. The
// (1 deltaZ/stepScale) divisor is stepScale — BN garbled it;
// settled via ACE CylSphere.StepSphereDown (pseudocode doc §4).
float deltaZ = cylHeight + s0.Radius - disp0.Z;
float interp = (1f - deltaZ / stepScale) * sp.WalkInterp;
if (interp >= sp.WalkInterp || interp < -0.1f)
return TransitionState.Collided;
float topZ = s0.Origin.Z + deltaZ - s0.Radius;
// is_water=1 verbatim retail (0x0053aae2). Do not "fix".
var contactPlane = new Plane(Vector3.UnitZ, -topZ);
CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
sp.WalkInterp = interp;
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, deltaZ));
return TransitionState.Adjusted;
}
/// <summary>
/// Retail <c>CCylSphere::slide_sphere</c> (0x0053b2a0, pc:324502):
/// normal_of_collision → CSphere::slide_sphere (our
/// <see cref="SlideSphere"/>) with the sliding sphere's own curr center.
/// </summary>
private TransitionState CylSlideSphere(ShadowEntry obj, SpherePath sp,
float cylHeight, Vector3 disp, float radsum, int sphereNum)
{
CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
sp.GlobalSphere[sphereNum].Radius, sphereNum, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum);
}
/// <summary>
/// Retail <c>CCylSphere::land_on_cylinder</c> (0x0053b3d0, pc:324542):
/// airborne foot hit — arm the Collide re-test (backup + flag) and relax
/// the walkable allowance to LandingZ. The NEXT attempt's branch 5 then
/// rests the sphere on the top with the exact time-of-impact.
/// </summary>
private TransitionState CylLandOnCylinder(ShadowEntry obj, SpherePath sp,
float cylHeight, Vector3 disp0, float radsum)
{
CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum,
sp.GlobalSphere[0].Radius, 0, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
sp.SetCollide(n);
sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x0053b41f)
return TransitionState.Adjusted;
}
/// <summary>
/// Retail <c>CCylSphere::collide_with_point</c> (0x0053acb0, pc:324173):
/// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers
/// record the collision normal and hard-stop; PerfectClip movers get the
/// exact time-of-impact reposition. TOI sub-branches ported per ACE
/// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no
/// PerfectClip mover exists in M1.5 (players never set it), so only the
/// Collided path is load-bearing today — revisit against Ghidra if
/// missiles ever arm PerfectClip (pseudocode doc §7).
/// </summary>
private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp,
float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum)
{
bool definite = CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
checkSphere.Radius, sphereNum, out var n);
if (NormalizeCheckSmall(ref n))
return TransitionState.Collided;
if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
{
collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Collided;
}
// A6.P6 (2026-05-25): retail-faithful CCylSphere::step_sphere_up for
// Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538.
//
// Retail check: step_up_height must clear (sphere.radius + cyl.height
// - offset.z) where offset.z is sphere center Z minus cyl low_pt Z.
// Geometrically: the height we need to lift the sphere to clear the
// cyl's top, less the sphere center's current height above the cyl
// base, equals cyl top minus sphere bottom (positive when sphere
// currently below cyl top).
//
// For the door's foot cyl (h=0.20m, sphere radius 0.48m, step_up 0.60m)
// at standing height (offset.z ~0.38m): cyl_clearance =
// 0.48 + 0.20 - 0.38 = 0.30m, step_up_height = 0.60m → step over OK.
if (oi.Contact && !sp.StepUp && !sp.StepDown && engine is not null)
{
float offsetZ = sphereCheckPos.Z - obj.Position.Z;
float cylClearance = sphRadius + cylTop - offsetZ;
// Retail reads global_curr_center[0] even for the head hit
// (0x0053ad26; ACE agrees) — verbatim.
Vector3 globCenter = sp.GlobalCurrCenter[0].Origin;
// Block offset = 0 (continuous world frame; see branch 5 note).
Vector3 movement = checkSphere.Origin - globCenter;
Vector3 oldDisp = globCenter - obj.Position;
float radsumEps = radsum + PhysicsGlobals.EPSILON;
if (oi.StepUpHeight >= cylClearance)
float xyMoveLenSq = movement.X * movement.X + movement.Y * movement.Y;
float dot2d = movement.X * oldDisp.X + movement.Y * oldDisp.Y;
float xyDiff = -dot2d;
float oldDispXYSq = oldDisp.X * oldDisp.X + oldDisp.Y * oldDisp.Y;
float diffSq = xyDiff * xyDiff - (oldDispXYSq - radsumEps * radsumEps) * xyMoveLenSq;
float time;
Vector3 scaledMovement;
if (!definite)
{
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
if (movement.Z > 0f)
{
// Try step-up over the cyl (DoStepUp probes upward by
// step_up_height, then step-down for walkable surface).
// On success: sphere is repositioned past/over the cyl,
// ContactPlane updated, returns OK.
if (DoStepUp(collisionNormal, engine))
return TransitionState.OK;
// Step-up failed — sphere couldn't find a walkable surface
// beyond the cyl (e.g., a wall behind it). Fall back to
// step_up_slide which uses the SlideSphereInternal crease
// projection — smoother than the radial push-out below
// because it follows the contact-plane / cyl-normal crease
// direction.
return sp.StepUpSlide(this);
}
// else: cyl too tall to step over — fall through to radial slide
}
// ─── Fallback: airborne / non-Contact / cyl-too-tall — wall-slide ───
// Wall-slide position (in world space):
// curr = sphereCurrPos (pre-step)
// movement = sphMovement
// projected = movement - (movement · normal) * normal
// slidPos = curr + projected
// Then push outward if still inside the cylinder radius.
Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
// Preserve vertical movement component (jumping/falling).
projectedMovement.Z = sphMovement.Z;
Vector3 slidPos = sphereCurrPos + projectedMovement;
// Ensure slid position is outside the cylinder radius horizontally.
float sdx = slidPos.X - obj.Position.X;
float sdy = slidPos.Y - obj.Position.Y;
float sDistSq = sdx * sdx + sdy * sdy;
float minDist = combinedR + 0.01f;
if (sDistSq < minDist * minDist)
{
float sDist = MathF.Sqrt(sDistSq);
if (sDist < PhysicsGlobals.EPSILON)
{
// Degenerate: push out along collisionNormal
slidPos.X = obj.Position.X + collisionNormal.X * minDist;
slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
n = new Vector3(0f, 0f, -1f);
time = (movement.Z + checkSphere.Radius) / movement.Z * -1f;
}
else
{
float pushDist = (minDist - sDist);
slidPos.X += (sdx / sDist) * pushDist;
slidPos.Y += (sdy / sDist) * pushDist;
n = new Vector3(0f, 0f, 1f);
time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z;
}
scaledMovement = movement * time;
Vector3 landed = scaledMovement + oldDisp;
if (landed.X * landed.X + landed.Y * landed.Y >= radsumEps * radsumEps)
{
if (MathF.Abs(xyMoveLenSq) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
if (diffSq >= 0f && xyMoveLenSq > PhysicsGlobals.EPSILON)
{
float diff = MathF.Sqrt(diffSq);
time = xyDiff - diff < 0f
? (diff - dot2d) / xyMoveLenSq
: (xyDiff - diff) / xyMoveLenSq;
scaledMovement = movement * time;
}
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
n.Z = 0f;
}
if (time < 0f || time > 1f)
return TransitionState.Collided;
Vector3 offsetOut = globCenter - scaledMovement - checkSphere.Origin;
sp.AddOffsetToCheckPos(offsetOut);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Adjusted;
}
// Apply the offset (difference between slid and current CheckPos)
Vector3 delta = slidPos - sphereCheckPos;
sp.AddOffsetToCheckPos(delta);
if (n.Z != 0f)
{
if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
ci.SetCollisionNormal(collisionNormal);
ci.SetSlidingNormal(collisionNormal);
return TransitionState.Slid;
time = movement.Z > 0f
? -((oldDisp.Z + checkSphere.Radius) / movement.Z)
: (checkSphere.Radius + cylHeight - oldDisp.Z) / movement.Z;
scaledMovement = movement * time;
if (time < 0f || time > 1f)
return TransitionState.Collided;
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
sp.AddOffsetToCheckPos(offsetOut);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Adjusted;
}
if (diffSq < 0f || xyMoveLenSq < PhysicsGlobals.EPSILON)
return TransitionState.Collided;
{
float diff = MathF.Sqrt(diffSq);
time = xyDiff - diff < 0f
? (diff - dot2d) / xyMoveLenSq
: (xyDiff - diff) / xyMoveLenSq;
scaledMovement = movement * time;
if (time < 0f || time > 1f)
return TransitionState.Collided;
n = (scaledMovement + globCenter - obj.Position) / radsumEps;
n.Z = 0f;
Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
sp.AddOffsetToCheckPos(offsetOut);
CollisionInfo.SetCollisionNormal(n);
return TransitionState.Adjusted;
}
}
// -----------------------------------------------------------------------
@ -3356,7 +3644,11 @@ public sealed class Transition
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
=> SlideSphere(collisionNormal, currPos);
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
/// <param name="sphereNum">Which path sphere is sliding (retail
/// CSphere::slide_sphere's <c>this</c> is the sphere instance — the head
/// sphere slides by its OWN displacement, 0x0053b843 passes
/// global_sphere[1]). Default 0 preserves every existing call site.</param>
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0)
{
var sp = SpherePath;
var ci = CollisionInfo;
@ -3364,7 +3656,7 @@ public sealed class Transition
// Degenerate case: zero collision normal — nudge halfway.
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f;
Vector3 halfOffset = (currPos - sp.GlobalSphere[sphereNum].Origin) * 0.5f;
sp.AddOffsetToCheckPos(halfOffset);
return TransitionState.Adjusted;
}
@ -3374,7 +3666,7 @@ public sealed class Transition
// gDelta: displacement from currPos to the current check sphere center.
// In the retail code this includes a block offset for cross-landblock
// transitions; for outdoor single-landblock movement this is zero.
Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos;
Vector3 gDelta = sp.GlobalSphere[sphereNum].Origin - currPos;
// Get the contact plane (prefer current, fall back to last known).
System.Numerics.Plane contactPlane;