# Issue #32 (local half) — the player runs off cliff edges instead of stopping or sliding **OUTCOME 2026-08-07 — Section 6's capture landed in decision-table ROW 1 verbatim** (six `branch2/steep-cliffslide` events at Rithwic, every one `curN == lastN == (-0.954, 0.000, 0.301)`, `angle=0.0000`, `apply=False`, outcome `degenerate-cross/last-known`), **the Section 7 fix shipped at `332045c7`, and the user passed it live**: "Yes works now." One caveat for the record: the first live "test" of the fix ran a stale checkout's binary and produced a void failure verdict — see the #32 entries in `docs/ISSUES.md` for that detour and the assembly-identity rule it produced. Section 3.5's cell-id note is now register row **AD-67**; its blast-radius items are carried as watch items on Campaign S slice S4. **Date:** 2026-08-06 **Mode:** research / report-only. No production or test code was written or changed. **Worktree:** `.claude/worktrees/peaceful-visvesvaraya-e0a196`, branch `claude/acdream-physics-divergence-5aa784`. --- ## 0. Git state caveat (read first) `git fetch github` succeeded; `git merge --ff-only github/main` was **refused** — this branch is diverged (1 ahead / 5 behind: it carries local commit `5c93c2c5`, which is not in `github/main`). Per instruction nothing else was done to git. That matters only if the five missing commits touch what this report analyses. They do not. `git diff --name-only HEAD github/main` over the analysed sources returns **no** hits for `TransitionTypes.cs`, `PhysicsEngine.cs`, `FlatBspQuery.cs`, `BSPQuery.cs`, `PlayerMovementController.cs`, or `RetailEdgeResponseOrderingTests.cs`. The only doc deltas are new rows for `#333`/`#334` and `AP-152`/`AP-156`/`AP-158`; `#32`'s own text is byte-identical between HEAD and `github/main`. **Every source claim below therefore holds at `github/main`.** Retail binary verified before any disassembly: ``` py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe" GUID = {9e847e2f-777c-4bd9-886c-22256bb87f32} === MATCH: this exe pairs with our acclient.pdb === ``` Every address cited below was resolved back to a name in `docs/research/named-retail/symbols.json` before being used, and every function body was bounded by finding its own `ret` before the next symbol. Address / symbol pairs confirmed this way: | Address | PDB symbol | |---|---| | `0x00509d80` | `COLLISIONINFO::set_contact_plane` | | `0x0050a6d0` | `CTransition::cliff_slide` | | `0x0050a880` | `SPHEREPATH::save_check_pos` | | `0x0050b100` | `SPHEREPATH::restore_check_pos` | | `0x0050b2a0` | `CTransition::step_down` | | `0x0050b3d0` | `CTransition::edge_slide` | | `0x0050b6f0` | `CTransition::transitional_insert` | | `0x0050aa70` | `CTransition::validate_transition` | | `0x0050cc80` | `SPHEREPATH::precipice_slide` | | `0x0050cf20` | `OBJECTINFO::get_walkable_z` | | `0x0050e850` | `CTransition::init_contact_plane` | | `0x0050e8e0` | `CTransition::init_last_known_contact_plane` | | `0x00511cc0` | `CPhysicsObj::get_object_info` | --- ## 1. Executive answer **The digest does not already carry #32's local half.** `claude-memory/project_physics_collision_digest.md` mentions `PrecipiceSlide` three times, all in *other* sagas (#185's DO-NOT-RETRY, #116's residual, the step-down/step-up seam notes). There is no recorded characterisation of the local-player cliff case. This is not a #331-style rediscovery. **What retail does at a walkable edge** is a four-stage chain inside one `CTransition::transitional_insert` attempt: probe down → if the probe finds no *walkable* support, run `edge_slide` → `edge_slide` picks exactly one of four responses in a fixed order → the winning response either laterally displaces the candidate (you glide along the rim) or refuses it outright (you stop). Section 2 decodes it, disassembly-verified. **acdream implements every stage of that chain, for the local player, with the correct branch order and the correct retail flags.** The mover-flag theory is dead: `PlayerMovementController` passes `ObjectInfoState.IsPlayer | EdgeSlide | OwnPvpFlags` (`src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:2661`) and `PhysicsEngine.ResolveWithTransition` sets `ObjectInfo.StepDown = true` unconditionally (`src/AcDream.Core/Physics/PhysicsEngine.cs:1919`). **The one divergence I found is real, is disassembly-proven, and sits exactly on the branch that produces "slide along the cliff edge".** `CollisionInfo.SetContactPlane` (`TransitionTypes.cs:446-480`) latches the **last-known** contact plane on every write. Retail's `COLLISIONINFO::set_contact_plane` (`0x00509d80`) does not touch last-known at all. The consequence is that by the time `CliffSlide` reads `LastKnownContactPlane.Normal` as its reference vector, that vector has already been overwritten with the *same* steep normal it is being crossed against — so the cross product is exactly zero, `CliffSlide` takes its degenerate `OK` return, `edge_slide` reports "did not stop", and the outer retry loop accepts the candidate that hangs over the drop. **That is a literal mechanism for "runs off the edge".** **It is not a remote-vs-local asymmetry.** Section 4 shows why `204d0ae0` could not have covered this: that commit deleted four *remote-only* forgeries layered on top of the shared engine. It never touched `Transition`, `CliffSlide`, or last-known-plane maintenance. The local player never had those forgeries, so there was nothing there for the remote fix to also fix. **Confidence, stated honestly.** The divergence is *proven* (Section 3). Its causal link to the user's specific cliff is a *hypothesis* — it requires `edge_slide` to take branch 2 (steep contact plane) rather than branch 3/5 (walkable-polygon precipice) in that geometry, and which branch fires depends on what the step-down probe actually finds under the user's cliff. **One live run of the probe that already exists settles it** (Section 6). Do not write the fix before that run. --- ## 2. What retail does at a walkable edge ### 2.1 Entry gate — `CTransition::transitional_insert` `0x0050b812` Disassembly: ``` 0050b812 cmp [esi+0x288], ebp ; collision_info.contact_plane_valid 0050b818 jne 0x50ba30 ; valid contact -> return 1 (OK), no step-down 0050b81e mov eax, [esi+4] ; object_info.state 0050b821 test al, 1 ; CONTACT 0050b823 je 0x50ba30 0050b829 cmp [esi+0x178], ebp ; sphere_path.step_down (must be 0) 0050b82f jne 0x50ba30 0050b835 cmp [esi+0x128], ebp ; sphere_path.check_cell (must be non-null) 0050b83b je 0x50ba30 0050b841 cmp [esi+0x18], ebp ; object_info.step_down (must be set) 0050b844 je 0x50ba30 ``` acdream, `TransitionTypes.cs:2176-2181`: ```csharp if (ci.ContactPlaneValid) return TransitionState.OK; ... if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) ``` Exact match, term for term, including the "a valid contact plane short-circuits the whole step-down tail" rule. ### 2.2 Thresholds — `0x0050b84a` ``` 0050b84a test al, 2 ; ON_WALKABLE 0050b84c mov ecx, [0x7c6870] ; = 0.0871557f (cos 85 deg) 0050b852 mov [esp+0x14], 0x3d23d70a ; step_down_height default = 0.04 m 0050b85a mov [esp+0x18], ecx ; walkable allowance default 0050b85e je 0x50b872 0050b860 call 0x50cf20 ; OBJECTINFO::get_walkable_z 0050b867 fstp [esp+0x18] ; allowance = get_walkable_z() (FloorZ) 0050b86b mov edx, [esi+0x10] ; object_info.step_down_height 0050b86e mov [esp+0x14], edx ``` In plain language: a mover **standing on walkable ground** probes down by its own authored step-down height (about 40 cm for a human) and demands a surface no steeper than `FloorZ` = 0.6642 — that is a surface tilted at most ~48.4 degrees from flat. A mover **not** on walkable ground gets 4 cm and the far more permissive ~85-degree allowance. acdream: `oi.GetWalkableZ()` / `oi.StepDownHeight` at `TransitionTypes.cs:2192-2194`, `FloorZ = 0.6642f` at `TransitionTypes.cs:1255`. ### 2.3 Probe schedule — `0x0050b886` ``` 0050b886 cmp [edi], 2 ; sphere_path.num_sphere 0050b889 jae 0x50b8ab 0050b88e fld [ecx+0xc] ; global_sphere[0].radius 0050b891 fadd st(0), st(0) ; 2*radius 0050b893 fcomp [esp+0x14] ; vs step_down_height 0050b899 test ah, 5 0050b89c jp 0x50b8ab ; taken when 2r >= h -> keep h 0050b89e fld [ecx+0xc] 0050b8a1 fmul [0x7928b8] ; = 0.5f 0050b8a7 fstp [esp+0x14] ; h = radius * 0.5 ... 0050b8b5 fadd st(0), st(0) ; 2*radius again 0050b8ba fcomp [esp+0x18] ; (after `push edi`) == the step_down_height slot 0050b8c3 jp 0x50b8ff ; 2r >= h -> ONE probe of h ; else -> TWO probes of h*0.5 ``` Note Binary Ninja mislabels that second comparand as the walkable allowance, because it does not account for the `push edi` at `0x0050b8b9` shifting the frame by 4. The disassembly settles it: both comparisons are against `step_down_height`. acdream's `GetStepDownProbePlan` (`TransitionTypes.cs:1893-1908`) reproduces this, with one boundary difference: retail branches on strict `2r < h`, acdream on `diameter <= probeHeight` / `diameter > probeHeight`. At **exact float equality** acdream clamps and splits where retail does not. Real, citable, and almost certainly inert (it needs `2*radius` to equal the authored step-down height bit for bit). For the human player it is unreachable anyway: `num_sphere == 2`, `radius 0.48` so `2r = 0.96 >= 0.4`, hence one full-height probe. Whatever height was actually used is then passed to `edge_slide` — `0x0050b921` `edge_slide(this, &state, step_down_height_used, allowance)`. ### 2.4 The response chooser — `CTransition::edge_slide` `0x0050b3d0` ``` 0050b3d8 mov eax, [esi+4] ; object_info.state 0050b3db test al, 2 ; ON_WALKABLE (0x0002) 0050b3de je 0x50b59b ; -> restore, OK, stop 0050b3e4 test ah, 2 ; EDGE_SLIDE (0x0200) 0050b3e7 je 0x50b59b ; -> restore, OK, stop 0050b3ed mov ecx, [esi+0x288] ; contact_plane_valid 0050b3f7 je 0x50b444 ; invalid -> skip cliff branch 0050b3f9 fld [esi+0x294] ; contact_plane.N.z 0050b3ff fcomp [esp+0x70] ; vs arg4 (the walkable allowance) 0050b405 test ah, 5 0050b408 jp 0x50b444 ; N.z >= allowance -> skip cliff branch 0050b40a ... walkable = 0 ; restore_check_pos ; cliff_slide(&contact_plane) 0050b43b xor eax, eax ; return FALSE (caller keeps retrying) 0050b444 cmp [esi+0x1f4], ebp ; sphere_path.walkable 0050b44a jne 0x50b563 ; non-null -> precipice branch 0050b450 cmp ecx, ebx ; contact_plane_valid -> restore + OK + stop ``` `test ah, 5` after `fnstsw` tests C0 (less-than) and C2 (unordered); with an ordered compare the parity of that pair is even exactly when C0 is clear, so `jp` is taken on `>=`. The cliff branch is therefore entered on **`contact_plane.N.z < allowance`** — a contact plane too steep to stand on. The five responses, in retail's order, are: 1. `!ON_WALKABLE || !EDGE_SLIDE` → restore the saved candidate, `OK_TS`, **stop**. 2. valid contact plane **steeper than the allowance** → restore, `cliff_slide`, return false so the insert keeps retrying against the displaced candidate. 3. a stored walkable polygon → restore, `precipice_slide` against it. 4. valid contact plane, no walkable polygon → restore, `OK_TS`, **stop**. 5. otherwise → back-probe from the current sphere centre to rediscover the polygon just left, restore, `precipice_slide` against that. acdream's `EdgeSlideAfterStepDownFailed` (`TransitionTypes.cs:2420-2537`) reproduces all five in that order. Verified branch by branch against the disassembly above; **no divergence found in the ordering or the gates.** ### 2.5 The two responses `SPHEREPATH::precipice_slide` `0x0050cc80`: if `CPolygon::find_crossed_edge` returns 0 (the candidate never left the polygon), it clears `walkable` and returns `COLLIDED_TS` — the move is refused, you stop. Otherwise it slides the sphere along the crossed edge. acdream `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:1073-1099`) matches, including the Collided return. `CTransition::cliff_slide` `0x0050a6d0`: ``` cross( arg2->N , collision_info.last_known_contact_plane.N ) with Z forced to 0 normalize_check_small(...) != 0 -> return 1 (OK_TS, no displacement at all) otherwise -> add_offset_to_check_pos(dir * ±dot) set_collision_normal(dir) return 3 (ADJUSTED_TS) ``` `acclient.h:6100-6108` gives `INVALID=0, OK=1, COLLIDED=2, ADJUSTED=3, SLID=4`; acdream's enum (`TransitionTypes.cs:9-16`) is identical, and `Transition.CliffSlide` returns `OK` on degeneracy and `Adjusted` otherwise. Match. **So retail's "slide along the cliff edge" is `cliff_slide`, and its entire direction comes from crossing the steep plane you are refusing against the last-known contact plane — the surface you were standing on a moment ago.** Kill the second vector and the slide becomes a no-op. --- ## 3. What acdream does — the divergence ### 3.1 Retail's last-known contact plane has exactly four writers Grepping every assignment in the named decomp: | Address | Function | Effect | |---|---|---| | `0x00509d62` | `COLLISIONINFO::init` | clear | | `0x00509de6` | `CTransition::init` | clear | | `0x0050a9b8` | inside `validate_transition` | clear | | `0x0050ad07`-`0x0050ad47` | `validate_transition` tail | `lkcp_valid = contact_plane_valid`; copy plane only when valid | | `0x0050b9d7` | `transitional_insert` phase-3 reset | clear | | `0x0050e85d` | `CTransition::init_contact_plane` | set both current **and** last-known | | `0x0050e8e6` | `CTransition::init_last_known_contact_plane` | set last-known only | `init_contact_plane` and `init_last_known_contact_plane` are the **per-transition seeds**, called once from `CPhysicsObj::get_object_info` `0x00511cc0` depending on whether `check_contact` succeeded. `validate_transition`'s tail is the **end** of the transition. Nothing in between writes last-known. And critically, the per-substep writer does not: ``` 00509d80 COLLISIONINFO::set_contact_plane 00509d86 mov [ecx+0x18], 1 ; contact_plane_valid 00509d8e lea eax, [ecx+0x1c] ; contact_plane.N.x/y/z, .d (4 dwords) 00509db1 mov [ecx+0x34], eax ; contact_plane_is_water 00509db5 ret 8 ``` Twenty-two bytes of body. It does not touch `+0x00`/`+0x04` (last-known valid / plane), and it does not touch `+0x2c` (`contact_plane_cell_id`) either. Compare `init_contact_plane` `0x0050e850`, which writes `[ecx+0x270]`, `[ecx+0x274..0x280]`, `[ecx+0x284]`, `[ecx+0x2a0]` (all last-known) **and** `[ecx+0x288]`, `[ecx+0x28c..]`, `[ecx+0x2a4]`, `[ecx+0x29c]` (all current). The two functions are deliberately different, and retail calls the narrow one from every collision-response site. ### 3.2 acdream's `SetContactPlane` does both `src/AcDream.Core/Physics/TransitionTypes.cs:446-480`: ```csharp public void SetContactPlane(Plane plane, uint cellId, bool isWater = false) { ... no-op-if-unchanged guard ... ContactPlaneValid = true; ContactPlane = plane; ContactPlaneCellId = cellId; ContactPlaneIsWater = isWater; LastKnownContactPlaneValid = true; // <-- retail does not do this LastKnownContactPlane = plane; // <-- nor this LastKnownContactPlaneCellId = cellId; // <-- nor this LastKnownContactPlaneIsWater = isWater; // <-- nor this } ``` There are **13 call sites** in the tree: ``` BSPQuery.cs:1297, 2035 FlatBspQuery.cs:1393, 1921 <- production-authoritative since I6/I7 PhysicsEngine.cs:1987 <- the per-transition seed (retail init_contact_plane: correct) TransitionTypes.cs:3613, 3636 <- ValidateWalkable, both resting and below branches TransitionTypes.cs:4409, 4562, 4732, 4874 <- water paths TransitionTypes.cs:5756 <- validate_transition recovery (restores FROM lkcp: self-write, harmless) TransitionTypes.cs:5835 ``` Only `PhysicsEngine.cs:1987` legitimately corresponds to retail's `init_contact_plane`. The other twelve correspond to retail's `set_contact_plane`, and every one of them clobbers the last-known plane that retail deliberately preserves across the whole transition. `git log -S` dates the latch to `9ea8ae51`, **2026-04-13**, "feat(physics): Transition system data structures" — the very first commit that introduced the type. It has never been retail-verified, and it matches the user's report that the symptom is pre-existing rather than a regression. ### 3.3 The causal chain to "runs off the cliff" Walking a grounded local player at a drop, in one `ResolveWithTransition` → `TransitionalInsert` attempt: 1. `PhysicsEngine` seeds the transition from the body's committed contact plane (`ResolveWithTransition`, the `check_contact` success branch, `PhysicsEngine.cs:1978-1993`). Both current and last-known now hold **the ground you are standing on**. This part is retail-correct. 2. The forward candidate hangs over the drop. The primary insert finds no support and returns OK with no contact plane, so the step-down block is entered (Section 2.1). 3. `DoStepDown` offsets down and re-inserts. It finds the **steep cliff face**, and `ValidateWalkable` (`TransitionTypes.cs:3613`/`3636`) calls `ci.SetContactPlane(steepPlane, ...)`. → **Last-known is now also the steep plane.** The ground reference is gone. 4. `DoStepDown` rejects the candidate because `ContactPlane.Normal.Z < FloorZ` (`TransitionTypes.cs:5405-5407`), and — matching retail `step_down` — leaves the steep plane in place on the way out. 5. `EdgeSlideAfterStepDownFailed` takes **branch 2**: `ContactPlaneValid && Normal.Z < zVal` → `CliffSlide(steepPlane)`. 6. `CliffSlide` computes `Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal)` (`TransitionTypes.cs:2550-2552`). Both operands are now **the same vector**. Cross product = zero → the degenerate guard at `:2559` fires → returns `TransitionState.OK` with **no displacement**. 7. `EdgeSlideAfterStepDownFailed` returns `false` (stop = false) with `edgeState == OK`, so `TransitionalInsert` does `transitState = edgeState; continue;` (`TransitionTypes.cs:2255-2262`). 8. The retry re-inserts at the same restored candidate, fails the same way, and when the attempt budget runs out `TransitionalInsert` returns **OK**. `ValidateTransition` commits it. The body is now over the void, gravity takes it, and the player falls off the edge. In retail the same eight steps run, but at step 6 last-known still holds the cliff-top plane, the cross product is non-zero, `cliff_slide` returns `ADJUSTED_TS` after laterally displacing the candidate, and the player glides along the rim. ### 3.4 The divergence is already pinned by a test — as correct `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs:188-230`, `TransitionalInsert_DegenerateCliffSlideOk_ContinuesOuterRetry`, added by `c559c48d` (2026-07-31, "restore retail edge-response ordering"). Its own comment reads: ``` // The nested downward probe finds a steep contact. It is // rejected as walkable, and because SetContactPlane also // latches the same last-known plane, CliffSlide's cross is // parallel/degenerate and writes OK_TS with stop=false. ``` and it then asserts `TransitionState.OK`, two outer object passes, and a committed contact plane. The test **observes acdream's behaviour and pins it**; it does not compare against retail. Given `0x00509d80`, retail could not reach that configuration at all. This is the shape the C4 closeout named as its most expensive process finding — a contract asserting a mechanism that does not exist — and the fix will have to rewrite this test, not merely satisfy it. ### 3.5 Second-order effects of the same latch (blast radius, not new bugs) - `ValidateTransition`'s non-OK recovery (`TransitionTypes.cs:5745-5762`) calls `oi.StopVelocity()` and restores contact from last-known. Retail gates the same recovery on `last_known_contact_plane_valid`. Because acdream's last-known is valid far more often than retail's, `kill_velocity` can fire where retail's would not — which is the exact opposite of the L.4/L.5 finding recorded in #32 itself ("Retail trace: 0 kill_velocity hits across 40,960 update_object calls"). Worth checking against #269's slope-slide residual after any fix. - `transitional_insert`'s phase-3 reset reads `LastKnownContactPlaneValid` (`TransitionTypes.cs:2072`) to choose between `StopVelocity` and `SetCollisionNormal(StepUpNormal)`. Same over-validity concern. - `TransitionTypes.cs:5089-5090` falls back to last-known when current is invalid. - **Minor, same site:** acdream's `SetContactPlane` also writes `ContactPlaneCellId`, which retail's `set_contact_plane` does not (retail writes the cell id only in `init_contact_plane`, `0x0050e8ca`). Note it; do not necessarily change it, since acdream's callers rely on the cell id being current. --- ## 4. Why the remote fix at `204d0ae0` could not cover the local player `204d0ae0` ("remote bodies slide on steep faces instead of freezing (#32)") changed six production files: ``` src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs src/AcDream.Core/Physics/InterpolationManager.cs src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs src/AcDream.Core/Physics/PhysicsDiagnostics.cs (probe family) src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs ``` Not in the list: `TransitionTypes.cs`, `PhysicsEngine.cs`, `BSPQuery.cs`, `FlatBspQuery.cs`, `PlayerMovementController.cs`. The commit did not touch the sweep, the edge family, `CliffSlide`, or contact-plane maintenance at all. What it *did* do, per its own message and the #32 row, was delete four **remote-only** overrides layered on top of the shared engine: 1. a per-tick `TransientState |= Contact | OnWalkable` forge, 2. a per-tick `Body.Velocity` zero, 3. a `PhysicsStateFlags.Gravity` clear at the landing edge, 4. a landing edge tested on `IsOnGround` (`inContact || ...`) instead of the sweep's own `OnWalkable`, and route the remote tick through `PhysicsObjUpdate.CommitSetPositionTransition` — which its message describes as "the same SetPositionInternal commit TickHidden and the local player" already used. **That is the whole answer to "what structurally excludes the local player".** Nothing excludes it. The local player never had those four forgeries — it has been running the full `check_contact` seed, the full sweep, and the full `SetPositionInternal` commit since the #265/#166 landing-bounce work (`PlayerMovementController.cs:2632-2760`). The remote bug was a remote-only layer *suppressing* a shared mechanism; removing it restored the remote to parity with the local player. It could not fix a defect that lives *inside* the shared mechanism, and #32's own note — "Local-player edge-slide is unchanged by this work" — is literally accurate rather than an admission of a missing per-mover-class feature. Corollary worth stating: **the divergence in Section 3 affects remotes too.** The user's accepted remote gate was a steep-**roof slide-down under gravity**, driven by `adjust_offset`'s per-substep plane projection — a path that never reaches `edge_slide`. A remote walked at a cliff rim by the server should exhibit the same run-off, and would not have been caught by that gate. --- ## 5. Is #134 the same defect? `#134` — "Player 'lags downward' instead of gliding along a dungeon ramp edge", `docs/ISSUES.md:13692` — is **the same family and plausibly the same mechanism, and its DONE status is not supported by evidence.** Three observations: 1. **Its closure is a triage inference, not a verification.** The row reads: "Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session)", and attributes the fix to "the later CSphere/CCylSphere collision-family ports (#172, #182) and the general slide-response work tracked under #32/#116". None of those touched `CliffSlide` or last-known-plane maintenance. It is a "probably fixed by adjacent work" closure. 2. **The symptom is the branch-2 signature indoors.** "Gliding along the slope tangent" is `cliff_slide`'s lateral displacement; "lagging downward" is what you get when that displacement is zero and gravity is the only thing left acting on the candidate. A dungeon ramp edge presents exactly the branch-2 input: the step-down probe finds the ramp's steep side face, `Normal.Z < FloorZ`, contact plane valid. 3. **The indoor path clobbers last-known identically.** `FlatBspQuery.cs:1393` and `:1921` (production-authoritative since the I6/I7 flat cutover) call the same `SetContactPlane`. There is no outdoor/indoor difference in the mechanism. I cannot prove they are one defect without a measurement — a ramp edge could instead route through branch 3/5 (a BSP polygon *is* small enough for `find_crossed_edge` to fire, unlike a 24 m terrain triangle, so indoors the precipice branch is genuinely reachable where outdoors it usually is not). **Recommendation:** treat `#134` as an unverified closure and re-open it as a confirmation item on the same probe run, not as a separate investigation. If the probe shows `branch=branch2/steep-cliffslide` with a degenerate cross at a dungeon ramp edge, `#134` and `#32`'s local half are one fix. --- ## 6. Do we need a live measurement? Yes — and the probe already exists **The divergence in Section 3 is proven; its reachability in the user's scenario is not.** The specific unknown is which of `edge_slide`'s five branches fires at the user's cliff. Only branches 2 and (indirectly) 4 involve last-known; branches 3 and 5 route to `PrecipiceSlide`, where the LKCP latch is irrelevant and the run-off would have a different cause. There is a real reason to distrust source-only reasoning here. Outdoor terrain in acdream is a continuous heightfield whose walkable polygon is *half of a 24 m landcell* (`TerrainSurface.CellSize = 24f`, `SampleTerrainWalkableInCell` → `TerrainTriangleVertices`). A candidate 10 cm past a cliff lip is still deep inside that triangle, so `FindCrossedEdge` cannot fire and branch 3/5 should degenerate to Collided (= stop). That reasoning predicts a *stop*, not a fall — which is the opposite of what the user observes — and I would rather name the contradiction than paper over it. It is resolved if branch 2 wins the race (Section 3.3 step 5), but "which branch wins" is a fact about the DAT geometry under the user's feet, not about the code, and it is precisely the kind of thing a DAT sweep would happily "confirm" either way. ### The probe No new instrumentation is needed. `ACDREAM_DUMP_EDGE_SLIDE=1` (`TransitionTypes.cs:1302-1303`) already emits, at exactly the right site: - `DumpEdgeSlideStepDownFailed` (`:2584`) — one `edge-slide: stepdown-failed` line carrying `edgeFlag`, `contactFlag`, `onWalkable`, `contactPlane`, `lastPlane`, `walkableValid`, `walkablePoly`, `lastWalkablePoly`, `stepDown`, `zVal`. - `DumpEdgeSlideBranch` (`:2626`) — one `edge-slide: branch=` line naming the branch taken, plus `contactN.Z`, **`lastN.Z`**, `walkPolyN.Z`, `onWalk`, `edgeFlag`, `zVal`. - `DumpCliffSlide` (`:2558`/`:2567`) — `degenerate-cross/last-known` versus `ok/last-known`. Volume is low: it only fires on a failed step-down. ### Run it ```powershell $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" $env:ACDREAM_LIVE = "1" $env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" $env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword" $env:ACDREAM_DUMP_EDGE_SLIDE = "1" dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "edge-slide.log" ``` Walk the local player at the cliff the user reproduced on, then close the window gracefully (`CloseMainWindow`, not `Stop-Process`). ### Decision table | `branch=` in the log | `lastN.Z` vs `contactN.Z` | Verdict | |---|---|---| | `branch2/steep-cliffslide` + `degenerate-cross/last-known` | equal | **Section 3 confirmed.** Root cause is the `SetContactPlane` LKCP latch. Ship the Section 7 fix. | | `branch2/steep-cliffslide` + `ok/last-known` | differ | Cliff slide is running and producing a real displacement. The run-off is downstream of `edge_slide` — look at `ValidateTransition` / the retry budget instead. | | `branch3/precipice-slide` or the back-probe branch | — | The LKCP latch is not on this path. Root cause is in `FindCrossedEdge` / walkable-polygon granularity; a 24 m terrain triangle cannot produce a rim slide and the *mechanism itself* would then be missing outdoors. Different, larger fix. | | `branch1/!onwalkable-or-!edgeslide` | — | The mover lost `OnWalkable` before the edge. Root cause is upstream in contact classification, not in the edge family. | | `branch4/contact-no-walkable` | — | Retail-correct stop; the run-off is happening somewhere else entirely and this whole line of inquiry is wrong. | | no `edge-slide:` line at all | — | The step-down block is never reached — check the four gates in Section 2.1 (most likely `oi.Contact` failing `check_contact`, or a valid contact plane short-circuiting at `:2176`). | If nothing prints, that is itself the most informative outcome and redirects the whole investigation. Do not write any code before this run. --- ## 7. What the fix would be, and how big **Only if the probe lands in row 1.** Do not pre-emptively implement it. **Shape — split the setter the way retail splits it.** 1. In `CollisionInfo` (`src/AcDream.Core/Physics/TransitionTypes.cs:446-480`), reduce `SetContactPlane` to retail's `COLLISIONINFO::set_contact_plane` `0x00509d80`: write `ContactPlaneValid`, `ContactPlane`, `ContactPlaneIsWater` (keep `ContactPlaneCellId` — see Section 3.5 — and file it as a one-line register row if kept). Delete the four last-known writes. 2. Add `InitContactPlane(plane, cellId, isWater)` mirroring `CTransition::init_contact_plane` `0x0050e850`: write both groups. 3. Point `PhysicsEngine.ResolveWithTransition`'s `check_contact` success branch (`PhysicsEngine.cs:1987`) at `InitContactPlane`. Leave the failure branch alone — it already mirrors `init_last_known_contact_plane`. 4. Leave the other eleven call sites on the narrowed `SetContactPlane`. 5. `ValidateTransition`'s existing tail (`TransitionTypes.cs:5779-5783`) already is retail's `0x0050ad07` end-of-transition latch. No change. **Size.** Two production files, roughly 20-25 lines changed, one of them a pure deletion. This is genuinely small — the whole edge family is already ported; the fix restores one field's write discipline. **Not a workaround.** This is porting the retail mechanism, not suppressing a symptom. `CLAUDE.md`'s no-workarounds rule is satisfied: no guard, no grace period, no flag. If the probe instead lands in row 3, the honest answer is different and larger — retail's rim-slide behaviour would be *absent* outdoors and would have to be ported properly, and I would say so plainly rather than reach for the closest available approximation. **Test cost — larger than the production cost.** - `RetailEdgeResponseOrderingTests.TransitionalInsert_DegenerateCliffSlideOk_ContinuesOuterRetry` must be rewritten. It currently asserts the divergence as correct (Section 3.4). Its replacement should set the seed plane and the probe plane independently and assert `Adjusted` + a lateral displacement. - `CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn` (`:132`) stays valid — retail does return `OK_TS` when last-known really is absent. - The other nine files touching `LastKnownContactPlane` (`BSPStepUpTests`, `IndoorContactPlaneRetentionTests`, `RetailStepDownPlacementTests`, `FindEnvCollisionsMultiCellTests`, `PhysicsSetPositionTests`, `RuntimeRemoteSteepContactSlideTests`, ...) must be re-run and inspected — any that pass *because* an intra-transition write kept last-known valid will change. - Gates: full Core + Runtime + App suites, plus the connected nine-stop route, because of the `kill_velocity`-frequency blast radius in Section 3.5 and its overlap with #269. - Register: the fix retires nothing currently filed (there is no row for this — it is an unfiled divergence, "a bug twice over" by the register's own rule). If `ContactPlaneCellId` is kept, that gets a new row in the same commit. --- ## 8. Stale claims in #32's own text (filed 2026-04-29) Checked against HEAD; each verified individually. | Claim in #32 | Status | |---|---| | "acdream does not yet preserve the full walkable polygon context from terrain/BSP step-down, so this is still the conservative stop-at-edge fallback" (mirrored in the code comment at `TransitionTypes.cs:2223-2232`) | **STALE.** Walkable context is preserved for both terrain (`CacheWalkableContext`, `:3660-3671`) and BSP (`SetWalkableTransformed`), and the full five-branch chain plus the back-probe is implemented. `TS-1` was retired 2026-07-30 as "the row was stale, not the code". The in-code comment is stale too and should be corrected with the fix. | | "Pragmatic ship-state: BSPQuery Path 6 keeps the L.4 slide-tangent deviation (project-along-steep-face-and-return-Slid)" | **STALE.** Production is `FlatBspQuery` since the I6/I7 flat cutover; its Path 6 (`FlatBspQuery.cs:2011-2042`) is the retail `set_collide` + `WalkableAllowance = LandingZ` + `Adjusted` shape. There is no slide-tangent return. | | "Remaining gaps: ... `NegPolyHit` dispatch" | **STALE.** Neg-poly dispatch shipped A6.P4 (2026-05-25) and is live at `TransitionTypes.cs:2110-2170` (`slide_sphere` / `step_up` → `step_up_slide`). | | "the AP-140 follow-up (point the two routing gates at `Body.InContact`) [remains open]" — in the status header | **STALE.** The register records AP-140 as "filed AND RETIRED 2026-08-04", same day. | | "We ported this gate as L.5 in `PlayerMovementController` via `_physicsAccum`" | **STALE.** `_physicsAccum` does not exist anywhere in the tree; the 30 Hz gate is `RetailObjectQuantumClock` since R6. | | "**Files:** `TransitionTypes.cs`, `BSPQuery.cs`, `tests/`" | **STALE/INCOMPLETE.** `BSPQuery` is no longer the production collision path (`FlatBspQuery` is). The file list should name `FlatBspQuery.cs` and `PhysicsEngine.cs`. | | "AD-10's terrain-only slope projection still cannot see building geometry" | Already self-corrected in the body (superseded 2026-08-06, AD-10 retired by deletion) but the **status header still lists it as a dependency**. Header and body disagree. | | "Local/remote movement passes the retail-default `EdgeSlide` flag" | **ACCURATE.** Verified at `PlayerMovementController.cs:2170`/`:2661` and `RuntimeRemotePhysicsUpdater.cs:442`/`:998`. Retail's source is `CPhysicsObj::get_object_info` `0x00511cd1`: `state & 0x400000 -> result 0x200`, and `PhysicsBody.cs:79` has `EdgeSlide = 0x00400000`. | | "Our Phase 3 reset path now matches retail's gate (only kills when valid)" | **ACCURATE** at `TransitionTypes.cs:2072`. (But see Section 3.5 — the *gate* matches while the *validity* is over-set.) | | "A LeaveGround-count bound is the missing test" | **ACCURATE, still open.** No `LeaveGround` count assertion exists anywhere in `tests/`. | | "#173's visual gate is still unrun" | Not independently checkable this session; left as-is. | --- ## 9. Summary for the caller - **Retail** refuses a step-down onto anything steeper than ~48.4 degrees, then runs `CTransition::edge_slide`, which picks one of five responses in a fixed order. The "slide along a cliff edge" response is `cliff_slide`, and its direction is `cross(steep plane normal, last-known contact plane normal)` — it needs the surface you were standing on a moment ago as its second vector. - **acdream** implements all five branches in the correct order with the correct flags, for the local player, verified against disassembly. But `CollisionInfo.SetContactPlane` latches the last-known contact plane on every write, where retail's `COLLISIONINFO::set_contact_plane` (`0x00509d80`, 22 bytes) never touches it. The step-down probe's own steep plane therefore overwrites the reference vector, `cliff_slide`'s cross product goes to zero, the degenerate `OK` return displaces nothing, and the retry loop accepts the candidate hanging over the drop. The latch dates to `9ea8ae51` (2026-04-13) and has never been retail-verified — consistent with "pre-existing, not a regression". - **The local player is not structurally excluded from anything.** `204d0ae0` deleted four remote-only forgeries sitting on top of the shared engine; it never touched the sweep, the edge family, or contact-plane maintenance. The same divergence should affect remotes walked at a rim — the accepted remote gate was a gravity roof-slide, which never reaches `edge_slide`. - **#134 is the same family and plausibly the same mechanism**, and its DONE status is a 2026-07-09 triage inference attributed to work that did not touch this code. Re-open it as a confirmation item on the same probe run. - **The fix** is to split `SetContactPlane` into retail's narrow setter plus an `InitContactPlane` seed, and point only the per-transition seed at the latter. Two files, ~20-25 lines, mostly deletion. The test cost is larger than the production cost: an existing test pins the divergence as correct and must be rewritten, and nine other files touching `LastKnownContactPlane` need re-inspection. - **Yes, measure first.** The divergence is proven; its reachability in the user's cliff is not, and one branch of my own reasoning predicts a stop rather than a fall, which I could not reconcile from source. `ACDREAM_DUMP_EDGE_SLIDE=1` already prints the branch taken and both normals at exactly the deciding site. One walk at the reproducing cliff settles it against the decision table in Section 6. No code should be written before that run.