acdream/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
Erik e8651b3819 fix #137 (corridor phantom resolved): slide_sphere opposing branch returns Collided; the 'wall' was synthetic
The mechanism-1 theory (PortalSide portal polys solid in our physics set)
is REFUTED for the corridor repro, and the remaining half of the phantom
is fixed — no cdb session needed:

- The live hit normal (-1.00,0.03,-0.03) matches NO dat polygon: a
  world-space sweep of both seam cells + every portal-adjacent neighbor
  (CorridorSeam_FindPolygonMatchingLiveHit) returns zero candidates. The
  normal is the negated movement direction — the SYNTHETIC value
  slide_sphere's opposing-normals branch records (reversed = -gDelta).
- Cell 0x8A02016E has IDENTITY rotation (the prior session's 'rotation
  maps the portal planes into the -X wall' was a misattribution). The
  PortalSide polys to 0x011E are +-Y planes 1.4 m beside the player's
  track, perpendicular to the +X run — pos_hits_sphere's directional
  cull rejects them for that movement. They ARE referenced by the dat's
  physics-BSP leaves (CorridorCell_PhysicsBspLeafMembership), so retail
  tests them too when approached into their plane; the dat's
  keep-PortalSide / strip-ExactMatch asymmetry reads as intentional
  (solid window/grate-class portals). No portal-poly filter — exactly
  the blanket-skip the pickup warned against.
- Port fix: CSphere::slide_sphere's opposing-normals branch
  (0x005375d7-0x0053762c) records the reversed displacement and returns
  COLLIDED_TS; our port returned OK ('retail returns OK here' was a
  decomp misread), letting the step complete as-is with the synthetic
  collision normal that validate's epilogue then persisted as the
  sliding normal the wedge absorbed on. TransitionTypes opposing branch
  now returns Collided; pinned by
  SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal
  (RED->GREEN).
- Dat-backed replay (Issue137CorridorSeamReplayTests) reproduces the
  live hit frame verbatim (same in/out to the millimeter, same 016E->017A
  transit, same +8mm settle) and runs the corridor CLEAN: hit=no, no
  sliding normal persisted, six further forward frames advance freely.
- Inspection tests extended: physics-BSP leaf membership walk +
  hit-normal candidate sweep + downward-poly sweep (all report-style,
  dat-gated). Pickup prompt banner'd SUPERSEDED; ISSUES #137 updated
  (door half stays open); audit doc extended with the resolution.

Suites: Core 2551 / App 713 / UI 425 / Net 385, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:27:40 +02:00

9.1 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).

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 = gDeltaset_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).