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>
9.1 KiB
#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_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:
- 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 fromslide_sphere's opposing-normals branch (reversed = −gDelta→set_collision_normal). - 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. - 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 bySlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal. - 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 — nowhit=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 inIssue137SlidingNormalLifecycleTestsare 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).