acdream/docs/research/2026-08-06-32-local-edge-slide-research.md
Erik 1dc81710f3 docs: close #32 — local edge-slide user-passed; AD-67 filed for the kept cell-id write
Both halves of #32 are now closed: remote at 204d0ae0 (user-passed
2026-08-04), local at 332045c7 (user-passed 2026-08-07 at the Rithwic
cliff, on the first launch whose capture printed the fixed binary's own
assembly path). The research doc carries the outcome banner: the live
capture landed in decision-table row 1 verbatim and Section 7's fix
shipped unchanged.

AD-67 records the one deliberate residual: the narrowed SetContactPlane
still writes ContactPlaneCellId, which retail writes only at
init_contact_plane (0x0050e8ca). Kept on the research doc's own advice —
our consumers want the current value — and not bundled into the fix
commit, where a second behaviour change would have made the user's
cliff gate ambiguous. AD section 49 -> 50.

Section 3.5's blast-radius items stay open as watch items, now strictly
MORE reachable than before the fix (last-known validity is narrower, so
the StopVelocity recovery and phase-3 reset take their invalid branches
more often). Carried onto Campaign S slice S4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:38:02 +02:00

38 KiB

Issue #32 (local half) — the player runs off cliff edges instead of stopping or sliding

OUTCOME 2026-08-07 — Section 6's capture landed in decision-table ROW 1 verbatim (six branch2/steep-cliffslide events at Rithwic, every one curN == lastN == (-0.954, 0.000, 0.301), angle=0.0000, apply=False, outcome degenerate-cross/last-known), the Section 7 fix shipped at 332045c7, and the user passed it live: "Yes works now." One caveat for the record: the first live "test" of the fix ran a stale checkout's binary and produced a void failure verdict — see the #32 entries in docs/ISSUES.md for that detour and the assembly-identity rule it produced. Section 3.5's cell-id note is now register row AD-67; its blast-radius items are carried as watch items on Campaign S slice S4.

Date: 2026-08-06 Mode: research / report-only. No production or test code was written or changed. Worktree: .claude/worktrees/peaceful-visvesvaraya-e0a196, branch claude/acdream-physics-divergence-5aa784.


0. Git state caveat (read first)

git fetch github succeeded; git merge --ff-only github/main was refused — this branch is diverged (1 ahead / 5 behind: it carries local commit 5c93c2c5, which is not in github/main). Per instruction nothing else was done to git.

That matters only if the five missing commits touch what this report analyses. They do not. git diff --name-only HEAD github/main over the analysed sources returns no hits for TransitionTypes.cs, PhysicsEngine.cs, FlatBspQuery.cs, BSPQuery.cs, PlayerMovementController.cs, or RetailEdgeResponseOrderingTests.cs. The only doc deltas are new rows for #333/#334 and AP-152/AP-156/AP-158; #32's own text is byte-identical between HEAD and github/main. Every source claim below therefore holds at github/main.

Retail binary verified before any disassembly:

py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"
  GUID = {9e847e2f-777c-4bd9-886c-22256bb87f32}
  === MATCH: this exe pairs with our acclient.pdb ===

Every address cited below was resolved back to a name in docs/research/named-retail/symbols.json before being used, and every function body was bounded by finding its own ret before the next symbol. Address / symbol pairs confirmed this way:

Address PDB symbol
0x00509d80 COLLISIONINFO::set_contact_plane
0x0050a6d0 CTransition::cliff_slide
0x0050a880 SPHEREPATH::save_check_pos
0x0050b100 SPHEREPATH::restore_check_pos
0x0050b2a0 CTransition::step_down
0x0050b3d0 CTransition::edge_slide
0x0050b6f0 CTransition::transitional_insert
0x0050aa70 CTransition::validate_transition
0x0050cc80 SPHEREPATH::precipice_slide
0x0050cf20 OBJECTINFO::get_walkable_z
0x0050e850 CTransition::init_contact_plane
0x0050e8e0 CTransition::init_last_known_contact_plane
0x00511cc0 CPhysicsObj::get_object_info

1. Executive answer

The digest does not already carry #32's local half. claude-memory/project_physics_collision_digest.md mentions PrecipiceSlide three times, all in other sagas (#185's DO-NOT-RETRY, #116's residual, the step-down/step-up seam notes). There is no recorded characterisation of the local-player cliff case. This is not a #331-style rediscovery.

What retail does at a walkable edge is a four-stage chain inside one CTransition::transitional_insert attempt: probe down → if the probe finds no walkable support, run edge_slideedge_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:

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_slide0x0050b921 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:

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 ResolveWithTransitionTransitionalInsert 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 < zValCliffSlide(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, SampleTerrainWalkableInCellTerrainTriangleVertices). 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

$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_upstep_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.