Netplay rb: per-seat peer frontier; retire a healed fork cap

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 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-09 16:54:26 -07:00
parent 3434424c0e
commit 1633ecf1d0
5 changed files with 201 additions and 7 deletions
+4
View File
@@ -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)
+83
View File
@@ -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 <stdint.h>
#include <string.h>
#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 */
+49 -6
View File
@@ -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;
@@ -0,0 +1,64 @@
#include "netplay_peer_frontier.h"
#include <stdio.h>
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;
}