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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
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