diff --git a/docs/research/2026-08-06-32-local-edge-slide-research.md b/docs/research/2026-08-06-32-local-edge-slide-research.md new file mode 100644 index 00000000..747a00c7 --- /dev/null +++ b/docs/research/2026-08-06-32-local-edge-slide-research.md @@ -0,0 +1,693 @@ +# Issue #32 (local half) — the player runs off cliff edges instead of stopping or sliding + +**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.