acdream/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md
Erik 6ab269894a fix #172: port the retail CCylSphere collision family (platform step-up)
The Holtburg town-network portal platform (stab 0xC0A9B465, Setup
0x020019E3, CylSphere r=2.597m h=0.256m) blocked the player with an
endless rim slide instead of retail's step-up-onto-top — gating the
whole #137 dungeon repro. Surfaced when #149 started registering
BSP-less stab CylSpheres: the collision SHAPE became right while the
RESPONSE was still the hand-rolled AP-6 approximation (step-up gate +
radial wall-slide only).

Root cause: no cylinder-TOP support anywhere. DoStepUp's internal
step-down probe needs retail's step_sphere_down (0x0053a9b0) to land on
the flat top — a cylinder has no polygons for the walkable search — so
every step-up onto a wide cylinder failed into StepUpSlide and the
player orbited the rim (probe-confirmed: [cyl-test] result=Slid with
horizontal rim normals, launch-137-repro.log).

Port the full family verbatim: dispatcher intersects_sphere 0x0053b440
(placement/ethereal detection, step-down cap landing, walkable probe,
grounded step_sphere_up 0x0053b310, PathClipped collide_with_point
0x0053acb0, airborne land_on_cylinder 0x0053b3d0, Collide-flag
exact-TOI cap rest) + collides_with_sphere 0x0053a880 +
normal_of_collision 0x0053ab50 + slide_sphere 0x0053b2a0. Pseudocode +
settled BN x87 ambiguities (via ACE cross-ref) + two ACE bugs found and
NOT copied (head-slide foot-disp; see doc §8):
docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md

Ethereal cylinders now flow through retail's Layer-2 override
(pc:276961) instead of the early-OK consume — same net #150 behavior,
plus retail placement-blocked-by-cylinder semantics. SlideSphere gains
a sphereNum param (retail slides the head sphere by its own
displacement, 0x0053b843).

Register: AP-6 retired; AP-83 added (PerfectClip TOI tail decoded per
ACE, dead code until missiles). Tests: CylSphereFamilyTests (grounded
step-up onto the exact platform shape, tall-cylinder block, airborne
top landing, ethereal guard); the #42 self-shadow control assertion
updated to the retail observable (denied movement — the old ~1m radial
self-push was the approximation's artifact, not retail). Suites: Core
2533 / App 713 / UI 425 / Net 385 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:52:28 +02:00

12 KiB
Raw Blame History

CCylSphere collision family — retail pseudocode (port prep)

Date: 2026-07-05 · Trigger: the Holtburg town-network portal platform (stab 0xC0A9B465, Setup 0x020019E3, CylSphere r=2.597 m h=0.256 m) blocks the player with an endless rim slide instead of the retail step-up-onto-top. Surfaced the moment #149 (4cf6eeb) started registering BSP-less stab CylSpheres — the collision SHAPE is right; the RESPONSE family was never ported. Feeds #137 (dungeon door feet flow through the same dispatcher).

Sources: named-retail pseudo-C (addresses below) = ground truth; references/ACE/Source/ACE.Server/Physics/CylSphere.cs = cross-reference (settles BN x87 garbles; one ACE bug found, noted in §8).

Retail function inventory

Function Address pseudo-C line
CCylSphere::intersects_sphere(CTransition*) — dispatcher 0x0053b440 :324558
CCylSphere::intersects_sphere(Position*, float scale, CTransition*) — wrapper 0x0053b8f0 :324744
CCylSphere::collides_with_sphere 0x0053a880 :323943
CCylSphere::normal_of_collision 0x0053ab50 :324102
CCylSphere::collide_with_point 0x0053acb0 :324173
CCylSphere::slide_sphere 0x0053b2a0 :324502
CCylSphere::step_sphere_up 0x0053b310 :324516
CCylSphere::land_on_cylinder 0x0053b3d0 :324542
CCylSphere::step_sphere_down 0x0053a9b0 :324032
COLLISIONINFO::set_contact_plane(plane, is_water) 0x00509d80 :271925

1. Wrapper (0x0053b8f0) — globalize the cylinder

intersects_sphere(cyl, Position* objPos, float scale, CTransition* t):
    SPHEREPATH::cache_localspace_sphere(&t->sphere_path, objPos, 1f)
    world_cyl = { low_pt: objPos.localtoglobal(cyl.low_pt * scale),
                  radius: cyl.radius * scale,
                  height: cyl.height * scale }
    return world_cyl.intersects_sphere(t)      // axis stays world-Z

acdream mapping: ShadowEntry already stores the globalized base point (Position = entity pos + rotated scaled local offset, registration sites in GameWindow.cs) and pre-scaled Radius/CylHeight — the wrapper's work is done at registration. cache_localspace_sphere matters only for localspace_pos (used by step_sphere_up's normal rotation, §6).

2. collides_with_sphere (0x0053a880) — pure overlap test

collides_with_sphere(cyl, CSphere* sphere, Vector3* disp, float radsum):
    // disp = sphere.center  cyl.low_pt (caller computes)
    if (disp.x² + disp.y² <= radsum²)                       // XY overlap
        halfH = cyl.height * 0.5
        if (|halfH  disp.z| <= sphere.radius  F_EPSILON + halfH)  // Z band
            return 1
    return 0

radsum at every call site = cyl.radius F_EPSILON + sphere.radius (ε shaved ONCE, in the dispatcher preamble). The ε is what makes "resting exactly on the top" a non-overlap, so landings settle instead of re-colliding.

3. Dispatcher (0x0053b440)

intersects_sphere(cyl, CTransition* t):        // cyl in world frame
    sp = t.sphere_path;  oi = t.object_info
    s0 = sp.global_sphere[0];  disp0 = s0.center  low_pt
    if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center  low_pt
    radsum = cyl.radius  F_EPSILON + s0.radius

    // ── branch 1: placement / ethereal — detection only ──
    if (sp.insert_type == PLACEMENT_INSERT || sp.obstruction_ethereal):
        if collides(s0, disp0) → COLLIDED
        if num_sphere>1 && collides(s1, disp1) → COLLIDED
        return OK

    // ── branch 2: step-down probe — land on the top ──
    if (sp.step_down): return step_sphere_down(t, s0, disp0, radsum)

    // ── branch 3: walkable probe — cylinder occupancy blocks ──
    if (sp.check_walkable):
        if collides(s0, disp0) → COLLIDED
        if num_sphere>1 && collides(s1, disp1) → COLLIDED
        return OK

    // ── branch 4: normal sweep (collide flag clear) ──
    if (!sp.collide):
        if (oi.state & (CONTACT|ON_WALKABLE)):              // grounded
            if collides(s0, disp0) → step_sphere_up(t, s0, disp0, radsum)
            if num_sphere>1 && collides(s1, disp1)
                → slide_sphere(t, s1, disp1, radsum, sphereNum=1)   // §8: retail passes disp1
        elif (oi.state & PATH_CLIPPED):
            if collides(s0, disp0) → collide_with_point(t, s0, disp0, radsum, 0)
        else:                                                // airborne
            if collides(s0, disp0) → land_on_cylinder(t, s0, disp0, radsum)
            if num_sphere>1 && collides(s1, disp1)
                → collide_with_point(t, s1, disp1, radsum, 1)
        return OK

    // ── branch 5: collide-flag re-test — exact-TOI cap landing ──
    if collides(s0,disp0) || (num_sphere>1 && collides(s1,disp1)):
        movement = sp.global_curr_center[0]  s0.center  block_offset(cur→check)
        if |movement.z| < F_EPSILON → COLLIDED
        timecheck = (height + s0.radius  disp0.z) / movement.z
        offset = movement * timecheck
        if radsum² < |xy(offset + disp0)|² → OK             // rewound off the cap
        t2 = (1  timecheck) * sp.walk_interp
        if t2 >= sp.walk_interp || t2 < 0.1 → COLLIDED
        pt = s0.center + offset;  pt.z = s0.radius
        ci.set_contact_plane(Plane(n=(0,0,1), d=pt.z), is_water=1)   // literal 1, §7
        ci.contact_plane_cell_id = sp.check_pos.objcell_id
        sp.walk_interp = t2
        sp.add_offset_to_check_pos(offset)
        return ADJUSTED
    return OK

State bits (verified against our ObjectInfoState): CONTACT=0x1, ON_WALKABLE=0x2, PATH_CLIPPED=0x8, PERFECT_CLIP=0x40.

4. step_sphere_down (0x0053a9b0) — land on the top during a step-down probe

step_sphere_down(t, s0, disp0, radsum):
    if !collides(s0,disp0) && !(num_sphere>1 && collides(s1,disp1)) → OK
    stepScale = sp.step_down_amt * sp.walk_interp
    if |stepScale| < F_EPSILON → COLLIDED
    deltaz = height + s0.radius  disp0.z          // lift so bottom rests on top
    interp = (1  deltaz / stepScale) * sp.walk_interp    // divisor = stepScale (BN garbled; ACE)
    if interp >= sp.walk_interp || interp < 0.1 → COLLIDED
    contactPt = (s0.center.x, s0.center.y, s0.center.z + deltaz  s0.radius)
    ci.set_contact_plane(Plane(n=(0,0,1), d=contactPt.z), is_water=1)   // §7
    ci.contact_plane_cell_id = sp.check_pos.objcell_id
    sp.walk_interp = interp
    sp.add_offset_to_check_pos((0,0,deltaz))
    return ADJUSTED

This is THE missing piece that made step-up-onto-a-wide-cylinder impossible: CTransition::step_up's internal step-down probe needs branch 2 to produce a walkable contact plane ON the cylinder top.

5. normal_of_collision (0x0053ab50)

normal_of_collision(cyl, sp, sphere, dispCheck, radsum, sphereNum, out n) → bool definite:
    dispCurr = sp.global_curr_center[sphereNum]  low_pt
    if (radsum² < dispCurr.x² + dispCurr.y²):               // curr was XY-OUTSIDE → side hit
        n = (dispCurr.x, dispCurr.y, 0)                     // radial, horizontal
        // definite unless the contact could actually be a diagonal cap hit:
        zBandOverlapAtCurr = |halfH  dispCurr.z| <= sphere.radius  F_EPSILON + halfH
        noZMovement        = |dispCurr.z  dispCheck.z| <= F_EPSILON
        return zBandOverlapAtCurr || noZMovement
    // curr was XY-INSIDE the footprint → cap hit
    n = (0, 0, (dispCheck.z  dispCurr.z <= 0) ? +1 : 1)   // descending → top (+1)
    return true

Cap polarity settled by ACE + geometry (BN's x87 branch rendering is untrustworthy here — feedback_bn_decomp_field_names class 2).

6. step_sphere_up (0x0053b310) / land_on_cylinder (0x0053b3d0) / slide_sphere (0x0053b2a0)

step_sphere_up(t, s0, disp0, radsum):
    if (oi.step_up_height < s0.radius + height  disp0.z)   // too tall
        → slide_sphere(t, s0, disp0, radsum, 0)
    definite = normal_of_collision(..., 0, out n)
    if normalize_check_small(n) → COLLIDED
    nWorld = localspace_pos.localtoglobalvec(n)             // rotate by the OBJECT's frame
    if CTransition::step_up(t, nWorld) → OK
    else → sp.step_up_slide(t)

land_on_cylinder(t, s0, disp0, radsum):                     // airborne foot hit
    normal_of_collision(..., 0, out n)
    if normalize_check_small(n) → COLLIDED
    sp.set_collide(n)                                        // backup + Collide flag
    sp.walkable_allowance = LANDING_Z (0.0871557)
    return ADJUSTED

slide_sphere(t, sphere, disp, radsum, sphereNum):
    normal_of_collision(..., sphereNum, out n)
    if normalize_check_small(n) → COLLIDED
    return CSphere::slide_sphere(sphere, sp, ci, n, sp.global_curr_center[sphereNum])

The airborne landing closes through the retry loop: land_on_cylinder (ADJUSTED, sets sp.collide) → next attempt → branch 5 exact-TOI rests the sphere on the top + CP → next attempt → ε-shaved overlap now misses → OK → TransitionalInsert Phase 3 sp.Collide placement re-test validates on the CP → landing completes.

7. collide_with_point (0x0053acb0) — PathClipped / head-sphere hits

Port per ACE CylSphere.CollideWithPoint verbatim (self-contained TOI math): non-PerfectClip movers → set_collision_normal + COLLIDED. PerfectClip → exact time-of-impact reposition (add_offset_to_check_pos) + ADJUSTED, with the not-definite branch deriving cap-vs-side from the movement.

8. Divergences + settled ambiguities (register-relevant)

  1. is_water=1 on cylinder-top contact planes is RETAIL (literal 1 at 0x0053aae2 and the branch-5 site; set_contact_plane 0x00509d80 stores arg3 → contact_plane_is_water). Port verbatim; do not "fix".
  2. ACE bug (do NOT copy): ACE's grounded head-sphere leg passes the FOOT disp to SlideSphere; retail 0x0053b843 passes the HEAD disp (x_2). Retail wins. (Class: feedback_bn_decomp_field_names #3 — ACE decode wrong in a branch ACE rarely exercises.)
  3. Block offset in branch 5: retail subtracts the cur→check landblock offset; acdream's physics frame is continuous world-space → offset = 0. Standing frame adaptation (same as SlideSphere's gDelta note).
  4. Ethereal targets: branch 1 returns COLLIDED on overlap even for ethereal; passability comes from the caller's Layer-2 override (pc:276961-276989, non-static + !step_down → forced OK) plus the #150 step-down skip. The previous port consumed ObstructionEthereal with an early OK before any test — response-equivalent for non-static targets, but branch 1 is the faithful shape and also gives placement inserts the retail blocked-by-cylinder semantics. Ported faithfully now.
  5. normalize_check_small = normalize; returns true (fail) when |v| < ε before normalizing — maps to LengthSquared() < EpsilonSq guard.
  6. step_sphere_up normal rotation: retail rotates the collision normal by the target OBJECT's frame (localspace_pos = the object's Position cached by the wrapper) before CTransition::step_up. For yaw-only AC objects this only affects yawed radial normals; ported faithfully via Vector3.Transform(n, obj.Rotation).

9. acdream port surface

Transition.CylinderCollision (TransitionTypes.cs) becomes the branch-4/5 dispatcher body; new private siblings CylCollidesWithSphere, CylNormalOfCollision, CylStepSphereUp, CylStepSphereDown, CylSlideSphere, CylLandOnCylinder, CylCollideWithPoint. Callers unchanged (FindObjCollisionsInCell Cylinder branch; the BspOnlyDispatch gate and the #150 ethereal step-down skip sit ABOVE this dispatch and are unaffected). DoStepUp (= CTransition::step_up, A6.P6) and SpherePath.StepUpSlide are reused as-is.