mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
5019 lines
269 KiB
Markdown
5019 lines
269 KiB
Markdown
# 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). Gap=1 uses a **short** invent grace before hold-last (§23);
|
||
gap≥2 uses the full §21 budget. 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 game version + guest semantics** | Prefer matching commits/deps. **Per-peer PGO is OK** (Settings → Optimize FMV Playback). Mixed trees/zlib still fork — size alone is not proof of match |
|
||
| 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 (`P = 4 + D`, see §22); optional Manual Input Delay / Prediction.
|
||
Adaptive mid-match delay bumps are always on (no lobby disable).
|
||
|
||
---
|
||
|
||
## 1. In-memory snapshot ring (host)
|
||
|
||
### Tasks
|
||
|
||
- [x] Add `boot_state_save_buffer` (or serialize-to-malloc) mirroring load-buffer
|
||
- [x] 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
|
||
- [x] Capture at a **safe** boundary — `psx_netplay_poll_snap` beside
|
||
`savestate_poll` on slow + MotK IRQ fast/mid paths (`interrupts.c`);
|
||
also flush pending snap in `finish_frame` (FMV mid-path used to skip it)
|
||
- [x] On load: `psx_cycles_resync_after_restore` / `interrupts_resync_after_restore`
|
||
/ `cdrom_accelerate_after_savestate` / `psx_frontend_on_savestate_loaded`
|
||
/ deferred `psx_scheduler_resume_at` via `psx_netplay_rb_flush_resume`
|
||
- [x] **Resume safety:** never `longjmp` from `rb_pump` under C++ vblank RAII —
|
||
apply without resume, then `flush_resume` on BB-edge (`poll_snap`) or
|
||
inside the admit wait loop (after present-body RAII destroyed)
|
||
- [x] **Admit/resume split:** `try_admit` never arms `needs_advance` while a
|
||
resume is pending or while still in `AwaitingBaseline`
|
||
- [x] **Baseline gate:** send/enter Replay only after `g_episode_snap_applied`
|
||
(peer BASELINE can arrive during SealInputs — must not skip restore)
|
||
- [x] Live snap rate-limit: every `PSX_NET_SNAP_INTERVAL` ticks (default **16**,
|
||
max 32); resim still snaps every tick. Ring uses **raw** `boot_state`
|
||
(no zlib) — `compress2` on RAM+VRAM+SPU was the live FPS tax; disk `.pst`
|
||
still zlib. (Skipping VRAM on live snaps is unsafe for rewind.)
|
||
- [x] Snap PC must be `psx_is_dispatchable` — pick IRQ/BB-edge resume PC (not
|
||
`cpu->pc` at present, often 0); defer save / abort episode instead of
|
||
`resume_at(0)` → `trap_crash`
|
||
- [x] `flush_resume` only in **Replay** (after both baselines); SealInputs
|
||
retransmits SEAL_ROWS; seal `get_input_row` invents hold-last so rows
|
||
are `is_valid` (peer mask never completes on empty exports)
|
||
- [x] Live `g_np.needs_advance` must not bypass `rb_try_admit` during an
|
||
episode (follower spun uncapped with zero `finish_frame`); arm load_tick
|
||
at Replay entry so the post-`flush_resume` quantum is committed
|
||
- [x] Retransmit BASELINE while AwaitingBaseline (TURN drops one-shots);
|
||
follower marks `digest_a=READY` once it has the peer digest; initiator
|
||
waits for that ready-ACK before Replay (no solo resim)
|
||
- [x] Retransmit POST while Verify; admit stalls until peer POST / commit
|
||
- [x] `load_tick` tip slack (one `snap_interval` behind newest) so lagging
|
||
peer still has the snap; follow refuse → SYNC `initiator=0` NACK;
|
||
initiator aborts SealInputs (plus 4s seal timeout)
|
||
- [x] SEAL_ROWS: stash if episode not open yet (same TURN/UDP reordering race
|
||
as BASELINE below — the initiator's `export_local_seals()` burst can
|
||
beat a delayed SYNC BEGIN through the relay); unstashed on
|
||
`begin_follower` open, keyed by epoch (2026-08-01 scheduler asymmetry
|
||
review gap-close).
|
||
- [x] BASELINE: stash if episode not open yet; burst rexmit on TURN; initiator
|
||
ready-timeout → Replay (Verify still waits for peer POST)
|
||
- [x] Resume PC: reject low/vector junk (`0xB0` etc.); prefer function entries;
|
||
rewrite on load; 5s replay stall + 4s verify POST timeout → abort
|
||
- [x] 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)
|
||
- [x] 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)
|
||
- [x] Never apply baseline/realign snaps from mid-guest `psx_netplay_pump`
|
||
(cycle watchdog) — only admit-wait / poll+immediate `flush_resume`;
|
||
initiator waits for ready-ACK (no solo Replay); ready timeout → realign
|
||
- [x] Pin episode baseline snap (resim must not overwrite load_tick); realign
|
||
loads the pin; audit logs `core=` vs `cd=`; skip CD boost + reset SPU CD
|
||
FIFO on RB restore
|
||
- [x] Baseline/POST/hash_confirm agree on **core** digest only (`cd=` audit);
|
||
CD-only forks were aborting good title/menu Start corrections
|
||
- [x] 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` (no `hash_confirm` promote for buttons).
|
||
A tap used to be two episodes (press + release). Fixed in recomp-net:
|
||
**tip-extend** (`rnet_rb_extend_target` / MotK `psx_netplay_rb_tip_extend`)
|
||
+ **TipHold** (`rnet_rb_enter_tip_hold` after POST match: Live continues,
|
||
`tip_runway` quiet 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 always `arm_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 → false
|
||
`resim 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 once
|
||
`sim > 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 counts `tip_runway` (MotK 24) pump frames at the invent
|
||
cap when sim cannot reach the old `tip_hold_until`.
|
||
**Seal span cap (menu hang):** peer-seal completion is a `uint64`
|
||
bitmask (`RNET_RB_PEER_SEAL_MASK_BITS` = 64). Tip-extend past
|
||
`seal_base+63` fails; TipHold used to `begin_refused` until runway
|
||
then open ep N+1 alone → `seal timeout`. Fix: TipHold span-cap
|
||
tip-hold-commits immediately and `begin_rewind` opens 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_POST` carries `target_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_test`
|
||
tip binding.
|
||
Post-TipHold `choose_load_tick` / follower frontier hard-cap at
|
||
`agreed_through` — ignore `hash_confirm` above 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`.
|
||
- [x] **Abort cooldown from live sim** (before realign rewinds the clock).
|
||
Old path armed `until` from 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").
|
||
**§47:** SPAN chunks stay under Replay ownership — Verify is a digest
|
||
barrier between chunks; TipHold is not part of confirmed catch-up.
|
||
Reconcile promotes wire for the whole abort window (`promote-no-resim`).
|
||
Clean commit does **not** arm cooldown.
|
||
- [x] **§47 Replay ownership contract** (contiguous confirmed frontier):
|
||
Replay owns forward progress until no further contiguous confirmed
|
||
simulation is possible; must never wait on already-confirmed inputs.
|
||
Local tip production continues during Replay (protocol peer).
|
||
POST match → chain next SPAN or Final Verify→Live (no TipHold invent-cap
|
||
mid catch-up). Long catch-up periodically presents live Replay VRAM.
|
||
- [x] **§49/§73 WAN chain/GO/realign:** ownership chain if `frontier > tip+4`
|
||
(`PSX_RB_CHAIN_MIN_AHEAD`; was tip+8 — §73 bisect); initiator keeps GO
|
||
until peer POST; ready timeout RTT-scaled; INPUT resends from peer ACK
|
||
when behind tip-redundancy. (§49b: do **not** clear remote tips on Live
|
||
realign — tip=0 pcap freeze.)
|
||
- [x] **§50 FMV tip-extend gate:** do not tip-extend / FOLLOW-raise through
|
||
FMV media or settle (same window as begin/follow refuse). TipHold + FMV
|
||
→ commit sealed tip; mid-FMV TipHold entry also commits. Stops host
|
||
rereplay past guest ownership-final Live → ABANDON `bfc03c04` core/CD fork.
|
||
- [x] **§51 choose_load / Replay layers:** (1) newest provably-safe snap —
|
||
peer RESOLVED must not clamp below HC floor; (2) inclusive commit-tip
|
||
span over `%iv` fallback; (3) ownership SPAN continue skip-snap (no
|
||
reload); (4) catchup present ~150ms cadence, not every resim frame.
|
||
- [x] **Present only at MotK wait CDA0:** netplay `gpu_vblank_flush_present`
|
||
skips while resume/check PC is `0x8006CD54` (leave pending until
|
||
`0x8006CDA0`) so sealed idle resim cannot digest opposite ping-pong
|
||
edges (cyc±9 / v0 fork after mid-handler present fix).
|
||
- [x] Netplay **CPU-authoritative VRAM** — software raster owns guest VRAM
|
||
(snaps/`av=`). OpenGL dual-raster keeps SW@1× authority + GL@Nx FBO
|
||
present (`g_gl_fbo_present=1`); never `glReadPixels`. Vulkan netplay
|
||
still uses a software window. baseline/POST agree on `av=` via dig_b /
|
||
POST input_digest
|
||
- [x] Netplay GPU lock **before snaps** + late re-arm keeps GL present when
|
||
OpenGL was requested (no teardown → black first match). Rematch clears
|
||
the lock on lobby soft-return. Pure-software still builds `SDL_Renderer`
|
||
when no GL context exists.
|
||
- [x] Netplay **SW sim scale 1** — dual-raster keeps SW@1×; GL hr FBO uses
|
||
launcher supersampling for present quality. SW-only netplay stays
|
||
native scanout + upscale (no `gr_render_display_hires`).
|
||
- [x] **§92 dual-raster present quality** — GP0 fan-out SW+GL; restage FBO
|
||
after snap; digests CRC CPU VRAM only
|
||
- [x] OpenGL resim **hold-last V-flip** — `HOLD_DRAWABLE` presents without
|
||
re-applying `PRESENT_VS` V-flip (was one upside-down frame on Replay
|
||
load until the next live CPU present).
|
||
- [x] **§91 tip-extend / ownership Live asymmetry** — honor peer `OP_COMMIT`
|
||
while still TipHold/Replay (not only Verify); snap fallback if
|
||
SNAP-STALE dropped extend_from; refuse tip-extend once peer committed
|
||
this epoch (stops verify-timeout + peer `pcap_freeze` hang).
|
||
- [x] Mid-FMV tip load: `cdrom_resync_deadlines_after_restore`; do not wipe SPU
|
||
CD FIFO while XA/FMV pending; FMV media includes `cdrom_fmv_stream_pending`
|
||
(XA mode+reading) so invent-off arms before first MDEC colour decode;
|
||
`mdec_recently_active` uses guest-cycle age (host `s_frame_count` lied
|
||
under present-skip / Replay). RB frontend hook resets depth24 cutover
|
||
when media is already live (avoid permanent cutover blank).
|
||
- [x] `flush_resume` releases 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 arms
|
||
`load+1` with present-after-IRQ on netplay BB edges.
|
||
- [x] Netplay depth24: present 1/4 vblanks; skip live FRAME_COMMIT
|
||
(full-RAM CRC) + prime `hash_confirm` when media ends
|
||
- [x] 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
|
||
- [x] MDEC snap `last_color_age` is guest-cycle relative (was host
|
||
`s_frame_count` → false baseline aux trips with matched FIFO/SPU)
|
||
- [x] Diag: `rb wire promote-no-resim` / `rewind-request` — late wire into hist
|
||
without resim (cooldown/FMV/sweep) vs real episode open / begin refused
|
||
- [x] Diag: `rb live dig` every 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
|
||
- [x] 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 print `ext=` + raw
|
||
`cd=`/`aux=`/`spad=`/`dma=`/`sio=`. Zero host `last_sector_frame` on CD
|
||
snap wire. mid-Replay FRAME_COMMIT abort on core mismatch (no false
|
||
POST); `rb audit fin` + abort dump parts + bus digests + `cpu-split`
|
||
- [x] **SIO resim fork:** `sioP` showed **fsm-only** forks with matched
|
||
regs/pads/mc + bit-identical guest. Fixes: (1) `sio_pace_walk` no longer
|
||
drops leftover cycles after a transition cap (peers batching advances
|
||
differently left divergent shift/ack remainders); (2) reseat
|
||
`sio_trace_seq` on snap load; (3) netplay `sio=` / 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`
|
||
- [x] Replay entry: `hc_prime_after(load-1)` drops live invent commits (false
|
||
`resim core diverge`); first `ready=1` baseline burst bypasses rate limit
|
||
(initiator was ready-timeout while follower solo-Replayed)
|
||
- [x] Present-edge digests clear PC (FRAME_COMMIT / audit fin / POST) — parked
|
||
`cpu->pc=0` vs live BB + host-local sticky forked dig_cpu with matched
|
||
RAM/clk; `rb cpu-split` logs gpr/hi_lo/cop0 vs raw_pc
|
||
- [x] **Core digest folds GTE** (was snap-only) — MFC2 can fork GPRs with
|
||
matched RAM/clk/SIO; `rb cpu-split` adds `gte=` + `rb gpr-dump` on
|
||
fin/abort/post so peers can diff which regs forked
|
||
- [x] **Core digest = fold of part digests** (one RAM CRC pass, not dual
|
||
stream); `core=` values change vs older binaries — peers must match
|
||
- [x] Log `rb binary path=… size=…` at rollback start — **peers must run the
|
||
same bit-identical binary**. Diags showed host `build-release/` vs guest
|
||
`motk-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)
|
||
- [x] FPS: raw in-memory snaps + default interval 8; resim audit skips AV/CD
|
||
on `arm` (VRAM CRC only on `fin` / baseline / POST). Still open: strip
|
||
CDROM/MDEC from MotK ring if match path allows (further RAM headroom —
|
||
raw ring ≈ 3.5 MiB × depth)
|
||
- [x] **MotK menu v0-only Replay fork:** matched baseline + tick N (all GPRs),
|
||
tick N+1 only `r2` differs (host `1` / guest `0x5bd2`) with sticky
|
||
`0x8006CDA0` wait-loop, matched RAM/GTE/cycles. BIOS left `v0=1` when
|
||
same-thread GPR restore skipped (mode-1 PC heuristic miss) while peer
|
||
restored the post-`lw` countdown. Fix: netplay auto
|
||
`PSX_SAME_THREAD_RESTORE=3` — same-TCB RFE/SYSCALL always restores
|
||
(ChangeThread still skips). Diag: `rb irqctx` on fin/abort with
|
||
`restored`/`v0_exit`/`v0_saved`. Do **not** canonicalize v0 in digests.
|
||
- [x] **Netplay BB-edge present:** after restore-3, idle sealed resim still
|
||
forked `cpu`+`ram` in one tick — `sdl_vblank_present`/`finish_frame` ran
|
||
nested from `fire_vblank_edge` mid-`psx_cyc_step` in the wait loop, so
|
||
peers digested different instr points. Fix: under netplay, queue the
|
||
GPU vblank callback and flush in `psx_check_interrupts` (BB edge);
|
||
clear deferred pending on snap resync. Offline still presents immediately.
|
||
- [x] **Card/save coexistence (BB-edge present hold):** BB-edge
|
||
`finish_frame` mid memcard busy-wait wedges Ape Escape's card-check
|
||
scene (empty starfield) and the same class of titles. Hold
|
||
`gpu_vblank_flush_present` while `sio_hold_present_for_card()` (SIO card
|
||
FSM busy + progress, stale escape ~10 VB) and while a deferred
|
||
cooperative ChangeThread is pending. Keep `s_present_pending`; MotK
|
||
menu-wait drain still runs outside card/save traffic. Do **not** remove
|
||
BB-edge commit — MotK sealed resim still needs it.
|
||
- [x] **Menu wait resim phase (CD54↔CDA0):** after present-guard fix, ep1
|
||
matched fin@load then forked fin@load+1 (`r2=1` vs countdown, cyc±5).
|
||
Cause: flush_resume armed deferred present for latched I_STAT → phantom
|
||
finish_frame before latched VBlank IRQ. Fix: arm `load+1` only; no
|
||
latched re-arm; present-after-IRQ when delivery is due.
|
||
- [x] **Mid-handler present flush:** sealed Cross tip still forked fin@load+2
|
||
(`v0=1` vs countdown, cyc±14) with matched pads/SIO/baseline; irqctx
|
||
stuck at `restored=0/reason=0`. Cause: handler BB edges flushed deferred
|
||
present while `in_exception` (IEc clear). Fix: skip present flush until
|
||
outer delivery returns; `gpu_vblank_flush_present` also refuses
|
||
`in_exception`.
|
||
- [x] **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.
|
||
- [x] **Returning-leaf resume crash:** after load+1 fix, Start-class episode
|
||
resumed at `0x8006A9F8` (`jr $ra` leaf) with IEc=0 → top-level
|
||
`execution completed, PC=0` (cps: `final_ra=0x8006CCA0`). Fix: resume
|
||
PC pick prefers IRQ/sticky/`$ra`; clear present+bb_defer on flush;
|
||
no sentinel `pc=0` while `top_level_resume`; scheduler null-pc recover.
|
||
- [ ] Memory budget / thinner snap: optional strip CDROM/MDEC if MotK match
|
||
path allows (further RAM headroom)
|
||
- [x] 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
|
||
|
||
- [x] Define MotK master digest (`netplay_master_digest` + CDROM partition)
|
||
- [x] After each committed sim tick, compute `digest[tick]` into a small ring
|
||
- [x] Exchange `RNET_PKT_RB_FRAME_COMMIT` via session send/take (queued)
|
||
- [x] Advance local `resolved_through` only when digests match through `T`
|
||
- [x] Implement `psx_netplay_hash_confirm_through(tick)`
|
||
- [x] Wire `hash_confirm_promote` in invent/contract + episode stick gates
|
||
- [x] Unit test: `runtime/tests/test_netplay_hash_confirm.c`
|
||
|
||
---
|
||
|
||
## 3. Input history + invent / prediction
|
||
|
||
### Tasks
|
||
|
||
- [x] Per-slot input history ring (`netplay_input_hist`)
|
||
- [x] Local path: `is_predicted = 0`
|
||
- [x] Remote invent = **hold-last** (neutral if no prior); stall when ahead of
|
||
remote tip by > P. Seal `get_input_row` gap-fill remains **idle**.
|
||
- [x] Late wire → `rnet_input_contract_stick_replace_decide` + `hash_confirm_promote`
|
||
- [x] Rewind → `psx_netplay_rb_begin_rewind` (episode)
|
||
- [x] `get_input_row` vtable → `netplay_ih_get`
|
||
- [x] `PsxNetPad` ↔ `RNetRbFrame` (incl. `analog` → SIO pad type; seal wire `source`); SIO from history / sealed rows
|
||
- [x] Unit test: `runtime/tests/test_netplay_input_hist.c`
|
||
- [x] Env: `PSX_NET_MODE=delay|rollback` → `psx_netplay_rollback_mode()`
|
||
|
||
---
|
||
|
||
## 4. Live frame loop (replace admit barrier when rollback on)
|
||
|
||
### Tasks
|
||
|
||
- [x] Branch `poll_admit` on rollback (invent; episode admit while active)
|
||
- [x] Never call delay-sync `try_admit` wait for missing remotes in rollback mode
|
||
- [x] Still use `RNetSession` for pad tip transport + ICE
|
||
- [x] Keep load-barrier / save-xfer / soft-exit paths on delay-sync semantics
|
||
- [x] `finish_frame` requests snap; episode path uses `psx_netplay_rb_finish_frame`
|
||
- [x] Skip wall pacer while `psx_netplay_is_resimulating()`
|
||
|
||
---
|
||
|
||
## 5. Episode path (resim)
|
||
|
||
### Tasks
|
||
|
||
- [x] Create `RNetRbSession` in `psx_netplay_rb.c` when mode=rollback
|
||
- [x] Fill `RNetRollbackVTable` (snap save/load, digest, hist get_input_row)
|
||
- [x] On rewind: `load_tick` = newest ring tick ≤ mismatch (refuse if ring empty)
|
||
- [x] `g_np.local_slot` / `slot_count` / delay set **before** `rb_start` (frozen into `RNetRbConfig`)
|
||
- [x] Peer seal apply ignores `is_valid=0` rows (wrong-seat export must not complete)
|
||
- [x] Seal export/apply over `RNET_PKT_RB_SEAL_ROWS` (+ SYNC/BASELINE/POST/RESOLVED)
|
||
- [x] During resim: skip wall pacer; publish sealed SIO (not live invent)
|
||
- [x] After commit: `rnet_rb_on_post_match` + session sim clock to `target+1`
|
||
- [ ] Soak: forced stick mismatch → one episode; digests match post-commit
|
||
|
||
---
|
||
|
||
## 6. Wire / transport mapping
|
||
|
||
### Tasks
|
||
|
||
- [x] Session send/take for SYNC / SEAL_ROWS / BASELINE / POST / RESOLVED /
|
||
FRAME_COMMIT
|
||
- [x] Delay-sync peers ignore opcodes 20–25 (session queues only when MotK drains)
|
||
- [x] Negotiation: `match_caps.rollback` + launch.`rollback` / `PSX_NET_MODE`
|
||
|
||
---
|
||
|
||
## 7. Determinism prerequisites (MotK-specific)
|
||
|
||
### Tasks
|
||
|
||
- [x] Netplay clears mods (`mod_runtime_clear_for_netplay` / launcher hook;
|
||
main skips `mod_runtime_commit` when `net_cfg.enabled`)
|
||
- [x] Same BIOS stem + disc identity on both peers (existing verify)
|
||
- [x] 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 as `restore-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; sticky
|
||
`text_diverged` / kernel-bless / overlay validation memos cleared on
|
||
RAM restore; **span-end load** via `psx_selfcheck_flush_load` after
|
||
present-body RAII (BB fast-poll tails forked #2/#3 load sites);
|
||
snap PC uses RB `resume_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` (+ optional `MASH_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) left `resolved_through` stuck; once that tick aged
|
||
out of the 128-slot dig ring, `choose_load_tick` fell 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 to `CDA0`.
|
||
**Agreed tip aged out of snap ring:** after tip-hold commit, Live can
|
||
advance ~900 ticks with matching FRAME_COMMITs (`hc=1824`) while
|
||
`agreed_through=919` falls below `oldest` — begin REFUSED forever
|
||
(`no confirmed snap`), invent≠wire never resims, cores fork with
|
||
matched clocks. Fix: `heal_agreed_watermark_if_aged_out` raises
|
||
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 local` is a breadcrumb (not peer match).
|
||
**BOOTSTRAP when tip-hold never ran:** doomed early FMV abort left
|
||
`agreed` invalid and `hc` stuck below ring oldest (live core forks
|
||
block `heal_stale_gap`). Remote saw host P1 on the wire (`pub=ffff
|
||
wire=ffbf`) but `begin REFUSED` forever — 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):** after `agreed 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_resync` zeroed
|
||
`cycles_since_vblank` (not in snap/digest) while timers/LCF kept mid-frame
|
||
phase; CDA0-only present treated latch `pc==0` as presentable; Replay
|
||
tip-apply from BB poll mutated CPU under live CPS. Fix: persist csv in
|
||
`BS_SEC_IRQ` (12B), stop zeroing on resync, fold csv into clk digest,
|
||
present only at CDA0 (sticky/last fallback), episode loads only via
|
||
`try_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; dig1 `fin@1202` and `fin@1203` shared identical dig/cyc
|
||
(double `finish_frame`, no guest advance). Cause: sticky I_STAT kept
|
||
`delivery_needed` so 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); coalesce `s_present_pending` to 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: entry `gpu_vblank_flush_present`
|
||
ran before `s_last_interrupt_check_pc` was updated, so MotK gate saw stale
|
||
`last==CD54` on 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 no `FMV 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 media` heartbeat 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 cyc `509725487` vs `509160985` (Δ≈564480 = exactly one VBlank),
|
||
wait countdown `r21=0x704` vs `0x705`, `v0=5c88` vs `5c89`. One peer
|
||
delivered one extra VBlank during replay. Cause: the CD54 delivery-hold
|
||
was gated on `gpu_vblank_present_pending()` — `s_present_pending` is
|
||
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 (tracking landed for snaps §96;
|
||
digest fold still planned).** Convert "CRC the whole buffer" into
|
||
"CRC only what changed since the last digest." **VRAM tracking is
|
||
in:** `gpu_vram_dirty.*` marks per-scanline dirtiness from
|
||
`gpu.c` (raster_pixel / GP0 A0) and `gpu_sw_renderer.c` (put_*/
|
||
fill/copy/transfer). Raw ring snaps already consume this (§96).
|
||
Next: `netplay_av_digest()` CRC only touched rows since the last
|
||
checkpoint (unblocker for one-sided episodes). **RAM is not a good
|
||
near-term target the same way**: guest stores happen from essentially
|
||
every recompiled `sw`/`sh`/`sb` site — thousands of call sites, not a
|
||
handful — so reliable RAM dirty-tracking means threading write-tracking
|
||
through the recompiler/interpreter store path. (Note:
|
||
`dirty_ram_interp.h`'s per-page bitmap is SMC detection — CLAUDE.md
|
||
Rule 18 — not a digest optimization.) **Risk:** a missed write site
|
||
silently produces a wrong digest. Shadow-verify:
|
||
`PSX_NET_VRAM_DIRTY_VERIFY=1`.
|
||
|
||
**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.
|
||
- [x] FMV / depth24: defer rewind (promote wire only); follow NACKs; RB snap
|
||
load uses light frontend hook (no FMV cutover/present thrash)
|
||
- [x] Multitap / N-slot: history + seal tables sized to `slot_count`
|
||
|
||
---
|
||
|
||
## 8. recomp-ui (branch `feat/rollback-netplay`)
|
||
|
||
### Tasks
|
||
|
||
- [x] Host Lobby Settings: **Disable Rollback** (off by default → rollback on)
|
||
- [x] Manual Input Delay + Manual Input Prediction (P locked when rollback off)
|
||
- [x] Auto D (RB tiers / delay-sync pad) + auto P at Play from max peer RTT
|
||
- [x] Plumb `launch.rollback` / `input_prediction` + match_caps + `net_cfg`
|
||
- [x] Diag: stderr episode begin/commit/load; invent/promote/rewind counters on hist
|
||
|
||
---
|
||
|
||
## 9. Suggested implementation order
|
||
|
||
1. ~~`boot_state_save_buffer` + snap ring~~
|
||
2. ~~Master digest + FRAME_COMMIT~~
|
||
3. ~~Invent + input contract~~
|
||
4. ~~Episode resim wiring~~
|
||
5. ~~Lobby flag + UI~~
|
||
6. ~~Rate-limit live snaps (`PSX_NET_SNAP_INTERVAL`) + safe episode resume~~
|
||
7. ~~Thinner snap / FMV policy~~ — interval **16**, settle invent ok; §96
|
||
dirty-VRAM mirror + FMV media snap interval 4 (skip-VRAM still unsafe)
|
||
8. **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=rollback` complete a match without
|
||
admit-stall on one-frame remote loss
|
||
- [x] 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_confirm` invent 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)
|
||
- [x] 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:
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **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.
|
||
4. **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 ms` at startup vs. the per-stall budget computed in
|
||
`np_invent_grace_stall`, which never prints and had to be reconstructed
|
||
from the tick-period EMA logged by the neighboring `np_timesync_throttle`
|
||
print).
|
||
5. **Validate any fix by holding episode/rewind count and correctness
|
||
(zero `post core/aux diverge` aborts) 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.**
|
||
|
||
- [x] Replace `np_invent_grace_stall`'s `2.5 × local_tick_ema` scaling term
|
||
with a budget derived from measured session RTT, capped low. No
|
||
existing live RTT signal existed anywhere in the netcode (checked
|
||
`RNetSessionStats`, 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 records
|
||
`g_verify_wait_ms` when it sends this peer's POST, for the existing
|
||
verify-timeout mechanism. Added `g_rb_rtt_ema_ms`, sampled at the
|
||
point the peer's POST is accepted — `now - g_verify_wait_ms` when 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 via
|
||
`psx_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_stall`
|
||
now sizes its budget as `1.5 × rtt` once 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.
|
||
- [x] 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).
|
||
- [x] 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):**
|
||
|
||
1. Both peers finish Replay and send identical POSTs.
|
||
2. Peer A receives B's POST first → `enter_tip_hold` → advances
|
||
`agreed_through` to the tip → **stops retransmitting its own POST**
|
||
(rexmit gate was `phase==Verify && local_post && !peer_post_ok`).
|
||
3. Peer B never got A's first POST (single UDP loss, even on direct LAN
|
||
`typ host`) → sits in Verify until `RB_VERIFY_TIMEOUT_MS` (4s) →
|
||
`ABORT — verify timeout (peer POST missing)` → realigns to last
|
||
*confirmed* tip (e.g. 1425).
|
||
4. `RB_RESOLVED` that A sent on tip-hold was ignored for Verify exit —
|
||
`take_rb_resolved` only called `rnet_rb_set_peer_convergence`.
|
||
5. A then opens tip-extend / fresh episodes with `load=` the unconfirmed tip
|
||
(1437). B refuses: `follow REFUSED … past frontier=1425` + NACK.
|
||
6. A's NACK handler was plain `abort_episode` — **no watermark demotion,
|
||
no cooldown** — so A immediately reopened `load=1437` → epochs 7..11
|
||
back-to-back NACK abort storm (the `2.7 fps` cliff).
|
||
|
||
**Fixes landed:**
|
||
|
||
- [x] On `enter_tip_hold`, burst `RB_POST_BURST` (8) copies of local POST +
|
||
RESOLVED *before* `clear_post_handshake`, so the lagging peer can still
|
||
complete Verify after the winner leaves it.
|
||
- [x] On `take_rb_resolved` while stuck in Verify with a matching
|
||
`g_post_target`, accept RESOLVED as the missing handshake half and
|
||
`enter_tip_hold` (log: `rb verify accept peer RESOLVED`).
|
||
- [x] On peer follow-NACK: demote `agreed_through` below the refused load,
|
||
drop a pin at/above that load, `schedule_live_realign` to a snap at
|
||
the demoted tip, and `arm_rewind_cooldown_ticks` so 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:**
|
||
|
||
- [x] Replace pump-frame quiet counter with wall-clock
|
||
`g_tip_hold_quiet_t0_ms`: finalize after `runway` frames at 60 Hz
|
||
(`runway * 1000 / 60` ms, clamped 80–500). Tip-extend resets the timer
|
||
so an active coalesce keeps the episode open.
|
||
- [x] 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:
|
||
|
||
- [x] `recomp-net`: added `RNetRbConfig.light_tip_max_depth` (0 → library
|
||
default `RNET_RB_LIGHT_TIP_MAX_DEPTH`, clamped to 32 like
|
||
`tip_runway`). Added `rnet_rb_get_light_tip_max_depth()` and
|
||
`rnet_rb_is_light_tip_candidate_ex(load, target, resolved_through,
|
||
max_depth)` (explicit-ceiling sibling of the existing
|
||
`rnet_rb_is_light_tip_candidate`, which now just calls `_ex` with the
|
||
library default so existing callers/tests are unaffected).
|
||
`rnet_rb_recommend_light_tip` and `rnet_rb_begin_episode` now read
|
||
`cfg.light_tip_max_depth` from the session instead of the bare
|
||
constant. New unit tests in `rollback_episode_test.c` cover: default
|
||
resolves to the constant, `_ex` with an explicit wider ceiling accepts
|
||
a depth the plain wrapper rejects, and a session created with
|
||
`light_tip_max_depth=24` correctly flags a depth-20 `begin_episode` as
|
||
light (would not with the untouched default).
|
||
- [x] `psx_netplay_rb.c`: `psx_netplay_rb_start()` sets
|
||
`cfg.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 + follower `begin_episode` paths) switched from
|
||
`rnet_rb_is_light_tip_candidate` to `rnet_rb_is_light_tip_candidate_ex`
|
||
with `rnet_rb_get_light_tip_max_depth(g_rb)` so the precomputed flag
|
||
matches what `rnet_rb_begin_episode` will compute internally.
|
||
- [x] `recomp-net` is a submodule (`psxrecomp/lib/recomp-net`, separate
|
||
clone from the standalone `recomp-net` workspace repo on the same
|
||
`feat/rollback` branch) — 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):**
|
||
|
||
1. Both peers commit through **3840** (matched POST).
|
||
2. Peer HC-advances `agreed 3840→3856` and opens `epoch=59 load=3856`.
|
||
3. Host still has frontier **3840** → `follow REFUSED … past frontier=3840`
|
||
+ NACK. (The follow-up `snap missing` line from `send_follow_nack` is
|
||
misleading — refuse reason was the frontier check.)
|
||
4. 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.
|
||
5. Cooldown after that realign made late wire `promote-no-resim` — peer
|
||
could not correct invents during catch-up.
|
||
6. Peer then immediately re-ran `agreed ADVANCE 3840→3856` from **stale
|
||
HC** (demote did not `netplay_hc_prime_after` / demote library
|
||
`resolved_through`) — same poison, next episode.
|
||
7. 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→3864` as **light=1** and
|
||
skipped ready-ACK without emitting GO.
|
||
8. Peer followed `load=3840` but its `resolved_through` was 3856 →
|
||
**not** light → waited for initiator GO → `ready timeout` after 4s
|
||
(the admit cliff). Host aborted with `resim core diverge sim=3841`
|
||
while in Verify — leftover of the earlier Live fork.
|
||
|
||
**Fixes landed:**
|
||
|
||
- [x] **NACK keep-live when snap never applied:** capture
|
||
`g_episode_snap_applied` before `abort_episode`; only
|
||
`schedule_live_realign(demote)` when the refused load was actually
|
||
applied. Otherwise log `NACK keep-live` and stay on the pre-episode
|
||
Live tip (symmetric with the NACK-sender, who never left Live).
|
||
- [x] **Demote + prime HC:** on NACK, demote `g_agreed_through` /
|
||
`g_agreed_span_lo`, call new `rnet_rb_demote_resolved_through(g_rb,
|
||
demote)` (library watermark survives `session_reset` and
|
||
`set_peer_convergence` only advances), and
|
||
`netplay_hc_prime_after(hc, demote)` so live hash_confirm cannot
|
||
immediately re-ADVANCE past the refused tip.
|
||
- [x] **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 until
|
||
`RB_READY_TIMEOUT_MS`. Log line:
|
||
`light-tip skip wait … (still emitted ready/GO for asymmetric peer)`.
|
||
- [x] Unit test for `rnet_rb_demote_resolved_through` (advance / demote /
|
||
no-op / survives `session_reset`). Synced into both the MotK
|
||
submodule and the standalone `recomp-net` checkout.
|
||
|
||
**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):**
|
||
|
||
1. **Tip-hold Live-walked held digital.** After a press episode POST-matched,
|
||
tip-hold entered with `invent_slack=0` and `until = 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 via `sim > 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`).
|
||
2. **Release episodes re-simulated several held frames** (often 6–10)
|
||
before the release tip — another held run after the tip-hold walk.
|
||
3. **Ghost second release:** after a real release commit, predicted hist
|
||
ahead still showed the button held (`pub=ffdf wire=ffff` again a few
|
||
dozen ticks later). Tip-hold enter/finalize also did not
|
||
`netplay_hc_prime_after(tip)`, so HC could re-`agreed ADVANCE` over
|
||
invent-poisoned ticks (soak: release tip=1607 then
|
||
`agreed ADVANCE 1607→1632`).
|
||
|
||
**Fixes landed:**
|
||
|
||
- [x] **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.
|
||
- [x] **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.
|
||
- [x] **Scrub-ahead on digital release promote:** rewrite predicted hist
|
||
`release_tick+1..sim` to the released pad (`rb scrub-ahead release`).
|
||
- [x] **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 and `tip_extend` (`rb tip-hold
|
||
coalesce-ahead`). TipHold tip-extend now rereplays when
|
||
`mismatch_tick > old_target` even if Live never left the tip
|
||
(`sim == old_target`).
|
||
- [x] 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.
|
||
|
||
---
|
||
|
||
## 18. POST-diverge tooling + post-realign cooldown (2026-08-01)
|
||
|
||
**Post-§17 soak:** coalesce worked (14 tip-extends, 15 coalesce-ahead,
|
||
6/23 tip-holds extended by up to 24 ticks). One new failure mode: at tip
|
||
9203 both peers POSTed different cores (`8a460717` vs `138700d6`),
|
||
aborted, realigned to 9200 — then `RB_ABORT_COOLDOWN_TICKS=24` made every
|
||
subsequent real dpad edge `promote-no-resim reason=cooldown`, so the
|
||
fork never self-corrected (live digs still mismatched at 9216/9248 until
|
||
disconnect). Also: POST only compared tip digests, so there was no
|
||
visibility into *which earlier tick* in the sealed span actually forked.
|
||
|
||
**Fixes landed:**
|
||
|
||
- [x] **POST-DIVERGE diag dump** (before `abort_episode` clears seals):
|
||
for `load..tip`, print sealed buttons + hist overlay per seat, then
|
||
local/peer FRAME_COMMIT cores from the HC ring, highlighting
|
||
`FIRST_MISMATCH` when the fork is earlier than the POST tip.
|
||
Grep: `rb POST-DIVERGE`. Both peers print the same shape — diff the
|
||
two logs at the abort to see seal/FC asymmetry.
|
||
- [x] **`netplay_hc_peer_digest`** getter (symmetric with `local_digest`)
|
||
for the FC scan; unit-tested in `test_netplay_hash_confirm`.
|
||
- [x] **Skip rewind cooldown after a successful POST realign**
|
||
(`RB_POST_REALIGN_COOLDOWN_TICKS=0` + `clear_rewind_cooldown`).
|
||
Storm cooldown (`streak >= 2`) and "no realign tip" baseline
|
||
cooldown are unchanged. Log: `rb rewind cooldown cleared (...post...)`.
|
||
|
||
**Re-soak checks:** on the next `post core/aux diverge`, expect a
|
||
`POST-DIVERGE diag` / `seals` / `fc FIRST_MISMATCH` block immediately
|
||
before `episode ABORT`; after realign, expect `rewind cooldown cleared`
|
||
(not `cooldown until sim=…`); subsequent real edges should open episodes
|
||
again instead of `promote-no-resim reason=cooldown`. If
|
||
`FIRST_MISMATCH` is earlier than POST tip, that tick is the next
|
||
investigation target (sealed-pad asymmetry vs. sim nondeterminism).
|
||
|
||
---
|
||
|
||
## 19. Tip-extend invent-idle seal + POST storm cooldown (2026-08-01)
|
||
|
||
**Post-§18 soak:** tip-hold coalesce/tip-extend worked through a press→
|
||
release→press chain (epoch 21, tip 1416→1425), then POST diverged at
|
||
1425 (`310e567b` vs `dd8432b2`). Diag:
|
||
|
||
- Host arm audit: `s1=ff7f` (LEFT — correct wire-promoted resign)
|
||
- Host POST dump: `t=1425 s1=ffffP/h=ff7f!` (seal invent-idle vs hist LEFT)
|
||
- Peer dump: `s1=ffffP/h=ffffP` (FOLLOW local seat never had the press in hist)
|
||
- Prior `ready timeout` left `streak=1`; POST → `streak=2` → **storm
|
||
cooldown** (`until sim=1474`) → promote-no-resim while Live stayed
|
||
forked at pin 1392 (`baseline core mismatch` storm)
|
||
|
||
**Root cause:**
|
||
|
||
1. Tip-hold invent-cap stalls Live at the tip, so FOLLOW's local hist
|
||
lacks `tip+1..edge` even though the pad was already sampled into the
|
||
delay ring at `sim+delay`. Seal `get_input_row` fell through to
|
||
`invent_idle` → exported `ffffP` for the correction seat.
|
||
2. Initiator had correctly resigned from wire peek (`ff7f`), then
|
||
`apply_peer_seal_rows` accepted the FOLLOW invent and overwrote it.
|
||
3. §18's `POST_REALIGN_COOLDOWN=0` was skipped by the storm branch
|
||
(`streak >= 2`); tip-hold commit did not clear the abort streak.
|
||
|
||
**Fixes landed:**
|
||
|
||
- [x] **`host_get_input_row` / tip-extend prefresh:** promote from
|
||
`rnet_session_peek(_remote)_input` at `wire_tick_from_sim` before
|
||
hist/invent; tip-extend (initiator + FOLLOW) prefreshes the
|
||
correction + local seats over the new tip range.
|
||
- [x] **`rnet_rb_apply_peer_seal_rows`:** reject predicted peer rows that
|
||
would clobber a non-predicted sealed row (still credit the mask).
|
||
- [x] **POST realign never storm-cools** when a realign tip exists
|
||
(clears streak + `RB_POST_REALIGN_COOLDOWN_TICKS`); tip-hold
|
||
finalize also clears `g_bl_mismatch_streak`.
|
||
|
||
**Re-soak checks:** tip-extend rereplay arm/fin should keep matching
|
||
auth pads (`s1=ff7f` not `ffffP`); POST dump should not show
|
||
`ffffP/h=ff7f!`; on POST diverge expect `rewind cooldown cleared
|
||
(...post...)` even after a prior ready-timeout; no immediate baseline
|
||
mismatch storm at the pin from promote-no-resim.
|
||
|
||
---
|
||
|
||
## 20. Skip presentation during resim ticks (2026-08-01)
|
||
|
||
**Motivation:** with §17-19 landed, soaks were spending a large fraction
|
||
of wall-clock time in `Replay` (the `[FPS] ... replay=NN% (n=…)` line).
|
||
Every resimulated vblank still ran the *full* GL/VK/SW present path
|
||
(`gl_renderer_present_vram` / `gl_renderer_sync_cpu` FBO readback,
|
||
`vk_renderer_present_*`, or the software `SDL_UpdateTexture` +
|
||
`SDL_RenderPresent` path) even though none of that pixel work is visible
|
||
— it gets fully overwritten by the next resimulated (or the final Live)
|
||
frame. Host audio pump was already skipped during resim (see the
|
||
existing `!psx_netplay_is_resimulating()` guard around `sdl_audio_update`
|
||
in `sdl_vblank_present_body`); presentation had no equivalent guard.
|
||
|
||
**Fix:** added a `psx_netplay_is_resimulating()` early return in
|
||
`sdl_vblank_present_body()` (`runtime/src/main.cpp`) immediately before
|
||
the `---- Display from our VRAM ----` block, mirroring the existing
|
||
audio-pump skip. This is presentation-only:
|
||
|
||
- `psx_netplay_finish_frame()` (the digest/hash-confirm capture) and the
|
||
`ep.do_epilogue` / `ep.override` / `ep.skip_pace` epilogue flags used
|
||
by `sdl_vblank_present()` for netplay admit/pace are all latched
|
||
*before* this point in the function, so admit/pace timing is
|
||
unaffected.
|
||
- `gpu_depth24_present_hold_tick()` and the widescreen-engage check
|
||
(which must tick every vblank per their own comments) run *before*
|
||
the new guard, so their bookkeeping is preserved.
|
||
- Host-only presentation state (frame-blend `prev_buf`, disabled-frame-
|
||
presented latch, present ring) simply doesn't advance on resim ticks —
|
||
the same category of skip already used by the blank-display,
|
||
catchup-budget, and turbo-loads early returns elsewhere in this
|
||
function. Live's next real present after the episode settles still
|
||
draws the final, correct frame.
|
||
- Digest content is unaffected: `host_state_digest()` never reads GPU/
|
||
VRAM/framebuffer state (core+aux hashing is CPU/RAM/IRQ/timers/CD/SPU
|
||
register state, not pixels), so skipping the pixel work cannot change
|
||
what gets hashed or sent over the wire.
|
||
|
||
Debug and Release builds both compile clean after the change.
|
||
|
||
**Re-soak checks:** `[FPS]` line's `replay=NN%` should be unaffected
|
||
(same resim ratio, since this doesn't change *when* resim happens, only
|
||
whether it presents), but wall-clock game FPS during heavy-resim spans
|
||
(chain-episoding, tip-hold storms) should improve since the GL readback/
|
||
present cost is no longer paid on non-terminal resim vblanks. No new
|
||
divergence should appear — this change touches host presentation only,
|
||
never digested/guest state.
|
||
|
||
---
|
||
|
||
## 21. Invent-grace ceiling scales with `input_delay` (2026-08-01)
|
||
|
||
**Motivation:** a soak sweep across `PSX_NET_DELAY` values (`D=4` twice
|
||
via the launcher's "default" and an explicit `d=4`, plus `D=6`) showed
|
||
raising `D` did not reduce prediction — it made it *worse*. Normalized
|
||
per 1000 sim ticks:
|
||
|
||
| | D=4 | D=6 |
|
||
|---|---|---|
|
||
| `rb invent grace` events | 127.7 | 232.0 |
|
||
| `tip-hold coalesce-ahead` | 18.9 | 73.0 |
|
||
| `rb episode commit` | 23.9 | 28.4 |
|
||
| `rb wire rewind-request` | 24.4 | 23.4 |
|
||
|
||
Invent (predict) frequency roughly doubled and coalesce-ahead nearly
|
||
quadrupled from D=4→D=6, while the actual correction/rewind rate barely
|
||
moved — the opposite of what a bigger delay buffer should buy.
|
||
|
||
**Root cause:** `np_try_admit_rollback()` computes `wire = sim + D`
|
||
(`rnet_wire_tick_from_sim`) but the decision to invent vs. stall never
|
||
used `D` at all. Any gap `wire > highest_remote_wire + 1` immediately
|
||
falls into `np_invent_grace_stall()`, whose patience budget was
|
||
`1.5x measured RTT`, hard-capped at **60ms regardless of `D`** (40ms in
|
||
the pre-RTT startup fallback). So choosing `D=6` (100ms of nominal
|
||
buffer) vs `D=4` (66.8ms) never changed how long the sim was willing to
|
||
wait for a late remote sample before predicting — the configured delay
|
||
only changed *which* wire tick was being requested, not the patience
|
||
for it. With this soak's RTTs already well under the 60ms ceiling
|
||
(17-53ms), the ceiling — not the delay — was the binding constraint the
|
||
whole time, so the extra `D` bought nothing but longer predicted runs to
|
||
later reconcile (more invents, more coalescing to absorb them, same
|
||
rewind rate).
|
||
|
||
**Fix landed:** `np_invent_grace_stall()`'s ceiling now scales with
|
||
`g_np.input_delay`: `rtt_ceiling = clamp(delay_ms/2, 60, 150)` and
|
||
`fallback_ceiling = clamp(delay_ms/3, 40, 100)`, where
|
||
`delay_ms = input_delay_ticks * tick_ms` (tick_ms from the live
|
||
tick-period EMA, nominal 17ms fallback). `D<=4` reproduces the old
|
||
60/40ms ceilings unchanged (floors preserve historical behavior); larger
|
||
`D` now actually grants more real wall-clock patience before falling
|
||
back to prediction, in proportion to the extra buffer the user asked
|
||
for. The RTT-based *scaling* of the budget itself (`1.5x RTT`) is
|
||
unchanged — only the ceiling moves. The `rb invent grace budget` log
|
||
line now also prints `delay_ms=` so a soak can confirm the ceiling in
|
||
effect.
|
||
|
||
**Re-soak checks:** re-run the same `D=4` vs `D=6` comparison; expect
|
||
`rb invent grace budget=...delay_ms=...` to show a materially higher
|
||
ceiling at `D=6` than `D=4`, and expect invent-events/1k-ticks to no
|
||
longer scale up with `D` (ideally trend down, since more genuinely-late
|
||
packets should now clear within the wait instead of triggering a
|
||
predict). `D<=4` behavior should be bit-for-bit unchanged from prior
|
||
soaks (floors match the old hard caps).
|
||
|
||
---
|
||
|
||
## 22. Delay-buffer admit + adaptive delay (2026-08-01)
|
||
|
||
**Motivation:** Soaks with manual `D=4` / `P=2` still saw frequent small
|
||
resims. Expectation mismatch: raising `D` alone does not widen the
|
||
sim-vs-remote phase gap that gates invent — both peers shift `wire=sim+D`
|
||
together, so the gap stays ~0–1. The product goal is delay-sync-like
|
||
operation at the delay edge (buffer filled, invent rare), with the
|
||
prediction runway as insurance for spikes, freeze+refill when `P` is
|
||
exhausted, and adaptive `D` growth when freezes persist.
|
||
|
||
**Preserved (do not regress):**
|
||
- Gap gate: invent freely at `gap ≤ 1` (stalling every miss serialized
|
||
both sims to ~40–50 fps — §12).
|
||
- Timesync micro-throttle remains the primary phase-lock tool.
|
||
|
||
**Lobby / auto D/P (recomp-ui):**
|
||
| Measured RTT | Input Delay | Prediction (`P = 4 + D`) |
|
||
| ------------ | ----------: | -----------------------: |
|
||
| 0–20 ms | 2 | 6 |
|
||
| 20–50 ms | 2 | 6 |
|
||
| 50–80 ms | 3 | 7 |
|
||
| 80–120 ms | 4 | 8 |
|
||
| 120–160 ms | 5 | 9 |
|
||
| then steps up | … | `4+D` |
|
||
|
||
Manual defaults: **D=2**, **P=6**. Prediction tooltip: keep
|
||
`prediction = 4 + delay`. No UI to disable adaptive delay — with
|
||
`P=4+D` freezes should be rare; when they happen, growing `D` is the
|
||
intended response. Soak-only: `PSX_RB_ADAPT_DELAY=0`.
|
||
|
||
**Runtime changes:**
|
||
1. **Timesync:** skip `note_late` during active episode / tip-hold (resim
|
||
cost ≠ transit latency). Pegged-streak off-guard raised 12→18.
|
||
`psx_netplay_timesync_on_episode_boundary()` clears pegged streak on
|
||
begin / tip-hold / abort (debt kept for post-episode pacing).
|
||
2. **Admit telemetry:** `rb admit stats invent_gap1/gap2/gap3+
|
||
pcap_stalls/pcap_enters` every ~5s; invent path buckets by gap.
|
||
3. **P-cap freeze:** `wire > highest_remote + P` logs
|
||
`rb pcap FREEZE enter/exit` and counts freeze enters in a 5s window.
|
||
4. **Adaptive delay (always on):** host (`local_slot==0`) after ≥3 freeze
|
||
*enters* in 5s, with 10s cooldown, calls
|
||
`rnet_session_request_delay_change(D+1)` (max 16). `P` stays at the
|
||
lobby-committed value. Guests apply via DELAY_SYNC.
|
||
5. **recomp-net DELAY_SYNC:** deferred apply queue
|
||
`(pending_delay, effective_tick)`; commit in `rnet_session_advance` /
|
||
`set_sim_tick` when `sim_tick >= effective_tick`. New
|
||
`rnet_session_request_delay_change`. Retransmit pending packets every
|
||
50ms while queued. MotK mirrors `g_np.input_delay` from
|
||
`rnet_session_committed_delay` on admit / after live advance.
|
||
|
||
**Re-soak checklist:**
|
||
- [ ] LAN with auto or manual **D=2 / P=6**: expect fewer invents than the
|
||
old D=4/P=2 soak; gap1 invents dominate over gap2/gap3+.
|
||
- [ ] Log lines: `rb admit stats …`, rare/absent `rb pcap FREEZE enter`.
|
||
- [ ] Induced latency spike: freeze enter → after 3 enters,
|
||
`rb adaptive delay bump` + both peers `rb delay committed`.
|
||
- [ ] Timesync does not trip `OFF 10s` solely from tip-hold/resim load.
|
||
- [ ] Fight scene: fps / episode density vs prior soak; no serialization
|
||
regression (still inventing at gap≤1).
|
||
|
||
---
|
||
|
||
## 23. Short invent grace at gap=1 (2026-08-01)
|
||
|
||
**Soak evidence (same session pair, TURN):**
|
||
| Setting | ep/1k ticks | host invent_gap1/1k | pcap freezes |
|
||
| ------- | ----------: | ------------------: | -----------: |
|
||
| D=2 P=6 | ~23 | ~260 | 0–1 |
|
||
| D=4 P=8 | ~25 | ~297 | 0 |
|
||
|
||
Raising D/P did nothing — invents were ~99% `gap1` (free path). Adaptive
|
||
delay never armed (P=8 runway unused). Hitch source remains
|
||
gap1 invent → edge mispredict → tip-hold/coalesce → resim.
|
||
|
||
**Fix:** ahead-only **short** invent grace when
|
||
`wire == highest_remote + 1`:
|
||
- Cap = `max(8, min(16, RTT_ms))` (auto), or `PSX_RB_GAP1_GRACE_MS`
|
||
(0 = old invent-free gap1).
|
||
- Must stay << one peer guest frame so we do **not** revive the
|
||
2026-07-31 serialization (full stall on every miss → 40–50 fps).
|
||
- Gap1 grace expiries do **not** feed the invent-grace adaptive-off
|
||
streak (would trip constantly under normal phase stagger).
|
||
- Gap≥2 keeps the full §21 budget unchanged.
|
||
|
||
**Timesync:** mispredict debt add ~0.75 tick (was 0.5); admit shave
|
||
≤4 ms/tick (was 3).
|
||
|
||
**Telemetry:** `rb admit stats` now includes `gap1_grace=` (times the
|
||
short stall returned before invent).
|
||
|
||
**Re-soak checks:** compare to D=4/P=8 baseline (~25 ep/1k):
|
||
- Expect `gap1_grace` rising and `invent_gap1` / ep/1k falling.
|
||
- Steady FPS must not collapse to ~40–50 with zero episodes (that
|
||
would mean the short cap is too large / both peers serializing).
|
||
- `PSX_RB_GAP1_GRACE_MS=0` restores prior gap1 invent-free behavior.
|
||
|
||
---
|
||
|
||
## 24. Admit-hitch cleanup after gap1 grace (2026-08-01)
|
||
|
||
**§23 soak:** ep/1k ~25→14 and invent_gap1/1k ~297→188, but feel was still
|
||
bad. FPS forensics: **17/53** host windows were admit-dominated hitch
|
||
(`admit` 12–17 ms, `replay=0%`) — not resim. Causes:
|
||
|
||
1. Gap1 grace spun the admit loop (~10k stall returns / ~2k ticks) and
|
||
often **expired into invent anyway** (stall tax without preventing
|
||
predict). Cap was up to 16 ms on measured RTT.
|
||
2. TipHold **coalesce-ahead** reset the quiet timer on every edge
|
||
(1825 resets) so Live stayed parked in tip-hold with high admit cost.
|
||
|
||
**Fixes:**
|
||
- Gap1 auto cap → `max(4, min(10, RTT/2))`; count `gap1_grace` once per
|
||
wire tick; after 10 consecutive expire→invent, **OFF 1s**
|
||
(`rb gap1 invent grace OFF 1s`).
|
||
- TipHold: **350 ms absolute wall cap** from enter (`rb tip-hold WALL CAP`);
|
||
pure TipHold coalesce extends `tip_hold_until` but **does not** reset
|
||
quiet_t0 (rereplay still does); coalesce-ahead log rate-limited.
|
||
|
||
**Re-soak:** expect fewer admit-dominated `replay=0%` dips, lower
|
||
`gap1_grace` counts (per-wire), occasional `gap1 invent grace OFF`,
|
||
`tip-hold WALL CAP` when menus mash, ep/1k staying ≤§23 or better.
|
||
|
||
---
|
||
|
||
## 25. TipHold WALL vs quiet + gap1 shrink (2026-08-01)
|
||
|
||
**§24 soak:** admit-spin fixed (`gap1_grace/1k` 4550→278, coalesce 1825→32)
|
||
but feel regressed: **27/27** tip-hold commits were `WALL CAP 350ms`
|
||
(quiet never won — quiet need ~400ms > WALL 350ms), and gap1 invent-free
|
||
OFF climbed invent_gap1/1k 188→360 and ep/1k 14→19. Hitching returned as
|
||
replay-dominated.
|
||
|
||
**Fixes:**
|
||
- TipHold: quiet max **180ms** (can finish); **THRASH CAP 150ms** only when
|
||
`coalesce_n ≥ 3`; **SAFETY CAP 500ms** last resort. Logs:
|
||
`tip-hold THRASH CAP` / `tip-hold SAFETY CAP`.
|
||
- Gap1: on expire→invent streak, **SHRINK to 2ms for 1s** (not invent-free).
|
||
Log: `rb gap1 invent grace SHRINK 2ms for 1000ms`.
|
||
|
||
**Re-soak:** expect mostly quiet tip-hold commits (not WALL), occasional
|
||
THRASH on mash, invent_gap1 not jumping when SHRINK fires, ep/1k ≤§23.
|
||
|
||
---
|
||
|
||
## 26. FMV lockstep was re-serializing WAN (2026-08-01)
|
||
|
||
**Clue:** `PSX_RB_GAP1_INVENT=1` still stayed ~30 fps on CGNAT/TURN. Early
|
||
menu with invent free-ran at **~60 fps / admit≈0**; the cliff lined up with
|
||
`FMV rewind-defer ON` and invent count **0** until `invent_at` (~4s of
|
||
post-FMV MIN+UNLOCK delay-sync). Tip-hold/replay windows later recovered
|
||
toward ~50 fps — so prediction *can* drive presentation; admit was still
|
||
gated on confirmed tip.
|
||
|
||
**Root causes:**
|
||
1. `lockstep_no_invent` = media **and** `g_fmv_lockstep_until` (MIN 180 +
|
||
UNLOCK_GRACE 64). Correct for depth24 Start/skip; fatal for WAN title
|
||
menus (network paces every frame).
|
||
2. Default admit refused invent while `remote_lead > 0` until TIP_STALE
|
||
(~150ms+) — delay-sync unless ENV forced gap1 invent.
|
||
3. RUNWAY_EMPTY invent grace floored at 60ms ceiling → ~25ms tax/tick.
|
||
|
||
**Fixes:**
|
||
- No-invent gate = **media + settle (24 ticks) only**. Digest lockstep
|
||
still drives dense snaps / RELEASE logs, not admit.
|
||
- Gap1 short-grace→invent is **default ON** (`PSX_RB_GAP1_INVENT=0` for
|
||
old cushion-wait).
|
||
- Invent grace OFF after 15 expiries for **5s**; RUNWAY ceiling floor 20 /
|
||
cap 80 (was 60/150).
|
||
|
||
**Re-soak (no ENV needed):** FMV itself may still track packet rate (no
|
||
invent during media — intentional). Post-FMV menus should invent within
|
||
~settle and climb toward 60 with rollbacks, not flat ~30. Look for
|
||
`gap1 invent ON`, `invent grace OFF 5s`, settle log
|
||
`no invent through settle; then invent+resim §26`.
|
||
|
||
---
|
||
|
||
## 27. Presentation continuity without resim storms (2026-08-01)
|
||
|
||
**Problem:** §26 unlocked invent/resim, but the eye still saw stalls —
|
||
tip-hold parked Live at tip (`invent_slack=0`), gap1/RUNWAY grace taxed
|
||
every miss, and deep `RUNWAY_EMPTY` invents (`pred_depth` 4–5) chained
|
||
episodes. Telemetry showed high `replay%` while `present` starved.
|
||
|
||
**Policy pivot:** keep the display clock moving with **shallow** prediction;
|
||
resim real edges; do not reintroduce delay-sync after invent commits.
|
||
|
||
**Fixes:**
|
||
- TipHold seal slack → library default **2** (was FORCE0); quiet **80ms**,
|
||
thrash **100ms @ coalesce≥2**, safety **250ms**.
|
||
- Gap1 invent grace default **0** (`PSX_RB_GAP1_GRACE_MS` override kept).
|
||
- Invent depth cap: `pred_depth ≥ 2` waits until tip stale (1× invent RTT,
|
||
floor 40ms); RUNWAY invent grace hard-capped at **8ms**.
|
||
- FPS line: `present_gap_p95=… ms max=… ms` (primary soak metric).
|
||
|
||
**Pass (WAN/TURN, post-settle):** `present_gap_p95 ≤ 33ms`, invent mostly
|
||
`GAP1` with `pred_depth ≤ 2`, ep/1k moderate (not zero invents, not
|
||
constant deep resim). FMV media may still be packet-paced (no invent).
|
||
|
||
---
|
||
|
||
## 28. Gap=1 phase-vs-starvation split — LAN resim storms (2026-08-01)
|
||
|
||
**Problem:** §27's `gap1_grace=0` default fixed WAN 30fps delay-sync but
|
||
regressed LAN. A 2-peer soak at `D=2 P=6` (one peer via TURN relay, one
|
||
direct host-host) showed **~310 gap1 invents / ~2400 frames**, a first
|
||
core diverge at sim=962, then a tip-hold commit storm (`ep/1k≈15`,
|
||
`present_gap_p95` spiking to 3.4s once). The invent geometry was
|
||
perfectly stable the whole soak:
|
||
|
||
```
|
||
wire = sim+2, remote_tip = sim+1, remote_lead = 1, pred_depth = 1
|
||
```
|
||
|
||
That is **not starvation** — the confirmed remote tip is still one row
|
||
ahead of sim, i.e. there is real work already in flight. It is a
|
||
one-tick **phase offset**: this peer is consistently sampling wire a
|
||
tick before the matching remote row lands. §27 treated every gap=1 miss
|
||
identically (`invent now`), converting each phase tick into a mispredict
|
||
→ rewind-request → tip-hold episode.
|
||
|
||
**Fix — Case A / Case B split at gap=1** (`np_gap1_grace_cap_ms`,
|
||
`np_tip_track_advance`/`np_tip_age_ms`, gap1 branch of
|
||
`np_try_admit_rollback` in `psx_netplay.c`):
|
||
|
||
- Track the remote tip's own arrival cadence every admit tick (not just
|
||
on miss): `g_tip_arrival_ema_ms` = EMA of ms between
|
||
`highest_remote_wire` advances, `np_tip_age_ms()` = ms since the last
|
||
advance.
|
||
- **Case A (healthy/advancing tip):** `remote_lead ≥ 1` and the tip
|
||
advanced within the last `1.5×` its own cadence. The newest row is
|
||
probably already in flight — wait `clamp(period − tip_age, 4, 10)` ms
|
||
before inventing, instead of 0. Logged as `reason=GAP1_PHASE`.
|
||
- **Case B (stale/starved tip):** `remote_lead ≤ 0`, or the tip hasn't
|
||
advanced in over 1.5× its cadence (or cadence unknown). Invent
|
||
immediately — same as §27's default — so genuine WAN gaps stay
|
||
responsive and this never turns back into cushion-wait.
|
||
- `PSX_RB_GAP1_GRACE_MS` still forces the old flat cap (bypasses the
|
||
Case A/B split entirely) for A/B testing or a manual operator override.
|
||
|
||
**Telemetry:** `rb admit stats` gained `gap1_case_a=`, `gap1_case_b=`,
|
||
`tip_ema=` (the learned arrival cadence in ms). Expect on LAN:
|
||
`gap1_case_a` rising, `gap1_case_b`/`invent_gap1_legacy` mostly flat,
|
||
`tip_ema` settling near the true tick period (~16–17ms at 60fps),
|
||
`present_gap_p95` and ep/1k dropping back toward the pre-§27 LAN
|
||
baseline (~60fps, near-zero episodes) without WAN regressing to 30fps.
|
||
|
||
**Re-soak:** LAN pass = few-to-no `GAP1_LEGACY`/`RUNWAY_EMPTY` invents
|
||
once `tip_ema` converges, `gap1_case_a` dominant, ep/1k near 0, fps back
|
||
near 60. WAN pass = unchanged from §27 (Case B fires immediately on a
|
||
genuinely stale/negative-lead tip, so high-RTT links don't gain a hidden
|
||
per-tick stall).
|
||
|
||
---
|
||
|
||
## 29. §28 Case A wait was the hitch — scheduler needs pacing, not waiting
|
||
(2026-08-01)
|
||
|
||
**Problem:** the very next LAN soak after §28 showed *worse* hitching.
|
||
`gap1_case_a`/`gap1_grace` climbed to **1000/1000** (every gap=1 miss took
|
||
the Case A wait), but only ~12% of those waits actually found the row —
|
||
88% expired into invent anyway (`invent_gap1_legacy` 883, `GAP1_PHASE`
|
||
314 logged). `tip_ema` (the learned tip-advance cadence) drifted from
|
||
15ms to **28-41ms** over the match — 2-3x the true ~16ms tick period —
|
||
because the wait itself delays the next admit, which delays the next
|
||
tip-advance observation, which makes the next miss look "fresher" than
|
||
it is (self-reinforcing feedback loop). Net effect: `admit` cost rose
|
||
from ~6ms/f to ~8ms/f baseline, first core diverge moved earlier
|
||
(sim 279 vs 962), ep/1k rose (16→27), and `present_gap` spiked to
|
||
**~4 seconds** during storm windows with fps dropping to single digits.
|
||
|
||
**Root cause, reframed as a scheduler question, not an invent-policy
|
||
question:** both soaks showed the exact same fixed point regardless of
|
||
gap1 policy:
|
||
|
||
```
|
||
wire = sim+D, remote_tip = sim+(D-1), remote_lead = D-1, pred_depth = 1
|
||
```
|
||
|
||
This is **rock-stable**, not jittery — the signature of a fixed ~1-tick
|
||
network transit/scheduling cost consuming one of the two delay frames,
|
||
combined with a self-reinforcing rate effect: because gap=1 has always
|
||
resolved via immediate invent (never a wait), the peer that structurally
|
||
gets there first never has to slow down, so it keeps racing exactly one
|
||
tick ahead of the other's publish rate indefinitely. A **per-miss wait**
|
||
cannot fix either half of this: it cannot change a fixed transit delay,
|
||
and by delaying every admit uniformly it does not reintroduce any
|
||
asymmetry between the peers — so both soaks converged to the same
|
||
useless (or actively harmful) place.
|
||
|
||
**Fix:** the only mechanism in this codebase that is actually
|
||
asymmetric-safe here is mispredict-driven `np_timesync_note_late`
|
||
pacing (`psx_netplay.c`) — soak evidence already established that real
|
||
mispredicts land almost entirely on one peer (the one racing ahead),
|
||
so pacing *that* peer down a few ms/tick closes phase without both
|
||
sides fighting each other or serializing. It was just too weak to
|
||
matter: MotK's hold-last invent means most gap=1 misses never produce a
|
||
mispredict (the invented value already matches), so genuine correction
|
||
signals are rare, and the old debt sizing (add ~0.75 tick/mispredict,
|
||
cap 2 ticks, shave <=4ms/tick) drained faster than it could accumulate
|
||
enough to move a persistent D-1 phase.
|
||
|
||
- **Reverted** §28's per-miss Case A wait. Gap=1 invents immediately
|
||
again (§27 behavior) — zero added admit latency. The Case A/B
|
||
classification is kept as **pure diagnostics** (`GAP1_PHASE` /
|
||
`GAP1_LEGACY` reason, `gap1_case_a`/`gap1_case_b` counters) computed
|
||
from the same tip-health signal, but nothing waits on it.
|
||
`PSX_RB_GAP1_GRACE_MS` still forces the old flat-cap wait for A/B
|
||
testing.
|
||
- **Strengthened timesync pacing** (`np_timesync_note_late`,
|
||
`np_timesync_throttle`): per-mispredict debt add raised from ~0.75
|
||
tick to ~1.25 ticks, cap raised from 2 ticks to 3 ticks, admit-side
|
||
shave raised from <=4ms/tick to <=6ms/tick — so a small cluster of
|
||
real mispredicts (e.g. one fight exchange) can actually walk a stuck
|
||
D-1 phase back toward D within a handful of ticks instead of dozens.
|
||
Adaptive-off guards (18 consecutive cap-hits → OFF 10s) are unchanged.
|
||
|
||
**What to watch in the next soak:** `gap1_case_a`/`gap1_case_b` should
|
||
still climb (that's expected — LAN transit still eats ~1 tick most of
|
||
the time) but `admit` ms/f and `present_gap_p95` should drop back near
|
||
pre-§27/§28 baselines since no wait is added; `tip_ema` should hold near
|
||
the true tick period instead of drifting; and `timesync pacing` log
|
||
lines / `rb timesync OFF` should show whether the stronger debt actually
|
||
narrows `remote_lead` back toward D over a match, or whether the
|
||
transit-delay theory needs D raised instead (next lever if pacing alone
|
||
doesn't move it).
|
||
|
||
---
|
||
|
||
## 30. Phase-control soak instrumentation — no algorithm change (2026-08-01)
|
||
|
||
**Context:** Prior soak showed host (`slot=0`) opening ~91 rewind-requests
|
||
against remote slot 1 while guest opened only ~29 against slot 0, with
|
||
timesync pacing 72 vs 17. Hypothesis: the peer that starts ahead spends
|
||
more time in `rb_active`/`tip_holding`, so `np_timesync_note_late()`
|
||
suppresses the very debt that would slow it — a control-loop leak —
|
||
rather than pure gameplay/transport asymmetry.
|
||
|
||
**Change:** telemetry only (`psx_netplay.c`). No pacing constants, grace,
|
||
or invent-policy edits.
|
||
|
||
**~1 Hz line** (each peer, stderr):
|
||
|
||
```
|
||
psxrecomp: rb phase ctrl slot=N sim=… lead=… lead_avg=… lead_min=…
|
||
lead_max=… debt_ms=… debt_added=… mispredict=… note_late=…
|
||
suppressed_rb=… suppressed_off=… D=…
|
||
```
|
||
|
||
| Field | Meaning |
|
||
|-------|---------|
|
||
| `lead` / `lead_avg/min/max` | `remote_lead` now + window over the last ~1s of admits |
|
||
| `debt_ms` | current timesync pacing debt (drains ≤6 ms/tick) |
|
||
| `debt_added` | cumulative ms of debt ever added (never resets) |
|
||
| `mispredict` | cumulative `pads_differ` resolves (true mispredicts) |
|
||
| `note_late` | cumulative `note_late` calls that actually added debt |
|
||
| `suppressed_rb` | cumulative `note_late` early-outs while `rb_active` or `tip_holding` |
|
||
| `suppressed_off` | cumulative early-outs while timesync adaptive-OFF window |
|
||
|
||
Same counters also appear on the 5s `rb admit stats` line.
|
||
|
||
**How to read host vs guest:**
|
||
|
||
1. **Supports control-loop instability:** one peer consistently has higher
|
||
`lead`/`lead_avg`, higher `mispredict`, *and* higher `suppressed_rb`
|
||
(especially `suppressed_rb` rising with `mispredict` while
|
||
`note_late` lags). That peer is losing correction signal during its
|
||
own resim/tip-hold windows.
|
||
2. **Points elsewhere (gameplay/transport):** `suppressed_rb` rare or
|
||
balanced, but `mispredict` still skewed — edges/content or path RTT
|
||
dominate; do not change the suppress gate yet.
|
||
3. **Secondary:** `suppressed_off` climbing on one side means adaptive-OFF
|
||
is also silencing pacing (different failure mode).
|
||
|
||
**Do not** tweak pacing constants from this soak until the hypothesis is
|
||
confirmed or rejected from these four signals.
|
||
|
||
## 31. Protocol correctness track — distributed episode contract (2026-08-01)
|
||
|
||
Work split into two tracks (protocol correctness first, scheduler feel
|
||
second) so remaining rollback storms can be attributed to scheduler policy
|
||
rather than peers disagreeing about control flow. This section is the
|
||
protocol track. All six items landed together; the RB_SYNC wire format
|
||
gained an op code (the old `initiator` byte) and a flags byte (the old pad
|
||
byte, so packet size is unchanged). Constants live in
|
||
`recomp_net/session.h` (`RNET_RB_SYNC_OP_*`, `RNET_RB_SYNC_FLAG_*`,
|
||
`RNET_RB_ABORT_CLASS_*`).
|
||
|
||
**1. Episode identity — slot-partitioned epoch ids.** Wire epoch =
|
||
`(counter << 3) | initiator_slot` (`RB_EPOCH_SLOT_BITS`). Concurrent dual
|
||
initiation can never collide on an id, and any epoch's initiator slot is
|
||
derivable from the id alone (needed by the tie-break). `g_epoch` is now
|
||
the counter, not the wire id; followers only raise their counter for log
|
||
monotonicity.
|
||
|
||
**2. Dual-initiation tie-break.** Both peers opening episodes
|
||
concurrently used to deadlock (each dropped the other's SYNC as "already
|
||
active") or cross-wire baselines. Now, on receiving a competing BEGIN
|
||
while initiating: the lower initiator slot wins. The loser yields quietly
|
||
— `abort_episode` with class REALIGN, cooldown cleared (contention is not
|
||
failure) — and falls through to follow the winner's episode. Yield only
|
||
happens in SealInputs/AwaitingBaseline before the snap load mutated Live;
|
||
deeper phases drop the SYNC and rely on abort propagation to clean up.
|
||
|
||
**3. Abort propagation.** Local aborts used to tear down silently; the
|
||
peer kept sealing/verifying until its own skewed timeout. Every
|
||
wire-visible abort now sends `RB_SYNC op=ABORT` (×3 burst, epoch-deduped
|
||
on receive) with the cooldown class in the mismatch field and the sender's
|
||
realign tick in the load field. The receiver mirrors teardown, realigns
|
||
via the shared `pick_realign_tip()` policy (pin > episode load > agreed),
|
||
and arms the same cooldown class.
|
||
|
||
**4. Shared cooldown class.** `abort_episode_realign` classifies BEFORE
|
||
aborting (REALIGN / ABORT / STORM / NO_SNAP → 0/24/48/90 ticks via
|
||
`abort_class_cooldown_ticks`) and stages the class into the wire notice,
|
||
so both peers re-arm on the same schedule instead of deriving different
|
||
cooldowns from different local evidence. The receiver also syncs its
|
||
`g_bl_mismatch_streak` from the class (REALIGN clears; STORM forces ≥2).
|
||
An ABORT for an episode we never joined still arms STORM/NO_SNAP classes
|
||
so we don't immediately initiate into a peer that just declared a hard
|
||
failure.
|
||
|
||
**5. Shared frontier.** Three parts: (a) follow-NACKs carry the
|
||
follower's confirmed frontier (`follower_frontier_hint()`: agreed
|
||
watermark, else mutually hash-confirmed tick) in the target field; the
|
||
initiator demotes to `min(load-1, peer_frontier)` walked down to a real
|
||
snap, so the reopened episode's load is provably followable instead of
|
||
NACK-cycling. (b) `finalize_tip_hold` bursts RESOLVED (enter_tip_hold and
|
||
commit already did) so a committed tip is never a local secret. (c) The
|
||
follow REFUSE "past frontier" check treats the library's peer-advertised
|
||
`rnet_rb_resolved_through` as a frontier floor — peers only advertise
|
||
committed ticks, so a load at/below it is followable even when our own
|
||
agreed/hc watermark lags from a lost packet.
|
||
|
||
**6. Shared rereplay + light-tip decision.** BEGIN/tip-extend SYNCs carry
|
||
initiator-authoritative flags: `FLAG_LIGHT_TIP` (follower adopts verbatim;
|
||
`rnet_rb_begin_episode` no longer auto-classifies when `from_peer_notify`,
|
||
and `rnet_rb_recommend_light_tip` reports `corr.flags` only — this was the
|
||
asymmetric baseline-burst seed) and `FLAG_REREPLAY` (tip-extend: follower
|
||
mirrors the initiator's rereplay decision, OR'd with its own
|
||
"Live invented past the prior tip" check, which can only add correction,
|
||
never skip one).
|
||
|
||
Verified: MotK `psx-runtime` builds; all 11 recomp-net tests pass
|
||
(rb_wire_test round-trips the op/flags byte and the ABORT shape;
|
||
rollback_episode_test covers follower flag adoption). Standalone
|
||
recomp-net repo synced with the same changes.
|
||
|
||
---
|
||
|
||
## 32. Scheduler-feel track kickoff — lead regulation (2026-08-01)
|
||
|
||
Protocol correctness (§31) landed clean: the rb-diag1/rb-diag2 re-soak had
|
||
no NACK cycles, no REFUSED, no SPAN CAP, tie-breaks resolved deterministically,
|
||
and ABORT propagated even when only one side locally detected the divergence.
|
||
Remaining asymmetry is scheduler *feel*, not protocol disagreement. Fable's
|
||
review flagged four items in priority order; this section covers the first
|
||
(highest-leverage) one: **lead regulation**.
|
||
|
||
**Observed asymmetry (rb-diag soak):** host began ~3× as many rewind
|
||
episodes as guest and owned almost all corrections against the guest's pad;
|
||
pacing debt accumulated mostly on host (1428 ms vs 456 ms cumulative). The
|
||
begin/mispredict ratio tracks almost 1:1 (host mispredict=124/begin=107,
|
||
guest mispredict=50/begin=33) — `rnet_input_contract_stick_replace_decide`
|
||
converts nearly every genuine mispredict into a rewind, so the real question
|
||
is *why one peer mispredicts 2.5× more than the other*, not the contract's
|
||
promote/rewind split.
|
||
|
||
**Hypothesis:** an over-prediction feedback loop. The peer that falls behind
|
||
wall-clock (more Replay/TipHold work from owning more corrections) still has
|
||
to keep pace with the wire tick rate, so it ends up predicting the remote pad
|
||
further into the future before the real row lands; deeper predictions are
|
||
mechanically more likely to be wrong; each wrong deep prediction becomes
|
||
another correction it initiates, which costs more Replay/TipHold time — the
|
||
same peer digs itself deeper. `np_timesync_note_late` already exists to
|
||
brake exactly this (pacing debt shaved off at admit), but until now every
|
||
mispredict added the *same* flat debt regardless of how deep the guess was,
|
||
so a peer running way ahead and a peer running barely ahead got identical
|
||
correction pressure.
|
||
|
||
**Change 1 — proportional debt (`psx_netplay.c`):** `np_timesync_note_late`
|
||
now takes the mispredict's *age* (`sim - t`: how many ticks the wrong
|
||
predicted row rode before the real one arrived and was caught). A row
|
||
resolved right at the normal input-delay boundary (`age ≈ D`) is expected
|
||
steady-state noise and gets the same flat debt as before — zero behavior
|
||
change in the common case. A row that rode notably longer than `D` ticks
|
||
means we were running unusually far ahead of the confirmed remote tip when
|
||
we guessed it; extra debt scales `+25%` of the base add per tick past `D`,
|
||
still capped by the existing 3-tick ceiling. This only activates additional
|
||
braking specifically during the runaway-ahead pattern; it cannot make the
|
||
aligned/steady-state case worse since `extra_age` is 0 there.
|
||
|
||
**Change 2 — telemetry (`psx_netplay.c` phase-ctrl line):** added
|
||
`mispredict_age_avg=` / `mispredict_age_max=` (match-lifetime, like the
|
||
other cumulative counters on that line) so the next soak can directly
|
||
confirm or reject the causal hypothesis by comparing host vs guest average
|
||
prediction age — instead of the begin/mispredict-count archaeology used to
|
||
produce the numbers above. If the hypothesis holds, host's
|
||
`mispredict_age_avg` should sit measurably above guest's, and both peers'
|
||
ratio should compress with change 1 applied vs a soak with it reverted.
|
||
|
||
**Deliberately not done yet:** did not touch the tie-break weighting, the
|
||
resim/tip-hold `note_late` suppression gate (already balanced — 17/17
|
||
`suppressed_rb` in the §31 re-soak, so that specific §30 hypothesis is
|
||
closed), or the admit-side throttle shave rate. Per this project's own
|
||
established practice (§29: a guessed pacing fix regressed and had to be
|
||
reverted after a soak), only one bounded, reversible, provably-no-worse-
|
||
in-steady-state change plus matching telemetry landed per cut — the next
|
||
soak's `mispredict_age_avg`/`lead_avg`/`debt_added` decide whether to push
|
||
further (e.g. widen the +25%/tick coefficient) or whether the asymmetry is
|
||
gameplay-driven (who's the aggressor) rather than scheduler-driven.
|
||
|
||
**Still open (scheduler-feel track, unchanged priority from fable's
|
||
review):**
|
||
- Presentation cadence during Replay/TipHold — **landed in §33** (hold-last
|
||
present on wall-clock cadence during `is_resimulating`).
|
||
- POST core/aux diverge (~0.8/1k ticks) — heal path (§31 ABORT propagation)
|
||
already handles it gracefully; root cause is a separate emulation
|
||
determinism gap (audit/cpu-split dumps already exist for that chase).
|
||
- Gap1/grace — left as `invent-immediate` (§29's finding stands); revisit
|
||
only after lead regulation is confirmed closing the gap, so grace changes
|
||
aren't evaluated against a still-skewed baseline.
|
||
|
||
---
|
||
|
||
## 33. Hold-last present during resim (2026-08-01)
|
||
|
||
**Problem:** `sdl_vblank_present_body` early-returned on
|
||
`psx_netplay_is_resimulating()` (AwaitingBaseline | Replay | Verify) and
|
||
skipped every Swap. Admit still advanced uncapped, so `replay%` windows
|
||
produced `present_gap_p95` of 80–150ms — the metric tracked replay load
|
||
because presents only resumed on Live ticks. Visually: frozen front buffer
|
||
for the whole resim span, then a jump.
|
||
|
||
**Fix (host presentation only — digests/wire unchanged):**
|
||
|
||
1. **GL sticky hold** (`gpu_gl_renderer.c`): before each Live `SwapWindow`,
|
||
`hold_capture_drawable()` copies the drawn backbuffer into a hold
|
||
texture. When interpolation owns Swap, `hold_capture_native_fbo()`
|
||
blits the display band from the VRAM/wide FBO instead. Mid-resim must
|
||
not re-read `s_hr_tex` (it mutates during Replay). First NATIVE
|
||
hold-present letterboxes then re-captures as DRAWABLE (soak-proven path).
|
||
2. **`gl_renderer_present_hold_last()`**: redraws the hold texture + Swap
|
||
(swap interval already 0 under netplay — no vsync tax on resim throughput).
|
||
3. **`main.cpp` resim branch**: on a wall-clock period clamped to
|
||
`[8, 33] ms` (≈ `g_frame_period_ms`), call hold-last + `netplay_note_present()`.
|
||
Suspend GL interpolation for the episode (`set_interpolation_suspended(1)`)
|
||
so the interp thread cannot fight hold-last `SwapWindow` (A/B: GL+interp
|
||
buggy, GL without interp clean). Live restores FMV/game suspend policy.
|
||
SW path re-presents with the last Live `src`/`dst` rects (not `NULL,NULL`
|
||
on the full 640×512 texture — that produced the upper-left postage stamp).
|
||
VK left as no-op hold (status quo skip) for this cut.
|
||
4. Epilogue still skips the frame pacer during resim — catch-up stays fast;
|
||
only the present starvation is fixed.
|
||
|
||
**Pass:** post-settle high-`replay%` windows should show `present_gap_p95`
|
||
near the frame period (~16–33ms) instead of tracking episode wall time.
|
||
Quiet Live tail unchanged. Re-soak GL+interp and SW; TipHold admit-stall
|
||
gaps (§32) are a separate follow-up.
|
||
|
||
---
|
||
|
||
## 34. Double menu inputs — tip-hold quiet + scrub + debounce (2026-08-01)
|
||
|
||
**Post-§33 soak (`rb-diag1/2`, LAN, software GPU, menu nav):** protocol
|
||
healthy enough (tip-extend live, quiet present gaps ~18ms) but **dpad taps
|
||
felt doubled** again. Audit arms showed fragmented digital pulses on the
|
||
wire (e.g. `ffbf` @1044, idle, `ffbf` @1048–50, idle, `ffbf` @1055,
|
||
tip-extend @1059) and tip-hold committing immediately between them.
|
||
`scrub-ahead` logged **0** times; releases during tip-hold hit
|
||
`begin_refused=1` then quiet-commit dropped the edge.
|
||
|
||
**Root causes:**
|
||
|
||
1. **TipHold quiet armed whenever `sim >= invent_cap`**, including through
|
||
1-tick idle holes between controller bounce pulses (`QUIET_MAX=80ms`).
|
||
2. **`np_scrub_ahead_predicted` no-op'd under TipHold** (`sim <= release_tick`
|
||
while coalesce promoted release at `tip+N`).
|
||
3. **`begin_refused` while tip-holding** (tip-extend fail / different seat)
|
||
did not block quiet finalize — commit raced the unreabsorbed release.
|
||
4. **1–2 tick idle holes** published as real press/release/press edges;
|
||
MotK menus edge-trigger → multiple navigations.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] Digital release debounce **removed** (2026-08-03): 2-tick whole-word
|
||
sticky merged intentional double-taps; Start sticky already removed
|
||
(taunt double-pause is in-game). Snap-load continuity remains.
|
||
- [x] Scrub-ahead end bound = `max(sim, tip+runway)` during TipHold.
|
||
- [x] TipHold quiet only while pads idle **and** no pending wire delta
|
||
`tip+1..runway` / `block_quiet`; held or pending resets the timer.
|
||
Quiet window **100–150ms** (was min 80 / max 80).
|
||
- [x] Thrash CAP suppressed while a wire edge is still pending (SAFETY
|
||
still wins).
|
||
- [x] `psx_netplay_rb_tip_hold_block_quiet` from coalesce/begin_refused;
|
||
different-seat tip-extend fail → tip-hold commit then fresh begin.
|
||
|
||
**Re-soak checks:** one physical dpad tap → one cursor move; look for
|
||
`scrub-ahead release` during tip-hold coalesce; far fewer
|
||
`begin_refused=1` → immediate tip-hold commit pairs; tip-hold→commit
|
||
dwell should often exceed ~100ms on calm edges.
|
||
|
||
---
|
||
|
||
## 35. TipHold SAFETY-while-held + hold-last on admit stall (2026-08-01)
|
||
|
||
**Post-§34 soak:** doubles improved (`begin_refused=0`, chatter pulses
|
||
down) but **21/30 tip-holds hit SAFETY CAP 250ms**, driving
|
||
`present_gap_p95≈240ms` during menu play. Quiet almost never won:
|
||
`wire_pending_delta` treated future presses (tip already idle) as
|
||
pending, and SAFETY committed **while digital still held** → Live
|
||
key-repeat after the stall. TipHold invent-cap stalls admit with no
|
||
guest vblank, so §33 hold-last never ran.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] SAFETY deferred while any pad held (log `SAFETY deferred`); commit
|
||
only on quiet/thrash when idle, or **ABSOLUTE 2000ms** stuck-pad
|
||
escape. `sim > tip_hold_until` also refuses commit while held.
|
||
- [x] Pending quiet-block = **unabsorbed release** only (tip held, wire
|
||
delta ahead). Future press while tip idle no longer parks quiet.
|
||
Stale `block_quiet` clears once idle + no pending release.
|
||
- [x] `netplay_hold_last_present_tick()` shared by resim present path and
|
||
TipHold admit-spin (`netplay_barrier_admit`) so SAFETY/held waits
|
||
keep Swap alive. Skip frame pacer while tip-holding.
|
||
|
||
**Re-soak:** expect mostly quiet tip-hold commits (not SAFETY);
|
||
`SAFETY deferred` while holding then quiet after release;
|
||
`present_gap_p95` during menu not glued to 250ms; ABSOLUTE rare.
|
||
|
||
---
|
||
|
||
## 36. TipHold held = live pad, not frozen sim peek (2026-08-02)
|
||
|
||
**Post-§35 soak:** SAFETY deferred worked as coded — and every deferred
|
||
episode then hit **ABSOLUTE CAP 2000ms** (`held=1 pending=0`, 6/6 both
|
||
peers). Admit blew out (`admit≈988ms/f`, `1.0 fps`). Cause: invent-cap
|
||
freezes `sim`, `stage_local` latch freezes `staged`, and
|
||
`tip_hold_any_pad_held(sim)` peeked that one immutable delay-ring row —
|
||
a real controller release could never clear `held`, so quiet never armed
|
||
and ABSOLUTE was guaranteed.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] `psx_netplay_stage_local` always refreshes a live physical snapshot
|
||
(even when latched). Admit spin captures while
|
||
`psx_netplay_rb_tip_holding()` so live keeps updating.
|
||
- [x] `psx_netplay_live_pad_buttons()` + `tip_hold_any_pad_held(tip)`:
|
||
local uses live buttons; remote initially walked latest-ahead
|
||
(revised in §37 to sealed tip only).
|
||
- [x] Invent-cap stall in `try_admit` still keys off wire/staged (do not
|
||
invent-walk while the published tip pad is held — that was the
|
||
key-repeat class). Only wall-clock finalize uses live.
|
||
|
||
**Re-soak:** `SAFETY deferred` then quiet/commit soon after release
|
||
(not ~2s ABSOLUTE); ABSOLUTE only for truly stuck pads; menu fps not
|
||
collapsing to ~1 on held tip-holds.
|
||
|
||
---
|
||
|
||
## 37. TipHold remote held = sealed tip only (2026-08-02)
|
||
|
||
**Post-§36 soak:** local live worked (`SAFETY deferred` → `held=0` commit
|
||
common) but **2–3 ABSOLUTE 2000ms** remained with tip pads idle
|
||
(`s0=ffff s1=ffff`, `pending=0`). Tip **1187** was asymmetric: slot0
|
||
quiet-committed immediately; slot1 deferred then ABSOLUTE while slot0
|
||
invent-raced (`lead=-5`, `pcap FREEZE`). Cause: §36 remote `held` walked
|
||
`tip..tip+runway` and treated a **post-tip press** as held — reintroducing
|
||
the §35 over-block, but only on the peer still tip-holding.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] Remote held = sealed **tip** row only (same rule as pending-release:
|
||
tip idle → not held for finalize). Future presses tip-extend / fresh
|
||
episode after quiet.
|
||
- [x] SAFETY/ABSOLUTE logs include `held_local=` / `held_remote=` for soak
|
||
attribution.
|
||
|
||
**Re-soak:** no ABSOLUTE on idle-tip + pending=0; quiet/SAFETY after local
|
||
release; asymmetric tip-hold lead cliffs gone.
|
||
|
||
---
|
||
|
||
## 38. Post–tip-hold agreed holdoff + load clamp (2026-08-02)
|
||
|
||
**Post-§37 soak:** ABSOLUTE gone, but a **follow-NACK resim storm** hit
|
||
after dense menu tip-holds. Tip **860**: slot0 quiet-committed then
|
||
`agreed ADVANCE 860→864` and `begin load=864`; slot1 still tip-holding
|
||
(`SAFETY deferred held_local=1`, frontier=860) → `follow REFUSED past
|
||
frontier` → NACK → demote/cooldown → invent fork → baseline mismatch
|
||
ABORT chain (`mispredict=24`, `lead` spikes to 75+).
|
||
|
||
**Cause:** HC ADVANCE after tip-hold commit races a peer that has not
|
||
left tip-hold yet. Follower frontier stays at the sealed tip while
|
||
initiator opens with load > tip.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] **300ms agreed ADVANCE holdoff** after tip-hold commit
|
||
(`RB_MOTK_POST_TIP_HOLD_AGREED_HOLDOFF_MS`) — covers peer SAFETY
|
||
deferred (~250ms).
|
||
- [x] **choose_load clamp** to commit frontier **only while holdoff is
|
||
active** (revised §39 — always-on ≤runway+4 was harmful).
|
||
- [x] **begin DEFER** during holdoff if load still above frontier
|
||
(promote wire; no NACK-bait episode).
|
||
|
||
**Re-soak:** no `follow REFUSED … past frontier` right after tip-hold
|
||
commit; no NACK demote storm; look for `choose_load clamp` /
|
||
`begin DEFER … tip-hold agreed holdoff` only inside the holdoff window.
|
||
|
||
---
|
||
|
||
## 39. Gate choose_load clamp on holdoff only (2026-08-02)
|
||
|
||
**Post-§38 soak:** NACK storm gone, but hangs arrived **sooner**. After
|
||
tip-hold 757, holdoff expired and HC ADVANCEd `757→784`; the always-on
|
||
`≤runway+4` clamp (gap=27) forced `choose_load clamp 784→757
|
||
(holdoff=0)` → `SPAN CAP load=752 target=776` while mismatch=792 →
|
||
tip-extend cliffs → peer tip-hold-committed while local tip-extended →
|
||
`verify timeout (peer POST missing)` → realign to 752 with
|
||
`lead=47` / `present_gap≈4s`.
|
||
|
||
**Fix:**
|
||
|
||
- [x] choose_load clamp runs **only** when `post_tip_hold_holdoff_active()`;
|
||
post-holdoff ADVANCE is left alone.
|
||
- [x] begin DEFER likewise holdoff-only (no runway+4 gate).
|
||
|
||
**Re-soak:** no `choose_load clamp … holdoff=0`; no SPAN CAP from clamping
|
||
a healthy ADVANCE; holdoff may still log clamp/DEFER in the first ~300ms
|
||
after tip-hold commit.
|
||
|
||
---
|
||
|
||
## 40. Advertise RESOLVED on ADVANCE + gate begin on peer frontier (2026-08-02)
|
||
|
||
**Post-§39 soak:** holdoff clamp gone, but NACK returned. Tip-hold 769 →
|
||
slot0 HC `ADVANCE 769→784` → `begin load=784`; slot1 frontier still 769
|
||
→ `follow REFUSED … past frontier` → NACK → demote → cooldown →
|
||
`promote-no-resim` → SPAN CAP → tip-extend ladder → POST/baseline desync.
|
||
|
||
**Cause:** agreed ADVANCE is **local-only**. RESOLVED was only burst on
|
||
tip-hold/episode commit, so the follower's
|
||
`frontier = max(agreed, resolved_through)` stayed at the tip while the
|
||
initiator opened past it. Holdoff only covers the first ~300ms; after
|
||
that the race is back.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] Track **`g_peer_resolved_through`** from inbound `RNET_RB_RESOLVED`
|
||
only (not local tip-hold `set_peer_convergence`, which would let the
|
||
initiator self-authorize).
|
||
- [x] On HC **ADVANCE** / HEAL / BOOTSTRAP agreed raise: burst RESOLVED at
|
||
the new watermark (`advertise_agreed_resolved`) so the follower's
|
||
library `resolved_through` can accept the load.
|
||
- [x] **choose_load clamp** + **begin DEFER** when `load > g_peer_resolved_through`
|
||
(logs: `peer RESOLVED frontier` / `peer RESOLVED`).
|
||
- [x] Drain RESOLVED **before** SYNC in `psx_netplay_rb_pump` so BEGIN in
|
||
the same poll sees the updated frontier.
|
||
|
||
**Re-soak:** no immediate `follow REFUSED … past frontier` after tip-hold
|
||
+ ADVANCE; look for `agreed ADVANCE` paired with peer `choose_load clamp
|
||
… (peer RESOLVED frontier)` until both sides exchange RESOLVED, then
|
||
healthy begin at the shared watermark.
|
||
|
||
---
|
||
|
||
## 41. Bound peer-RESOLVED gate + no HEAL-FORCE advertise (2026-08-02)
|
||
|
||
**Post-§40 soak (2/2):** NACK storm gone, but `slot=1` permanently
|
||
`begin DEFER … peer RESOLVED` after `HEAL-FORCE` raised local agreed past
|
||
a stuck `g_peer_resolved_through` (tip-hold tip). Peer never ADVANCEd
|
||
(still tip-holding / forked HC), so inbound RESOLVED never rose —
|
||
unbounded gate silently blocked every invent≠wire correction for the
|
||
rest of the session. FPS stayed 60; Live desynced with no episode open.
|
||
|
||
**Cause:** §40 gated begin forever on peer RESOLVED with no timeout, and
|
||
advertised unconfirmed `HEAL-FORCE`/`BOOTSTRAP` watermarks as if they
|
||
were mutual.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] **500ms gate** (`RB_MOTK_PEER_RESOLVED_GATE_MS`): after waiting,
|
||
sticky-expire and force-open (`peer RESOLVED stall force`); NACK
|
||
remains recoverable. Re-arm only when peer RESOLVED advances or a
|
||
fresh HC-confirmed advertise raises past peer.
|
||
- [x] **Heartbeat** (`RB_MOTK_PEER_RESOLVED_HB_MS=100`): retransmit last
|
||
*confirmed* RESOLVED while peer lags.
|
||
- [x] **HEAL-FORCE / BOOTSTRAP do not advertise** RESOLVED (unconfirmed).
|
||
- [x] Tip-hold enter/commit still track `g_local_advertised_through` for
|
||
the heartbeat.
|
||
|
||
**Re-soak:** after HEAL-FORCE, expect at most ~500ms of
|
||
`begin DEFER … peer RESOLVED`, then `peer RESOLVED stall force` and an
|
||
episode open (or follow NACK→demote, not forever-DEFER). No unbounded
|
||
`frontier=<tip>` DEFER ladder.
|
||
|
||
## 42. Mutual abort realign + tip-extend abandon + fork-storm guard (2026-08-02)
|
||
|
||
**Post-§41 soak (3/3):** DEFER deadlock gone, but a *permanent core fork*
|
||
appeared. Sequence: both peers tip-hold and POST-match tip 986; slot0
|
||
absorbs a coalesce-ahead edge → tip-extend 986→996 → rereplay → Verify@996;
|
||
slot1 meanwhile SAFETY-CAP commits at 986 and leaves the episode (its
|
||
RESOLVED=986 arrives at slot0 but was ignored — below `g_post_target`).
|
||
Slot0 sits in Verify until `verify timeout (peer POST missing)` and
|
||
realigns to its pre-episode pin (**944**); slot1, receiving the ABORT
|
||
after its commit, realigns to its own pick (**986**). The two Lives then
|
||
run from different bases; every subsequent episode dies with
|
||
`baseline core mismatch local=… peer=…` in an infinite abort→realign→
|
||
reopen loop (~40 cycles until disconnect).
|
||
|
||
**Causes:**
|
||
|
||
1. **Tip-extend race:** extension from a matched tip while the peer
|
||
commits at the old tip can never verify — but we rode it into a
|
||
4-second verify timeout instead of abandoning.
|
||
2. **Asymmetric realign:** the wire `OP_ABORT` already carries the
|
||
sender's realign tick (`load` field), but the receiver ignored it and
|
||
realigned to its own local evidence — different ticks, permanent fork.
|
||
Worse, a peer that had already committed and left the episode
|
||
(epoch no longer active) skipped realign entirely.
|
||
3. **No fork backstop:** same-tick baseline-mismatch aborts repeated
|
||
forever, rubber-banding Live on every reopen.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] **P2 — tip-extend abandon** (`g_tip_extend_from_tick`): extension
|
||
from TipHold/Verify records the matched tip it extended from. If
|
||
peer RESOLVED lands in `[from, post_target)` while we sit in
|
||
Verify at the extended tip, the peer committed at the old tip —
|
||
abandon: set agreed=from (both cores matched there), prime HC,
|
||
advertise, `abort_episode` with wire class REALIGN +
|
||
realign_tick=from, realign Live to the nearest snap ≤ from, clear
|
||
cooldown. The extension edge re-arrives as a fresh wire mismatch.
|
||
- [x] **P1 — honor wire realign tick** (`honor_peer_abort_realign`): on
|
||
peer ABORT, prefer the sender's realign tick over the local pick.
|
||
If we are *ahead* of it (unilateral tip-hold commit past it),
|
||
demote agreed/resolved/HC to it and rewind — even when no episode
|
||
snap was applied. Also honored when the abort's epoch matches
|
||
`g_last_commit_epoch` (we committed and left before the ABORT
|
||
arrived — exactly the soak case).
|
||
- [x] **P4 — fork-storm guard** (`RB_FORK_STORM_LIMIT=4`,
|
||
`RB_FORK_STORM_COOLDOWN_TICKS=600`): ≥4 consecutive
|
||
baseline-mismatch aborts realigning to the same tick → loud
|
||
`rb DESYNC — unrecoverable fork` log, keep-live (no more
|
||
rubber-band rewinds), STORM class on the wire, 600-tick cooldown.
|
||
Any commit resets the streak. Backstop only — P1/P2 should prevent
|
||
the fork from forming.
|
||
|
||
**Re-soak:** on a tip-extend race expect
|
||
`rb tip-extend ABANDON — peer RESOLVED=…` (or
|
||
`rb peer abort realign honor …` on the committed side) and both peers
|
||
realigning to the SAME tick; no `baseline core mismatch` storm; the
|
||
`DESYNC — unrecoverable fork` line should never appear.
|
||
|
||
**Re-soak result (2026-08-02, 4/4):** fork storm GONE. Two tip-extend
|
||
races occurred (757→762 and 936→944-ish); both resolved cleanly via P1 —
|
||
the committed side logged `peer abort realign honor 720/912 … unilateral
|
||
tip rolled back` and both peers landed on the same tick. No baseline
|
||
mismatch, no NACK, no DESYNC. Remaining cost: P2 ABANDON never fired —
|
||
the SAFETY commit's RESOLVED burst arrived while the extender was still
|
||
mid tip-extend *rereplay* (Replay phase, e.g. slot1 commit@757 landed
|
||
during slot0's 758..762 resim), so `apply_peer_resolved`'s Verify-phase
|
||
check missed it and no further RESOLVED ever came. The extender rode the
|
||
full 4s verify timeout (4000ms present-gap freeze) before P1 healed it.
|
||
|
||
**§42b follow-up (SUPERSEDED by §42c — caused the soak-4 fork):**
|
||
|
||
- [x] Extracted the abandon into `maybe_abandon_tip_extend()` and also
|
||
call it from the Verify wait poll in `psx_netplay_rb_pump` with the
|
||
stored `g_peer_resolved_through` — the check no longer depends on
|
||
RESOLVED arriving while already in Verify. Expected effect: the 4s
|
||
freeze becomes an immediate ABANDON on the first Verify poll.
|
||
- [x] Abandon commits at the peer's RESOLVED tick itself, not
|
||
`g_tip_extend_from_tick` — with chained extends (744→751→754→757)
|
||
from_tick is only the FIRST matched tip; the peer committed at the
|
||
latest one, and its commit proves our POST there matched.
|
||
|
||
## 42c. OP_COMMIT wire signal — abandon keyed on commits only (2026-08-02)
|
||
|
||
**Soak 4 (low-latency, auto D/P):** §42b's abandon FALSE-FIRED and created
|
||
the fork it was meant to prevent. Both peers were in epoch 24, extended
|
||
760→773, resimming IDENTICAL states (audit digests match tick-for-tick
|
||
through 766) — a healthy episode that would have verified at 773. On the
|
||
first Verify poll the abandon saw sticky `g_peer_resolved_through=768`
|
||
and treated it as "peer committed and left". But 768 was a stale §40
|
||
HC-ADVANCE watermark from earlier live play (interval-aligned, 96×8) —
|
||
the peer never committed anything; it was mid-FOLLOW at tick 767. The
|
||
abandon committed agreed=768 using our own resim snap (`828285e2`, never
|
||
POSTed by anyone), aborted, and told the peer to realign to 768 — whose
|
||
ring snap there was its PRE-rereplay live state (`6cafabd5`). Same tick,
|
||
two states → baseline mismatch → fork storm at 992 → P4 DESYNC guard
|
||
fired (mechanically correct) but the fork persisted to disconnect.
|
||
|
||
**Two root errors in §42/§42b:**
|
||
|
||
1. **RESOLVED conflates two meanings** — "HC watermark advanced" (§40
|
||
live-play interval confirms) and "I committed the episode and left"
|
||
(SAFETY-commit burst). Only the latter justifies abandoning.
|
||
2. **Tick equality ≠ state equality** — a ring snap at tick T is only
|
||
the mutual state if T's state was PROVEN mutual (POST pair matched /
|
||
baseline digest-checked) and never overwritten by a later resim.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] **`RNET_RB_SYNC_OP_COMMIT` (=3)** in recomp-net session.h: episode
|
||
commit notice carrying (epoch, committed tick in the load field).
|
||
Burst (×3) from `finalize_tip_hold` and `commit_episode` alongside
|
||
RESOLVED. Receiver records `g_peer_commit_epoch/tick` and folds the
|
||
tick into the RESOLVED watermark.
|
||
- [x] **Abandon keys on OP_COMMIT only**: same epoch, commit tick <
|
||
post_target, and tick == `g_tip_extend_from_tick` (now updated on
|
||
EVERY TipHold/Verify extend, so it names the tip the current
|
||
rereplay reloaded from — the one snap provably preserved as the
|
||
POST-matched state). Exact snap required; no walk-down. Checked on
|
||
OP_COMMIT arrival and on every Verify wait poll (commit usually
|
||
lands mid-rereplay). RESOLVED no longer triggers abandon at all.
|
||
- [x] Missing conditions fall back to verify timeout + §42 P1 honor,
|
||
which soak 3 proved converges both peers at the sender's pin tick.
|
||
|
||
## 43. LAN micro-grace before gap-1 invent (2026-08-02)
|
||
|
||
**Soak 4 scheduler audit:** with `rtt_raw` 0–1ms the admit path still ran
|
||
~100% of admissions 1 tick ahead of the confirmed wire —
|
||
`invent_gap1=578/483`, ALL case A ("tip healthy/advancing"),
|
||
`invent_runway_empty` 4/0, `tip_ema≈period`. That is a sub-frame PHASE
|
||
offset between the two peers' frame boundaries, not latency; D=2 covers
|
||
the transit easily. §29's immediate-invent policy is correct for WAN (a
|
||
fixed transit delay cannot be waited out) but on LAN the needed row is
|
||
in flight and lands a few ms later; inventing instead cost slot0 96
|
||
mispredict correction episodes (vs 6 on slot1) — a large share of the
|
||
session's episode churn.
|
||
|
||
**Fix:** in the §28 adaptive split (no `PSX_RB_GAP1_GRACE_MS` override),
|
||
case A now takes a bounded micro-grace instead of `cap=0` when either:
|
||
|
||
- the link is provably fast (`rtt_raw <= 12ms`), or
|
||
- the next tip is due imminently by cadence (`tip_age + 6ms >= period`
|
||
— case A already bounds `tip_age < 1.5×period`).
|
||
|
||
Cap = `rtt_raw/2 + 3ms`, max 6ms. Expiry still invents (§29 behavior,
|
||
worst-case 6ms tax), `np_gap1_note_expire_invent` unchanged, and the
|
||
existing `gap1_grace` counter in `rb admit stats` shows how often the
|
||
wait converted an invent into a real row.
|
||
|
||
**Re-soak watch:** `invent_gap1` should collapse toward 0 on LAN with
|
||
`gap1_grace` rising instead; mispredict counts should drop accordingly.
|
||
On the tip-extend race expect `rb tip-extend ABANDON — peer COMMIT` (or
|
||
the §42 P1 `realign honor` fallback) with both peers on the same tick
|
||
and NO `baseline core mismatch` follow-up.
|
||
|
||
## 44. Real-delay consumption + scheduler extraction (2026-08-02)
|
||
|
||
**Root cause behind §27–§29/§43's permanent `remote_lead=D-1`:** the
|
||
rollback admit consumed the wire at `sim + D` — the SAME row the local
|
||
pad was sampled into on that very admit. Local input latency was zero
|
||
and D was only a numbering offset, so by construction the peer's row
|
||
for the consumption wire was still in flight on every tick: the
|
||
steady state was `pred_depth=1`, hundreds of GAP1_PHASE invents per
|
||
match, and the entire §21–§43 grace/pacing stack existed to manage a
|
||
cushion that never existed. Notably, recomp-net's own LOCKSTEP path
|
||
(`rnet_session_try_admit`) always did this correctly — "simulate wire
|
||
T while sampling local input for wire T+D" with a primed neutral
|
||
prefix — only MotK's rollback admit diverged.
|
||
|
||
**§44 real delay (default ON, `PSX_RB_ZERO_DELAY=1` restores legacy):**
|
||
|
||
- **Consumption**: guest tick T plays wire row T. All sim→wire mapping
|
||
now goes through ONE helper, `np_sched_wire_for_sim()` (admit, seal
|
||
promote, reconcile, tip-hold peeks, coalesce-ahead, pending-release
|
||
walk — 8 former `rnet_wire_tick_from_sim` call sites).
|
||
- **Production**: unchanged — `rnet_session_prepare_local_tip` samples
|
||
the pad at admit(T) into wire T+D. A press costs D ticks of local
|
||
latency and buys D ticks of transit budget; the peer's row for T
|
||
arrives ~D·period − transit BEFORE it is needed. Healthy steady
|
||
state: `remote_lead ≈ D`, `pred_depth = 0`, zero invents. The §21–§43
|
||
stack still exists but is now the RECOVERY path (lag spikes that
|
||
actually drain the cushion), not the per-tick norm.
|
||
- **Startup / delay changes**: the mutual-ready `hard_resync + prime`
|
||
already fills wires [0..D) (each peer primes its own hold);
|
||
`prepare_local_tip` (recomp-net, both copies) now back-fills EVERY
|
||
missing wire in [sim..sim+D] instead of storing only the tip, so a
|
||
mid-session DELAY_SYNC increase no longer leaves an unproduced gap
|
||
the peer must invent across. Also unifies rollback with lockstep
|
||
consumption (both wire=sim), removing a mapping discontinuity at
|
||
FMV-lockstep ↔ rollback transitions.
|
||
- **`cushion rebuilt`** (post-episode invent refusal until
|
||
`remote_lead >= D-1`) now literally means the cushion is refilled —
|
||
lead is measured against sim, so D-1 is "one in flight".
|
||
|
||
**Auto D resolution (`PSX_RB_AUTO_DELAY=0` disables):** with a real
|
||
cushion, D IS the latency/robustness tradeoff, so the host resolves it
|
||
from the link: `target = ceil(one_way/tick) + 1`, clamped [2..16],
|
||
trusted POST RTT only (>=4ms), 3 consecutive 5s evaluations must agree,
|
||
30s cooldown, and lowering additionally requires 30s freeze-free (a
|
||
pcap freeze means prediction was needed at the CURRENT D). Proposed via
|
||
the existing DELAY_SYNC path; the reactive §22 bump still covers
|
||
untrusted-RTT freeze storms. LAN: rtt untrusted/low → D stays at the
|
||
configured 2 (33ms latency, 33ms cushion).
|
||
|
||
**Scheduler extraction:** all admission POLICY moved to
|
||
`runtime/src/psx_netplay_sched.c` + `runtime/include/psx_netplay_sched.h`
|
||
(~850 lines out of psx_netplay.c): invent RTT synth, §21 grace, §27/§28/
|
||
§29/§43 gap1 policy, tip-arrival cadence, §29–§32 timesync pacing +
|
||
phase-ctrl telemetry, pcap freeze + §22 adaptive bump, admit stats, §44
|
||
wire mapping + auto delay. psx_netplay.c keeps the MECHANICS (ring
|
||
peeks, hist puts, tip-hold invent-cap, publish) and calls:
|
||
`np_sched_pre_admit` (throttle/cushion/telemetry gate) →
|
||
`np_sched_on_remote_miss` (stall vs invent decision) →
|
||
`np_sched_note_remote_hit` / `np_sched_post_admit`;
|
||
reconcile feeds `np_sched_note_mispredict`; episode boundaries call
|
||
`psx_netplay_timesync_on_episode_boundary` (unchanged name, now a
|
||
wrapper over `np_sched_note_episode_boundary`). State is bridged via
|
||
`np_sched_bind` (pointers to session/D/P/local_slot) at netplay start.
|
||
Everything in the file is host-side pacing — no guest-visible values,
|
||
so determinism is unaffected by tuning it.
|
||
|
||
**Re-soak watch:** `rb runway` lines should show `remote_lead ≈ D` and
|
||
`pred_depth=0` in steady state; `invent_gap1`/`gap1_case_a` ≈ 0 (§43's
|
||
micro-grace should almost never even arm); mispredict correction
|
||
episodes near zero outside real lag spikes; local input latency D ticks
|
||
(feel check: menu cursor). A/B against the old pipeline via
|
||
`PSX_RB_ZERO_DELAY=1` on BOTH peers (consumption mapping must match or
|
||
the sims interpret wire rows D apart — instant desync).
|
||
|
||
## 45. Tip-hold race-ahead + cushion absurd-lead guard (2026-08-02)
|
||
|
||
**Soak (post-§44):** real-delay confirmed (`wire=sim`, auto D 2→3, steady
|
||
`pred_depth=0`, no DESYNC/NACK), but a fight/menu exchange still felt like
|
||
"long rewind that doesn't play forward." Logs:
|
||
|
||
1. Slot0 tip-hold at 5078 hit **ABSOLUTE CAP 2000 ms** (`held_remote=1` on
|
||
the sealed tip) while slot1 SAFETY-committed ~250 ms and kept Live.
|
||
2. Slot0 parked at invent-cap; peer tip raced to ~5379 → `remote_lead≈280`.
|
||
3. `RNET_HISTORY_LENGTH=128` (~2.1 s) aged out the wires slot0 needed;
|
||
post-commit invent `RUNWAY_EMPTY` across the gap, then SPAN CAP
|
||
`mismatch=5375 load=5072/5120` (visual snap-back ~250 ticks, chunked
|
||
24) with `replay%=30–67%`. Hold-last present (§33) made it look like
|
||
rewind→freeze→jump, not corrected forward play.
|
||
4. Cushion rebuild cleared at `lead=11` then **`lead=280`** — treating a
|
||
park cliff as a refilled cushion, then inventing into the aged gap.
|
||
|
||
**Fixes:**
|
||
|
||
- [x] **RACE CAP** — while SAFETY-deferred (digital held), if
|
||
`highest_remote_wire > tip + 24` (one tip runway), commit
|
||
immediately. Peer tip that far ahead means they left TipHold; sitting
|
||
for the rest of ABSOLUTE only ages the ring.
|
||
- [x] **REMOTE-HELD wall 500 ms** — when `!held_local && held_remote`
|
||
(we released; sealed tip still shows peer pressed), Absolute escape
|
||
is 500 ms instead of 2000 ms. Stuck local pad keeps the full 2000 ms
|
||
wall. Coalesce/tip-extend still own the first SAFETY window.
|
||
- [x] **Cushion absurd-lead guard** — clear `cushion_rebuild` only when
|
||
`D-1 <= lead <= D+P`. Leads above that log `cushion KEEP (absurd
|
||
lead=…)` and keep invent refused so catch-up consumes real rows
|
||
instead of inventing across a cliff.
|
||
|
||
**Re-soak watch:** no more `ABSOLUTE CAP 2000` on held_remote-only while
|
||
peer tip advances; expect `RACE CAP` / `REMOTE-HELD CAP` instead. No
|
||
`cushion rebuilt remote_lead=2xx`. SPAN CAP / `replay%` spikes after
|
||
tip-hold should collapse. Presentation still hold-last during resim (§33)
|
||
— that is intentional; the goal here is to stop *needing* deep catch-up.
|
||
|
||
## 46. HC-silent promote for button mispredicts (2026-08-02)
|
||
|
||
**Problem:** MotK is digital — the portable input contract always
|
||
**rewinds** when `buttons` differ, and only uses `hash_confirm_promote`
|
||
for small stick deltas. Soak: loading-screen clicks produced
|
||
`rewind-request … pub=ffff wire=bfff → episode_open=1` even though the
|
||
game wasn't reading pads (digests would match). Ungated menu soft-promote
|
||
was tried earlier and forked RAM (sticky hold-last Up skipped a needed
|
||
resim); that path stays off.
|
||
|
||
**Fix:** before the contract rewind path, if:
|
||
- published buttons ≠ wire buttons,
|
||
- the tick is completed (`sim > t`),
|
||
- and `netplay_hc_confirm_through(t)` (both peers' FRAME_COMMIT digests
|
||
matched through t),
|
||
|
||
then promote the wire into hist (scrub-ahead on release-only) and
|
||
**do not** open an episode or feed timesync mispredict debt. Fail-closed:
|
||
if digests did not match, behavior is unchanged (real press that affected
|
||
state still resims). `PSX_RB_HC_SILENT=0` disables.
|
||
|
||
Log: `rb wire hc-silent-promote … (digests matched through t — hist only,
|
||
no resim)`.
|
||
|
||
**Re-soak watch:** loading-screen / ignored-pad clicks should log
|
||
`hc-silent-promote` with `episode_open=0`; menu/fight presses that change
|
||
state still `rewind-request`. No new baseline forks from silent promote.
|
||
|
||
## 47. Replay ownership — contiguous confirmed catch-up (2026-08-02)
|
||
|
||
**One-sentence scheduler invariant:**
|
||
|
||
> **Replay owns the simulation until it has exhausted every contiguous confirmed tick; only then does control return to Live, and TipHold is entered only when the next tick cannot yet be confirmed.**
|
||
|
||
Expanded:
|
||
|
||
- **Replay owns forward progress until no further confirmed simulation is possible.**
|
||
- **Replay must never wait for information that is already confirmed.**
|
||
- **Replay participates in the protocol exactly like Live** — produces local confirmed tip rows while consuming remote confirmed rows (not an isolated repair loop).
|
||
|
||
### Roles (non-overlapping)
|
||
|
||
| Phase | Responsibility |
|
||
|-------|----------------|
|
||
| **Live** | Predict only beyond the confirmed frontier |
|
||
| **Replay** | Consume all contiguous confirmed work as fast as possible |
|
||
| **Verify** | Digest/protocol sync between Replay SPAN chunks (barrier, not owner) |
|
||
| **TipHold** | Wait only for genuinely unavailable inputs |
|
||
|
||
Not: `Replay → Verify → TipHold → Replay` as ordinary catch-up.
|
||
|
||
### Contiguous frontier
|
||
|
||
```text
|
||
from = replay_sim + 1
|
||
|
||
confirmed_frontier = greatest contiguous tick T such that every seat has a
|
||
confirmed (non-predicted) row for every tick from `from`
|
||
through T. If `from` is incomplete → frontier = replay_sim.
|
||
|
||
Example (replay_sim=99 → from=100):
|
||
100 ✓ 101 ✓ 102 missing 103 ✓ 104 ✓ → frontier = 101 (not 104)
|
||
```
|
||
|
||
Helpers: `psx_netplay_rb_confirmed_frontier(from)`,
|
||
`psx_netplay_rb_confirmed_remaining()`, `psx_netplay_rb_ownership_step()`.
|
||
|
||
### Ownership loop
|
||
|
||
While Replay is active (decision site: ownership_step + POST match):
|
||
|
||
1. If `frontier > target` and within `RB_MAX_RESIM_SPAN` of seal_base → tip-extend.
|
||
2. If chunk full but `frontier > tip + 1` after Verify POST match → **chain**
|
||
next SPAN episode immediately (no TipHold invent-cap / quiet wall).
|
||
`frontier == tip + 1` returns to Live (§48 — avoid 1-tick chain thrash).
|
||
3. If `frontier <= tip` after POST match → Final Verify → Live
|
||
(`enter_tip_hold` + immediate `finalize_tip_hold`).
|
||
|
||
Verify remains the digest agreement point between chunks; Replay ownership
|
||
continues across chunk boundaries until contiguous confirmed work is exhausted.
|
||
|
||
### Tip refill + present cadence
|
||
|
||
- Each Replay admit / resim `finish_frame` calls `prepare_local_tip` from the
|
||
live physical pad (`np_rb_produce_local_tip_for_sim`).
|
||
- Sim stays uncapped (pacer skipped during resim).
|
||
- Remaining contiguous span ≤ 8: §33 hold-last present.
|
||
- Longer catch-up: wall-clock period presents **live** Replay VRAM
|
||
(`rb catchup … present=live`); otherwise skip present and keep draining.
|
||
|
||
**Re-soak watch:** mispredict → continuous `rb arm`/`finish_frame` /
|
||
`ownership extend|chain` until frontier exhausted; no TipHold invent-cap mid
|
||
confirmed catch-up; gap at T stops frontier at T−1; local tip advances during
|
||
Replay; TipHold only when a remote row is actually missing.
|
||
|
||
## 48. Sticky invent Up → ownership-chain thrash (2026-08-02)
|
||
|
||
**Problem:** Post-FMV unlock, gap1 hold-last invent sticky D-pad
|
||
(`pub=ffef` Up) vs wire release (`ffff`) opened a tip episode (~902). After
|
||
Verify, both peers chained SPAN for `frontier=tip+1`, dual-initiated every
|
||
chunk (tie-break WIN/YIELD), and `agreed_span_lo=tip` emptied the dense load
|
||
span so the next begin walked back to `%iv` snaps (`904→896`) and re-replayed
|
||
the Up stretch — menu ownership thrash / dual-init abort loop.
|
||
|
||
**Fix:**
|
||
1. **Unlock-grace soft-promote:** while `psx_netplay_rb_fmv_unlock_grace_active()`
|
||
(RELEASE…`dense_until`), invent→release-only mispredicts promote+scrub hist
|
||
without opening an episode (presses still rewind).
|
||
2. **Ownership chain:** chain only when `frontier > tip + 1`; only seat 0
|
||
calls `begin_rewind` (seat 1 commits tip watermark and waits FOLLOW SYNC);
|
||
preserve `agreed_span_lo` from the prior episode load so choose_load keeps
|
||
the dense committed span.
|
||
|
||
**Re-soak watch:** after FMV into menu, `soft-promote … unlock-grace-release`
|
||
or no `rewind-request pub=ffef`; no `tie-break YIELD` / `ownership chain
|
||
tip=N frontier=N+1` storm; D-pad navigation stays single-step.
|
||
|
||
## 49. WAN ownership micro-chains + GO loss + realign tip cliff (2026-08-02)
|
||
|
||
**WAN soak (FORCE_TURN / hotspot):** FMV settle hang fixed (no invent-hold
|
||
deadlock). Session played ~60 fps until ~sim 1231, then ownership chained
|
||
SPAN every few ticks (epochs 16→64), guest `ready timeout (no initiator GO)`
|
||
while host sat in Verify, dual realign to ~1264 with stale `remote_tip≈1544`
|
||
→ `WIRE_HOLE` + `cushion KEEP (absurd lead=279)`.
|
||
|
||
**Fix:**
|
||
1. **Ownership chain floor** `frontier > tip + N` (env `PSX_RB_CHAIN_MIN_AHEAD`;
|
||
§49 shipped N=8; **§73** lowers default to **4** after tip+8 tip-held every
|
||
POST). Micro catch-ups below the floor stay Live / tip-hold.
|
||
2. **Initiator GO rexmit** until peer POST (TURN dropped the one-shot burst);
|
||
ready timeout scales with RTT (4s…12s).
|
||
3. **INPUT ACK resend** when peer ack lags the tip-redundancy window.
|
||
|
||
**§49b (same day):** Live realign must **not** `clear_remote_inputs`. Full wipe
|
||
→ `remote_tip=0` → mutual `pcap FREEZE` (wire≫P; neither advances). Selective
|
||
invalidate also fails after ring wrap (only far tips remain). Pre-clear soak
|
||
already recovered: peer post-realign production fills `need` while
|
||
`prepare_local_tip` keeps emitting; §45 cushion refuses invent across holes
|
||
until real rows land. API kept for hard_resync-style paths only.
|
||
|
||
**Re-soak watch:** no `ready timeout (no initiator GO)` under TURN; no
|
||
`ownership chain tip=N frontier=N+1..N+3` storms (floor is tip+4); after abort
|
||
realign no `pcap FREEZE … remote_tip=0` / `remote tips cleared`.
|
||
|
||
## 50. FMV tip-extend through lockstep → BIOS realign / CD fork (2026-08-02)
|
||
|
||
**WAN soak (§49b binary):** handshake + GO healthy; ~55–60 fps; death was a
|
||
permanent core+CD fork after the second FMV (locked at tick 1136
|
||
`ea3971ce` vs `fbb8c8aa`, CD `7ac33b56` vs `ac7c89e4`). First fork was earlier:
|
||
|
||
1. Episode opens pre-FMV (`begin epoch=8 mismatch=112 load=96`).
|
||
2. FMV media/settle arms mid-Replay — begin/follow refuse *new* episodes, but
|
||
**tip-extend still raised** the tip (`116→122`) and TipHold-rereplayed.
|
||
3. Guest `ownership final tip=119 → Live`; host tip-extends `119→122` with
|
||
rereplay; guest `follow REFUSED — FMV lockstep` on the post-commit BEGIN.
|
||
4. Host `tip-extend ABANDON` realigns to snap `pc=0xbfc03c04` (BIOS sticky);
|
||
`FIRST CORE DIVERGE sim=121`; CD digests split and never fully heal.
|
||
|
||
**Fix:** Gate `psx_netplay_rb_tip_extend` and tip-extend FOLLOW on the same
|
||
`rb_in_fmv_lockstep_window()` as begin/follow. TipHold + FMV →
|
||
`finalize_tip_hold` (commit sealed tip). Replay + FMV → keep current target
|
||
(no raise). Entering FMV while TipHolding also commits immediately.
|
||
|
||
**Re-soak watch:** no `tip-extend …` / `tip-extend FOLLOW` during
|
||
`FMV rewind-defer` / settle; no `tip-extend ABANDON` → `pc=0xbfc03c04` after
|
||
first movie; no early `FIRST CORE DIVERGE` ~121; cores/CD stay matched through
|
||
menu + second FMV.
|
||
## 51. choose_load vs Replay ownership vs present (2026-08-02)
|
||
|
||
Independent layers (do not fix load bugs by changing Replay behavior):
|
||
|
||
```text
|
||
Layer 1 choose_load Where do I start?
|
||
Layer 2 commit-tip snap Prefer newest safe snap that exists
|
||
Layer 3 Replay ownership How do I reach the frontier? (no restart)
|
||
Layer 4 Presentation How does the player see it?
|
||
Layer 5 SPAN size Last — only if depth still justifies it
|
||
```
|
||
|
||
**Invariant (Layer 1):** `choose_load` returns the **newest snapshot both peers
|
||
can prove is safe** to replay from. Proof sources: commit/ADVANCE watermark,
|
||
hash_confirm, peer RESOLVED. Peer RESOLVED must **not** lower the load below
|
||
an HC-proven floor (soak: `ADVANCE 1189→1248` then `clamp→1189` → interval
|
||
`load=1184` → 65-tick SPAN storm / visual twitch).
|
||
|
||
**Layer 2:** Inclusive dense span `[span_lo, through]` so the commit tip itself
|
||
is preferred over `%iv` fallback when `span_lo == tip`.
|
||
|
||
**Layer 3:** Ownership SPAN chain continues Replay at the verified tip with
|
||
`skip-snap` (baseline from current state) — SPAN is bookkeeping, not a
|
||
semantic restart (`snap applied` every chunk was the twitch).
|
||
|
||
**Layer 4:** Catchup live present gated at ~150 ms (not ~frame period).
|
||
|
||
**Re-soak watch:** after menu mispredict, `choose_load` near mismatch (depth
|
||
≲ iv); no `clamp … peer RESOLVED` that drops below `hc_floor`; chain logs
|
||
`ownership continue skip-snap`; `replay%` / present flashes much lower;
|
||
`RB_MAX_RESIM_SPAN` unchanged.
|
||
|
||
## 52. Seal rows had no rexmit → silent AwaitingBaseline deadlock (2026-08-02)
|
||
|
||
**WAN soak (§51 binary):** late-session 4s freeze. Host tip-extended
|
||
`2598→2599` right after entering Replay (light tip), reached POST, then
|
||
`verify timeout` → `episode ABORT` → both realign to 2576. Guest never
|
||
entered Replay: baselines matched (`9bf062ce` both), ready=1, phase stuck at
|
||
AwaitingBaseline with **zero log output** — the only silent early return in
|
||
`maybe_enter_replay`'s gate chain is `!rnet_rb_all_peer_seal_rows_complete`.
|
||
|
||
**Root cause:** `export_local_seals()` fired only on discrete events
|
||
(begin / tip-extend / FOLLOW) with no retransmit. The guest's FOLLOW
|
||
extension grew its `sealed_span` to include 2599, but the host's post-extend
|
||
re-export carrying the slot-0 row for 2599 was a single unacknowledged UDP
|
||
burst — one WAN drop (hotspot, RTT 72ms) parked the guest forever. Baseline
|
||
and GO already had keepalive rexmit (§49); seals did not. The receive-side
|
||
stash comment even assumed "the sender's normal chunk retransmit cadence
|
||
re-delivers it" — that cadence never existed.
|
||
|
||
**Fix:**
|
||
1. **Seal keepalive** `maybe_rexmit_seals()` — rides `maybe_send_baseline`'s
|
||
call path, re-runs `export_local_seals()` on the `RB_BASELINE_BURST_MS`
|
||
cadence until the peer's POST arrives (POST proves a complete seal table).
|
||
Idempotent receive (rows OR into `peer_seal_mask`), ≤3 chunks per send.
|
||
2. **Named stall diag** — `!all_peer_seal_rows_complete` in
|
||
`maybe_enter_replay` now logs once per episode after 500ms:
|
||
`rb waiting peer seal rows base=… span=… slotN=0`.
|
||
|
||
**Re-soak watch:** no verify-timeout abort where the peer log shows matched
|
||
baselines + ready but no `rb replay`; if `rb waiting peer seal rows` appears
|
||
it should be followed by `rb seal rexmit` and a normal Replay entry, not an
|
||
abort.
|
||
|
||
## 53. Live ADVANCE cadence + choose_load max floor (2026-08-02)
|
||
|
||
**WAN soak (§52 binary):** match-start countdown mispredict felt like a deep
|
||
rewind into the intro. Logs:
|
||
|
||
```text
|
||
host: agreed HEAL 1069→2336 (hc=2347) → begin load=2336 mismatch=2461
|
||
guest: agreed HEAL 1069→2448 (hc=2460) → follow load=2336 (host BEGIN)
|
||
```
|
||
|
||
Depth 125 with snaps through 2448 and guest HC at the mismatch — Layer 1
|
||
violated again. Seal keepalive (§52) was healthy (`seal rexmit` → Replay).
|
||
|
||
**Root cause:**
|
||
1. `advance_agreed_watermark_from_hc` / aged HEAL only ran at begin/follow, so
|
||
`agreed` froze at tip-hold commit **1069** for ~1400 Live ticks until the
|
||
snap aged out (`oldest=1440`), then emergency HEAL jumped to each peer's
|
||
*local* HC interval (asymmetric: 2336 vs 2448).
|
||
2. `choose_load` set `shared = agreed` and refused to raise via HC ("ADVANCE
|
||
already promotes") — so initiator BEGIN locked both peers to the worse
|
||
HEAL. Host HC itself lagged (~2347 vs guest ~2460) from invent-fork stalls
|
||
in the 128-slot ring; peer RESOLVED from a Live-ADVANCE'd guest would have
|
||
proven 2448, but raise-via-peer was not allowed.
|
||
|
||
**Fix:**
|
||
1. **Live cadence** — every `psx_netplay_rb_pump`: `hc_heal_stale_gap` +
|
||
`advance_agreed_watermark_from_hc` (existing guards skip episodes /
|
||
TipHold / post-tip-hold holdoff). Agreed tracks HC through countdown.
|
||
2. **`choose_load` raise** — outside tip-hold holdoff,
|
||
`shared = max(agreed, hc_interval_floor, peer_resolved)`.
|
||
|
||
**§53b (same day — regression from first cut):** Live pump also ran aged
|
||
`heal_agreed_watermark_if_aged_out`, and raise used raw `hc_floor`. Soak:
|
||
|
||
```text
|
||
choose_load raise 752→765 → follow NACK (frontier=752 unconfirmed tip)
|
||
HEAL-FORCE 752→816→880→944→1008 (hc stuck) → baseline core mismatch forever
|
||
```
|
||
|
||
HEAL-FORCE does not advertise; under a live HC stall it invented unconfirmed
|
||
agreed tips every pump. Raw hc tip (765) is not mutual with follower's
|
||
ADVANCE'd interval frontier (752).
|
||
|
||
**Corrected:**
|
||
1. Live pump: **ADVANCE only** — aged HEAL / HEAL-FORCE stay at begin/follow.
|
||
2. Raise / peer-RESOLVED clamp floor: **HC-confirmed interval snaps only**
|
||
(`hc_interval_floor`), plus peer RESOLVED — never raw tip `hc_floor`.
|
||
|
||
**Re-soak watch:** periodic `agreed ADVANCE …` through Live; no Live
|
||
`HEAL-FORCE` cascades; no `choose_load raise …→` non-interval tip; no
|
||
follow NACK on load just above agreed interval; no repeating
|
||
`baseline core mismatch` after first menu edge. Countdown mispredict still
|
||
loads near mismatch when peer RESOLVED / HC interval is current.
|
||
|
||
|
||
## 54. Silent permanent fork: cooldown-swallowed correction + no HC recovery (2026-08-02)
|
||
|
||
**WAN soak (§53b binary):** resim-purity abort at sim≈1363 (local pad edge
|
||
sampled mid-resim on the guest corrupted the sealed tip) → episode ABORT →
|
||
cooldown. At sim=1370 a *completed-tick* pad mispredict arrived during the
|
||
cooldown window; reconcile's `no_resim` branch promoted the wire row into
|
||
history and moved on. The live state had already simmed the wrong pad — both
|
||
peers then ran ~1000 ticks at a smooth 60 fps with permanently diverged
|
||
cores. No new pad mispredicts occurred, so no episode ever opened again;
|
||
`FIRST CORE DIVERGE` was log-only.
|
||
|
||
**Two gaps, two fixes (both in `psx_netplay.c`):**
|
||
|
||
1. **Deferred rewind for cooldown promotes.** The `no_resim`
|
||
(sweep / cooldown / fmv-lockstep) branch now records the earliest
|
||
`pads_differ && completed` tick it promote-only'd
|
||
(`s_deferred_rw_{valid,tick,slot}`). Once the window ends
|
||
(`!no_resim && !fmv_defer`), reconcile either drops it — if
|
||
`hc_confirm_through(t)` proves both peers matched after the tick anyway —
|
||
or opens the episode the cooldown suppressed
|
||
(`rb deferred rewind t=… slot=…`). Refusals retry next pump.
|
||
2. **HC-fork recovery episode.** `np_check_core_diverge` no longer stops at
|
||
logging: if the `hc_peek_mismatch` fork persists ≥16 ticks (well before
|
||
the 128-slot hc ring wraps and peek goes quiet) with no episode / TipHold /
|
||
pending load / cooldown / FMV window, the **slot-0 host** (single-initiator,
|
||
avoids dual-BEGIN collision) opens a recovery episode at the fork tick
|
||
(`rb hc-fork recovery begin t=…`), correction seat = remote slot. Retries
|
||
every 32 ticks while the fork persists; guest follows via the normal wire
|
||
SYNC. `choose_load` starts it at the newest provably-safe snap (HC is
|
||
confirmed through fork−1 by definition).
|
||
|
||
Fix 1 should catch the divergence at its source; fix 2 is the backstop for
|
||
*any* fork that slips past pad-level reconciliation (invent races, purity
|
||
bugs, CD timing).
|
||
|
||
**Re-soak watch:** after any `episode ABORT` + cooldown, a mispredict inside
|
||
the window should be followed by `rb deferred rewind …` → normal Replay (or
|
||
silence if HC confirmed through it); no repeat of the smooth-60fps silent
|
||
fork — if cores split without a pad edge, expect `rb hc-fork recovery begin`
|
||
within ~16 ticks and digests re-matching after the episode.
|
||
|
||
## 55. Resim purity + dead-timeline evidence + fork bisect (2026-08-02)
|
||
|
||
**WAN soak (§54 binary):** §54's hc-fork recovery healed two real silent forks
|
||
(t=2627 after a seal-timeout abort, t=4049 after a tip-extend abandon) —
|
||
matched baselines, clean replay, session continued. The session still died at
|
||
sim≈4160 to a three-stage failure:
|
||
|
||
1. **Purity leak (root):** during the epoch=145 tip-extend chain the host
|
||
armed tick 4179 with the guest seat unsealed — `audit arm sim=4179 …
|
||
s1=----`. `rnet_rb_extend_target` zeroes remote-seat rows (peer SEAL_ROWS
|
||
fill them later) and `publish_sealed_sio` silently falls back to live hist
|
||
for any missing seat. The per-tick audit digest is CPU-only, so the RAM
|
||
fork was invisible until the FRAME_COMMIT abort at 4180
|
||
(`resim core diverge`).
|
||
2. **Dead-timeline evidence:** both peers realigned to the matched 4160 snap,
|
||
then the guest ran `agreed ADVANCE 4160→4176 (hc=4179 live confirm)` — hash
|
||
confirms from the *aborted* timeline. Each peer re-simmed 4161..4176 with
|
||
its own history → permanently diverged 4176 snaps (`4ced9093` vs
|
||
`accdaaec`) that choose_load kept certifying as provably safe.
|
||
3. **Same-snap retry loop:** four recovery episodes all loaded 4176, all died
|
||
on `baseline core mismatch`, cooldowns escalated to the DESYNC
|
||
storm-breaker, the fork tick aged out of the 128-slot hc ring, peek went
|
||
quiet, and both sides ran silently forked at 60 fps until disconnect.
|
||
|
||
**Fixes (one per stage):**
|
||
|
||
1. **Seal authority (lib `rnet_rollback.c` + runtime arm gate).**
|
||
- `rnet_rb_fill_local_row`: remote seats now pre-seal from host history
|
||
when the row is wire-confirmed (`!is_predicted`) — that is the owner's
|
||
transmitted pad, byte-identical to what the owner seals, so it is
|
||
authoritative without waiting for SEAL_ROWS. Predicted rows stay
|
||
unsealed.
|
||
- `rnet_rb_resign_slot_range`: the peer-seal mask bit is only credited for
|
||
wire-confirmed rows on remote seats — invented pads no longer count as
|
||
authority (they used to satisfy `all_peer_seal_rows_complete`).
|
||
- New `rnet_rb_seat_row_authoritative()` + runtime `rb_arm_rows_ready()`:
|
||
Replay may not arm a sealed-span tick until every seat's row is
|
||
authoritative (local / peer-delivered / wire-confirmed). Stall sites:
|
||
`try_admit`, the `finish_frame` chain-arm, and `arm_rereplay_after_load`
|
||
(`rb arm wait rows sim=… slot=…`). Seal keepalive (§52) redelivers rows;
|
||
`RB_REPLAY_STALL_MS` aborts a genuinely dead episode.
|
||
2. **Realign evidence clamp** (`realign_invalidate_evidence`, at realign
|
||
queue AND apply): hash_confirm primed after the realign tick, agreed /
|
||
peer-RESOLVED / local-advertised watermarks clamped to it, and ring snaps
|
||
above it dropped (`netplay_snap_ring_drop_after`). Post-realign live
|
||
FRAME_COMMITs then re-prove (or immediately re-flag) the timeline from the
|
||
realign point instead of inheriting confirms for states that no longer
|
||
exist. Log: `rb realign evidence clamp tick=… (snaps_dropped=…)`.
|
||
3. **Fork bisect cap** (`g_bl_fork_cap`): a baseline core mismatch at load L
|
||
proves the fork predates L. choose_load now forces the next load strictly
|
||
below the failed one (`rb fork cap`, `rb choose_load bisect load=…`),
|
||
refusing episodes when nothing provably safe remains below. Cleared on any
|
||
verified commit or session reset.
|
||
|
||
**Re-soak watch:** no `arm … s?=----` audits inside a sealed span (grep
|
||
`s0=----|s1=----` between `rb replay` and POST); occasional
|
||
`rb arm wait rows` immediately healed by `rb seal rexmit` is fine; after any
|
||
abort expect `rb realign evidence clamp` and NO `agreed ADVANCE` above the
|
||
realign tick until fresh live confirms; if a baseline mismatch does occur,
|
||
the next attempt should log `choose_load bisect` at a lower load instead of
|
||
retrying the same snap 4×.
|
||
|
||
## 56. Scheduler equilibrium: cushion-rebuild exit, pipeline slack, scorecard (2026-08-02)
|
||
|
||
**Post-§55 soak:** correctness held (no baseline mismatches, no desync, one
|
||
self-healed aux diverge) but the scheduler never reached its own contract.
|
||
`lead_avg` sat at 0…−1 the whole session instead of ≈ D=2, `invent_gap1`
|
||
climbed continuously (~225 by session end), and `cushion rebuilt` fired at
|
||
`remote_lead=1` with D=2 — one tick short of full. The user-reported symptom
|
||
is the consequence: episodes fire back-to-back because live play permanently
|
||
runs at the edge of the cushion, so every arrival wobble becomes an invent →
|
||
mispredict → episode.
|
||
|
||
Reordered roadmap (user, 2026-08-02): 1) scheduler equilibrium, 2) continuous
|
||
replay ownership, 3) dense live snapshots, 4) jitter-driven adaptive D,
|
||
5) episode extension/reuse. This section is step 1's first slice: fix the one
|
||
provable exit bug and instrument the pipeline so the remaining phase loss is
|
||
measured, not guessed.
|
||
|
||
**Changes:**
|
||
|
||
1. **Cushion-rebuild exit (`np_sched_pre_admit`).** The old exit cleared at
|
||
`remote_lead >= D-1` — the rebuild "completed" one tick short and the next
|
||
miss invented into the missing tick. Now: `lead >= D` clears immediately
|
||
(`rb cushion rebuilt FULL`); `lead == D-1` only clears after holding
|
||
400 ms (`rb cushion rebuilt NEAR … held=…ms`), giving the pipeline a real
|
||
chance to top up while still never deadlocking a link that genuinely
|
||
plateaus at D−1. Below D−1 the behavior is unchanged (wait; absurd-lead
|
||
guard intact). Rebuild-window start is stamped at
|
||
`np_sched_note_episode_boundary`.
|
||
2. **Pipeline slack instrumentation (lib + sched).** New
|
||
`rnet_session_remote_arrival_age_ms(s, slot, wire)`: every remote wire row
|
||
gets a monotonic arrival stamp at `store_remote_frame` (first-wins,
|
||
128-deep, mirrors the ring); the scheduler samples, once per sim at
|
||
pre-admit, how long the row for the consumption wire had been sitting
|
||
there. That is the *time-domain* cushion the tick-domain `remote_lead`
|
||
quantizes away: healthy ≈ D·16 ms − transit; ≈0 ms means rows arrive
|
||
just-in-time and the cushion exists on paper only. Misses at need are
|
||
counted via `on_remote_miss` (`miss_need`).
|
||
3. **Equilibrium scorecard (~30 s cadence, both peers).**
|
||
`rb scorecard dt=… ep=+N depth_avg=… gap1=+… tip_stale=+… miss_need=…
|
||
lead avg/min/max slack avg/min/max n=… D=… cushion=…` — per-window deltas
|
||
(episode count from `psx_netplay_rb_episode_count`, replay depth from the
|
||
new cumulative `psx_netplay_rb_replay_ticks_total`, which is independent
|
||
of the FPS logger's take-and-reset counter). Scheduler changes should move
|
||
`ep`/`gap1`; snapshot changes should move `depth_avg` — the two are now
|
||
separable per soak window.
|
||
|
||
**Re-soak watch:** compare `rb scorecard` lines across the session and
|
||
between peers. Expect `cushion rebuilt FULL` (or NEAR with held≈400ms) after
|
||
episodes instead of instant lead=1 exits. The `slack avg` value is the key
|
||
new datum: if it reads near zero while `lead avg` looks healthy, the produce
|
||
→consume pipeline is consuming the whole cushion in phase (production or
|
||
send timing), and that — not invent policy — is the next equilibrium fix; if
|
||
slack is healthy but `miss_need`/`gap1` still climb, the losses are arrival
|
||
jitter and step 4 (jitter-driven adaptive D) moves up.
|
||
|
||
## 57. Arrival-driven adaptive D + transit-aware targets (2026-08-02)
|
||
|
||
**§56 soak verdict:** the scorecard closed the "where did the cushion go?"
|
||
question. Slack was NOT near zero (24–36 ms of buffered arrival age), lead
|
||
matched the equilibrium the delay budget permits once transport is counted
|
||
(`lead ≈ D−1−transit`: predicted 0.6/2.1 for the two peers, measured
|
||
0.24/0.91), yet `miss_need` stayed at ~25% of ticks and episodes scaled with
|
||
`miss_need`, not with scheduler instability. Conclusion: the scheduler
|
||
converges to the equilibrium permitted by the current delay budget and the
|
||
measured transport latency — the budget itself is what's short. D=4 minus
|
||
1 pipeline tick minus ~2.5 ticks of one-way transit leaves ~0.5 ticks of
|
||
margin; every jitter excursion becomes a miss → invent → mispredict lottery.
|
||
Correctness layer held throughout (§54 hc-fork recovery healed the early
|
||
fork; two resim-diverge aborts realigned cleanly under the §55 evidence
|
||
clamp; zero baseline mismatches / desyncs).
|
||
|
||
Roadmap consequence (user-confirmed): adaptive provisioning moves to the
|
||
front — it reduces how OFTEN rollback happens; dense snapshots then reduce
|
||
how MUCH each one costs; replay ownership the efficiency of the remainder.
|
||
|
||
**Changes:**
|
||
|
||
1. **Auto-delay signal replaced: RTT → arrival stream** (`np_auto_delay_tick`
|
||
§57 rewrite). The POST RTT EMA measured 80 ms on the host and 12 ms on the
|
||
guest for the same link — unusable. The controller now consumes exactly
|
||
what the scheduler consumes, per 5 s eval window of LIVE ticks only
|
||
(episode / tip-hold / |lead|≥32 samples excluded):
|
||
- `miss_rate` — rows absent at need (from `on_remote_miss`, once/wire);
|
||
- `lateness` — first-miss → arrival delta for misses we waited out
|
||
(timer armed in `note_miss`, resolved in `note_remote_hit`, invalidated
|
||
if the miss was invented instead; >2 s artifacts discarded);
|
||
- `transit_est` — EMA of `D−1−lead_avg` in 1/16-tick units, maintained on
|
||
BOTH peers (drives local targets; host alone proposes DELAY_SYNC).
|
||
Raise: `miss_rate > 2%` → `D += ceil(late_avg/tick)` (1..2/step), confirm
|
||
on 2 evals (10 s). Lower: `miss_rate < 0.2%` AND ≥2 spare ticks of lead,
|
||
confirm on 3 evals + 30 s cooldown + no pcap freeze in 30 s. Asymmetric
|
||
on purpose: a raise costs only input latency, a miss costs determinism.
|
||
Log: `rb auto delay X → Y (arrival: miss=… late avg/max … lead_avg=…
|
||
transit_est=…)`. The §22 reactive pcap-storm bump is unchanged.
|
||
2. **Cushion-rebuild target is now the achievable lead** (`FULL` unchanged at
|
||
`lead ≥ D`; the D−1 NEAR exit is replaced by
|
||
`achievable = clamp(D−1−round(transit_est), 1, D−1)` with the 400 ms
|
||
hold → `rb cushion rebuilt ACH …`). §56's NEAR exit spent 12.6 s parked
|
||
against a target the link physically cannot reach.
|
||
3. **Scorecard gains `transit_est=…`** so soak logs show the controller's
|
||
view of the link next to the raw lead/slack numbers.
|
||
|
||
**Re-soak watch:** expect `rb auto delay ON (§57 …)` at session start and,
|
||
on the WAN link, a stepped climb (4→5/6 within ~20 s of fight start) with
|
||
each step logging its arrival evidence; after it settles, `rb scorecard`
|
||
should show `miss_need` collapsing toward zero, `gap1` deltas an order of
|
||
magnitude down, `ep=+N` per window dropping toward low single digits, and
|
||
`lead avg` rising to ≈ `D−1−transit_est`. `cushion rebuilt ACH` should
|
||
replace the long NEAR holds. If `miss_need` stays high at D=6–7 with
|
||
`transit_est` stable, the residue is loss/retransmit (wire holes), not
|
||
provisioning — investigate `rb wire hole` lines before raising D further.
|
||
Trade-off to feel for in play: each +1 D adds ~17 ms of local input latency;
|
||
if the WAN session feels laggy but smooth, that's the controller working —
|
||
step 3 (dense snapshots) then attacks replay cost so D can ride lower.
|
||
|
||
## 58. Dense tip snapshots + Start debounce in replay produce (2026-08-02)
|
||
|
||
**§57 soak verdict (healthiest yet — committed):** the arrival-driven
|
||
controller worked with evidence on every move (`5→6` at 153‰ miss / 11 ms
|
||
lateness, later `6→5` at 0‰ sustained). Scorecard trend across windows:
|
||
miss_need 198→150→32 (host) / 72→19→17 (guest), gap1 +116→+24 / +50→+8,
|
||
episodes ~1–2 per 30 s (was 6–8), `lead avg` sitting on the achievable
|
||
`D−1−transit_est`, `cushion rebuilt ACH` replacing the NEAR parks. One abort
|
||
per side, zero baseline mismatch / desync. Two items remained: replay
|
||
`depth_avg` 14–41 with pred_depth 1–2 (snap granularity dominates episode
|
||
cost), and one user-visible bug — a Start press opened TWO overlapping pause
|
||
menus (online only).
|
||
|
||
**1. Start doubling (historical note):** initially blamed on §47 replay
|
||
produce bypassing §34 dig sticky. Later soaks + DuckStation/keyboard
|
||
isolation showed post-match **taunt** double-pause is in-game MotK; sticky
|
||
filters were removed (2026-08-03). Snap-load continuity (§79) still covers
|
||
Replay `tip_without_stage` false edges.
|
||
|
||
**2. Dense tip snapshots (`PSX_NET_SNAP_DENSE`, default 8, 0=off, max 24).**
|
||
Live path now saves EVERY tick; non-interval ticks are tracked in a sliding
|
||
FIFO and evicted from the ring once they age past the window (pinned
|
||
baseline / episode load / commit-span snaps are never dense-evicted), so
|
||
interval history is untouched. Ring depth 64→80 to absorb the extra
|
||
occupancy. Cost: one raw boot_state save per frame instead of per 16 —
|
||
watch the [FPS] guest ms/f; PSX_NET_SNAP_DENSE=0 restores §57 behavior.
|
||
|
||
**3. choose_load can now raise to the raw HC-proven tick** when a local
|
||
snap exists there (`hc_dense=` in the raise log). The §53b interval-only
|
||
raise rule predates dense snaps; with both peers keeping the same window the
|
||
snap is normally present on the follower too, and when it is not, follower
|
||
NACK → demote already covers it (one extra round trip, rare). Together with
|
||
the walk's existing HC-confirmed acceptance, a near-tip mispredict should
|
||
now load at (or within a tick of) the mismatch.
|
||
|
||
**Re-soak watch:** `rb snap ring ready … tip_dense=8`; scorecard
|
||
`depth_avg` should drop from 14–41 toward `pred_depth + handshake span`
|
||
(single digits); watch `[FPS]` guest ms/f for the per-frame save tax (if
|
||
Live drops below 60, set PSX_NET_SNAP_DENSE=4 or 0 and re-evaluate);
|
||
occasional `choose_load raise … hc_dense=…` lines are the new path working —
|
||
a burst of follower NACK/demotes right after them would mean the peer's
|
||
dense window is out of phase and the raise should be narrowed. For the Start
|
||
fix: pausing near resim activity should be single-menu; if a double recurs,
|
||
enable the pad edge log and capture the row sequence.
|
||
|
||
**Pad-trace (2026-08-02):** for Start/menu doubles use
|
||
`PSX_RB_PAD_TRACE=1` (implies richer pipeline logging; keep
|
||
`PSX_RB_PAD_LOG=1` for classic `pad-edge` lines). Checkpoints:
|
||
`dev` (capture card / raw SDL Start / fallback), `stage` (raw vs
|
||
debounce / latch), `live-only` (SDL edges while sim latched),
|
||
`tip-produce`, `sio` (`apply_n` + DUP). Automatic lines:
|
||
`VERDICT pulse` (gap≤2), `multipress` (gap≤60), `dup_sio`,
|
||
`sample_vs_apply` (stage↓ vs sio↓ at +D — not a bug),
|
||
`tip_without_stage`. One deliberate Start → read the VERDICT.
|
||
|
||
**Pad sticky removed (2026-08-03):** Start sticky (8 ticks) and dig sticky
|
||
(2 ticks) both removed. Taunt double-pause is in-game (DuckStation too);
|
||
dig sticky was merging intentional double-taps across 1–2 tick idle holes.
|
||
|
||
### Start cadence bisect (2026-08-02)
|
||
|
||
Do **not** raise debounce further until this answers where online first
|
||
diverges from offline. Env `PSX_START_BISECT=1` logs every sample:
|
||
|
||
```
|
||
start-bisect n=… mode=offline|online path=offline|cap|stage|tip|sio|spin
|
||
wall_ms=… sim=… sdl=… cap=… deb=… sio=… latch=… tip_hold=… resim=…
|
||
```
|
||
|
||
**Stage 1:** hold Start ~1s offline, then online (same pad). Compare
|
||
`sdl=` streams. If they differ → scheduler/admit cadence. If identical →
|
||
move downstream (`cap` → `deb`/`stage` → `sio`).
|
||
|
||
**Stage 3 toggles** (one at a time, rebuild not required — env only):
|
||
|
||
| Env | Effect |
|
||
|-----|--------|
|
||
| `PSX_START_BISECT_NO_GC_UPDATE_IN_ADMIT=1` | Skip `SDL_GameControllerUpdate` in admit spin |
|
||
| `PSX_START_BISECT_NO_TIPHOLD_CAPTURE=1` | Only capture when latching a new tip (no TipHold live refresh) |
|
||
| `PSX_START_BISECT_NO_CATCHUP=1` | Force catch-up budget 0 |
|
||
| `PSX_START_BISECT_NO_REPLAY_PRODUCE=1` | Skip §47 replay tip produce |
|
||
| `PSX_START_BISECT_SPIN=1` | Also log raw SDL on admit spins without capture (dense) |
|
||
|
||
Strongest suspect: dense `GameControllerUpdate` + per-sim-tick capture
|
||
while offline samples on the paced present loop.
|
||
|
||
### Start consumer bisect (2026-08-02)
|
||
|
||
SDL soaks showed dirty Start offline *and* online (different pulse shapes).
|
||
Next question is **consumer equivalence**: given a long hold, does MotK's
|
||
SIO reader see the same Start timeline offline vs online?
|
||
|
||
Env `PSX_START_CONSUMER=1` logs once per sim frame at SIO apply
|
||
(offline `sample_pad_into_sio`, online `host_publish` / snap-load merge):
|
||
|
||
```
|
||
start-consumer n=… mode=offline|online sim=… slot=… start=0|1
|
||
edge=↓|↑|- buttons=…. wall_ms=… resim=0|1
|
||
```
|
||
|
||
**Procedure:** hold Start ~1s offline, then online (same pad, presser slot).
|
||
Collapse `start=` into bitstrings. Cases:
|
||
|
||
| Offline | Online | Meaning |
|
||
|---------|--------|---------|
|
||
| solid `1…1` | `1…0…1` | online pipeline changes game-visible Start |
|
||
| `1…0…1` | `1…0…1` | consumers match → MotK pause/menu differs |
|
||
| solid `1…1` | solid `1…1` | yet double pause → pause invoked without input edge |
|
||
|
||
Invariant: same logical button timeline ⇒ same Start timeline at SIO.
|
||
Remaining differences then belong to MotK pause handling, not netplay.
|
||
|
||
## 59. Lobby RTT→D table bump + TURN floor + faster first raise (2026-08-02)
|
||
|
||
**§58 soak:** dense tip snaps worked; after auto-delay reached D=6 the
|
||
scorecard hit `ep=+0` windows. Early minute still invent-stormed because
|
||
Play started at **D=3** with `force_turn=1` — lobby UDP RTT underestimates
|
||
the TURN game path.
|
||
|
||
**Changes:** rollback RTT→D table +1..+2 per band (floor 3); forced-TURN
|
||
floors D at 5; first auto-delay raise of a session confirms on 1 eval (~5 s).
|
||
|
||
## 60. Ownership-chain BEGIN stash + rexmit (FOLLOW deadlock) (2026-08-02)
|
||
|
||
**§59 soak hang:** early D=5 was healthy (`ep=+1`, depth_avg=10). Mid/late
|
||
cascade (core diverge → tip-extend abandon → baseline mismatch) ended in:
|
||
|
||
* host: `sim=3320 stall=rb_baseline` forever after `begin epoch=72`
|
||
* guest: `wait FOLLOW` then Live-ahead → permanent `pcap_freeze`
|
||
|
||
**Root cause:** pump drains SYNC before POST. Initiator finishes Verify+POST,
|
||
opens the next ownership-chain BEGIN, and sends it while the follower is
|
||
still in Verify for the prior epoch. `begin_follower` hit
|
||
`if (rnet_rb_is_active) return;` with **no log** — BEGIN dropped. BEGIN was
|
||
fire-and-forget (unlike seal/baseline keepalive), so the follower parked on
|
||
`wait FOLLOW` forever and the initiator sat in `rb_baseline`.
|
||
|
||
**Fixes:**
|
||
|
||
1. **Stash peer BEGIN** when a new-epoch BEGIN arrives while locally active
|
||
(tip-extend same-epoch still absorbed in place). Log:
|
||
`rb BEGIN stashed …`.
|
||
2. **Apply stash** when idle — at the follower's ownership-chain
|
||
`wait FOLLOW` return, and at end of pump when `!active`. Log:
|
||
`rb BEGIN unstash …`.
|
||
3. **BEGIN rexmit** from initiator while SealInputs/AwaitingBaseline and
|
||
peer baseline not yet seen (`rb BEGIN rexmit …`), same cadence as
|
||
seal/baseline keepalive.
|
||
4. **Skip-snap follow** at the ownership tip: follower may join a chain
|
||
BEGIN at `load == agreed_through` without a ring snap (mirrors
|
||
initiator skip-snap), instead of NACK → storm.
|
||
|
||
**Re-soak watch:** after ownership-chain lines, expect either immediate
|
||
`follow epoch=…` or `BEGIN stashed` → `BEGIN unstash` → `follow`; initiator
|
||
may log `BEGIN rexmit` on TURN. Must not see host `rb_baseline` + guest
|
||
`wait FOLLOW` with no follow for >1–2 s. Residual mid-session core forks
|
||
remain a separate correctness track.
|
||
|
||
## 61. Same-epoch BEGIN stash reopen + hc_dense peer clamp (2026-08-02)
|
||
|
||
**§60 soak regression:** FOLLOW deadlock was fixed, but sessions felt like
|
||
“finish resim → immediately redo.” Guest log pattern:
|
||
|
||
1. `follow epoch=16` (good)
|
||
2. while still active, host `BEGIN rexmit` for epoch 16 → **stashed**
|
||
3. `ownership final → Live`
|
||
4. `BEGIN unstash epoch=16` → re-open the episode just committed
|
||
5. host already past that work → guest `verify timeout` → both realign
|
||
and churn into the next epoch (FPS 3–11, long `present_gap`)
|
||
|
||
Also: host `choose_load` raised via `hc_dense` above peer RESOLVED
|
||
(e.g. 1180 vs 1168) → `follow REFUSED` / NACK / demote storm.
|
||
|
||
**Fixes:**
|
||
|
||
1. **Do not stash same-epoch BEGIN** while active (keepalive rexmit only).
|
||
2. **Refuse stash / unstash** for `g_last_commit_epoch`.
|
||
3. **Clear same-epoch stash** on ownership final and every episode commit
|
||
path (tip-hold, ownership tip commit, POST match).
|
||
4. **`hc_dense` raise only if** `peer_resolved_through >= hc_floor`
|
||
(otherwise stay on interval floor / agreed / peer_resolved).
|
||
|
||
**Re-soak watch:** after `ownership final` / `episode commit`, must **not**
|
||
see `BEGIN unstash` of that same epoch. `hc_dense` raises only when peer
|
||
RESOLVED already covers the tip. Legitimate *new*-epoch `BEGIN stashed` →
|
||
`unstash` still expected across ownership-chain handoffs.
|
||
|
||
## 62. NACK deep-demote WIRE_HOLE hang + peer NACK floor (2026-08-02)
|
||
|
||
**§61 soak:** stash fix held (zero `BEGIN stashed`/`unstash`), but the run
|
||
ended in an asymmetric permanent hang:
|
||
|
||
1. Ownership continue skip-snap at load=10232 → baseline core mismatch
|
||
(`f795b460` vs `a6e2e2a6`) → fork cap 10232, both sides realign to the
|
||
**same** snap (`core=07993348`).
|
||
2. Host bisects to load=10231, opens epoch 112 spanning back from live
|
||
≈10359.
|
||
3. Guest `follow REFUSED … snap missing` (ring newest ≈10364, oldest ≈10284
|
||
— 10231 evicted) → NACK with `frontier=10352` (**ahead** of the load, so
|
||
the existing "demote to peer frontier" clamp did nothing).
|
||
4. Host `NACK realign demote=10230 live_was=10359` (~129 ticks,
|
||
`snaps_dropped=70`). Remote wire rows 10231..10243 were already past the
|
||
tip-window retransmit → permanent `WIRE_HOLE need=10231` stall; guest ran
|
||
ahead to 10376 then froze on `pcap_freeze lead=-10`.
|
||
|
||
Also confirmed: the mid-session "jumped back far" feel (epoch 65, ~21 ticks
|
||
to the episode pin 7952 after `resim core diverge sim=7973`) is **correct**
|
||
behavior — the fork lies inside the episode span, so rewinding to a later
|
||
tip-extend snap would preserve the forked state. Not changed.
|
||
|
||
**Fixes:**
|
||
|
||
1. **Peer NACK floor** (`g_peer_nack_floor`): a NACK whose frontier is at or
|
||
ahead of the refused load means the peer's ring evicted that tick.
|
||
`choose_load` refuses any load ≤ floor (both fork-cap and plain paths);
|
||
log `choose_load refuse load=… ≤ peer NACK floor`. Cleared with the fork
|
||
cap on every verified commit / session reset.
|
||
2. **Wire-hole guard on NACK realign**: before rewinding Live to the demote
|
||
tick, scan remote wire rows (demote, live]. A missing tick followed by a
|
||
later present tick is a dead hole (retransmit window has moved on);
|
||
realign instead to the first snap past the hole
|
||
(`rb NACK hole-guard realign_to=…`). Watermarks still demote — only the
|
||
Live rewind depth is capped.
|
||
|
||
**Residual:** with fork cap forcing load < 10232 and NACK floor forcing
|
||
load > 10231, the bisect window can be empty — no episode opens and the fork
|
||
persists (live continues, no hang). True mid-session fork healing below both
|
||
snap rings needs full-state transfer — separate track. The skip-snap
|
||
baseline mismatch that *started* the cascade (same realign snap, different
|
||
skip-snap baselines) is also still open.
|
||
|
||
**Re-soak watch:** no minutes-long `WIRE_HOLE` / `admit waiting` stalls; on
|
||
follow NACK expect `peer NACK floor=…` then either a followable reopen or
|
||
`choose_load refuse` + cooldown; `NACK realign` lines now print
|
||
`demote=… realign=…` and realign must stay above any logged wire hole.
|
||
|
||
## 63. Double menu inputs — tip-hold after ownership final + resim present (2026-08-02)
|
||
|
||
**§62 soak:** protocol healthy (no WIRE_HOLE / stash reopen; scorecards
|
||
`ep≥0`, D≈5–6, clean lobby exit). UX regression: **dpad/Start felt
|
||
doubled** again. With `PSX_RB_PAD_LOG=1`, every menu episode re-applied
|
||
SIO edges Live had already applied (`DUP_SIO` at the same slot+sim):
|
||
|
||
* after `ownership final tip=3533 → Live`, Live walked 3536/3543/3548,
|
||
then epoch 16 loaded 3533 and resim'd those edges;
|
||
* Start↓ @5861 Live, invent hold-last Start↓ @5868 while auth was
|
||
release → episode load=5856 → Start↓ @5861 again on resim;
|
||
* `scrub-ahead` fired **0** times; catchup live-present (`rem>8`) could
|
||
flash those resim edges on screen.
|
||
|
||
**Root causes:**
|
||
|
||
1. Ownership final called `enter_tip_hold` then **immediate**
|
||
`finalize_tip_hold` → straight Live; next taps presented then got
|
||
rewound by the following episode.
|
||
2. Resim catchup live-presented when `remaining > 8` — tip episodes
|
||
(~15–24 ticks) re-showed edge-trigger outcomes.
|
||
3. Scrub-ahead end bound outside TipHold was only `sim`, so invent
|
||
poison past the tip was not cleared when a release promoted.
|
||
4. Host pad-edge trackers survived snap load (diag noise).
|
||
|
||
**Fixes:**
|
||
|
||
1. **Ownership final → tip-hold only** (quiet/coalesce/SAFETY own the
|
||
commit). Log: `ownership final … → tip-hold`. Same for chain-begin
|
||
failure fallback.
|
||
2. **Hold-last present through rem≤24** (full tip SPAN); only deeper
|
||
ownership-chain catch-up live-presents.
|
||
3. **Scrub-ahead** outside TipHold extends to `sim+9` (pred depth) so
|
||
release promote clears invent hold-last ahead of Live.
|
||
4. **`psx_netplay_on_rb_snap_loaded`** resets sio/invent/local pad-edge
|
||
trackers from `psx_frontend_on_rb_snap_loaded`.
|
||
|
||
**Re-soak watch:** `ownership final … → tip-hold` then
|
||
`tip-hold through=…` / quiet or coalesce commit (not immediate Live);
|
||
`scrub-ahead release` should appear on digital releases; tip episodes
|
||
should not log `catchup … present=live` unless rem>24; one physical
|
||
Start/dpad → one menu action. Pad-log may still show resim SIO edges
|
||
(correct for sim) — the feel test is presentation, not DUP_SIO count.
|
||
|
||
## 64. Catchup rem tip-cap + hc-fork persist not across episodes (2026-08-02)
|
||
|
||
**§63 short soak:** user saw **two resims back-to-back** and stopped.
|
||
|
||
1. `hc-fork recovery begin t=1023` (post-FMV unlock, core diverge, no pad
|
||
mispredict) → epoch 8 `1008..1032` with `catchup present=live rem=37`.
|
||
2. Tip-hold @1032, coalesce tip-extend →1046, peer COMMIT @1032 →
|
||
`tip-extend ABANDON` → realign 1032.
|
||
3. Immediate `hc-fork recovery begin t=1033 (persisted 39 ticks)` →
|
||
epoch 16. Then ownership-chain skip-snap @1056 → baseline mismatch.
|
||
|
||
**Confirmed in code:**
|
||
|
||
* `psx_netplay_rb_confirmed_remaining` returned `frontier−sim`, so tip
|
||
SPAN episodes reported rem=37/47 whenever confirmed work ran past the
|
||
tip — §63's `rem≤24` hold-last never engaged.
|
||
* `np_try_hc_fork_recovery` updated `s_fork_first_sim` / accumulated
|
||
persist **while the episode was still active**, then the active guard
|
||
only blocked the open. On tip-extend abandon → Live, persist was
|
||
already satisfied → second recovery with zero Live gap.
|
||
|
||
**Fixes:**
|
||
|
||
1. **Confirmed remaining** capped by `target−sim` while an episode is
|
||
active (catchup present keys off tip depth).
|
||
2. **hc-fork recovery** returns early on active/tip-hold/load **before**
|
||
persist bookkeeping — persist only counts Live ticks.
|
||
3. **`psx_netplay_hc_fork_recovery_restart`** on tip-extend abandon
|
||
(fresh first_sim + last_attempt) even though rewind cooldown clears.
|
||
|
||
**Residual:** post-FMV core fork itself and skip-snap baseline mismatch
|
||
remain separate correctness tracks — this only stops the immediate
|
||
double-resim feel and tip-episode live-present flash.
|
||
|
||
**Re-soak watch:** tip SPAN episodes should hold-last (`catchup
|
||
present=live` only when tip rem>24, e.g. deep ownership catch-up); after
|
||
`tip-extend ABANDON` expect `hc-fork recovery restart` then ≥16 Live
|
||
ticks before another `hc-fork recovery begin` (not same-tick reopen).
|
||
|
||
## 65. Tip-hold SAFETY defer when peer wire past tip (2026-08-02)
|
||
|
||
**§64 soak:** protocol healthy (0 hc-fork begins, 0 WIRE_HOLE, clean exit,
|
||
cores matched). Remaining quality hit: **4× tip-extend ABANDON** on host.
|
||
|
||
Pattern (every case):
|
||
|
||
1. Both peers ownership-final → tip-hold at tip T.
|
||
2. Host coalesce tip-extends T→T+N and POSTs the extended tip.
|
||
3. Guest `tip-hold SAFETY CAP 250 — commit through=T` with
|
||
`remote_tip=T+7` (peer wire already past sealed tip) → OP_COMMIT at T.
|
||
4. Host `tip-extend ABANDON — peer COMMIT … tick=T` → realign.
|
||
|
||
**Root cause:** SAFETY defer only covered *digital held*. Race-ahead
|
||
(`remote_tip > tip+24`) forces commit when the peer left TipHold entirely.
|
||
A moderate ahead (`remote_tip = tip+7`) meant the initiator was still
|
||
tip-extending — SAFETY should **wait**, not commit.
|
||
|
||
**Fix:** while `remote_tip > tip` and not yet race-margin, defer
|
||
SAFETY / quiet / thrash / tip_hold_until commits (log
|
||
`SAFETY deferred (peer wire past tip) … wait tip-extend`). Absolute /
|
||
REMOTE-HELD / RACE walls still escape. Coalesce tip-extend FOLLOW then
|
||
owns the edge.
|
||
|
||
**Re-soak watch:** far fewer `tip-extend ABANDON`; expect
|
||
`SAFETY deferred (peer wire past tip)` then `tip-extend FOLLOW` /
|
||
coalesce instead of guest SAFETY CAP while host extends. ABSOLUTE CAP
|
||
with `remote_tip > tip` should stay rare.
|
||
|
||
## 66. TipHold tip-extend rereplay defer / coalesce batch (2026-08-02)
|
||
|
||
**§65 soak:** 0 tip-extend ABANDON; early-match feel still abrasive.
|
||
|
||
**Confirmed (logs + code):** fight-start ownership around sim **2449–2632**:
|
||
|
||
1. `ownership final → tip-hold` at tip T.
|
||
2. Immediate `tip-hold coalesce-ahead` edge T+N.
|
||
3. TipHold `need_rereplay` because `mismatch_tick > old_target` (§17) →
|
||
**snap reload + short resim every 2–4 ticks**.
|
||
4. Episode pin stuck at first load; FPS `54→10`, admit 20–40 ms, replay
|
||
59–83%, then invent ahead → `pcap FREEZE` → deeper episode.
|
||
|
||
Root cause in `psx_netplay_rb_tip_extend`: TipHold always scheduled
|
||
`schedule_episode_rereplay` on peek-ahead coalesce, defeating the MotK
|
||
TipHold quiet window (docs: stay TipHold; rereplay only after Live
|
||
invented past tip — and even then one batch, not per edge).
|
||
|
||
**Fix:** TipHold tip-extend (initiator + FOLLOW) **raises tip + marks
|
||
deferred rereplay** (`g_tip_hold_rereplay_pending` / earliest
|
||
`g_tip_hold_rereplay_from`); stays TipHold; does **not** snap immediately.
|
||
Quiet / thrash / SAFETY / tip_hold_until / span-cap / seat-yield call
|
||
`tip_hold_try_finalize` → one `deferred rereplay flush` then commit.
|
||
Verify/Replay tip-extend still rereplays immediately. FMV TipHold commit
|
||
drops pending without flush.
|
||
|
||
**Re-soak watch:** `tip-extend rereplay DEFER` / `FOLLOW rereplay DEFER`
|
||
then a single `tip-hold deferred rereplay flush from=… tip=…`; far fewer
|
||
`tip-extend snap applied` per fight-start burst; no 10 fps admit spike
|
||
from per-edge rereplay cascade.
|
||
|
||
## 67. Yield must not flush deferred extension + audit digest cache (2026-08-02)
|
||
|
||
**§66 soak:** cascade fixed (DEFER→single flush works, 0 pcap FREEZE),
|
||
but two new host failures, same root:
|
||
|
||
1. **POST diverge epoch 16 @ tip 888** → realign 864 → hc-fork recovery.
|
||
2. **tip-extend ABANDON epoch 24** (guest COMMIT 887 vs host POST 888).
|
||
|
||
**Confirmed sequence (both):** host tip-holds with a *deferred* extension
|
||
(from=884→888 / 887→888). Guest sees a **different-seat** edge, yield-commits
|
||
at the matched tip, opens a new epoch. The peer's new-epoch BEGIN hits the
|
||
host's `tip-hold yield FOLLOW new epoch` path, which (§66) called
|
||
`tip_hold_try_finalize` → **flushed the rereplay instead of committing** —
|
||
kept the stale epoch alive → `tie-break WIN … drop` → host POSTs extended
|
||
tip against a peer that already left → diverge/ABANDON.
|
||
|
||
**Fixes:**
|
||
|
||
1. **`finalize_tip_hold` commit clamp:** with a deferred extension pending,
|
||
commit at the POST-matched `from` tip and drop the extension (log
|
||
`tip-hold commit clamp T→from`). Never commit an unresimmed raised tip —
|
||
that span still holds invent-predicted state.
|
||
2. **Yield paths commit, don't flush:** new-epoch FOLLOW yield and
|
||
different-seat yield call `finalize_tip_hold` directly (clamped). The
|
||
successor episode (load ≤ from) repairs the extension span. FMV TipHold
|
||
commits also rely on the clamp (no silent pending drop). Wall paths
|
||
(quiet/thrash/SAFETY/until/span-cap) keep flush-first
|
||
`tip_hold_try_finalize`.
|
||
3. **Resim audit digest cache (perf):** `log_resim_tick_audit` computed a
|
||
full core digest (2 MiB RAM CRC) **twice per resim tick** (arm+fin) —
|
||
the `admit=20–40 ms/f` catchup hitch. arm(N) now reuses the cached
|
||
fin(N−1) digest; invalidated on every snap apply / rereplay load.
|
||
|
||
**Audit — remaining gaps (not fixed this pass):**
|
||
|
||
* `schedule_episode_rereplay` can reload a Live-saved snap **above** the
|
||
agreed watermark (guest re-follow reloaded live snap 885 with invent
|
||
state; matched by luck). Rereplay base should be ≤ last verified tip or
|
||
a snap saved during this episode's resim.
|
||
* POST-DIVERGE `fc` ring is mostly `00000000?` inside episode spans
|
||
(no FRAME_COMMIT cores during resim/tip-hold) — `FIRST_MISMATCH` cannot
|
||
localize forks precisely when they matter most.
|
||
* Per-tick `host_save_state` during resim (~0.2–0.6 ms each) is acceptable
|
||
but stacks on deep spans; audit fin still pays one RAM CRC per tick.
|
||
|
||
**Re-soak watch:** on peer new-epoch BEGIN during deferred TipHold expect
|
||
`tip-hold commit clamp` then a normal `follow epoch=…` (no
|
||
`tie-break WIN … drop` of the peer BEGIN, no POST diverge at the extended
|
||
tip); catchup windows should show admit well below 20 ms/f.
|
||
|
||
## 68. Tie-break YIELD after local snap apply (2026-08-02)
|
||
|
||
**§67 soak hang:** dual begin at tip 846 / mismatch 848 / load 836 —
|
||
|
||
* Host slot0 → epoch **16**, Guest slot1 → epoch **17**.
|
||
* Both snap-applied and `waiting peer baseline`.
|
||
* Host: `tie-break WIN … drop` ×404 (never follows 17).
|
||
* Guest: `BEGIN stashed epoch=16` instead of YIELD — stay on 17 forever.
|
||
* Both `admit … stall=rb_baseline` until disconnect (~16s).
|
||
|
||
**Root cause:** tie-break YIELD required
|
||
`handshake && !g_episode_snap_applied`. After the local baseline snap load,
|
||
the loser (higher initiator slot) fell through to **stash** while the
|
||
winner kept dropping the competing BEGIN. Mutual baseline wait = hang.
|
||
|
||
**Fix:** YIELD for the full SealInputs / AwaitingBaseline handshake even
|
||
when `g_episode_snap_applied` (log `post-snap`). Abort the local episode
|
||
and fall through to follow the winner. WIN path unchanged; log
|
||
rate-limited. Replay/Verify still refuse yield.
|
||
|
||
**Re-soak watch:** concurrent dual begin → loser logs
|
||
`tie-break YIELD … post-snap` then `follow epoch=<winner>`; no multi-second
|
||
`stall=rb_baseline` storm; WIN drops at most one line per epoch pair.
|
||
|
||
## 69. Stop double-fire tip-extend / RACE flush sandwich (2026-08-02)
|
||
|
||
**§68 soak:** no hang; resims felt like they double-fired.
|
||
|
||
Confirmed sandwich:
|
||
|
||
1. Ownership episode resim (e.g. `4400..4409`).
|
||
2. `ownership final → tip-hold` → DEFER coalesce.
|
||
3. Guest **RACE CAP** flushed early at tip=4420 while host still deferred
|
||
toward 4443 → guest mid-Replay then FOLLOW `4420→4443 rereplay=1` →
|
||
**second snap** @4416.
|
||
4. Host flush then often `ownership chain begin` for leftover frontier
|
||
(third hitch / skip-snap baseline fork).
|
||
|
||
**Fixes:**
|
||
|
||
1. **Replay tip-extend = raise-only:** initiator never sets need_rereplay
|
||
in Replay; FOLLOW with REREPLAY while already Replay logs
|
||
`FOLLOW raise-only … no snap` and only extends the tip.
|
||
2. **RACE deferred while rereplay pending:** do not flush on RACE until
|
||
REMOTE-HELD/ABSOLUTE (log `RACE deferred (rereplay pending)`), giving
|
||
peer tip-extends time to land so one flush covers the full coalesce.
|
||
3. **Absorb confirmed frontier before flush:** raise tip through
|
||
`confirmed_frontier` (span-capped, SYNC if initiator) so the deferred
|
||
rereplay spans the ownership-chain remainder in one pass.
|
||
|
||
**Re-soak watch:** at most one `tip-extend snap applied` per deferred
|
||
flush; `FOLLOW raise-only` / `RACE deferred (rereplay pending)` /
|
||
`absorb frontier … before flush`; far fewer back-to-back
|
||
`deferred rereplay flush` → `ownership chain begin` pairs.
|
||
|
||
## 70. Race-pending wall + ownership skip-snap pin (2026-08-02)
|
||
|
||
**§69 soak:** flush:snap 1:1, 0 POST-DIVERGE / ABANDON / tie-break, but:
|
||
|
||
1. **ABSOLUTE mega-flush:** `RACE deferred (rereplay pending)` @~350ms
|
||
waited until `ABSOLUTE CAP 2000` → `absorb frontier 2562→2607` (45-tick
|
||
catchup, FPS ~11–13). Coalesce was stalled (`coalesce_n=1`); Live invent
|
||
walked to `sim=2610` while tip sat at 2562.
|
||
2. **Ownership skip-snap baseline fork:** both peers POSTed matching
|
||
`dig_m=9b2fd78e` / core `342b04f6` at tip 2607, then ownership-chained.
|
||
Guest wait-FOLLOW Live-invented past tip (pad edges @2610); skip-snap
|
||
hashed drifted CPU (`fc7641e6`) while host still had POST state. Ring
|
||
pin at 2607 was tip-hold invent (`359bf24b` on realign) — not the
|
||
matched tip.
|
||
|
||
**Fixes:**
|
||
|
||
1. **`RB_MOTK_TIP_HOLD_RACE_PENDING_WALL_MS` (400):** RACE+deferred only
|
||
waits this long, then RACE-flush (no ABSOLUTE park).
|
||
2. **Absorb cap `tip + RB_MAX_RESIM_SPAN` (24):** leftover frontier is
|
||
ownership-chain after POST, not one mega rereplay.
|
||
3. **`pin_baseline_from_cpu(tip)`** in `ownership_chain_next_span` before
|
||
`session_reset` — capture POST-matched state, refresh ring, drop invent
|
||
snaps after tip. Keep pin (do not `clear_baseline_pin`).
|
||
4. **Skip-snap restore:** load pin (or refreshed ring), resync, set
|
||
`sim=tip+1`, then baseline — never hash Live-drifted CPU after
|
||
wait-FOLLOW.
|
||
|
||
**Re-soak watch:** RACE deferred logs `cap 400 ms` then `RACE CAP` (not
|
||
`ABSOLUTE CAP 2000` after a long park); absorb spans ≤24; ownership chain
|
||
logs `baseline pinned from CPU` + `skip-snap … pin restore`; no baseline
|
||
core mismatch at the flush tip after matching POST.
|
||
|
||
## 71. Post-FMV START NACK → promote-no-resim fork (2026-08-02)
|
||
|
||
**Soak:** Live digests matched through FMV (17/17 before sim=864). After
|
||
settle @867 guest START↓ @880 → host `begin epoch=8 load=880`. Guest
|
||
`follow REFUSED … past frontier=843`. Host `NACK keep-live` + cooldown
|
||
`promote-no-resim` for START → cores diverge from sim=896 forever.
|
||
Lockstep EXTEND streak=0 then MAX-release. Later `hc-fork recovery`
|
||
bisect storm (1808→1792→1776→…) all die on baseline mismatch — felt like
|
||
“resims during loading while hashes should match.” Hashes already forked.
|
||
|
||
**Root:**
|
||
|
||
1. **`choose_load` / follow asymmetry:** with `agreed_valid`, interval
|
||
snaps *above* `agreed_through` were treated as mutual, so initiator
|
||
opened load=880 while follow hard-NACKs `load > frontier`.
|
||
2. **NACK keep-live** when snap never applied but Live already past the
|
||
refused load → invent/promote-no-resim forks the press.
|
||
3. **hc-fork** retried every 32 ticks into the bisect ladder.
|
||
|
||
**Fixes:**
|
||
|
||
1. **`choose_load`:** interval ticks above `agreed_through` require HC
|
||
confirm or peer RESOLVED (same hard cap as follow).
|
||
2. **NACK realign** when `live_sim > load` even if snap was never applied
|
||
(`NACK realign (live past refused load)`).
|
||
3. **hc-fork backoff 256 ticks** while `baseline_fork_cap > 0`.
|
||
4. **Lockstep MAX unmatched:** longer dense/`promote_sweep` window when
|
||
RELEASE fires with streak below confirm.
|
||
|
||
**Re-soak watch:** post-FMV START opens load ≤ agreed/hc frontier (no
|
||
`past frontier=843` NACK on an interval tip); if NACK still happens with
|
||
Live ahead, expect realign not keep-live; no rapid `hc-fork recovery`
|
||
ladder of baseline mismatches during loading; live digs stay matched
|
||
through title/menu when pads are idle.
|
||
|
||
## 72. Ownership skip-snap must defer resume (2026-08-02)
|
||
|
||
**§70/§71 soak crash:** guest (slot1) hard-exited right after ownership
|
||
chain skip-snap:
|
||
|
||
* `skip-snap load=1976 pc=0x8006cda0` then
|
||
`replay … resume_pending=0` → `execution completed, PC=0x00000000`.
|
||
* Host saw `netplay_peer_disconnect` mid-replay @1977.
|
||
|
||
**Root:** §70 pin-restore cleared `g_pending_resume_valid`. Normal snap
|
||
apply arms resume-deferred + `flush_resume` longjmp into Replay; skip-snap
|
||
replaced CPU state mid–wait-FOLLOW and continued the Live native stack,
|
||
which returned PC=0.
|
||
|
||
**Fix:** after pin/ring restore, set `g_pending_resume_pc` /
|
||
`g_pending_resume_valid=1` (same as snap apply). `flush_resume` still
|
||
waits for Replay phase.
|
||
|
||
**Re-soak watch:** ownership skip-snap logs `resume deferred`; next line
|
||
in Replay is `flush_resume pc=…` (not `execution completed`); no peer
|
||
disconnect at chain begin.
|
||
|
||
## 73. Ownership MIN_AHEAD tip-hold hang bisect (2026-08-02)
|
||
|
||
**§72 soak felt “multiple hangs”** near endgame (~tip 4146→4296) with
|
||
matched digests (0 POST-DIVERGE / ABANDON / WIRE_HOLE / PC=0). Hitch was
|
||
tip-hold park + deferred flush, not desync.
|
||
|
||
**Code+log bisect (rb-diag1 host / rb-diag2 guest):**
|
||
|
||
| Gate | Observation |
|
||
|------|-------------|
|
||
| Entry | **0** `ownership chain` lines all session; **every** POST → `ownership final … → tip-hold` |
|
||
| Ahead | Host endgame finals: ahead=5×8, ahead=4×1; guest: ahead=5×11. **Never** ahead≥9 |
|
||
| Floor | `RB_OWNERSHIP_CHAIN_MIN_AHEAD=8` requires `frontier > tip+8` → tip+4/+5 **always** tip-hold |
|
||
| Counterfactual | `MIN_AHEAD=4` would chain **8/9** host and **11/11** guest endgame finals |
|
||
| Exit | Host: tip-hold → SAFETY → `REMOTE-HELD CAP 500` → absorb + `deferred rereplay flush` spans 4–21; guest often `ABSOLUTE CAP 2000` while `held_local=1` |
|
||
| Sticky held | `tip_hold_held_split` uses **sealed tip** buttons; §66 DEFER tip-extends onto press rows → `held_remote=1 pending=0` until wall |
|
||
|
||
Causal chain for tip=4199: POST tip+4 → tip-hold → DEFER coalesce tip→4208 → REMOTE-HELD → absorb to 4216 → flush from=4199 (17 ticks) → POST → tip-hold again.
|
||
|
||
REMOTE-HELD/ABSOLUTE walls are working as designed **after** wrongly entering tip-hold on contiguous confirmed work. Shortening those walls without fixing entry would only trim parks; the structural bug is the §49 floor.
|
||
|
||
**Fix:** default `RB_OWNERSHIP_CHAIN_MIN_AHEAD` **8→4** so observed tip+5 frontiers Replay-own (skip-snap SPAN) instead of tip-hold. tip+4 still tip-holds. Env override unchanged. Do **not** patch REMOTE-HELD ms in this step.
|
||
|
||
**Re-soak watch:** endgame logs `ownership chain tip=N frontier=N+5` (not `ownership final → tip-hold` on every POST); far fewer `REMOTE-HELD CAP` / `deferred rereplay flush` mega-spans; digests stay matched; no return of §49 `frontier=N+1..N+3` epoch storms (raise floor via env if WAN regenerates them).
|
||
|
||
## 74. Chain-Replay hold-last hitch + stale-snap diagnostic (2026-08-02)
|
||
|
||
**§73 soak:** tip-hold loop gone (0 `ownership final`, 20 chains, 0
|
||
REMOTE-HELD storms), but the user still felt "stalls then skips ahead"
|
||
during fight clusters. Bisect:
|
||
|
||
1. **Hitch = blanket hold-last during chain Replay.** Ownership chains fire
|
||
every ~11–15 ticks in fights; each episode's target extends per wire
|
||
arrival (`ownership extend sim=N target T→T+1`), so Replay **treadmills
|
||
at wire pace** and `confirmed_remaining` stays ≤ 24 forever. In
|
||
`main.cpp`, `netplay_replay_catchup_should_live_present` returns 0 for
|
||
remaining ≤ 24 → hold-last for the whole episode → 135–212 ms frozen
|
||
(`present_gap max` matches exactly), then the display snaps ~12 frames
|
||
forward. replay% 33–76 during clusters; idle stretches clean 60 fps.
|
||
2. **Endgame diverge @3167 = stale interval snap.** Guest fin 3166 =
|
||
`089b276e` (== host arm 3167), then FOLLOW rereplay loaded snap
|
||
tick=3166 → re-arm 3167 = `f92756fb` ≠ the state it *just computed*.
|
||
The loaded snap forked the guest → POST diverge `497462cd` vs
|
||
`0cdb378f` + host resim-diverge abort. Matches the open audit item
|
||
(Live/invent snaps above watermark in `schedule_episode_rereplay`).
|
||
|
||
**Fixes:**
|
||
|
||
1. **§74 display watermark (main.cpp):** track the highest sim ever
|
||
presented. During resim, frames **above** the watermark are new forward
|
||
progress (treadmill chain) → present live every vblank; frames at/below
|
||
keep §63 hold-last (edge-input re-show / DUP_SIO stays impossible).
|
||
Watermark raised only on real presents; reset at netplay session start.
|
||
2. **SNAP-STALE diagnostic (psx_netplay_rb.c, log-only):** when a snap
|
||
load targets the tick the audit-fin cache just recorded, compare the
|
||
loaded state's core digest against the just-simulated digest; log
|
||
`rb SNAP-STALE tick=… snap_core=… fin_core=…` on mismatch. This names
|
||
the fork source at the moment it happens instead of 12 ticks later at
|
||
POST. Fix for the snap-provenance bug itself is deferred until a soak
|
||
captures SNAP-STALE with context (which snap-record path wrote it).
|
||
|
||
**Re-soak watch:** fight clusters keep `present_gap max` ≤ ~35 ms (no
|
||
135–212 ms holds); no DUP_SIO / re-shown menu edges after aborts (frames
|
||
below watermark still held); any episode diverge is preceded by a
|
||
`SNAP-STALE` line identifying the stale snap tick.
|
||
|
||
## 75. Tip-extend SNAP-STALE — keep-live + auth snap floor (2026-08-02)
|
||
|
||
**§74 soak:** SNAP-STALE fired and named the fork. Every mid-fight abort
|
||
and the final DESYNC were preceded by `tip-extend rereplay load=T` where
|
||
the ring snap at T ≠ the just-finished sealed fin core at T:
|
||
|
||
| Guest SNAP-STALE | Follow-on |
|
||
|------------------|-----------|
|
||
| 1953 | host `ABORT resim core diverge sim=1954` |
|
||
| 2034 | host `ABORT … sim=2035` |
|
||
| 4833 / 4843 / 4860 | tip-hold flush → baseline `b1be9e12` vs `d72cde06` → **DESYNC @4857** |
|
||
|
||
Seals matched; the ring held a Live/invent snap that tip-extend reloaded
|
||
over correct sealed CPU state (or after Live re-poisoned a Replay slot).
|
||
|
||
**Fixes:**
|
||
|
||
1. **`g_auth_snap_through`:** raised when Replay successfully saves a snap
|
||
(and on CPU pin). Live `request_snap` refuses `tick ≤ auth` so invent
|
||
cannot overwrite sealed slots.
|
||
2. **`tip_extend_keep_live`:** if tip-extend rereplay asks for tick T and
|
||
the live CPU still matches sealed fin at T (`sim` is T or T+1), refresh
|
||
the ring from live and arm T+1 — log `tip-extend KEEP-LIVE` — never
|
||
reload the ring.
|
||
3. **SNAP-STALE fallback:** if a tip-extend load still disagrees with fin,
|
||
drop the poison slot and reload the episode pin (keep tip-extend arm);
|
||
abort only when no fallback exists.
|
||
|
||
**Re-soak watch:** `tip-extend KEEP-LIVE` on FOLLOW extends; **0**
|
||
`SNAP-STALE` (or only with immediate `fallback load=`); no resim-core
|
||
ABORT / DESYNC from tip-extend poison; digests stay matched through fight
|
||
clusters.
|
||
|
||
## 76. Verify raise-only + tip-hold entry pin (2026-08-02)
|
||
|
||
**§75 soak:** DESYNC gone, but stalls remained. KEEP-LIVE never fired (0).
|
||
Every hitch was `SNAP-STALE → fallback episode pin` → 15–30 tick resim
|
||
(fps 5–16, admit 40–160 ms). Root causes (not KEEP-LIVE):
|
||
|
||
1. **Verify always `need_rereplay=1`** — tip-extend at the POST tip reloaded
|
||
the prior tip (often invent-poisoned) instead of continuing into the new
|
||
span. FOLLOW mirrored that. Replay already raise-only (§69); Verify did not.
|
||
2. **Tip-hold flush reloaded a poison ring slot at `from`** — tip-hold entry
|
||
never froze the POST tip into pin/ring, so deferred flush fell through to
|
||
SNAP-STALE → episode-pin fallback (deep hitch).
|
||
|
||
**Fixes:**
|
||
|
||
1. **Verify tip-extend = raise-only** (initiator + FOLLOW): leave Verify into
|
||
Replay for `old_tip+1..new_tip` with no snap reload. Log
|
||
`Verify→Replay raise-only` / `FOLLOW Verify→Replay`. Wire REREPLAY cleared
|
||
for Verify/Replay FOLLOW.
|
||
2. **`pin_baseline_from_cpu(tip)` on tip-hold entry** — sealed POST tip into
|
||
pin+ring+auth before Live invent walks.
|
||
3. **Tip-hold flush prefers that pin** when `from == pin_tick`; tip-extend
|
||
apply loads pin buffer (not invent ring) for that tick. Resim span is the
|
||
coalesce window only (~6–8 ticks), not episode-pin depth.
|
||
|
||
KEEP-LIVE remains as a belt-and-suspenders path; it is not the stall cure.
|
||
|
||
**Re-soak watch:** `Verify→Replay raise-only` / `tip-hold flush load pin=`;
|
||
far fewer `SNAP-STALE` / `fallback load=`; tip-hold flush hitch ≤ coalesce
|
||
span; fight clusters stay near 60 fps without pin-depth freezes.
|
||
|
||
## 77. Verify-time tip pin + SIO edge seed after snap (2026-08-02)
|
||
|
||
**§76 soak:** tip-hold flush used the pin (`flush load pin=` ×3) but every
|
||
pin was still SNAP-STALE → episode-pin fallback → POST-DIVERGE. Cross↓
|
||
doublets without ↑ all sat on skip-snap / flush_resume (mushy P1 dash).
|
||
|
||
**Bisect — pin≠fin:**
|
||
|
||
```
|
||
audit fin sim=2257 dig=f4d9bd26
|
||
post sent / verify wait peer POST …
|
||
baseline pinned from CPU tick=2257 ← AFTER invent walk
|
||
tip-hold through=2257 invent_slack=2
|
||
… Live sim→2263 …
|
||
flush load pin=2257 → SNAP-STALE snap≠fin → fallback 2236
|
||
```
|
||
|
||
`pin_baseline_from_cpu` on tip-hold *entry* runs after Verify wait, when
|
||
Live has already invented past the tip (`sim=2258` on the next line). That
|
||
overwrote the good finish_frame ring snap with invent state labeled as the
|
||
tip. Flush then correctly detected pin≠audit-fin and fell back.
|
||
|
||
**Bisect — Cross double↓:** every host slot0 Cross↓ pair without ↑ had an
|
||
ownership skip-snap / snap apply between them. `psx_netplay_on_rb_snap_loaded`
|
||
reset edge trackers to `0xFFFF` (all released); first republish of held Cross
|
||
logged as a fresh press.
|
||
|
||
**Fixes:**
|
||
|
||
1. **`pin_baseline_from_cpu(done)` at `enter_verify_at_tip`** — freeze tip
|
||
while CPU still matches sealed fin (before peer-POST wait / invent).
|
||
2. **Tip-hold entry + ownership chain keep verify pin** — do not re-pin from
|
||
drifted Live CPU; log `tip-hold keep verify pin` /
|
||
`ownership chain keep verify pin`.
|
||
3. **Tip-extend load of verify pin: trust pin** — no episode-pin SNAP-STALE
|
||
fallback when the intentional sealed pin was the load source.
|
||
4. **`psx_netplay_on_rb_snap_loaded`:** seed sio/local/invent edge `prev` from
|
||
`sio_get_pad_buttons_slot` (restored state), not `0xFFFF`.
|
||
**Superseded by §79** for the local tip pipeline: prefer live/staged
|
||
continuity + merge held bits into SIO (SIO-only seed still false-edged
|
||
Start across skip-snap when snap SIO was idle).
|
||
|
||
**Re-soak watch:** `baseline pinned` / verify pin at POST tip *before*
|
||
`tip-hold through`; `tip-hold keep verify pin`; flush pin without
|
||
`fallback load=`; no Cross↓ doublet across skip-snap; tip-hold hitch ≈
|
||
coalesce span only.
|
||
|
||
## 78. Tip-hold flush = matched-prefix resim (no pin reload) (2026-08-02)
|
||
|
||
**§77 soak:** verify-time pin + keep-verify-pin landed, but the one
|
||
`trust verify pin` flush **forked immediately**. Epoch 200: both peers
|
||
fin-matched tip 3078 (`7f4f24a4`); host flushed `load pin=3078` →
|
||
SNAP-STALE `snap=fd199e8c` → trusted it; guest (§76 raise-only FOLLOW)
|
||
never reloaded. Digests fork from 3082, POST diverge at 3093, then the
|
||
recovery cascade (baseline mismatch → fork cap → NACK → 3 realigns,
|
||
lead max 65, slack max 1.6 s).
|
||
|
||
**Case law from three soaks:**
|
||
|
||
- Reloads are safe only when **both** peers load digest-identical state
|
||
(episode baselines, ownership skip-snap — both verified).
|
||
- **One-sided reload forks**: `load(save(S))` re-derives device timing
|
||
(cycles/IRQ/CD deadlines resync) ≠ live continuation of S. §76 made
|
||
Verify/Replay FOLLOW raise-only, so every initiator-side flush reload
|
||
is now asymmetric by construction.
|
||
- The coalesce DEFER already names the first divergent row
|
||
(`mismatch=3084`): Live's hold-last invent **matched the seals** for
|
||
3079..3083 — the host was still on the verified timeline through 3083
|
||
and never needed state at 3078.
|
||
|
||
**Fixes:**
|
||
|
||
1. **`g_tip_hold_rereplay_mismatch`** — earliest coalesce mismatch across
|
||
deferred extends (initiator + FOLLOW mirror, min-tracked).
|
||
2. **Flush resims from the matched prefix**: `schedule_episode_rereplay
|
||
(first_bad)` → walk-down lands on the §58 dense live snap at
|
||
`first_bad-1` (host's own live timeline == sealed timeline there).
|
||
No pin reload, no asymmetric state acquisition. Log:
|
||
`flush from=… first_bad=… tip=…`.
|
||
3. **Late-wire guard**: before narrowing, verify every sealed remote row
|
||
in `(from, first_bad)` equals the sealed row at `from` (hold-last
|
||
assumption); lower `first_bad` on any difference/invalid row.
|
||
4. **Trust-verify-pin reverted**: SNAP-STALE on a tip-extend load always
|
||
falls back to the peer-verified episode pin (logs `src=pin|ring`).
|
||
5. **PIN-SKEW diag**: `pin_baseline_from_cpu` digests the pin at save
|
||
(canonical resume pc). `PIN-SKEW` when it differs from the audit fin
|
||
at the same tick — separates pc-substitution digest drift from real
|
||
load infidelity for the next soak.
|
||
|
||
**Re-soak watch:** flush logs `first_bad` ≈ coalesce edge; loads land at
|
||
`first_bad-1` (dense live snap), not the tip pin; no SNAP-STALE on flush
|
||
loads; POST after flush matches (no epoch-200-style fork); PIN-SKEW
|
||
presence/absence names the digest-delta layer.
|
||
|
||
## 79. TipHold dense snaps survive until flush (2026-08-02)
|
||
|
||
**§78 soak:** `first_bad` was correct (`from=3216 first_bad=3224`) but
|
||
every flush still loaded the tip pin:
|
||
|
||
```
|
||
prefer=3223 → load=3216 (pin) → SNAP-STALE → fallback episode pin
|
||
```
|
||
|
||
KEEP-LIVE cannot help: at flush `sim≈3232`, audit stuck at tip, Live has
|
||
walked past `prefer` — all three KEEP-LIVE gates fail (0 KEEP-LIVE in soak).
|
||
|
||
**Cause:** §58 dense tip window defaults to **8** ticks. TipHold Live
|
||
invent/wire-walks 16–30 ticks before REMOTE-HELD flush; `tip_dense_push`
|
||
evicts `first_bad-1` before the flush can land there.
|
||
|
||
**Fixes:**
|
||
|
||
1. **While TipHold:** save **every** Live tick through the dense path
|
||
(not only non-interval).
|
||
2. **`tip_dense_push` TipHold retention:** raise effective window to
|
||
`NP_DENSE_SNAP_MAX` (24) and **never drop** snaps with `tick > sealed
|
||
tip` (`g_tip_hold_rereplay_from` when deferred, else `g_agreed_through`).
|
||
Matched-prefix candidates survive until flush.
|
||
3. **Schedule log:** `has_prefer=0|1` so the next soak names landing.
|
||
|
||
**Re-soak watch:** `rereplay load=N prefer=N has_prefer=1` with
|
||
`N=first_bad-1`; no `src=pin` SNAP-STALE on tip-hold flush; resim span ≈
|
||
coalesce suffix only; no resim-core diverge from deep episode fallback.
|
||
|
||
## 80. Late-wire rereplay + tip-hold flush symmetry (2026-08-02)
|
||
|
||
**§79 soak:** dense prefer landing worked (`load=4022 prefer=4022
|
||
has_prefer=1`), then POST-DIVERGE / DESYNC.
|
||
|
||
**Bisect:**
|
||
|
||
1. **Late wire below tip, raise-only ignore (root at 4012).** Host began
|
||
epoch 40 `load=4000 target=4012`, armed 4012 with `s1=ffff`, finished
|
||
`fin=fdd66fda`. At sim=4013 wire arrived `pub=ffff wire=fdff` →
|
||
`tip_extend` / ownership raise-only (§69/§76) — seals updated to fdff
|
||
but CPU never resimmed 4012. Guest later full-followed `target=4023`
|
||
with sealed fdff → `fin@4012=a7144f72`. Matched tip 4014 was built on
|
||
the wrong pad; tip-hold flush then amplified the fork.
|
||
2. **Tip-hold flush local-only.** Initiator flushed invent-snap rereplay;
|
||
FOLLOW Verify/Replay forced `need_rereplay=0` and logged
|
||
`FOLLOW raise-only` / `Verify→Replay (no snap)`. FIRST_MISMATCH@4023.
|
||
|
||
**Fixes:**
|
||
|
||
1. **Replay/Verify tip-extend:** `need_rereplay=1` when the mismatch tick
|
||
is already inside the simulated tip (`mismatch < sim` in Replay;
|
||
`mismatch <= old_target` in Verify). Raise-only remains for pure tip
|
||
raises past the tip. Reload prefer = `mismatch` (arm at the bad tick).
|
||
2. **Same-target REREPLAY SYNC** so FOLLOW hears late-wire repairs when
|
||
the tip does not rise.
|
||
3. **FOLLOW honors wire REREPLAY** in Verify/Replay (and same-target
|
||
tip-extend absorb). `mismatch` on the wire is `prefer_plus` for
|
||
`schedule_episode_rereplay`.
|
||
4. **Tip-hold park Live** while deferred rereplay is pending
|
||
(`invent_slack=0` + admit stall `tip_hold_deferred` when `sim > tip`).
|
||
5. **Tip-hold flush notifies FOLLOW** with REREPLAY SYNC; when Live is
|
||
still at the POST tip (`parked=1`) prefer_plus = `from+1` (KEEP-LIVE),
|
||
else matched-prefix `first_bad` as §78/§79.
|
||
|
||
**Re-soak watch:** `tip-extend same-target rereplay` / `FOLLOW rereplay`
|
||
after `wire rewind-request` below tip; no arm with seals that later
|
||
disagree with wire without a rereplay; tip-hold flush logs `parked=1`
|
||
and FOLLOW `flush-rereplay` / `FOLLOW rereplay`; no DESYNC after tip-hold
|
||
coalesce.
|
||
|
||
## 81. Tip-hold hitch: DEFER-PEER flush + no double REREPLAY (2026-08-02)
|
||
|
||
**§80 soak:** correctness held (no DESYNC/POST-DIVERGE) but a visible hitch
|
||
near the end: FPS **11.2**, `admit≈73 ms` around sim 5012–5029.
|
||
|
||
**Bisect:**
|
||
|
||
1. **ABSOLUTE 2000 ms wall.** Tip-hold deferred at 5012, coalesce to 5023,
|
||
SAFETY deferred on `peer_past_tip` while `remote_tip=5029` (only +6 —
|
||
never hit RACE_MARGIN 24). Sat until ABSOLUTE CAP, then absorb+flush.
|
||
2. **Double FOLLOW rereplay.** `absorb_frontier` sent REREPLAY with
|
||
episode-original mismatch, then flush sent prefer_plus=first_bad —
|
||
guest scheduled prefer 4990 then 5019 (mid-span snap thrash).
|
||
3. **`parked=0` always.** §80 parked at *raised tip*, so Live walked with
|
||
coalesce; flush invent-snap path every time.
|
||
|
||
**Fixes:**
|
||
|
||
1. **DEFER-PEER:** once `g_tip_hold_rereplay_pending && peer_past_tip`,
|
||
only wait `RACE_PENDING_WALL` (400 ms) for more coalesce, then
|
||
`DEFER-PEER CAP` flush — do not sit to ABSOLUTE 2000.
|
||
2. **Absorb sends no SYNC** — flush owns the single REREPLAY notify with
|
||
correct prefer_plus / tip.
|
||
3. **FOLLOW same-target REREPLAY SKIP** when already tip-extend-repairing
|
||
the same tip (both-peer CAP / residual dupes).
|
||
4. **Park Live at `from`** (`psx_netplay_rb_tip_hold_rereplay_from`), not
|
||
the raised tip; flush treats tip audit as parked for KEEP-LIVE.
|
||
|
||
**Re-soak watch:** `DEFER-PEER CAP 400` instead of `ABSOLUTE CAP 2000` on
|
||
deferred tip-holds with peer ahead; no back-to-back FOLLOW prefer_plus
|
||
churn; flush `parked=1` + KEEP-LIVE / prefer=from; no 11 fps admit cliffs
|
||
from 2 s tip-hold walls.
|
||
|
||
## 79. Pad edge continuity across Replay/skip-snap (2026-08-02)
|
||
|
||
**Invariant:** Replay, skip-snap, and ownership transitions must preserve
|
||
logical button state. They may never synthesize a fresh edge for a button
|
||
that remained continuously held across the transition. Every tip/publish
|
||
Start↓ should have a corresponding staged Start↓ (`tip_without_stage` is
|
||
a contract break).
|
||
|
||
**Soak (hold Start, no release → double pause):** mid-Replay
|
||
`ownership continue skip-snap` at sim 6082 between held samples. SDL and
|
||
debounced Start stayed down (`cap/deb=1`), but §77 seeded edge `prev` from
|
||
**restored SIO** (often idle under D / pre-press snap). Tip-produce then
|
||
logged `START↓` + `tip_without_stage`; stage/local logged a second
|
||
`START↓` at 6083 while the finger never moved.
|
||
|
||
**§79 first fix (live-prefer):** Continuity = live → staged → SIO; merge
|
||
SIO; seed sticky/edges. Killed `tip_without_stage`. Residual: skip-snap
|
||
with `live=ff6f` (SDL chatter hole) preferred raw live → cleared sticky →
|
||
next SDL↓ was a fresh edge.
|
||
|
||
**Fix (continuity, no debounce rewrite):**
|
||
|
||
1. Sample = `staged` if valid, else live, else SIO.
|
||
2. Continuity = sample (no sticky rewrite).
|
||
3. Merge into local SIO: `sio &= continuity` (active-low).
|
||
4. Seed edge trackers from continuity; keep Start gesture "down" if held.
|
||
|
||
**Instrument:** skip-snap logs `sdl= sample= seed= sio= merged=`.
|
||
|
||
**Re-soak:** **no** `tip_without_stage`. Taunt double-pause is MotK game logic.
|
||
|
||
## 82. Tip-hold bare peer-ahead flush wall (2026-08-02)
|
||
|
||
**Symptom:** TipHold sat on ABSOLUTE 2000 ms when the remote was only a few
|
||
ticks past tip (under RACE_MARGIN), hitching FPS.
|
||
|
||
**Fix:** bare `peer_past_tip` (no deferred rereplay) flushes after
|
||
`RACE_PENDING_WALL` (400 ms) — `PEER-AHEAD CAP`. Far-ahead Absolute still
|
||
clamps (`DEFER-PEER` / absurd lead).
|
||
|
||
## 83. Fork-cap realign + DESYNC streak + absurd invent catch-up (2026-08-02)
|
||
|
||
**A:** Raise `g_bl_fork_cap` before `pick_realign_tip`; never realign to a tip
|
||
≥ fork_cap; fallback snap below cap or keep-live; peer-abort honor clamps
|
||
below fork_cap.
|
||
|
||
**B:** Fork DESYNC streak keys on `fork_cap`; peer-abort does **not** clear
|
||
the streak while fork_cap is set.
|
||
|
||
**C:** `np_sched_arm_absurd_invent_catchup()` — brief invent through cushion
|
||
rebuild when lead is absurd after a baseline-abort Live realign.
|
||
|
||
## 84. Post-FMV follow-NACK asymmetric realign (2026-08-03)
|
||
|
||
**Symptom (Force TURN soak):** live digs first diverge ~sim 768 after FMV
|
||
exit; late "long rewind" at ~3056 is discovery of that early fork.
|
||
|
||
**Bisect:**
|
||
|
||
1. Post-FMV host `begin` load past guest frontier → `follow REFUSED` +
|
||
NACK (`frontier` < load).
|
||
2. Host NACK path realigned Live locally but called `abort_episode` **without**
|
||
staging `g_abort_wire_realign_tick` → OP_ABORT carried realign=0.
|
||
3. Guest never joined the refused epoch → idle ABORT only honored
|
||
post-commit; `honor_peer_abort_realign(0)` no-op. Guest invents while
|
||
host rewound → permanent fork. Cooldown also made late wire
|
||
`promote-no-resim`.
|
||
|
||
**Fixes:**
|
||
|
||
1. **NACK ABORT wire tip:** stage `RNET_RB_ABORT_CLASS_REALIGN` +
|
||
demote/realign tick **before** `abort_episode` (even on local keep-live
|
||
so FOLLOW can converge).
|
||
2. **Idle ABORT honor:** always try `honor_peer_abort_realign` when the wire
|
||
carries a tip (not only `g_last_commit_epoch`).
|
||
3. **Live-ahead honor:** if Live invent is past the wire tip and we never
|
||
applied the episode snap, still `schedule_live_realign` (+ absurd
|
||
catch-up when the gap is large).
|
||
4. **Cooldown:** NACK with a wire tip uses REALIGN class (0 ticks) instead of
|
||
always arming `RB_ABORT_COOLDOWN_TICKS` promote-no-resim poison.
|
||
|
||
**Re-soak watch:** after `follow REFUSED` / NACK, both sides log mutual
|
||
realign (`NACK realign` / `peer abort realign live-ahead`); no solo host
|
||
rewind + guest invent; live digs stay matched past FMV; no early
|
||
`promote-no-resim reason=cooldown` storm on the NACK window.
|
||
|
||
## 85. Ownership chain Replay treadmill (Force-TURN audio mute) (2026-08-03)
|
||
|
||
**Symptom:** Mid-match audio cut out (worse under OBS). Host/guest logs showed
|
||
`replay=80–100%` for ~800 sim ticks with continuous `ownership extend` /
|
||
`ownership chain tip` / `tip-extend`.
|
||
|
||
**Bisect (rb-diag1/2):**
|
||
|
||
1. Chain tips advanced **+23** (one SPAN) every POST: 5799→5822→…→6580
|
||
(**35 hops**, **781 ticks**). Frontier gap stayed **tip+5..+8** the whole time
|
||
(Force TURN `D≈6–7` steady-state pipeline).
|
||
2. `RB_OWNERSHIP_CHAIN_MIN_AHEAD=4` (§73) means `frontier > tip+4` → **always
|
||
chain** while the peer stays Live. §47 never "exhausts" confirmed work
|
||
because the peer keeps confirming during our Replay.
|
||
3. Mid-episode `ownership_step` tip-extended at **wire pace** (§74 already
|
||
named this) so each SPAN filled to 24 even when the begin target was only
|
||
tip+6 — continuous resim → host audio pump skipped → silence.
|
||
|
||
**Invariant refinement:** Replay owns the contiguous confirmed backlog that
|
||
existed at **episode begin** (frozen catch-up cap), for a **bounded** number of
|
||
chain hops / tip advance. Wire that arrives during Replay is TipHold/Live's
|
||
job after commit — not an unbounded Replay chase.
|
||
|
||
**Fixes:**
|
||
|
||
1. **`g_replay_catchup_cap`:** set to begin/follow `target`; `ownership_step`
|
||
must not tip-extend past it.
|
||
2. **Chain budget:** default max **2 hops** / **48 tip ticks** from the first
|
||
chain of a recovery (`PSX_RB_CHAIN_MAX_HOPS` / `PSX_RB_CHAIN_MAX_TICKS`).
|
||
Over budget → `ownership chain BUDGET … → tip-hold` (both seats).
|
||
3. Clear budget on tip-hold / abort.
|
||
|
||
**Re-soak watch:** no 30+ hop chain runs; `ownership chain BUDGET` when the
|
||
peer keeps confirming; `ownership extend` rare inside an episode; `replay%`
|
||
spikes short (tens of ticks, not ~800); audio should survive OBS capture.
|
||
|
||
## 86. BUDGET→Live + min_ahead(D) + chain suppress (2026-08-03)
|
||
|
||
**§85 soak:** hop cap stopped the ~800-tick mute, but fights still hitch:
|
||
`BUDGET → tip-hold invent_slack` → coalesce-ahead yield → guest begin →
|
||
host `tip-hold yield FOLLOW` → short Replay → chain again (~every 12 tips).
|
||
|
||
**Invariant:** TipHold only when the next tick is unavailable. BUDGET with
|
||
`frontier > tip` must not TipHold-park confirmed work.
|
||
|
||
**Fixes:**
|
||
|
||
- **A:** `ownership chain BUDGET → Live` via immediate enter+finalize (no
|
||
invent_slack park). Log: `ownership Live … (chain budget) — no TipHold`.
|
||
- **B:** `min_ahead = max(4, D)` (env `PSX_RB_CHAIN_MIN_AHEAD` overrides).
|
||
Force-TURN delay cushion is not catch-up backlog.
|
||
- **C:** after BUDGET Live, suppress re-chain for **+24 tips / 400 ms**
|
||
(`PSX_RB_CHAIN_SUPPRESS_TICKS` / `_MS`). Hits log
|
||
`ownership chain SUPPRESS … → Live` for residual hitch diagnosis.
|
||
|
||
**Re-soak watch:** `BUDGET → Live` not `→ tip-hold`; few/no
|
||
`tip-hold yield FOLLOW` right after budget; `SUPPRESS` lines name leftover
|
||
hitches; fight `replay%` short; digests matched.
|
||
|
||
## 87. Ownership tip-hold hang (zombie epoch follow) (2026-08-03)
|
||
|
||
**§86 soak:** with Force TURN `D≈8–9`, `min_ahead=D` made every POST
|
||
`frontier=tip+D` land on **tip-hold** (never BUDGET/SUPPRESS/Live). Tip-hold
|
||
invent_slack thrash returned, then both seats hung until lobby:
|
||
|
||
1. Guest tip-hold commit → begin epoch=17; host still tip-hold epoch=8,
|
||
tip-extends then `yield FOLLOW 17`.
|
||
2. Guest **tie-break YIELD 17→8** → `follow epoch=8 load=1808` (already
|
||
committed tip-hold epoch; load behind agreed tip=1852) → stuck
|
||
`rb_baseline`.
|
||
3. Host aborts 17, Live invent ahead → `pcap_freeze lead=-13` until peer
|
||
disconnect.
|
||
|
||
**Invariant:** never reopen a committed tip-hold/ownership epoch; TipHold
|
||
only when `frontier <= tip` (next tick unavailable). Delay cushion
|
||
(`tip < frontier <= tip+min_ahead`) is Live, not invent park.
|
||
|
||
**Fixes:**
|
||
|
||
1. **Zombie follow refuse:** idle `begin_follower` rejects
|
||
`epoch == g_last_commit_epoch` and `load < g_agreed_through` (log
|
||
`already committed (zombie epoch)` / `behind agreed tip`).
|
||
2. **ownership final:** `frontier > tip` → Live (`final confirmed ahead`)
|
||
+ suppress; tip-hold only when `frontier <= tip`.
|
||
3. **B soften:** `min_ahead = max(4, D-1)` so Force-TURN `frontier≈tip+D`
|
||
still chains once instead of tip-hold every POST.
|
||
|
||
**Re-soak watch:** no `follow` of `g_last_commit_epoch` after tip-hold
|
||
commit; `ownership … → Live (confirmed ahead)` when frontier>tip below
|
||
chain threshold; `min_ahead` logs as D-1; no mutual `rb_baseline` /
|
||
`pcap_freeze` hang.
|
||
|
||
## 88. Netplay CPU-authoritative VRAM + OpenGL present (2026-08-03)
|
||
|
||
**Motivation:** forcing a full software *window* when the user picked OpenGL
|
||
was correct for determinism (FBO-auth + `glReadPixels` forked snaps) but a
|
||
weak product compromise. Digests/snaps already CRC **CPU** VRAM; the missing
|
||
piece was keeping GL for display without making the FBO guest authority.
|
||
|
||
**Design (Option A):**
|
||
|
||
- **Sim:** `gr_set_backend(SOFTWARE)` under netplay — all GP0 draws / fills /
|
||
copies / GPUREAD hit CPU VRAM (same as today’s forced-SW path).
|
||
- **Present:** if an OpenGL context exists, leave it up with
|
||
`g_gl_fbo_present=0` so Live frames CPU-scanout → `gl_renderer_present`
|
||
(same path as 24-bit FMV / `PSX_GL_FORCE_CPU_PRESENT`).
|
||
- **Never** `glReadPixels` while `psx_netplay_active()` (`ensure_cpu` early-out).
|
||
- Vulkan: still software window until the same present-only path exists.
|
||
- Call `psx_frontend_netplay_force_sw_gpu()` **before** `g_np.active=1` so a
|
||
one-shot FBO→CPU sync can still run if a session started on FBO-auth GL.
|
||
|
||
**Logs to expect:**
|
||
|
||
- `netplay — OpenGL present + software raster (CPU-authoritative VRAM; no FBO readback)`
|
||
- `netplay CPU-authoritative VRAM + OpenGL present (no FBO readback)`
|
||
- Not: `netplay forced software GPU (GL/VK VRAM readback forks peers)` with
|
||
GL torn down.
|
||
|
||
**Re-soak watch:** OpenGL selected in launcher → GL window under netplay;
|
||
matched `av=` / pin zlib across peers; no black first match; rematch keeps
|
||
GL present after lobby soft-return.
|
||
|
||
## 89. Netplay sim scale 1 — decouple SSAA from CPU-auth (2026-08-03)
|
||
|
||
**Motivation:** hybrid CPU-auth + GL present still paid full SW supersampling
|
||
cost when settings said 4× (`gr_set_scale(4)` + `gr_render_display_hires`).
|
||
That is fill-rate CPU work, not GPU SSAA — mid-fight ~guest 20ms even with
|
||
`replay≈0%`.
|
||
|
||
**Design:**
|
||
|
||
| Layer | Netplay CPU-auth | Offline |
|
||
|--------|------------------|---------|
|
||
| Sim / snaps / `av=` | `gr_set_scale(1)` always | `g_video_scale` SSAA |
|
||
| Present | native scanout → GL/SDL upscale + filter | hires mirror when scale>1 |
|
||
| Settings | `g_video_scale` kept; not synced from `gr_scale()` | unchanged |
|
||
|
||
- `s_netplay_sim_native_scale` + `netplay_cpu_auth_gpu()` gate present/tex sizing.
|
||
- `psx_frontend_netplay_force_sw_gpu` / netplay startup force scale 1.
|
||
- Launcher supersampling label notes "offline"; tooltip explains netplay 1× sim.
|
||
- True ordered-grid SSAA remains offline-only (would need SW×N or FBO-auth).
|
||
|
||
**Logs to expect:**
|
||
|
||
- `netplay sim supersampling clamped to 1x (settings Nx kept for offline)`
|
||
- `… (no FBO readback; sim scale 1)` / `… (CPU-authoritative VRAM; sim scale 1)`
|
||
|
||
## 90. OpenGL hold-last drawable V-flip (2026-08-03)
|
||
|
||
**Symptom:** first hold-last frame during Replay (menu resim) upside-down;
|
||
next live CPU→GL present corrects orientation.
|
||
|
||
**Cause:** `hold_capture_drawable` copies the already-presented backbuffer
|
||
(screen-oriented). `gl_renderer_present_hold_last` redrew it through
|
||
`present_target_quad` / `PRESENT_VS`, which always V-flips for PSX CPU/FBO
|
||
bands → double flip.
|
||
|
||
**Fix:** `present_target_quad(..., v_flip)`. `HOLD_DRAWABLE` passes
|
||
`v_flip=0` (swap UV v ends so the shader flip cancels). `HOLD_NATIVE` /
|
||
VRAM / wide keep `v_flip=1`.
|
||
|
||
## 91. Tip-extend vs peer ownership Live → verify / pcap hang (2026-08-03)
|
||
|
||
**Soak:** mid-match ownership POST @T; guest `ownership Live tip=T` (frontier
|
||
in delay cushion) while host TipHold tip-extends `T→T+N`, then
|
||
`verify wait peer POST` @T+N → timeout; guest invents into `pcap_freeze`;
|
||
later `baseline ext` (SPU/aux) abort cascade.
|
||
|
||
**Root:** `ownership_on_post_match` is local (frontier / min_ahead / hops).
|
||
Peer Live emits `OP_COMMIT` @T; host abandon only ran in **Verify** and
|
||
required an **exact** snap at `g_tip_extend_from_tick` — SNAP-STALE often
|
||
drops that pin — so COMMIT was ignored and host sat 4s in verify.
|
||
|
||
**Fix (`maybe_abandon_tip_extend` + tip-extend refuse):**
|
||
|
||
1. Honor peer COMMIT in **TipHold / Replay / Verify** (TipHold → finalize
|
||
clamp; Verify/Replay → abort+realign).
|
||
2. If exact snap missing, walk to nearest ring snap ≤ commit (or pin).
|
||
3. Refuse further `tip_extend` when peer already COMMIT'd this epoch at
|
||
tick ≤ sealed tip; poll TipHold calls abandon before coalesce walls.
|
||
|
||
**Re-soak watch:** `tip-extend ABANDON (TipHold)` / `tip-extend REFUSED …
|
||
peer COMMIT` instead of `verify timeout` + peer `pcap_freeze`; no dual
|
||
`lead=±12` park after ownership Live on one seat.
|
||
|
||
## 92. Dual-raster OpenGL present quality (2026-08-03)
|
||
|
||
**Motivation:** §88/§89 kept OpenGL for present but uploaded 1× CPU scanout
|
||
and stretched — settings 4× SSAA never reached the hr FBO under netplay
|
||
(`GL GPU pipeline ready (internal scale 1x)` while settings said 4×).
|
||
|
||
**Design (dual raster):**
|
||
|
||
```
|
||
GP0 → Software @ 1× → CPU VRAM → snaps / av= / GPUREAD (authority)
|
||
└→ OpenGL @ N× → hr FBO → present_vram (cosmetics only)
|
||
never glReadPixels / ensure_cpu under dual or netplay
|
||
```
|
||
|
||
| Layer | Dual-raster (OpenGL) | SW-only netplay |
|
||
|--------|----------------------|-----------------|
|
||
| Authority | SW writes every GP0 @ 1× | SW backend @ 1× |
|
||
| Present | `g_gl_fbo_present=1` @ `g_video_scale` | CPU scanout → SDL/GL upload |
|
||
| Snap load | `restage_vram` + `invalidate_present` | N/A (no FBO) |
|
||
| Digests | CRC CPU VRAM only | same |
|
||
|
||
- `gl_renderer_set_cpu_auth_dual(1)` — `glb_draw_*` / fill / copy write SW
|
||
then GPU; `s_gpu_dirty` stays clear; `ensure_cpu` never readbacks.
|
||
- `glb_set_scale(N)` already keeps `sw_renderer_set_scale(1)`.
|
||
- Depth24 / FMV still CPU present (`gl_renderer_present`).
|
||
- Soft-return clears dual flag.
|
||
|
||
**Logs to expect:**
|
||
|
||
- `netplay — dual-raster (SW@1x CPU-auth + OpenGL present quality; …)`
|
||
- `netplay GL present supersampling Nx (SW authority stays 1x)`
|
||
- `GL GPU pipeline ready (dual-raster, internal scale Nx, …)`
|
||
- `netplay dual-raster (SW@1x CPU-auth + OpenGL@Nx present; …)` from
|
||
`force_sw_gpu`
|
||
|
||
**Re-soak watch:** matched `av=` across peers; visible 4× GL quality in
|
||
match; no black first present after tip load; rematch keeps dual after
|
||
lobby soft-return.
|
||
|
||
## 93. FMV stability gates — refuse media episodes / resim storm / MAX DESYNC (2026-08-05)
|
||
|
||
**Soak (rb-diag1/2 session 8):** post-FMV resim from matched baseline `39655d1b`
|
||
diverged at sim 184 (~459 cycles) twice with identical abort digests; match-start
|
||
episode load=736 died on baseline `mdec`/`cd` mismatch; FMV lockstep RELEASE
|
||
with `streak=0` / MAX unmatched then soft-desynced the rest of the fight.
|
||
Broken main-menu assets after were consistent with mid-FMV VRAM realign churn.
|
||
|
||
FMV savestate completeness is not the bug — cross-peer determinism / policy is.
|
||
|
||
**Fixes (§93 stability P0):**
|
||
|
||
1. **Media-range episode refuse:** track `g_fmv_media_lo`/`hi` through each FMV
|
||
bout. Begin/follow refuse when mismatch or chosen load sits in
|
||
`[lo, hi+SETTLE]` (or DESYNC hold), even if Live tip already left media.
|
||
`psx_netplay_rb_fmv_episode_unsafe` + hc-fork skip. Snap apply that lands
|
||
mid-media aborts before Replay (`episode load into FMV media`) and raises
|
||
fork_cap. No `LIGHT_TIP` into media-range loads.
|
||
2. **Resim-diverge storm:** second `resim core diverge` on the same sim →
|
||
DESYNC keep-live + storm cooldown; no same-pin realign. First hit still
|
||
raises fork_cap so the doomed load is not reused.
|
||
3. **MAX unmatched RELEASE:** if lockstep hits MAX with `streak < CONFIRM`,
|
||
arm `g_fmv_unmatched_desync` + storm cooldown — invent/begin stay held
|
||
until cores rematch CONFIRM ticks, invent-hold expires (~600 ticks /
|
||
§94), netplay SAVE completes (§94), or session reset. Log
|
||
`rb DESYNC — FMV lockstep MAX unmatched`.
|
||
|
||
**Re-soak watch:**
|
||
|
||
- No `begin epoch=…` / `follow` into FMV media or settle; expect
|
||
`begin REFUSED … FMV media-range` / `episode load into FMV media`.
|
||
- No second `ABORT — resim core diverge` on the same sim; expect
|
||
`DESYNC — resim diverge storm` instead of same-pin realign loop.
|
||
- Post-FMV: either `RELEASE … cores matched` or `DESYNC — MAX unmatched`
|
||
with invent held — never `streak=0` + keep inventing through a fork.
|
||
- Idle digs: `core`/`cd` match through title → vs → match start.
|
||
- Menu after disconnect stays clean (no mid-FMV realign poison).
|
||
|
||
**Still open (P1/P2):** mid-session host state-transfer on storm; CD/MDEC
|
||
cycle determinism for matched-baseline sim 184.
|
||
|
||
## 94. Netplay SAVE under FMV DESYNC invent-hold — tip-hole deadlock (2026-08-05)
|
||
|
||
**Soak (rb-diag1):** after FMV `DESYNC — MAX unmatched` at sim=1044 (streak=0,
|
||
never rematched), fight ran wire-only (~60 fps, lead≈-1). Host Shift+F2 SAVE
|
||
at sim=2598 completed (`hashes match, skip transfer`), then admit froze at
|
||
sim=2608 `stall=fmv_settle lead=-1` for 20s → `admit stall timeout` → lobby.
|
||
Guest tip stuck at 2607. Save itself was fine; resume needed gap1 invent and
|
||
could not get it.
|
||
|
||
**Cause:** `g_fmv_unmatched_desync` keeps `lockstep_no_invent` true forever when
|
||
soft-fork digests never hit CONFIRM. Scheduler mis-tagged that stall as
|
||
`fmv_settle` (media inactive). SAVE coord freezes both peers → tip hole →
|
||
neither invents → mutual wait → watchdog.
|
||
|
||
**Fixes (§94):**
|
||
|
||
1. **Stall tag:** `fmv_desync_hold` when DESYNC invent-hold is the gate
|
||
(`psx_netplay_rb_fmv_desync_hold`); keep `fmv_media` / `fmv_settle`.
|
||
2. **Invent-hold expire:** `RB_FMV_DESYNC_INVENT_EXPIRE` (600 ticks) after arm —
|
||
clear invent-hold if rematch never comes. Log
|
||
`FMV DESYNC invent-hold cleared (invent-hold expired…)`.
|
||
3. **Save complete clear:** host/guest after SAVE hash-match or transfer done
|
||
call `psx_netplay_rb_clear_fmv_desync_hold("netplay save complete")` so tip
|
||
invent can refill immediately.
|
||
|
||
**Re-soak watch:** SAVE mid-fight after post-FMV DESYNC does not stall-timeout;
|
||
expect invent-hold cleared (expire or save) and gap1 invent resumes. Stall
|
||
lines should say `fmv_desync_hold` not `fmv_settle` while the hold is live.
|
||
|
||
## 95. Netplay LOAD — host hung `wait_confirm`, guest-only apply (2026-08-05)
|
||
|
||
**Soak (rb-diag1/2 session 19):** SAVE transfer OK. Host Shift+F1 LOAD hash-match
|
||
→ guest `LOADED` + `waiting for host`; host stuck
|
||
`load_applying+wait_confirm` ~14s (never `LOADED`) → peer disconnect / desync
|
||
(guest on .pst, host still live timeline).
|
||
|
||
**Cause:** `LOAD_APPLYING` used delay-sync `rnet_session_try_admit` (INPUT_CONFIRM).
|
||
Rollback live does not confirm. Faster peer applies → `LOAD_READY` freezes admit
|
||
→ slower peer never gets confirm for the next tick → `savestate_poll` never runs.
|
||
|
||
**Fixes (§95):**
|
||
|
||
1. **Rollback load-barrier admit:** while `LOAD_APPLYING` + `savestate_pending`
|
||
(and when exiting `LOAD_READY` after mutual sync), use tip + hold-last invent
|
||
with **no** confirm (`np_try_admit_load_barrier_rb`). Always invent missing
|
||
remotes — peer may already be frozen in LOAD_READY.
|
||
2. **Clear DESYNC invent-hold** at `np_commit_load_sync` (hard resync).
|
||
|
||
**Re-soak watch:** both peers log `LOADED` / `applied, waiting…` then
|
||
`peer ready, resuming lockstep`. No host-only `load_applying+wait_confirm`
|
||
stall after hash-match apply.
|
||
|
||
## 96. FMV snap cost — dirty VRAM mirror + media interval (2026-08-05)
|
||
|
||
**Goal:** keep invent/episode policy for FMV media (§50/§93) but make the
|
||
rollback snap machinery cheaper while the movie runs.
|
||
|
||
**What landed:**
|
||
|
||
1. **Per-scanline VRAM dirty tracking** (`gpu_vram_dirty.*`) on every CPU-VRAM
|
||
write path (gpu.c A0/raster + sw fill/copy/put/transfer). **Gated:**
|
||
`gpu_vram_dirty_set_tracking(1)` only in `psx_netplay_rb_start`; off in
|
||
`shutdown`. Offline / delay-sync: `g_psx_vram_dirty_tracking==0` so
|
||
`mark_row` is an inlined no-op (zero gameplay impact).
|
||
2. **Incremental raw snap VRAM** (`boot_state_save_buffer_raw` + tracking on):
|
||
persistent 1 MiB mirror; only dirty scanlines are copied from live VRAM
|
||
before emitting a still-complete `BS_SEC_VRAM` (loads stay independent /
|
||
no delta chain). Offline raw/zlib saves keep full `gr_vram_transfer_out`.
|
||
3. **FMV media snap interval** (default **4**, `PSX_NET_FMV_SNAP_INTERVAL`):
|
||
during media, live snaps every N ticks. Settle + post-FMV lockstep remain
|
||
every-tick dense (cutover near-tip load). Episodes into media still refused.
|
||
4. **Telemetry:** `rb snap tele` every 64 saves — `last/avg ms`, `dirty_rows`,
|
||
`incr%%`. Bring-up verify: `PSX_NET_VRAM_DIRTY_VERIFY=1` memcmp vs full
|
||
transfer_out.
|
||
|
||
**Not changed (still accept):** no invent through media; SW@1× CPU-auth VRAM;
|
||
soft-fork / DESYNC invent-hold after unmatched FMV.
|
||
|
||
**Re-soak watch:** during FMV media, `rb snap tele` shows `incr=1` with
|
||
`dirty_rows` ≪ 512 (typical depth24 band), lower `avg ms` than pre-change,
|
||
and media snap cadence ~interval 4. Settle/lockstep still dense. Optional
|
||
verify run once with `PSX_NET_VRAM_DIRTY_VERIFY=1` (no VERIFY FAIL lines).
|
||
|
||
## 97. FMV media keyframe episodes — invent into media (2026-08-05)
|
||
|
||
**Goal:** abandon blanket “no invent / no episode into FMV media” (§93) by
|
||
healing stream soft-forks the same way SAVE/LOAD heals state: **host seals a
|
||
raw snap keyframe at `load_tick` before Replay**.
|
||
|
||
**Default ON** (`PSX_NET_FMV_MEDIA_KF=0` restores §93 refuse + invent-off).
|
||
|
||
**What landed:**
|
||
|
||
1. **Invent during live media** when MEDIA_KF on (settle + DESYNC hold still
|
||
block invent). Media snap interval defaults to **1** (every tick) so
|
||
`load_tick` hits a ring snap.
|
||
2. **Begin/follow into media-range** allowed when a snap exists (initiator) or
|
||
peer flags `RNET_RB_SYNC_FLAG_MEDIA_KF` (follower may lack the snap).
|
||
3. **Host keyframe transfer** (`RNET_STATE_OP_RB_KF`): probe CRC of raw snap →
|
||
skip xfer on match; else chunked transfer. Both peers install into pin+ring
|
||
(`rb MEDIA-KF …`). No LIGHT_TIP into media KF episodes.
|
||
4. **Apply waits** for `g_media_kf_ready`; prefers pin; **skips**
|
||
`episode load into FMV media` abort for KF episodes.
|
||
5. Restore epilogue unchanged (`cdrom_resync_*`, depth24 cutover, XA FIFO).
|
||
|
||
**Re-soak watch:**
|
||
|
||
- Mid-FMV: invent can fire; logs `rb begin … media_kf=1` /
|
||
`MEDIA-KF probe` / `hash match` or `transfer start` / `installed`.
|
||
- No resim-diverge storm on first media episode; baseline digests match after KF.
|
||
- `PSX_NET_FMV_MEDIA_KF=0` restores prior refuse / invent-off behavior.
|
||
|
||
## 98. FMV admit/FPS — freeze D + timesync during media (2026-08-05)
|
||
|
||
**Diag (post-§97):** snaps were already cheap (`rb snap tele avg≈0.7 ms`,
|
||
`incr=1`, `dirty_rows≈128`) but FMV sat ~43–55 fps. Ceiling was
|
||
`guest≈13–16 ms/f` + `admit≈4–8 ms/f` (timesync debt + D ratcheted to 6–7
|
||
under invent-through-media). `present_gap_p95≈80–100 ms` was mostly the
|
||
hardcoded present-1/4 skip (~67 ms+ between Swaps), not snap cost.
|
||
|
||
**What landed:**
|
||
|
||
1. **No timesync debt during FMV media** — `np_timesync_note_late` skips;
|
||
`np_timesync_throttle` clears debt and never stalls admit while media.
|
||
2. **Freeze adaptive D during media** — both `np_adapt_delay_on_pcap_enter`
|
||
and `np_auto_delay_tick` no-op while `psx_netplay_rb_fmv_media_active()`.
|
||
3. **No LAN gap1 micro-grace during media** — invent immediately when the
|
||
MEDIA_KF path allows invent (avoid admit wait for a peer that is also
|
||
inventing through the movie).
|
||
4. **Media snap interval default 2** with MEDIA_KF (was 1). Near-tip loads
|
||
still work; halves snap tax vs every-tick. Override
|
||
`PSX_NET_FMV_SNAP_INTERVAL`.
|
||
5. **Present every 2nd depth24 vblank** during hot MDEC (was every 4th).
|
||
Override `PSX_NET_FMV_PRESENT_DIV` (`1`=every frame, `4`=legacy).
|
||
|
||
**Re-soak watch:** during FMV media, `admit=` near ~0–2 ms/f (not 5–11),
|
||
`D` stable (no mid-movie 6→7), `present_gap_p95` roughly half of §97,
|
||
`rb snap tele` cadence ~interval 2, sim FPS closer to guest ceiling.
|
||
|
||
---
|
||
|
||
## 101. Zombie-load follow NACK (session-136 seal hang) (2026-08-06)
|
||
|
||
**Symptoms (session 136 soak):** guest `begin epoch=33 load=3184` while host
|
||
already had `agreed tip=3200` → host `follow REFUSED … zombie load` ×100 with
|
||
**no NACK** → guest sat in `SealInputs` until `RB_SEAL_TIMEOUT_MS` (4s) →
|
||
`episode ABORT` → hc-fork + ownership cascade; host FPS cliff `0.8` /
|
||
`admit≈1241 ms`, present_gap max ~3–4 s.
|
||
|
||
**Root cause:** §87 zombie-load / zombie-epoch refuse returned silently. Other
|
||
refuse paths (past frontier, missing snap, FMV) already armed `follow NACK`.
|
||
|
||
**What landed:**
|
||
|
||
1. **NACK on zombie load / zombie epoch** — `follow_refuse_nack` arms the
|
||
same follow-NACK (frontier = `follower_frontier_hint` / agreed tip) so the
|
||
initiator aborts Seal in ~1 RTT instead of 4s.
|
||
2. **Rate-limited refuse log** — one line per `(epoch, load)`, then
|
||
`+N similar` every 32; one NACK arm per unique refuse.
|
||
3. **NACK peer-ahead fast path** — when NACK `frontier >= load`, set
|
||
`g_peer_nack_floor`, `apply_peer_resolved(frontier)`, abort Seal, and
|
||
**keep-live** when the snap was never applied (no demote-to-`load-1`
|
||
rewind of good Live).
|
||
4. **Begin raise** — after `choose_load`, if `peer_resolved > load`, raise
|
||
to a mutual snap ≤ peer tip so we do not reopen a zombie load.
|
||
|
||
**Re-soak watch:** no `seal timeout (peer missing snap / NACK lost)` after
|
||
zombie refuse; expect `follow NACK … frontier=` then
|
||
`NACK keep-live peer-ahead` / `begin raise load` within tens of ms, not a
|
||
multi-second present hitch.
|
||
|
||
---
|
||
|
||
## 102. Peer-ahead NACK → one light tip (no ownership cascade) (2026-08-06)
|
||
|
||
**Symptoms (session 140 after §101):** zombie refuse NACKed cleanly, but host
|
||
still opened `hc-fork` → `begin load=6640 target=6660` → ownership hops
|
||
32/40 → host **22 fps / replay 96%** / present_gap max ~531 ms.
|
||
|
||
**Root cause:** keep-live left hc-fork recovery to open a deep SPAN; Replay
|
||
ownership then chained to drain confirmed backlog.
|
||
|
||
**What landed:**
|
||
|
||
1. **Drain RESOLVED before `choose_load`** — avoid opening a zombie load when
|
||
the peer tip is already queued on the socket.
|
||
2. **`psx_netplay_hc_fork_recovery_clear`** — peer-ahead NACK drops pending
|
||
hc-fork bookkeeping so it cannot open a SPAN recovery.
|
||
3. **Immediate light tip reopen** — raise agreed to peer tip, arm
|
||
`g_peer_ahead_force_load`, `begin` with target capped to `load+4`, force
|
||
light-tip class.
|
||
4. **No ownership chain** — `ownership_on_post_match` exits that episode to
|
||
Live (`peer-ahead light → Live`) instead of SPAN hops.
|
||
|
||
**Re-soak watch:** after zombie NACK expect
|
||
`peer-ahead light reopen` / `begin … light=1` / `peer-ahead light → Live`
|
||
with **no** `ownership chain tip=` hops and no `hc-fork recovery begin` on
|
||
that edge. Hitch should be a short light replay (depth ≤4), not a 96% window.
|
||
|
||
---
|
||
|
||
## 103. Past-frontier NACK deep demote → MEDIA-KF hang (2026-08-06)
|
||
|
||
**Symptoms (session 142):** post-FMV host `begin epoch=16 load=816 light=1
|
||
media_kf=0`; guest `follow REFUSED … past frontier=763` + NACK; host
|
||
`NACK realign … demote=763 live_was=827` (dropped 63 snaps) →
|
||
`FIRST CORE DIVERGE @764` → `begin … load=752 media_kf=1` (~3.7 MB transfer,
|
||
`stall=rb_baseline`) → after KF tip=776 ownership hop
|
||
`begin REFUSED … FMV lockstep` → tip-hold; host present_gap max ~3531 ms /
|
||
~15 fps, guest ~7.4 fps.
|
||
|
||
**Root cause:** past-frontier NACK treated a stale follower frontier like a
|
||
shared rewind tip. Live demote across the FMV bout forked cores and forced a
|
||
deep MEDIA-KF; re-armed settle then blocked the ownership catch-up hop.
|
||
|
||
**What landed:**
|
||
|
||
1. **Deep keep-live** — when snap was never applied and
|
||
`live − demote > RB_NACK_DEEP_REALIGN_MAX` (8): no Live realign; soft-demote
|
||
agreed to `load-1` only; clamp `peer_resolved` to the follower frontier.
|
||
2. **Sticky peer gate** — `g_past_frontier_sticky` prevents GATE_MS force-open
|
||
from reopening above that frontier until the follower advertises RESOLVED.
|
||
3. **Ownership through settle** — `begin` allows `g_ownership_chain` (and
|
||
active MEDIA_KF) through FMV lockstep so post-KF SPAN catch-up is not
|
||
tip-held.
|
||
|
||
**Re-soak watch:** after `follow REFUSED … past frontier` expect
|
||
`NACK keep-live deep frontier=…` (not `NACK realign … demote=` far below
|
||
live); no immediate `MEDIA-KF transfer` / `stall=rb_baseline` from that edge;
|
||
if a MEDIA-KF still completes, ownership should log `ownership chain tip=`
|
||
hops rather than `ownership chain FAILED … tip-hold`.
|
||
|
||
---
|
||
|
||
## 104. Fight ownership tax + guest invent (session 144) (2026-08-06)
|
||
|
||
**Symptoms (session 144 after §103):** no MEDIA-KF / deep demote / seal hang.
|
||
Median 60 fps. Remaining cost was fight-phase: many short ownership
|
||
`final`/`BUDGET` hops → replay windows 12–79% / present_gap max ~430 ms;
|
||
guest invent ~5× host (`GAP1_LEGACY` case_b + `RUNWAY_EMPTY`); auto-delay
|
||
`5→4` mid-fight; one ~3.1 s FMV present cliff (unchanged — not `present_div`).
|
||
|
||
**What landed:**
|
||
|
||
1. **Ownership floor tip+6** — `RB_OWNERSHIP_CHAIN_MIN_AHEAD` 4→6 so tip+4/+5
|
||
delay cushion exits Live instead of SPAN. Suppress after budget
|
||
24→48 ticks / 400→800 ms.
|
||
2. **Gap1 micro-grace for relay-LAN** — `RB_GAP1_LAN_RTT_MAX_MS` 12→48 so
|
||
`force_input_relay` RTT≈21–45 ms arms grace; cap 6→12 ms. Healthy-lead
|
||
case_b also waits (not invent-now on tip cadence blips).
|
||
3. **Runway grace** — `RB_INVENT_RUNWAY_GRACE_CAP_MS` 8→12.
|
||
4. **Auto-delay lower floor** — never shrink below D=5 mid-match
|
||
(`RB_AUTO_DELAY_LOWER_FLOOR`); tighter lower bar (miss<1‰ + lead≥3).
|
||
Force-TURN floor 6 unchanged.
|
||
5. **PIN-SKEW log rate-limit** — one line per tick (cosmetic; no control change).
|
||
|
||
**Left alone:** FMV `PSX_NET_FMV_PRESENT_DIV` default 2 (3.1 s cliff is a
|
||
single media stall, not 1/N cadence); HC-silent promote stay fail-closed on
|
||
digest proof; §101–103 NACK paths untouched.
|
||
|
||
**Re-soak watch:** fewer `ownership chain tip=` / `BUDGET` lines during
|
||
fight; guest `admit stats invent_gap1` closer to host; `gap1_grace` counts
|
||
rise; no `delay committed 5 → 4`; replay% spikes under button traffic
|
||
smaller (aim <30% windows, present_gap max <200 ms outside FMV).
|
||
|
||
---
|
||
|
||
## 105. Invent≠delay raise + gap1 wait for tip invent (session 149) (2026-08-06)
|
||
|
||
**Symptoms (session 149 after §104):** felt regressive. Ownership begins
|
||
dropped (good), but host invent exploded (`invent_gap1=2285`, all
|
||
`remote_lead=-1` / case_b), `gap1_grace=0` (healthy-lead arm dead), lobby
|
||
`D=4` then auto-delay `4→5→6→7` on invent-as-miss (`miss=993‰`,
|
||
`late_n≈1`, `lead_avg≈-2`). Ended in FMV DESYNC MAX unmatched.
|
||
|
||
**Root cause:** tip invent counted as auto-delay arrival miss → raising D
|
||
added lag without stopping invent. §104 grace required `lead≥D-1`, which
|
||
never holds on the invent path (`lead=-1`).
|
||
|
||
**What landed:**
|
||
|
||
1. **Undo invent miss** — invent clears the auto-delay miss sample
|
||
(`np_auto_delay_undo_invent_miss`); scorecard miss-at-need still counts.
|
||
2. **Raise needs lateness** — auto-delay bump only when `late_n>0` (waited-out
|
||
arrival), not invent-only miss storms.
|
||
3. **Gap1 grace for tip invent** — on gap=1, if RTT≤48 ms or tip-due, always
|
||
wait `min(½RTT+base, 12ms)` (no healthy-lead gate).
|
||
4. **Session D floor 5** — rollback clamps lobby seed `<5` → `5` at
|
||
`rnet_session_create` and in `np_sched_sync_delay_from_session`.
|
||
|
||
**Kept from §104:** ownership `min_ahead=6` / suppress 48/800; runway grace
|
||
12; PIN-SKEW rate-limit; auto-delay lower floor 5.
|
||
|
||
**Re-soak watch:** start log `session delay floor … → 5` if lobby was 4;
|
||
`gap1_grace` > 0 in admit stats; no `auto delay 5 → 6/7` driven by
|
||
`miss≈1000‰` with `late_n=0/1` and `lead_avg<0`; invent counts lower; D
|
||
stable near 5–6 unless real lateness / pcap.
|
||
|
||
---
|
||
|
||
## 106. No-ICE MotK → SFU dial / clear ice_p2p refuse (session 151) (2026-08-06)
|
||
|
||
**Symptoms (session 151):** host (`dist/motk`, ICE build) started SFU
|
||
(`force_input_relay=1`, peer `192.168.66.3:8777`). Guest Desktop pack
|
||
failed start `-4` with `hosted lobby requires ICE, but ICE is not available
|
||
in this build` while peer was already `netplay…:8777` — SFU endpoint present,
|
||
`force_input_relay` not applied / ICE required anyway.
|
||
|
||
**Root cause:** no-ICE guest + MotK room treated as hard ICE requirement even
|
||
when a usable SFU peer (or equal host/guest relay advertise) was available.
|
||
`ice_p2p` launch on no-ICE builds fell through to the same opaque `-4`.
|
||
|
||
**What landed:**
|
||
|
||
1. **`resolve_use_ice` no-ICE** — MotK + non-empty `peer_hostport` → LAN/SFU
|
||
dial (log note); MotK/`transport=1` with empty peer → clearer SFU/ICE error.
|
||
2. **Launch `ice_p2p` without ICE** — refuse with `last_error=ice_required`
|
||
(do not leave launch_pending for a doomed start).
|
||
3. **`ae_np_fill_launch`** — infer `force_input_relay=1` when
|
||
`host_endpoint==guest_endpoint` (SFU advertise) even if caps bit omitted.
|
||
|
||
**Re-soak watch:** no-ICE guest on SFU MotK logs `server input relay` or
|
||
`no-ICE build — MotK room dialing peer … as LAN/SFU` and connects; ice_p2p
|
||
without ICE shows `ice_required` in lobby UI, not start `-4`; both sides
|
||
share SFU when server picked `transport=sfu`.
|
||
|
||
---
|
||
|
||
## 107. Gap1 micro-grace on SFU/relay RTT (post-§105 soak) (2026-08-06)
|
||
|
||
**Symptoms (post-§105 soak, ~18k ticks, SFU `force_input_relay=1`):** resim
|
||
health good (mispredict host 8 / guest 17, freeze=0, MEDIA-KF sealed, D
|
||
ends 7 and moves on real `late_n`). Remaining cost: host tip invent storms
|
||
in fight windows (`GAP1_LEGACY` ~550–600 at `remote_lead=-1` while guest
|
||
~0–5); **1277/1305 host invents had `rtt>48`**. One auto-delay window
|
||
`miss=926‰` with `lead_avg≈-0.9` at rtt~75 (tip-park tax).
|
||
|
||
**Root cause:** §105 gap1 micro-grace only armed when `rtt_raw≤48` (LAN gate)
|
||
or tip-due. SFU/relay POST-RTT sat ~75–125 → `lan=false` → `gap1_cap=0` →
|
||
invent every gap=1 tick. §105 correctly stopped invent-as-miss from
|
||
ratcheting D; it did not stop the invents.
|
||
|
||
**What landed:**
|
||
|
||
1. **Always grace on gap=1** (non-FMV) — `min(½RTT+base, cap)` for tip invent.
|
||
2. **Split caps** — LAN / tip-due keep cap **12 ms**; relay (`rtt>48` and not
|
||
tip-due) cap **20 ms** so SFU gets a real wait without parking a full
|
||
frame on a due tip.
|
||
3. **FMV unchanged** — §98 invent-immediate during media.
|
||
|
||
**Re-soak watch:** host invents/1k drop hard in fight windows (aim ≪70);
|
||
`gap1_grace` rises vs invent_gap1; guest invent stays low; D still only
|
||
raises on real lateness (`late_n>0`); no feel of extra stall on LAN
|
||
(`rtt≤48` path unchanged at cap 12).
|
||
|
||
---
|
||
|
||
## 108. Always SFU for online MotK/BPE lobbies (2026-08-06)
|
||
|
||
**Problem:** Online connectivity layered lobby SFU + waiting-room ICE
|
||
`path_report` → optional `ice_p2p` + Coturn/Force TURN. Host/guest could
|
||
disagree (session 151 no-ICE guest, stale caps bits, Force TURN meaning
|
||
ICE-relay vs SFU). Too many signals for CGNAT reachability SFU already covers.
|
||
|
||
**What landed:**
|
||
|
||
1. **Server** — `start_use_sfu` always returns SFU (`reason=always_sfu`).
|
||
No `ice_p2p` selection from path reports / caps.
|
||
2. **Client `resolve_use_ice`** — MotK room / `force_input_relay` → LAN dial
|
||
to peer/SFU; match ICE not used online.
|
||
3. **Launch** — legacy `transport=ice_p2p` refused (`sfu_required`);
|
||
`path_report` kept as telemetry only.
|
||
4. **Force TURN** — delay-floor hint only; does not change transport.
|
||
5. **LAN/direct** — unchanged (local UDP / host hub; no MotK SFU).
|
||
|
||
**Re-soak watch:** start logs `use_sfu=true reason=always_sfu`; both peers
|
||
`server input relay — LAN transport to …`; no `ice_p2p` / `ice_required`
|
||
on online MotK; no-ICE Desktop guests connect; Force TURN only affects D floor.
|
||
|
||
## 109. Post-FMV silent hc-fork → apply-only MEDIA_KF heal (2026-08-13)
|
||
|
||
**Soak (TM4 Win↔CachyOS 2P session 26):** digs matched through sim 2368 after
|
||
FMV settle; `FIRST CORE DIVERGE @2380` with **no pad mispredict**; host opened
|
||
`begin … load=2368 media_kf=0`; both `ABORT — resim core diverge @2380`; then
|
||
`DESYNC — FMV lockstep MAX unmatched`. Transport/admit stayed up — tip-snap
|
||
SPAN cannot heal undigested / cross-OS forks once sealed Replay re-diverges.
|
||
|
||
**Root cause:** §54 hc-fork after settle used mutual tip snaps without a host
|
||
keyframe. §26 unlocks invent/hc-fork after settle (24) while dense lockstep
|
||
CONFIRM still runs to MIN=180 — so the doomed SPAN fires inside the confirm
|
||
window. `media_kf=0` because load was past the media/settle unsafe range.
|
||
|
||
**What landed (§109):**
|
||
|
||
1. **`psx_netplay_rb_request_post_fmv_heal_kf`** — hc-fork (and first post-FMV
|
||
resim-diverge escalate) request an apply-only heal on the next begin.
|
||
2. **Begin** — when requested: arm `MEDIA_KF`, **CAP target→load** (no Replay
|
||
span), wire `RNET_RB_SYNC_FLAG_MEDIA_KF`. Host transfers raw snap; both
|
||
install pin and Verify at load.
|
||
3. **choose_load / follow** — treat post-FMV lockstep + DESYNC hold like
|
||
media-range for MEDIA_KF mutual snaps / missing-snap OK.
|
||
4. **hc-fork gate** — allow through DESYNC invent-hold when MEDIA_KF can heal;
|
||
still block live media + settle.
|
||
5. **On heal Live/tip-hold** — clear DESYNC invent-hold and RELEASE lockstep.
|
||
6. **Pad-mispredict** begins in the post-FMV window still SPAN (optionally with
|
||
MEDIA_KF via `rb_want_heal_kf`); only silent-fork recovery is apply-only.
|
||
7. **POST match → Live** — `ownership_on_post_match` treats heal like
|
||
peer-ahead light: no ownership SPAN hop (session 28: matched POST @826
|
||
then chain target=850 hung guest wait-FOLLOW → disconnect).
|
||
|
||
**Re-soak watch (Win↔Linux 2P through title FMV):**
|
||
|
||
- After settle expect `post-FMV heal KF requested` then
|
||
`begin … media_kf=1 heal=1` / `post-FMV heal KF CAP target …→load`.
|
||
- `MEDIA-KF probe` / hash match or transfer; `post sent tip=<load>`;
|
||
`FMV lockstep RELEASE (post-FMV heal KF …)`.
|
||
- No `ABORT — resim core diverge` on that heal episode; no
|
||
`DESYNC — FMV lockstep MAX unmatched` from the same cutover.
|
||
- Linux↔Linux control still clean; 3P WIRE_HOLE is a separate bug class.
|
||
|
||
## 110. Post-FMV heal sticky + invent hold (session 3 loading hitch) (2026-08-13)
|
||
|
||
**Soak (TM4 Win↔CachyOS session 3):** §109 heal POST matched @2494 → Live →
|
||
`FMV lockstep RELEASE`. Loading-screen hitch kept HC forked at 2495; absurd
|
||
lead + invent catch-up; second `hc-fork` with `heal=0` tip-SPAN →
|
||
`resim core diverge @2495` → storm DESYNC.
|
||
|
||
**Cause:** `request_post_fmv_heal_kf` gated on pre-RELEASE lockstep only.
|
||
RELEASE dropped eligibility; invent free-ran through the soft fork.
|
||
|
||
**What landed (§110):**
|
||
|
||
1. **Heal sticky** (`RB_FMV_HEAL_STICKY_TICKS=300`) armed on heal Live/tip-hold
|
||
— `rb_post_fmv_heal_eligible` stays true through sticky (and DESYNC /
|
||
lockstep). Re-heal hc-fork keeps `media_kf=1 heal=1`.
|
||
2. **Invent hold** during sticky via `lockstep_no_invent` — no GAP1 invent
|
||
catch-up until sticky expires or cores rematch CONFIRM.
|
||
3. Early sticky clear when post-RELEASE HC streak hits CONFIRM.
|
||
|
||
**Re-soak watch:** after heal expect `heal sticky until sim=…`; on hitch
|
||
re-fork expect `heal KF requested … sticky_until=` then apply-only again —
|
||
not `begin … media_kf=0 heal=0` / resim storm. Digs rematch →
|
||
`heal sticky cleared (cores rematched…)`.
|
||
|
||
## 111. Post-FMV tip+1 ±1-cycle Win↔Linux (dirty entry poll) (2026-08-13)
|
||
|
||
**Soak (MotK Win↔CachyOS after §110b verify heal):** tip **870** matched
|
||
(`core=1d304d69`, MEDIA-KF CRC match, resume `pc=0x80076880`,
|
||
`i_stat=00000001`). Resim fin@871: Win `cyc=492226568` vs Cachy
|
||
`492226569` (±1); cpu/clk/tim/ram fork; hc-fork `no pad mispredict`.
|
||
|
||
**Cause:** post-FMV wait lives in a dirty-interp hole (not in static emit).
|
||
Dirty entry used a host-only `s_interp_entry_poll %% 64` stride to pump IRQs.
|
||
Peers that drifted through FMV entered the wait with opposite phases while
|
||
VBlank was already latched — one delivered immediately, the other after a
|
||
few wait-loop instructions (`exit_pc=0x800768C8`, `v0=1`). Same class as the
|
||
old CD54 hold gated on host `s_present_pending`.
|
||
|
||
**What landed (§111):**
|
||
|
||
1. **Deliverable → poll every dirty entry** (guest-deterministic). Keep the
|
||
%%64 throttle only when no IRQ is deliverable.
|
||
2. **`dirty_ram_irq_ambient_resync_after_restore`** — reset entry-poll stride
|
||
+ 4096-insn pump gap from `psx_cycles_resync_after_restore`.
|
||
3. **Second MotK wait pair** `0x800768C8` (hold A) ↔ `0x80076880` (canonical
|
||
B) for IRQ hold / present gate / snap resume canonicalize.
|
||
|
||
**Re-soak watch:** through title FMV → settle → tip+1 must keep matched
|
||
`audit fin` cyc (no ±1) and no `FIRST CORE DIVERGE` / heal-verify abort at
|
||
the first post-settle frame. Linux↔Linux control still clean.
|
||
|
||
## 112. Post-snap LEGACY_SENTINEL tip+1 (loading screen) (2026-08-13)
|
||
|
||
**Soak (MotK Win↔CachyOS after §111):** matched through settle@1354 and live
|
||
dig@1472; soft fork **sim=1480**. Tip **1479** matched (`core=f9e21305`,
|
||
resume `pc=0x800756e0`, `i_stat=00000001`). Heal verify fin@1480 cyc Δ4;
|
||
Win abort irqctx `same_thr=0 reason=3 exit_pc=0 epc=80000048` at arm cycle
|
||
(`PSX_EXC_ESCAPE_LEGACY_SENTINEL`).
|
||
|
||
**Cause:** `interrupts_resync_after_restore` zeros compiled/dirty resume
|
||
latches. Sticky VBlank then delivered on the first post-`flush_resume`
|
||
check with `take_pc=0` → sentinel EPC / cross-thread restore, while a peer
|
||
that latched the BB PC first took the real same-thread path.
|
||
|
||
**What landed (§112):**
|
||
|
||
1. **`psx_irq_arm_compiled_resume_pc`** — publish resume PC (+ last-check edge).
|
||
2. **`psx_scheduler_resume_at` + RESUME_CURRENT dispatch** arm before
|
||
`psx_dispatch`.
|
||
3. **Delivery fallback** — if latches still 0 under top-level resume, use
|
||
`cpu->pc` instead of the sentinel.
|
||
|
||
**Re-soak watch:** loading-screen tip+1 — no Win `irqctx … reason=3
|
||
epc=80000048` on heal abort; fin cyc match; no `FIRST CORE DIVERGE` at the
|
||
first post-settle loading frame.
|
||
|
||
|
||
## 113. Win↔UNIX post-FMV invent + ahead-tip pacing (2026-08-13)
|
||
|
||
**Soak:** after §111/§112 accuracy work, Win↔Cachy still showed chronic
|
||
cadence skew through loading: Win `phase ctrl lead≈+7..8` / `debt_ms=0`,
|
||
Cachy `GAP1_LEGACY` / `RUNWAY_EMPTY` invents. Soft fork @1480 sat **before**
|
||
`dense_lockstep min=1510` while invent had already unlocked at settle@1354.
|
||
|
||
**What landed (§113):**
|
||
|
||
1. **Invent hold through lockstep MIN** — `rb_in_fmv_lockstep_window` now
|
||
gates invent until `g_fmv_lockstep_until` (media_end+MIN), matching the
|
||
gate comment that already said invent stays off through MIN. Stall tag
|
||
`fmv_lockstep` after settle; settle log `invent_hold=` reports MIN.
|
||
2. **Ahead-of-tip timesync** — `np_timesync_note_ahead_skew`: when
|
||
`remote_lead < 0` for ~8 admits outside media/lockstep, add ~½-tick debt
|
||
so the inventing seat paces before GAP1 (BattleShip cross-OS pattern).
|
||
3. **Gap1 LAN grace cap 12→20 ms** — more room for a mid-flight tip row on
|
||
LAN Win↔UNIX before inventing.
|
||
|
||
**Re-soak watch:** `RBE_CROSS_OS_PACING_DIAG=1` — through FMV→loading,
|
||
`stall=fmv_lockstep` (not invent) until MIN; `debt_ms` rises on the
|
||
ahead-of-tip seat; fewer `GAP1_LEGACY` / `RUNWAY_EMPTY` before tip+1.
|
||
Accuracy (§112 arm) still required for matched fin cyc at tip+1.
|
||
|
||
|
||
## 114. Platform tip+1 apply-only heal + KF stream (2026-08-13)
|
||
|
||
**Problem:** matched-baseline tip+1 after post-FMV loading is Win↔Linux
|
||
platform nondeterminism (cpu/clk/tim/ram diverge with matched dirty). Verify
|
||
span past load always aborted; empty tip KF rematched tip and stormed.
|
||
|
||
**What landed (§114):**
|
||
|
||
1. Apply-only MEDIA_KF heal (`target=load`) — no tip+1 resim.
|
||
2. Heal Live → invent off + `g_post_fmv_platform_nondet` + initiator KF stream
|
||
every 16 sim ticks (dense snaps).
|
||
3. Heal loop CAP → same keep-live stream instead of hard DESYNC abort.
|
||
|
||
**Gap:** stream always rewound to HC's stuck tip+1 fork (`resolved+1`), so
|
||
`choose_load` re-pinned keep forever → loading-screen livelock (no DESYNC).
|
||
|
||
## 115. Platform KF stream escape / host-tip ride (2026-08-13)
|
||
|
||
**Soak (MotK Win↔CachyOS after §114):** `FIRST CORE DIVERGE sim=1112`, heal
|
||
pin `1111` / `pc=0x800768e8`, then hundreds of `§114 KF stream begin
|
||
fork=1112` + `heal KF CAP →1111` with `invent off until rematch`. No DESYNC;
|
||
loading never advanced. HC `peek_mismatch` stays at tip+1 while
|
||
`resolved_through=keep`.
|
||
|
||
**What landed (§115):**
|
||
|
||
1. **Host-tip ride** — when HC fork is the known platform tip+1 and
|
||
`sim > fork`, stream `begin_rewind(sim)` so MEDIA-KF pins near host tip.
|
||
2. **Accept on advance** — heal Live/tip-hold with `tip > keep` primes HC
|
||
past the soft fork, clears invent-hold/stream, sets
|
||
`g_platform_accepted_fork` (hc-fork / begin refuse re-heal).
|
||
3. **Stream CAP** (`RB_FMV_PLATFORM_KF_STREAM_LIMIT=6`) — same stale fork
|
||
without escape → force accept.
|
||
|
||
**Re-soak watch:** after first post-FMV tip+1 diverge, logs
|
||
`§115 KF stream ride host tip` and/or `§115 platform fork accepted`; sim
|
||
advances past keep; no endless `snap applied tick=<keep>`; loading completes.
|