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; +}