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]}."); + } + } +}