From 6ab269894a2e65bf7c9fc31abc74e635a665795b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 14:52:28 +0200 Subject: [PATCH] fix #172: port the retail CCylSphere collision family (platform step-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 50 ++ .../retail-divergence-register.md | 4 +- ...-ccylsphere-collision-family-pseudocode.md | 236 +++++++ src/AcDream.Core/Physics/TransitionTypes.cs | 574 +++++++++++++----- .../Physics/CylSphereFamilyTests.cs | 287 +++++++++ .../Physics/PhysicsEngineTests.cs | 27 +- 6 files changed, 1027 insertions(+), 151 deletions(-) create mode 100644 docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md create mode 100644 tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs 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(