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>
115 lines
9.1 KiB
Markdown
115 lines
9.1 KiB
Markdown
# #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).
|