# 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.