acdream/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
Erik a11df5b8d3 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>
2026-07-05 18:08:29 +02:00

6.9 KiB
Raw Blame History

#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 `
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_slideCSphere::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_sphereslide_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.