# #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). ## Mechanism 1 RESOLVED the same session — the "phantom wall" never existed Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory entirely; **no cdb session needed for this repro**: 1. **The recorded hit normal matches NO polygon.** A world-space sweep of both seam cells + every portal-adjacent neighbor (`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`) found zero physics polygons within 18° of `(−1.00,0.03,−0.03)` near the hit point. The normal is the player's **negated movement direction** — a SYNTHETIC value from `slide_sphere`'s opposing-normals branch (`reversed = −gDelta` → `set_collision_normal`). 2. **The PortalSide polys were a red herring for this hit.** Cell 0x8A02016E has IDENTITY rotation (the prior session's "rotation maps them into the −X wall" was wrong); polys 1/3/5 are ±Y-normal planes at world y≈−38.33, 1.4 m beside the player's track and PERPENDICULAR to the +X run — `pos_hits_sphere`'s directional cull (dot ≥ 0 → culled, 0x005394f0 tail) rejects them for that movement outright. They ARE referenced by a physics-BSP leaf (`CorridorCell_PhysicsBspLeafMembership`), so retail tests them too when approached INTO their plane — most likely they are legitimately solid one-way/window-class geometry (which is why the dat keeps PortalSide-only portal polys in the physics set while removing every ExactMatch one). The pickup's warning against a blanket "skip portal polys" filter stands — no filter is needed at all. 3. **A second slide_sphere port bug found and fixed:** the opposing-normals branch returned OK where retail returns COLLIDED_TS (0x005375d7-0x0053762c: `*normal = −gDelta; normalize; set_collision_normal; return 2`). Our OK let the step complete as-is while carrying the synthetic reversed-movement collision normal — `validate_transition`'s epilogue then converted it into the sliding normal the wedge absorbed on. Fixed at the same TransitionTypes site; pinned by `SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal`. 4. **The dat-backed corridor replay reproduces the live frame and runs clean** (`Issue137CorridorSeamReplayTests`): same input, same full advance to (85.253, −39.776, −5.992), same 016E→017A transit — now `hit=no`, no sliding normal persisted, and six further forward frames advance freely. (The pre-fix code did NOT reproduce the wedge in the replay — the live entry chain involved session state beyond the replay's reach — so the replay is the CLEAN-corridor pin, not a red/green falsification; the site-level pins in `Issue137SlidingNormalLifecycleTests` are the red→green proof.) Remaining for #137: the user's corridor re-run (visual gate) + the issue's door half (doors block/pass per open state — separate acceptance).