docs: research #32's local half — the last-known contact plane is clobbered mid-transition

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-06 18:32:26 +02:00
parent 801ff5fd44
commit d5dd0b554b

View file

@ -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.