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>
This commit is contained in:
parent
0cbe1102d9
commit
96ae274081
6 changed files with 908 additions and 85 deletions
266
docs/research/2026-07-07-csphere-collision-family-pseudocode.md
Normal file
266
docs/research/2026-07-07-csphere-collision-family-pseudocode.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# 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.**
|
||||
Loading…
Add table
Add a link
Reference in a new issue