diff --git a/docs/research/2026-08-08-347-fix-contract.md b/docs/research/2026-08-08-347-fix-contract.md new file mode 100644 index 00000000..92226db0 --- /dev/null +++ b/docs/research/2026-08-08-347-fix-contract.md @@ -0,0 +1,122 @@ +# #347 fix contract — retail's within-tick slide continuation (full-rate glide) + +**Date:** 2026-08-08. **Implementer: Fable directly (user direction), dual +Opus review, then the user's slope feel gate.** Predecessor: #345 landed at +`ab89ebdf` (validate_walkable return scoping); this contract closes AD-70. + +## The defect (measured, not hypothesized) + +Post-#345, the glide alternates in a strict two-tick cycle (scratch trace, +2026-08-08, 45-degree diagonal fixture): +- arming tick: ZERO XY yield, sliding normal becomes (0.707,-0.707,0), + transient gains Sliding; +- moving tick: +0.115/+0.115 (the along-crease component), sliding normal + CLEARS; +- repeat. 14 of 30 ticks stuck; lateral rate is HALF the input's lateral + component. + +Retail delivers motion EVERY tick: cdb counters (`345-retail-glide.cdb.log`) +show edge_slide/cliff_slide 594 each over one ~15 s glide (~per 30 Hz tick), +set_sliding_normal 538, step_up 0. + +## Retail's mechanism (pinned from pc this session) + +`CTransition::edge_slide` @0x0050b3d0 (pc:273001+), steep-contact branch +(contact valid AND N.z < allowance @0050b3f7-0050b441): +1. `sphere_path.walkable = null; restore_check_pos()` — back to the SAVED + (advanced, pre-step-down) candidate; +2. `*outState = cliff_slide(&contact_plane)` — see below; +3. clears contact plane validity/water; +4. **returns 0 (FALSE = do not stop)** — `transitional_insert`'s attempt + loop CONTINUES and retries at the position cliff_slide just produced. + +`CTransition::cliff_slide` @0x0050a6d0 (pc:272397+): +1. crease = cross(steepN, **last_known_contact_plane.N**) — NOT the current + contact; horizontal-projected (Z forced 0 via *0f arithmetic in BN's + rendering) and 90-degree-rotated: vector (-crossY, crossX, 0) = the + horizontal PERPENDICULAR to the crease; +2. `normalize_check_small` degenerate -> return 1 (OK_TS); +3. proj = dot(check-minus-curr displacement + LandDefs::get_block_offset + (curr cell vs check cell — the AD-69 seam family), perpVector); +4. sign-dependent arm: adds `perpVector * (+-proj)` to the CHECK POSITION + (add_offset_to_check_pos @0050a804/0050a857) — REMOVING the into-face + perpendicular component so the check pos keeps only the along-crease + part — and sets the collision normal (one arm negates the normal: + 0050a813-0050a827); +5. **returns 3 (ADJUSTED)**. + +Net: the SAME transitional_insert call retries at the slid position, the +insert validates clean, step-down lands on the flat side, OK — motion +delivered within the tick, every tick. The sliding normal is set as well, +so next tick's AdjustOffset pre-projection ALSO applies (that projection +producing an already-clean request is why retail still fires edge_slide +per tick: the request keeps pressing into the face). + +## Our port today (`TransitionTypes.cs`) + +- `TransitionalInsert` (:2306-2335) already continues the loop when + `EdgeSlideAfterStepDownFailed` returns false — the LOOP shape is ported. +- `EdgeSlideAfterStepDownFailed` branch2 (:2534-2544) matches retail's + steep-contact branch: restore, CliffSlide, clear, return false. +- `CliffSlide` (:2630-2672) computes the same perpVector and even calls + `sp.AddOffsetToCheckPos` with a sign-dependent arm and returns Adjusted. + +**Yet the arming tick yields zero.** So the divergence is INSIDE this +chain, not its shape. Candidates, in test order: + +1. **Sign-arm inversion (BN flag-test ambiguity).** Ours adds + `collideNormal*angle` when angle<=0 and `collideNormal*(-angle)` when + angle>0 — BOTH are non-positive multiples. Removing the perpendicular + component requires the CANCELLING sign in both arms: for angle>0 + subtract (ours does), for angle<0 ADD the positive multiple (ours + subtracts more — DOUBLING the into-face component instead of + cancelling). Retail's two arms at 0050a7d6/0050a849 vs 0050a7f6 are + sign-mushed in BN — byte-decode 0050a7a0-0050a870 (fchs placement) + before concluding. If ours doubles the into-face component on one arm, + the slid retry re-collides HARDER -> retries exhaust -> zero yield -> + exactly the alternation (next tick's pre-projection is what moves). +2. **Retry-after-CliffSlide dies.** The Adjusted continue (:2323-2327) + re-enters InsertIntoCell; if the slid check pos still validates against + the steep triangle (e.g. because the offset was wrong per candidate 1, + or because the retry re-runs the PRIMARY phase from the pre-advance + position rather than the slid candidate), attempts exhaust. The + transit-fail probe's buffered [transit-fail-insert] lines (19 per stuck + tick in the scratch run) name each attempt's phase outcomes — READ THEM + FIRST; they may settle candidates 1 and 2 in one look. +3. **restore_check_pos ordering.** Retail restores BEFORE cliff_slide and + cliff_slide then adds its offset to the RESTORED (advanced) candidate. + Ours: branch2 restores then CliffSlide adds to... verify which position + RestoreCheckPos leaves in GlobalSphere (SaveCheckPos at :2265 saved the + ADVANCED candidate, so restore should equal retail). Confirm with the + probe's per-attempt positions. + +## D1 — the minimal port + +Fix ONLY what the instrumentation names (expected: the CliffSlide sign +arm(s), possibly one retry-entry position). Do NOT touch: the loop shape, +branch1/3/4 responses, PrecipiceSlide, the #331 sliding-normal +persistence/clearing, AdjustOffset (AD-66!), ValidateWalkable (#345 just +landed), DoStepDown internals. + +## D2 — tests + +1. Tighten `Issue345SteepSlopeGlideTests.Angled45Approach_GlidesAlongTheDiagonal`: + full-rate — stuckTicks small (crossing transient only, e.g. <= 3) and + lateral advance ~= the input's lateral component times the post-crossing + tick count (assert >= 85% of it, ordering-safe), replacing the + alternation-tolerant `Ticks/2 + 2` bound and its comment (and drop the + #347-residual comment). +2. The 30/60-degree ordering pin and perpendicular stop stay green + unchanged. +3. AD-65/AD-66 conformance, the #331 absorb pin, RetailEdgeResponseOrdering, + Issue265, TransitFailProbe, EdgeSlideBackProbePrecipiceSlideTests, and + the #271 staircase guard ALL stay green untouched. +4. Sabotage: revert the sign/continuation fix -> the tightened full-rate + assertion reds with the alternation numbers; restore. + +## Acceptance + +Clean-room complete suite; dual Opus review (conformance byte-check of +cliff_slide's fchs arms + blast radius over every CliffSlide caller); +retire AD-70 in the same commit; user slope feel gate (~2 min: glide speed +now matches retail side-by-side; downhill/uphill/hover unchanged).