Both halves of #32 are now closed: remote at204d0ae0(user-passed 2026-08-04), local at332045c7(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>
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_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:
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:
!ON_WALKABLE || !EDGE_SLIDE→ restore the saved candidate,OK_TS, stop.- valid contact plane steeper than the allowance → restore,
cliff_slide, return false so the insert keeps retrying against the displaced candidate. - a stored walkable polygon → restore,
precipice_slideagainst it. - valid contact plane, no walkable polygon → restore,
OK_TS, stop. - otherwise → back-probe from the current sphere centre to rediscover the
polygon just left, restore,
precipice_slideagainst 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
ResolveWithTransition → TransitionalInsert attempt:
PhysicsEngineseeds the transition from the body's committed contact plane (ResolveWithTransition, thecheck_contactsuccess branch,PhysicsEngine.cs:1978-1993). Both current and last-known now hold the ground you are standing on. This part is retail-correct.- 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).
DoStepDownoffsets down and re-inserts. It finds the steep cliff face, andValidateWalkable(TransitionTypes.cs:3613/3636) callsci.SetContactPlane(steepPlane, ...). → Last-known is now also the steep plane. The ground reference is gone.DoStepDownrejects the candidate becauseContactPlane.Normal.Z < FloorZ(TransitionTypes.cs:5405-5407), and — matching retailstep_down— leaves the steep plane in place on the way out.EdgeSlideAfterStepDownFailedtakes branch 2:ContactPlaneValid && Normal.Z < zVal→CliffSlide(steepPlane).CliffSlidecomputesVector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal)(TransitionTypes.cs:2550-2552). Both operands are now the same vector. Cross product = zero → the degenerate guard at:2559fires → returnsTransitionState.OKwith no displacement.EdgeSlideAfterStepDownFailedreturnsfalse(stop = false) withedgeState == OK, soTransitionalInsertdoestransitState = edgeState; continue;(TransitionTypes.cs:2255-2262).- The retry re-inserts at the same restored candidate, fails the same way, and
when the attempt budget runs out
TransitionalInsertreturns OK.ValidateTransitioncommits 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) callsoi.StopVelocity()and restores contact from last-known. Retail gates the same recovery onlast_known_contact_plane_valid. Because acdream's last-known is valid far more often than retail's,kill_velocitycan 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 readsLastKnownContactPlaneValid(TransitionTypes.cs:2072) to choose betweenStopVelocityandSetCollisionNormal(StepUpNormal). Same over-validity concern.TransitionTypes.cs:5089-5090falls back to last-known when current is invalid.- Minor, same site: acdream's
SetContactPlanealso writesContactPlaneCellId, which retail'sset_contact_planedoes not (retail writes the cell id only ininit_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:
- a per-tick
TransientState |= Contact | OnWalkableforge, - a per-tick
Body.Velocityzero, - a
PhysicsStateFlags.Gravityclear at the landing edge, - a landing edge tested on
IsOnGround(inContact || ...) instead of the sweep's ownOnWalkable,
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:
- 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
CliffSlideor last-known-plane maintenance. It is a "probably fixed by adjacent work" closure. - 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. - The indoor path clobbers last-known identically.
FlatBspQuery.cs:1393and:1921(production-authoritative since the I6/I7 flat cutover) call the sameSetContactPlane. 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) — oneedge-slide: stepdown-failedline carryingedgeFlag,contactFlag,onWalkable,contactPlane,lastPlane,walkableValid,walkablePoly,lastWalkablePoly,stepDown,zVal.DumpEdgeSlideBranch(:2626) — oneedge-slide: branch=line naming the branch taken, pluscontactN.Z,lastN.Z,walkPolyN.Z,onWalk,edgeFlag,zVal.DumpCliffSlide(:2558/:2567) —degenerate-cross/last-knownversusok/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.
- In
CollisionInfo(src/AcDream.Core/Physics/TransitionTypes.cs:446-480), reduceSetContactPlaneto retail'sCOLLISIONINFO::set_contact_plane0x00509d80: writeContactPlaneValid,ContactPlane,ContactPlaneIsWater(keepContactPlaneCellId— see Section 3.5 — and file it as a one-line register row if kept). Delete the four last-known writes. - Add
InitContactPlane(plane, cellId, isWater)mirroringCTransition::init_contact_plane0x0050e850: write both groups. - Point
PhysicsEngine.ResolveWithTransition'scheck_contactsuccess branch (PhysicsEngine.cs:1987) atInitContactPlane. Leave the failure branch alone — it already mirrorsinit_last_known_contact_plane. - Leave the other eleven call sites on the narrowed
SetContactPlane. ValidateTransition's existing tail (TransitionTypes.cs:5779-5783) already is retail's0x0050ad07end-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_ContinuesOuterRetrymust 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 assertAdjusted+ a lateral displacement.CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn(:132) stays valid — retail does returnOK_TSwhen 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
ContactPlaneCellIdis 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 iscliff_slide, and its direction iscross(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.SetContactPlanelatches the last-known contact plane on every write, where retail'sCOLLISIONINFO::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 degenerateOKreturn displaces nothing, and the retry loop accepts the candidate hanging over the drop. The latch dates to9ea8ae51(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.
204d0ae0deleted 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 reachesedge_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
SetContactPlaneinto retail's narrow setter plus anInitContactPlaneseed, 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 touchingLastKnownContactPlaneneed 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=1already 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.