diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index e7bf0842..317b2e1b 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,6 +46,50 @@ Copy this block when adding a new issue:
---
+## #182 — Player wedges in a packed monster crowd, can't wiggle free (hand-rolled SphereCollision)
+
+**Status:** DONE (fix landed; pending user visual gate)
+**Severity:** MEDIUM
+**Filed:** 2026-07-07
+**Component:** physics / collision
+
+**Description:** In a large group of monsters packed around the player it was too
+easy to get stuck — the player couldn't shuffle/slide out. Retail leaves room to
+wiggle free.
+
+**Root cause:** humanoid creatures/players collide as body **Spheres**
+(`ShadowShapeBuilder.FromSetup` emits `ShadowCollisionType.Sphere` for a Setup with
+Spheres + no CylSpheres), so the 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 ε, force-pushed each contact **radially**
+to a fixed `combinedR + 1 cm` shell, ignored the head sphere, always returned Slid,
+and leaked `SetSlidingNormal`. In a crowd the opposing radial de-penetration pushes
+from neighbours fight each other → wedge (the "until an oblique input clears it" feel
+TS-45 predicted).
+
+**Fix:** ported the full `CSphere::intersects_sphere` 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) →
+tangential shuffle along the contact toward gaps, retail-faithful. TS-45 retired,
+AP-84 added (PerfectClip TOI dead in M1.5). Verified retail-faithful: retail's
+`validate_transition` (0x0050aa70) 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.
+
+**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision` + the
+six new `Sphere*` siblings + `FindSphereTimeOfCollision`; caller at
+`FindObjCollisionsInCell` threads `isCreature`).
+
+**Research:** `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`.
+
+**Acceptance:** user shuffles free of a spawned monster pack the same way retail does.
+Conformance: `SphereCollisionFamilyTests` (slide-around trajectory, head-on block,
+ethereal passable) + `ShadowShapeBuilderShapeSourceTests` (body-spheres → Sphere-type).
+Core 2603/0, App 741/0.
+
+---
+
## #181 — Facility Hub flicker at pressed camera (residual after the full 2026-07-06 fix ladder) — PARKED
**⏸️ PARKED 2026-07-06 (user decision after the static-curve gate: "flickering is still
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index b3299213..96a05ace 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -101,7 +101,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit)
+## 3. Documented approximation (AP) — 74 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -184,8 +184,9 @@ accepted-divergence entries (#96, #49, #50).
| AP-85 | **Point-light pool = single 128-cap player-nearest list vs retail's dual pools (7 dynamic + 40 static, degrade-scaled)** (#176 CORRECTED AGAIN 2026-07-06, second correction same day): the pool is now retail's shape — collected per frame from ALL registered (=resident-cell) lit lights and, over cap, kept dynamics-FIRST then nearest-THE-PLAYER (`Render::insert_light` 0x0054d1b0 sorts by `Render::player_pos`; `CEnvCell::add_dynamic_lights` 0x0052d410 walks the WHOLE static `CEnvCell::visible_cell_table`, which `add_visible_cell` 0x0052de40 proves is the RESIDENT-cell registry — it DBObj-loads absent cells; NOT the portal flood). **Two prior camera-coupled models were each a #176 flicker mechanism and are deleted: (1) nearest-CAMERA-128 (chase-boom churn), (2) frame-FLOOD scoping `c500912b` (the prior AP-85 text called it faithful — WRONG: the doc glossed `visible_cell_table` as "the portal-flood visible set"; the under-room portal purples entered/left the pool as the camera turned = the seam blink, probe-proven `[seam-blk]`: flood churned 8→41 cells over a full turn while the fixed pool held the same 8 identities).** Remaining deviation: ONE pool capped 128 vs retail's separate 7-dynamic/40-static with degrade scaling (`0x0081ec94/98`, `SetDegradeLevelInternal` 0x0054c3c0) — in the Hub this admits 7 purples + viewer (8 dynamics) where retail's 7-dynamic cap + static slots would mix in warm fixture light (cdb saw 4 purples + viewer) | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter` | Single-pool is the minimal faithful-in-effect shape: dynamics-first ordering reproduces the never-evicted property; 128 > 40+7 only ever admits EXTRA far statics (out of selection range anyway) | A room with >7 resident dynamics shows them all (retail trims to 7 player-nearest) — slightly purpler Hub wedge than retail; adopt the dual pools + degrade caps in the A7 arc | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (resident registry); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
+| AP-84 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port (2026-07-07, retires TS-45) reads `collide_with_point`'s PerfectClip time-of-impact math from ACE `Sphere.CollideWithPoint` + `FindTimeOfCollision` (ported as `FindSphereTimeOfCollision`) because the BN x87 mush is unreadable there. NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — `SetCollisionNormal` + Collided — is decomp-verified). Sibling of AP-83 (the cylinder family's identical dead branch) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint` PerfectClip branch + `FindSphereTimeOfCollision`; pseudocode doc `docs/research/2026-07-07-csphere-collision-family-pseudocode.md` §6/§8.2) | The load-bearing path (non-PerfectClip Collided; the family's step-up/slide/land) is decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the ACE-derived TOI may diverge from retail — clip-through / wrong deflection on sphere targets; re-decompile 0x00537230 in Ghidra before shipping missiles | `CSphere::collide_with_point` 0x00537230 (pc:321327); ACE Sphere.cs `CollideWithPoint` |
-## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded)
+## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -229,7 +230,7 @@ accepted-divergence entries (#96, #49, #50).
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` |
-| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
+| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
---
diff --git a/docs/research/2026-07-07-csphere-collision-family-pseudocode.md b/docs/research/2026-07-07-csphere-collision-family-pseudocode.md
new file mode 100644
index 00000000..eedaf8df
--- /dev/null
+++ b/docs/research/2026-07-07-csphere-collision-family-pseudocode.md
@@ -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.**
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index a9a9414d..c52c1552 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2792,7 +2792,13 @@ public sealed class Transition
continue;
}
- result = SphereCollision(obj, sp);
+ // Retail var_8_1 (pc:276846; ACE PhysicsObj.cs:409): the target is
+ // a creature OR a missile (state & MISSILE_PS 0x40). Gates OFF the
+ // "stand-on / land-on" branches (2 and 5) of the CSphere family —
+ // you push against a creature horizontally but never rest on it.
+ bool isCreature = (obj.State & 0x40u) != 0
+ || (obj.Flags & EntityCollisionFlags.IsCreature) != 0;
+ result = SphereCollision(obj, sp, engine, isCreature);
}
else
{
@@ -3117,114 +3123,340 @@ public sealed class Transition
}
///
- /// Sphere collision test for objects.
- /// Uses a true 3-D sphere-sphere overlap test — no height clamp, no XY-only
- /// distance — matching retail's CSphere::intersects_sphere @ 0x00537A80.
+ /// Retail CSphere::intersects_sphere dispatcher — port of
+ /// 0x00537A80 (acclient_2013_pseudo_c.txt:321678). Full family:
+ /// collides_with_sphere 0x005369e0, step_sphere_up 0x00537900,
+ /// slide_sphere 0x00537440, land_on_sphere 0x005379a0,
+ /// collide_with_point 0x00537230, step_sphere_down 0x00536d20.
+ /// Pseudocode + settled ambiguities + ACE cross-reference:
+ /// docs/research/2026-07-07-csphere-collision-family-pseudocode.md.
///
///
- /// Implements the subset of the 6-path dispatcher needed for static/placed
- /// Sphere objects: static overlap check (obstruction_ethereal / check_walkable /
- /// Contact-grounded paths), plus a 3-D outward push-back for the slide response.
- /// The swept quadratic from
- /// is used for the narrow-phase; the slide response mirrors the cylinder's
- /// wall-slide but pushes outward in 3-D (not XY-only).
+ /// Player↔humanoid-creature collision runs HERE: humanoid Setups carry body
+ /// Spheres (no CylSpheres) →
+ /// emits . Replaces the pre-2026-07-07
+ /// hand-rolled 3-D wall-slide (register TS-45, retired here): that
+ /// approximation shaved no ε, force-pushed the mover RADIALLY out to a fixed
+ /// combinedR + 1 cm shell every contact, ignored the head sphere, and
+ /// always returned Slid — so a player packed by a crowd of body-sphere
+ /// creatures wedged (opposing radial pushes cancel) instead of shuffling out
+ /// along the contact crease. The faithful family routes the grounded slide
+ /// through the shared (the Ghidra-confirmed
+ /// CSphere::slide_sphere 0x00537440 crease projection — #116), giving
+ /// retail's tangential wiggle room. Driving repro: the user's live
+ /// crowd-collision report, 2026-07-07.
///
///
///
- /// ACE oracle: Sphere.IntersectsSphere in
- /// ACE.Server/Physics/Sphere.cs — particularly the
- /// ObstructionEthereal/Placement, CheckWalkable, and
- /// Contact branches. Retail decomp cross-reference:
- /// acclient_2013_pseudo_c.txt:321678.
+ /// The already carries the wrapper overload's
+ /// (0x00537fd0) work: Position = globalized center (entity frame at
+ /// registration), Radius pre-scaled; CSphere uses full 3-D distance (no
+ /// height clamp — unlike CCylSphere). Ethereal targets keep the existing
+ /// early-OK consume site 1 (retail's void ethereal branch produces no block);
+ /// the caller's Layer-2 override is the door passability mechanism, and the
+ /// step-down pass never reaches here for ethereal targets (pc:276799 skip).
///
///
- private TransitionState SphereCollision(ShadowEntry obj, SpherePath sp)
+ /// Retail var_8_1 (pc:276846; ACE
+ /// PhysicsObj.cs:409): the target is a creature OR a missile
+ /// (state & MISSILE_PS 0x40). Gates OFF the "stand-on / land-on"
+ /// branches (2 and 5) — you push against a creature horizontally but never
+ /// rest on it. Computed by the caller.
+ private TransitionState SphereCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine, bool isCreature)
{
// Consume site 1 — CSphere::intersects_sphere @ 0x00537ae4 (pc:321692).
- // 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:321692.
+ // Ethereal-alone (state & 0x4): retail's void ethereal branch returns
+ // without a blocking result. Mirror by returning OK immediately — the
+ // caller's Layer-2 override handles non-static ethereal passability for
+ // the other shape branches; only the Sphere branch early-outs here.
+ // Unchanged from the pre-port behavior → every ethereal/door test stays
+ // identical.
if (sp.ObstructionEthereal)
return TransitionState.OK;
var ci = CollisionInfo;
- Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
- Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
- float sphRadius = sp.GlobalSphere[0].Radius;
- Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
+ var oi = ObjectInfo;
+ var s0 = sp.GlobalSphere[0];
+ Vector3 disp0 = s0.Origin - obj.Position;
+ // radsum: ε shaved ONCE (0x00537acd). Resting flush against a monster is
+ // a NON-overlap, so a shuffle that ends touching settles instead of
+ // re-colliding every frame — the first wiggle-room ingredient TS-45 lost.
+ float radsum = s0.Radius + obj.Radius - PhysicsGlobals.EPSILON;
- // 3-D distance from check position to target sphere centre.
- // Unlike CCylSphere (which clips to a height range and uses XY-only
- // distance), CSphere uses the full 3-D Euclidean distance.
- // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 —
- // the displacement vector is the full (x,y,z) delta, not XY-only.
- float dx = sphereCheckPos.X - obj.Position.X;
- float dy = sphereCheckPos.Y - obj.Position.Y;
- float dz = sphereCheckPos.Z - obj.Position.Z;
- float distSq = dx * dx + dy * dy + dz * dz;
- float combinedR = sphRadius + obj.Radius;
- float combinedRSq = combinedR * combinedR;
+ bool hasHead = sp.NumSphere > 1;
+ Vector3 disp1 = default;
+ if (hasHead)
+ disp1 = sp.GlobalSphere[1].Origin - obj.Position;
- if (distSq >= combinedRSq)
- return TransitionState.OK; // not overlapping at check position
-
- // ── Overlap detected — compute 3-D outward collision normal ──────
- float dist = MathF.Sqrt(distSq);
- Vector3 collisionNormal;
- if (dist < PhysicsGlobals.EPSILON)
+ // ── Branch 1 (0x00537ae4): placement — detection only. ──
+ if (sp.InsertType == InsertType.Placement)
{
- // Sphere centers coincide — push back along reverse movement.
- float mLen = sphMovement.Length();
- if (mLen > PhysicsGlobals.EPSILON)
- collisionNormal = -sphMovement / mLen;
- else
- collisionNormal = Vector3.UnitX;
- }
- else
- {
- collisionNormal = new Vector3(dx / dist, dy / dist, dz / dist);
+ if (SphereCollidesWithSphere(disp0, radsum))
+ return TransitionState.Collided;
+ if (hasHead && SphereCollidesWithSphere(disp1, radsum))
+ return TransitionState.Collided;
+ return TransitionState.OK;
}
- // ── Wall-slide response (mirrors CylinderCollision but in 3-D) ───
- // Project movement onto the plane perpendicular to the collision normal,
- // then push the slid position outside the combined-radius shell.
- float movementIntoWall = Vector3.Dot(sphMovement, collisionNormal);
- Vector3 projectedMovement = sphMovement - collisionNormal * movementIntoWall;
-
- Vector3 slidPos = sphereCurrPos + projectedMovement;
-
- // Ensure slid position is outside combined radius (3-D push).
- float sdx = slidPos.X - obj.Position.X;
- float sdy = slidPos.Y - obj.Position.Y;
- float sdz = slidPos.Z - obj.Position.Z;
- float sDistSq = sdx * sdx + sdy * sdy + sdz * sdz;
- float minDist = combinedR + 0.01f;
- if (sDistSq < minDist * minDist)
+ // ── Branch 2 (0x00537af5): step-down probe — land on the top. ──
+ if (sp.StepDown)
{
- float sDist = MathF.Sqrt(sDistSq);
- if (sDist < PhysicsGlobals.EPSILON)
+ if (isCreature)
+ return TransitionState.OK; // §8.1 — never stand ON a creature/missile
+ return SphereStepSphereDown(obj, sp, disp0, radsum);
+ }
+
+ // ── Branch 3 (0x00537b27): walkable probe — occupancy blocks. ──
+ if (sp.CheckWalkable)
+ {
+ if (SphereCollidesWithSphere(disp0, radsum))
+ return TransitionState.Collided;
+ if (hasHead && SphereCollidesWithSphere(disp1, radsum))
+ return TransitionState.Collided;
+ return TransitionState.OK;
+ }
+
+ if (!sp.Collide)
+ {
+ // ── Branch 4 (0x00537de8): normal transition sweep. ──
+ if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0)
{
- slidPos.X = obj.Position.X + collisionNormal.X * minDist;
- slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
- slidPos.Z = obj.Position.Z + collisionNormal.Z * minDist;
+ // Grounded: foot hit → step over / slide; head hit → slide.
+ if (SphereCollidesWithSphere(disp0, radsum))
+ return SphereStepSphereUp(obj, sp, engine, disp0, radsum);
+ if (hasHead && SphereCollidesWithSphere(disp1, radsum))
+ return SphereSlideSphere(obj, sp, 1);
+ }
+ else if ((oi.State & ObjectInfoState.PathClipped) != 0)
+ {
+ if (SphereCollidesWithSphere(disp0, radsum))
+ return SphereCollideWithPoint(obj, sp, s0, radsum, 0);
}
else
{
- float pushDist = minDist - sDist;
- slidPos.X += (sdx / sDist) * pushDist;
- slidPos.Y += (sdy / sDist) * pushDist;
- slidPos.Z += (sdz / sDist) * pushDist;
+ // Airborne: foot hit → land on the sphere top; head hit → point hit.
+ if (SphereCollidesWithSphere(disp0, radsum))
+ return SphereLandOnSphere(obj, sp);
+ if (hasHead && SphereCollidesWithSphere(disp1, radsum))
+ return SphereCollideWithPoint(obj, sp, sp.GlobalSphere[1], radsum, 1);
}
+ return TransitionState.OK;
}
- Vector3 delta = slidPos - sphereCheckPos;
- sp.AddOffsetToCheckPos(delta);
+ // ── Branch 5 (0x00537b80): collide-flag re-test — exact-TOI landing on a
+ // sphere's curved top (a TILTED contact plane by the sphere-to-sphere
+ // direction, unlike the cylinder's flat disc). Creature/missile-gated OFF;
+ // in M1.5 only non-creature Sphere-shape statics reach it (rare — statics
+ // are CylSphere/BSP). Ported per ACE Sphere.cs:369-414.
+ if (isCreature)
+ return TransitionState.OK; // §8.1
- ci.SetCollisionNormal(collisionNormal);
- ci.SetSlidingNormal(collisionNormal);
- return TransitionState.Slid;
+ bool hit0 = SphereCollidesWithSphere(disp0, radsum);
+ if (!hit0 && !(hasHead && SphereCollidesWithSphere(disp1, radsum)))
+ return TransitionState.OK;
+
+ // Block offset = 0 (continuous world frame; same note as SlideSphere's gDelta).
+ Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin;
+ float radsumEps = radsum + PhysicsGlobals.EPSILON;
+ float lenSq = movement.LengthSquared();
+ if (MathF.Abs(lenSq) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+ float diff = -Vector3.Dot(movement, disp0);
+ float disc = diff * diff - (disp0.LengthSquared() - radsumEps * radsumEps) * lenSq;
+ if (disc < 0f)
+ return TransitionState.Collided; // defensive: retail reaches the TOI only via a colliding sphere (disc≥0)
+ float tt = MathF.Sqrt(disc) + diff;
+ if (tt > 1f) tt = diff * 2f - tt;
+ float time = tt / lenSq;
+ float timecheck = (1f - time) * sp.WalkInterp;
+ if (timecheck >= sp.WalkInterp || timecheck < -0.1f)
+ return TransitionState.Collided;
+ movement *= time;
+ Vector3 dispN = (disp0 + movement) / radsumEps;
+ if (dispN.Z <= sp.WalkableAllowance) // !is_walkable_allowable — sphere top too steep to rest on
+ return TransitionState.OK;
+ Vector3 restPt = s0.Origin - dispN * s0.Radius;
+ // is_water=1 verbatim retail (0x00536ecf). Do not "fix".
+ var contactPlane = new Plane(dispN, -Vector3.Dot(dispN, restPt));
+ ci.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
+ sp.WalkInterp = timecheck;
+ sp.AddOffsetToCheckPos(movement);
+ return TransitionState.Adjusted;
+ }
+
+ ///
+ /// Retail CSphere::collides_with_sphere (0x005369e0, pc:320964): pure
+ /// 3-D overlap — |disp|² <= radsum². = the
+ /// mover sphere center − target center. No XY/Z-band split — that's the
+ /// cylinder.
+ ///
+ private static bool SphereCollidesWithSphere(Vector3 disp, float radsum)
+ => disp.LengthSquared() <= radsum * radsum;
+
+ ///
+ /// Retail CSphere::step_sphere_up (0x00537900, pc:321611). Too tall to
+ /// step over → slide; otherwise the generic step-up ( =
+ /// CTransition::step_up). Unlike the cylinder path, retail passes the
+ /// UNNORMALIZED global_curr_center − center and does NOT rotate by the
+ /// target frame (a sphere has no orientation, 0x0053794e). For a full-height
+ /// creature the head clearance far exceeds step_up_height, so this always
+ /// slides — the grounded-crowd path.
+ ///
+ private TransitionState SphereStepSphereUp(ShadowEntry obj, SpherePath sp,
+ PhysicsEngine engine, Vector3 disp0, float radsum)
+ {
+ // radsum += ε here (dispatcher shaved it once; net = s0.r + obj.r) —
+ // retail 0x0053790a / ACE Sphere.cs:688.
+ float radsumEps = radsum + PhysicsGlobals.EPSILON;
+ if (ObjectInfo.StepUpHeight < radsumEps - disp0.Z)
+ return SphereSlideSphere(obj, sp, 0);
+
+ // Unnormalized center-to-center (retail 0x0053794e passes var_c raw).
+ Vector3 n = sp.GlobalCurrCenter[0].Origin - obj.Position;
+
+ // engine==null only in bare unit-test transitions with no step-up
+ // machinery — the retail-faithful fallback is the slide.
+ if (engine is not null && DoStepUp(n, engine))
+ return TransitionState.OK;
+
+ return sp.StepUpSlide(this);
+ }
+
+ ///
+ /// Retail CSphere::slide_sphere (0x00537440 core via the 0x00537a10
+ /// wrapper, pc:321403/321660): collision normal =
+ /// global_curr_center[sphereNum] − center (normalized), then the shared
+ /// crease projection (). THE wiggle-room primitive —
+ /// the mover slides ALONG the tangent where the monster's side meets the
+ /// ground, toward the gap, instead of being force-pushed radially out.
+ ///
+ private TransitionState SphereSlideSphere(ShadowEntry obj, SpherePath sp, int sphereNum)
+ {
+ Vector3 n = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position;
+ if (NormalizeCheckSmall(ref n))
+ return TransitionState.Collided;
+ return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum);
+ }
+
+ ///
+ /// Retail CSphere::land_on_sphere (0x005379a0, pc:321642): airborne
+ /// foot hit — arm the Collide re-test (backup + flag) and relax the walkable
+ /// allowance to LandingZ. The NEXT attempt's branch 5 rests the sphere on the
+ /// top with the exact time-of-impact. Identical shape to
+ /// .
+ ///
+ private TransitionState SphereLandOnSphere(ShadowEntry obj, SpherePath sp)
+ {
+ Vector3 n = sp.GlobalCurrCenter[0].Origin - obj.Position;
+ if (NormalizeCheckSmall(ref n))
+ return TransitionState.Collided;
+ sp.SetCollide(n);
+ sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x005379f2)
+ return TransitionState.Adjusted;
+ }
+
+ ///
+ /// Retail CSphere::collide_with_point (0x00537230, pc:321327):
+ /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip records the
+ /// center-to-center collision normal and hard-stops (the M1.5 load-bearing
+ /// path — players never set PerfectClip). PerfectClip gets the exact
+ /// time-of-impact reposition (missiles only — AP-84, dead in M1.5, ported per
+ /// ACE Sphere.cs:175-210; re-verify vs Ghidra before missiles ship).
+ ///
+ private TransitionState SphereCollideWithPoint(ShadowEntry obj, SpherePath sp,
+ Sphere checkSphere, float radsum, int sphereNum)
+ {
+ Vector3 gCenter = sp.GlobalCurrCenter[sphereNum].Origin;
+ Vector3 globalOffset = gCenter - obj.Position;
+
+ if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
+ {
+ if (!NormalizeCheckSmall(ref globalOffset))
+ CollisionInfo.SetCollisionNormal(globalOffset);
+ return TransitionState.Collided;
+ }
+
+ // PerfectClip exact time-of-impact (AP-84 — dead in M1.5). Block offset = 0.
+ Vector3 checkOffset = checkSphere.Origin - gCenter;
+ double toi = FindSphereTimeOfCollision(checkOffset, globalOffset, radsum + PhysicsGlobals.EPSILON);
+ if (toi < PhysicsGlobals.EPSILON || toi > 1.0)
+ return TransitionState.Collided;
+ Vector3 collisionOffset = checkOffset * (float)toi - checkOffset;
+ Vector3 oldDisp = collisionOffset + checkSphere.Origin - obj.Position;
+ CollisionInfo.SetCollisionNormal(oldDisp / radsum);
+ sp.AddOffsetToCheckPos(oldDisp);
+ return TransitionState.Adjusted;
+ }
+
+ ///
+ /// Retail CSphere::step_sphere_down (0x00536d20, pc:321133): during a
+ /// step-down probe, land the foot sphere on a sphere's curved top — contact
+ /// plane tilted by the sphere-to-sphere direction (unlike the cylinder's flat
+ /// disc). Creature/missile-gated OFF at the dispatcher (branch 2). Ported per
+ /// ACE Sphere.cs:617-664.
+ ///
+ private TransitionState SphereStepSphereDown(ShadowEntry obj, SpherePath sp,
+ Vector3 disp0, float radsum)
+ {
+ bool hit = SphereCollidesWithSphere(disp0, radsum);
+ if (!hit && sp.NumSphere > 1)
+ {
+ Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position;
+ hit = SphereCollidesWithSphere(disp1, radsum);
+ }
+ if (!hit)
+ return TransitionState.OK;
+
+ float stepDown = sp.StepDownAmt * sp.WalkInterp;
+ if (MathF.Abs(stepDown) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+
+ float radsumEps = radsum + PhysicsGlobals.EPSILON;
+ // Curved-top height at this XY offset (the sphere equation solved for z).
+ float underRoot = radsumEps * radsumEps - (disp0.X * disp0.X + disp0.Y * disp0.Y);
+ if (underRoot < 0f)
+ return TransitionState.Collided; // defensive: XY already outside radsum
+ float val = MathF.Sqrt(underRoot);
+ float scaledStep = (val - disp0.Z) / stepDown;
+ float timecheck = (1f - scaledStep) * sp.WalkInterp;
+ if (timecheck >= sp.WalkInterp || timecheck < -0.1f)
+ return TransitionState.Collided;
+
+ float interp = stepDown * scaledStep;
+ Vector3 dispN = new Vector3(disp0.X, disp0.Y, disp0.Z + interp) / radsumEps;
+ if (dispN.Z <= sp.WalkableAllowance)
+ return TransitionState.OK;
+
+ // Contact point on the TARGET sphere surface (retail 0x00536e9a:
+ // this.center + this.radius·disp).
+ Vector3 restPt = obj.Position + dispN * obj.Radius;
+ var restPlane = new Plane(dispN, -Vector3.Dot(dispN, restPt));
+ CollisionInfo.SetContactPlane(restPlane, sp.CheckCellId, isWater: true);
+ sp.WalkInterp = timecheck;
+ sp.AddOffsetToCheckPos(new Vector3(0f, 0f, interp));
+ return TransitionState.Adjusted;
+ }
+
+ ///
+ /// Sphere-vs-sphere swept time-of-collision (retail
+ /// CSphere::FindTimeOfCollision; ACE Sphere.cs:232). Returns a 0-1
+ /// fraction along , or -1 for no forward hit. Used
+ /// only by the (M1.5-dead) PerfectClip branch of
+ /// .
+ ///
+ private static double FindSphereTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)
+ {
+ float distSq = movement.LengthSquared();
+ if (distSq < PhysicsGlobals.EPSILON) return -1;
+ float nonCollide = spherePos.LengthSquared() - radSum * radSum;
+ if (nonCollide < PhysicsGlobals.EPSILON) return -1;
+ float similar = -Vector3.Dot(spherePos, movement);
+ double nonCollideB = (double)similar * similar - (double)nonCollide * distSq;
+ if (nonCollideB < 0) return -1;
+ double cDist = Math.Sqrt(nonCollideB);
+ if (similar - cDist < 0)
+ return -1 * (cDist + similar) / distSq;
+ return -1 * (similar - cDist) / distSq;
}
///
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs
index 7677b548..925ba472 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
@@ -90,4 +91,32 @@ public class ShadowShapeBuilderShapeSourceTests
Assert.Empty(shapes);
}
+
+ // 2026-07-07 — the PREMISE of the player-vs-monster crowd-collision fix (the
+ // CSphere family port): a humanoid Setup carries body Spheres and NO
+ // CylSpheres, so FromSetup emits Sphere-type shadows → collision runs through
+ // Transition.SphereCollision (the retail CSphere::intersects_sphere family),
+ // NOT the cylinder path. Mirrors the human Setup 0x02000001 (two body spheres
+ // at 0.475 / 1.350, r=0.48 — TS-46 / the physics digest). If this ever flips
+ // to CylSphere-first, the crowd fix would be aimed at the wrong dispatcher.
+ [Fact]
+ public void Setup_WithBodySpheres_NoCylSpheres_EmitsSphereShapes()
+ {
+ var setup = new Setup
+ {
+ CylSpheres = new List(),
+ Spheres = new List
+ {
+ new Sphere { Origin = new Vector3(0f, 0f, 0.475f), Radius = 0.48f },
+ new Sphere { Origin = new Vector3(0f, 0f, 1.350f), Radius = 0.48f },
+ },
+ Parts = new List>(),
+ PlacementFrames = new Dictionary(),
+ };
+
+ var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false);
+
+ Assert.Equal(2, shapes.Count);
+ Assert.All(shapes, s => Assert.Equal(ShadowCollisionType.Sphere, s.CollisionType));
+ }
}
diff --git a/tests/AcDream.Core.Tests/Physics/SphereCollisionFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/SphereCollisionFamilyTests.cs
new file mode 100644
index 00000000..b2545c0e
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/SphereCollisionFamilyTests.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+using Xunit.Abstractions;
+using Plane = System.Numerics.Plane;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance tests for the retail CSphere::intersects_sphere collision
+/// family port (2026-07-07) — dispatcher 0x00537A80 + step_sphere_up
+/// 0x00537900 + slide_sphere 0x00537440 +
+/// land_on_sphere 0x005379a0 + collide_with_point
+/// 0x00537230 + step_sphere_down 0x00536d20. Pseudocode:
+/// docs/research/2026-07-07-csphere-collision-family-pseudocode.md.
+///
+///
+/// The driving repro: the user's live report that a player packed inside a crowd
+/// of monsters gets wedged and can't wiggle free, where retail leaves room to
+/// shuffle out. Humanoid creatures/players collide as body Spheres
+/// (Setup.Spheres → emits
+/// ), so the crowd contact runs through
+/// Transition.SphereCollision. Before this port that method was a
+/// hand-rolled 3-D wall-slide with a forced fixed de-penetration (register
+/// TS-45) — opposing radial pushes from neighbours cancelled and the
+/// player wedged. The faithful family routes the slide through the shared crease
+/// projection, restoring retail's tangential shuffle. Synthetic spheres only — no
+/// dat dependency.
+///
+///
+public class SphereCollisionFamilyTests
+{
+ private readonly ITestOutputHelper _out;
+ public SphereCollisionFamilyTests(ITestOutputHelper output) => _out = output;
+
+ private const uint TestLandblockId = 0xA9B40000u;
+ private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0)
+
+ private const float SphereRadius = 0.48f; // retail player capsule radius
+ private const float SphereHeight = 1.20f;
+ private const float StepUpHeight = 0.60f;
+ private const float StepDownHeight = 0.04f;
+
+ private const float CreatureRadius = 0.48f; // a humanoid body sphere
+
+ ///
+ /// THE headline behavior: the crease slide lets a grounded player SHUFFLE
+ /// AROUND a body-sphere creature that partly blocks the path, instead of
+ /// sticking to it. The creature sits slightly EAST of the player's northward
+ /// line; the player walks N, grazes the creature's south-west, and must slide
+ /// along it (crease = collisionNormal × ground-up) and continue past.
+ ///
+ ///
+ /// This is the retail-faithful tangential slide the port restores. The
+ /// pre-2026-07-07 hand-rolled SphereCollision force-pushed the contact
+ /// RADIALLY to a fixed combinedR + 1 cm shell (shoving the player
+ /// south-west, back along its approach) — TS-45. In a crowd those radial
+ /// shoves from several neighbours fight each other and wedge the player; the
+ /// tangential crease slide is what leaves "room to shuffle out". The player
+ /// starts CLEAR (not penetrating) — a start deep inside overlapping spheres
+ /// reverts in retail too (validate_transition restores curr_pos on a
+ /// non-clean step, 0x0050aa70:272593), so that is not a valid repro.
+ ///
+ ///
+ [Fact]
+ public void GroundedDiagonalApproach_SlidesPastOffsetCreature()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCreatureSphere(engine, 0xC0C0u, 10.35f, 12f); // slightly EAST of the N path
+
+ var body = MakeGroundedBody(new Vector3(10f, 10f, 0f));
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ var perTick = new Vector3(0f, 0.08f, 0f); // push NORTH
+
+ for (int tick = 0; tick < 60; tick++)
+ {
+ var result = engine.ResolveWithTransition(
+ pos, pos + perTick, cellId,
+ SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
+ grounded, body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0);
+
+ body.Position = result.Position;
+ pos = result.Position;
+ cellId = result.CellId;
+ grounded = result.IsOnGround;
+ _out.WriteLine($"tick {tick,2}: ({pos.X:F3},{pos.Y:F3})");
+ }
+
+ _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}");
+
+ // The creature centre is at Y=12. If the player slid around it, it ends
+ // NORTH of the creature; if it stuck, it wedges near Y≈11 (surface at
+ // Y = 12 − sqrt(0.96² − 0.35²) ≈ 11.11).
+ Assert.True(pos.Y > 12.5f,
+ $"Player must slide around the offset creature and continue north, not stick at " +
+ $"its surface; got Y={pos.Y:F3}");
+ }
+
+ ///
+ /// A single body-sphere creature dead ahead: the player pushing straight into
+ /// it must stop AT the surface (a head-on push has no crease tangent → the
+ /// slide degenerates → Collided) and must NOT penetrate or pass through.
+ /// Surface contact is at Y = 11.5 − (0.48+0.48) = 10.54.
+ ///
+ [Fact]
+ public void GroundedSingleCreature_HeadOnPush_BlocksWithoutPenetration()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCreatureSphere(engine, 0xC0B0u, 12f, 11.5f); // due north
+
+ var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ var perTick = new Vector3(0f, 0.08f, 0f); // straight in
+
+ for (int tick = 0; tick < 30; tick++)
+ {
+ var result = engine.ResolveWithTransition(
+ pos, pos + perTick, cellId,
+ SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
+ grounded, body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0);
+
+ body.Position = result.Position;
+ pos = result.Position;
+ cellId = result.CellId;
+ grounded = result.IsOnGround;
+ }
+
+ _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
+
+ Assert.True(pos.Y < 10.7f,
+ $"Player must be blocked at the sphere surface (Y≈10.54), not penetrate; got Y={pos.Y:F3}");
+ Assert.True(pos.Y > 10.3f,
+ $"Player must actually reach the creature (not stop early); got Y={pos.Y:F3}");
+ }
+
+ ///
+ /// An ETHEREAL sphere (state 0x4, non-static) is fully passable — the branch-1
+ /// early-OK consume plus the caller's Layer-2 override. Guards the door/ghost
+ /// passability against the family rewrite.
+ ///
+ [Fact]
+ public void GroundedEtherealSphere_IsFullyPassable()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCreatureSphere(engine, 0xC0E0u, 12f, 11.5f,
+ state: 0x4u, isCreatureFlag: false); // ETHEREAL_PS, non-static
+
+ var body = MakeGroundedBody(new Vector3(12f, 10f, 0f));
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ var perTick = new Vector3(0f, 0.08f, 0f);
+
+ for (int tick = 0; tick < 45; tick++)
+ {
+ var result = engine.ResolveWithTransition(
+ pos, pos + perTick, cellId,
+ SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
+ grounded, body: body,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0);
+
+ body.Position = result.Position;
+ pos = result.Position;
+ cellId = result.CellId;
+ grounded = result.IsOnGround;
+ }
+
+ _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
+
+ Assert.True(pos.Y > 12.5f,
+ $"Ethereal sphere must not block (walked from 10 past the axis); got Y={pos.Y:F3}");
+ }
+
+ // ───────────────────────────────────────────────────────────────
+ // Harness (mirrors CylSphereFamilyTests)
+ // ───────────────────────────────────────────────────────────────
+
+ private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
+ {
+ cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ var heights = new byte[81];
+ var heightTable = new float[256]; // all zero → terrain Z = 0
+ engine.AddLandblock(
+ landblockId: TestLandblockId,
+ terrain: new TerrainSurface(heights, heightTable),
+ cells: Array.Empty(),
+ portals: Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ return engine;
+ }
+
+ ///
+ /// Register a humanoid body sphere at foot height (Z = SphereRadius) so the
+ /// player's foot sphere overlaps it horizontally. Flagged as a creature by
+ /// default (the crowd case), mimicking a live monster's body-sphere shadow.
+ ///
+ private static void RegisterCreatureSphere(PhysicsEngine engine, uint entityId,
+ float x, float y, float radius = CreatureRadius, uint state = 0u,
+ bool isCreatureFlag = true)
+ {
+ engine.ShadowObjects.Register(
+ entityId, gfxObjId: 0u,
+ new Vector3(x, y, SphereRadius), Quaternion.Identity, radius,
+ worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
+ collisionType: ShadowCollisionType.Sphere,
+ cylHeight: 0f, scale: 1f,
+ state: state,
+ flags: isCreatureFlag ? EntityCollisionFlags.IsCreature : EntityCollisionFlags.None,
+ isStatic: false);
+ }
+
+ private static PhysicsBody MakeGroundedBody(Vector3 position)
+ {
+ var floorPlane = new Plane(Vector3.UnitZ, 0f);
+ var floorVerts = new[]
+ {
+ new Vector3(-100f, -100f, 0f),
+ new Vector3( 100f, -100f, 0f),
+ new Vector3( 100f, 100f, 0f),
+ new Vector3(-100f, 100f, 0f),
+ };
+
+ return new PhysicsBody
+ {
+ Position = position,
+ Orientation = Quaternion.Identity,
+ ContactPlaneValid = true,
+ ContactPlane = floorPlane,
+ ContactPlaneCellId = TestCellId,
+ WalkablePolygonValid = true,
+ WalkablePlane = floorPlane,
+ WalkableVertices = floorVerts,
+ WalkableUp = Vector3.UnitZ,
+ TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ };
+ }
+}