diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index 7b1fccb9..b00eb3f6 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -3185,3 +3185,51 @@ 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 root cause — §47 replay produce bypassed the §34 +debounce.** `np_rb_produce_local_tip_for_sim` copied the raw live snapshot +(`staged = live`) without `np_digital_debounce_staged()`, so a press +overlapping an episode could publish press/idle/press across consecutive tip +rows (SDL snapshot bounce mid-resim); MotK's pause is edge-triggered → two +menus. This soak had an episode every ~30 s, which is exactly the exposure +window. Fix: the replay producer now runs the same debounce, keeping the +sticky release state coherent across live↔replay transitions. + +**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. diff --git a/runtime/include/netplay_snap_ring.h b/runtime/include/netplay_snap_ring.h index eb7ebb07..386a41e5 100644 --- a/runtime/include/netplay_snap_ring.h +++ b/runtime/include/netplay_snap_ring.h @@ -18,7 +18,10 @@ extern "C" { #endif -#define NETPLAY_SNAP_RING_DEFAULT_DEPTH 64u +/* §58: 64 → 80 — dense tip snaps (PSX_NET_SNAP_DENSE window) occupy up to + * ~24 extra slots alongside the interval snaps; keep the interval history + * span (~1024 ticks at iv=16) from shrinking under dense pressure. */ +#define NETPLAY_SNAP_RING_DEFAULT_DEPTH 80u typedef struct NetplaySnapRing NetplaySnapRing; @@ -53,6 +56,8 @@ const uint8_t* netplay_snap_ring_peek(const NetplaySnapRing* r, uint32_t tick, /* Invalidate every snapshot with tick > tick (dead-timeline snaps after an * abort realign). Returns the number of slots dropped. */ uint32_t netplay_snap_ring_drop_after(NetplaySnapRing* r, uint32_t tick); +/* Drop the single snap at tick (dense-window eviction). 1 if dropped. */ +int netplay_snap_ring_drop_tick(NetplaySnapRing* r, uint32_t tick); uint32_t netplay_snap_ring_oldest_tick(const NetplaySnapRing* r); uint32_t netplay_snap_ring_newest_tick(const NetplaySnapRing* r); diff --git a/runtime/src/netplay_snap_ring.c b/runtime/src/netplay_snap_ring.c index 55d7abc0..f6ed9c98 100644 --- a/runtime/src/netplay_snap_ring.c +++ b/runtime/src/netplay_snap_ring.c @@ -178,6 +178,14 @@ int netplay_snap_ring_load(NetplaySnapRing* r, uint32_t tick, CPUState* cpu, return boot_state_load_buffer(data, size, bios_checksum, entry_pc, cpu); } +int netplay_snap_ring_drop_tick(NetplaySnapRing* r, uint32_t tick) { + int idx = find_slot(r, tick); + if (idx < 0) return 0; + slot_clear(&r->slots[idx]); + if (r->count) r->count--; + return 1; +} + uint32_t netplay_snap_ring_drop_after(NetplaySnapRing* r, uint32_t tick) { uint32_t i, n = 0; if (!r) return 0; diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 84f8f9e5..5c6bbfde 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -2009,6 +2009,8 @@ static void np_rollback_reconcile_wire(void) } +static void np_digital_debounce_staged(void); + /* §47: Replay participates in the protocol like Live — produce local tip * from the live physical pad while consuming remote confirmed rows. */ static void np_rb_produce_local_tip_for_sim(rnet_u32 sim) @@ -2023,6 +2025,12 @@ static void np_rb_produce_local_tip_for_sim(rnet_u32 sim) g_np.staged = g_np.live; psx_netplay_normalize_pad(&g_np.staged); g_np.staged_valid = 1; + /* §58: this producer bypassed the §34 release debounce — a Start + * press overlapping an episode could publish press/idle/press across + * consecutive tip rows (SDL snapshot bounce mid-resim) and MotK's + * edge-triggered pause opened twice. Same debounce as stage_local so + * the sticky state stays coherent across live↔replay transitions. */ + np_digital_debounce_staged(); } g_np.latched_for_tick = 0; (void)rnet_session_prepare_local_tip(g_np.session, sim); diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 7b9b2d8a..07204e5a 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -163,6 +163,40 @@ static uint32_t snap_interval(void) } return iv; } +/* §58 dense tip snaps: live saves every tick inside a sliding window so a + * near-tip mispredict loads at (or within a tick of) the mismatch instead of + * up to snap_interval-1 ticks below it (§57 soak: depth_avg 14–41 with + * pred_depth 1–2 — replay work was dominated by snap granularity). The + * window is bounded: non-interval ticks older than the window are dropped + * from the ring so interval history is not evicted. PSX_NET_SNAP_DENSE + * overrides the window size (0 disables, max 24). */ +#define NP_DENSE_SNAP_MAX 24u +static uint32_t g_tip_dense_fifo[NP_DENSE_SNAP_MAX]; +static uint32_t g_tip_dense_fifo_n; +static uint32_t g_tip_dense_fifo_head; + +static uint32_t tip_dense_window(void) +{ + static int latched; + static uint32_t win = 8u; + if (!latched) { + const char *e = getenv("PSX_NET_SNAP_DENSE"); + if (e && e[0]) { + unsigned v = 0; + if (sscanf(e, "%u", &v) == 1 && v <= NP_DENSE_SNAP_MAX) + win = v; + } + latched = 1; + } + return win; +} + +static void tip_dense_reset(void) +{ + g_tip_dense_fifo_n = 0; + g_tip_dense_fifo_head = 0; +} + static int g_local_baseline_sent; static uint32_t g_local_baseline_digest; static uint32_t g_local_baseline_av; @@ -384,6 +418,32 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; /* Baseline mismatch with no agreed tip — don't reopen every few ticks. */ #define RB_BASELINE_MISMATCH_COOLDOWN_TICKS 90u +/* §58: track a live non-interval snap tick; evict the oldest dense snap once + * the window is full (never the pinned baseline / episode load / commit + * span — those stay until normal oldest-tick eviction). */ +static void tip_dense_push(uint32_t tick) +{ + uint32_t win = tip_dense_window(); + if (win == 0u) + return; + if (g_tip_dense_fifo_n >= win) { + uint32_t victim = + g_tip_dense_fifo[(g_tip_dense_fifo_head + NP_DENSE_SNAP_MAX - + g_tip_dense_fifo_n) % NP_DENSE_SNAP_MAX]; + g_tip_dense_fifo_n--; + if (!(g_pin_valid && victim == g_pin_tick) && + !(g_rb && rnet_rb_is_active(g_rb) && + victim == rnet_rb_get_load_tick(g_rb)) && + !(g_agreed_valid && victim >= g_agreed_span_lo && + victim <= g_agreed_through)) { + (void)netplay_snap_ring_drop_tick(g_snaps, victim); + } + } + g_tip_dense_fifo[g_tip_dense_fifo_head] = tick; + g_tip_dense_fifo_head = (g_tip_dense_fifo_head + 1u) % NP_DENSE_SNAP_MAX; + g_tip_dense_fifo_n++; +} + static int g_was_in_fmv; static uint32_t g_fmv_settle_until; static uint32_t g_fmv_lockstep_until; @@ -2054,8 +2114,18 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) * (ADVANCE/HC-HEAL/commit advertise; never HEAL-FORCE). */ if (!post_tip_hold_holdoff_active()) { uint32_t raised = have_shared ? shared : 0u; + uint32_t hc_dense = 0u; if (hc_iv > raised) raised = hc_iv; + /* §58: dense tip snaps — the raw HC-proven tick is raise-safe when a + * snap exists locally (both peers keep the same dense window; a + * missing peer snap is recoverable via follower NACK → demote, + * unlike the §53b interval-only rule which predates dense snaps). */ + if (tip_dense_window() > 0u && hc_floor > raised && + netplay_snap_ring_has(g_snaps, hc_floor)) { + raised = hc_floor; + hc_dense = hc_floor; + } if (g_peer_resolved_through > raised) raised = g_peer_resolved_through; if (raised > 0u && (!have_shared || raised > shared)) { @@ -2064,10 +2134,10 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) if (s_raise_log != raised) { fprintf(stderr, "psxrecomp: rb choose_load raise %u→%u " - "(agreed=%u hc_iv=%u peer_resolved=%u)\n", + "(agreed=%u hc_iv=%u hc_dense=%u peer_resolved=%u)\n", (unsigned)shared, (unsigned)raised, (unsigned)(g_agreed_valid ? g_agreed_through : 0u), - (unsigned)hc_iv, + (unsigned)hc_iv, (unsigned)hc_dense, (unsigned)g_peer_resolved_through); fflush(stderr); s_raise_log = raised; @@ -3776,6 +3846,7 @@ void psx_netplay_rb_start(void) return; g_snaps = netplay_snap_ring_create(NETPLAY_SNAP_RING_DEFAULT_DEPTH); + tip_dense_reset(); if (!g_snaps) { fprintf(stderr, "psxrecomp: rb snap ring create FAILED — rewind disabled\n"); return; @@ -3863,8 +3934,11 @@ void psx_netplay_rb_start(void) g_bl_fork_cap = 0u; /* §55: commit/reset clears the bisect cap */ clear_baseline_pin(); clear_episode_wire_state(); - fprintf(stderr, "psxrecomp: rb snap ring ready depth=%u snap_interval=%u\n", - (unsigned)netplay_snap_ring_depth(g_snaps), (unsigned)snap_interval()); + fprintf(stderr, + "psxrecomp: rb snap ring ready depth=%u snap_interval=%u " + "tip_dense=%u\n", + (unsigned)netplay_snap_ring_depth(g_snaps), + (unsigned)snap_interval(), (unsigned)tip_dense_window()); } void psx_netplay_rb_shutdown(void) @@ -3931,7 +4005,7 @@ void psx_netplay_rb_shutdown(void) /* Dense tip snaps: media/settle, plus the digest lockstep/+tip window even * after invent is allowed again (§26) — first Cross/Start after FMV still * wants a near-tip load. */ -static int rb_dense_snap_window(void) +static int rb_fmv_dense_snap_window(void) { RNetSession *s = sess(); uint32_t sim = s ? rnet_session_sim_tick(s) : 0u; @@ -3962,10 +4036,16 @@ void psx_netplay_rb_request_snap(uint32_t tick) * invent-miss loads near the mismatch (954→944 was a ~10-tick hitch; * with tip snaps + shared frontier → load≈953). */ if (!(g_rb && rnet_rb_is_resimulating(g_rb))) { - if (!rb_dense_snap_window()) { + if (!rb_fmv_dense_snap_window()) { iv = snap_interval(); - if (iv > 1u && (tick % iv) != 0u) - return; + if (iv > 1u && (tick % iv) != 0u) { + /* §58: dense tip window — save every live tick and keep the + * last N non-interval snaps so a near-tip mispredict loads + * at the mismatch, not an interval boundary. */ + if (tip_dense_window() == 0u) + return; + tip_dense_push(tick); + } } else if (tick >= g_fmv_dense_through) { g_fmv_dense_through = tick; }