acdream/docs/research/2026-07-07-csphere-collision-family-pseudocode.md
Erik 96ae274081 fix #182: port CSphere collision family — retail-faithful crowd wiggle (retires TS-45)
Humanoid creatures/players collide as body Spheres (ShadowShapeBuilder emits
Sphere-type shadows for a Setup with Spheres + no CylSpheres), so player-vs-monster
crowd contact ran through Transition.SphereCollision — a hand-rolled 3-D wall-slide
(register TS-45), NOT a port of retail CSphere::intersects_sphere. It shaved no eps,
force-pushed each contact RADIALLY to a fixed combinedR+1cm shell, ignored the head
sphere, and always returned Slid. In a crowd the opposing radial de-penetration
pushes from neighbours fight each other -> the player wedges and can't wiggle free
(the user's live report).

Port the full CSphere family verbatim — dispatcher 0x00537A80 + step_sphere_up /
slide_sphere / land_on_sphere / collide_with_point / step_sphere_down — the direct
analog of the 2026-07-05 CCylSphere port (#172). The grounded slide now routes
through the shared crease SlideSphere (0x00537440, #116-Ghidra-confirmed) ->
tangential shuffle along the contact toward gaps, retail-faithful. isCreature
(target creature/missile) gates OFF the stand-on/land-on branches (2 & 5). ACE
Sphere.cs = readable oracle; pseudocode doc 2026-07-07-csphere-collision-family.

Retail-faithfulness verified: CTransition::validate_transition (0x0050aa70:272593)
reverts curr_pos on any non-clean-OK step, so a deep-mutual-overlap start wedges in
retail too — the realistic crowd-edge graze slides free (SphereCollisionFamilyTests
slide-around trajectory: player grazes a creature's SW, curves around its west side,
continues N).

TS-45 retired, AP-84 added (PerfectClip TOI dead in M1.5). Core 2603/0, App 741/0.
Pending user visual gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:25:56 +02:00

13 KiB
Raw Blame History

CSphere collision family — retail pseudocode (port prep)

Date: 2026-07-07 · Trigger: the user's live report — "in a packed crowd of monsters it's too easy to get stuck; you can't wiggle free. In retail the same crowd leaves a bit more room to shuffle/slide out." Humanoid creatures/players collide as body spheres (Setup.Spheres, no CylSpheres → ShadowShapeBuilder.FromSetup emits ShadowCollisionType.Sphere), so the crowd contact runs through Transition.SphereCollision — which was a hand-rolled 3-D wall-slide with a forced fixed de-penetration, NOT a port of the retail CSphere::intersects_sphere family. Register row TS-45 already documented this divergence and predicted the exact symptom ("absorbs to a zero offset … until an oblique input clears it"); it was deferred to avoid disturbing the freshly-gated #171 sticky-melee work.

This is the direct analog of the 2026-07-05 CCylSphere family port (docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md). Same shape, same siblings, same ACE-as-readable-oracle method. The transition loop (transitional_insert) is unchanged — only the per-contact Sphere response is replaced.

Sources: named-retail pseudo-C (addresses below) = ground truth; references/ACE/Source/ACE.Server/Physics/Sphere.cs = readable cross-reference (settles the x87 garbles). One ACE quirk noted in §8.

Retail function inventory

Function Address pseudo-C line
CSphere::intersects_sphere(Position*, scale, CTransition*, isCreature) — wrapper 0x00537fd0 :321881
CSphere::intersects_sphere(CTransition*, isCreature) — dispatcher 0x00537a80 :321678
CSphere::collides_with_sphere(disp, radsum) 0x005369e0 :320964
CSphere::step_sphere_up 0x00537900 :321611
CSphere::slide_sphere(sp, ci, normal, currCenter) — core crease slide 0x00537440 :321403
CSphere::land_on_sphere 0x005379a0 :321642
CSphere::collide_with_point 0x00537230 :321327
CSphere::step_sphere_down 0x00536d20 :321133

Dispatch contextCPhysicsObj::FindObjCollisions (0x0050f050, pc:276776): isCreature = (target.state & MISSILE_PS 0x40) || target.IsCreature() (pc:276846-276853; ACE PhysicsObj.cs:409). The target's shape is chosen CylSphere-first, else Sphere (pc:276868/276917; ACE PhysicsObj.cs:414-436). Humanoids have GetNumCylsphere()==0 + GetNumSphere()>0the sphere branch, isCreature passed in.

1. collides_with_sphere (0x005369e0) — pure 3-D overlap

collides_with_sphere(disp, radsum):        // disp = globalSphere.center  this.center
    return |disp|² <= radsum²              // full 3-D distance (unlike CCylSphere's XY+Z-band)

radsum at every call site = globalSphere.radius + this.radius F_EPSILON (ε shaved ONCE in the dispatcher preamble, 0x00537acd). The ε is what makes resting exactly touching a non-overlap, so a shuffle that ends flush against a monster settles instead of re-colliding every frame. The hand-rolled version omitted this ε — the first stickiness source.

2. Dispatcher (0x00537a80)

intersects_sphere(this, CTransition* t, isCreature):   // this.center in world frame
    sp = t.sphere_path;  oi = t.object_info
    s0 = sp.global_sphere[0];  disp0 = s0.center  this.center
    if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center  this.center
    radsum = s0.radius + this.radius  F_EPSILON

    // ── branch 1: placement / ethereal — detection only ──
    if (sp.obstruction_ethereal || sp.insert_type == PLACEMENT_INSERT):
        if collides(disp0) → COLLIDED
        if num_sphere>1 && collides(disp1) → COLLIDED
        return OK

    // ── branch 2: step-down probe — land on the top ──
    if (sp.step_down):
        if isCreature → OK                 // §8.1 — you never stand ON a creature/missile
        return step_sphere_down(this, s0, disp0, radsum)

    // ── branch 3: walkable probe — occupancy blocks ──
    if (sp.check_walkable):
        if collides(disp0) → COLLIDED
        if num_sphere>1 && collides(disp1) → COLLIDED
        return OK

    // ── branch 4: normal sweep (collide flag clear) ──
    if (!sp.collide):
        if (oi.state & (CONTACT|ON_WALKABLE)):             // grounded
            if collides(disp0) → step_sphere_up(this, disp0, radsum)
            if num_sphere>1 && collides(disp1) → slide_sphere(this, disp1, sphereNum=1)
        elif (oi.state & PATH_CLIPPED):
            if collides(disp0) → collide_with_point(this, s0, radsum, 0)
        else:                                              // airborne
            if collides(disp0) → land_on_sphere(this)
            if num_sphere>1 && collides(disp1) → collide_with_point(this, s1, radsum, 1)
        return OK

    // ── branch 5: collide-flag re-test — exact-TOI cap landing ──
    if isCreature → OK                     // §8.1 — never land ON a creature/missile
    if !collides(disp0) && !(num_sphere>1 && collides(disp1)) → OK
    movement = sp.global_curr_center[0]  s0.center  block_offset(cur→check)
    radsum += F_EPSILON
    lenSq = |movement|²
    diff  = dot(movement, disp0)
    if |lenSq| < F_EPSILON → COLLIDED
    t = sqrt(diff²  (|disp0|²  radsum²)·lenSq) + diff       // quadratic solve for TOI
    if t > 1: t = diff·2  t
    time = t / lenSq
    timecheck = (1  time)·sp.walk_interp
    if timecheck >= sp.walk_interp || timecheck < 0.1 → COLLIDED
    movement *= time
    disp0 = (disp0 + movement) / radsum
    if !is_walkable_allowable(disp0.z) → OK                   // too steep to rest on
    contactPt = s0.center  disp0·s0.radius
    ci.set_contact_plane(Plane(n=disp0, d=dot(disp0, contactPt)), is_water=1)
    ci.contact_plane_cell_id = sp.check_pos.objcell_id
    sp.walk_interp = timecheck
    sp.add_offset_to_check_pos(movement, s0.radius)
    return ADJUSTED

State bits (verified against our ObjectInfoState): CONTACT=0x1, ON_WALKABLE=0x2, PATH_CLIPPED=0x8. Note branch 5's landing surface is a tilted plane (n = disp0, the sphere-to-sphere direction) — unlike the cylinder's flat top — because a sphere top is curved.

3. step_sphere_up (0x00537900)

step_sphere_up(this, disp0, radsum):
    radsum += F_EPSILON
    if (oi.step_up_height < radsum  disp0.z)         // too tall to step over
        → slide_sphere(this, disp0, 0)
    n = sp.global_curr_center[0]  this.center
    if CTransition::step_up(t, n) → OK
    else → sp.step_up_slide(t)

For a full-height creature the head clearance radsum disp0.z far exceeds step_up_height (~0.4 m) → always slide_sphere. This is the grounded-crowd path: slide, don't stop.

4. slide_sphere (grounded) — the wiggle-room primitive

The grounded foot/head hit reduces to CSphere::slide_sphere (0x00537440), which acdream already has as Transition.SlideSphere(normal, currPos, sphereNum) (Ghidra-confirmed via #116). It computes the collision normal n = global_curr_center[sphereNum] this.center, normalizes, then projects the displacement onto the crease = cross(n, contactPlane.normal) — the tangent where the monster's side meets the ground — and slides ALONG it. Returns SLID (or COLLIDED when the projected offset degenerates < ~1.41 cm).

This is the whole fix. The hand-rolled version projected onto the plane ⊥ the raw 3-D normal and then FORCE-pushed to a fixed combinedR + 0.01 m shell (radial de-penetration). Radial pushes from neighbours on opposite sides cancel/fight; the crease slide moves you tangentially toward the gap.

slide_sphere(this, disp, sphereNum):
    n = sp.global_curr_center[sphereNum]  this.center
    if normalize_check_small(n) → COLLIDED
    return SlideSphere(n, sp.global_curr_center[sphereNum], sphereNum)   // shared crease slide

5. land_on_sphere (0x005379a0) — airborne foot hit

land_on_sphere(this):
    n = sp.global_curr_center[0]  this.center
    if normalize_check_small(n) → COLLIDED
    sp.set_collide(n)                        // backup + Collide flag → next attempt runs branch 5
    sp.walkable_allowance = LANDING_Z (0.0871557)
    return ADJUSTED

Identical shape to CCylSphere::land_on_cylinder.

6. collide_with_point (0x00537230) — PathClipped / airborne head hits

collide_with_point(this, checkSphere, radsum, sphereNum):
    globalOffset = sp.global_curr_center[sphereNum]  this.center
    if !PerfectClip:
        if !normalize_check_small(globalOffset): ci.set_collision_normal(globalOffset)
        return COLLIDED
    // PerfectClip → exact TOI reposition (missiles only; §8.2)
    blockOffset = get_block_offset(cur, check)
    checkOffset = checkSphere.center  global_curr_center[sphereNum] + blockOffset
    collisionTime = find_time_of_collision(checkOffset, globalOffset, radsum + F_EPSILON)
    if collisionTime < F_EPSILON || collisionTime > 1 → COLLIDED
    collisionOffset = checkOffset·collisionTime  checkOffset
    old_disp = collisionOffset + checkSphere.center  this.center
    ci.set_collision_normal(old_disp / radsum)
    sp.add_offset_to_check_pos(old_disp, checkSphere.radius)
    return ADJUSTED

Non-PerfectClip = set_collision_normal + COLLIDED (decomp-verified). The PerfectClip TOI tail is dead code in M1.5 (no mover sets PerfectClip) — same status as the cylinder family's AP-83.

7. step_sphere_down (0x00536d20) — land on the top during a step-down probe

step_sphere_down(this, s0, disp0, radsum):
    if !collides(disp0) && !(num_sphere>1 && collides(disp1)) → OK
    stepDown = sp.step_down_amt · sp.walk_interp
    if |stepDown| < F_EPSILON → COLLIDED
    radsum += F_EPSILON
    val = sqrt(radsum²  (disp0.x² + disp0.y²))          // curved-top height at this XY
    scaledStep = (val  disp0.z) / stepDown
    timecheck = (1  scaledStep)·sp.walk_interp
    if timecheck >= sp.walk_interp || timecheck < 0.1 → COLLIDED
    interp = stepDown · scaledStep
    disp0 = (disp0.x, disp0.y, disp0.z + interp) / radsum
    if disp0.z <= sp.walkable_allowance → OK              // too steep — sphere top is a dome
    contactPt = disp0·this.radius + this.center
    ci.set_contact_plane(Plane(n=disp0, d=dot(disp0, contactPt)), is_water=1)
    ci.contact_plane_cell_id = sp.check_pos.objcell_id
    sp.walk_interp = timecheck
    sp.add_offset_to_check_pos((0,0,interp), checkSphere.radius)
    return ADJUSTED

Gated OFF for creatures at the dispatcher (branch 2: isCreature → OK), so in M1.5 this only lands the player on non-creature Sphere-shape statics. Ported for completeness/faithfulness (ACE Sphere.cs:617).

8. Divergences + settled ambiguities (register-relevant)

  1. isCreature gates "stand-on/land-on" (branches 2 and 5). A creature or missile is a solid you push against horizontally but never rest on vertically. Retail short-circuits both to OK for isCreature (0x00537b8c / 0x00537af5). Port verbatim.
  2. PerfectClip TOI tail is dead in M1.5 — no mover arms PerfectClip (players never do). The load-bearing path is non-PerfectClip set_collision_normal + COLLIDED (decomp-verified). Same status/row style as the cylinder family's AP-83; re-decompile 0x00537230 in Ghidra before shipping missiles (F.3).
  3. is_water=1 on contact planes is RETAIL (0x00536ecf / branch-5 site; set_contact_plane arg3 → contact_plane_is_water). Port verbatim; do not "fix" — same as the cylinder family §8.1.
  4. Block offset = 0 in branch 5 / collide_with_point: retail subtracts the cur→check landblock offset; acdream's physics frame is continuous world-space → 0. Standing adaptation (same note as SlideSphere's gDelta and the cylinder family §8.3).
  5. ACE bug NOT copied: ACE Sphere.cs has no equivalent of the cylinder family's foot-vs-head disp mixup; the grounded head slide passes globSphere_ (the head sphere) at IntersectsSphere:348 — correct. Retail 0x00537ec4 agrees. No divergence here.
  6. normalize_check_small = normalize; returns true (fail) when |v| < F_EPSILON — maps to our existing NormalizeCheckSmall.

9. acdream port surface

Transition.SphereCollision(ShadowEntry, SpherePath, bool isCreature) becomes the branch-2/3/4/5 dispatcher body (branch 1 ethereal keeps the existing early-OK consume + the caller's Layer-2 override, identical to the cylinder family). New private siblings mirror the Cyl* set: SphereCollidesWithSphere, SphereStepSphereUp, SphereSlideSphere, SphereLandOnSphere, SphereCollideWithPoint, SphereStepSphereDown. Reused as-is: the shared SlideSphere (crease projection), DoStepUp (= CTransition::step_up), SpherePath.StepUpSlide, NormalizeCheckSmall, CollisionInfo.SetContactPlane/SetCollisionNormal. The caller (FindObjCollisionsInCell, Sphere branch) computes isCreature = (obj.State & 0x40) != 0 || (obj.Flags & IsCreature) != 0 and passes it in. The BspOnlyDispatch gate and the ethereal step-down skip sit ABOVE this dispatch and are unaffected. Retires TS-45.