diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index d6917dbd..0e973558 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,6 +46,56 @@ Copy this block when adding a new issue:
---
+## #172 β Town-network portal platform blocks instead of stepping up (CCylSphere family was never ported)
+
+**Status:** π‘ FIX SHIPPED 2026-07-05 (this commit) β pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run).
+**Severity:** HIGH (blocks dungeon access β gates the whole #137 repro)
+**Filed:** 2026-07-05
+**Component:** physics β CylSphere object collision response
+
+**Description (user):** the Holtburg town-network portal sits on a stone
+platform the player collides with instead of stepping up onto it (retail just
+walks up). Entity `0xC0A9B465` = landblock stab #0x65, Setup `0x020019E3`,
+one CylSphere **r=2.597 m, h=0.256 m** β a 26 cm disc, trivially steppable in
+retail. Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less
+stab CylSpheres (before that fix the platform had NO collision at all, so the
+player clipped through it β the collision *shape* was the #149 fix; the
+collision *response* was never retail).
+
+**Root cause (probe-confirmed, `launch-137-repro.log`):** the pre-port
+`CylinderCollision` was a hand-rolled approximation (AP-6): step-up gate +
+radial wall-slide only. Every contact returned `Slid` with a horizontal rim
+normal (`[cyl-test] β¦ result=Slid`, `[resolve] β¦ n=(0.99,-0.11,0.00)`) and
+the player orbited the rim forever. The step-up *gate* passed (clearance
+0.256 β€ 0.6) but `DoStepUp`'s internal step-down probe could never validate a
+landing ON the cylinder top β a cylinder has no polygons, and the port had no
+`step_sphere_down` cap-landing (top-disc contact plane). Airborne landings on
+tops (`land_on_cylinder` + the Collide-flag exact-TOI branch) were missing
+too.
+
+**Fix (this commit):** verbatim port of the full retail `CCylSphere` family β
+dispatcher `intersects_sphere` 0x0053b440, `collides_with_sphere` 0x0053a880,
+`normal_of_collision` 0x0053ab50, `collide_with_point` 0x0053acb0,
+`slide_sphere` 0x0053b2a0, `step_sphere_up` 0x0053b310, `land_on_cylinder`
+0x0053b3d0, `step_sphere_down` 0x0053a9b0. Pseudocode + settled BN
+ambiguities + two ACE-bug findings:
+`docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md`.
+Register: AP-6 retired, AP-83 added (PerfectClip TOI tail per ACE, dead code
+in M1.5). Conformance: `CylSphereFamilyTests` (grounded step-up-onto-top on
+the exact platform shape, tall-cylinder block, airborne top landing, ethereal
+Layer-2 guard); the #42 self-shadow control assertion updated to the retail
+observable (denied movement, not the old artifact radial push).
+
+**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylinderCollision`
++ `Cyl*` family), `tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs`.
+
+**Acceptance:** walk straight onto the Holtburg town-network portal platform
+(no rim slide); jumping onto it also lands. Doors/torches/NPC cylinders
+unregressed (suites green; #150 open-door behavior unchanged). Likely also
+advances #137's door-foot half β re-check in the dungeon repro.
+
+---
+
## #171 β Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0)
**Status:** DONE (2026-07-04) β **user visual gate PASSED** ("Looks good, ship it")
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 24ff2e1a..470348f0 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -99,7 +99,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)
+## 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)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -108,7 +108,6 @@ accepted-divergence entries (#96, #49, #50).
| AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped β float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 |
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` β restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK β body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject β slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
-| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities β the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 |
| AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 β 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions β post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) |
| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON β SetContactPlane never fires β float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility β other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80β350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
@@ -180,6 +179,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone β without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions β a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 β which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) |
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speedΓquantum < \|dist\|`, `dist < 0`), acdream applies `delta = β(speedΓquantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there β a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDistβ0 β "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum β₯1/30 β threshold ~1 m; render-rate quanta β ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
+| 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` |
## 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)
diff --git a/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md b/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md
new file mode 100644
index 00000000..a68144fb
--- /dev/null
+++ b/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md
@@ -0,0 +1,236 @@
+# 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.
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index a633efd4..8d193894 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -2811,9 +2811,12 @@ public sealed class Transition
// Effect (pc:276973-276977):
// state_3 = OK_TS β force passable
// collision_normal_valid = 0 β clear stale slide normal
- // Note: Cylinder and Sphere shapes already return OK from their own
- // obstruction_ethereal early-out, so this clause only fires in practice
- // for the BSP branch, but is written unconditionally as retail does.
+ // Note: the BSP branch AND (since the 2026-07-05 CCylSphere family
+ // port) the Cylinder branch rely on this clause for ethereal
+ // passability β CylinderCollision's branch 1 returns Collided on
+ // overlap like retail, and THIS override clears it for non-static
+ // targets. Only the Sphere branch still early-outs on
+ // obstruction_ethereal (consume site 1).
if (result != TransitionState.OK
&& sp.ObstructionEthereal
&& !sp.StepDown
@@ -3171,168 +3174,453 @@ public sealed class Transition
}
///
- /// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
- /// door foot-colliders). For Contact-grounded movers, attempts to step over short
- /// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers,
- /// movers already stepping, or cylinders too tall to step over, applies a
- /// horizontal wall-slide response.
+ /// Retail CCylSphere::intersects_sphere dispatcher β verbatim port
+ /// of 0x0053b440 (acclient_2013_pseudo_c.txt:324558). Full family:
+ /// collides_with_sphere 0x0053a880, normal_of_collision
+ /// 0x0053ab50, collide_with_point 0x0053acb0, slide_sphere
+ /// 0x0053b2a0, step_sphere_up 0x0053b310, land_on_cylinder
+ /// 0x0053b3d0, step_sphere_down 0x0053a9b0. Pseudocode + settled
+ /// BN ambiguities + ACE cross-reference notes:
+ /// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
///
///
- /// A6.P6 (2026-05-25): the step-over path matches retail's
- /// CCylSphere::step_sphere_up at
- /// acclient_2013_pseudo_c.txt:324516-324538. The door's foot
- /// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to
- /// produce smooth sliding along the slab β the radial push-out
- /// fires as a "phantom collision" at the door's center when the
- /// sphere is touching the slab face and the cyl is just within reach.
- /// Retail steps the sphere over the cyl (succeeds when
- /// step_up_height >= sphere.radius + cyl.height - offset.z),
- /// which lets the sphere walk past the cyl without the radial push.
- /// On step-up failure (cyl too tall, no walkable surface beyond),
- /// falls back to step_up_slide β the same crease-projection
- /// slide the BSP path uses, which produces smoother behavior than
- /// the radial push.
+ /// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up
+ /// gate + radial wall-slide) which had NO cylinder-TOP support: a
+ /// grounded mover stepping up onto a WIDE cylinder (the Holtburg
+ /// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m)
+ /// could never validate a landing β the step-up's internal step-down
+ /// probe needs branch 2 (step_sphere_down β contact plane ON the flat
+ /// top) β so DoStepUp failed into StepUpSlide and the player orbited the
+ /// rim forever. Airborne landings on tops (land_on_cylinder + the
+ /// collide-flag exact-TOI branch) were likewise missing.
+ ///
+ ///
+ ///
+ /// The already carries the wrapper overload's
+ /// (0x0053b8f0) work: Position = globalized low_pt (entity frame applied
+ /// at registration), Radius/CylHeight pre-scaled; the cylinder axis stays
+ /// world-Z. Ethereal targets: branch 1 returns Collided on overlap and
+ /// the caller's Layer-2 override (pc:276961-276989) clears it for
+ /// non-static targets β the retail passability mechanism (#150); the
+ /// step-down pass never reaches here for ethereal targets (pc:276799
+ /// skip).
///
///
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine)
{
- // Consume site 2 β CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573).
- // 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:324573.
- if (sp.ObstructionEthereal)
- return TransitionState.OK;
+ // Degenerate dat heights: registration sites apply the same fallback;
+ // kept for entries registered before it (pre-dates this port).
+ float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f;
+
+ var s0 = sp.GlobalSphere[0];
+ Vector3 disp0 = s0.Origin - obj.Position;
+ // radsum: Ξ΅ shaved ONCE in the dispatcher preamble (0x0053b48e) β
+ // this is what lets a sphere REST exactly on the top without
+ // re-colliding every frame.
+ float radsum = obj.Radius - PhysicsGlobals.EPSILON + s0.Radius;
+
+ bool hasHead = sp.NumSphere > 1;
+ Vector3 disp1 = default;
+ float headRadius = 0f;
+ if (hasHead)
+ {
+ disp1 = sp.GlobalSphere[1].Origin - obj.Position;
+ headRadius = sp.GlobalSphere[1].Radius;
+ }
+
+ // ββ Branch 1 (0x0053b4a0): placement / ethereal β detection only. ββ
+ if (sp.InsertType == InsertType.Placement || sp.ObstructionEthereal)
+ {
+ if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
+ return TransitionState.Collided;
+ if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
+ return TransitionState.Collided;
+ return TransitionState.OK;
+ }
+
+ // ββ Branch 2 (0x0053b4c0): step-down probe β land on the top. ββ
+ if (sp.StepDown)
+ return CylStepSphereDown(obj, sp, cylHeight, disp0, radsum);
+
+ // ββ Branch 3 (0x0053b4d7): walkable probe β occupancy blocks. ββ
+ if (sp.CheckWalkable)
+ {
+ if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
+ return TransitionState.Collided;
+ if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
+ return TransitionState.Collided;
+ return TransitionState.OK;
+ }
- var ci = CollisionInfo;
var oi = ObjectInfo;
- Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
- Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
- float sphRadius = sp.GlobalSphere[0].Radius;
- Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
- // Vertical check: does sphere reach the cylinder's height range at all?
- float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
- float checkZ = sphereCheckPos.Z;
- if (checkZ - sphRadius > obj.Position.Z + cylTop ||
- checkZ + sphRadius < obj.Position.Z)
+ if (!sp.Collide)
+ {
+ // ββ Branch 4 (0x0053b701): normal transition sweep. ββ
+ if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0)
+ {
+ // Grounded mover: foot hit β step over / onto; head hit β slide.
+ if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
+ return CylStepSphereUp(obj, sp, engine, cylHeight, disp0, radsum);
+ if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
+ // Retail 0x0053b843 passes the HEAD disp (its x_2); ACE's
+ // port passes the foot disp here β retail wins (pseudocode
+ // doc Β§8.2).
+ return CylSlideSphere(obj, sp, cylHeight, disp1, radsum, 1);
+ }
+ else if ((oi.State & ObjectInfoState.PathClipped) != 0)
+ {
+ if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
+ return CylCollideWithPoint(obj, sp, cylHeight, s0, disp0, radsum, 0);
+ }
+ else
+ {
+ // Airborne: foot hit β land on the top; head hit β point hit.
+ if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius))
+ return CylLandOnCylinder(obj, sp, cylHeight, disp0, radsum);
+ if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))
+ return CylCollideWithPoint(obj, sp, cylHeight, sp.GlobalSphere[1], disp1, radsum, 1);
+ }
+ return TransitionState.OK;
+ }
+
+ // ββ Branch 5 (0x0053b525): collide-flag re-test β exact-TOI cap landing. ββ
+ // Runs on the attempt after land_on_cylinder set sp.Collide: rewinds
+ // the sphere along the REVERSE movement to rest exactly on the top,
+ // sets the contact plane, and consumes walk_interp.
+ if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)
+ || (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)))
+ {
+ // movement = curr β check. Retail subtracts the curβcheck
+ // landblock offset (get_curr_pos_check_pos_block_offset); our
+ // physics frame is continuous world-space β zero (same standing
+ // adaptation as SlideSphere's gDelta).
+ Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin;
+ if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+
+ float timecheck = (cylHeight + s0.Radius - disp0.Z) / movement.Z;
+ Vector3 offset = movement * timecheck;
+
+ Vector3 sum = offset + disp0;
+ if (radsum * radsum < sum.X * sum.X + sum.Y * sum.Y)
+ return TransitionState.OK; // rewound point is off the cap β not a top landing
+
+ float t = (1f - timecheck) * sp.WalkInterp;
+ if (t >= sp.WalkInterp || t < -0.1f)
+ return TransitionState.Collided;
+
+ Vector3 pt = s0.Origin + offset;
+ pt.Z -= s0.Radius;
+ // is_water=1 is verbatim retail (0x0053b6b9 β set_contact_plane
+ // arg3 = contact_plane_is_water, 0x00509db1). Do not "fix".
+ var contactPlane = new Plane(Vector3.UnitZ, -pt.Z);
+ CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
+ sp.WalkInterp = t;
+ sp.AddOffsetToCheckPos(offset);
+ return TransitionState.Adjusted;
+ }
+
+ return TransitionState.OK;
+ }
+
+ ///
+ /// Retail CCylSphere::collides_with_sphere (0x0053a880,
+ /// pc:323943): XY overlap against radsum, then Z-band overlap against the
+ /// cylinder's [0, height] extent. = sphere center
+ /// β cylinder base point.
+ ///
+ private static bool CylCollidesWithSphere(Vector3 disp, float radsum, float cylHeight, float sphereRadius)
+ {
+ if (disp.X * disp.X + disp.Y * disp.Y <= radsum * radsum)
+ {
+ float halfH = cylHeight * 0.5f;
+ if (sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - disp.Z))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Retail CCylSphere::normal_of_collision (0x0053ab50, pc:324102).
+ /// Discriminates on where the sphere was at the START of the step
+ /// (GlobalCurrCenter): XY-outside the combined radius β radial side
+ /// normal; XY-inside the footprint β cap normal (descending β top +Z,
+ /// ascending β underside βZ; polarity settled by ACE + geometry β BN's
+ /// x87 branch rendering is untrustworthy here). Returns "definite":
+ /// false when a radial contact could actually be a diagonal cap hit
+ /// (curr was outside the Z band AND there is vertical movement) β
+ /// consumed only by .
+ ///
+ private bool CylNormalOfCollision(ShadowEntry obj, SpherePath sp, float cylHeight,
+ Vector3 dispCheck, float radsum, float sphereRadius, int sphereNum, out Vector3 normal)
+ {
+ Vector3 dispCurr = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position;
+ if (radsum * radsum < dispCurr.X * dispCurr.X + dispCurr.Y * dispCurr.Y)
+ {
+ normal = new Vector3(dispCurr.X, dispCurr.Y, 0f);
+ float halfH = cylHeight * 0.5f;
+ bool zBandOverlapAtCurr =
+ sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - dispCurr.Z);
+ bool noZMovement = MathF.Abs(dispCurr.Z - dispCheck.Z) <= PhysicsGlobals.EPSILON;
+ return zBandOverlapAtCurr || noZMovement;
+ }
+ normal = new Vector3(0f, 0f, dispCheck.Z - dispCurr.Z <= 0f ? 1f : -1f);
+ return true;
+ }
+
+ ///
+ /// Retail AC1Legacy::Vector3::normalize_check_small: returns true
+ /// (degenerate β caller yields Collided) when |v| < F_EPSILON, else
+ /// normalizes in place.
+ ///
+ private static bool NormalizeCheckSmall(ref Vector3 v)
+ {
+ float mag = v.Length();
+ if (mag < PhysicsGlobals.EPSILON)
+ return true;
+ v /= mag;
+ return false;
+ }
+
+ ///
+ /// Retail CCylSphere::step_sphere_up (0x0053b310, pc:324516).
+ /// Too-tall cylinders slide; otherwise the generic step-up
+ /// ( = CTransition::step_up) runs β its internal
+ /// step-down probe lands on the cylinder top via
+ /// (branch 2), which is what makes
+ /// stepping ONTO a wide cylinder possible.
+ ///
+ private TransitionState CylStepSphereUp(ShadowEntry obj, SpherePath sp, PhysicsEngine engine,
+ float cylHeight, Vector3 disp0, float radsum)
+ {
+ var s0 = sp.GlobalSphere[0];
+
+ // step_up_height must clear (sphere.radius + height β disp.z) β the
+ // lift needed so the sphere bottom rests on the top (0x0053b323).
+ if (ObjectInfo.StepUpHeight < s0.Radius + cylHeight - disp0.Z)
+ return CylSlideSphere(obj, sp, cylHeight, disp0, radsum, 0);
+
+ // Retail computes the normal and ignores the definite flag here.
+ CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, s0.Radius, 0, out var n);
+ if (NormalizeCheckSmall(ref n))
+ return TransitionState.Collided;
+
+ // Retail rotates the normal by the target OBJECT's frame
+ // (localtoglobalvec via the wrapper's cached localspace_pos,
+ // 0x0053b38d) before CTransition::step_up. Yaw-only AC frames leave
+ // vertical normals unchanged; radial normals pick up the yaw.
+ var nWorld = Vector3.Transform(n, obj.Rotation);
+
+ // engine==null only in bare unit-test transitions β no step-up
+ // machinery available; the retail-faithful fallback is the slide.
+ if (engine is not null && DoStepUp(nWorld, engine))
return TransitionState.OK;
- // XY distance from sphere check position to cylinder axis.
- float dxCheck = sphereCheckPos.X - obj.Position.X;
- float dyCheck = sphereCheckPos.Y - obj.Position.Y;
- float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
- float combinedR = sphRadius + obj.Radius;
- float combinedRSq = combinedR * combinedR;
+ return sp.StepUpSlide(this);
+ }
- if (distSqCheck >= combinedRSq)
- return TransitionState.OK; // not overlapping at check position
+ ///
+ /// Retail CCylSphere::step_sphere_down (0x0053a9b0, pc:324032):
+ /// during a step-down probe, land the foot sphere ON the cylinder's flat
+ /// top β contact plane (0,0,1) through the top, walk_interp consumed,
+ /// CheckPos lifted so the sphere bottom rests exactly on it. THE piece
+ /// whose absence made every step-up onto a wide cylinder fail (the
+ /// portal-platform rim orbit).
+ ///
+ private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp,
+ float cylHeight, Vector3 disp0, float radsum)
+ {
+ var s0 = sp.GlobalSphere[0];
- // βββ Overlap detected βββββββββββββββββββββββββββββββββββββ
- // Horizontal outward normal from the cylinder axis to the sphere
- // check position. For the degenerate case where the sphere center
- // is exactly on the axis, use the movement direction as a fallback
- // (pushes the sphere back out along the way it came in).
- float distCheck = MathF.Sqrt(distSqCheck);
- Vector3 collisionNormal;
- if (distCheck < PhysicsGlobals.EPSILON)
+ bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius);
+ if (!hit && sp.NumSphere > 1)
{
- // Sphere center on cylinder axis β push along reverse movement.
- float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y);
- if (mxy > PhysicsGlobals.EPSILON)
- collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
- else
- collisionNormal = Vector3.UnitX;
+ Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position;
+ hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius);
}
- else
+ if (!hit)
+ return TransitionState.OK;
+
+ float stepScale = sp.StepDownAmt * sp.WalkInterp;
+ if (MathF.Abs(stepScale) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+
+ // Lift so the foot sphere's bottom rests on the top disc. The
+ // (1 β deltaZ/stepScale) divisor is stepScale β BN garbled it;
+ // settled via ACE CylSphere.StepSphereDown (pseudocode doc Β§4).
+ float deltaZ = cylHeight + s0.Radius - disp0.Z;
+ float interp = (1f - deltaZ / stepScale) * sp.WalkInterp;
+ if (interp >= sp.WalkInterp || interp < -0.1f)
+ return TransitionState.Collided;
+
+ float topZ = s0.Origin.Z + deltaZ - s0.Radius;
+ // is_water=1 verbatim retail (0x0053aae2). Do not "fix".
+ var contactPlane = new Plane(Vector3.UnitZ, -topZ);
+ CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true);
+ sp.WalkInterp = interp;
+ sp.AddOffsetToCheckPos(new Vector3(0f, 0f, deltaZ));
+ return TransitionState.Adjusted;
+ }
+
+ ///
+ /// Retail CCylSphere::slide_sphere (0x0053b2a0, pc:324502):
+ /// normal_of_collision β CSphere::slide_sphere (our
+ /// ) with the sliding sphere's own curr center.
+ ///
+ private TransitionState CylSlideSphere(ShadowEntry obj, SpherePath sp,
+ float cylHeight, Vector3 disp, float radsum, int sphereNum)
+ {
+ CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
+ sp.GlobalSphere[sphereNum].Radius, sphereNum, out var n);
+ if (NormalizeCheckSmall(ref n))
+ return TransitionState.Collided;
+
+ return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum);
+ }
+
+ ///
+ /// Retail CCylSphere::land_on_cylinder (0x0053b3d0, pc:324542):
+ /// airborne foot hit β arm the Collide re-test (backup + flag) and relax
+ /// the walkable allowance to LandingZ. The NEXT attempt's branch 5 then
+ /// rests the sphere on the top with the exact time-of-impact.
+ ///
+ private TransitionState CylLandOnCylinder(ShadowEntry obj, SpherePath sp,
+ float cylHeight, Vector3 disp0, float radsum)
+ {
+ CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum,
+ sp.GlobalSphere[0].Radius, 0, out var n);
+ if (NormalizeCheckSmall(ref n))
+ return TransitionState.Collided;
+
+ sp.SetCollide(n);
+ sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x0053b41f)
+ return TransitionState.Adjusted;
+ }
+
+ ///
+ /// Retail CCylSphere::collide_with_point (0x0053acb0, pc:324173):
+ /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers
+ /// record the collision normal and hard-stop; PerfectClip movers get the
+ /// exact time-of-impact reposition. TOI sub-branches ported per ACE
+ /// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no
+ /// PerfectClip mover exists in M1.5 (players never set it), so only the
+ /// Collided path is load-bearing today β revisit against Ghidra if
+ /// missiles ever arm PerfectClip (pseudocode doc Β§7).
+ ///
+ private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp,
+ float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum)
+ {
+ bool definite = CylNormalOfCollision(obj, sp, cylHeight, disp, radsum,
+ checkSphere.Radius, sphereNum, out var n);
+ if (NormalizeCheckSmall(ref n))
+ return TransitionState.Collided;
+
+ if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip))
{
- collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f);
+ CollisionInfo.SetCollisionNormal(n);
+ return TransitionState.Collided;
}
- // A6.P6 (2026-05-25): retail-faithful CCylSphere::step_sphere_up for
- // Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538.
- //
- // Retail check: step_up_height must clear (sphere.radius + cyl.height
- // - offset.z) where offset.z is sphere center Z minus cyl low_pt Z.
- // Geometrically: the height we need to lift the sphere to clear the
- // cyl's top, less the sphere center's current height above the cyl
- // base, equals cyl top minus sphere bottom (positive when sphere
- // currently below cyl top).
- //
- // For the door's foot cyl (h=0.20m, sphere radius 0.48m, step_up 0.60m)
- // at standing height (offset.z ~0.38m): cyl_clearance =
- // 0.48 + 0.20 - 0.38 = 0.30m, step_up_height = 0.60m β step over OK.
- if (oi.Contact && !sp.StepUp && !sp.StepDown && engine is not null)
- {
- float offsetZ = sphereCheckPos.Z - obj.Position.Z;
- float cylClearance = sphRadius + cylTop - offsetZ;
+ // Retail reads global_curr_center[0] even for the head hit
+ // (0x0053ad26; ACE agrees) β verbatim.
+ Vector3 globCenter = sp.GlobalCurrCenter[0].Origin;
+ // Block offset = 0 (continuous world frame; see branch 5 note).
+ Vector3 movement = checkSphere.Origin - globCenter;
+ Vector3 oldDisp = globCenter - obj.Position;
+ float radsumEps = radsum + PhysicsGlobals.EPSILON;
- if (oi.StepUpHeight >= cylClearance)
+ float xyMoveLenSq = movement.X * movement.X + movement.Y * movement.Y;
+ float dot2d = movement.X * oldDisp.X + movement.Y * oldDisp.Y;
+ float xyDiff = -dot2d;
+ float oldDispXYSq = oldDisp.X * oldDisp.X + oldDisp.Y * oldDisp.Y;
+ float diffSq = xyDiff * xyDiff - (oldDispXYSq - radsumEps * radsumEps) * xyMoveLenSq;
+
+ float time;
+ Vector3 scaledMovement;
+
+ if (!definite)
+ {
+ if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+ if (movement.Z > 0f)
{
- // Try step-up over the cyl (DoStepUp probes upward by
- // step_up_height, then step-down for walkable surface).
- // On success: sphere is repositioned past/over the cyl,
- // ContactPlane updated, returns OK.
- if (DoStepUp(collisionNormal, engine))
- return TransitionState.OK;
-
- // Step-up failed β sphere couldn't find a walkable surface
- // beyond the cyl (e.g., a wall behind it). Fall back to
- // step_up_slide which uses the SlideSphereInternal crease
- // projection β smoother than the radial push-out below
- // because it follows the contact-plane / cyl-normal crease
- // direction.
- return sp.StepUpSlide(this);
- }
- // else: cyl too tall to step over β fall through to radial slide
- }
-
- // βββ Fallback: airborne / non-Contact / cyl-too-tall β wall-slide βββ
-
- // Wall-slide position (in world space):
- // curr = sphereCurrPos (pre-step)
- // movement = sphMovement
- // projected = movement - (movement Β· normal) * normal
- // slidPos = curr + projected
- // Then push outward if still inside the cylinder radius.
- Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
- float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
- Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
- // Preserve vertical movement component (jumping/falling).
- projectedMovement.Z = sphMovement.Z;
-
- Vector3 slidPos = sphereCurrPos + projectedMovement;
-
- // Ensure slid position is outside the cylinder radius horizontally.
- float sdx = slidPos.X - obj.Position.X;
- float sdy = slidPos.Y - obj.Position.Y;
- float sDistSq = sdx * sdx + sdy * sdy;
- float minDist = combinedR + 0.01f;
- if (sDistSq < minDist * minDist)
- {
- float sDist = MathF.Sqrt(sDistSq);
- if (sDist < PhysicsGlobals.EPSILON)
- {
- // Degenerate: push out along collisionNormal
- slidPos.X = obj.Position.X + collisionNormal.X * minDist;
- slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
+ n = new Vector3(0f, 0f, -1f);
+ time = (movement.Z + checkSphere.Radius) / movement.Z * -1f;
}
else
{
- float pushDist = (minDist - sDist);
- slidPos.X += (sdx / sDist) * pushDist;
- slidPos.Y += (sdy / sDist) * pushDist;
+ n = new Vector3(0f, 0f, 1f);
+ time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z;
}
+ scaledMovement = movement * time;
+
+ Vector3 landed = scaledMovement + oldDisp;
+ if (landed.X * landed.X + landed.Y * landed.Y >= radsumEps * radsumEps)
+ {
+ if (MathF.Abs(xyMoveLenSq) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+ if (diffSq >= 0f && xyMoveLenSq > PhysicsGlobals.EPSILON)
+ {
+ float diff = MathF.Sqrt(diffSq);
+ time = xyDiff - diff < 0f
+ ? (diff - dot2d) / xyMoveLenSq
+ : (xyDiff - diff) / xyMoveLenSq;
+ scaledMovement = movement * time;
+ }
+ n = (scaledMovement + globCenter - obj.Position) / radsumEps;
+ n.Z = 0f;
+ }
+
+ if (time < 0f || time > 1f)
+ return TransitionState.Collided;
+
+ Vector3 offsetOut = globCenter - scaledMovement - checkSphere.Origin;
+ sp.AddOffsetToCheckPos(offsetOut);
+ CollisionInfo.SetCollisionNormal(n);
+ return TransitionState.Adjusted;
}
- // Apply the offset (difference between slid and current CheckPos)
- Vector3 delta = slidPos - sphereCheckPos;
- sp.AddOffsetToCheckPos(delta);
+ if (n.Z != 0f)
+ {
+ if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
- ci.SetCollisionNormal(collisionNormal);
- ci.SetSlidingNormal(collisionNormal);
- return TransitionState.Slid;
+ time = movement.Z > 0f
+ ? -((oldDisp.Z + checkSphere.Radius) / movement.Z)
+ : (checkSphere.Radius + cylHeight - oldDisp.Z) / movement.Z;
+ scaledMovement = movement * time;
+
+ if (time < 0f || time > 1f)
+ return TransitionState.Collided;
+
+ Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
+ sp.AddOffsetToCheckPos(offsetOut);
+ CollisionInfo.SetCollisionNormal(n);
+ return TransitionState.Adjusted;
+ }
+
+ if (diffSq < 0f || xyMoveLenSq < PhysicsGlobals.EPSILON)
+ return TransitionState.Collided;
+
+ {
+ float diff = MathF.Sqrt(diffSq);
+ time = xyDiff - diff < 0f
+ ? (diff - dot2d) / xyMoveLenSq
+ : (xyDiff - diff) / xyMoveLenSq;
+ scaledMovement = movement * time;
+
+ if (time < 0f || time > 1f)
+ return TransitionState.Collided;
+
+ n = (scaledMovement + globCenter - obj.Position) / radsumEps;
+ n.Z = 0f;
+
+ Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin;
+ sp.AddOffsetToCheckPos(offsetOut);
+ CollisionInfo.SetCollisionNormal(n);
+ return TransitionState.Adjusted;
+ }
}
// -----------------------------------------------------------------------
@@ -3356,7 +3644,11 @@ public sealed class Transition
internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos)
=> SlideSphere(collisionNormal, currPos);
- private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
+ /// Which path sphere is sliding (retail
+ /// CSphere::slide_sphere's this is the sphere instance β the head
+ /// sphere slides by its OWN displacement, 0x0053b843 passes
+ /// global_sphere[1]). Default 0 preserves every existing call site.
+ private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0)
{
var sp = SpherePath;
var ci = CollisionInfo;
@@ -3364,7 +3656,7 @@ public sealed class Transition
// Degenerate case: zero collision normal β nudge halfway.
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
- Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f;
+ Vector3 halfOffset = (currPos - sp.GlobalSphere[sphereNum].Origin) * 0.5f;
sp.AddOffsetToCheckPos(halfOffset);
return TransitionState.Adjusted;
}
@@ -3374,7 +3666,7 @@ public sealed class Transition
// gDelta: displacement from currPos to the current check sphere center.
// In the retail code this includes a block offset for cross-landblock
// transitions; for outdoor single-landblock movement this is zero.
- Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos;
+ Vector3 gDelta = sp.GlobalSphere[sphereNum].Origin - currPos;
// Get the contact plane (prefer current, fall back to last known).
System.Numerics.Plane contactPlane;
diff --git a/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
new file mode 100644
index 00000000..57f405a4
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs
@@ -0,0 +1,287 @@
+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 CCylSphere collision family port
+/// (2026-07-05) β dispatcher 0x0053b440 + step_sphere_down
+/// 0x0053a9b0 + step_sphere_up 0x0053b310 +
+/// land_on_cylinder 0x0053b3d0. Pseudocode:
+/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
+///
+///
+/// The driving repro: the Holtburg town-network portal platform (stab
+/// 0xC0A9B465, Setup 0x020019E3) registers a WIDE LOW cylinder
+/// (r=2.597 m, h=0.256 m). Retail steps a grounded player UP ONTO its flat
+/// top; the pre-port approximation could only radial-slide, so the player
+/// orbited the rim forever (launch-137-repro.log, 2026-07-05). These tests
+/// pin the three retail behaviors the family provides: grounded
+/// step-up-onto-top, too-tall side slide, and the airborne top landing.
+/// Synthetic cylinders only β no dat dependency.
+///
+///
+public class CylSphereFamilyTests
+{
+ private readonly ITestOutputHelper _out;
+ public CylSphereFamilyTests(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;
+
+ // The live platform's registered shape ([cyl-test] launch-137-repro.log).
+ private const float PlatformRadius = 2.597f;
+ private const float PlatformHeight = 0.256f;
+
+ ///
+ /// The portal-platform repro: a grounded player walking into the wide low
+ /// cylinder must STEP UP onto its flat top (retail
+ /// grounded branch β step_sphere_up β CTransition::step_up, whose
+ /// step-down probe lands via step_sphere_down's top-disc contact plane) β
+ /// not slide around the rim.
+ ///
+ [Fact]
+ public void Grounded_WalkIntoWideLowCylinder_StepsUpOntoTop()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCylinder(engine, entityId: 0xCAFEu,
+ worldPos: new Vector3(12f, 14f, 0f),
+ radius: PlatformRadius, height: PlatformHeight);
+
+ var body = MakeGroundedBody(new Vector3(12f, 10.4f, 0f));
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ var perTick = new Vector3(0f, 0.10f, 0f);
+
+ for (int tick = 0; tick < 40; 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}) grounded={grounded}");
+
+ // Rim contact is at Y β 14 β 2.597 β 0.48 = 10.92. Pre-port the player
+ // pinned there (Z stayed 0, Y never passed the rim). Post-port the
+ // player must be standing ON the platform top.
+ Assert.True(pos.Y > 11.5f,
+ $"Player must advance past the rim contact (pre-port it pinned at Yβ10.9); got Y={pos.Y:F3}");
+ Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
+ $"Player must stand ON the platform top (Zβ{PlatformHeight:F3}); got Z={pos.Z:F3}");
+ Assert.True(grounded, "Player must remain grounded after stepping onto the platform");
+ }
+
+ ///
+ /// A tall thin cylinder (the Holtburg torch shape, r=0.2 h=2.2 β #149)
+ /// exceeds step_up_height: the grounded dead-center approach must NOT
+ /// step up and must NOT pass through β retail slides (dead-center the
+ /// crease projection degenerates to a hard stop).
+ ///
+ [Fact]
+ public void Grounded_WalkIntoTallCylinder_BlocksBeforeAxis()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCylinder(engine, entityId: 0xF00Du,
+ worldPos: new Vector3(12f, 14f, 0f),
+ radius: 0.2f, height: 2.2f);
+
+ var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ var perTick = new Vector3(0f, 0.10f, 0f);
+
+ 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}) grounded={grounded}");
+
+ // Surface contact at Y = 14 β 0.2 β 0.48 = 13.32.
+ Assert.True(pos.Y < 13.4f,
+ $"Tall cylinder must block the dead-center approach; got Y={pos.Y:F3}");
+ Assert.True(pos.Z < 0.5f,
+ $"Player must NOT end up on top of a 2.2 m cylinder; got Z={pos.Z:F3}");
+ }
+
+ ///
+ /// Airborne landing: a falling sphere over the platform center must land
+ /// ON the flat top (land_on_cylinder β Collide re-test β branch-5
+ /// exact-TOI rest + top-disc contact plane), not fall through to the
+ /// terrain inside the footprint.
+ ///
+ [Fact]
+ public void Airborne_FallOntoWideCylinder_LandsOnTop()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCylinder(engine, entityId: 0xCAFEu,
+ worldPos: new Vector3(12f, 14f, 0f),
+ radius: PlatformRadius, height: PlatformHeight);
+
+ Vector3 pos = new(12f, 14f, 1.0f); // 1 m above the base, over the center
+ uint cellId = TestCellId;
+ bool grounded = false;
+ var perTick = new Vector3(0f, 0f, -0.25f);
+
+ int landedTick = -1;
+ for (int tick = 0; tick < 20; tick++)
+ {
+ var result = engine.ResolveWithTransition(
+ pos, pos + perTick, cellId,
+ SphereRadius, SphereHeight, StepUpHeight, StepDownHeight,
+ grounded,
+ body: null,
+ moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0);
+
+ pos = result.Position;
+ cellId = result.CellId;
+ grounded = result.IsOnGround;
+
+ if (grounded) { landedTick = tick; break; }
+ }
+
+ _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded} landedTick={landedTick}");
+
+ Assert.True(grounded, "Falling sphere must land (ground) on the platform top");
+ Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f,
+ $"Landing must rest on the top disc (Zβ{PlatformHeight:F3}), not the terrain " +
+ $"(Z=0) inside the footprint; got Z={pos.Z:F3}");
+ }
+
+ ///
+ /// Ethereal cylinders stay fully passable through the caller's Layer-2
+ /// override (pc:276961-276989) β branch 1 detects, the override clears.
+ /// Guards the #150 door behavior against the branch-1 change from the
+ /// old early-OK consume.
+ ///
+ [Fact]
+ public void Grounded_EtherealCylinder_IsFullyPassable()
+ {
+ var engine = BuildEngine(out _);
+ RegisterCylinder(engine, entityId: 0xE7E7u,
+ worldPos: new Vector3(12f, 14f, 0f),
+ radius: 0.2f, height: 2.2f,
+ state: 0x4u); // ETHEREAL_PS, non-static
+
+ var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f));
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ var perTick = new Vector3(0f, 0.10f, 0f);
+
+ 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 > 14.5f,
+ $"Ethereal cylinder must not block (walked from 12.6 to past the axis); got Y={pos.Y:F3}");
+ }
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Harness
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private static PhysicsEngine BuildEngine(out PhysicsDataCache cache)
+ {
+ cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ // Flat terrain at Z=0 across the whole landblock.
+ 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;
+ }
+
+ private static void RegisterCylinder(PhysicsEngine engine, uint entityId,
+ Vector3 worldPos, float radius, float height, uint state = 0u)
+ {
+ engine.ShadowObjects.Register(
+ entityId, gfxObjId: 0u,
+ worldPos, Quaternion.Identity, radius,
+ worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
+ collisionType: ShadowCollisionType.Cylinder,
+ cylHeight: height,
+ state: state);
+ }
+
+ 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,
+ };
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
index 26664ad9..b61d4329 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
@@ -487,9 +487,20 @@ public class PhysicsEngineTests
collisionType: ShadowCollisionType.Cylinder,
cylHeight: 1.835f);
- // Without the gate (movingEntityId == 0): the sweep must self-push.
- // This proves the registry actually causes a collision, so the
- // following filtered case is not a vacuous pass.
+ // Without the gate (movingEntityId == 0): the sweep must be
+ // INTERFERED WITH by the self-entry. This proves the registry
+ // actually causes a collision, so the following filtered case is not
+ // a vacuous pass.
+ //
+ // Observable updated for the 2026-07-05 CCylSphere family port: the
+ // old hand-rolled response radial-pushed the sphere ~1 m sideways
+ // (the original #42 symptom this test asserted). Retail's dispatcher
+ // (0x0053b440) resolves this geometry β airborne, dead-center on the
+ // cylinder axis, moving up β through land_on_cylinder β the Collide
+ // re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition
+ // then reverts to a stay-put (no sideways teleport, Ok=true). The
+ // response-model-independent interference signal is the DENIED +Z
+ // movement: the sweep must NOT reach the +0.022 target.
var unfiltered = engine.ResolveWithTransition(
currentPos: bodyPos, targetPos: targetPos,
cellId: 0xA9B40039u,
@@ -498,11 +509,11 @@ public class PhysicsEngineTests
isOnGround: false,
movingEntityId: 0u);
- float unfilteredXY = MathF.Sqrt(
- (unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) +
- (unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y));
- Assert.True(unfilteredXY > 0.5f,
- $"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)");
+ Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f,
+ $"Without movingEntityId, the sweep must collide with the mover's own " +
+ $"ShadowEntry and deny the +Z movement (retail: land_on_cylinder β " +
+ $"Collide re-test β COLLIDED β stay-put). Got Z={unfiltered.Position.Z:F4}, " +
+ $"target Z={targetPos.Z:F4}");
// With the gate: the sweep must leave XY unchanged.
var filtered = engine.ResolveWithTransition(