# #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.