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>
704 lines
38 KiB
Markdown
704 lines
38 KiB
Markdown
# 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`:
|
|
|
|
```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.
|