fix(physics): restore retail edge-response ordering
This commit is contained in:
parent
4ca7230b36
commit
c559c48d80
4 changed files with 525 additions and 180 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1733,6 +1733,40 @@ public sealed class Transition
|
|||
PhysicsEngine 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)
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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]}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue