From 1633ecf1d06607801bf3912e62593affa430230b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:54:26 -0700 Subject: [PATCH] Netplay rb: per-seat peer frontier; retire a healed fork cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rollback bookkeeping bugs that only show with more than two seats over a real link: - g_peer_resolved_through took the highest RESOLVED from any peer. With three seats the fastest follower stood in for the others, so the host opened loads past the slower followers' frontiers (3P WAN: load 2400 vs frontiers 2344 and 2319); every follower refused, episodes aborted and the session diverged at 2496 before the race started. Adverts are now kept per seat (netplay_peer_frontier.h) and the lowest across the peer roster is the frontier. - The §55 bisect cap (next load < L after a baseline mismatch at L) was only cleared by an episode commit. An abort followed by a Live heal left it armed for the rest of the session; once its loads left the snapshot ring every episode was refused with "no confirmed snap" (4P WAN: cap 1928, agreed 3136, ring 2656..3166) and a later fork at 3150 was never repaired. A live hash-confirmed agreed ADVANCE at or past the cap now retires it. Bumps lib/recomp-net for RB_RESOLVED sender attribution and rb_* queue retransmit coalescing (RetroPortingToolKit/recomp-net fix/rb-ctrl-queue-dedupe). Co-Authored-By: Claude Opus 5.5 --- lib/recomp-net | 2 +- runtime/CMakeLists.txt | 4 ++ runtime/include/netplay_peer_frontier.h | 83 ++++++++++++++++++++++ runtime/src/psx_netplay_rb.c | 55 ++++++++++++-- runtime/tests/test_netplay_peer_frontier.c | 64 +++++++++++++++++ 5 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 runtime/include/netplay_peer_frontier.h create mode 100644 runtime/tests/test_netplay_peer_frontier.c diff --git a/lib/recomp-net b/lib/recomp-net index b1eab87b..b0ace56c 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b1eab87bf3c011da8c22890601c0e659f1f89fbb +Subproject commit b0ace56c3b5c731fde2f77a8dd6841663c691673 diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ab2abb6b..b92cd1cd 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1046,6 +1046,10 @@ if(BUILD_TESTING) ../recompiler/lib/toml11) add_test(NAME text_xlate_fix_only_test COMMAND text_xlate_fix_only_test) + add_executable(netplay_peer_frontier_test tests/test_netplay_peer_frontier.c) + target_include_directories(netplay_peer_frontier_test PRIVATE include) + add_test(NAME netplay_peer_frontier_test COMMAND netplay_peer_frontier_test) + add_executable(netplay_content_gate_test tests/test_netplay_content_gate.c) target_include_directories(netplay_content_gate_test PRIVATE include) add_test(NAME netplay_content_gate_test COMMAND netplay_content_gate_test) diff --git a/runtime/include/netplay_peer_frontier.h b/runtime/include/netplay_peer_frontier.h new file mode 100644 index 00000000..2d2fb9d0 --- /dev/null +++ b/runtime/include/netplay_peer_frontier.h @@ -0,0 +1,83 @@ +/* Per-seat peer RESOLVED frontiers for rollback episodes. + * + * A follower can only replay from a snapshot at or below the frontier it + * advertised. With two seats there is one follower and one number. With more, + * the initiator must open at or below the SLOWEST follower's frontier: taking + * the highest advert (the old single watermark) let the fastest peer stand in + * for the others, and on a 3-seat WAN race the host opened load=2400 against + * followers at 2344 and 2319 -- every follower refused, the episodes aborted + * and the session diverged before the race started. + * + * Header-only and free of runtime state so the bookkeeping is unit-tested + * (tests/test_netplay_peer_frontier.c). */ +#ifndef NETPLAY_PEER_FRONTIER_H +#define NETPLAY_PEER_FRONTIER_H + +#include +#include + +#define NETPLAY_PEER_FRONTIER_SEATS 16u + +typedef struct NetplayPeerFrontier { + uint32_t through[NETPLAY_PEER_FRONTIER_SEATS]; +} NetplayPeerFrontier; + +static inline void netplay_peer_frontier_reset(NetplayPeerFrontier *f) +{ + if (f) + memset(f, 0, sizeof(*f)); +} + +/* A RESOLVED advert from `seat`. Watermark: never lowers. */ +static inline void netplay_peer_frontier_note(NetplayPeerFrontier *f, int seat, + uint32_t through) +{ + if (!f || seat < 0 || (uint32_t)seat >= NETPLAY_PEER_FRONTIER_SEATS) + return; + if (through > f->through[seat]) + f->through[seat] = through; +} + +/* Evidence above `tick` was invalidated (realign) or a follower proved it + * cannot follow above it (NACK): no seat may stay above it. */ +static inline void netplay_peer_frontier_clamp(NetplayPeerFrontier *f, uint32_t tick) +{ + uint32_t i; + if (!f) + return; + for (i = 0; i < NETPLAY_PEER_FRONTIER_SEATS; ++i) + if (f->through[i] > tick) + f->through[i] = tick; +} + +/* Frontier every seat in `seats` has reached: the lowest, and 0 while any of + * them has not advertised yet. */ +static inline uint32_t netplay_peer_frontier_min(const NetplayPeerFrontier *f, + uint32_t seats) +{ + uint32_t i, lo = 0xffffffffu; + int any = 0; + if (!f) + return 0u; + for (i = 0; i < NETPLAY_PEER_FRONTIER_SEATS; ++i) { + if (!(seats & (1u << i))) + continue; + any = 1; + if (f->through[i] < lo) + lo = f->through[i]; + } + return any ? lo : 0u; +} + +/* §55 bisect cap: after a baseline mismatch at load L the next load must be + * below L. Once every peer has hash-confirmed a tick at or past L (agreed + * ADVANCE from a live confirm) the fork is healed and the cap is stale; left + * armed it refused every later episode, because the only loads it allowed had + * long left the snapshot ring (4-seat race: cap 1928, agreed 3136, ring + * 2656..3166 -> "no confirmed snap" and a permanent fork from 3168). */ +static inline int netplay_fork_cap_retired(uint32_t fork_cap, uint32_t agreed_through) +{ + return fork_cap > 0u && agreed_through >= fork_cap; +} + +#endif /* NETPLAY_PEER_FRONTIER_H */ diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index a4db587c..61492900 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -116,6 +116,7 @@ uint32_t psx_netplay_rb_rtt_estimate_ms(void) { return 0; } #else #include "netplay_hash_confirm.h" +#include "netplay_peer_frontier.h" #include "netplay_input_hist.h" #include "netplay_rb_post.h" #include "netplay_snap_ring.h" @@ -435,6 +436,9 @@ static uint32_t g_agreed_span_lo; * otherwise ADVANCE→begin races the follower's agreed and NACK-storms * (2026-08-02 post-§39 soak). */ static uint32_t g_peer_resolved_through; +/* Per-seat RESOLVED adverts; g_peer_resolved_through is the lowest of them + * across every peer seat (netplay_peer_frontier.h). */ +static NetplayPeerFrontier g_peer_frontier; /* §41: wall-clock when choose_load/begin first blocked on peer RESOLVED * (0 = not waiting). After RB_MOTK_PEER_RESOLVED_GATE_MS, force-open — * unbounded defer silently desynced Live (2026-08-02 post-§40 soak). */ @@ -2245,6 +2249,7 @@ static void realign_invalidate_evidence(uint32_t tick) } if (g_peer_resolved_through > tick) g_peer_resolved_through = tick; + netplay_peer_frontier_clamp(&g_peer_frontier, tick); if (g_local_advertised_through > tick) g_local_advertised_through = tick; if (g_snaps) @@ -3295,10 +3300,35 @@ static int maybe_abandon_tip_extend(void) /* Apply inbound peer RESOLVED: raise g_peer_resolved_through + library * convergence, and complete Verify if POST was lost. */ -static void apply_peer_resolved(uint32_t resolved) +static void apply_peer_resolved(int from, uint32_t advert) { - if (!g_rb || resolved == 0u) + uint32_t seats = psx_netplay_peer_seats(); + uint32_t resolved; + if (!g_rb || advert == 0u) return; + if (seats == 0u) { + /* No seat roster (bare two-seat session): the one peer's advert. */ + resolved = advert; + } else { + int seat = -1; + if (from >= 0 && (uint32_t)from < NETPLAY_PEER_FRONTIER_SEATS && + (seats & (1u << from))) + seat = from; + else if ((seats & (seats - 1u)) == 0u) { + /* Sole peer: the sender is unambiguous. */ + seat = 0; + while (!(seats & (1u << seat))) + seat++; + } + if (seat < 0) + return; + netplay_peer_frontier_note(&g_peer_frontier, seat, advert); + /* With more than two seats an advert speaks only for its sender: + * the frontier every follower has reached is the lowest. */ + resolved = netplay_peer_frontier_min(&g_peer_frontier, seats); + if (resolved == 0u) + return; + } if (resolved > g_peer_resolved_through) { g_peer_resolved_through = resolved; g_peer_resolved_gate_expired = 0; @@ -3522,6 +3552,15 @@ static void advance_agreed_watermark_from_hc(void) fprintf(stderr, "psxrecomp: rb agreed ADVANCE %u→%u (hc=%u live confirm)\n", (unsigned)old, (unsigned)t, (unsigned)rt); + /* §55: every peer matched cores at t >= the bisect cap, so the + * fork it bounded is healed. A stale cap refuses every later + * episode once its loads leave the ring. */ + if (netplay_fork_cap_retired(g_bl_fork_cap, t)) { + fprintf(stderr, + "psxrecomp: rb fork cap %u retired (agreed %u live confirm)\n", + (unsigned)g_bl_fork_cap, (unsigned)t); + g_bl_fork_cap = 0u; + } fflush(stderr); /* §40: peer follow frontier lags local agreed until RESOLVED lands. */ advertise_agreed_resolved(t); @@ -6415,6 +6454,7 @@ void psx_netplay_rb_start(void) g_agreed_through = 0; g_agreed_span_lo = 0; g_peer_resolved_through = 0; + netplay_peer_frontier_reset(&g_peer_frontier); g_peer_resolved_wait_t0_ms = 0ull; g_peer_resolved_gate_expired = 0; g_past_frontier_sticky = 0; @@ -6595,6 +6635,7 @@ void psx_netplay_rb_shutdown(void) g_agreed_through = 0; g_agreed_span_lo = 0; g_peer_resolved_through = 0; + netplay_peer_frontier_reset(&g_peer_frontier); g_peer_resolved_wait_t0_ms = 0ull; g_peer_resolved_gate_expired = 0; g_past_frontier_sticky = 0; @@ -7796,7 +7837,7 @@ int psx_netplay_rb_begin_rewind(uint32_t mismatch_tick, int slot) { rnet_u32 resolved = 0; while (rnet_session_take_rb_resolved(s, &resolved)) - apply_peer_resolved(resolved); + apply_peer_resolved(rnet_session_rb_last_take_from(s), resolved); } /* Tick settle tracker. Media/lockstep episodes resume into MotK wait/VLC @@ -9003,7 +9044,7 @@ void psx_netplay_rb_pump(void) { rnet_u32 resolved = 0; while (rnet_session_take_rb_resolved(s, &resolved)) - apply_peer_resolved(resolved); + apply_peer_resolved(rnet_session_rb_last_take_from(s), resolved); } /* §53b: Live cadence — only HC-confirmed ADVANCE. Aged HEAL / HEAL-FORCE * stay at begin/follow: running them every pump under a live HC stall @@ -9027,7 +9068,7 @@ void psx_netplay_rb_pump(void) (g_peer_commit_epoch != epoch || load > g_peer_commit_tick)) { g_peer_commit_epoch = epoch; g_peer_commit_tick = load; - apply_peer_resolved(load); + apply_peer_resolved(rnet_session_rb_last_take_from(s), load); (void)maybe_abandon_tip_extend(); } continue; @@ -9144,7 +9185,7 @@ void psx_netplay_rb_pump(void) (unsigned)peer_frontier); fflush(stderr); } - apply_peer_resolved(peer_frontier); + apply_peer_resolved(rnet_session_rb_last_take_from(s), peer_frontier); if (!g_agreed_valid || g_agreed_through < peer_frontier) { g_agreed_through = peer_frontier; g_agreed_span_lo = peer_frontier; @@ -9284,6 +9325,8 @@ void psx_netplay_rb_pump(void) if (g_peer_resolved_through == 0u || g_peer_resolved_through > peer_frontier) g_peer_resolved_through = peer_frontier; + netplay_peer_frontier_clamp(&g_peer_frontier, + peer_frontier); g_peer_resolved_gate_expired = 0; g_peer_resolved_wait_t0_ms = 0ull; g_past_frontier_sticky = 1; diff --git a/runtime/tests/test_netplay_peer_frontier.c b/runtime/tests/test_netplay_peer_frontier.c new file mode 100644 index 00000000..72234fa9 --- /dev/null +++ b/runtime/tests/test_netplay_peer_frontier.c @@ -0,0 +1,64 @@ +#include "netplay_peer_frontier.h" + +#include + +static int failures; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + printf("FAIL: %s\n", msg); \ + failures++; \ + } else { \ + printf("ok: %s\n", msg); \ + } \ + } while (0) + +int main(void) +{ + NetplayPeerFrontier f; + const uint32_t two = 1u << 1; /* host + seat 1 */ + const uint32_t three = (1u << 1) | (1u << 2); /* host + seats 1, 2 */ + const uint32_t four = three | (1u << 3); + + netplay_peer_frontier_reset(&f); + CHECK(netplay_peer_frontier_min(&f, three) == 0u, "nothing advertised -> 0"); + + netplay_peer_frontier_note(&f, 1, 752u); + CHECK(netplay_peer_frontier_min(&f, two) == 752u, "two seats: the single peer's frontier"); + CHECK(netplay_peer_frontier_min(&f, three) == 0u, + "three seats: one advert does not speak for the silent seat"); + + /* The 3-seat WAN failure: host had 2400 from the fast peer only. */ + netplay_peer_frontier_reset(&f); + netplay_peer_frontier_note(&f, 1, 2344u); + netplay_peer_frontier_note(&f, 2, 2319u); + netplay_peer_frontier_note(&f, 1, 2400u); + CHECK(netplay_peer_frontier_min(&f, three) == 2319u, "slowest follower bounds the load"); + + netplay_peer_frontier_note(&f, 2, 2300u); + CHECK(netplay_peer_frontier_min(&f, three) == 2319u, "reordered older advert never lowers"); + + netplay_peer_frontier_note(&f, 3, 5000u); + CHECK(netplay_peer_frontier_min(&f, four) == 2319u, "four seats: still the slowest"); + + netplay_peer_frontier_clamp(&f, 2320u); + CHECK(f.through[1] == 2320u && f.through[2] == 2319u && f.through[3] == 2320u, + "clamp lowers every seat above the tick, leaves the rest"); + + netplay_peer_frontier_note(&f, -1, 9u); + netplay_peer_frontier_note(&f, 99, 9u); + CHECK(netplay_peer_frontier_min(&f, four) == 2319u, "out-of-range seats ignored"); + CHECK(netplay_peer_frontier_min(&f, 0u) == 0u, "no peer seats -> 0"); + + CHECK(!netplay_fork_cap_retired(0u, 3136u), "no cap: nothing to retire"); + CHECK(!netplay_fork_cap_retired(1928u, 1920u), "agreed below the cap keeps it"); + CHECK(netplay_fork_cap_retired(1928u, 1928u), "agreed at the cap retires it"); + CHECK(netplay_fork_cap_retired(1928u, 3136u), "agreed past the cap retires it (4-seat race)"); + + if (failures) { + printf("%d failure(s)\n", failures); + return 1; + } + printf("ALL PASS\n"); + return 0; +}