feat(physics): Task 2 — true sphere collision primitive (CSphere::intersects_sphere)

Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r),
which is geometrically wrong: a cylinder has flat caps; a sphere does not.
This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow
entries are tested as spheres — 3-D distance, no height clamping.

Changes:
- ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2).
  The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder
  sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path
  (anyCyl=false → included), which is correct.
- ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere
  (CylHeight=0) for Setup.Spheres instead of a short Cylinder.
- CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept
  solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port
  of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention
  confirmed against the decomp: retail negates the root to produce a
  forward t ∈ (0,1].
- TransitionTypes.cs: added Sphere narrow-phase branch between BSP and
  Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap
  (not XY-only). Added SphereCollision() method implementing the 3-D
  wall-slide response. Updated diagnostic logging at :2734 to cover Sphere.
- Updated ShadowShapeBuilderTests for new Sphere type assertion.
- New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored
  cases (head-on, tangent, perpendicular-miss, lateral-near-miss,
  sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping,
  vertical-sweep).

Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail);
ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed).
Build: 0 errors, 10 warnings (pre-existing).
Tests: 1576 pass / 0 fail / 2 skip (1578 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 19:08:53 +02:00
parent 79dee342f2
commit 78e5758185
7 changed files with 609 additions and 16 deletions

View file

@ -0,0 +1,130 @@
# CSphere::intersects_sphere — swept-sphere-vs-sphere pseudocode
**Date:** 2026-06-24
**Task:** Task 2 — true sphere collision primitive (collision-inclusion phase)
---
## Oracles consulted
1. **Named-retail decomp** `acclient_2013_pseudo_c.txt`:
- `CSphere::collides_with_sphere` @ `0x005369E0` — static overlap test
- `CSphere::intersects_sphere` (primary) @ `0x00537A80` — the full 6-path dispatcher
- `CSphere::intersects_sphere` (Position variant) @ `0x00537FD0`
2. **ACE C# port** `references/ACE/Source/ACE.Server/Physics/Sphere.cs`:
- `CollidesWithSphere(Vector3 otherSphere, float radsum)` — static overlap
- `FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)` — swept solve
- `IntersectsSphere(Vector3 center, float radius, Transition transition, bool isCreature)` — 6-path dispatcher
---
## CSphere::collides_with_sphere (static overlap test)
Retail @ `0x005369E0`:
```
collides_with_sphere(this, disp_vec3, radsum_float):
lenSq = disp_vec3.x² + disp_vec3.y² + disp_vec3.z²
if radsum² > lenSq: // i.e. lenSq < radsum²
return 1 (true — overlapping)
return 0 (false)
```
ACE equivalent: `disp.LengthSquared() <= radsum * radsum`
Note: retail uses `>` (strictly greater-than radsum²), ACE uses `<=`. These are the same predicate — the
retail FPU instruction emits "collides" when radsum² is NOT less than lenSq, which is `lenSq <= radsum²`.
---
## FindTimeOfCollision (swept quadratic, from ACE)
ACE `Sphere.FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)`:
Interprets "mover starts at origin, travels by `movement`; target is at `spherePos` relative to mover".
```
distSq = |movement|² // if < EPSILON: no sweep (degenerate), return -1
nonCollide = |spherePos|² - radSum² // if < EPSILON: already overlapping no forward collision needed, return -1
similar = -dot(spherePos, movement) // projection of separation onto movement direction
disc = similar² - nonCollide * distSq // discriminant of quadratic
if disc < 0: return -1 // no real intersection
cDist = sqrt(disc)
if similar - cDist < 0:
return -(cDist + similar) / distSq
else:
return -(similar - cDist) / distSq
```
This returns a time in the range [0, 1] for the first contact.
A return of -1 means no hit (miss or already overlapping).
Values > 1 mean the sweep doesn't reach the target within the movement step.
---
## SweptSphereHitsSphere — our primitive (pure function)
Wraps `FindTimeOfCollision` with a clean bool/out API for the narrow-phase dispatch:
```
SweptSphereHitsSphere(moverCenter, moverRadius, sweepDelta, targetCenter, targetRadius, out float t):
movement = sweepDelta // vector the mover travels
spherePos = targetCenter - moverCenter // target relative to mover's start
radSum = moverRadius + targetRadius
t = (float) FindTimeOfCollision(movement, spherePos, radSum)
return t > 0 && t <= 1
```
`t` is the parametric fraction of `sweepDelta` at which surfaces first touch.
`t <= 0`: target is behind or already overlapping (use static test separately).
`t > 1`: sweep misses (target too far in this step).
---
## Retail dispatch order for Sphere objects
From `CSphere::intersects_sphere @ 0x00537A80` — the same 6-path structure as for CylSpheres:
1. `obstruction_ethereal || insert_type == PLACEMENT_INSERT`:
Static overlap test only (`collides_with_sphere`). Return Collided or OK.
2. `step_down != 0`:
Delegates to `step_sphere_down` (for non-creature movers).
3. `check_walkable != 0`:
Static overlap test. Return Collided or OK.
4. `collide == 0`:
Sub-dispatch on `object_info.state & 3` (Contact/OnWalkable):
- Contact: step_sphere_up or slide_sphere
- PathClipped: collide_with_point
- Default: land_on_sphere or collide_with_point
5. `collide != 0` + `isCreature`:
Return OK (creatures don't block each other via sphere-sphere in this path).
6. `collide != 0` + not creature:
Full swept quadratic. Set contact plane, adjust check_pos.
For our narrow-phase dispatch in `FindObjCollisionsInCell`, the "narrow-phase Sphere branch"
maps directly to ACE's `IntersectsSphere` — which acdream already implements for Cylinder objects
via `CylinderCollision`. The sphere primitive just provides the swept check without the cylinder's
height clipping.
---
## Acdream adaptation note
The `SweptSphereHitsSphere` primitive is PURE (no Transition state). The actual 6-path dispatch
(step-up, land-on, slide, etc.) is handled by the existing `CylinderCollision` infrastructure —
for Sphere-typed shadow entries we call through the same dispatcher after the overlap check,
using 3-D distance for the broad-phase (not XY-only cylinder distance).
The primitive's narrow phase: `static overlap` (`CollidesWithSphere`) is the gate; the swept
quadratic from `FindTimeOfCollision` resolves the time-of-contact for the walkable landing path.
For the initial ship (Task 2), we implement the static overlap test in the dispatch
(matching the `obstruction_ethereal`/`check_walkable`/`Contact` paths that don't use the swept
form), plus `SweptSphereHitsSphere` for the swept narrow-phase. The full 6-path wiring for
sphere objects mirrors the cylinder path already in `CylinderCollision`, extended to use 3-D
distance instead of XY-only.