From d5dd0b554b861bc1f22a477a5465c0c87d39c6d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 6 Aug 2026 18:32:26 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20research=20#32's=20local=20half=20?= =?UTF-8?q?=E2=80=94=20the=20last-known=20contact=20plane=20is=20clobbered?= =?UTF-8?q?=20mid-transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report-only investigation of the local player running off cliff edges. No production or test code changed. Retail's rim slide is CTransition::cliff_slide (0x0050a6d0), whose direction is cross(steep contact normal, last_known_contact_plane.N) — it needs the surface the mover was standing on as its second vector. Disassembly of the PDB-paired binary shows COLLISIONINFO::set_contact_plane (0x00509d80, 22 bytes) writes only contact_plane_valid / contact_plane / contact_plane_is_water; the last-known group has exactly four semantic writers in retail, all outside the per-substep collision response (CTransition::init_contact_plane 0x0050e850, init_last_known_contact_plane 0x0050e8e0, the validate_transition tail 0x0050ad07, and the clears). acdream's CollisionInfo.SetContactPlane latches the last-known group on every write, at all 13 call sites. The step-down probe's own steep plane therefore overwrites the ground reference before EdgeSlideAfterStepDownFailed reads it, CliffSlide's cross product goes to zero, its degenerate OK return displaces nothing, and TransitionalInsert's retry accepts the candidate hanging over the drop. The latch dates to 9ea8ae51 (2026-04-13) and was never retail-verified, matching the user's "pre-existing, not a regression". Also established: every edge_slide branch, gate and threshold in acdream is byte-exact against 0x0050b3d0 / 0x0050b812 / 0x0050b886 (the step-down probe schedule is confirmed against the disassembly, which corrects a Binary Ninja stack-slot misread at 0x0050b8ba); 204d0ae0 deleted four remote-only forgeries and never touched the sweep, the edge family or contact-plane maintenance, so it could not have covered the local player; #134 is the same family with an unverified 2026-07-09 triage closure; and six claims in #32's own text are stale at HEAD. The causal link to the user's cliff is a hypothesis, not a proof — it needs edge_slide to take branch 2. ACDREAM_DUMP_EDGE_SLIDE=1 already prints the branch and both normals at the deciding site; the report carries the run command and a six-row decision table. No code before that run. Co-Authored-By: Claude Opus 4.8 --- ...2026-08-06-32-local-edge-slide-research.md | 693 ++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 docs/research/2026-08-06-32-local-edge-slide-research.md 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.