From 90266d4eaa33215877ff4635f1c2ae515c277b0f Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sun, 2 Aug 2026 19:11:04 -0400 Subject: [PATCH] resim optimizations --- docs/ROLLBACK_MOTK_HOOKUP.md | 207 +++++++++++++++++ runtime/include/psx_netplay.h | 7 + runtime/src/main.cpp | 16 +- runtime/src/psx_netplay.c | 88 +++++-- runtime/src/psx_netplay_rb.c | 398 +++++++++++++++++++++++++++++--- runtime/src/psx_netplay_sched.c | 27 ++- 6 files changed, 679 insertions(+), 64 deletions(-) diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index b00eb3f6..ddbeae03 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -3233,3 +3233,210 @@ 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. + +## 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). diff --git a/runtime/include/psx_netplay.h b/runtime/include/psx_netplay.h index cd166198..c9ef81d6 100644 --- a/runtime/include/psx_netplay.h +++ b/runtime/include/psx_netplay.h @@ -204,6 +204,13 @@ void psx_netplay_poll_snap(struct CPUState *cpu, uint32_t resume_pc); /* 1 while a rollback episode is resimulating (mute audio / skip wall pacer). */ int psx_netplay_is_resimulating(void); +/* §63: reset host pad-edge trackers after snap load (guest SIO restored). */ +void psx_netplay_on_rb_snap_loaded(void); + +/* §64: restart hc-fork persist/retry clocks after tip-extend abandon so a + * second recovery episode cannot open on the same Live tick. */ +void psx_netplay_hc_fork_recovery_restart(void); + /* Force software GPU when netplay starts — GL/VK VRAM readback is host-GPU * nondeterministic and forks rollback snaps/resim. Implemented in main.cpp. */ void psx_frontend_netplay_force_sw_gpu(void); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 67dad6c6..8ec76b32 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -887,6 +887,8 @@ extern "C" void psx_frontend_on_rb_snap_loaded(void) { } gl_renderer_restage_vram_after_savestate(); vk_renderer_restage_vram_after_savestate(); + /* §63: guest SIO came from the snap — drop Live pad-edge trackers. */ + psx_netplay_on_rb_snap_loaded(); } static uint64_t smooth_60_frame_hash(const uint32_t* pixels, size_t count) { @@ -3767,16 +3769,20 @@ static void netplay_hold_last_present_tick(void) { } } -/* §47/§51 Layer 4: long Replay catch-up — wall-clock gate for a real VRAM +/* §47/§51/§63 Layer 4: long Replay catch-up — wall-clock gate for a real VRAM * present. Independent of SPAN chunk boundaries (those are bookkeeping, not - * present events). Returns 1 if this guest vblank should fall through. */ + * present events). Returns 1 if this guest vblank should fall through. + * + * §63: tip episodes are ≤ tip_runway (24). Live-presenting those frames + * re-showed edge-trigger menu inputs that Live had already displayed before + * the rewind (pad-log DUP_SIO Start/dpad). Hold-last through a full SPAN; + * only ownership-chain catch-up deeper than that shows live progress. */ static int netplay_replay_catchup_should_live_present(uint32_t remaining) { static uint64_t s_catchup_present_ms; uint64_t now = SDL_GetTicks64(); - /* ~6–7 Hz: enough to show progress without flashing every resim frame - * (old period≈frame_ms presented nearly every tick when rem>8). */ + /* ~6–7 Hz: enough to show progress without flashing every resim frame. */ uint32_t period = 150u; - if (remaining <= 8u) + if (remaining <= 24u) return 0; if (s_catchup_present_ms != 0ull && now >= s_catchup_present_ms && (uint32_t)(now - s_catchup_present_ms) < period) diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 5c6bbfde..a87a9bd8 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -200,6 +200,8 @@ int psx_netplay_start(const PsxNetplayConfig *cfg) } void psx_netplay_shutdown(void) {} void psx_netplay_stage_local(const PsxNetPad *pad) { (void)pad; } +void psx_netplay_on_rb_snap_loaded(void) {} +void psx_netplay_hc_fork_recovery_restart(void) {} int psx_netplay_needs_local_sample(void) { return 0; } int psx_netplay_live_pad_buttons(uint16_t *out) { @@ -441,6 +443,15 @@ static void np_try_hc_fork_recovery(uint32_t fork_tick) return; if (g_np.local_slot != 0) return; /* initiator side only */ + /* §64: do not bookkeep persist while an episode / tip-hold / load is + * in flight. Previously fork_tick advances during tip-extend rereplay + * started the persist clock, so tip-extend abandon → Live opened a + * second hc-fork recovery with zero Live gap (soak: epoch 8 → 16, + * "persisted 39 ticks"). */ + if (psx_netplay_rb_active() || psx_netplay_rb_tip_holding() || + psx_netplay_rb_load_pending() || psx_netplay_rb_rewind_suppressed() || + psx_netplay_rb_fmv_defer_rewind() || psx_netplay_rb_lockstep_no_invent()) + return; sim = rnet_session_sim_tick(g_np.session); if (fork_tick != s_fork_tick) { s_fork_tick = fork_tick; @@ -452,10 +463,6 @@ static void np_try_hc_fork_recovery(uint32_t fork_tick) if (s_fork_last_attempt_sim != 0u && sim >= s_fork_last_attempt_sim && (sim - s_fork_last_attempt_sim) < retry_ticks) return; - if (psx_netplay_rb_active() || psx_netplay_rb_tip_holding() || - psx_netplay_rb_load_pending() || psx_netplay_rb_rewind_suppressed() || - psx_netplay_rb_fmv_defer_rewind() || psx_netplay_rb_lockstep_no_invent()) - return; s_fork_last_attempt_sim = sim; remote = (g_np.local_slot == 0) ? 1 : 0; if (g_np.slot_count < 2) @@ -468,6 +475,21 @@ static void np_try_hc_fork_recovery(uint32_t fork_tick) (void)psx_netplay_rb_begin_rewind(fork_tick, remote); } +void psx_netplay_hc_fork_recovery_restart(void) +{ + uint32_t sim; + if (!g_np.session) + return; + sim = rnet_session_sim_tick(g_np.session); + /* Force a fresh Live persist window + retry gap after abandon/realign. */ + s_fork_first_sim = sim; + s_fork_last_attempt_sim = sim; + fprintf(stderr, + "psxrecomp: rb hc-fork recovery restart (sim=%u — fresh persist)\n", + (unsigned)sim); + fflush(stderr); +} + static void np_check_core_diverge(void) { uint32_t tick = 0, local_d = 0, peer_d = 0; @@ -1346,18 +1368,39 @@ static void np_pad_log_edge(const char *tag, int slot, uint32_t sim, uint16_t pr fflush(stderr); } +/* §63: snap load restores guest SIO from the savestate; host-side edge + * trackers must not keep Live's post-tip buttons or resim logs (and invent + * edge state) false-edge against a stale prev. */ +static uint16_t g_sio_pad_prev[PSX_MAX_PLAYERS]; +static uint8_t g_sio_pad_have[PSX_MAX_PLAYERS]; +static uint16_t g_inv_pad_prev[PSX_MAX_PLAYERS]; +static uint8_t g_inv_pad_have[PSX_MAX_PLAYERS]; +static uint16_t g_local_pad_prev = 0xFFFFu; +static int g_local_pad_have; + +void psx_netplay_on_rb_snap_loaded(void) +{ + int i; + for (i = 0; i < PSX_MAX_PLAYERS; ++i) { + g_sio_pad_prev[i] = 0xFFFFu; + g_sio_pad_have[i] = 0u; + g_inv_pad_prev[i] = 0xFFFFu; + g_inv_pad_have[i] = 0u; + } + g_local_pad_prev = 0xFFFFu; + g_local_pad_have = 0; +} + static void apply_pad_slot(int slot, const PsxNetPad *pad) { if (slot < 0 || slot >= g_np.slot_count || slot >= PSX_MAX_PLAYERS || !pad) return; const int on_tap = sio_pad_on_multitap(slot); if (np_pad_log_enabled()) { - static uint16_t s_sio_prev[PSX_MAX_PLAYERS]; - static uint8_t s_sio_have[PSX_MAX_PLAYERS]; - uint16_t prev = s_sio_have[slot] ? s_sio_prev[slot] : 0xFFFFu; + uint16_t prev = g_sio_pad_have[slot] ? g_sio_pad_prev[slot] : 0xFFFFu; uint32_t sim = g_np.session ? rnet_session_sim_tick(g_np.session) : 0u; np_pad_log_edge("sio", slot, sim, prev, pad->buttons); - s_sio_prev[slot] = pad->buttons; - s_sio_have[slot] = 1u; + g_sio_pad_prev[slot] = pad->buttons; + g_sio_pad_have[slot] = 1u; } sio_set_pad_connected(slot, 1); sio_set_pad_config_capable(slot, on_tap ? 0 : 1); @@ -1501,7 +1544,10 @@ static int np_digital_new_press(const RNetInputContractFrame *pub, * RIGHT release@1607 then another `pub=ffdf wire=ffff` @1634). * * §34: TipHold stalls sim at tip while coalesce promotes release at tip+N — - * end must reach tip+runway, not only sim (sim <= release_tick was a no-op). */ + * end must reach tip+runway, not only sim (sim <= release_tick was a no-op). + * §63: outside TipHold, also scrub sim..sim+P so gap1 invent poison past the + * current tip cannot re-hold Start/dpad before wire catches up (soak: sio + * Start↓@5861 then invent Start↓@5868 while auth was release). */ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, const RNetRbFrame *released) { @@ -1519,6 +1565,12 @@ static void np_scrub_ahead_predicted(int slot, rnet_u32 release_tick, uint32_t tip_end = tip + runway; if (tip_end > end) end = tip_end; + } else { + /* Pred depth default matches sched P=9; scrub invent rows Live will + * admit before the next remote burst. */ + rnet_u32 ahead = sim + 9u; + if (ahead > end) + end = ahead; } if (end <= release_tick) return; @@ -2165,13 +2217,11 @@ static int np_try_admit_rollback(void) any_invent = 1; (void)netplay_ih_invent_hold_last(&g_np.ih, slot, sim, &row); if (np_pad_log_enabled() && row.is_valid) { - static uint16_t s_inv_prev[PSX_MAX_PLAYERS]; - static uint8_t s_inv_have[PSX_MAX_PLAYERS]; - uint16_t prev = s_inv_have[slot] ? s_inv_prev[slot] : 0xFFFFu; + uint16_t prev = g_inv_pad_have[slot] ? g_inv_pad_prev[slot] : 0xFFFFu; if (slot >= 0 && slot < PSX_MAX_PLAYERS) { np_pad_log_edge("invent", slot, sim, prev, row.buttons); - s_inv_prev[slot] = row.buttons; - s_inv_have[slot] = 1u; + g_inv_pad_prev[slot] = row.buttons; + g_inv_pad_have[slot] = 1u; } } } @@ -2277,12 +2327,10 @@ void psx_netplay_stage_local(const PsxNetPad *pad) psx_netplay_normalize_pad(&g_np.staged); np_digital_debounce_staged(); if (np_pad_log_enabled()) { - static uint16_t s_local_prev = 0xFFFFu; - static int s_local_have; - uint16_t prev = s_local_have ? s_local_prev : 0xFFFFu; + uint16_t prev = g_local_pad_have ? g_local_pad_prev : 0xFFFFu; np_pad_log_edge("local", g_np.local_slot, t, prev, g_np.staged.buttons); - s_local_prev = g_np.staged.buttons; - s_local_have = 1; + g_local_pad_prev = g_np.staged.buttons; + g_local_pad_have = 1; } g_np.staged_valid = 1; g_np.latched_for_tick = 1; diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 07204e5a..9b282ec9 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -278,6 +278,20 @@ static struct { uint16_t row_count; } g_stash_seal_rows[RB_STASH_SEAL_ROWS_MAX]; static int g_stash_seal_rows_logged; +/* §60: SYNC BEGIN that arrived while we were still in an active episode + * (ownership-chain race — initiator finishes Verify+POST and emits the next + * BEGIN before our POST drain runs). Was silently dropped; follower then + * waited forever for FOLLOW. Latest-wins stash; applied once idle. */ +static int g_stash_begin_valid; +static uint32_t g_stash_begin_epoch; +static uint32_t g_stash_begin_mismatch; +static uint32_t g_stash_begin_load; +static uint32_t g_stash_begin_target; +static int g_stash_begin_slot; +static uint8_t g_stash_begin_flags; +static int g_stash_begin_logged; +static uint64_t g_last_begin_rexmit_ms; +static int g_begin_rexmit_logged; static uint64_t g_baseline_handshake_ms; /* when local baseline first sent */ static uint64_t g_last_baseline_burst_ms; static int g_ready_timeout_logged; @@ -499,6 +513,12 @@ static uint32_t g_fork_streak; * L (2026-08-02 soak: 4× at 4176) can never heal — the next episode load must * be strictly below it. 0 = no cap; cleared on verified commit / session reset. */ static uint32_t g_bl_fork_cap; +/* §62: a follow NACK with the peer's frontier AHEAD of the refused load means + * the peer's snap ring has evicted that tick — reopening at or below it can + * only NACK again (2026-08-02 soak: bisect 10231 vs peer ring oldest ≈10284 + * → NACK → 129-tick demote → permanent WIRE_HOLE). Loads ≤ this floor are + * refused; cleared on verified commit / session reset. */ +static uint32_t g_peer_nack_floor; static RNetSession *sess(void); /* fwd — used by FMV lockstep window */ static void commit_episode(void); /* fwd — legacy immediate commit */ @@ -995,6 +1015,8 @@ static void clear_episode_wire_state(void) g_last_seal_rexmit_ms = 0; g_seal_rexmit_logged = 0; g_seal_wait_logged = 0; + g_last_begin_rexmit_ms = 0; + g_begin_rexmit_logged = 0; } static void clear_baseline_pin(void) @@ -1556,6 +1578,7 @@ uint32_t psx_netplay_rb_confirmed_remaining(void) RNetSession *s = sess(); uint32_t sim; uint32_t frontier; + uint32_t rem; if (!s || !g_rb) return 0; if (!rnet_rb_is_resimulating(g_rb) && !rnet_rb_is_active(g_rb)) @@ -1564,7 +1587,18 @@ uint32_t psx_netplay_rb_confirmed_remaining(void) if (sim == 0xffffffffu) return 0; frontier = psx_netplay_rb_confirmed_frontier(sim + 1u); - return (frontier > sim) ? (frontier - sim) : 0u; + rem = (frontier > sim) ? (frontier - sim) : 0u; + /* §64: catchup present keys off the *episode tip*, not confirmed work + * past tip. Tip SPAN episodes (≤24) were reporting rem=37+ whenever the + * contiguous frontier ran ahead, so §63's rem≤24 hold-last never engaged + * (soak: catchup present=live on 1008..1032). Cap by target−sim. */ + if (rnet_rb_is_active(g_rb)) { + uint32_t tip = rnet_rb_get_target_tick(g_rb); + uint32_t tip_rem = (tip > sim) ? (tip - sim) : 0u; + if (tip_rem < rem) + rem = tip_rem; + } + return rem; } static void host_prefresh_seal_range(int slot, uint32_t lo, uint32_t hi) @@ -1768,6 +1802,10 @@ static int maybe_abandon_tip_extend(void) abort_episode("tip-extend abandon (peer committed below extension)"); schedule_live_realign(tick, "tip-extend abandon"); clear_rewind_cooldown("tip-extend abandon"); + /* §64: tip-extend abandon clears rewind cooldown (extension edge may + * re-arrive as a pad mismatch). Also restart hc-fork persist so a + * state-resync episode cannot reopen on this same Live tick. */ + psx_netplay_hc_fork_recovery_restart(); return 1; } @@ -2117,11 +2155,14 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) 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). */ + /* §58/§61: dense tip snaps — raise to raw HC-proven tick only when + * the peer has advertised RESOLVED at least that far. Otherwise both + * peers keep the dense window, but open can still land above the + * follower's frontier (soak: host 1180 vs peer_resolved 1168 → + * REFUSED / NACK / demote storm). Missing peer_resolved stays on + * interval floor (§53b); follower NACK remains the backstop. */ if (tip_dense_window() > 0u && hc_floor > raised && + g_peer_resolved_through >= hc_floor && netplay_snap_ring_has(g_snaps, hc_floor)) { raised = hc_floor; hc_dense = hc_floor; @@ -2286,6 +2327,23 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) m = g_bl_fork_cap; /* inner's shared walk returns load < m */ if (!choose_load_tick_inner(m, out_load)) return 0; + /* §62: peer proved it cannot follow ≤ floor (snap evicted). If the + * bisect window (must be < fork cap AND > peer floor) is empty, no + * mutually-loadable snap covers the fork — refuse to open rather + * than NACK-cycle into a deep demote. */ + if (g_peer_nack_floor > 0u && *out_load <= g_peer_nack_floor) { + static uint32_t s_floor_log; + if (s_floor_log != *out_load) { + fprintf(stderr, + "psxrecomp: rb choose_load refuse load=%u ≤ peer NACK " + "floor %u (fork cap %u — no mutually-loadable snap)\n", + (unsigned)*out_load, (unsigned)g_peer_nack_floor, + (unsigned)g_bl_fork_cap); + fflush(stderr); + s_floor_log = *out_load; + } + return 0; + } if (*out_load >= g_bl_fork_cap) { static uint32_t s_refuse_log; if (s_refuse_log != g_bl_fork_cap) { @@ -2310,7 +2368,23 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) } return 1; } - return choose_load_tick_inner(mismatch, out_load); + if (!choose_load_tick_inner(mismatch, out_load)) + return 0; + /* §62: same floor without a fork cap — do not reopen at a load the peer + * just proved unfollowable. */ + if (g_peer_nack_floor > 0u && *out_load <= g_peer_nack_floor) { + static uint32_t s_floor_log2; + if (s_floor_log2 != *out_load) { + fprintf(stderr, + "psxrecomp: rb choose_load refuse load=%u ≤ peer NACK " + "floor %u\n", + (unsigned)*out_load, (unsigned)g_peer_nack_floor); + fflush(stderr); + s_floor_log2 = *out_load; + } + return 0; + } + return 1; } /* Follower's confirmed frontier for the NACK wire hint: the deepest tick this @@ -2330,6 +2404,54 @@ static uint32_t follower_frontier_hint(void) return 0u; } +/* §62: realigning Live to `demote` requires remote wire inputs for every tick + * after it. A missing tick that is followed by a later PRESENT tick is a true + * hole — the peer's tip-window retransmit has already moved past it and it + * will never refill (2026-08-02 soak: demote 10359→10230, remote rows + * 10231..10243 evicted, 10244+ present → permanent WIRE_HOLE stall). A + * missing tail with nothing after it is just in flight and fine. */ +static uint32_t remote_wire_hole_end_after(uint32_t demote, uint32_t live_sim) +{ + RNetSession *s = sess(); + int local, slot; + uint32_t t; + uint32_t first_missing = 0u; + uint32_t hole_start = 0u; + uint32_t hole_end = 0u; + int have_missing = 0; + if (!s || demote >= live_sim) + return 0u; + local = g_b.local_slot ? *g_b.local_slot : 0; + slot = (local == 0) ? 1 : 0; + if (g_b.slot_count && *g_b.slot_count < 2) + return 0u; + for (t = demote + 1u; t <= live_sim; ++t) { + RNetInputSample sample; + rnet_u32 wire = np_sched_wire_for_sim(t); + int present = rnet_session_peek_remote_input(s, slot, wire, &sample) && + sample.valid; + if (!present) { + if (!have_missing) { + first_missing = t; + have_missing = 1; + } + } else if (have_missing) { + /* keep scanning — report the LAST closed hole */ + hole_start = first_missing; + hole_end = t - 1u; + have_missing = 0; + } + } + if (hole_end > 0u) { + fprintf(stderr, + "psxrecomp: rb wire hole %u..%u after demote=%u — " + "realign there would stall\n", + (unsigned)hole_start, (unsigned)hole_end, (unsigned)demote); + fflush(stderr); + } + return hole_end; +} + /* MotK convention: SYNC op=NACK = follower cannot open episode (missing load * snap / past frontier). Initiator aborts SealInputs instead of hanging. * target field carries this follower's frontier (recomputed every send). */ @@ -2450,6 +2572,12 @@ static void maybe_enter_replay(void); static void accept_peer_baseline(uint32_t epoch, uint32_t load, uint32_t dig_m, uint32_t dig_a, uint32_t dig_b, uint32_t dig_c); static void apply_stashed_baseline(void); +static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, + uint32_t target, int slot, uint8_t wire_flags); +static void stash_peer_begin(uint32_t epoch, uint32_t mismatch, uint32_t load, + uint32_t target, int slot, uint8_t wire_flags); +static void apply_stashed_begin(void); +static void maybe_rexmit_begin(void); static void enter_awaiting_baseline(void) { @@ -2714,6 +2842,8 @@ static void maybe_send_baseline(void) * rexmit — runs through SealInputs/AwaitingBaseline/Replay/Verify until * the peer POSTs. */ maybe_rexmit_seals(); + /* §60: BEGIN keepalive until the follower joins (peer baseline seen). */ + maybe_rexmit_begin(); follower = rnet_rb_is_from_peer_notify(g_rb) ? 1 : 0; if (!g_local_baseline_sent) { NetplayCoreParts parts; @@ -3326,7 +3456,11 @@ static void ownership_chain_next_span(uint32_t tip, uint32_t frontier) if (g_b.hc) netplay_hc_prime_after(g_b.hc, tip); advertise_agreed_resolved(tip); - g_last_commit_epoch = rnet_rb_get_epoch_id(g_rb); + g_last_commit_epoch = rnet_rb_get_epoch_id(g_rb); /* §42 P1 */ + if (g_stash_begin_valid && g_stash_begin_epoch == g_last_commit_epoch) { + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + } send_commit_notice(g_last_commit_epoch, tip); rnet_rb_session_reset(g_rb); @@ -3351,6 +3485,7 @@ static void ownership_chain_next_span(uint32_t tip, uint32_t frontier) g_fork_streak = 0u; g_fork_streak_tick = 0u; g_bl_fork_cap = 0u; /* §55: commit/reset clears the bisect cap */ + g_peer_nack_floor = 0u; /* §62 */ /* §48: only the lower seat initiates chain BEGINs. Both peers calling * begin_rewind every chunk caused dual-init → tie-break abort → reload @@ -3362,6 +3497,9 @@ static void ownership_chain_next_span(uint32_t tip, uint32_t frontier) "(slot %d; peer initiates; skip-snap)\n", (unsigned)tip, (unsigned)frontier, local); fflush(stderr); + /* §60: initiator's next BEGIN often arrived (and was stashed) while we + * were still in Verify — apply it now that we are idle. */ + apply_stashed_begin(); return; } @@ -3377,12 +3515,12 @@ static void ownership_chain_next_span(uint32_t tip, uint32_t frontier) fflush(stderr); if (!psx_netplay_rb_begin_rewind(mismatch, slot)) { fprintf(stderr, - "psxrecomp: rb ownership chain FAILED tip=%u frontier=%u — Live\n", + "psxrecomp: rb ownership chain FAILED tip=%u frontier=%u — " + "tip-hold\n", (unsigned)tip, (unsigned)frontier); fflush(stderr); g_ownership_skip_snap = 0; enter_tip_hold(tip); - finalize_tip_hold(); } g_ownership_chain = 0; g_ownership_chain_frontier = 0; @@ -3409,13 +3547,23 @@ static void ownership_on_post_match(uint32_t tip) return; } } - /* Exhausted contiguous confirmed work → Live (immediate tip-hold commit). */ + /* Exhausted contiguous confirmed work → tip-hold until pads quiet. + * §63: immediate finalize_tip_hold() Live-walked the next dpad/Start + * edges, then the following episode rewound and re-applied them + * (pad-log DUP_SIO / felt doubled). Tip-hold invent-cap + quiet/coalesce + * owns those edges instead. */ fprintf(stderr, - "psxrecomp: rb ownership final tip=%u frontier=%u → Live\n", + "psxrecomp: rb ownership final tip=%u frontier=%u → tip-hold\n", (unsigned)tip, (unsigned)frontier); fflush(stderr); + /* §61: discard any stashed BEGIN for this episode (rexmit) so Live does + * not reopen it. */ + if (g_stash_begin_valid && g_rb && + g_stash_begin_epoch == rnet_rb_get_epoch_id(g_rb)) { + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + } enter_tip_hold(tip); - finalize_tip_hold(); } void psx_netplay_rb_ownership_step(void) @@ -3573,6 +3721,11 @@ static void finalize_tip_hold(void) fprintf(stderr, "psxrecomp: rb episode commit through=%u (tip-hold)\n", (unsigned)target); fflush(stderr); g_last_commit_epoch = rnet_rb_get_epoch_id(g_rb); /* §42 P1 */ + /* §61: drop same-epoch BEGIN stash so Live does not reopen it. */ + if (g_stash_begin_valid && g_stash_begin_epoch == g_last_commit_epoch) { + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + } send_commit_notice(g_last_commit_epoch, target); /* §42c */ rnet_rb_session_reset(g_rb); g_tip_hold_until = 0; @@ -3595,6 +3748,7 @@ static void finalize_tip_hold(void) g_fork_streak = 0u; /* §42 P4: clean commit ends any fork suspicion */ g_fork_streak_tick = 0u; g_bl_fork_cap = 0u; /* §55 */ + g_peer_nack_floor = 0u; /* §62 */ /* §38: hold agreed at tip until peer tip-hold (SAFETY ~250ms) can exit. */ g_post_tip_hold_holdoff_t0_ms = rb_mono_ms(); if (g_post_tip_hold_holdoff_t0_ms == 0ull) @@ -3805,6 +3959,10 @@ static void commit_episode(void) (unsigned)g_episode_count); fflush(stderr); g_last_commit_epoch = rnet_rb_get_epoch_id(g_rb); /* §42 P1 */ + if (g_stash_begin_valid && g_stash_begin_epoch == g_last_commit_epoch) { + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + } send_commit_notice(g_last_commit_epoch, target); /* §42c */ rnet_rb_session_reset(g_rb); g_tip_extend_rereplay = 0; @@ -3818,6 +3976,7 @@ static void commit_episode(void) g_fork_streak = 0u; /* §42 P4 */ g_fork_streak_tick = 0u; g_bl_fork_cap = 0u; /* §55 */ + g_peer_nack_floor = 0u; /* §62 */ /* No post-commit cooldown: promote-only after commit made char-select * D-pad feel rejected (hist updated, live sim not rolled). Abort/storm * cooldown still arms from live sim on failed episodes. */ @@ -3926,12 +4085,15 @@ void psx_netplay_rb_start(void) g_tip_extend_prime_tick = 0; g_tip_extend_from_tick = 0; g_ownership_skip_snap = 0; + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; g_last_commit_epoch = 0xffffffffu; g_peer_commit_epoch = 0xffffffffu; g_peer_commit_tick = 0; g_fork_streak = 0u; g_fork_streak_tick = 0u; g_bl_fork_cap = 0u; /* §55: commit/reset clears the bisect cap */ + g_peer_nack_floor = 0u; /* §62 */ clear_baseline_pin(); clear_episode_wire_state(); fprintf(stderr, @@ -3979,12 +4141,17 @@ void psx_netplay_rb_shutdown(void) g_tip_extend_prime_tick = 0; g_tip_extend_from_tick = 0; g_ownership_skip_snap = 0; + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + g_last_begin_rexmit_ms = 0; + g_begin_rexmit_logged = 0; g_last_commit_epoch = 0xffffffffu; g_peer_commit_epoch = 0xffffffffu; g_peer_commit_tick = 0; g_fork_streak = 0u; g_fork_streak_tick = 0u; g_bl_fork_cap = 0u; /* §55: commit/reset clears the bisect cap */ + g_peer_nack_floor = 0u; /* §62 */ g_last_good_bb_pc = 0; g_agreed_valid = 0; g_agreed_through = 0; @@ -4844,6 +5011,104 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) return 1; } +static void stash_peer_begin(uint32_t epoch, uint32_t mismatch, uint32_t load, + uint32_t target, int slot, uint8_t wire_flags) +{ + /* §61: never stash a BEGIN for the episode we are already in — that is a + * keepalive rexmit. Stashing it and unstashing after Live re-opened the + * same epoch (soak: follow 16 → final → unstash 16 → verify timeout). */ + if (g_rb && rnet_rb_is_active(g_rb) && + epoch == rnet_rb_get_epoch_id(g_rb)) + return; + /* Also ignore BEGINs for an epoch we already committed. */ + if (epoch == g_last_commit_epoch) + return; + g_stash_begin_valid = 1; + g_stash_begin_epoch = epoch; + g_stash_begin_mismatch = mismatch; + g_stash_begin_load = load; + g_stash_begin_target = target; + g_stash_begin_slot = slot; + g_stash_begin_flags = wire_flags; + if (!g_stash_begin_logged) { + fprintf(stderr, + "psxrecomp: rb BEGIN stashed epoch=%u load=%u target=%u " + "(local episode still active — apply when idle)\n", + (unsigned)epoch, (unsigned)load, (unsigned)target); + fflush(stderr); + g_stash_begin_logged = 1; + } +} + +static void apply_stashed_begin(void) +{ + uint32_t epoch, mismatch, load, target; + int slot; + uint8_t flags; + if (!g_stash_begin_valid || !g_rb || rnet_rb_is_active(g_rb)) + return; + /* §61: drop stashes for epochs already committed / completed. */ + if (g_stash_begin_epoch == g_last_commit_epoch) { + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + return; + } + epoch = g_stash_begin_epoch; + mismatch = g_stash_begin_mismatch; + load = g_stash_begin_load; + target = g_stash_begin_target; + slot = g_stash_begin_slot; + flags = g_stash_begin_flags; + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; + fprintf(stderr, + "psxrecomp: rb BEGIN unstash epoch=%u load=%u target=%u\n", + (unsigned)epoch, (unsigned)load, (unsigned)target); + fflush(stderr); + begin_follower(epoch, mismatch, load, target, slot, flags); +} + +/* §60: BEGIN was fire-and-forget; on TURN a single drop left the follower + * parked on "wait FOLLOW" while the initiator sat in rb_baseline forever. + * Retransmit on the same cadence as seal/baseline keepalive. */ +static void maybe_rexmit_begin(void) +{ + RNetSession *s = sess(); + RNetRbPhase ph; + uint64_t now; + rnet_u8 wire_flags; + int32_t slot; + if (!g_rb || !s || !rnet_rb_is_active(g_rb) || rnet_rb_is_from_peer_notify(g_rb)) + return; + ph = rnet_rb_get_phase(g_rb); + if (ph != nRNetRbPhaseSealInputs && ph != nRNetRbPhaseAwaitingBaseline) + return; + if (g_peer_baseline_ok) + return; /* follower has joined the episode */ + now = rb_mono_ms(); + if (g_last_begin_rexmit_ms != 0ull && now >= g_last_begin_rexmit_ms && + (now - g_last_begin_rexmit_ms) < (uint64_t)RB_BASELINE_BURST_MS) + return; + if (g_last_begin_rexmit_ms != 0ull && !g_begin_rexmit_logged) { + fprintf(stderr, + "psxrecomp: rb BEGIN rexmit epoch=%u load=%u target=%u\n", + (unsigned)rnet_rb_get_epoch_id(g_rb), + (unsigned)rnet_rb_get_load_tick(g_rb), + (unsigned)rnet_rb_get_target_tick(g_rb)); + fflush(stderr); + g_begin_rexmit_logged = 1; + } + g_last_begin_rexmit_ms = now; + wire_flags = (rnet_u8)( + (rnet_rb_get_corr_flags(g_rb) & RNET_RB_CORR_LIGHT_TIP) + ? RNET_RB_SYNC_FLAG_LIGHT_TIP : 0u); + slot = rnet_rb_get_corrected_slot(g_rb); + (void)rnet_session_send_rb_sync( + s, rnet_rb_get_epoch_id(g_rb), rnet_rb_get_mismatch_tick(g_rb), + rnet_rb_get_load_tick(g_rb), rnet_rb_get_target_tick(g_rb), + (rnet_u8)(slot < 0 ? 0 : slot), RNET_RB_SYNC_OP_BEGIN, wire_flags); +} + static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uint32_t target, int slot, uint8_t wire_flags) { @@ -4987,8 +5252,17 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin return; } } - if (!g_rb || rnet_rb_is_active(g_rb)) + if (!g_rb) return; + if (rnet_rb_is_active(g_rb)) { + /* §60/§61: tip-extend same-epoch is handled above. Same-epoch BEGIN + * rexmit must not be stashed (keepalive). Only stash a *new* epoch + * that arrived while we are still Verify/Replay on the prior one. */ + if (epoch == rnet_rb_get_epoch_id(g_rb)) + return; + stash_peer_begin(epoch, mismatch, load, target, slot, wire_flags); + return; + } (void)rb_in_fmv_defer_rewind_window(); /* Same policy as begin: do not follow into media/lockstep tip episodes. */ if (rb_in_fmv_lockstep_window()) { @@ -5008,24 +5282,35 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin return; } snap_n = g_snaps ? netplay_snap_ring_count(g_snaps) : 0u; + /* §60: ownership-chain continue uses skip-snap at the verified tip — the + * initiator does not require a ring snap there; neither may the follower, + * or we NACK a legitimate chain BEGIN and storm. */ if (!g_snaps || !netplay_snap_ring_has(g_snaps, load)) { + int skip_ok = (g_ownership_skip_snap && g_agreed_valid && + load == g_agreed_through); + if (!skip_ok) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u — snap missing " + "(ring count=%u newest=%u)\n", + (unsigned)epoch, (unsigned)load, (unsigned)snap_n, + (unsigned)(g_snaps ? netplay_snap_ring_newest_tick(g_snaps) + : 0u)); + fflush(stderr); + g_follow_nack_pending = 1; + g_follow_nack_epoch = epoch; + g_follow_nack_mismatch = mismatch; + g_follow_nack_load = load; + g_follow_nack_target = target; + g_follow_nack_slot = slot; + g_follow_nack_sends = 0; + send_follow_nack(epoch, mismatch, load, target, slot, 1); + g_follow_nack_sends = 1; + return; + } fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u — snap missing " - "(ring count=%u newest=%u)\n", - (unsigned)epoch, (unsigned)load, (unsigned)snap_n, - (unsigned)(g_snaps ? netplay_snap_ring_newest_tick(g_snaps) : 0u)); + "psxrecomp: rb follow skip-snap load=%u (ownership chain tip)\n", + (unsigned)load); fflush(stderr); - /* Tell initiator to abort SealInputs; rexmit on TURN. */ - g_follow_nack_pending = 1; - g_follow_nack_epoch = epoch; - g_follow_nack_mismatch = mismatch; - g_follow_nack_load = load; - g_follow_nack_target = target; - g_follow_nack_slot = slot; - g_follow_nack_sends = 0; - send_follow_nack(epoch, mismatch, load, target, slot, 1); - g_follow_nack_sends = 1; - return; } /* NACK loads past our shared frontier — initiator tip-slack beyond * hash_confirm is a doomed baseline (cpu-only tip snap fork). */ @@ -5104,6 +5389,10 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin g_needs_advance = 0; g_seal_export_logged = 0; g_follow_nack_pending = 0; + /* Consumed — don't re-apply a stashed copy of this BEGIN next pump. */ + if (g_stash_begin_valid && g_stash_begin_epoch == epoch) + g_stash_begin_valid = 0; + g_stash_begin_logged = 0; rnet_rb_seal_inputs(g_rb, load, target, slot); g_seal_wait_ms = rb_mono_ms(); export_local_seals(); @@ -5258,6 +5547,18 @@ void psx_netplay_rb_pump(void) * load is followable instead of NACK-cycling. */ if (peer_frontier > 0u && peer_frontier < demote) demote = peer_frontier; + /* §62: frontier AHEAD of the refused load = the peer's snap + * ring evicted that tick (not "peer behind"). Remember the + * floor so choose_load never reopens at/below it. */ + if (peer_frontier >= load && load > g_peer_nack_floor) { + g_peer_nack_floor = load; + fprintf(stderr, + "psxrecomp: rb peer NACK floor=%u " + "(peer frontier=%u ahead — snap evicted)\n", + (unsigned)g_peer_nack_floor, + (unsigned)peer_frontier); + fflush(stderr); + } /* Capture before abort_episode clears it — decides whether Live * must rewind (snap already applied) or can stay put. */ int snap_was_applied = g_episode_snap_applied; @@ -5302,12 +5603,41 @@ void psx_netplay_rb_pump(void) abort_episode(why); if (snap_was_applied && g_snaps && netplay_snap_ring_has(g_snaps, demote)) { + /* §62: never realign below a remote-input wire hole the + * tip-window retransmit no longer covers — Live would + * stall on WIRE_HOLE forever. Raise the realign target + * to the first snap past the hole (watermarks stay + * demoted; only the Live rewind depth is capped). */ + uint32_t realign_to = demote; + uint32_t hole_end = + remote_wire_hole_end_after(demote, live_sim); + if (hole_end > 0u) { + uint32_t t; + uint32_t newest = + netplay_snap_ring_newest_tick(g_snaps); + realign_to = 0u; + for (t = hole_end; t <= newest; ++t) { + if (netplay_snap_ring_has(g_snaps, t)) { + realign_to = t; + break; + } + } + if (realign_to == 0u) + realign_to = newest; + fprintf(stderr, + "psxrecomp: rb NACK hole-guard realign_to=%u " + "(demote=%u hole_end=%u)\n", + (unsigned)realign_to, (unsigned)demote, + (unsigned)hole_end); + fflush(stderr); + } fprintf(stderr, "psxrecomp: rb NACK realign (snap was applied) " - "demote=%u live_was=%u\n", - (unsigned)demote, (unsigned)live_sim); + "demote=%u realign=%u live_was=%u\n", + (unsigned)demote, (unsigned)realign_to, + (unsigned)live_sim); fflush(stderr); - schedule_live_realign(demote, why); + schedule_live_realign(realign_to, why); } else { fprintf(stderr, "psxrecomp: rb NACK keep-live demote=%u " @@ -5423,6 +5753,7 @@ void psx_netplay_rb_pump(void) if (rnet_rb_is_active(g_rb) && rnet_rb_get_phase(g_rb) == nRNetRbPhaseSealInputs) { uint64_t now = rb_mono_ms(); export_local_seals(); + maybe_rexmit_begin(); if (g_seal_wait_ms != 0ull && now >= g_seal_wait_ms && (now - g_seal_wait_ms) >= (uint64_t)RB_SEAL_TIMEOUT_MS) { abort_episode("seal timeout (peer missing snap / NACK lost)"); @@ -5438,6 +5769,11 @@ void psx_netplay_rb_pump(void) maybe_enter_replay(); } + /* §60: idle follower with a stashed ownership-chain BEGIN (POST drain + * ordered after SYNC in this pump, or BEGIN arrived while Verify-active). */ + if (!rnet_rb_is_active(g_rb)) + apply_stashed_begin(); + /* Keep BASELINE on the wire through Replay until the peer is clearly in. */ if (rnet_rb_is_active(g_rb) && rnet_rb_get_phase(g_rb) == nRNetRbPhaseReplay) { if (g_local_baseline_sent && !g_local_post_sent) { diff --git a/runtime/src/psx_netplay_sched.c b/runtime/src/psx_netplay_sched.c index 0f67295e..dddc2009 100644 --- a/runtime/src/psx_netplay_sched.c +++ b/runtime/src/psx_netplay_sched.c @@ -1208,15 +1208,26 @@ static void np_auto_delay_tick(uint32_t now) s_last_target = target; return; } - if (target != s_last_target) { - s_last_target = target; - s_agree_streak = 1u; - return; + /* §59: first raise of the session (never changed yet) confirms on 1 eval + * (~5 s) so a badly under-provisioned lobby D does not invent-storm for + * a full 10 s. Later raises keep 2-eval confirm; lowers stay at 3. */ + { + uint32_t need_agree; + if (target < d) + need_agree = RB_AUTO_DELAY_AGREE; + else if (s_last_change_ms == 0u) + need_agree = 1u; + else + need_agree = 2u; + if (target != s_last_target) { + s_last_target = target; + s_agree_streak = 1u; + if (s_agree_streak < need_agree) + return; + } else if (++s_agree_streak < need_agree) { + return; + } } - /* Asymmetric hysteresis: raises hurt only input latency, misses hurt - * determinism — confirm a raise on 2 evals (10 s), a lower on 3 (15 s). */ - if (++s_agree_streak < ((target > d) ? 2u : RB_AUTO_DELAY_AGREE)) - return; if (s_last_change_ms != 0u && (uint32_t)(now - s_last_change_ms) < RB_AUTO_DELAY_COOLDOWN_MS) return;