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

266 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 context**`CPhysicsObj::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()>0` → **the 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.**