fix(physics): restore retail edge-response ordering

This commit is contained in:
Erik 2026-07-31 12:55:49 +02:00
parent 4ca7230b36
commit c559c48d80
4 changed files with 525 additions and 180 deletions

View file

@ -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 **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, 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 | | # | 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-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 | `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-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-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-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 | | 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 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 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-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:273191273307 | | ~~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:273191273307 |
| 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-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:273001273090 |
| 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-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-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 | | ~~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 |

View file

@ -1,7 +1,7 @@
# P2 — Collision response-layer edge family: port-ready pseudocode # P2 — Collision response-layer edge family: port-ready pseudocode
**Status: RESEARCH PASS COMPLETE (2026-07-30); IMPLEMENTATION PASS PARTIAL **Status: RESEARCH PASS COMPLETE (2026-07-30); RETAIL RESPONSE ORDER PORTED
(2026-07-30).** Originally a research-only doc for Campaign P Slice P2 (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 (`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 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 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 `Adjusted` without repositioning the sphere) and what a future attempt
needs to check first; #116 remains a genuine oracle-first research item 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 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 Every claim below is tagged **FACT** (grep/read-verified against the
named-retail decomp, the register, ISSUES.md, or current acdream source 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 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 is DOWN for this research pass — mark any residual x87-ambiguous claim
Ghidra-verify, cite ACE as the fallback tiebreaker, do not silently guess. 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 9. **SUPERSEDED 2026-07-31 by Campaign P Slice 1B.** AP-4's CliffSlide-first
live, load-bearing reordering compensating for acdream's incomplete compensation was removed only after the complete retail
OnWalkable bookkeeping — touches the same code region as TS-1. Do not `transitional_insert`/`edge_slide` order was read and branch-order plus
revert AP-4's reordering without re-verifying OnWalkable is complete; graph/flat multi-frame roof/ledge controls passed. Do not reintroduce the
read AP-4's full row before changing `TransitionTypes.cs:1316` control compensation; see §8 and the retired AP-4 row.
flow.
10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2** 10. **TS-46 (two-scalar sphere reconstruction) is OUT OF SCOPE for P2**
(it's P3) but shares files (`TransitionTypes.cs` `InitPath`) — do not (it's P3) but shares files (`TransitionTypes.cs` `InitPath`) — do not
fold TS-46 sphere-list work into a P2 commit. fold TS-46 sphere-list work into a P2 commit.
@ -1066,3 +1068,46 @@ it blocks.
behavior." 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.
---

View file

@ -1733,6 +1733,40 @@ public sealed class Transition
PhysicsEngine engine) PhysicsEngine engine)
=> TransitionalInsert(numAttempts, engine); => TransitionalInsert(numAttempts, engine);
/// <summary>
/// Retail's one-versus-two-sphere step-down schedule from
/// <c>CTransition::transitional_insert</c> (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.
/// </summary>
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) private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
{ {
if (SpherePath.CheckCellId == 0) return TransitionState.OK; 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. // Retail returns immediately for every valid contact plane,
// This happens when the player is on a slope edge: they're marked // including a steep one. The ordinary step-down tail is reached
// as in contact with the ground, but the current CheckPos has no // only when contact is invalid (0050b818-0050b844).
// terrain contact (walked off an edge). Attempt a step-down to if (ci.ContactPlaneValid)
// maintain ground contact. return TransitionState.OK;
//
// L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane DumpStepDownBranchGate(contactInvalid: true);
// IS valid but the surface is too steep to walk on. This is the if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown)
// "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)
{ {
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable, // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
// LandingZ when not. acdream was unconditionally LandingZ — // LandingZ when not. acdream was unconditionally LandingZ —
@ -2028,7 +2043,11 @@ public sealed class Transition
sp.WalkableAllowance = zVal; sp.WalkableAllowance = zVal;
sp.SaveCheckPos(); 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 // L.2.3h (2026-04-29): pass runPlacement=false. This
// branch's job is to maintain ground contact during normal // 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, // would fail Placement and trigger the L.2.3e edge-block,
// leaving the player stuck near walls. DoStepUp still runs // leaving the player stuck near walls. DoStepUp still runs
// Placement for the step-UP-through-walls protection. // 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)) if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{ {
sp.ClearWalkable(); steppedDown = true;
return TransitionState.OK; break;
} }
} }
else
if (steppedDown)
{ {
stepDownHeight *= 0.5f; sp.ClearWalkable();
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false) return TransitionState.OK;
|| DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
{
sp.ClearWalkable();
return TransitionState.OK;
}
} }
// L.2c (2026-04-30): step-down failed — the move would put // L.2c (2026-04-30): step-down failed — the move would put
@ -2205,38 +2221,9 @@ public sealed class Transition
var ci = CollisionInfo; var ci = CollisionInfo;
var oi = ObjectInfo; var oi = ObjectInfo;
// L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check // Retail Branch 1 is first: a mover that is not OnWalkable or does
// moved BEFORE the OnWalkable/EdgeSlide gate. // not carry EdgeSlide restores the saved candidate and returns OK.
// // No steep-plane exception precedes this gate (0050b3d8-0050b3e7).
// 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.
if (!oi.OnWalkable || !oi.EdgeSlide) if (!oi.OnWalkable || !oi.EdgeSlide)
{ {
DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal); DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal);
@ -2272,49 +2259,8 @@ public sealed class Transition
// rapidly down the stairs. Do not restore stale history here. // rapidly down the stairs. Do not restore stale history here.
if (sp.HasWalkablePolygon) 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); DumpEdgeSlideBranch("branch3/precipice-slide", zVal);
sp.RestoreCheckPos();
ci.ContactPlaneValid = false; ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false; ci.ContactPlaneIsWater = false;
return sp.PrecipiceSlide(this); return sp.PrecipiceSlide(this);
@ -2382,54 +2328,12 @@ public sealed class Transition
var sp = SpherePath; var sp = SpherePath;
var ci = CollisionInfo; var ci = CollisionInfo;
// L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane // Retail CTransition::cliff_slide (0050a6d0) consumes the raw
// as the cross-product reference, falling back to world-up when no // last-known contact normal directly. It does not substitute the
// walkable history is available. Without this, when the player has // remembered walkable plane or world-up. An invalid/default or
// been on a steep slope for >1 frame, ValidateTransition's L.2.3i // parallel normal naturally takes normalize_check_small's
// FloorZ test propagates the steep plane into LastKnownContactPlane, // degenerate OK return below.
// so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no Vector3 referenceNormal = ci.LastKnownContactPlane.Normal;
// 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";
}
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal); Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal);
contactNormal.Z = 0f; contactNormal.Z = 0f;
@ -2437,7 +2341,7 @@ public sealed class Transition
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{ {
DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane, DumpCliffSlide("degenerate-cross/last-known", contactPlane,
new Plane(referenceNormal, 0f), contactNormal, 0f, false); new Plane(referenceNormal, 0f), contactNormal, 0f, false);
return TransitionState.OK; return TransitionState.OK;
} }
@ -2446,7 +2350,7 @@ public sealed class Transition
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
float angle = Vector3.Dot(collideNormal, offset); float angle = Vector3.Dot(collideNormal, offset);
DumpCliffSlide($"ok/{refSource}", contactPlane, DumpCliffSlide("ok/last-known", contactPlane,
new Plane(referenceNormal, 0f), collideNormal, angle, true); new Plane(referenceNormal, 0f), collideNormal, angle, true);
if (angle <= 0f) if (angle <= 0f)
@ -2481,7 +2385,7 @@ public sealed class Transition
/// skipped the contact-recovery branch matters for whether CliffSlide /// skipped the contact-recovery branch matters for whether CliffSlide
/// has any chance of firing. /// has any chance of firing.
/// </summary> /// </summary>
private void DumpStepDownBranchGate(bool contactInvalidOrSteep) private void DumpStepDownBranchGate(bool contactInvalid)
{ {
if (!DumpEdgeSlideEnabled) return; if (!DumpEdgeSlideEnabled) return;
@ -2489,7 +2393,7 @@ public sealed class Transition
var ci = CollisionInfo; var ci = CollisionInfo;
var oi = ObjectInfo; var oi = ObjectInfo;
bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown bool wouldEnter = contactInvalid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown; && sp.CheckCellId != 0 && oi.StepDown;
if (!wouldEnter) return; // only log when entering, to keep noise low if (!wouldEnter) return; // only log when entering, to keep noise low

View file

@ -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;
/// <summary>
/// Pins the branch order in retail <c>CTransition::transitional_insert</c>,
/// <c>CTransition::edge_slide</c>, and <c>CTransition::cliff_slide</c>.
/// These cases distinguish the retail implementation from the four former
/// acdream compensations tracked as AP-3, AP-4, AD-53, and AD-54.
/// </summary>
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<Vector3>(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<Vector3>(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<ushort, ResolvedPolygon> Resolved) fixture,
bool preparedFlat,
uint gfxObjId)
{
var normalized = new Dictionary<ushort, ResolvedPolygon>(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<CellSurface>(),
Array.Empty<PortalPlane>(),
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]}.");
}
}
}