From c8f668cc9efab74d9188b4db2961ddcaaf03029d Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 5 Aug 2026 22:23:30 -0400 Subject: [PATCH] Fix lobby relay rewrite via WS peer IP and harden rollback netplay. Resolve stash conflict in psx_lobby_client by keeping getpeername-based relay rewrite with the RFC1918-aware path, and land pending rollback hookup/runtime changes. --- docs/ROLLBACK_MOTK_HOOKUP.md | 40 +++++ runtime/include/psx_netplay_rb.h | 7 +- runtime/src/psx_lobby_client.c | 133 ++++++++++----- runtime/src/psx_netplay.c | 10 +- runtime/src/psx_netplay_rb.c | 272 +++++++++++++++++++++++++++++-- 5 files changed, 403 insertions(+), 59 deletions(-) diff --git a/docs/ROLLBACK_MOTK_HOOKUP.md b/docs/ROLLBACK_MOTK_HOOKUP.md index 27d8ec23..6121a455 100644 --- a/docs/ROLLBACK_MOTK_HOOKUP.md +++ b/docs/ROLLBACK_MOTK_HOOKUP.md @@ -4414,3 +4414,43 @@ GP0 → Software @ 1× → CPU VRAM → snaps / av= / GPUREAD (authority) **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 (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. diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index b37e710a..392b7951 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -79,9 +79,14 @@ int psx_netplay_rb_fmv_defer_rewind(void); int psx_netplay_rb_fmv_media_active(void); /* 1 during FMV media + short settle (§26) — admit waits for remote wire. - * Post-FMV digest lockstep no longer blocks invent. Ticks FMV→settle. */ + * Post-FMV digest lockstep no longer blocks invent. Ticks FMV→settle. + * Also 1 while §93 MAX-unmatched DESYNC hold is armed. */ int psx_netplay_rb_lockstep_no_invent(void); +/* §93: 1 if tick sits in the last FMV media bout / settle tail (or DESYNC + * hold). Begin/follow/hc-fork must not open episodes that load there. */ +int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick); + /* Mid-guest resim pump: abort if Replay has made no finish_frame progress. * Full rb_pump stays admit/present-edge only (host-asymmetric). */ void psx_netplay_rb_poll_replay_stall(void); diff --git a/runtime/src/psx_lobby_client.c b/runtime/src/psx_lobby_client.c index a02bebfa..fad4fef3 100644 --- a/runtime/src/psx_lobby_client.c +++ b/runtime/src/psx_lobby_client.c @@ -131,6 +131,8 @@ typedef struct { char display_name[PSX_LOBBY_NAME_LEN]; char host[128]; int port; + /* Actual TCP peer after connect (split-horizon / hairpin-safe for SFU). */ + char connected_peer_ip[64]; char path[128]; char rx_http[4096]; size_t rx_http_len; @@ -283,48 +285,30 @@ static int endpoint_host_port(const char *ep, char *host, size_t host_cap, int * return *port_out > 0 && *port_out <= 65535; } -static int host_name_is_loopback(const char *host) +static void lobby_store_connected_peer_ip(int fd) { - if (!host || !host[0]) - return 0; - if (strncmp(host, "127.", 4) == 0) - return 1; - if (strcmp(host, "::1") == 0) - return 1; - if (strcmp(host, "localhost") == 0) - return 1; - return 0; -} - -/* Lobby WebSocket and UDP input relay are the same recomp-net-server process. - * Launch may advertise INPUT_RELAY_ADVERTISE_HOST=127.0.0.1 (server default). - * Remote peers rewrite that to the WS host they dialed (LAN IP / public DNS). - * Same-machine peers keep loopback when UDP relay port is bound locally — - * avoids hairpin NAT through the public DNS and start failures on hostname. */ -static int rewrite_relay_endpoint_to_lobby_host(char *ep, size_t cap) -{ - char rh[128]; - int rport = 0; - int n; - if (!ep || !cap || !g_lc.host[0]) - return 0; - if (!endpoint_host_port(ep, rh, sizeof(rh), &rport)) - return 0; - if (strcmp(rh, g_lc.host) == 0) - return 0; - if (host_name_is_loopback(g_lc.host)) - return 0; - if (host_endpoint_is_loopback(ep) && rnet_udp_port_available(rport) == 0) { - fprintf(stderr, - "psx_lobby: keep relay_endpoint %s " - "(local UDP %d busy — same host as lobby server)\n", - ep, rport); - return 0; + struct sockaddr_storage ss; + socklen_t slen = (socklen_t)sizeof(ss); + g_lc.connected_peer_ip[0] = '\0'; + if (fd < 0) + return; + memset(&ss, 0, sizeof(ss)); + if (getpeername(fd, (struct sockaddr *)&ss, &slen) != 0) + return; + if (ss.ss_family == AF_INET) { + const struct sockaddr_in *in = (const struct sockaddr_in *)&ss; + if (!inet_ntop(AF_INET, &in->sin_addr, g_lc.connected_peer_ip, + sizeof(g_lc.connected_peer_ip))) + g_lc.connected_peer_ip[0] = '\0'; } - n = snprintf(ep, cap, "%s:%d", g_lc.host, rport); - if (n <= 0 || (size_t)n >= cap) - return -1; - return 1; +#if defined(AF_INET6) + else if (ss.ss_family == AF_INET6) { + const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)&ss; + if (!inet_ntop(AF_INET6, &in6->sin6_addr, g_lc.connected_peer_ip, + sizeof(g_lc.connected_peer_ip))) + g_lc.connected_peer_ip[0] = '\0'; + } +#endif } static int parse_ipv4_dotted(const char *host, unsigned *o) @@ -357,6 +341,67 @@ static int ipv4_is_rfc1918(const unsigned o[4]) return 0; } +static int host_is_loopback_name(const char *host) +{ + if (!host || !host[0]) + return 0; + if (strncmp(host, "127.", 4) == 0) + return 1; + if (strcmp(host, "::1") == 0) + return 1; + if (strcmp(host, "localhost") == 0) + return 1; + return 0; +} + +static int host_is_rfc1918_name(const char *host) +{ + unsigned o[4]; + if (!host || !host[0]) + return 0; + if (!parse_ipv4_dotted(host, o)) + return 0; + return ipv4_is_rfc1918(o); +} + +/* Lobby WebSocket and UDP input relay are the same recomp-net-server process. + * Rewrite only when it improves reachability: + * - keep an already-RFC1918 advertise (server LAN pick / split-horizon) + * - rewrite to WS peer IP only when that peer is private/loopback and + * advertise is public, loopback, or a hostname (never clobber LAN with WAN) + * - else fall back to WS URL host string for loopback advertise only */ +static int rewrite_relay_endpoint_to_lobby_host(char *ep, size_t cap) +{ + char rh[128]; + const char *use_host = NULL; + int rport = 0; + int n; + int adv_lan, adv_loop, peer_local; + if (!ep || !cap) + return 0; + if (!endpoint_host_port(ep, rh, sizeof(rh), &rport)) + return 0; + adv_lan = host_is_rfc1918_name(rh); + adv_loop = host_is_loopback_name(rh); + if (adv_lan) + return 0; /* server already gave a same-LAN dial target */ + peer_local = g_lc.connected_peer_ip[0] && + (host_is_rfc1918_name(g_lc.connected_peer_ip) || + host_is_loopback_name(g_lc.connected_peer_ip)); + if (peer_local) + use_host = g_lc.connected_peer_ip; + else if (adv_loop && g_lc.host[0] && !host_is_loopback_name(g_lc.host)) + use_host = g_lc.host; + if (!use_host || !use_host[0]) + return 0; + if (strcmp(rh, use_host) == 0) + return 0; + n = snprintf(ep, cap, "%s:%d", use_host, rport); + if (n <= 0 || (size_t)n >= cap) + return -1; + return 1; +} + static int ipv4_is_link_local(const unsigned o[4]) { return o && o[0] == 169 && o[1] == 254; @@ -1783,7 +1828,7 @@ static void handle_server_json(const char *json) if (rewrite_relay_endpoint_to_lobby_host(relay_endpoint, sizeof(relay_endpoint)) > 0) { fprintf(stderr, - "psx_lobby: relay_endpoint %s → %s (lobby WS host)\n", + "psx_lobby: relay_endpoint %s → %s (WS peer/host)\n", relay_raw, relay_endpoint); } strncpy(g_lc.join.host_endpoint, relay_endpoint, @@ -1967,6 +2012,12 @@ int psx_lobby_connect(const char *ws_url) return -3; } g_lc.fd = fd; + lobby_store_connected_peer_ip(fd); + if (g_lc.connected_peer_ip[0] && + strcmp(g_lc.connected_peer_ip, g_lc.host) != 0) { + fprintf(stderr, "psx_lobby: WS connected peer %s (url host %s)\n", + g_lc.connected_peer_ip, g_lc.host); + } for (i = 0; i < 16; ++i) { key_raw[i] = (char)(rand() & 0xff); } diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index c530dd29..a2b9a85d 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -668,7 +668,8 @@ static void np_try_hc_fork_recovery(uint32_t fork_tick) * "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()) + psx_netplay_rb_fmv_defer_rewind() || psx_netplay_rb_lockstep_no_invent() || + psx_netplay_rb_fmv_episode_unsafe(fork_tick)) return; sim = rnet_session_sim_tick(g_np.session); fork_cap = psx_netplay_rb_baseline_fork_cap(); @@ -3950,6 +3951,13 @@ static void np_pump_session(void) #endif drain_lobby_signals(); rnet_session_pump(g_np.session); + /* STATE_CHUNK AIMD still needs many pumps per wall-second on TURN; frame- + * paced call sites alone leave the cwnd idle between vblanks. */ + if (rnet_session_state_busy(g_np.session)) { + int burst; + for (burst = 0; burst < 12; burst++) + rnet_session_pump(g_np.session); + } np_drain_peer_frame_commits(); if (g_np.rollback) { np_rollback_reconcile_wire(); diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 91e7108c..d8f5d059 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -34,6 +34,7 @@ int psx_netplay_rb_rewind_suppressed(void) { return 0; } int psx_netplay_rb_fmv_defer_rewind(void) { return 0; } int psx_netplay_rb_fmv_media_active(void) { return 0; } int psx_netplay_rb_lockstep_no_invent(void) { return 0; } +int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick) { (void)tick; return 0; } void psx_netplay_rb_poll_replay_stall(void) {} int psx_netplay_rb_take_promote_sweep(void) { return 0; } int psx_netplay_rb_fmv_unlock_grace_active(void) { return 0; } @@ -406,6 +407,9 @@ static uint32_t g_last_begin_mismatch = 0xffffffffu; * an unrecoverable fork (loud DESYNC log, keep-live, long cooldown). */ #define RB_FORK_STORM_LIMIT 4u #define RB_FORK_STORM_COOLDOWN_TICKS 600u +/* §93: matched-baseline resim diverge on the same sim twice is proven + * nondeterminism (FMV/CD) — do not realign to the same pin again. */ +#define RB_RESIM_DIVERGE_STORM_LIMIT 2u /* After a genuine POST diverge where Live successfully realigned, do not * promote-only-block the next edges — cooldown was preventing the fork from * self-correcting (2026-08-01 §17 soak: tip=9203 abort → promote-no-resim @@ -533,6 +537,17 @@ static uint32_t g_fmv_core_match_streak; static int g_fmv_lockstep_released; /* sticky: never re-lock after invent on */ /* Inclusive last tick both peers dense-snapped (media/lockstep/+tip). */ static uint32_t g_fmv_dense_through; +/* §93: media bout range (sim ticks). Begin/follow refuse loads/mismatches + * inside [lo, unsafe_hi] even when Live tip has already left media — that + * was the hole that opened ep8 load=176 / ep24 load=736 into FMV state. */ +static uint32_t g_fmv_media_lo; +static uint32_t g_fmv_media_hi; +/* §93: FMV lockstep hit MAX with streak < CONFIRM — cores never agreed. + * Keep invent/begin off until cores rematch CONFIRM ticks or session reset. */ +static int g_fmv_unmatched_desync; +/* §93: consecutive resim-core aborts on the same diverge sim. */ +static uint32_t g_resim_diverge_tick; +static uint32_t g_resim_diverge_streak; static uint32_t g_bl_mismatch_streak; static uint32_t g_tip_hold_until; /* TipHold invent-cap quiet: wall-clock ms when we first stalled at tip+slack @@ -597,6 +612,8 @@ static void poll_tip_hold_finalize(void); static void clear_tip_extend_prime(void); static void clear_tip_hold_rereplay_pending(void); static int flush_tip_hold_deferred_rereplay(void); +static void arm_rewind_cooldown_ticks(uint32_t sim, uint32_t ticks, const char *why); +static void clear_rewind_cooldown(const char *why); static void tip_hold_try_finalize(void); static void schedule_episode_rereplay(uint32_t prefer_plus_one); static void ownership_on_post_match(uint32_t tip); @@ -795,7 +812,7 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) int matched = 0; uint32_t cap; uint32_t prev_until; - if (!g_fmv_media_end_sim || rb_fmv_media_active() || g_fmv_lockstep_released) + if (!g_fmv_media_end_sim || rb_fmv_media_active()) return; if (g_b.hc && !netplay_hc_peek_mismatch(g_b.hc, NULL, NULL, NULL)) { @@ -808,6 +825,22 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) else g_fmv_core_match_streak = 0; + /* §93: after MAX-unmatched DESYNC, keep counting so a later rematch can + * clear invent/begin hold without requiring a new media bout. */ + if (g_fmv_lockstep_released) { + if (g_fmv_unmatched_desync && + g_fmv_core_match_streak >= RB_FMV_LOCKSTEP_CONFIRM) { + fprintf(stderr, + "psxrecomp: rb FMV DESYNC cleared — cores rematched " + "streak=%u sim=%u\n", + (unsigned)g_fmv_core_match_streak, (unsigned)sim); + fflush(stderr); + g_fmv_unmatched_desync = 0; + clear_rewind_cooldown("FMV cores rematched after MAX unmatched"); + } + return; + } + if (sim < g_fmv_media_end_sim + RB_FMV_LOCKSTEP_MIN) return; /* accumulate streak; invent stays off through MIN */ @@ -827,23 +860,37 @@ static void rb_fmv_update_lockstep_gate(uint32_t sim) g_fmv_lockstep_until = grace_until; g_fmv_lockstep_released = 1; g_promote_sweep = 1; - /* §71: MAX unlock with streak=0 means cores never confirmed after - * FMV — keep promote_sweep sticky via a longer dense window so - * reconcile prefers hist repair over tip episodes into a fork. */ + /* §71/§93: MAX unlock with streak=0 means cores never confirmed after + * FMV — do NOT invent-unlock into a soft fork. Arm DESYNC hold + + * storm cooldown; invent/begin stay gated until rematch or reset. */ if (max_unmatched) { uint32_t dense = sim + RB_FMV_UNLOCK_GRACE * 2u; if (dense > g_fmv_lockstep_until) g_fmv_lockstep_until = dense; + g_fmv_unmatched_desync = 1; + arm_rewind_cooldown_ticks(sim, RB_FORK_STORM_COOLDOWN_TICKS, + "FMV lockstep MAX unmatched " + "(cores never confirmed)"); + fprintf(stderr, + "psxrecomp: rb DESYNC — FMV lockstep MAX unmatched " + "sim=%u streak=%u cap=%u — invent/begin held until " + "cores rematch (confirm=%u)\n", + (unsigned)sim, (unsigned)g_fmv_core_match_streak, + (unsigned)cap, (unsigned)RB_FMV_LOCKSTEP_CONFIRM); + fflush(stderr); } fprintf(stderr, "psxrecomp: rb FMV lockstep RELEASE sim=%u streak=%u " "cap=%u dense_until=%u (unlock_grace=%u; promote_sweep%s; " - "invent already on after settle §26)\n", + "%s)\n", (unsigned)sim, (unsigned)g_fmv_core_match_streak, (unsigned)cap, (unsigned)g_fmv_lockstep_until, (unsigned)RB_FMV_UNLOCK_GRACE, max_unmatched ? "; MAX unmatched" - : ((sim >= cap) ? "; MAX" : "; cores matched")); + : ((sim >= cap) ? "; MAX" : "; cores matched"), + max_unmatched + ? "DESYNC hold — invent off until rematch" + : "invent already on after settle §26"); fflush(stderr); (void)prev_until; return; @@ -880,6 +927,7 @@ static void rb_fmv_tick_settle(void) if (media) { if (!g_was_in_fmv) { g_was_in_fmv = 1; + g_fmv_media_lo = sim; fprintf(stderr, "psxrecomp: rb FMV rewind-defer ON (depth24/mdec; no invent)\n"); fflush(stderr); @@ -896,6 +944,10 @@ static void rb_fmv_tick_settle(void) finalize_tip_hold(); } } + if (sim >= g_fmv_media_hi) + g_fmv_media_hi = sim; + if (g_fmv_media_lo == 0u || sim < g_fmv_media_lo) + g_fmv_media_lo = sim; /* Heartbeat through the movie — digs are sparse while MDEC is hot. */ { static uint32_t s_fmv_hb_sim; @@ -924,6 +976,8 @@ static void rb_fmv_tick_settle(void) if (g_was_in_fmv) { g_was_in_fmv = 0; g_fmv_media_end_sim = sim; + if (sim >= g_fmv_media_hi) + g_fmv_media_hi = sim; g_fmv_core_match_streak = 0; g_fmv_lockstep_released = 0; g_fmv_settle_until = sim + RB_FMV_SETTLE_TICKS; @@ -946,6 +1000,37 @@ static void rb_fmv_tick_settle(void) rb_fmv_update_lockstep_gate(sim); } +/* §93: true if an episode load/mismatch tick sits inside the last FMV media + * bout or its settle tail — even when Live tip has already left media. */ +static int rb_fmv_tick_unsafe_for_episode(uint32_t tick) +{ + uint32_t unsafe_hi; + if (rb_fmv_media_active()) + return 1; + if (g_fmv_unmatched_desync) + return 1; + if (g_fmv_media_hi == 0u && g_fmv_media_end_sim == 0u) + return 0; + unsafe_hi = g_fmv_settle_until; + if (g_fmv_media_hi > 0u) { + uint32_t media_settle = g_fmv_media_hi + RB_FMV_SETTLE_TICKS; + if (media_settle > unsafe_hi) + unsafe_hi = media_settle; + } + if (g_fmv_media_end_sim > 0u) { + uint32_t end_settle = g_fmv_media_end_sim + RB_FMV_SETTLE_TICKS; + if (end_settle > unsafe_hi) + unsafe_hi = end_settle; + } + if (unsafe_hi == 0u) + return 0; + /* Media bout may start at 0 (boot). Treat lo==0 with hi set as from 0. */ + if (tick <= unsafe_hi && + (g_fmv_media_lo == 0u || tick >= g_fmv_media_lo)) + return 1; + return 0; +} + /* Defer rewind during movie + short settle. */ static int rb_in_fmv_defer_rewind_window(void) { @@ -954,6 +1039,8 @@ static int rb_in_fmv_defer_rewind_window(void) rb_fmv_tick_settle(); if (rb_fmv_media_active()) return 1; + if (g_fmv_unmatched_desync) + return 1; return sim < g_fmv_settle_until; } @@ -963,7 +1050,8 @@ static int rb_in_fmv_defer_rewind_window(void) * re-serialized presentation to ~packet rate (~30 fps) even with * PSX_RB_GAP1_INVENT — invent count was 0 from FMV-on through invent_at. * Digest-gated lockstep (g_fmv_lockstep_until) still runs for dense snaps - * / RELEASE logs; it no longer stalls admit. */ + * / RELEASE logs; it no longer stalls admit. + * §93: also hold invent after MAX-unmatched DESYNC until cores rematch. */ static int rb_in_fmv_lockstep_window(void) { RNetSession *s = sess(); @@ -971,6 +1059,8 @@ static int rb_in_fmv_lockstep_window(void) rb_fmv_tick_settle(); if (rb_fmv_media_active()) return 1; + if (g_fmv_unmatched_desync) + return 1; return sim < g_fmv_settle_until; } @@ -1654,23 +1744,79 @@ static void abort_episode_realign(const char *why) int hard_fail; int post_fail = why && strstr(why, "post ") != NULL; int baseline_fail = why && strstr(why, "baseline") != NULL; + int resim_fail = why && strstr(why, "resim core") != NULL; + int fmv_load_fail = why && strstr(why, "FMV media") != NULL; int arm_absurd_catchup = 0; /* §55/§83: raise fork_cap BEFORE pick_realign_tip so we never select the * just-failed (or previously failed) baseline as the Live heal tip. */ - if (baseline_fail) { + if (baseline_fail || fmv_load_fail) { uint32_t failed_load = g_rb ? rnet_rb_get_load_tick(g_rb) : 0u; + if (fmv_load_fail && failed_load == 0u && why) { + const char *lp = strstr(why, "load="); + if (lp) + (void)sscanf(lp + 5, "%u", &failed_load); + } if (failed_load > 0u && (g_bl_fork_cap == 0u || failed_load < g_bl_fork_cap)) { g_bl_fork_cap = failed_load; fprintf(stderr, "psxrecomp: rb fork cap — next load must be < %u " - "(baseline mismatch there)\n", - (unsigned)g_bl_fork_cap); + "(%s)\n", + (unsigned)g_bl_fork_cap, + fmv_load_fail ? "FMV media load" : "baseline mismatch there"); fflush(stderr); } } + /* §93: matched-baseline resim diverge on the same sim is nondeterminism + * (FMV/CD). Second hit → DESYNC keep-live; do not realign to the same pin. */ + if (resim_fail) { + uint32_t diverge_sim = 0u; + const char *p = strstr(why, "sim="); + if (p && sscanf(p + 4, "%u", &diverge_sim) == 1 && diverge_sim > 0u) { + if (diverge_sim == g_resim_diverge_tick) + g_resim_diverge_streak++; + else { + g_resim_diverge_tick = diverge_sim; + g_resim_diverge_streak = 1u; + } + } else { + g_resim_diverge_streak++; + } + { + uint32_t failed_load = g_rb ? rnet_rb_get_load_tick(g_rb) : 0u; + if (failed_load > 0u && + (g_bl_fork_cap == 0u || failed_load < g_bl_fork_cap)) { + g_bl_fork_cap = failed_load; + fprintf(stderr, + "psxrecomp: rb fork cap — next load must be < %u " + "(resim diverge there)\n", + (unsigned)g_bl_fork_cap); + fflush(stderr); + } + } + if (g_resim_diverge_streak >= RB_RESIM_DIVERGE_STORM_LIMIT) { + fprintf(stderr, + "psxrecomp: rb DESYNC — resim diverge storm sim=%u " + "(%u× same tick; matched baseline nondeterminism) — " + "keep-live, cooldown %u ticks (no same-pin realign)\n", + (unsigned)g_resim_diverge_tick, + (unsigned)g_resim_diverge_streak, + (unsigned)RB_FORK_STORM_COOLDOWN_TICKS); + fflush(stderr); + g_abort_wire_class = RNET_RB_ABORT_CLASS_STORM; + g_abort_wire_realign_tick = 0u; + abort_episode(why); + arm_rewind_cooldown_ticks(live_sim, RB_FORK_STORM_COOLDOWN_TICKS, + "DESYNC resim diverge storm"); + return; + } + } else if (!baseline_fail) { + g_resim_diverge_tick = 0u; + g_resim_diverge_streak = 0u; + } + have = pick_realign_tip(&tick); /* Sealed table dies in abort_episode — dump first on POST diverge. */ if (post_fail) @@ -1726,6 +1872,7 @@ static void abort_episode_realign(const char *why) hard_fail = !have || (why && (strstr(why, "baseline") != NULL || strstr(why, "resim core") != NULL || + strstr(why, "FMV media") != NULL || post_fail || strstr(why, "desync") != NULL)); if (hard_fail) @@ -4575,6 +4722,8 @@ static void finalize_tip_hold(void) g_bl_mismatch_streak = 0; g_fork_streak = 0u; /* §42 P4: clean commit ends any fork suspicion */ g_fork_streak_tick = 0u; + g_resim_diverge_tick = 0u; + g_resim_diverge_streak = 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. */ @@ -4954,6 +5103,8 @@ static void commit_episode(void) g_bl_mismatch_streak = 0; g_fork_streak = 0u; /* §42 P4 */ g_fork_streak_tick = 0u; + g_resim_diverge_tick = 0u; + g_resim_diverge_streak = 0u; g_bl_fork_cap = 0u; /* §55 */ g_peer_nack_floor = 0u; /* §62 */ /* No post-commit cooldown: promote-only after commit made char-select @@ -5112,6 +5263,11 @@ void psx_netplay_rb_shutdown(void) g_fmv_core_match_streak = 0; g_fmv_lockstep_released = 0; g_fmv_dense_through = 0; + g_fmv_media_lo = 0; + g_fmv_media_hi = 0; + g_fmv_unmatched_desync = 0; + g_resim_diverge_tick = 0; + g_resim_diverge_streak = 0; g_bl_mismatch_streak = 0; g_tip_hold_until = 0; g_tip_hold_quiet_t0_ms = 0ull; @@ -5507,6 +5663,30 @@ static int try_apply_pending_load(CPUState *cpu_in) "psxrecomp: rb snap applied tick=%u pc=0x%08x (resume deferred)\n", (unsigned)loaded_tick, (unsigned)pc); fflush(stderr); + /* §93: Live media detector can be cold while the load snap is mid- + * FMV (soak: begin@184 load=176 → flush media=1). Expand the media + * range and abort before Replay poisons both peers. */ + if (rb_fmv_media_active()) { + if (!g_was_in_fmv) { + g_was_in_fmv = 1; + fprintf(stderr, + "psxrecomp: rb FMV rewind-defer ON " + "(depth24/mdec; no invent)\n"); + fflush(stderr); + } + if (g_fmv_media_lo == 0u || loaded_tick < g_fmv_media_lo) + g_fmv_media_lo = loaded_tick; + if (loaded_tick >= g_fmv_media_hi) + g_fmv_media_hi = loaded_tick; + if (!g_ownership_skip_snap) { + char why[96]; + snprintf(why, sizeof(why), + "episode load into FMV media (load=%u)", + (unsigned)loaded_tick); + abort_episode_realign(why); + return 0; + } + } maybe_send_baseline(); return 1; } @@ -5670,10 +5850,17 @@ int psx_netplay_rb_fmv_media_active(void) int psx_netplay_rb_lockstep_no_invent(void) { - /* Media + settle only (§26). Post-FMV title menus invent+resim. */ + /* Media + settle only (§26). Post-FMV title menus invent+resim. + * §93: also hold after MAX-unmatched DESYNC until cores rematch. */ return rb_in_fmv_lockstep_window(); } +int psx_netplay_rb_fmv_episode_unsafe(uint32_t tick) +{ + (void)rb_in_fmv_defer_rewind_window(); + return rb_fmv_tick_unsafe_for_episode(tick); +} + void psx_netplay_rb_poll_replay_stall(void) { uint64_t now; @@ -6069,13 +6256,29 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) if (s_fmv_refuse_sim != sim) { fprintf(stderr, "psxrecomp: rb begin REFUSED mismatch=%u — FMV lockstep " - "(no tip episode; admit waits for wire)\n", - (unsigned)mismatch_tick); + "(no tip episode; admit waits for wire)%s\n", + (unsigned)mismatch_tick, + g_fmv_unmatched_desync ? " [DESYNC hold]" : ""); fflush(stderr); s_fmv_refuse_sim = sim; } return 0; } + /* §93: refuse when the *mismatch* sits in the last media bout — Live tip + * may already be past FMV while hc-fork still targets an in-media tick. */ + if (rb_fmv_tick_unsafe_for_episode(mismatch_tick)) { + static uint32_t s_media_mm_refuse_sim; + if (s_media_mm_refuse_sim != sim) { + fprintf(stderr, + "psxrecomp: rb begin REFUSED mismatch=%u — FMV media-range " + "(lo=%u hi=%u settle_until=%u; no episode into media)\n", + (unsigned)mismatch_tick, (unsigned)g_fmv_media_lo, + (unsigned)g_fmv_media_hi, (unsigned)g_fmv_settle_until); + fflush(stderr); + s_media_mm_refuse_sim = sim; + } + return 0; + } /* Coalesce: refuse reopen after abort/storm only; reconcile promotes wire. * until is armed from live sim at failure (not rewound tip) so catch-up @@ -6183,6 +6386,19 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) s_refuse_last_mismatch = 0xffffffffu; } + /* §93: chosen load may sit inside a prior FMV bout even when Live tip and + * mismatch are past settle (soak: load=176 / 736 into media). */ + if (rb_fmv_tick_unsafe_for_episode(load)) { + fprintf(stderr, + "psxrecomp: rb begin REFUSED mismatch=%u load=%u — FMV " + "media-range load (lo=%u hi=%u settle_until=%u)\n", + (unsigned)mismatch_tick, (unsigned)load, + (unsigned)g_fmv_media_lo, (unsigned)g_fmv_media_hi, + (unsigned)g_fmv_settle_until); + fflush(stderr); + return 0; + } + target = rnet_rb_suggest_target(g_rb, mismatch_tick, sim); if (target < load) target = load; @@ -6219,8 +6435,10 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) /* Prefer MotK agreed tip when library watermark not yet set. Depth * ceiling comes from the session's configured light_tip_max_depth * (== RB_MOTK_TIP_RUNWAY), not the library default — see - * psx_netplay_rb_start(). */ + * psx_netplay_rb_start(). §93: never light-tip into/near FMV media. */ if (g_agreed_valid && + !rb_fmv_tick_unsafe_for_episode(load) && + !rb_fmv_tick_unsafe_for_episode(target) && rnet_rb_is_light_tip_candidate_ex(load, target, g_agreed_through, rnet_rb_get_light_tip_max_depth(g_rb))) corr.flags = RNET_RB_CORR_LIGHT_TIP; @@ -6684,8 +6902,30 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin /* Same policy as begin: do not follow into media/lockstep tip episodes. */ if (rb_in_fmv_lockstep_window()) { fprintf(stderr, - "psxrecomp: rb follow REFUSED epoch=%u load=%u — FMV lockstep\n", - (unsigned)epoch, (unsigned)load); + "psxrecomp: rb follow REFUSED epoch=%u load=%u — FMV lockstep%s\n", + (unsigned)epoch, (unsigned)load, + g_fmv_unmatched_desync ? " [DESYNC hold]" : ""); + 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; + } + /* §93: refuse follow when load/mismatch sits in the last FMV media bout. */ + if (rb_fmv_tick_unsafe_for_episode(load) || + rb_fmv_tick_unsafe_for_episode(mismatch)) { + fprintf(stderr, + "psxrecomp: rb follow REFUSED epoch=%u load=%u mismatch=%u — " + "FMV media-range (lo=%u hi=%u settle_until=%u)\n", + (unsigned)epoch, (unsigned)load, (unsigned)mismatch, + (unsigned)g_fmv_media_lo, (unsigned)g_fmv_media_hi, + (unsigned)g_fmv_settle_until); fflush(stderr); g_follow_nack_pending = 1; g_follow_nack_epoch = epoch;