86 KiB
MotK rollback hookup checklist
Status: hooked (experimental) · branch feat/rollback-netplay · depends on
lib/recomp-net @ feat/rollback
Today MotK / psxrecomp lobby netplay defaults to rollback:
stage_local → poll_admit (invent within P) → guest frame → finish_frame.
Missing remotes are invented (hold-last, MotK digital) only while
wire_need ≤ highest_remote + P; outside that window admit stalls (BattleShip
phase_lock). Idle invent re-mismatched every held D-pad tick after commit
(char-select episode storm). Seal gap-fill stays idle. Late wire goes through
the input contract; rewinds open an RNetRbSession episode. Menu releases
rewind (FMV soft-promote release-only only). Host Disable Rollback (or
PSX_NET_MODE=delay) forces classic delay-sync try_admit.
0. Ground rules (do not skip)
| Rule | Why |
|---|---|
Keep delay-sync RNetSession working behind a flag |
Lobby / ICE / save-xfer stay useful; Disable Rollback opts out |
Predicted rows promote only via hash_confirm (or host protect) |
Library invariant; NULL hash_confirm_promote = always rewind |
| Digests must be bit-identical across peers for the same sealed inputs | Otherwise every invent becomes an episode storm |
| Same bit-identical binary on both peers | Mixed build-release vs packaged motk-* can match live digests for hundreds of ticks then fork GPRs/timers in Replay (pin zlib skew is the symptom) |
| Snapshots must restore at the exact sim tick requested | Ring load is the only rewind mechanism |
| Single-thread ownership of session + rings | Same as delay-sync |
Env gate: PSX_NET_MODE=delay|rollback. Lobby default publishes
match_caps.rollback=true; Disable Rollback publishes false. Auto D/P from
RTT (BattleShip tiers / phase_lock); optional Manual Input Delay / Prediction.
1. In-memory snapshot ring (host)
Tasks
-
Add
boot_state_save_buffer(or serialize-to-malloc) mirroring load-buffer -
New module e.g.
runtime/src/netplay_snap_ring.c:- depth
N(≥RNET_RB_SEAL_MAX_SPAN= 128; start with 64 for soak) - keyed by
sim_tick ring_save(tick),ring_load(tick),ring_has(tick), drop oldest
- depth
-
Capture at a safe boundary —
psx_netplay_poll_snapbesidesavestate_pollon slow + MotK IRQ fast/mid paths (interrupts.c); also flush pending snap infinish_frame(FMV mid-path used to skip it) -
On load:
psx_cycles_resync_after_restore/interrupts_resync_after_restore/cdrom_accelerate_after_savestate/psx_frontend_on_savestate_loaded/ deferredpsx_scheduler_resume_atviapsx_netplay_rb_flush_resume -
Resume safety: never
longjmpfromrb_pumpunder C++ vblank RAII — apply without resume, thenflush_resumeon BB-edge (poll_snap) or inside the admit wait loop (after present-body RAII destroyed) -
Admit/resume split:
try_admitnever armsneeds_advancewhile a resume is pending or while still inAwaitingBaseline -
Baseline gate: send/enter Replay only after
g_episode_snap_applied(peer BASELINE can arrive during SealInputs — must not skip restore) -
Live snap rate-limit: every
PSX_NET_SNAP_INTERVALticks (default 16, max 32); resim still snaps every tick. Ring uses rawboot_state(no zlib) —compress2on RAM+VRAM+SPU was the live FPS tax; disk.pststill zlib. (Skipping VRAM on live snaps is unsafe for rewind.) -
Snap PC must be
psx_is_dispatchable— pick IRQ/BB-edge resume PC (notcpu->pcat present, often 0); defer save / abort episode instead ofresume_at(0)→trap_crash -
flush_resumeonly in Replay (after both baselines); SealInputs retransmits SEAL_ROWS; sealget_input_rowinvents hold-last so rows areis_valid(peer mask never completes on empty exports) -
Live
g_np.needs_advancemust not bypassrb_try_admitduring an episode (follower spun uncapped with zerofinish_frame); arm load_tick at Replay entry so the post-flush_resumequantum is committed -
Retransmit BASELINE while AwaitingBaseline (TURN drops one-shots); follower marks
digest_a=READYonce it has the peer digest; initiator waits for that ready-ACK before Replay (no solo resim) -
Retransmit POST while Verify; admit stalls until peer POST / commit
-
load_ticktip slack (onesnap_intervalbehind newest) so lagging peer still has the snap; follow refuse → SYNCinitiator=0NACK; initiator aborts SealInputs (plus 4s seal timeout) -
BASELINE: stash if episode not open yet; burst rexmit on TURN; initiator ready-timeout → Replay (Verify still waits for peer POST)
-
Resume PC: reject low/vector junk (
0xB0etc.); prefer function entries; rewrite on load; 5s replay stall + 4s verify POST timeout → abort -
Seal from
load_tick(not only mismatch); hist then sealed SIO publish; sticky BB PC; skip CD accelerate + audio pump on resim; POST digest canonicalizes parked PC; POST diverge aborts episode (lobby stays) -
Master digest folds CDROM controller FSM; per-tick resim audit logs dig/cd/sealed pads; idle_skip + auto_skip_fmv forced off under netplay; POST/baseline diverge → Live realign to matched load (or last commit)
-
Never apply baseline/realign snaps from mid-guest
psx_netplay_pump(cycle watchdog) — only admit-wait / poll+immediateflush_resume; initiator waits for ready-ACK (no solo Replay); ready timeout → realign -
Pin episode baseline snap (resim must not overwrite load_tick); realign loads the pin; audit logs
core=vscd=; skip CD boost + reset SPU CD FIFO on RB restore -
Baseline/POST/hash_confirm agree on core digest only (
cd=audit); CD-only forks were aborting good title/menu Start corrections -
Storm calm: abort/storm cooldown + promote-sweep only (not after clean commit). Post-commit promote-only made char-select D-pad feel rejected (hist ok, sim not rolled). Live invent is hold-last (idle invent → hold mismatch every tick = episode storm). Seal gap-fill idle. Menu release soft-promote removed (forked sticky Up with hold-last); FMV settle still soft-promotes releases. Press/release → episode; held matching invent → no episode. Char-select “hang” with matched POST: digital button edges always rewind in
rnet_input_contract(nohash_confirmpromote for buttons). A tap used to be two episodes (press + release). Fixed in recomp-net: tip-extend (rnet_rb_extend_target/ MotKpsx_netplay_rb_tip_extend) + TipHold (rnet_rb_enter_tip_holdafter POST match: Live continues,tip_runwayquiet window, late release tip-extends without a second baseline) coalesce press+release into one episode. Tip-extend from TipHold/Verify clears the POST handshake; follower FOLLOW mirrors initiator rereplay; TipHold ignores stale rexmit POSTs. Tip-extend during Seal/AwaitingBaseline resigns+extends only (no rereplay) so the initiator cannot solo-Replay before ready GO. TipHold tip-extend stays TipHold (library); rereplay only if Live already invented past the prior tip. FOLLOW mirrors that gate. Tip-extend rereplay reloads the prior tip on both peers (not mismatch-1→pin fallback vs FOLLOW old_t). Tip-extend snap apply alwaysarm_rereplay_after_load(hc_prime +sim=reload+1) — poll_snap used to apply without arming, leaving the TipHold Live tip clock and invent FRAME_COMMITs → falseresim core diverge(matched sealed fin, stale peer invent). TipHold Live does not emit FRAME_COMMIT; tip-extend Replay drops in-flight invent commits until the peer's sealed resim matches. TipHold invent-cap (menu D-pad storm): Live stalls oncesim > tip + tip_seal_slack(MotK forces slack 0). Stops invent racing ahead of the tip and tip-extend rereplays on every held edge. Quiet finalize countstip_runway(MotK 24) pump frames at the invent cap when sim cannot reach the oldtip_hold_until. Seal span cap (menu hang): peer-seal completion is auint64bitmask (RNET_RB_PEER_SEAL_MASK_BITS= 64). Tip-extend pastseal_base+63fails; TipHold used tobegin_refuseduntil runway then open ep N+1 alone →seal timeout. Fix: TipHold span-cap tip-hold-commits immediately andbegin_rewindopens a fresh episode; FOLLOW yields TipHold on a new-epoch SYNC. Self-test:rollback_episode_test(extend to span=64 OK, span=65 fails). Stale POST after tip-extend:RB_POSTcarriestarget_tick; Verify ignores peer POSTs whose tip ≠ episode tip (tip-extend left POST@T in the queue while Verify@T+1 → false diverge / peer verify timeout). Self-test:test_rb_post_tip_filter+rb_wire_testtip binding. Post-TipHoldchoose_load_tick/ follower frontier hard-cap atagreed_through— ignorehash_confirmabove that watermark (Live invent + PC-cleared FRAME_COMMIT false-confirmed forked tip snaps).`tip_seal_slack` (MotK 0 / library default 2) sets invent headroom past tip + initial seal; `tip_runway` (MotK 24 / library default 12) caps TipHold quiet + suggest slack; **light tip** (`RNET_RB_CORR_LIGHT_TIP`) skips ready-ACK RTT when load is at the shared frontier and depth ≤ 8. Do **not** soft-promote releases. **Resim depth (main-menu "back to title" hang):** `choose_load_tick` always applied interval rounding + one-interval tip slack, so a mismatch one tick after a commit (798) re-loaded 768 and every menu tap replayed ~30 ticks twice. Fixed with a **shared-frontier fast path**: ticks the peer provably holds need no slack — (a) `hash_confirm` resolved_through (both simmed, core digests matched → same interval snaps) and (b) the last committed replay span (both peers resim-save a snap at every replayed tick). Release-after-press now loads at commit target, ~2-tick replay. Peer eviction still covered by follower NACK (abort, not hang). Do **not** freeze `cdrom_advance` during Replay (FMV skip resim hung). Promote wire into hist **before** `begin_rewind` seal. FMV media + **digest-gated post-FMV lockstep**: no invent + refuse begin/follow for at least MIN=180 ticks (~3s; was 90 — invent≠Cross at +14 opened a `0x8006CDA0` tip storm); hold until hash_confirm matched CONFIRM=16 contiguous ticks or MAX=300. On RELEASE: invent stays off **UNLOCK_GRACE=64** more ticks (do not clamp until→sim) and arm `promote_sweep` so sticky hold-last poison cannot invent at unlock+1. Invent stays **hold-last** after unlock (idle invent re-mismatches every held D-pad tick — char-select freeze class); a `pub=ffbf wire=ffff` FIRST CORE at unlock is a normal press/release mispredict and light tips handle it. Dense tip snaps during media/lockstep/+32 after invent unlock. MotK TipHold: `tip_runway=24`, invent slack **0** (Live stalls at tip) so tip-extend rarely rereplays — menu D-pad coalesce without the prior-tip reload FPS cliff. Symmetric ready: follower ACK → initiator GO → both Replay. **`flush_resume` releases `gpu_vblank_flush_present` reentrancy guard** before longjmp (stuck `s_flushing_present` blocked all later presents / `finish_frame` — MotK `0x8006CDA0` Replay hang). Mid-guest pump runs `poll_replay_stall` (5s abort). **Menu wait-loop resim (CD54↔CDA0):** do **not** arm deferred present on flush_resume / do **not** re-finish `load_tick` (snap is already post-present). Phantom fin@load before the latched VBlank IRQ put peers on opposite ping-pong edges (`v0=1` vs countdown, ~5-cycle skew). First arm is `load+1`; `hc` primed after load. Netplay flushes deferred present **after** IRQ delivery when both are due (post-RFE digest). Also: do **not** flush present on BB edges while `in_exception` (handler sees IEc clear → old path `finish_frame` mid-BIOS before RFE restore; soak irqctx `restored=0/reason=0`, sealed Cross resim forked `v0=1` vs countdown / cyc±14). **Returning-leaf flush_resume (`0x8006A9F8` → PC=0):** prefer IRQ/sticky/ `$ra` over bare function-entry snap PCs; `flush_resume` clears deferred present + bb_defer nest; sentinel same-thread path must not publish `pc=0` during top-level RB resume; scheduler recovers null-pc via `$ra`. -
Abort cooldown from live sim (before realign rewinds the clock). Old path armed
untilfrom rewound tip → uncapped catch-up burned a short window in ms → char-select episode storms (STALE COOLDOWN). Failed episode: 24 ticks from live (48 on streak≥2; was 60/120). Begin SPAN CAP=24: deep post-abort catch-up is chunked (commit → next episode) instead of one Replay across the whole cooldown (soak depths 63→128 felt like "pushing further back"). Reconcile promotes wire for the whole abort window (promote-no-resim). Clean commit does not arm cooldown. -
Present only at MotK wait CDA0: netplay
gpu_vblank_flush_presentskips while resume/check PC is0x8006CD54(leave pending until0x8006CDA0) so sealed idle resim cannot digest opposite ping-pong edges (cyc±9 / v0 fork after mid-handler present fix). -
Netplay forces software GPU — GL/VK
glReadPixelsVRAM readback was forking peer snaps (core matched, pin zlib ~220KB apart) and mid-resim cores; baseline/POST also agree onav=(GPU+VRAM) via dig_b / POST input_digest -
SW GPU before window + late
force_swbuildsSDL_Rendererafter GL teardown (first match was black: clearedg_gl_activewith null present). Rematch: only lock SW afterensure_sw_sdl_presentsucceeds; retry if prior force left a null renderer (empty window after lobby rematch). -
Mid-FMV tip load:
cdrom_resync_deadlines_after_restore; do not wipe SPU CD FIFO while XA/FMV pending; FMV media includescdrom_fmv_stream_pending(XA mode+reading) so invent-off arms before first MDEC colour decode;mdec_recently_activeuses guest-cycle age (hosts_frame_countlied under present-skip / Replay). RB frontend hook resets depth24 cutover when media is already live (avoid permanent cutover blank). -
flush_resumereleases present-flush reentrancy guard (no latched-VBlank re-arm — that phantom-finished load and forked menu wait resim). Symmetric ready GO. Live snaps stay on during media. Replay armsload+1with present-after-IRQ on netplay BB edges. -
Netplay depth24: present 1/4 vblanks; skip live FRAME_COMMIT (full-RAM CRC) + prime
hash_confirmwhen media ends -
MDEC SSE2 IDCT + 24bpp YCbCr row encode (Beetle-matching, bit-identical to scalar; self-check at
mdec_init) — raises offline/netplay FMV on slow peers; remaining FMV FPS is mostly host CPU ceiling -
MDEC snap
last_color_ageis guest-cycle relative (was hosts_frame_count→ false baseline aux trips with matched FIFO/SPU) -
Diag:
rb wire promote-no-resim/rewind-request— late wire into hist without resim (cooldown/FMV/sweep) vs real episode open / begin refused -
Diag:
rb live digevery 32 ticks +rb FIRST CORE DIVERGE(FRAME_COMMIT mismatch) with core partitions (cpu/clk/tim/ram/dirty) + av/cd — find when peers fork before the first doomed baseline abort -
Baseline
dig_c= ext = crc(aux, cd, spad, dma, sio) — gate before Replay; matched core/av/aux with divergent CD/bus (pin zlib skew) was doomed resim. Wire still dig_c; logs printext=+ rawcd=/aux=/spad=/dma=/sio=. Zero hostlast_sector_frameon CD snap wire. mid-Replay FRAME_COMMIT abort on core mismatch (no false POST);rb audit fin+ abort dump parts + bus digests +cpu-split -
SIO resim fork:
sioPshowed fsm-only forks with matched regs/pads/mc + bit-identical guest. Fixes: (1)sio_pace_walkno longer drops leftover cycles after a transition cap (peers batching advances differently left divergent shift/ack remainders); (2) reseatsio_trace_seqon snap load; (3) netplaysio=/ baseline_ext fold only through fsm pace (exclude host-audit meta/byte_seq); (4) mid-Replay cycle-watchdog pump drains FRAME_COMMIT only — no reconcile/rb_pump. Logs:sioP=regs/pads/mc/pace/meta -
Replay entry:
hc_prime_after(load-1)drops live invent commits (falseresim core diverge); firstready=1baseline burst bypasses rate limit (initiator was ready-timeout while follower solo-Replayed) -
Present-edge digests clear PC (FRAME_COMMIT / audit fin / POST) — parked
cpu->pc=0vs live BB + host-local sticky forked dig_cpu with matched RAM/clk;rb cpu-splitlogs gpr/hi_lo/cop0 vs raw_pc -
Core digest folds GTE (was snap-only) — MFC2 can fork GPRs with matched RAM/clk/SIO;
rb cpu-splitaddsgte=+rb gpr-dumpon fin/abort/post so peers can diff which regs forked -
Core digest = fold of part digests (one RAM CRC pass, not dual stream);
core=values change vs older binaries — peers must match -
Log
rb binary path=… size=…at rollback start — peers must run the same bit-identical binary. Diags showed hostbuild-release/vs guestmotk-0.1.0-linux-x64/with pin zlib ~1.34M vs ~1.13M, matched baselines, then Replay GPR-only (ep1) / cpu+tim+ram (ep2 tick after a good Up) -
FPS: raw in-memory snaps + default interval 8; resim audit skips AV/CD on
arm(VRAM CRC only onfin/ baseline / POST). Still open: strip CDROM/MDEC from MotK ring if match path allows (further RAM headroom — raw ring ≈ 3.5 MiB × depth) -
MotK menu v0-only Replay fork: matched baseline + tick N (all GPRs), tick N+1 only
r2differs (host1/ guest0x5bd2) with sticky0x8006CDA0wait-loop, matched RAM/GTE/cycles. BIOS leftv0=1when same-thread GPR restore skipped (mode-1 PC heuristic miss) while peer restored the post-lwcountdown. Fix: netplay autoPSX_SAME_THREAD_RESTORE=3— same-TCB RFE/SYSCALL always restores (ChangeThread still skips). Diag:rb irqctxon fin/abort withrestored/v0_exit/v0_saved. Do not canonicalize v0 in digests. -
Netplay BB-edge present: after restore-3, idle sealed resim still forked
cpu+ramin one tick —sdl_vblank_present/finish_frameran nested fromfire_vblank_edgemid-psx_cyc_stepin the wait loop, so peers digested different instr points. Fix: under netplay, queue the GPU vblank callback and flush inpsx_check_interrupts(BB edge); clear deferred pending on snap resync. Offline still presents immediately. -
Menu wait resim phase (CD54↔CDA0): after present-guard fix, ep1 matched fin@load then forked fin@load+1 (
r2=1vs countdown, cyc±5). Cause: flush_resume armed deferred present for latched I_STAT → phantom finish_frame before latched VBlank IRQ. Fix: armload+1only; no latched re-arm; present-after-IRQ when delivery is due. -
Mid-handler present flush: sealed Cross tip still forked fin@load+2 (
v0=1vs countdown, cyc±14) with matched pads/SIO/baseline; irqctx stuck atrestored=0/reason=0. Cause: handler BB edges flushed deferred present whilein_exception(IEc clear). Fix: skip present flush until outer delivery returns;gpu_vblank_flush_presentalso refusesin_exception. -
CDA0-only present + abort span cap: after mid-handler fix, tip+1 idle sealed resim still forked cyc±9 on CD54↔CDA0; abort cooldown then opened 63–128-tick catch-up Replays. Present defers at CD54; abort cool 24/48; begin SPAN CAP 24 chunks catch-up.
-
Returning-leaf resume crash: after load+1 fix, Start-class episode resumed at
0x8006A9F8(jr $raleaf) with IEc=0 → top-levelexecution completed, PC=0(cps:final_ra=0x8006CCA0). Fix: resume PC pick prefers IRQ/sticky/$ra; clear present+bb_defer on flush; no sentinelpc=0whiletop_level_resume; scheduler null-pc recover. -
Memory budget / thinner snap: optional strip CDROM/MDEC if MotK match path allows (further RAM headroom)
-
Standalone ring bookkeeping test:
runtime/tests/test_netplay_snap_ring.c
Vtable: save_state / load_state → ring only (never disk slots).
2. Master state digest + frame-commit watermark
Tasks
- Define MotK master digest (
netplay_master_digest+ CDROM partition) - After each committed sim tick, compute
digest[tick]into a small ring - Exchange
RNET_PKT_RB_FRAME_COMMITvia session send/take (queued) - Advance local
resolved_throughonly when digests match throughT - Implement
psx_netplay_hash_confirm_through(tick) - Wire
hash_confirm_promotein invent/contract + episode stick gates - Unit test:
runtime/tests/test_netplay_hash_confirm.c
3. Input history + invent / prediction
Tasks
- Per-slot input history ring (
netplay_input_hist) - Local path:
is_predicted = 0 - Remote invent = hold-last (neutral if no prior); stall when ahead of
remote tip by > P. Seal
get_input_rowgap-fill remains idle. - Late wire →
rnet_input_contract_stick_replace_decide+hash_confirm_promote - Rewind →
psx_netplay_rb_begin_rewind(episode) get_input_rowvtable →netplay_ih_getPsxNetPad↔RNetRbFrame(incl.analog→ SIO pad type; seal wiresource); SIO from history / sealed rows- Unit test:
runtime/tests/test_netplay_input_hist.c - Env:
PSX_NET_MODE=delay|rollback→psx_netplay_rollback_mode()
4. Live frame loop (replace admit barrier when rollback on)
Tasks
- Branch
poll_admiton rollback (invent; episode admit while active) - Never call delay-sync
try_admitwait for missing remotes in rollback mode - Still use
RNetSessionfor pad tip transport + ICE - Keep load-barrier / save-xfer / soft-exit paths on delay-sync semantics
finish_framerequests snap; episode path usespsx_netplay_rb_finish_frame- Skip wall pacer while
psx_netplay_is_resimulating()
5. Episode path (resim)
Tasks
- Create
RNetRbSessioninpsx_netplay_rb.cwhen mode=rollback - Fill
RNetRollbackVTable(snap save/load, digest, hist get_input_row) - On rewind:
load_tick= newest ring tick ≤ mismatch (refuse if ring empty) g_np.local_slot/slot_count/ delay set beforerb_start(frozen intoRNetRbConfig)- Peer seal apply ignores
is_valid=0rows (wrong-seat export must not complete) - Seal export/apply over
RNET_PKT_RB_SEAL_ROWS(+ SYNC/BASELINE/POST/RESOLVED) - During resim: skip wall pacer; publish sealed SIO (not live invent)
- After commit:
rnet_rb_on_post_match+ session sim clock totarget+1 - Soak: forced stick mismatch → one episode; digests match post-commit
6. Wire / transport mapping
Tasks
- Session send/take for SYNC / SEAL_ROWS / BASELINE / POST / RESOLVED / FRAME_COMMIT
- Delay-sync peers ignore opcodes 20–25 (session queues only when MotK drains)
- Negotiation:
match_caps.rollback+ launch.rollback/PSX_NET_MODE
7. Determinism prerequisites (MotK-specific)
Tasks
-
Netplay clears mods (
mod_runtime_clear_for_netplay/ launcher hook) -
Same BIOS stem + disc identity on both peers (existing verify)
-
Audit non-deterministic host clocks in sim path — selfcheck-driven (was soak-driven).
PSX_RB_SELFCHECK=1(offline, single process,runtime/src/psx_selfcheck.c): every INTERVAL boundaries snap the machine at a savestate BB edge, record SPAN ticks of applied pad rows + full digest partitions, then rewind and resim the window twice from the same snap. Replay#1 vs replay#2 is the rollback invariant (both peers resim in an episode) → PASS/FAIL with per-part DIVERGE lines; live-vs-resim is reported asrestore-drift(diagnostic; VBlank phase re-applied from snap). Verdict = warm #2vs#3 after ambient-prime. Fixes via selfcheck: VBlank-phase restore; overlay freeze; discard dirty/interp load-delay on restore; pin host frame; RECORD=resim host-skip profile; idle_skip off; turbo/FMV-skip gated; top-level resume; IRQ escape + check-cycle/poll-throttle clear; stickytext_diverged/ kernel-bless / overlay validation memos cleared on RAM restore; span-end load viapsx_selfcheck_flush_loadafter present-body RAII (BB fast-poll tails forked #2/#3 load sites); snap PC uses RBresume_pc_ok(reject 0xB0). MotK attract soak/tmp/selfcheck_spanend2.log: 316+ PASS / 0 FAIL through the classic win#70/#73/#118/#138/#139/#277/#279/#301 set. Re-verified after hold-last invent (/tmp/selfcheck_verify.log): win#70/#73/#118/#139 PASS, 157+ PASS / 0 FAIL and climbing. Post-FMV cutover soak (/tmp/selfcheck_fmvcut.log, INTERVAL=120 SPAN=48): 52 PASS / 0 FAIL through early attract/menu — confirms 954-class peer fork is invent≠wire, not offline resim non-determinism. Tune:PSX_RB_SELFCHECK_INTERVAL/SPAN/FAULT/PRIME. Mash:PSX_RB_SELFCHECK_MASH=1(+ optionalMASH_SEED/MASH_RATE) synthesizes fighter-style face/D-pad/shoulder spam on live boundaries so headless soaks stress invent edges / wait-loop resim without a controller. Rows are recorded and replayed. Stuck hash_confirm / doomed tip loads: a transient FIRST CORE (e.g. sim 201) leftresolved_throughstuck; once that tick aged out of the 128-slot dig ring,choose_load_tickfell through to tip-slack (912) while confirmed snaps were gone — baseline then aborted on cpu-only MotK wait-loop PC forks (CD54↔CDA0) despite matched ram/clk. Fix:netplay_hc_heal_stale_gap, refuse loads past the shared frontier, canonicalize wait-loop snap PC toCDA0. Agreed tip aged out of snap ring: after tip-hold commit, Live can advance ~900 ticks with matching FRAME_COMMITs (hc=1824) whileagreed_through=919falls belowoldest— begin REFUSED forever (no confirmed snap), invent≠wire never resims, cores fork with matched clocks. Fix:heal_agreed_watermark_if_aged_outraises agreed to the highest hc-confirmed snap still in the ring (only when no agreed-era snap remains — TipHold false-confirm guard intact).rb live dig localis a breadcrumb (not peer match). BOOTSTRAP when tip-hold never ran: doomed early FMV abort leftagreedinvalid andhcstuck below ring oldest (live core forks blockheal_stale_gap). Remote saw host P1 on the wire (pub=ffff wire=ffbf) butbegin REFUSEDforever — P2 corrections still opened on host. Fix: seed agreed to newest interval snap (BOOTSTRAP/HEAL-FORCE); choose_load/follow fall through when watermark< oldest. Post-HEAL resim CD54↔CDA0 (cyc±3 / −43): afteragreed HEAL→944, identical baseline + pads still forked fin@945 — dig2 IRQ at CDA0 (cyc=…569, countdown v0) vs dig1 at CD54 (…572, v0=1); dig2 later matched dig1 on a warm retry. Cause:interrupts_resynczeroedcycles_since_vblank(not in snap/digest) while timers/LCF kept mid-frame phase; CDA0-only present treated latchpc==0as presentable; Replay tip-apply from BB poll mutated CPU under live CPS. Fix: persist csv inBS_SEC_IRQ(12B), stop zeroing on resync, fold csv into clk digest, present only at CDA0 (sticky/last fallback), episode loads only viatry_admit. Post-csv residual (clk/tim +9, double fin): after csv persist, sealed tip still aborted at resim 1202 with matched GPR/cpu/ram/av/SIO but irqctx cyc ±9; dig1fin@1202andfin@1203shared identical dig/cyc (doublefinish_frame, no guest advance). Cause: sticky I_STAT keptdelivery_neededso entry skipped present while post-IRQ flush also skipped on CD54 → deferred pending stacked across a full VBlank, then one CDA0 drain ran finish twice; sticky-unknown defer (latch cleared) worsened accumulation. Fix: attempt present at entry even when delivery is due (CDA0 gate no-ops CD54); coalesces_present_pendingto 1; sticky CDA0 with cleared latches allows coalesce present. Arm→first-present +2 VB on long Replay: post-HEAL short tips matched, but cold span-cap load (ep9 952→976) forked fin@953 — dig1 arm+2.03 VB vs dig2 arm+1.00 VB, pads matched. Cause: entrygpu_vblank_flush_presentran befores_last_interrupt_check_pcwas updated, so MotK gate saw stalelast==CD54on every CDA0 edge and no-op'd; present only drained on post-IRQ at a CDA0 delivery. First post-arm VB taken at CD54 → full extra period. Fix: publish edge PC before entry flush; gate prefers compiled CDA0 pc over stale last. Non-det fin@946 CD54 vs CDA0 (resim storm): matched baseline + pads; dig1 alone flipped cores across warm retries — IRQ at CD54 (v0=1) vs present-before-IRQ at CDA0 (countdown). Fix: hold VBlank-only delivery at CD54 while deferred present is armed (deliver after CDA0 present); MotK gate requires explicit CDA0 edge (no sticky-only allow); flush_resume pins sticky to canonical CDA0. FMV1→FMV2 cutover stall: soak matched through FMV arm then guest collapsed (~5 ms) with TURN admit wait and noFMV settle— media stayed “live” (depth24) while MDEC idled. MotK present gate treated sticky wait PC as in_wait and blocked non-CDA0 edges (FMV/cutover). Fix: gate only CD54 edges (sticky only when latch cleared); depth24 1/4 present-skip and FRAME_COMMIT skip only while MDEC hot;rb FMV mediaheartbeat every 32. ±1 VB replay fork at MotK wait (abort@902): identical committed baseline 901 (e6a64d47) + idle pads still forked the first resim tick — abort cyc509725487vs509160985(Δ≈564480 = exactly one VBlank), wait countdownr21=0x704vs0x705,v0=5c88vs5c89. One peer delivered one extra VBlank during replay. Cause: the CD54 delivery-hold was gated ongpu_vblank_present_pending()—s_present_pendingis host-only state (not in the snap), so peers entered replay with different hold behaviour. Fix: hold VBlank-only delivery at the CD54 edge in netplay unconditionally (edge PC + I_STAT are guest-deterministic; CDA0 delivers a few instructions later). The FIRST CORE @896 (pub=ffbf wire=ffff) preceding it was a normal press/release mispredict — ep1–2 committed fine; the storm was this replay fork. UNLOCK_GRACE stays 64.**Residual Δ8-cycle replay fork (abort@940) — I-cache tags:** with the unconditional CD54 hold in, the next soak forked far smaller: identical baseline `f83f685b` / arm cyc `530611223` on both peers, fin@940 cyc `531175713` vs `531175721` (Δ8), `v0=5c83` vs `5c86` — VBlank taken a few wait-loop iterations apart, both at CDA0. Cause: the R3000A I-cache fetch-cost tags (`g_psx_icache_tv`, `psx_icache.c`) are host-persistent and were NOT in the snapshot — each peer replayed with the cache its own live/retry timeline had built, so fetch-miss cycles differed and IRQ delivery drifted. (Selfcheck/overlay replay already shadow this state; RB didn't.) Fix: new `BS_SEC_ICACHE` snapshot section (1024 tag words) written/applied by `boot_state.c` — warm loads, retries, and the exchanged baseline blob all share the exact fetch-timing state — and the tags are folded into the `clk` digest partition so any cache asymmetry surfaces at compare time instead of as a mid-resim abort. Section is optional on load (old blobs load untouched); netplay peers are bit-identical builds anyway. **Post-fix soak (2026-07-31): determinism CLEAN, storm is now pure episode cost.** Full soak with BS_SEC_ICACHE: **zero aborts, zero resim diverges** — 40+ consecutive episodes all committed first try. But menu play ran 0.44–0.8x: every button press/release edge (`pub=ffef/ffbf` vs `wire=ffff`, wire 1–2 ticks late) opens a paired episode (baseline pin 3.6MB + seal/POST handshake + tip-hold). The follow-up soak with `guest=`/`admit=` FPS split corrected the first theory: the link was **direct LAN all along** (ICE selected `typ host` candidates despite `force_turn=1` in the config banner) and `admit` (network wait) is mostly <5 ms/f. The real costs: (a) **guest CPU** — the MotK menu scene costs ~16.6 ms/f of guest work under netplay (software GPU forced + idle_skip forced off), i.e. zero headroom at 60fps, so every 2–4-tick resim burst drops frames (guest hits 20–29 ms/f during storms; pre-menu boot ran 119 fps at guest 6–8 ms/f — it's the scene, not the engine); (b) **mispredict frequency** — with delay=2 and both sims hovering ±1 tick apart (lead=0 admission), the wire for tick T routinely seals 1–2 ticks late on every edge, and each episode further delays input relay (self-sustaining storm). Next: raise input delay to 3–4 (margin for sim skew; kills most menu invents on LAN), and/or LAN `PSX_NET_PREDICTION=0` (stall instead of invent — RTT≈0 makes it free). Longer pole: shrink menu guest cost (hardware GPU needs a deterministic VRAM snapshot path; sw rasterizer + full busy-wait emulation is the ceiling), and drop baseline exchange/POST verify from sealed-input-only episodes now that determinism is proven (zero aborts across two full soaks). **Stall-before-invent grace (implemented):** `np_try_admit_rollback` invented hold-last on the FIRST miss of a remote wire row; the row then landed 1–2 ticks later and the mispredicted edge opened an episode. New `np_invent_grace_stall` (psx_netplay.c): before inventing, stall the admit up to **PSX_RB_INVENT_GRACE_MS (default 30 ms)** from the first miss of that wire tick — the admit loop pumps the session and waits on UDP at ~1 ms granularity. Adaptive off: 45 consecutive expiries (peer genuinely lagging beyond the budget, real WAN) disables the grace for 2 s so it never stacks a per-tick stall on top of real latency. Host-side pacing only — invented values are unchanged, guest determinism unaffected. Set `PSX_RB_INVENT_GRACE_MS=0` to disable. The first cut used 8 ms and failed: the follow-up soak showed the lateness is **sim skew, not link latency** — ALL late edges were on the peer running 1–2 ticks ahead (22×late-1, 5×late-2; the other peer had zero), and 8 ms is less than one tick of skew, so the grace expired on every edge, tripped adaptive-off, and the storm returned ("stable for a few seconds then nonstop"). The grace is really a **rate governor**: while the ahead peer stalls, the behind peer keeps simulating and catches up, so a budget that covers ~2 ticks keeps the sims aligned and, in steady state, inputs arrive before the seal with no stall at all. **Tick-period scaling (2026-07-31):** the first full-match soak showed the fixed 30 ms budget being outrun in gameplay: the fight scene runs 35–45 fps (guest 9–20 ms/f + admit), so a tick lasts ~25 ms and 2 ticks of skew is 50 ms — edges mispredicted again (~40 episodes/match, "stormy" feel; lateness profile 27×late-0 / 12×late-1 on the ahead peer). The grace now measures the live tick period (EMA over consecutive tracked wire ticks) and uses `max(PSX_RB_INVENT_GRACE_MS, 2.5 × tick EMA)`, capped 100 ms, as the effective budget, so it self-scales to whatever rate the sim actually runs. At 60 fps the EMA path gives ~42 ms; at 40 fps ~62 ms. Adaptive-off (45 expiries → OFF 2 s) still guards real WAN latency. **Gap gate (2026-07-31, same day):** the scaled grace fixed the storms (soak: zero episodes, zero aborts, deterministic match) but serialized the sims: stalling on ANY missing row meant neither peer ever ran ahead, so each admit waited out the other peer's guest frame — the FPS logs showed each side's admit ms/f mirroring the OTHER side's guest ms/f (8+12 vs 13+9, both summing ~23 ms → 40–50 fps all match with no episodes at all). Fix: the grace is only invoked when `wire > highest_remote_wire + 1` — a 1-tick gap is the normal phase offset of two pipelined sims and is invented through freely (hold-last only mispredicts on real edges, and episodes are cheap now that the agreed watermark tracks live). The stall — the rate governor — engages only at >=2 ticks of genuine skew, which was the storm condition it was built for. **Timesync micro-throttle (2026-07-31, follow-up soak):** with the gap gate, idle play held a clean 60 fps (admit ~0) — but every input burst dropped fps to 34–48. Episode forensics: 29 episodes/side, 28/29 already on the light path, loads shallow (2–9 ticks). The cost is the replay itself — a paired 2–9 tick resim at ~13–16 ms guest/tick on BOTH peers per button edge — and the episodes all came from the same source as ever: the ahead peer running ~1 tick of phase ahead and inventing each edge just before the real row landed (lateness 23xlate-0 / 6xlate-1, all on one side). New `np_timesync_throttle` (psx_netplay.c), GGPO-style: advantage = wire_need − highest received remote wire row, EMA'd once per tick; above +0.5 tick the ahead peer shaves up to 3 ms per admitted tick until the phase closes. Exactly one side sees positive advantage, so one side paces down briefly and then rows arrive before the seal — no invent, no episode. Capped at 3 ms/tick (worst case ~20% pace, never lockstep); skipped during episodes; adaptive off (engaged ~3 s straight → OFF 5 s) because WAN transit inflates the advantage and can't be closed by pacing. `PSX_RB_TIMESYNC=0` disables. **Advantage metric was wrong (2026-08-01 soak) — replaced with mispredict-driven pacing debt.** The soak showed BOTH peers measuring ~+0.6 ticks of "advantage" (10/16 and 11/16): each side samples wire_need at the start of its own tick while the peer's newest row was sent at the start of *its* tick, so the metric's natural operating point is ~+0.5, not 0. Both sides throttled, neither could close it, both tripped the off-guard (`advantage 17/16 not closing`), and the mispredicts continued (33xlate-1, 43 episodes/side, fights at 0.54–0.7x). Rework: the unambiguous "I am the ahead peer" signal is the mispredict itself — only the ahead side reconciles real rows that arrived AFTER it invented them. `np_timesync_note_late()` is called from `np_rollback_reconcile_wire` on every predicted row whose real row lands (matching or not, so held/idle rows feed the loop and alignment happens before an edge); each event adds ~half a tick of pacing debt (capped 2 ticks) and the admit path shaves it at <=3 ms/tick. Steady state aligned = no invents = no debt = zero cost. Adaptive off: debt continuously nonzero ~5 s (WAN transit — every row late regardless of phase) → OFF 10 s. Longer pole (documented, not built): one-sided episodes — the follower's live state through a sealed-only span is already the post-replay state (hc stores its per-tick digests), so it could answer baseline/POST from stored data and never rewind, halving system replay cost. Not attempted yet: protocol-level change on freshly stabilized machinery. **Two bugs found in a matched-pair soak (2026-08-01), both fixed:** 1. `note_late()` was called unconditionally on every predicted-row resolution, not gated on `pads_differ`. Under normal input-delay prediction EVERY invented row eventually resolves against a real wire row whether or not the guess was right — that's just how delay-based prediction works — so the debt signal was mostly noise. Moved the call after the `pads_differ` computation and gated on it; the busier peer's rewind-request count (28 vs 12 across a matched pair) now cleanly identifies which side is actually mispredicting. 2. The off-guard used "debt continuously nonzero for 5s" → OFF. Soak evidence it was actively harmful: 24/36 episode commits and 19/28 rewind-requests on the busier peer landed AFTER the off-guard fired mid-storm — an active mispredict burst legitimately keeps debt elevated (each edge tops it up before the last slice fully drains), that isn't pacing failing. Replaced with a streak counter: debt landing AT THE CAP with no room to have drained between hits, 12 times in a row, before disabling for 10s. This is the actual "pacing can't help, this is transit" signal. **Depth24 (FMV) present path batched (2026-08-01):** FMV frames showed guest cost roughly double the non-FMV baseline (15–22 ms/f vs 7–13 ms/f) with admit cost a minor fraction — a local compute cost, not a netplay one. `gpu_display_pixel_argb` was called once per output pixel, and for `depth24` did 3 separate `gpu_vram_byte()` calls each recomputing the row/wrap math fresh — 3x the per-pixel work of the 16bpp path, as a function-call chain instead of a straight-line loop. New `gpu_depth24_present_row()` (gpu.c) hoists the per-row invariants (vy, base byte offset, how many pixels fall inside the 2048-byte VRAM row before the black-fill tail) out of the inner loop and inlines the byte extraction — same byte-order shifts as `gpu_vram_byte` (no host-endianness assumption), same output. Wired into both depth24 present call sites in `main.cpp` (Vulkan CPU-readout path and the generic non-hires path); the 16bpp path is untouched. **Unrelated build-system bug found while soaking:** `runtime.cmake`'s `PSXRECOMP_RUNTIME_SOURCES` list was missing `netplay_snap_ring.c`, `netplay_state_digest.c`, `netplay_hash_confirm.c`, `netplay_input_hist.c`, `psx_netplay_rb.c`, and `psx_selfcheck.c` — a stale local edit unrelated to netplay work, likely from the `psxrecomp_add_game_runtime` scaffolding added alongside it. Every rebuild in this session up to this point silently linked STALE prebuilt `.o`s for those 6 units from before the edit (their sources hadn't changed, so ninja never flagged them) — nothing this session touched in those files, so no correctness impact, but a fresh `cmake` reconfigure (e.g. after editing `runtime.cmake` itself, or on a clean clone) would have dropped rollback netplay from the link entirely. Restored the six lines. **Post-A/B soak (2026-08-01) — "never-ending storm" traced to a round-start input-density burst, not a regression.** Full match soak with the `pads_differ` gate + pegged-streak off-guard + batched depth24 present: **zero core diverges, zero aborts**, both peers' episode lists byte-identical (34 commits, ticks 159 and 1006..1400), full recovery to a sustained 60.0 fps for 450+ ticks until voluntary disconnect (`sdl_window_close` / `peer_disconnect`, not a stall). ICE selected `typ host` on both sides (confirmed LAN, not TURN) so link latency is ruled out. All 34 episodes land in one contiguous window starting the tick *right after* `FMV lockstep RELEASE sim=939` hands control back to normal 2-tick-delay prediction — i.e. the moment the round actually starts and both controllers begin moving/attacking at once. Timesync debt during that window stayed tiny (1–8 ms, cap is ~34 ms) and the pacer never neared the cap, so this is not a phase-skew problem the pacer could close faster — it is genuine simultaneous real input density from two controllers exceeding what a 2-tick delay can predict, and it self-resolves once the opening exchange calms down (~30 real seconds at 40–70 fps here). Distinct from earlier storms: no cascade, no divergence, no re-arming after it clears. Also reconfirmed the pre-existing FMV admit cost: 11–15 ms/f *admit* (network/lockstep wait) during FMV playback vs 6–11 ms/f guest (render, post Fix B) — the FMV frame-rate ceiling is now the lockstep admit wait, not GPU present cost. Next candidates, not yet tried: (a) `PSX_NET_DELAY=3` to cut mispredict frequency in exactly these high-density round-start windows (flagged 2026-07-31, never soaked); (b) shrink FMV lockstep per-tick admit wait now that render cost is off the table. **Residual gameplay determinism fork (open, 1 hit / 4000 ticks):** soak 2026-07-31 aborted once at sim=4034 (fight scene). Forensics: identical baseline (epoch=40 load=4033 core=fc779568 both peers), identical sealed pads (s0=ffff s1=ffbf), identical fin cycle count (2277677049), and clk/tim/av/cd/dma/spad/sio/aux all matched — only **cpu** (50d03880 vs 01ccbeaf) and **ram** (88a8e19f vs 58f7e8de) forked. So clocks, IRQ timing, icache, and devices were bit-identical and the GPRs/RAM still diverged — a genuine non-clock replay fork (GTE state? uninitialised read? resim-order RAM write?). Needs a repro before digging; realign recovered it in-band (cooldown, no cascade). **Char-select storm = frozen agreed watermark (fixed):** with the 30 ms grace, menu D-pad was clean (6 late edges all soak, was 29) — but char-select stormed with the peers' live digs IDENTICAL through the whole window. Cause: after a tip-hold commit set `g_agreed_valid`, the hard watermark only advanced at episode commits; hundreds of hc-confirmed clean live ticks were ignored, so every new edge reloaded from an ancient snap (soak: `mismatch=2135 load=1361`) and SPAN CAP crawled 24 ticks per episode while Live galloped ahead — permanently behind. Fix: `advance_agreed_watermark_from_hc()` — outside episodes/TipHold/ pending loads, raise agreed to the newest hc-confirmed interval snap in the ring (span_lo collapses to the new watermark); called from `choose_load_tick` and the follower REFUSE gate (the follower must advance too or the initiator's valid frontier loads get NACKed). The historical false-confirm hazard behind the hard cap (TipHold Live invent FRAME_COMMITs with cleared PCs) is structurally gone — TipHold Live emits no FRAME_COMMITs and the core digest folds full CPU split + csv + icache. Edges now load from ~one interval back and replay a handful of ticks (light tips), like the main-menu path. **Rematch black screen (fixed):** second lobby launch armed invent-lockstep at `sim=0` with `FMV media … depth24=0 mdec=0 xa=0 fmv_pend=1 cd_reading=1 mode=e0 present_pend=1` and never printed another FPS line. Cause: `rb_fmv_media_active` treated `cdrom_fmv_stream_pending()` (`reading && mode&0x48`) as media — MotK boot/LoadExe sets XA mode bits on ordinary CD reads, so rematch locked invent before the first frame. Also soft-exit left `s_present_pending` / flush reentrancy sticky (`gpu_init` does not clear them). Fix: media = depth24 | mdec hysteresis | real XA stream only; `present_session_reset` clears deferred present; rb shutdown clears sticky BB PC + agreed watermark. **Episode cost cut: redundant full-bus digests off the per-tick hot path (2026-08-01).** `delay=3` and `delay=4` soaks showed the same "chain of small, back-to-back episodes" storm shape as `delay=2` — increasing the prediction buffer didn't help, so the fix target moved from network/prediction tuning to what a replayed tick actually *costs*. Found: `log_resim_tick_audit()`'s `"fin"` call (fired once per replayed tick, i.e. every tick of every episode, not just the last) unconditionally computed the *full* bus digest breakdown — `netplay_core_digest_parts` (a full 2 MiB RDRAM CRC plus GPR/COP0/GTE/ clock/timer CRCs), a full 1 MiB VRAM CRC (`netplay_av_digest`), plus CD-ROM/aux/scratchpad/DMA/SIO digests — purely to print it. Worse, on the *last* tick of every episode, `enter_verify_at_tip()` then recomputed core/aux/av **again**, independently, for the real POST digest sent over the wire — so the commit tick paid for two full sweeps back to back. For a storm with dozens of 3–5 tick episodes this was megabytes of pure CRC32 work landing on exactly the ticks already busy re-simulating, compounding guest ms/frame during the exact window players felt as "stormy." Fix (no protocol/behavior change, diagnostics only): `log_resim_tick_audit()` now always takes the cheap path (register + clock/timer + RAM core digest only, same as it already did for the `"arm"` tag) for every replayed tick, `"fin"` included — the per-tick line still shows core/sealed-pad state for triage, just not the full breakdown. The full breakdown moved *into* `enter_verify_at_tip()` itself, computed exactly once, from the exact `dig_cpu` used for the real POST digest, and reused for both diagnostic prints (`rb post parts`, `rb post sent`) and the wire send — eliminating the double sweep on every commit. Net effect: full-bus digest cost drops from "every replayed tick, twice on the last one" to "once per episode." Also added a `replay=NN%%` field to the `PSX_NETPLAY_TIMING=1` `[FPS]` line (`psx_netplay_rb_take_replay_ticks()`) so a soak can directly confirm what fraction of the window was spent resimulating instead of inferring it from episode density in the raw log. Considered a `PSX_RB_VERBOSE_AUDIT` opt-out flag for the remaining once-per-episode full audit print, but skipped it: at episode-count frequency (not tick-count) the residual cost is already small, and every prior determinism bug in this doc (I-cache tags, csv, the Δ8-cycle fork) was found *from* this exact print, so defaulting it off risked trading a small, now-rare cost for reduced forensics on the next one. Not yet re-soaked; next step is confirming this shrinks the storm's guest ms/f without changing episode count/shape. **Re-soak (2026-08-01): confirmed genuine, self-clearing, input-density storm — plus two follow-up ideas investigated, one landed, one shelved.** `replay=NN%%` (new telemetry above) confirmed 50-60% of simulated ticks were spent in Replay during bursts of rapid main-menu D-pad taps, dropping to 0% between bursts — bursty and self-clearing, not a runaway cascade (zero core diverges/aborts across two full logs). Root-caused every rewind to a genuine Up/Down press/release edge (`wire rewind-request ... pub=ffff wire=ffbf` etc.) — confirmed as *inherent* to the current contract: `np_try_admit_rollback()` invents via hold-last **immediately, with zero wait**, whenever the local sim is ≤1 tick ahead of the remote's last-confirmed tip (the normal steady-state operating point on a fast LAN) — raising `delay`/`P` does not change this, since the code never even checks for the real value before inventing at that gap. A discrete edge is therefore always wrong under hold-last and always forces a rewind per the "button deltas always rewind" contract, independent of delay/prediction settings — investigated and shelved, not worth testing further. Also investigated **one-sided episodes** (let the follower answer POST from already-recorded live history instead of doing a full snapshot-load + replay, when its own simulation was never the one that mispredicted). Found a real obstacle: the live per-tick history ring (`s_part_ring` in `psx_netplay.c`) only refreshes VRAM/CD/aux every 32 ticks (`np_emit_frame_commit`'s `crumb` gate) specifically *to avoid* a permanent per-frame VRAM-CRC cost — so answering POST correctly for an arbitrary mismatched tick from stored history would need that data fresh every tick, trading a rare, bursty, ~50ms/f spike for a permanent ~1-2ms/f tax on every frame of ordinary play. Not a free win; shelved pending a cheaper way to get exact per-tick VRAM state (e.g. dirty-region VRAM CRC instead of full-frame). **What did land:** found `accept_peer_baseline()` unconditionally `fprintf`+`fflush`(stderr) on *every* received baseline packet, including every redundant copy of `RB_BASELINE_BURST=8` UDP retransmits sent from up to ~9 call sites across an episode's lifecycle — soak logs showed ~25 identical "rb peer baseline" lines (2932 across 117 episodes in one log) for state that only actually changes once per episode. Each `fflush` is a blocking syscall, firing repeatedly right on the ticks already busy resimulating. Fixed: the accepted state (`g_peer_baseline_ok`/digest/av/aux/ready) still updates unconditionally on every copy (protocol behavior unchanged), but the diagnostic print is now gated on the (epoch, load, dig_m, dig_a, dig_b, dig_c) tuple actually changing since the last log — pure I/O-volume reduction, zero effect on decision logic. Not yet re-soaked. **Engine-level digest cost (2026-08-01): Tier 1 landed, Tiers 2/3 planned.** The above cuts were all "stop doing expensive work more often than needed" within the netplay layer. A separate axis is "make the expensive work itself cheaper" at the engine level, independent of rollback protocol logic — `netplay_av_digest()` CRCs the full 1 MiB VRAM buffer and `netplay_core_digest_parts()` CRCs the full 2 MiB RAM buffer from scratch every time either is called, with no dirty-region shortcut. Three tiers identified, ordered by risk/complexity: **Tier 1 — faster CRC32 (landed).** `runtime/src/crc32.c` used the textbook byte-at-a-time Sarwate table method: one table lookup per byte, each depending on the previous byte's output CRC (a serial dependency chain that stops the CPU overlapping consecutive steps). Replaced with **slicing-by-8**: 8 of those serially-dependent steps are restated as 8 *independent* table lookups (each depends only on an input byte) XORed together, letting the CPU run them out-of-order. Pure implementation swap — same polynomial, same bytes in, bit-identical output — verified by fuzzing the new implementation against the old one over 27k+ cases (exhaustive small lengths 0-40 from 32 offsets × 2 starting-CRC values, 20k random large buffers/offsets/starting-CRCs, 5k chained/incremental folds matching how `netplay_state_digest.c` folds `core`/`aux` together) before replacing it, since a subtle CRC bug here would be exactly the class of silent-corruption bug this doc has spent months chasing. Measured ~5x faster on both VRAM (1 MiB: 1.90ms → 0.38ms) and RAM (2 MiB: 3.78ms → 0.76ms) sized buffers on the dev host. No protocol/wire-format implication — the digest is an opaque comparison value never compared across differently-built peers, and both peers already have to run the identical binary. **Tier 2 — dirty-region digest (planned, not started).** Convert "CRC the whole buffer" into "CRC only what changed since the last digest." **VRAM is the tractable target**: audited every write to the 1 MiB `vram[]` array in `gpu.c` and found only **3 call sites** in the whole GPU emulation (CPU→VRAM transfer ~L2071, pixel plot/fill/line/poly ~L2930, depth24/16bpp write ~L4744) — a small, fully auditable surface where each site could mark a per-scanline or per-tile "touched" bitmap, and `netplay_av_digest()` would CRC only touched regions since the last checkpoint. This is also the cheaper alternative flagged above as the unblocker for "one-sided episodes" (exact per-tick VRAM state without a permanent per-frame CRC tax). **RAM is not a good near-term target the same way**: guest stores happen from essentially every recompiled `sw`/`sh`/`sb` site across the whole program — thousands of call sites, not 3 — so reliable RAM dirty-tracking means threading write-tracking through the recompiler/interpreter's store path itself, a much larger change. (Note: `dirty_ram_interp.h`'s existing per-page dirty bitmap is an unrelated subsystem — self-modifying-code detection for the static recompiler, CLAUDE.md Rule 18 — not a digest optimization and not reusable for this without its own risk analysis.) **Risk to respect:** a missed write site doesn't crash, it silently produces a digest that doesn't reflect true state — masking a real divergence or manufacturing a phantom one. Should ship with a "shadow-verify against a full recompute" mode during bring-up, not on faith. **Tier 3 — Merkle/hash-tree digest (planned, longer-horizon idea).** Replace the flat CRC with fixed-size blocks (e.g. 4 KiB) each hashed, plus a tree of hashes-of-hashes up to a root digest; a write invalidates only its own block's hash and that hash's ancestors (same underlying tracking problem as Tier 2, organized as a tree instead of a flat bitmap). Beyond the performance win, this buys **free divergence localization**: every forensic hunt in this doc (I-cache tags, the Δ8-cycle fork, the open cpu/ram-forked-but- everything-else-matched case) currently starts from "two 32-bit CRCs disagree" and requires manually narrowing via partition digests / sub-CRCs built up over sessions. A hash tree turns that narrowing into an O(log N) walk from the two disagreeing roots down to the differing leaf. Bigger lift than Tier 2; revisit after Tier 2 ships and its audit tooling exists to reuse. -
FMV / depth24: defer rewind (promote wire only); follow NACKs; RB snap load uses light frontend hook (no FMV cutover/present thrash)
-
Multitap / N-slot: history + seal tables sized to
slot_count
8. recomp-ui (branch feat/rollback-netplay)
Tasks
- Host Lobby Settings: Disable Rollback (off by default → rollback on)
- Manual Input Delay + Manual Input Prediction (P locked when rollback off)
- Auto D (RB tiers / delay-sync pad) + auto P at Play from max peer RTT
- Plumb
launch.rollback/input_prediction+ match_caps +net_cfg - Diag: stderr episode begin/commit/load; invent/promote/rewind counters on hist
9. Suggested implementation order
boot_state_save_buffer+ snap ringMaster digest + FRAME_COMMITInvent + input contractEpisode resim wiringLobby flag + UIRate-limit live snaps (PSX_NET_SNAP_INTERVAL) + safe episode resumeThinner snap / FMV policy— interval 16, settle invent ok, digest fold (skip-VRAM on live snaps unsafe for rewind; optional later)- Dual-instance soak — prove Done-when items
10. File touch map
| Area | Files |
|---|---|
| Snap ring | boot_state.*, netplay_snap_ring.*, psx_netplay_rb.*, interrupts.c |
| Digests | netplay_hash_confirm.*, netplay_state_digest.*, psx_netplay.c |
| Invent / contract | netplay_input_hist.*, psx_netplay.c |
| Episode | psx_netplay_rb.*, lib/recomp-net session RB send/take |
| Frame loop | main.cpp (sdl_vblank_present epilogue), psx_netplay.c |
| Caps / UI | psx_lobby_client.*, recomp_launcher.h, launcher_imgui.cpp, main.cpp |
11. Done when
- Two MotK instances with
PSX_NET_MODE=rollbackcomplete a match without admit-stall on one-frame remote loss - Forced remote digital correction opens episode(s); POST digests match (char-select L/R soak: 4 commits, live dig ok after). Tap = press+release = two episodes by input-contract (button deltas always rewind).
hash_confirminvent path shows promotes in diag without opening episodes when master hashes still agree (stick/analog path; not MotK digital buttons — contract always rewinds button deltas)- Delay-sync path (
PSX_NET_MODE=delay) unchanged for lobby rematch / save xfer
Reference: lib/recomp-net/docs/rollback.md,
include/recomp_net/rollback.h, include/recomp_net/input_contract.h,
tests/rollback_episode_test.c.
12. Portable policy: size "wait before invent" budgets from RTT, not local tick cadence
Found on MotK (2026-08-01), but the mistake is generic to any rollback netcode with a "stall briefly, hoping the real remote row arrives, before falling back to prediction" grace mechanism — worth checking for on every future engine port before it ships. Written up here so it travels with the rollback playbook rather than staying MotK-specific tribal knowledge.
The mistake. MotK's np_invent_grace_stall() (runtime/src/psx_netplay.c)
decides how long to block waiting for a genuinely-late remote row before
giving up and hold-last-inventing it. Its budget was max(floor_ms, 2.5 × measured_local_tick_period), capped at 100ms — i.e. it used an EMA of
how long this peer's own ticks have recently been taking as a proxy for
"how patient should I be." That metric is the wrong one to feed a stall
gate: local tick period gets longer precisely when the engine is already
struggling (an active resim burst, a heavy scene, GC/OS jitter) — none of
which has anything to do with how long the network actually needs. The
result is a stall whose size floats up right when the system is already
behind, adding pure idle wait on top of the very slowdown it's reacting to.
Confirmed on a genuine direct-LAN connection (ICE selected typ host
candidates both sides, sub-ms RTT by any reasonable estimate): the
"grace" stall was measured landing at 70-90ms per occurrence — sized as if
this were a lossy WAN link, not because the link needed it, but because the
formula was reading the engine's own local distress as the loneliness of a
lonely remote packet.
Why it partially masqueraded as "working as intended." Lowering the
env-configured floor from 30ms to 5ms did measurably help (a same-scene
soak comparison showed replay-time share dropping ~31%→~25% and total
rewind episodes dropping ~113→~91 over an equal-length session) — so the
mechanism isn't useless, and the fix isn't "delete it." But the improvement
was partial and the storms remained intermittent rather than
disappearing, because the 2.5× scaling term overrides a low floor the
moment tick cadence degrades even slightly — exactly the condition a live
storm creates. A fix that only tunes the floor constant will always leave
this residual: the floor is not the part doing the damage during a burst,
the scaling term is.
The general policy for the next engine. When a rollback implementation needs a "how long do I wait for a late remote row before I predict it" budget:
- Size it from measured network RTT, not from local simulation cadence. Most rollback netcodes already track RTT for something else (auto delay/prediction tiering, connection-quality UI, etc.) — reuse that signal. A LAN connection with sub-ms RTT should get a budget in the low single-digit milliseconds; a WAN connection with 40ms RTT legitimately needs more. Local tick period answers "how is my engine doing", not "how is the link doing", and a wait-for-network gate should only ever ask the second question.
- Audit every adaptive stall/pacing input for the feedback question: "If this stall makes my own performance worse, does that get fed back into a bigger version of the same stall?" Any signal derived from wall-clock frame/tick timing on the same host that is currently stalling is a candidate for this trap. It doesn't have to be obvious — here it was buried inside an otherwise-reasonable "scale grace to the observed tick rate so slow scenes don't false-positive" rationale that made sense in isolation but interacted badly with the stall's own side effect on that same tick rate.
- Keep "wait for late data" (invent grace) and "close phase skew"
(timesync pacing/advantage throttle — MotK's
np_timesync_throttle) as separate mechanisms with separate inputs, even though both are "make the admit loop briefly wait." They answer different questions (is the data late vs. am I running ahead of my peer) and conflating their signals — e.g. letting both key off the same local-tick-cadence EMA — multiplies the risk of the feedback trap in (2) instead of isolating it to one place that's easy to reason about and cap. - Give the mechanism a real ceiling independent of any adaptive term,
and log both the configured floor and the actual applied budget
separately, so a soak can tell "the operator's floor setting" apart
from "what the scaling term inflated it to" — this distinction is what
made the MotK case diagnosable at all (see
psxrecomp: rb invent grace N msat startup vs. the per-stall budget computed innp_invent_grace_stall, which never prints and had to be reconstructed from the tick-period EMA logged by the neighboringnp_timesync_throttleprint). - Validate any fix by holding episode/rewind count and correctness
(zero
post core/aux divergeaborts) constant while comparing perceived latency/replay%— the goal is moving the budget's source signal, not just its magnitude; a naive floor-only tune (as done here first, for speed) is a legitimate stop-gap but should be labeled as partial, not as the fix, in whatever tracking doc records it.
Status for MotK itself: implemented (2026-08-01), not yet re-soaked.
- Replace
np_invent_grace_stall's2.5 × local_tick_emascaling term with a budget derived from measured session RTT, capped low. No existing live RTT signal existed anywhere in the netcode (checkedRNetSessionStats, the ICE layer, and the lobby's one-shot pre-match LAN probe — none fit: the first two don't exist, the third measures a LAN-list endpoint that isn't necessarily this match's peer and doesn't update during play). Rather than add a wire-protocol change (new ping/pong message) or thread a value through the recomp-ui lobby UI (out of scope, and that submodule has its own in-flight uncommitted netplay-launch refactor — touching the same structs risked colliding with it), sourced RTT for free from data already being timestamped:enter_verify_at_tip()(psx_netplay_rb.c) already recordsg_verify_wait_mswhen it sends this peer's POST, for the existing verify-timeout mechanism. Addedg_rb_rtt_ema_ms, sampled at the point the peer's POST is accepted —now - g_verify_wait_mswhen we sent ours first (the common case) is dominated by one-way transit of the peer's packet, not local compute, since both sides enter Verify around the same real-world moment (triggered by the same mismatch/rewind-request). EMA'd (3:1), sanity-bounded (samples over 2s discarded as stale/bogus), exposed viapsx_netplay_rb_rtt_estimate_ms(), reset to 0 on session start/shutdown (rematch) alongside the rest of the per-session RB state. This piggybacks on an existing, already-occurring exchange — no new wire message, no protocol version concern.np_invent_grace_stallnow sizes its budget as1.5 × rttonce a sample exists (hard-capped at 60ms regardless of RTT), falling back to the old tick-cadence term (now 1.5 ticks / 40ms cap, down from 2.5 ticks / 100ms) only in the brief startup window before the first episode has round-tripped. The env-configured floor (PSX_RB_INVENT_GRACE_MS) default also dropped 30→8ms, since the soak showed 5ms already worked and the floor now matters far less than the (fixed) scaling term. - Log the applied per-stall budget directly, not just the configured
floor:
psxrecomp: rb invent grace budget=N ms (floor=F rtt=R tick_ema=T) slot=S wire=W, printed once per tracked wire tick (deduped so a multi-ms stall doesn't spam one line per retry). - Re-soak after RTT-sourced invent grace (2026-08-01): steady-state
improved (
replay%~7% overall / ~3.6% quiet post-FMV; invent budgets 8–22 ms). Remaining cliffs were not invent-grace — they were the Verify POST-loss / tip-extend NACK storm in §13. Note: sampled "rtt" reads ~15–26 ms on direct LAN because the POST handshake wait includes peer Verify compute asymmetry, not pure UDP transit — still a better budget signal than local tick cadence, but not a true ping.
Touched: runtime/src/psx_netplay_rb.c (g_rb_rtt_ema_ms, sampling in the
peer-POST-accepted path, psx_netplay_rb_rtt_estimate_ms(), reset in
psx_netplay_rb_shutdown()), runtime/include/psx_netplay_rb.h (new
declaration), runtime/src/psx_netplay.c (np_invent_grace_stall()
budget formula + floor default + applied-budget log line). Builds clean,
no new lints. np_timesync_throttle intentionally left untouched per
policy point 3 above (separate mechanism, already has its own small fixed
cap and adaptive-off).
13. Verify POST loss → unilateral tip-hold → tip-extend NACK storm (2026-08-01)
Symptoms (RTT-budget soak): long quiet stretches at 60 fps / replay≈0%,
punctuated by cliffs (8.1 then 2.7 fps, admit=108–351 ms). Not invent-
grace — budgets stayed at 22 ms (rtt=15).
Root cause (matched-pair logs at tip=1256 and tip=1437):
- Both peers finish Replay and send identical POSTs.
- Peer A receives B's POST first →
enter_tip_hold→ advancesagreed_throughto the tip → stops retransmitting its own POST (rexmit gate wasphase==Verify && local_post && !peer_post_ok). - Peer B never got A's first POST (single UDP loss, even on direct LAN
typ host) → sits in Verify untilRB_VERIFY_TIMEOUT_MS(4s) →ABORT — verify timeout (peer POST missing)→ realigns to last confirmed tip (e.g. 1425). RB_RESOLVEDthat A sent on tip-hold was ignored for Verify exit —take_rb_resolvedonly calledrnet_rb_set_peer_convergence.- A then opens tip-extend / fresh episodes with
load=the unconfirmed tip (1437). B refuses:follow REFUSED … past frontier=1425+ NACK. - A's NACK handler was plain
abort_episode— no watermark demotion, no cooldown — so A immediately reopenedload=1437→ epochs 7..11 back-to-back NACK abort storm (the2.7 fpscliff).
Fixes landed:
- On
enter_tip_hold, burstRB_POST_BURST(8) copies of local POST + RESOLVED beforeclear_post_handshake, so the lagging peer can still complete Verify after the winner leaves it. - On
take_rb_resolvedwhile stuck in Verify with a matchingg_post_target, accept RESOLVED as the missing handshake half andenter_tip_hold(log:rb verify accept peer RESOLVED). - On peer follow-NACK: demote
agreed_throughbelow the refused load, drop a pin at/above that load,schedule_live_realignto a snap at the demoted tip, andarm_rewind_cooldown_ticksso tip-extend cannot immediately reopen the same unilateral tip.
Re-soak checks: no verify timeout (peer POST missing) on LAN; if one
still appears, the lagging peer should show verify accept peer RESOLVED
instead of a 4s hang; NACK lines should be followed by realign + cooldown,
not begin epoch=N+1 with the same refused load= within milliseconds.
14. TipHold coalesce was pump-spin, not sim/wall time (2026-08-01)
Symptoms (full-match soak after §13): zero verify timeouts / NACKs /
aborts, avg ~57 fps — but 351 rewind-requests / 350 fresh light
episodes (~33 per 1k frames), avg replay% ~25%, and 0 tip-extends.
Every press/release edge (gaps of 3–7 sim ticks) opened a new episode
immediately after tip-hold committed.
Root cause: MotK uses tip_seal_slack = 0, so Live invent stalls at the
sealed tip during TipHold. Finalize counted admit-pump iterations while
sim >= tip. The admit loop spins with wait_recv(1), so "24 quiet frames"
elapsed in a few milliseconds of wall time — long before the paired release
edge arrived — then tip-hold committed and the next edge opened epoch N+1.
Fixes landed:
- Replace pump-frame quiet counter with wall-clock
g_tip_hold_quiet_t0_ms: finalize afterrunwayframes at 60 Hz (runway * 1000 / 60ms, clamped 80–500). Tip-extend resets the timer so an active coalesce keeps the episode open. - TipHold admit: still never invent past tip+slack, but do advance
when every remote wire row is already present — so tip-hold is not a
hard freeze for the whole coalesce window, and sim-time finalize
(
sim > tip_hold_until) can fire when confirmed inputs walk forward.
Re-soak checks: rb tip-extend epoch= / tip-extend FOLLOW should
appear for press→release pairs; rewind-request count and avg replay%
should drop vs the 351 / 25% baseline; tip-hold→commit gaps should be
tens–hundreds of ms, not adjacent log lines.
15. Light-tip depth ceiling vs TipHold coalesce runway (2026-08-01)
Post-§14 soak (LAN, menu nav, delay=4): genuine improvement —
avg 59.1 fps, avg replay% 17% (down from ~25%), 91 rewinds / 6799 frames
(~13.4/1k, down from ~32.7/1k). But tip-extend fired only once in the
whole soak: the presses in this soak were spaced ~800 ms apart on average
(avg gap=47 ticks @ 60 Hz), wider than the 24-tick (~400 ms) TipHold
coalesce window, so §14 rarely got a second edge to merge. The count/cost
drop here is mostly attributable to Tier‑1 CRC + the RTT-sized invent-grace
fix (§12), not coalescing — worth re-checking during dense-input gameplay
(combo strings), not just menu nav, where coalescing should matter more.
Every remaining rewind-request carries a real pub vs wire bit diff (a
genuine opponent input change), confirming these are not spurious — under a
hold-last/idle-invent predictor a real remote button transition necessarily
mispredicts. ICE also confirmed typ host on both sides (not relayed
through the coturn TURN server despite force_turn=1 in the startup line,
which only affects candidate gathering, not which candidate wins), with a
genuine ~10–13 ms measured latency — this is real direct-LAN behavior, not a
transport misconfiguration.
New finding — the two remaining depth knobs had drifted apart:
RNET_RB_LIGHT_TIP_MAX_DEPTH (recomp-net/include/recomp_net/rollback.h)
is a library-wide constant of 16 ticks: episodes at or under that depth
skip the pre-Replay ready-ACK round trip (rnet_rb_is_light_tip_candidate).
§14 widened MotK's TipHold coalesce runway (RB_MOTK_TIP_RUNWAY) to 24
so it could keep absorbing edges longer — but nothing widened the light-tip
ceiling to match. Depth-bucketing every light= episode in the soak showed
a perfectly clean split:
light=1 depths: 1,2,3 ... 16 (73 episodes, all <= 16)
light=0 depths: 17 (x5), 24 (x12) (17 episodes, all > 16)
17 of 90 episodes (19%) in this soak grew past 16 ticks purely from coalescing/span-cap headroom and lost light-tip eligibility, paying an extra ready-ACK round trip on top of the POST-verify round trip both paths already pay — and these are also the deepest rewinds, i.e. already the most visually disruptive ones, now also getting the most expensive handshake.
Fix landed: made the light-tip depth ceiling a per-session config field instead of a single hardcoded constant, and set MotK's session to match its TipHold runway:
recomp-net: addedRNetRbConfig.light_tip_max_depth(0 → library defaultRNET_RB_LIGHT_TIP_MAX_DEPTH, clamped to 32 liketip_runway). Addedrnet_rb_get_light_tip_max_depth()andrnet_rb_is_light_tip_candidate_ex(load, target, resolved_through, max_depth)(explicit-ceiling sibling of the existingrnet_rb_is_light_tip_candidate, which now just calls_exwith the library default so existing callers/tests are unaffected).rnet_rb_recommend_light_tipandrnet_rb_begin_episodenow readcfg.light_tip_max_depthfrom the session instead of the bare constant. New unit tests inrollback_episode_test.ccover: default resolves to the constant,_exwith an explicit wider ceiling accepts a depth the plain wrapper rejects, and a session created withlight_tip_max_depth=24correctly flags a depth-20begin_episodeas light (would not with the untouched default).psx_netplay_rb.c:psx_netplay_rb_start()setscfg.light_tip_max_depth = RB_MOTK_TIP_RUNWAY(24) so the two thresholds can't drift apart again. Both direct pre-flight call sites (initiator + followerbegin_episodepaths) switched fromrnet_rb_is_light_tip_candidatetornet_rb_is_light_tip_candidate_exwithrnet_rb_get_light_tip_max_depth(g_rb)so the precomputed flag matches whatrnet_rb_begin_episodewill compute internally.recomp-netis a submodule (psxrecomp/lib/recomp-net, separate clone from the standalonerecomp-networkspace repo on the samefeat/rollbackbranch) — kept both copies of the touched files (include/recomp_net/rollback.h,src/rollback/rnet_rollback.c,tests/rollback_episode_test.c,docs/rollback.md) byte-identical.
Not changed (deliberately): did not just bump the library constant
RNET_RB_LIGHT_TIP_MAX_DEPTH itself — it's shared across every recomp-net
consumer, not just MotK, so widening it globally would be a cross-project
policy change, not a MotK tuning knob. The per-session cfg field lets
MotK opt in without affecting other hosts that still want the conservative
16-tick default.
Re-soak checks: light=0 should now only appear for episodes that hit
SPAN CAP (depth > RB_MOTK_TIP_RUNWAY), not for every coalesced episode
past depth 16; the light=1/light=0 split should track RB_MOTK_TIP_RUNWAY
(24) instead of the old library default (16). Re-run a soak with denser
input (active gameplay, not just menu nav) to see whether §14's coalescing
actually engages more (tip-extend count > 1) once presses land closer
together than the ~800 ms average seen here — that's the next real lever if
rewind volume is still the complaint, since the per-episode cost items
(Tier 1 CRC, RTT-sized invent grace, this light-tip alignment) are now
largely exhausted. After that, Tier 2 (dirty-region digest, documented
above) is the next compute-cost lever, and it disproportionately helps
exactly the deep/full-verify episodes since they still walk the full
2 MiB RAM + 1 MiB VRAM CRC at Verify regardless of light/full status.
16. Follow-NACK realign fork + HC re-poison + asymmetric light-tip (2026-08-01)
Post-§15 soak (LAN, menu nav, delay=4): §15 worked — 44/44 episodes
light=1 including depths 17 and 24; avg replay% 13% (down from 17%);
~8.9 rewinds/1k frames. Then a late cliff: host 4.0 fps admit=235 ms,
peer 5.9 fps, ready timeout (initiator never sent GO), resim core diverge sim=3841, and peer follow NACK load=3856 / promote-no-resim reason=cooldown spam.
Root cause chain (matched-pair at tip=3840):
- Both peers commit through 3840 (matched POST).
- Peer HC-advances
agreed 3840→3856and opensepoch=59 load=3856. - Host still has frontier 3840 →
follow REFUSED … past frontier=3840- NACK. (The follow-up
snap missingline fromsend_follow_nackis misleading — refuse reason was the frontier check.)
- NACK. (The follow-up
- Peer's NACK handler demoted the watermark and always
schedule_live_realign(demote), even though the refused load snap was never applied (still in SealInputs). That rewound ~25 ticks of good matched Live on only the peer → silent core fork at demote+1, while host kept running Live ahead. - Cooldown after that realign made late wire
promote-no-resim— peer could not correct invents during catch-up. - Peer then immediately re-ran
agreed ADVANCE 3840→3856from stale HC (demote did notnetplay_hc_prime_after/ demote libraryresolved_through) — same poison, next episode. - Host's agreed stayed stuck at 3840 for ~229 ticks (peer digests no
longer confirmed). Host finally opened
SPAN CAP mismatch=4069 load=3840 target→3864as light=1 and skipped ready-ACK without emitting GO. - Peer followed
load=3840but itsresolved_throughwas 3856 → not light → waited for initiator GO →ready timeoutafter 4s (the admit cliff). Host aborted withresim core diverge sim=3841while in Verify — leftover of the earlier Live fork.
Fixes landed:
- NACK keep-live when snap never applied: capture
g_episode_snap_appliedbeforeabort_episode; onlyschedule_live_realign(demote)when the refused load was actually applied. Otherwise logNACK keep-liveand stay on the pre-episode Live tip (symmetric with the NACK-sender, who never left Live). - Demote + prime HC: on NACK, demote
g_agreed_through/g_agreed_span_lo, call newrnet_rb_demote_resolved_through(g_rb, demote)(library watermark survivessession_resetandset_peer_convergenceonly advances), andnetplay_hc_prime_after(hc, demote)so live hash_confirm cannot immediately re-ADVANCE past the refused tip. - Light-tip still emits ready/GO: skip waiting for the RTT (the
light-tip win) but still
send_baseline_burst(1, …)so a peer that classified the same episode as non-light is not stranded untilRB_READY_TIMEOUT_MS. Log line:light-tip skip wait … (still emitted ready/GO for asymmetric peer). - Unit test for
rnet_rb_demote_resolved_through(advance / demote / no-op / survivessession_reset). Synced into both the MotK submodule and the standalonerecomp-netcheckout.
Re-soak checks: after a follow REFUSED … past frontier / NACK, look
for NACK keep-live (not an immediate realign to demote) when the
initiator never applied the load snap; no immediate
agreed ADVANCE demote→refused_load on the next pump; no
ready timeout (initiator never sent GO) when one side light-skips; host
and peer live digests at the same sim should stay matched after a NACK
instead of forking for hundreds of ticks then SPAN-CAP from a stale
frontier.
17. Tip-hold Live-walk held dpad → double menu inputs (2026-08-01)
Post-§16 soak (LAN, menu nav, delay=4): §16 healthy — 0 aborts /
NACKs / ready-timeouts / promote-no-resim; ~61 fps; replay% ~17%. UX
still broken: dpad press/release felt like double inputs, tip-extend
stayed at 0, and every tip-hold→commit pair was still adjacent
(runway consumed as Live, not as wait-for-release).
Root cause (not fake mismatches — every rewind was a real edge):
- Tip-hold Live-walked held digital. After a press episode POST-matched,
tip-hold entered with
invent_slack=0anduntil = tip + 24. Admit advanced past invent-cap whenever remote wire was present — on a held dpad that walked ~24 Live frames with the button still down, then finalized viasim > tip_hold_until. MotK menus key-repeat on hold → ~24 extra navigations from one physical press. The release arrived after tip-hold already committed → second episode; coalesce never saw it (tip-extend=0). - Release episodes re-simulated several held frames (often 6–10) before the release tip — another held run after the tip-hold walk.
- Ghost second release: after a real release commit, predicted hist
ahead still showed the button held (
pub=ffdf wire=ffffagain a few dozen ticks later). Tip-hold enter/finalize also did notnetplay_hc_prime_after(tip), so HC could re-agreed ADVANCEover invent-poisoned ticks (soak: release tip=1607 thenagreed ADVANCE 1607→1632).
Fixes landed:
- Tip-hold invent-cap stall while digital held: past
tip+slack, admit advances only when every remote wire row is present and all pads (local + remote) are idle (buttons == 0xFFFF). Held → stall so wall-clock quiet / peek-ahead coalesce owns the runway. - HC prime on tip-hold enter + finalize:
netplay_hc_prime_after(hc, tip)(+set_peer_convergence) so invent-hold FRAME_COMMITs past the tip cannot immediately re-ADVANCE. - Scrub-ahead on digital release promote: rewrite predicted hist
release_tick+1..simto the released pad (rb scrub-ahead release). - Tip-hold coalesce-ahead: while tip-holding, peek wire
tip+1..tip+runway(no predicted hist required); on first pad delta vs tip hist, promote the span andtip_extend(rb tip-hold coalesce-ahead). TipHold tip-extend now rereplays whenmismatch_tick > old_targeteven if Live never left the tip (sim == old_target). - Getter
psx_netplay_rb_tip_runway()for the host scan.
Re-soak checks: dpad tap should move the cursor once; look for
tip-hold coalesce-ahead / tip-extend on press→release within the
runway; scrub-ahead release after release promotes; no back-to-back
ghost pub=held wire=ffff release episodes; no
agreed ADVANCE tip→tip+N immediately after tip-hold enter/commit from
stale HC; tip-hold→commit should often be non-adjacent when a release
extends the tip.