diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 6ebad661..0b588c86 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -1,4 +1,4 @@
-# Retail Divergence Register — current through 2026-07-30
+# Retail Divergence Register — current through 2026-07-31
**What this is.** The single auditable register of every known place acdream's
runtime behavior can deviate from the retail client (Sept 2013 EoR build,
@@ -66,8 +66,8 @@ accepted-divergence entries (#96, #49, #50).
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
-| AD-53 | `Transition.CliffSlide`'s reference-normal cross-product operand tries THREE sources in priority order (`LastWalkablePlane` if `Normal.Z >= FloorZ`, then `LastKnownContactPlane` at the same threshold, then world-up `UnitZ`); retail's `CTransition::cliff_slide` uses `this->collision_info.last_known_contact_plane.N` directly, with no fallback chain | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`, the `referenceNormal`/`refSource` selection above the cross-product) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). A fresh read of `last_known_contact_plane`'s own maintenance (pc:272659-272668) confirms retail overwrites it unconditionally from `contact_plane` every `validate_transition` pass — including with a steep plane — so retail keeps NO separately-preserved flat-ground history there either; this fallback chain is a genuine acdream invention, not a retail-matching read. Kept because it compensates for AP-4's incomplete `OnWalkable` bookkeeping (L.4-cliffslide-fallback, 2026-04-30): without it, `cross(currentSteep, lastKnownSteep)` degenerates to zero after >1 frame on a continuous steep slope, and CliffSlide returns `OK` (no deflection) instead of downhill drift — the "stay on the roof" wedge the L.4 session fought | If AP-4's `OnWalkable` reordering is ever completed/removed, `last_known_contact_plane` should carry the same information retail's does and this fallback chain becomes unneeded ballast (or, worse, silently picks a stale `LastWalkablePlane` over the now-correct current one) — re-audit together with AP-4 | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #2 |
-| AD-54 | `Transition.EdgeSlideAfterStepDownFailed` reroutes to `CliffSlide` instead of `PrecipiceSlide` when the stored walkable polygon itself is steeper than `FloorZ` (`sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ`); retail's raw `SPHEREPATH::edge_slide` has no steepness branch here — `if (walkable != null) { ... precipice_slide(...) }` unconditionally | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`, the `L.4-walkable-steep` block) | Filed 2026-07-30 splitting TS-1's retirement (Campaign P Slice P2). The permissive `LandingZ` walkable-acceptance threshold that lets a steep roof become "walkable" in the first place IS confirmed retail-faithful (`BSPTREE::find_collisions` unconditional `walkable_allowance = LandingZ`, pc:323740-323783, TS-4's own citation) — so a steep-roof walkable polygon is a real state retail also reaches. What is NOT independently verified is whether retail's outer `transitional_insert` caller absorbs a same-polygon-standing `COLLIDED_TS` from `precipice_slide` (its raw `find_crossed_edge` returning false while standing on, not crossing, the polygon) some other way that avoids the acdream "stuck in a Collided revert loop" this reroute prevents | If retail's outer retry loop turns out to already handle the no-crossed-edge-while-standing-on-a-steep-poly case without a reroute, this branch is an unnecessary compensating layer that could route a genuinely PrecipiceSlide-bound case (a shallow polygon edge that happens to sit at exactly `FloorZ`) into CliffSlide instead | `SPHEREPATH::edge_slide` pc:273001-273090 (0050b3d0, direct walkable branch quoted at pc:364-370 in the P2 research doc); `BSPTREE::find_collisions` pc:323740-323783 (0053a730, unconditional `LandingZ`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2 gap #3 |
+| ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) |
+| ~~AD-54~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** Every stored walkable polygon now routes unconditionally to `PrecipiceSlide`, including a plane steeper than `FloorZ`; the invented steep-walkable reroute to `CliffSlide` is gone. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` pc:273001-273090 (0050b3d0) |
| AD-55 | `calc_friction`'s Sledding slope-flatness test compares `GroundNormal.Z > 0.99999536f` (≈0.175° from flat); the raw retail decomp literally computes `__fcos(0.17453292519943295)` (= cos(10°) ≈ 0.984808) and compares that against `contact_plane.N.z` — physically very different tests (0.175° accepts only essentially-perfectly-flat ground; 10° accepts any modest slope) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`, the Sledding near-flat branch) | Filed 2026-07-30 splitting AP-7's retirement (Campaign P Slice P2). Two hypotheses, neither confirmed this pass: (a) BN misdecompiled a raw float-constant load as an `__fcos()` call (a known BN artifact class), or (b) ACE's own port made an independent error and cos(10°) is correct. `0.99999536f` is kept provisionally — least churn, since it is what acdream's own prior (structurally unreachable) dead code already had — pending a live Ghidra decompile of `0050ee70` checking whether the FCOS opcode is real or a raw `FLD` of one of these two constants | Currently harmless in production: nothing sets `PhysicsState.Sledding` client-side (see #166 research), so this branch is unreachable either way. The moment a data-authored Sledding toggle exists, the wrong constant changes which slopes get the light 0.2f sled-friction override vs. the heavier default | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70), the `__fcos(0.17453292519943295)` slope-flatness comparison; ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141 (`0.99999536f`); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1, §7 item 3 |
| AD-46 | **LIVE. Reframed at Campaign V slice V11 (2026-07-29), when GL was deleted and the comparison that discovered this row ceased to exist.** Dense alpha-blended distant scenery (the treeline) may read slightly denser than retail's, because the anisotropic TAP PATTERN is implementation-defined and acdream's Vulkan driver does not tap identically to retail's D3D9 one. Both request the same sampler state — trilinear, clamp-and-repeat, the device's maximum anisotropy. **What changed at V11 is only the left-hand side of the comparison**: this was measured GL-vs-Vulkan (~15% of the pixels in the band), and it is now a Vulkan-vs-retail question against the D3D oracle in the last column. The measurement below is retained as the evidence that the residual is a tap pattern and not a bug, even though one of its two arms no longer exists. | `src/AcDream.App/Rendering/Wb/WorldTextureArray.cs` (`RhiWorldTextureArray.WorldArrayAnisotropy`); measured in plan §5.5.19, reframed §5.5.24 | Not assumed — narrowed by measurement while both backends still existed, on an offline capture with no session, no entities and both clocks pinned. Anisotropy 1 → 41,509 differing pixels in the tree band; anisotropy 16 (GL's value, and retail's `m_D3DCaps.MaxAnisotropy`) → 22,266, and the rest of the frame fell to 497 px of 563,200, i.e. 8.8e-04, inside the campaign's 0.001 threshold. The residual was not a sub-pixel shift (an integer shift search found none), not a sharpness change (high-frequency energy matched within 5%), and not depth precision (forcing Vulkan's window-depth range to GL's compressed [0.5, 1] moved it by 3%). Monotone improvement toward GL's own anisotropy with no knob left is what made it a driver property rather than a bug. | Distant foliage shimmers or reads denser than retail's. The class is confined to alpha-blended dense overlap: opaque terrain, roofs, walls, water, statics, the character and the whole retained UI are inside threshold. **Now unfalsifiable by self-differential** — with GL gone, the only way to retire this row is a side-by-side against the retail client, not against another acdream backend. | `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`, whose `SetSamplerState(stage, 0xA /* D3DSAMP_MAXANISOTROPY */, m_D3DCaps.MaxAnisotropy)` at `0x005a4230` is the value acdream requests |
| AD-47 | **Filed at Campaign V slice V11 (2026-07-29); the campaign's risk register scheduled this row here.** Multisample resolve sample POSITIONS are unspecified by both the Vulkan and D3D9 specifications, so acdream's MSAA-on silhouette edges do not match retail's pixel-for-pixel even at the same sample count. acdream's strict pixel gates therefore run with MSAA forced OFF on every arm, and MSAA-on gets only a relaxed visual smoke. | `src/AcDream.App/RuntimeOptions.cs` (`ACDREAM_MSAA_SAMPLES`); forced to 0 in `tools/run-offline-pixel-gate.ps1` | Measured, not assumed: plan §5.5.16 compared two backends at 4x and found **8.83% of the frame differing — 81,359 px of 921,600 — essentially all of it hugging foliage and silhouette edges**, which is ninety-fold over the 0.001 gate threshold. That is two implementations' sample patterns, not a renderer divergence, which is why forcing MSAA off is what makes the remaining difference attributable rather than a threshold relaxation. | Edge quality on thin geometry (fence rails, foliage, distant railings) differs from retail at the sub-pixel level whenever MSAA is on, which is the ordinary player configuration. Because the gates run MSAA off, **a real regression confined to the multisample path would not be caught by them** — that is the actual exposure this row records. | D3D9 `D3DRS_MULTISAMPLEANTIALIAS` / `D3DMULTISAMPLE_TYPE` as set by `RenderDeviceD3D::SetDefaultD3DStates @ 0x005a3800`; retail's sample pattern is the driver's, exactly as ours is |
@@ -118,7 +118,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 90 active rows (AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
+## 3. Documented approximation (AP) — 88 active rows (AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@@ -128,8 +128,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|---|---|---|---|---|---|
| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
-| AP-3 | Step-down chain also runs for a valid contact plane when that plane is steeper than walkable; retail's `transitional_insert` OK-path returns immediately for every valid contact plane and enters the step-down tail only when contact is invalid | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`) | The added steep-contact entry preserves the current cliff-slide compensation while the response-layer state/order family remains open (AP-4/AD-53/AD-54/TS-4) | A steep valid contact can enter step-down/edge response where retail restores or validates state through its normal contact path, producing different retry and slide behavior | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 |
-| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
+| ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 |
+| ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
| ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. **CORRECTION (2026-07-30, same day, #265/#166 capture bisect):** the sentence above undersold the gap — `calc_friction` wasn't merely "no horizontal velocity to hammer," it was structurally UNREACHABLE with meaningful data on ANY grounded path: (a) the animation-root-motion path zeroed `Velocity.X/Y` outright every tick (the actual #265/#166 root cause, ten days pre-existing, not a Campaign-P regression), and (b) `PhysicsBody.GroundNormal` — the vector `calc_friction` dots velocity against — had ZERO production writers anywhere and silently defaulted to `Vector3.UnitZ` forever, so even surviving velocity would have been tested against a fake flat-ground normal on any real slope. Both gaps are now closed: `PlayerMovementController.cs`'s grounded block no longer reconstructs `Velocity` for the animation-root-motion case, and `PhysicsEngine.cs` syncs `body.GroundNormal` from the committed `ContactPlane.Normal` at the same commit point that already publishes `ContactPlane`. The 0.25f threshold port itself (this row's original subject) was always correct — it just had nothing real to operate on until this fix. See `docs/research/2026-07-30-265-capture-bisect.md`'s as-fixed addendum. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`GroundNormal` wiring); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (grounded-velocity fix); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block); `tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs`; `tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs` | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1; `docs/research/2026-07-30-265-capture-bisect.md` |
| ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `PhysicsEngine.SampleTerrainWalkable`'s `isWater = waterDepth >= 0.45f` threshold means the restore does not flip the dry corner's water classification (0.1 still < 0.45) — only the sink-in depth changes. Full Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | — | — | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 |
diff --git a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
index ff83cf7c..b284efa4 100644
--- a/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
+++ b/docs/research/2026-07-30-response-layer-edge-family-pseudocode.md
@@ -1,7 +1,7 @@
# P2 — Collision response-layer edge family: port-ready pseudocode
-**Status: RESEARCH PASS COMPLETE (2026-07-30); IMPLEMENTATION PASS PARTIAL
-(2026-07-30).** Originally a research-only doc for Campaign P Slice P2
+**Status: RESEARCH PASS COMPLETE (2026-07-30); RETAIL RESPONSE ORDER PORTED
+(2026-07-31).** Originally a research-only doc for Campaign P Slice P2
(`docs/plans/2026-07-29-physics-parity-campaign.md` §P2); a same-day
implementation session landed TS-1's retirement and AP-7's fix, attempted
TS-4 per this doc's own §6 Step 3 fixture-first order, reproduced the
@@ -24,7 +24,10 @@ unreachable from Path 6's unconditional `SetCollide`, which returns
`Adjusted` without repositioning the sphere) and what a future attempt
needs to check first; #116 remains a genuine oracle-first research item
needing live cdb/Ghidra, not an implementation item (see §5). Read §6 (port
-order) before starting further implementation on this family.
+order) before starting further implementation on this family. Campaign P
+Slice 1B subsequently performed that fresh `transitional_insert` read and
+removed AP-3, AP-4, AD-53, and AD-54; the exact closeout and controls are in
+§8. TS-4 remains deliberately unchanged.
Every claim below is tagged **FACT** (grep/read-verified against the
named-retail decomp, the register, ISSUES.md, or current acdream source
@@ -72,12 +75,11 @@ These bind the P2 implementer. Do not re-attempt any of these shapes.
once already (2026-06-12) for the EPSILON-vs-EpsilonSq bug; Ghidra MCP
is DOWN for this research pass — mark any residual x87-ambiguous claim
Ghidra-verify, cite ACE as the fallback tiebreaker, do not silently guess.
-9. **AP-4 (CliffSlide check moved before retail's Branch-1 gate)** is a
- live, load-bearing reordering compensating for acdream's incomplete
- OnWalkable bookkeeping — touches the same code region as TS-1. Do not
- revert AP-4's reordering without re-verifying OnWalkable is complete;
- read AP-4's full row before changing `TransitionTypes.cs:1316` control
- flow.
+9. **SUPERSEDED 2026-07-31 by Campaign P Slice 1B.** AP-4's CliffSlide-first
+ compensation was removed only after the complete retail
+ `transitional_insert`/`edge_slide` order was read and branch-order plus
+ graph/flat multi-frame roof/ledge controls passed. Do not reintroduce the
+ compensation; see §8 and the retired AP-4 row.
10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2**
(it's P3) but shares files (`TransitionTypes.cs` `InitPath`) — do not
fold TS-46 sphere-list work into a P2 commit.
@@ -1066,3 +1068,46 @@ it blocks.
behavior."
---
+
+## 8. Campaign P Slice 1B closeout — exact response ordering (2026-07-31)
+
+The follow-up read used the complete named-retail bodies, not the earlier
+excerpt summaries:
+
+- `CTransition::transitional_insert` at `0x0050B6F0`
+ (pseudo-C:273137 onward) returns `OK_TS` as soon as
+ `contact_plane_valid != 0`. Only an invalid contact reaches the ordinary
+ StepDown tail, whose remaining gates are Contact,
+ `!sphere_path.step_down`, a non-null check cell, and ObjectInfo.StepDown.
+- Its StepDown schedule is asymmetric by authored sphere count. For a
+ one-sphere mover whose requested height exceeds the foot diameter, retail
+ clamps the probe to half the foot radius and performs one probe. Otherwise
+ a request within the diameter probes once; an over-diameter request on a
+ two-sphere mover is halved and probes twice in sequence.
+- `CTransition::edge_slide` at `0x0050B3D0`
+ (pseudo-C:273001-273090) runs `!OnWalkable || !EdgeSlide` restore-and-OK
+ before its steep-contact CliffSlide branch. Any stored walkable polygon
+ routes to PrecipiceSlide without a steepness test.
+- `CTransition::cliff_slide` at `0x0050A6D0`
+ (pseudo-C:272397 onward) crosses the supplied contact normal only with
+ `collision_info.last_known_contact_plane.N`. It has no remembered-walkable
+ or world-up substitute. A default, invalid, parallel, or otherwise
+ degenerate cross naturally returns `OK_TS` through the retail normalization
+ guard.
+
+`TransitionTypes.cs` now follows that order exactly. AP-3, AP-4, AD-53, and
+AD-54 are retired together. The implementation deliberately preserves the
+existing ordinary-tail `runPlacement: false` choice and does not alter TS-4's
+Path-6 steep-polygon shortcut.
+
+`RetailEdgeResponseOrderingTests` pins every distinguishing branch: valid
+steep-contact early return, the one/two-sphere probe schedule,
+not-OnWalkable-before-CliffSlide, last-known-only source selection, degenerate
+last-known handling, and stored-steep-walkable-to-Precipice routing. It also
+runs multi-frame steep-roof and flat-roof-edge controls through both parsed
+graph and prepared-flat collision traversal, requires exact trace parity, and
+rejects a greater-than-15-tick frozen streak. The earlier dedicated
+`Ts4SteepRoofWedgeCaptureTests` remains green, so retiring these four
+compensations did not require weakening or deleting the TS-4 control.
+
+---
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index f98776a0..6889b0af 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -1733,6 +1733,40 @@ public sealed class Transition
PhysicsEngine engine)
=> TransitionalInsert(numAttempts, engine);
+ ///
+ /// Retail's one-versus-two-sphere step-down schedule from
+ /// CTransition::transitional_insert (0050b889-0050b8f0).
+ /// A one-sphere mover whose requested drop exceeds its diameter is
+ /// clamped to half its radius and probes once. A two-sphere mover keeps
+ /// the requested height and splits an over-diameter drop into two equal
+ /// probes.
+ ///
+ internal static (float ProbeHeight, int ProbeCount) GetStepDownProbePlan(
+ int numSpheres,
+ float sphereRadius,
+ float requestedHeight)
+ {
+ float diameter = sphereRadius * 2f;
+ float probeHeight = requestedHeight;
+
+ if (numSpheres < 2 && diameter < probeHeight)
+ probeHeight = sphereRadius * 0.5f;
+
+ if (diameter >= probeHeight)
+ return (probeHeight, 1);
+
+ return (probeHeight * 0.5f, 2);
+ }
+
+ internal TransitionState EdgeSlideAfterStepDownFailedForTest(
+ PhysicsEngine engine,
+ float stepDownHeight,
+ float zVal)
+ => EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
+
+ internal TransitionState CliffSlideForTest(Plane contactPlane)
+ => CliffSlide(contactPlane);
+
private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
{
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
@@ -1987,33 +2021,14 @@ public sealed class Transition
}
}
- // Handle step-down when in contact but no ground plane found.
- // This happens when the player is on a slope edge: they're marked
- // as in contact with the ground, but the current CheckPos has no
- // terrain contact (walked off an edge). Attempt a step-down to
- // maintain ground contact.
- //
- // L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane
- // IS valid but the surface is too steep to walk on. This is the
- // "player standing on a steep roof / steep terrain" case. Phase 1
- // sets ContactPlane on the slope (geometric touch is enough — no
- // walkable check), so without this clause the step-down branch
- // skips and EdgeSlideAfterStepDownFailed never gets the chance to
- // call CliffSlide. With this clause: step-down probes for a
- // walkable surface, fails (the slope is the only thing here and
- // it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects
- // motion. Then gravity does the rest of the downhill drift.
- //
- // Retail's transitional_insert OK-path always runs the step-down
- // chain (per agent reports of acclient_2013_pseudo_c.txt:273191).
- // We approximate that by triggering it whenever the current contact
- // is invalid OR steeper than walkable.
- bool contactInvalidOrSteep = !ci.ContactPlaneValid
- || ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ;
- // L.4-diag (2026-04-30): trace why we don't slide down roofs.
- DumpStepDownBranchGate(contactInvalidOrSteep);
- if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
- && sp.CheckCellId != 0 && oi.StepDown)
+ // Retail returns immediately for every valid contact plane,
+ // including a steep one. The ordinary step-down tail is reached
+ // only when contact is invalid (0050b818-0050b844).
+ if (ci.ContactPlaneValid)
+ return TransitionState.OK;
+
+ DumpStepDownBranchGate(contactInvalid: true);
+ if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown)
{
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
// LandingZ when not. acdream was unconditionally LandingZ —
@@ -2028,7 +2043,11 @@ public sealed class Transition
sp.WalkableAllowance = zVal;
sp.SaveCheckPos();
- float radsum = sp.GlobalSphere[0].Radius * 2f;
+ (float probeHeight, int probeCount) = GetStepDownProbePlan(
+ sp.NumSphere,
+ sp.GlobalSphere[0].Radius,
+ stepDownHeight);
+ stepDownHeight = probeHeight;
// L.2.3h (2026-04-29): pass runPlacement=false. This
// branch's job is to maintain ground contact during normal
@@ -2038,23 +2057,20 @@ public sealed class Transition
// would fail Placement and trigger the L.2.3e edge-block,
// leaving the player stuck near walls. DoStepUp still runs
// Placement for the step-UP-through-walls protection.
- if (radsum >= stepDownHeight)
+ bool steppedDown = false;
+ for (int probe = 0; probe < probeCount; probe++)
{
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
- sp.ClearWalkable();
- return TransitionState.OK;
+ steppedDown = true;
+ break;
}
}
- else
+
+ if (steppedDown)
{
- stepDownHeight *= 0.5f;
- if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
- || DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
- {
- sp.ClearWalkable();
- return TransitionState.OK;
- }
+ sp.ClearWalkable();
+ return TransitionState.OK;
}
// L.2c (2026-04-30): step-down failed — the move would put
@@ -2205,38 +2221,9 @@ public sealed class Transition
var ci = CollisionInfo;
var oi = ObjectInfo;
- // L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check
- // moved BEFORE the OnWalkable/EdgeSlide gate.
- //
- // Why: by the time this dispatch runs on subsequent frames (player
- // standing on a steep slope), ValidateTransition's L.2.3i FloorZ
- // test has already CLEARED OnWalkable (steep slope → not a walkable
- // surface). The original Branch 1 (`!OnWalkable → restore + OK`)
- // therefore fires every frame, stopping the player dead — exactly
- // the "stay on the roof" symptom the user reported.
- //
- // Re-ordering: if the surface is too steep AND we have a contact
- // plane on it, run CliffSlide regardless of OnWalkable. The
- // cross(currentNormal, lastKnownNormal) deflection plus gravity
- // produces visible downhill drift each frame.
- //
- // Branch 1 (the !OnWalkable stop) still fires when we DON'T have
- // a contact plane — the original "walked off into thin air"
- // case, which should still stop or fall normally rather than
- // CliffSlide on nothing.
- if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide)
- {
- var cliffPlane = ci.ContactPlane;
- DumpEdgeSlideBranch("priority/steep-cliffslide", zVal);
- sp.ClearWalkable();
- sp.RestoreCheckPos();
- ci.ContactPlaneValid = false;
- ci.ContactPlaneIsWater = false;
- return CliffSlide(cliffPlane);
- }
-
- // Retail lets non-EdgeSlide movers continue over the boundary. Player
- // movement carries EdgeSlide, so the local avatar takes the slide path.
+ // Retail Branch 1 is first: a mover that is not OnWalkable or does
+ // not carry EdgeSlide restores the saved candidate and returns OK.
+ // No steep-plane exception precedes this gate (0050b3d8-0050b3e7).
if (!oi.OnWalkable || !oi.EdgeSlide)
{
DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
@@ -2272,49 +2259,8 @@ public sealed class Transition
// rapidly down the stairs. Do not restore stale history here.
if (sp.HasWalkablePolygon)
{
- // L.4-walkable-steep (2026-04-30): the stored Walkable polygon
- // can be a too-steep surface (e.g., a roof the player jumped
- // onto — Path 4's airborne-landing branch uses LandingZ, the
- // permissive 0.087 threshold, so steep roofs get accepted as
- // "walkable" for the landing). On subsequent frames the player
- // is STANDING ON that polygon, not crossing its edge, so
- // PrecipiceSlide's find_crossed_edge returns false and the
- // player gets stuck in a Collided revert loop.
- //
- // Detect the case: if the walkable polygon's plane is steeper
- // than FloorZ, route to CliffSlide using that plane instead of
- // PrecipiceSlide. CliffSlide deflects motion along the ridge
- // between current-steep and last-known-walkable; gravity then
- // produces visible downhill drift.
- //
- // TS-1 gap #3 (register AD-54, Campaign P Slice P2 2026-07-30):
- // retail's raw SPHEREPATH::edge_slide has NO steepness branch here
- // — `if (walkable != null) { ... precipice_slide(...) }` unconditionally
- // (acclient_2013_pseudo_c.txt:364-370 per the P2 research quote). The
- // LandingZ permissive acceptance itself IS retail-faithful — confirmed
- // by TransitionalInsert's own Path-4 Collide branch
- // (TransitionTypes.cs, `DoCheckWalkable(PhysicsGlobals.LandingZ, engine)`
- // above) and TS-4's BSPTREE::find_collisions read
- // (pc:323740-323783: `sphere_path.walkable_allowance = LandingZ`
- // unconditionally, no slope test) — so a steep roof really is
- // "walkable" in retail too. What is NOT independently verified from
- // the raw decomp is whether retail's OUTER caller (transitional_insert)
- // absorbs a same-polygon-standing Collided from precipice_slide via
- // its own retry loop rather than needing this reroute; see
- // docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §2
- // gap #3.
- if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ)
- {
- var cliffPlane = sp.WalkablePlane;
- DumpEdgeSlideBranch("walkable-poly-steep-cliffslide", zVal);
- sp.ClearWalkable();
- sp.RestoreCheckPos();
- ci.ContactPlaneValid = false;
- ci.ContactPlaneIsWater = false;
- return CliffSlide(cliffPlane);
- }
-
DumpEdgeSlideBranch("branch3/precipice-slide", zVal);
+ sp.RestoreCheckPos();
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
return sp.PrecipiceSlide(this);
@@ -2382,54 +2328,12 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
- // L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane
- // as the cross-product reference, falling back to world-up when no
- // walkable history is available. Without this, when the player has
- // been on a steep slope for >1 frame, ValidateTransition's L.2.3i
- // FloorZ test propagates the steep plane into LastKnownContactPlane,
- // so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no
- // deflection. Using LastWalkable preserves the prior flat-ground
- // plane across continuous-slope frames; world-up gives a guaranteed
- // non-zero deflection when no walkable history exists at all.
- //
- // TS-1 gap #2 (register AD-53, Campaign P Slice P2 2026-07-30): retail's
- // raw CTransition::cliff_slide (pc:272397, 0050a6d0) uses
- // this->collision_info.last_known_contact_plane.N DIRECTLY as the second
- // cross-product operand — no fallback chain. Confirmed by a fresh read of
- // last_known_contact_plane's own maintenance
- // (acclient_2013_pseudo_c.txt:272659-272668, pc ~0050ad07): retail
- // overwrites last_known_contact_plane from contact_plane UNCONDITIONALLY
- // on every validate_transition pass, the same "gets overwritten by
- // whatever's current, including a steep plane" behavior this file's
- // ContactPlane/LastKnownContactPlane tracking already has — retail does
- // NOT maintain a separately-preserved flat-ground history there either.
- // This three-source chain (LastWalkablePlane -> LastKnownContactPlane ->
- // UnitZ) is therefore a genuine acdream invention, not a retail-matching
- // read — kept because it compensates for AP-4's incomplete OnWalkable
- // bookkeeping (see DO-NOT-RETRY item 9 in
- // docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §0)
- // and removing it reintroduces the degenerate-cross "stay on the roof"
- // wedge the L.4 session (2026-04-30) fought. See that doc's §2 gap #2.
- Vector3 referenceNormal;
- string refSource;
- if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ)
- {
- referenceNormal = sp.LastWalkablePlane.Normal;
- refSource = "last-walkable";
- }
- else if (ci.LastKnownContactPlaneValid && ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
- {
- referenceNormal = ci.LastKnownContactPlane.Normal;
- refSource = "last-known-walkable";
- }
- else
- {
- // Fallback: world up. cross(steepNormal, UnitZ) gives the
- // ridge direction (horizontal contour line of the slope).
- // collideNormal then becomes the downhill horizontal axis.
- referenceNormal = Vector3.UnitZ;
- refSource = "world-up-fallback";
- }
+ // Retail CTransition::cliff_slide (0050a6d0) consumes the raw
+ // last-known contact normal directly. It does not substitute the
+ // remembered walkable plane or world-up. An invalid/default or
+ // parallel normal naturally takes normalize_check_small's
+ // degenerate OK return below.
+ Vector3 referenceNormal = ci.LastKnownContactPlane.Normal;
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
contactNormal.Z = 0f;
@@ -2437,7 +2341,7 @@ public sealed class Transition
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
- DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane,
+ DumpCliffSlide("degenerate-cross/last-known", contactPlane,
new Plane(referenceNormal, 0f), contactNormal, 0f, false);
return TransitionState.OK;
}
@@ -2446,7 +2350,7 @@ public sealed class Transition
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
float angle = Vector3.Dot(collideNormal, offset);
- DumpCliffSlide($"ok/{refSource}", contactPlane,
+ DumpCliffSlide("ok/last-known", contactPlane,
new Plane(referenceNormal, 0f), collideNormal, angle, true);
if (angle <= 0f)
@@ -2481,7 +2385,7 @@ public sealed class Transition
/// skipped the contact-recovery branch matters for whether CliffSlide
/// has any chance of firing.
///
- private void DumpStepDownBranchGate(bool contactInvalidOrSteep)
+ private void DumpStepDownBranchGate(bool contactInvalid)
{
if (!DumpEdgeSlideEnabled) return;
@@ -2489,7 +2393,7 @@ public sealed class Transition
var ci = CollisionInfo;
var oi = ObjectInfo;
- bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown
+ bool wouldEnter = contactInvalid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown;
if (!wouldEnter) return; // only log when entering, to keep noise low
diff --git a/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
new file mode 100644
index 00000000..d8a23835
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs
@@ -0,0 +1,396 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Pins the branch order in retail CTransition::transitional_insert,
+/// CTransition::edge_slide, and CTransition::cliff_slide.
+/// These cases distinguish the retail implementation from the four former
+/// acdream compensations tracked as AP-3, AP-4, AD-53, and AD-54.
+///
+public sealed class RetailEdgeResponseOrderingTests
+{
+ private const uint Cell = 0xA9B40001u;
+
+ [Fact]
+ public void TransitionalInsert_ValidSteepContact_ReturnsBeforeOrdinaryStepDownTail()
+ {
+ Vector3 current = new(2f, 3f, 4f);
+ Vector3 target = current + new Vector3(0.1f, 0f, 0f);
+ var transition = BSPStepUpFixtures.MakeGroundedTransition(current, target, cellId: Cell);
+ transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
+ transition.ObjectInfo.StepDown = true;
+ transition.ObjectInfo.StepDownHeight = 2f;
+
+ Vector3 untouchedBackup = new(97f, 98f, 99f);
+ const uint untouchedBackupCell = 0xA9B40044u;
+ transition.SpherePath.BackupCheckPos = untouchedBackup;
+ transition.SpherePath.BackupCheckCellId = untouchedBackupCell;
+
+ var steep = new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
+ var engine = new PhysicsEngine
+ {
+ TransitionCellCollisionTestHook = (candidate, phase, _, actual) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Objects)
+ candidate.CollisionInfo.SetContactPlane(steep, Cell, isWater: true);
+ return actual;
+ },
+ };
+
+ TransitionState result = transition.TransitionalInsertForTest(1, engine);
+
+ Assert.Equal(TransitionState.OK, result);
+ Assert.True(transition.CollisionInfo.ContactPlaneValid);
+ Assert.True(transition.CollisionInfo.ContactPlaneIsWater);
+ Assert.Equal(steep, transition.CollisionInfo.ContactPlane);
+ Assert.Equal(untouchedBackup, transition.SpherePath.BackupCheckPos);
+ Assert.Equal(untouchedBackupCell, transition.SpherePath.BackupCheckCellId);
+ }
+
+ [Theory]
+ [InlineData(1, 0.5f, 2.0f, 0.25f, 1)]
+ [InlineData(2, 0.5f, 2.0f, 1.00f, 2)]
+ [InlineData(1, 0.5f, 0.75f, 0.75f, 1)]
+ [InlineData(2, 0.5f, 0.75f, 0.75f, 1)]
+ public void StepDownProbePlan_PreservesRetailOneVersusTwoSphereSplit(
+ int sphereCount,
+ float radius,
+ float requestedHeight,
+ float expectedProbeHeight,
+ int expectedProbeCount)
+ {
+ (float probeHeight, int probeCount) = Transition.GetStepDownProbePlan(
+ sphereCount,
+ radius,
+ requestedHeight);
+
+ Assert.Equal(expectedProbeHeight, probeHeight);
+ Assert.Equal(expectedProbeCount, probeCount);
+ }
+
+ [Fact]
+ public void EdgeSlide_NotOnWalkableSteepContact_RestoresBeforeCliffSlide()
+ {
+ var transition = MakeFailedStepDownTransition();
+ transition.ObjectInfo.State = ObjectInfoState.EdgeSlide;
+ transition.CollisionInfo.ContactPlaneValid = true;
+ transition.CollisionInfo.ContactPlane =
+ new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.25f)), 0f);
+ transition.CollisionInfo.ContactPlaneIsWater = true;
+ transition.CollisionInfo.LastKnownContactPlaneValid = true;
+ transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
+
+ Vector3 failedCandidate = transition.SpherePath.BackupCheckPos;
+ TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest(
+ new PhysicsEngine(),
+ stepDownHeight: 0.04f,
+ zVal: PhysicsGlobals.FloorZ);
+
+ Assert.Equal(TransitionState.OK, result);
+ Assert.Equal(failedCandidate, transition.SpherePath.CheckPos);
+ Assert.False(transition.CollisionInfo.ContactPlaneValid);
+ Assert.False(transition.CollisionInfo.ContactPlaneIsWater);
+ Assert.False(transition.CollisionInfo.CollisionNormalValid);
+ }
+
+ [Fact]
+ public void CliffSlide_UsesOnlyLastKnownContactPlaneNormal()
+ {
+ var transition = MakeFailedStepDownTransition();
+
+ // A qualifying remembered walkable normal deliberately points along Y.
+ // The former AD-53 fallback consumed it; retail consumes the explicit
+ // last-known contact normal below and therefore resolves along -X.
+ Plane rememberedWalkable = new(Vector3.Normalize(new Vector3(0f, 1f, 1f)), 0f);
+ transition.SpherePath.SetWalkable(
+ rememberedWalkable,
+ SquareOnPlaneZ0(),
+ Vector3.UnitZ);
+ transition.SpherePath.ClearWalkable();
+
+ transition.CollisionInfo.LastKnownContactPlaneValid = true;
+ transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
+ Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f);
+
+ TransitionState result = transition.CliffSlideForTest(steepContact);
+
+ Assert.Equal(TransitionState.Adjusted, result);
+ Assert.True(transition.CollisionInfo.CollisionNormalValid);
+ Assert.True(Vector3.Distance(-Vector3.UnitX, transition.CollisionInfo.CollisionNormal) < 0.0001f);
+ }
+
+ [Fact]
+ public void CliffSlide_InvalidDefaultLastKnownPlane_TakesDegenerateOkReturn()
+ {
+ var transition = MakeFailedStepDownTransition();
+ transition.CollisionInfo.LastKnownContactPlaneValid = false;
+ transition.CollisionInfo.LastKnownContactPlane = default;
+ Plane steepContact = new(Vector3.Normalize(new Vector3(1f, 0f, 0.5f)), 0f);
+
+ TransitionState result = transition.CliffSlideForTest(steepContact);
+
+ Assert.Equal(TransitionState.OK, result);
+ Assert.False(transition.CollisionInfo.CollisionNormalValid);
+ }
+
+ [Fact]
+ public void EdgeSlide_StoredSteepWalkable_AlwaysRoutesToPrecipiceSlide()
+ {
+ var transition = MakeFailedStepDownTransition();
+ transition.ObjectInfo.State =
+ ObjectInfoState.Contact | ObjectInfoState.OnWalkable | ObjectInfoState.EdgeSlide;
+ transition.CollisionInfo.ContactPlaneValid = false;
+ transition.CollisionInfo.LastKnownContactPlaneValid = true;
+ transition.CollisionInfo.LastKnownContactPlane = new Plane(Vector3.UnitZ, 0f);
+
+ Vector3 steepNormal = Vector3.Normalize(new Vector3(-2f, 0f, 1f));
+ var steepPlane = new Plane(steepNormal, 0f);
+ Vector3[] steepQuad =
+ [
+ new(0f, -1f, 0f),
+ new(1f, -1f, 2f),
+ new(1f, 1f, 2f),
+ new(0f, 1f, 0f),
+ ];
+ transition.SpherePath.SetWalkable(steepPlane, steepQuad, Vector3.UnitZ);
+
+ Vector3 failedCandidate = new(0.5f, 0f, 1f);
+ transition.SpherePath.SetCheckPos(failedCandidate, Cell);
+ transition.SpherePath.SaveCheckPos();
+ transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f));
+
+ TransitionState result = transition.EdgeSlideAfterStepDownFailedForTest(
+ new PhysicsEngine(),
+ stepDownHeight: 0.04f,
+ zVal: PhysicsGlobals.FloorZ);
+
+ // The restored point is inside the remembered polygon, so retail's
+ // unconditional PrecipiceSlide returns Collided. AD-54's steep-plane
+ // reroute instead returned Adjusted through CliffSlide.
+ Assert.Equal(TransitionState.Collided, result);
+ Assert.Equal(failedCandidate, transition.SpherePath.CheckPos);
+ Assert.False(transition.SpherePath.HasWalkablePolygon);
+ Assert.False(transition.CollisionInfo.CollisionNormalValid);
+ }
+
+ [Fact]
+ public void MultiFrameSteepRoof_GraphAndFlatTraversalRemainExactAndDoNotWedge()
+ {
+ Vector3[] graph = RunSteepRoofTrace(preparedFlat: false);
+ Vector3[] flat = RunSteepRoofTrace(preparedFlat: true);
+
+ Assert.Equal(graph, flat);
+ Assert.Contains(graph, position =>
+ position.X < 0f
+ && position.Z <= BSPStepUpFixtures.SphereRadius + 0.05f);
+ AssertNoLongFrozenStreak(graph, maximumTicks: 15);
+ }
+
+ [Fact]
+ public void MultiFrameFlatRoofLedge_GraphAndFlatTraversalRemainExactAndSlideAlongEdge()
+ {
+ Vector3[] graph = RunFlatRoofLedgeTrace(preparedFlat: false);
+ Vector3[] flat = RunFlatRoofLedgeTrace(preparedFlat: true);
+
+ Assert.Equal(graph, flat);
+ AssertNoLongFrozenStreak(graph, maximumTicks: 15);
+ Assert.True(graph[^1].Y > graph[0].Y + 0.25f,
+ $"The roof-edge control made no along-edge progress: {graph[0]} -> {graph[^1]}.");
+ }
+
+ private static Transition MakeFailedStepDownTransition()
+ {
+ Vector3 current = Vector3.Zero;
+ Vector3 failedCandidate = new(1f, 0f, 0f);
+ var transition = BSPStepUpFixtures.MakeGroundedTransition(
+ current,
+ failedCandidate,
+ cellId: Cell);
+ transition.ObjectInfo.State |= ObjectInfoState.EdgeSlide;
+ transition.SpherePath.SetCheckPos(failedCandidate, Cell);
+ transition.SpherePath.SaveCheckPos();
+ transition.SpherePath.AddOffsetToCheckPos(new Vector3(0f, 0f, -0.25f));
+ return transition;
+ }
+
+ private static Vector3[] SquareOnPlaneZ0() =>
+ [
+ new(-2f, -2f, 0f),
+ new( 2f, -2f, 0f),
+ new( 2f, 2f, 0f),
+ new(-2f, 2f, 0f),
+ ];
+
+ private static Vector3[] RunSteepRoofTrace(bool preparedFlat)
+ {
+ var fixture = BSPStepUpFixtures.SlopedUnwalkable();
+ PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E101u);
+ float radius = BSPStepUpFixtures.SphereRadius;
+ const float dt = 1f / 30f;
+ const float gravity = -9.8f;
+ var body = new PhysicsBody { TransientState = TransientStateFlags.Active };
+ Vector3 position = new(0.5f, 0f, 3f);
+ float velocityZ = 0f;
+ var trace = new List(91) { position };
+
+ for (int tick = 0; tick < 90; tick++)
+ {
+ velocityZ += gravity * dt;
+ ResolveResult result = engine.ResolveWithTransition(
+ position,
+ position + new Vector3(0f, 0f, velocityZ * dt),
+ Cell,
+ radius,
+ radius * 2f,
+ stepUpHeight: 0.30f,
+ stepDownHeight: 0.04f,
+ isOnGround: false,
+ body,
+ ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0x01000000u);
+
+ position = result.Position;
+ body.Position = position;
+ if (result.IsOnGround)
+ velocityZ = 0f;
+ trace.Add(position);
+
+ if (position.X < 0f && position.Z <= radius + 0.05f)
+ break;
+ }
+
+ return trace.ToArray();
+ }
+
+ private static Vector3[] RunFlatRoofLedgeTrace(bool preparedFlat)
+ {
+ var fixture = BSPStepUpFixtures.FlatRoof();
+ PhysicsEngine engine = BuildCollisionEngine(fixture, preparedFlat, 0x0100E102u);
+ ResolvedPolygon roof = fixture.Resolved[BSPStepUpFixtures.FlatRoof_RoofId];
+ float radius = BSPStepUpFixtures.SphereRadius;
+ Vector3 position = new(1.55f, -0.75f, 3f);
+ var body = new PhysicsBody
+ {
+ Position = position,
+ Orientation = Quaternion.Identity,
+ ContactPlaneValid = true,
+ ContactPlane = roof.Plane,
+ ContactPlaneCellId = Cell,
+ WalkablePolygonValid = true,
+ WalkablePlane = roof.Plane,
+ WalkableVertices = roof.Vertices,
+ WalkableUp = Vector3.UnitZ,
+ TransientState = TransientStateFlags.Active
+ | TransientStateFlags.Contact
+ | TransientStateFlags.OnWalkable,
+ };
+ var trace = new List(13) { position };
+
+ for (int tick = 0; tick < 12; tick++)
+ {
+ ResolveResult result = engine.ResolveWithTransition(
+ position,
+ position + new Vector3(0.12f, 0.08f, 0f),
+ Cell,
+ radius,
+ radius * 2f,
+ stepUpHeight: 0.30f,
+ stepDownHeight: 0.04f,
+ isOnGround: true,
+ body,
+ ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
+ movingEntityId: 0x01000001u);
+
+ position = result.Position;
+ body.Position = position;
+ body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable);
+ if (result.InContact)
+ body.TransientState |= TransientStateFlags.Contact;
+ if (result.OnWalkable)
+ body.TransientState |= TransientStateFlags.OnWalkable;
+ trace.Add(position);
+ }
+
+ return trace.ToArray();
+ }
+
+ private static PhysicsEngine BuildCollisionEngine(
+ (PhysicsBSPNode Root, Dictionary Resolved) fixture,
+ bool preparedFlat,
+ uint gfxObjId)
+ {
+ var normalized = new Dictionary(fixture.Resolved.Count);
+ foreach ((ushort id, ResolvedPolygon polygon) in fixture.Resolved)
+ {
+ normalized.Add(id, new ResolvedPolygon
+ {
+ Id = id,
+ Vertices = polygon.Vertices,
+ Plane = polygon.Plane,
+ NumPoints = polygon.NumPoints,
+ SidesType = polygon.SidesType,
+ });
+ }
+
+ var physics = new GfxObjPhysics
+ {
+ SourceId = gfxObjId,
+ BSP = new PhysicsBSPTree { Root = fixture.Root },
+ Resolved = normalized,
+ BoundingSphere = fixture.Root.BoundingSphere,
+ };
+ var cache = new PhysicsDataCache();
+ if (preparedFlat)
+ {
+ cache.CollisionTraversalMode = CollisionTraversalMode.Flat;
+ cache.CacheGfxObj(gfxObjId, FlatCollisionAssetBuilder.FlattenGfxObj(physics));
+ }
+ else
+ {
+ cache.RegisterGfxObjForTest(gfxObjId, physics);
+ }
+
+ var heights = new byte[81];
+ var heightTable = new float[256];
+ Array.Fill(heightTable, -1000f);
+ var engine = new PhysicsEngine { DataCache = cache };
+ engine.AddLandblock(
+ 0xA9B40000u,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ 0f,
+ 0f);
+ engine.ShadowObjects.Register(
+ gfxObjId,
+ gfxObjId,
+ Vector3.Zero,
+ Quaternion.Identity,
+ fixture.Root.BoundingSphere.Radius,
+ 0f,
+ 0f,
+ 0xA9B4FFFFu,
+ ShadowCollisionType.BSP,
+ 1f);
+ return engine;
+ }
+
+ private static void AssertNoLongFrozenStreak(Vector3[] trace, int maximumTicks)
+ {
+ int streak = 0;
+ for (int i = 1; i < trace.Length; i++)
+ {
+ streak = Vector3.Distance(trace[i - 1], trace[i]) < 0.001f
+ ? streak + 1
+ : 0;
+ Assert.True(streak <= maximumTicks,
+ $"Trace froze for {streak} ticks at {trace[i]}.");
+ }
+ }
+}