diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 67a890a3..a5aff0ea 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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).
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 09d3f274..2cadd2bd 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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 |
---
diff --git a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
new file mode 100644
index 00000000..b23773a3
--- /dev/null
+++ b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
@@ -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.
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index 87c127dc..3538e497 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -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.
// -------------------------------------------------------------------------
///
- /// BSPTree.slide_sphere — apply sliding collision response.
+ /// BSPTree.slide_sphere — dispatch the real sphere-level slide
+ /// (CSphere::slide_sphere 0x00537440, ported as
+ /// ): 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 —
+ /// GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)).
///
///
- /// 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
+ /// CollisionInfo.SlidingNormal and returned Slid. Retail's BSP layer
+ /// never writes the sliding normal — its only in-transition writer is
+ /// CTransition::validate_transition (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).
///
- ///
- /// ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.
///
+ /// Collision normal already in world space (the
+ /// call sites apply L2W; ACE globalizes inside slide_sphere instead).
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.
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 1090559e..da52716b 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -1085,16 +1085,27 @@ public sealed class PhysicsEngine
body.WalkableVertices = null;
}
- if (ci.SlidingNormalValid
- && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ // 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)
{
- body.SlidingNormal = ci.SlidingNormal;
- body.TransientState |= TransientStateFlags.Sliding;
- }
- else
- {
- body.SlidingNormal = Vector3.Zero;
- body.TransientState &= ~TransientStateFlags.Sliding;
+ if (ci.SlidingNormalValid
+ && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ {
+ body.SlidingNormal = ci.SlidingNormal;
+ body.TransientState |= TransientStateFlags.Sliding;
+ }
+ else
+ {
+ body.SlidingNormal = Vector3.Zero;
+ body.TransientState &= ~TransientStateFlags.Sliding;
+ }
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
index c2474e4e..be9b279a 100644
--- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
@@ -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 " +
diff --git a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs
new file mode 100644
index 00000000..a5e2899b
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs
@@ -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;
+
+///
+/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06).
+///
+///
+/// Retail's in-transition collision_info.sliding_normal has exactly ONE
+/// writer besides the per-frame seed: CTransition::validate_transition
+/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The
+/// BSP collision layer NEVER writes it — BSPTREE::find_collisions'
+/// Contact branch dispatches full hits to step_sphere_up (foot,
+/// 0x0053a719) / BSPTREE::slide_sphere (head, 0x0053a697), and
+/// CSphere::slide_sphere (0x00537440) slides IN-FRAME via
+/// add_offset_to_check_pos 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
+/// (CPhysicsObj::SetPositionInternal 0x005154c2, SLIDING_TS bit-4 sync
+/// at 0x005154e1) runs only on transition SUCCESS.
+///
+///
+///
+/// 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).
+///
+///
+public class Issue137SlidingNormalLifecycleTests
+{
+ // =========================================================================
+ // Site-level pins — BSPQuery.FindCollisions Contact branch must not write
+ // the transition's sliding normal (retail: only validate_transition does).
+ // =========================================================================
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Retail: a blocked step-up funnels to SPHEREPATH::step_up_slide →
+ /// CSphere::slide_sphere (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).
+ ///
+ ///
+ [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);
+ }
+
+ ///
+ /// Contact HEAD-sphere FULL HIT must dispatch BSPTREE::slide_sphere
+ /// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real
+ /// Sphere.SlideSphere 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.
+ ///
+ [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();
+
+ 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(), Array.Empty(),
+ 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);
+
+ ///
+ /// 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).
+ ///
+ [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);
+ }
+}