6.4 KiB
#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):
sphere_path.walkable = null; restore_check_pos()— back to the SAVED (advanced, pre-step-down) candidate;*outState = cliff_slide(&contact_plane)— see below;- clears contact plane validity/water;
- 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+):
- 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;
normalize_check_smalldegenerate -> return 1 (OK_TS);- proj = dot(check-minus-curr displacement + LandDefs::get_block_offset (curr cell vs check cell — the AD-69 seam family), perpVector);
- 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); - 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 whenEdgeSlideAfterStepDownFailedreturns false — the LOOP shape is ported.EdgeSlideAfterStepDownFailedbranch2 (:2534-2544) matches retail's steep-contact branch: restore, CliffSlide, clear, return false.CliffSlide(:2630-2672) computes the same perpVector and even callssp.AddOffsetToCheckPoswith 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:
- Sign-arm inversion (BN flag-test ambiguity). Ours adds
collideNormal*anglewhen angle<=0 andcollideNormal*(-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). - 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.
- 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
- 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-tolerantTicks/2 + 2bound and its comment (and drop the #347-residual comment). - The 30/60-degree ordering pin and perpendicular stop stay green unchanged.
- AD-65/AD-66 conformance, the #331 absorb pin, RetailEdgeResponseOrdering, Issue265, TransitFailProbe, EdgeSlideBackProbePrecipiceSlideTests, and the #271 staircase guard ALL stay green untouched.
- 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).