fix #137 (mechanism 2): BSP full-hit stubs leaked sliding normals — the corridor absorbing wedge
The Facility Hub corridor dead-stop's second half: after one seam hit, every forward resolve returned ok=False hit=no with zero advance. The body-persisted SlidingNormal (-1,0,0) projected the exactly-anti-parallel corridor push to zero in AdjustOffset and the step loop aborted at step 0 before any collision test could refresh the state. Audit (docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md): retail's only in-transition sliding-normal writer is validate_transition (0x0050ac21); the whole sphere/BSP layer writes NONE (grep-verified), and the body persistence (SetPositionInternal 0x005154c2, SLIDING_TS bit sync 0x005154e1) runs only on transition success. Our BSPQuery Contact-branch full-hit responses were stubs (SetCollisionNormal + SetSlidingNormal + return Slid) where retail dispatches the real slide_sphere — so the seam hit (a SUCCESSFUL full-advance resolve per the live log) persisted the phantom wall's normal, which retail's lifecycle structurally cannot do. - BSPQuery Contact foot full-hit fallback + head full-hit now route through Transition.SlideSphereInternal (CSphere::slide_sphere 0x00537440 — in-frame slide, no sliding-normal write; ACE BSPTree.cs:202,310-316). The dead stub is rewritten as the faithful BSPTREE::slide_sphere wrapper. - PhysicsEngine sliding writeback gated on ok (retail success-only placement; behaviorally latent, removes the failed-frame leak class). - Register: TS-4 amended (Path-6 steep-tangent sites still write the normal — now documented), TS-45 added (SphereCollision's write — same leak class, left for a follow-up out of #171's blast radius). - Pins: Issue137SlidingNormalLifecycleTests — both site pins RED->GREEN, plus the retail persist/absorb/clear wall lifecycle (validate-write persistence, faithful absorbed anti-parallel frame, oblique escape clears the bit). BSPQueryTests full-hit pin updated to the real slide. Mechanism 1 (PortalSide portal polys solid in the physics set) stays OPEN - #137 not closed; the corridor re-test rides that session. Suites: Core 2545 / App 713 / UI 425 / Net 385, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e73e45da54
commit
a11df5b8d3
7 changed files with 474 additions and 31 deletions
|
|
@ -1220,6 +1220,27 @@ matching the door's open/closed state.)
|
|||
(get_object_info pc:279992 governs only the NEXT frame — #116 notes);
|
||||
audit who clears the body's sliding normal when no contact recurs.
|
||||
|
||||
**MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in
|
||||
`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):**
|
||||
retail's ONLY in-transition sliding-normal writer is
|
||||
`validate_transition` (0x0050ac21); the BSP/sphere layer never writes it,
|
||||
and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1)
|
||||
is success-only. Our BSPQuery Contact-branch full-hit responses were
|
||||
STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the
|
||||
real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve,
|
||||
`ok=True` in the log, not a failed one) leaked the phantom wall's normal
|
||||
into the body, and the seed absorbed every later forward push. Fix:
|
||||
both stub sites now route through the real
|
||||
`Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440,
|
||||
in-frame, no sliding write) and the body writeback is gated on
|
||||
transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site
|
||||
pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended
|
||||
(steep-tangent sites still write the normal — documented), TS-45 added
|
||||
(`SphereCollision`'s write — same class, out of blast radius). The
|
||||
absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful
|
||||
(the persisted normal is a "still pressed" cache); only the phantom
|
||||
PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus),
|
||||
`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See
|
||||
`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table).
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
|
||||
| TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 |
|
||||
| TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall |
|
||||
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
||||
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
|
||||
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
|
||||
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
|
||||
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
|
||||
|
|
@ -225,6 +225,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) |
|
||||
| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) |
|
||||
| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) |
|
||||
| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does |
|
||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
# #137 mechanism 2 — the sliding-normal lifecycle audit (2026-07-06)
|
||||
|
||||
The pickup prompt (`2026-07-06-137-corridor-phantom-pickup-prompt.md`) asked:
|
||||
*who writes the body's persisted SlidingNormal, and where does retail CLEAR
|
||||
it when contact does not recur?* This note is the complete decomp-verified
|
||||
answer, the divergence map it produced, and the fix that shipped.
|
||||
|
||||
## Retail lifecycle (every site, named-retail-verified)
|
||||
|
||||
`collision_info.sliding_normal` (per-transition) and
|
||||
`CPhysicsObj::sliding_normal` + `SLIDING_TS` (transient_state bit 4,
|
||||
body-persisted) form a two-level cache:
|
||||
|
||||
| Step | Function | Address / pc line | What it does |
|
||||
|---|---|---|---|
|
||||
| seed | `CPhysicsObj::get_object_info` | 0x00511cc0, seed @0x00511d44 | `if (transient_state & 4) CTransition::init_sliding_normal(&this->sliding_normal)` — last frame's persisted normal seeds the new transition |
|
||||
| consume | `CTransition::adjust_offset` | 0x0050a370 | `dot(offset, sliding_normal) < 0` → project the per-step offset (crease `cross(contact_plane.N, sliding_normal)` when grounded, `offset −= n·dot` otherwise); `dot >= 0` (moving away) → `sliding_normal_valid = 0` |
|
||||
| step gate | `CTransition::find_transitional_position` | 0x0050bdf0, small-offset @0x0050bf83-0x0050bfb7 | adjusted offset `|off|² < EPSILON²` at step 0 → transition FAILS; at step i>0 → succeed iff last validate state OK. **The absorbed frame is retail-faithful** — the persisted normal is a "still pressed against this wall" cache that suppresses re-testing |
|
||||
| per-step clear | same | @0x0050c010 | `sliding_normal_valid = 0` (+ contact plane) BEFORE each step's `transitional_insert` — a step that runs and does not re-collide leaves the transition clean |
|
||||
| in-transition write | `CTransition::validate_transition` | 0x0050aa70, write @0x0050ac21-ac30 | `if (collision_normal_valid) set_sliding_normal(collision_normal)` — **the ONLY in-transition writer**. Fires when a step needed collision handling |
|
||||
| body writeback | `CPhysicsObj::SetPositionInternal` | copy @0x005154c2, bit sync @0x005154e1 | `sliding_normal = transition's; SLIDING_TS ⇔ sliding_normal_valid`. **Success-only** — a failed `find_valid_position` discards the transition whole; the body keeps its prior state |
|
||||
| NOT writers | `CSphere::slide_sphere` 0x00537440, `CCylSphere::slide_sphere` 0x0053b2a0, `BSPTREE::slide_sphere`/`step_sphere_up`/`find_collisions` | pc:321400+, 323700+ | grep-verified: **zero** `sliding_normal` references in the whole sphere/BSP layer (nothing between pc 283518 and 1155326). The sphere-level slide is IN-FRAME (`add_offset_to_check_pos`) |
|
||||
|
||||
So the answer to "where does retail clear it": **the success writeback**
|
||||
(bit 4 syncs to the transition's final `sliding_normal_valid`, which the
|
||||
per-step clear leaves false unless the last step's validate re-recorded a
|
||||
collision) plus `adjust_offset`'s moving-away invalidation. On a FAILED
|
||||
transition nothing clears it — and nothing needs to, because a persisted
|
||||
normal can only have come from a validate write against real geometry
|
||||
(pressed-at-a-wall is a correct absorbed state; any oblique input escapes
|
||||
via the tangential projection remainder and the escape frame's writeback
|
||||
clears the bit).
|
||||
|
||||
ACE mirrors all of it: seed `PhysicsObj.cs:2611`, writeback
|
||||
`PhysicsObj.cs:1249-1251`, validate write `Transition.cs:1027`, the only
|
||||
`SetSlidingNormal` call sites in ACE's whole physics tree.
|
||||
|
||||
## The wedge (live evidence, launch-175-verify2.log:42858)
|
||||
|
||||
The seam-hit frame **succeeded with full advance** (`ok=True`,
|
||||
`out == tgt` in XY, +8 mm step-up settle, crossing 0x8A02016E→0x8A02017A)
|
||||
and still recorded `hit=yes n=(−1.00,0.03,−0.03)`. Retail ending that frame
|
||||
would write back `sliding_normal_valid=0` (no blocked step at the end → the
|
||||
per-step clear wins) and the bit would CLEAR. We persisted a normal anyway —
|
||||
because our BSP Contact branch carried **stub** slide responses
|
||||
(`SetCollisionNormal + SetSlidingNormal + return Slid`) at the sites where
|
||||
retail dispatches the real `slide_sphere`. Every following forward resolve
|
||||
then seeded the stale normal, `adjust_offset` projected the
|
||||
exactly-anti-parallel corridor push to zero, and the step-0 abort returned
|
||||
`ok=False hit=no` with zero advance — before any collision test could
|
||||
refresh the state. An absorbing wedge; strafing escapes because an oblique
|
||||
offset keeps a tangential remainder.
|
||||
|
||||
## Divergence map → what shipped
|
||||
|
||||
| Site | Was | Retail | Action |
|
||||
|---|---|---|---|
|
||||
| `BSPQuery` Contact foot full-hit, step-up unavailable (recursion guard / engine-null) | stub | blocked step-up funnels to `step_up_slide` → `CSphere::slide_sphere` | **FIXED** — routes through `Transition.SlideSphereInternal` (the real port, #116-verified thresholds) |
|
||||
| `BSPQuery` Contact head full-hit | stub | `BSPTREE::slide_sphere` @0x0053a697 (ACE BSPTree.cs:202, 310-316 — slides GlobalSphere[0]) | **FIXED** — same routing; the dead private stub rewritten as the faithful `BSPTREE::slide_sphere` wrapper |
|
||||
| `PhysicsEngine.ResolveWithTransition` sliding writeback | unconditional (ran on `ok=False`) | `SetPositionInternal` success-only | **FIXED** — gated on `ok` (behaviorally latent today: a failed transition's ci always still holds the seed, so gate-vs-rewrite is value-identical; the gate removes the class) |
|
||||
| `BSPQuery` Path-6 steep slide-tangent (2 sites) | in-frame projection + `SetSlidingNormal` | no BSP-layer write | left (documented deviation TS-4 — row amended to name the sliding write); L.5+ retail-strict follow-up |
|
||||
| `Transition.SphereCollision` (shadow Sphere objects) | hand-rolled slide + `SetSlidingNormal` | `CSphere::intersects_sphere` → `slide_sphere`, no write | left — **new register row TS-45**; fix = route the tail through `SlideSphere` like `CylSlideSphere` (#172) does |
|
||||
| seed / step loop / `AdjustOffset` / validate write @TransitionTypes:4317 / real `SlideSphere` port | — | — | verified faithful, unchanged |
|
||||
|
||||
Tests: `Issue137SlidingNormalLifecycleTests` — two site pins (Contact
|
||||
foot-fallback + head full-hit must not write the sliding normal; face-on
|
||||
grounded → `Collided` per the degenerate crease projection) + the
|
||||
engine-level wall lifecycle pin (persist-on-block via validate →
|
||||
absorbed exactly-anti-parallel frame → oblique escape CLEARS the body
|
||||
state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385).
|
||||
|
||||
## What this does and does not fix in the corridor
|
||||
|
||||
The wedge (mechanism 2) is fixed at its entry: a full-advance hit can no
|
||||
longer persist a phantom normal, so the corridor dead-stop degrades to
|
||||
whatever mechanism 1 actually produces — a momentary stutter if the portal
|
||||
poly only face-hits in the crossing window, or an honest (re-tested,
|
||||
strafe-escapable) wall if it keeps colliding. **Mechanism 1 — PortalSide
|
||||
portal polygons being solid in our physics set — is still open**; the
|
||||
pickup's step −1 cdb session (trace whether retail's BSP test chain ever
|
||||
TESTS those polys, or membership order never reaches them from the passable
|
||||
side) remains the next action, and needs the user to run retail.
|
||||
|
|
@ -1399,26 +1399,35 @@ public static class BSPQuery
|
|||
|
||||
// -------------------------------------------------------------------------
|
||||
// slide_sphere — BSPTree level
|
||||
// ACE: BSPTree.cs slide_sphere
|
||||
// Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
|
||||
// at 0x0053a697). ACE: BSPTree.cs:310-316.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// BSPTree.slide_sphere — apply sliding collision response.
|
||||
/// BSPTree.slide_sphere — dispatch the real sphere-level slide
|
||||
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
|
||||
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
|
||||
/// crease between the collision normal and the contact plane, applied to
|
||||
/// sphere 0's check position (ACE BSPTree.cs:315 —
|
||||
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Sets the sliding normal on CollisionInfo so the outer transition loop
|
||||
/// applies a wall-slide projection.
|
||||
/// #137 mechanism 2 (2026-07-06): this was a stub that set
|
||||
/// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
|
||||
/// never writes the sliding normal — its only in-transition writer is
|
||||
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
|
||||
/// leaked normal survived to the body writeback and absorbed the next
|
||||
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
|
||||
/// phantom's dead-stop half (ISSUES #137).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
|
||||
/// </summary>
|
||||
/// <param name="worldNormal">Collision normal already in world space (the
|
||||
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
|
||||
private static TransitionState SlideSphere(
|
||||
Transition transition,
|
||||
Vector3 collisionNormal)
|
||||
{
|
||||
transition.CollisionInfo.SetSlidingNormal(collisionNormal);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
Vector3 worldNormal)
|
||||
=> transition.SlideSphereInternal(
|
||||
worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// collide_with_pt — BSPTree level
|
||||
|
|
@ -1894,11 +1903,14 @@ public static class BSPQuery
|
|||
if (engine is not null && !path.StepUp && !path.StepDown)
|
||||
return StepSphereUp(transition, worldNormal, engine);
|
||||
|
||||
// No engine OR step-up/step-down already in progress — fall
|
||||
// back to wall-slide.
|
||||
collisions.SetCollisionNormal(worldNormal);
|
||||
collisions.SetSlidingNormal(worldNormal);
|
||||
return TransitionState.Slid;
|
||||
// No engine OR step-up/step-down already in progress — the
|
||||
// real slide response. Retail: a blocked step-up funnels to
|
||||
// SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
|
||||
// SpherePath.cs:316); the slide records the collision normal
|
||||
// itself and never writes the sliding normal (#137 mechanism
|
||||
// 2 — the old SetSlidingNormal stub here leaked a normal that
|
||||
// wedged the next frame's forward offset).
|
||||
return SlideSphere(transition, worldNormal);
|
||||
}
|
||||
|
||||
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
|
||||
|
|
@ -1937,9 +1949,10 @@ public static class BSPQuery
|
|||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||
|
||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||
collisions.SetCollisionNormal(worldNormal);
|
||||
collisions.SetSlidingNormal(worldNormal);
|
||||
return TransitionState.Slid;
|
||||
// Retail head-sphere full hit → BSPTREE::slide_sphere
|
||||
// (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
|
||||
// slide, no sliding-normal write (#137 mechanism 2).
|
||||
return SlideSphere(transition, worldNormal);
|
||||
}
|
||||
|
||||
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
|
||||
|
|
|
|||
|
|
@ -1085,6 +1085,16 @@ public sealed class PhysicsEngine
|
|||
body.WalkableVertices = null;
|
||||
}
|
||||
|
||||
// Retail persists sliding state to the body ONLY on transition
|
||||
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
|
||||
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
|
||||
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
|
||||
// is unreachable when find_valid_position fails (the transition is
|
||||
// discarded whole; the body keeps its prior state). #137 mechanism
|
||||
// 2: an unconditional writeback here could persist a normal retail
|
||||
// would discard.
|
||||
if (ok)
|
||||
{
|
||||
if (ci.SlidingNormalValid
|
||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
|
|
@ -1096,6 +1106,7 @@ public sealed class PhysicsEngine
|
|||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
}
|
||||
}
|
||||
|
||||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||||
// Phase 3's reset path sets VelocityKilled when an airborne hit
|
||||
|
|
|
|||
|
|
@ -699,8 +699,13 @@ public class BSPQueryTests
|
|||
// Regression guard for the FULL-HIT case in the same Path 5 branch.
|
||||
// Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT
|
||||
// reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)`
|
||||
// branch. With engine=null we fall through to the slide fallback
|
||||
// (SetCollisionNormal + SetSlidingNormal + return Slid).
|
||||
// branch. With engine=null we fall through to the real slide
|
||||
// (CSphere::slide_sphere via Transition.SlideSphereInternal). No
|
||||
// contact plane is seeded on this bare Transition, so the slide takes
|
||||
// the wall-only branch (project out the into-wall displacement,
|
||||
// return Slid) — and per retail it must NOT write the sliding normal
|
||||
// (#137 mechanism 2; validate_transition 0x0050ac21 is the only
|
||||
// in-transition writer).
|
||||
var (root, resolved) = BuildSingleWallBsp();
|
||||
|
||||
var transition = new Transition();
|
||||
|
|
@ -731,6 +736,9 @@ public class BSPQueryTests
|
|||
Assert.Equal(TransitionState.Slid, state);
|
||||
Assert.True(transition.CollisionInfo.CollisionNormalValid,
|
||||
"Full hit should set the collision normal (slide fallback).");
|
||||
Assert.False(transition.CollisionInfo.SlidingNormalValid,
|
||||
"find_collisions must not write the sliding normal — retail's " +
|
||||
"only in-transition writer is validate_transition (#137).");
|
||||
Assert.False(transition.SpherePath.NegPolyHit,
|
||||
"Full hit should NOT also fire NegPolyHit — that's the near-miss " +
|
||||
"path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,307 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's in-transition <c>collision_info.sliding_normal</c> has exactly ONE
|
||||
/// writer besides the per-frame seed: <c>CTransition::validate_transition</c>
|
||||
/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The
|
||||
/// BSP collision layer NEVER writes it — <c>BSPTREE::find_collisions</c>'
|
||||
/// Contact branch dispatches full hits to <c>step_sphere_up</c> (foot,
|
||||
/// 0x0053a719) / <c>BSPTREE::slide_sphere</c> (head, 0x0053a697), and
|
||||
/// <c>CSphere::slide_sphere</c> (0x00537440) slides IN-FRAME via
|
||||
/// <c>add_offset_to_check_pos</c> without touching sliding_normal
|
||||
/// (grep-verified: zero sliding_normal references between 0x005155 and
|
||||
/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only
|
||||
/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and
|
||||
/// Transition.cs:1027 (validate). The body-side persistence
|
||||
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x005154c2, SLIDING_TS bit-4 sync
|
||||
/// at 0x005154e1) runs only on transition SUCCESS.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// acdream's BSPQuery Contact branch carried stub fallbacks
|
||||
/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real
|
||||
/// slide. The leaked sliding normal survived to the transition end, the
|
||||
/// unconditional body writeback persisted it, and the next frame's seed
|
||||
/// projected an exactly-anti-parallel push to zero — aborting at step 0
|
||||
/// BEFORE any collision test could refresh the state. Live shape: the
|
||||
/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall
|
||||
/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no
|
||||
/// zero-advance resolves; strafe escapes).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class Issue137SlidingNormalLifecycleTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Site-level pins — BSPQuery.FindCollisions Contact branch must not write
|
||||
// the transition's sliding normal (retail: only validate_transition does).
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Contact foot-sphere FULL HIT with the step-up recursion unavailable
|
||||
/// (engine=null / step-up already in progress) must dispatch the real
|
||||
/// sphere slide — never the SetSlidingNormal stub.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail: a blocked step-up funnels to <c>SPHEREPATH::step_up_slide</c> →
|
||||
/// <c>CSphere::slide_sphere</c> (ACE SpherePath.cs:316 → Sphere.cs:558) —
|
||||
/// in-frame slide, no sliding_normal write. Face-on into a vertical wall
|
||||
/// while grounded: the crease projection (cross(wallN, floorN)) has no
|
||||
/// component along the movement, the slide offset is degenerate
|
||||
/// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite()
|
||||
{
|
||||
var (root, resolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
// Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5
|
||||
// (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall.
|
||||
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||
|
||||
var localSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = to,
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
root, resolved, t, localSphere, null,
|
||||
from, Vector3.UnitZ, 1.0f);
|
||||
|
||||
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||
"find_collisions must not write collision_info.sliding_normal — " +
|
||||
"retail's only in-transition writer is validate_transition " +
|
||||
"(0x0050ac21). A sliding normal leaked here survives to the body " +
|
||||
"writeback and absorbs the next frame's forward offset (#137).");
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid,
|
||||
"The real slide records the collision normal (CSphere::slide_sphere " +
|
||||
"→ set_collision_normal).");
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact HEAD-sphere FULL HIT must dispatch <c>BSPTREE::slide_sphere</c>
|
||||
/// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real
|
||||
/// <c>Sphere.SlideSphere</c> on GlobalSphere[0]) — never the stub.
|
||||
/// The corridor phantom's portal-side polys span head height; this is the
|
||||
/// path that recorded the (−1,0,0) normal the wedge absorbed on.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite()
|
||||
{
|
||||
// Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere
|
||||
// (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head
|
||||
// sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it.
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>();
|
||||
|
||||
var floorVerts = new[]
|
||||
{
|
||||
new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f),
|
||||
new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f),
|
||||
};
|
||||
resolved[1] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = floorVerts,
|
||||
Plane = new Plane(Vector3.UnitZ, 0f),
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var wallNormal = new Vector3(-1f, 0f, 0f);
|
||||
var wallVerts = new[]
|
||||
{
|
||||
new Vector3(0.5f, -1f, 0.6f),
|
||||
new Vector3(0.5f, -1f, 5f),
|
||||
new Vector3(0.5f, 1f, 5f),
|
||||
new Vector3(0.5f, 1f, 0.6f),
|
||||
};
|
||||
resolved[2] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = wallVerts,
|
||||
Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5
|
||||
NumPoints = 4,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
|
||||
var leaf = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f },
|
||||
};
|
||||
leaf.Polygons.Add(1);
|
||||
leaf.Polygons.Add(2);
|
||||
|
||||
var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius);
|
||||
var t = BSPStepUpFixtures.MakeGroundedTransition(from, to);
|
||||
|
||||
var footSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = to,
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
var headSphere = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
Origin = new Vector3(to.X, to.Y, 0.8f),
|
||||
Radius = BSPStepUpFixtures.SphereRadius,
|
||||
};
|
||||
|
||||
var result = BSPQuery.FindCollisions(
|
||||
leaf, resolved, t, footSphere, headSphere,
|
||||
from, Vector3.UnitZ, 1.0f);
|
||||
|
||||
Assert.False(t.CollisionInfo.SlidingNormalValid,
|
||||
"Head full hit must go through the real BSPTREE::slide_sphere — " +
|
||||
"no sliding_normal write at the BSP layer (retail 0x0053a697).");
|
||||
Assert.True(t.CollisionInfo.CollisionNormalValid);
|
||||
Assert.Equal(TransitionState.Collided, result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a
|
||||
// REAL wall. Guards the fix against regressing wall behavior, and
|
||||
// documents where retail CLEARS the body's sliding state (the successful
|
||||
// transition's writeback, when no step re-records a collision).
|
||||
// =========================================================================
|
||||
|
||||
private const uint CellId = 0xA9B40157u;
|
||||
|
||||
private static PhysicsEngine BuildWallEngine()
|
||||
{
|
||||
var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall();
|
||||
|
||||
var cell = new CellPhysics
|
||||
{
|
||||
BSP = new PhysicsBSPTree { Root = wallRoot },
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Resolved = wallResolved,
|
||||
CellBSP = new CellBSPTree
|
||||
{
|
||||
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
|
||||
},
|
||||
};
|
||||
|
||||
var engine = new PhysicsEngine();
|
||||
engine.DataCache = new PhysicsDataCache();
|
||||
|
||||
// Flat terrain strip so the outdoor fall-through has something to
|
||||
// sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests).
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)0);
|
||||
engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()),
|
||||
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
engine.DataCache.RegisterCellStructForTest(CellId, cell);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static float[] BuildHeightTable()
|
||||
{
|
||||
var ht = new float[256];
|
||||
for (int i = 0; i < 256; i++) ht[i] = i * 1.0f;
|
||||
return ht;
|
||||
}
|
||||
|
||||
private static PhysicsBody GroundedBody()
|
||||
{
|
||||
var body = new PhysicsBody();
|
||||
body.ContactPlaneValid = true;
|
||||
body.ContactPlane = new Plane(Vector3.UnitZ, 0f);
|
||||
body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
return body;
|
||||
}
|
||||
|
||||
private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body,
|
||||
Vector3 from, Vector3 to)
|
||||
=> engine.ResolveWithTransition(
|
||||
currentPos: from,
|
||||
targetPos: to,
|
||||
cellId: CellId,
|
||||
sphereRadius: BSPStepUpFixtures.SphereRadius,
|
||||
sphereHeight: 0f, // single sphere — keeps the scenario deterministic
|
||||
stepUpHeight: 0.04f, // cannot scale the 5 m wall
|
||||
stepDownHeight: 0.04f,
|
||||
isOnGround: true,
|
||||
body: body);
|
||||
|
||||
/// <summary>
|
||||
/// The full retail lifecycle at a real wall:
|
||||
/// (1) a blocked face-on push persists the validate-recorded sliding
|
||||
/// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync,
|
||||
/// 0x005154e1);
|
||||
/// (2) the next exactly-anti-parallel push is absorbed by the seed
|
||||
/// (get_object_info 0x00511d44 → adjust_offset projects to zero →
|
||||
/// find_transitional_position's step-0 small-offset abort) — the
|
||||
/// retail cache semantics: "still pressed against this wall";
|
||||
/// (3) an oblique push escapes along the wall tangent, the step runs
|
||||
/// without re-recording a collision, and the successful writeback
|
||||
/// CLEARS the body's sliding state (sliding_normal_valid==0 → bit
|
||||
/// 4 cleared).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape()
|
||||
{
|
||||
var engine = BuildWallEngine();
|
||||
var body = GroundedBody();
|
||||
|
||||
// ── 1. Face-on +X into the wall at x=0.5 (normal −X) ─────────────
|
||||
var r1 = ResolveForward(engine, body,
|
||||
from: new Vector3(0.10f, 0f, 0f),
|
||||
to: new Vector3(0.35f, 0f, 0f));
|
||||
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A blocked push must persist the validate-recorded sliding normal " +
|
||||
"(retail SetPositionInternal 0x005154c2 on transition success).");
|
||||
Assert.True(body.SlidingNormal.X < -0.9f,
|
||||
$"Persisted normal should face the mover (−X); got {body.SlidingNormal}.");
|
||||
Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius
|
||||
<= 0.5f + PhysicsGlobals.EPSILON * 20f,
|
||||
$"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}.");
|
||||
|
||||
// ── 2. Exactly-anti-parallel push again: absorbed frame ──────────
|
||||
var r2 = ResolveForward(engine, body,
|
||||
from: r1.Position,
|
||||
to: r1.Position + new Vector3(0.15f, 0f, 0f));
|
||||
|
||||
Assert.False(r2.Ok,
|
||||
"The seeded sliding normal projects the exactly-anti-parallel " +
|
||||
"offset to zero → step-0 abort (retail find_transitional_position " +
|
||||
"0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall.");
|
||||
Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A failed transition leaves the body's sliding state untouched " +
|
||||
"(retail: SetPositionInternal never runs on failure).");
|
||||
|
||||
// ── 3. Oblique push escapes and CLEARS the persisted state ───────
|
||||
var r3 = ResolveForward(engine, body,
|
||||
from: r2.Position,
|
||||
to: r2.Position + new Vector3(0.10f, 0.15f, 0f));
|
||||
|
||||
Assert.True(r3.Ok, "Oblique push must escape along the wall tangent.");
|
||||
Assert.True(r3.Position.Y > r2.Position.Y + 0.05f,
|
||||
$"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " +
|
||||
$"(from {r2.Position.Y:F4}).");
|
||||
Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding),
|
||||
"A successful transition whose steps re-record no collision must " +
|
||||
"CLEAR the body's sliding state (retail SetPositionInternal " +
|
||||
"0x005154e1: bit 4 synced from the transition's final " +
|
||||
"sliding_normal_valid, which each step clears before its insert).");
|
||||
Assert.Equal(Vector3.Zero, body.SlidingNormal);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue