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:
Erik 2026-07-05 18:08:29 +02:00
parent e73e45da54
commit a11df5b8d3
7 changed files with 474 additions and 31 deletions

View file

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