From 4f76e2eec2f6b24dfd2c9e6837147c7cdef437b3 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:37 -0700 Subject: [PATCH] Netplay rollback: count post-movie agreement in confirmed ticks The post-FMV lockstep gate counted a tick as agreed only when sim-1 was already hash-confirmed. A peer digest arrives one RTT after its tick, so the peer running ahead (the host, cross-machine) never saw sim-1 confirmed, reset the streak every tick and logged 'rb DESYNC - FMV lockstep MAX unmatched' after every movie while every digest matched. Each newly confirmed tick past the movie's end now counts once; only a real mismatch resets the streak. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 3 +++ runtime/include/netplay_fmv_confirm.h | 21 +++++++++++++++ runtime/src/psx_netplay_rb.c | 34 +++++++++++++++++------- runtime/tests/test_netplay_fmv_confirm.c | 31 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 runtime/include/netplay_fmv_confirm.h create mode 100644 runtime/tests/test_netplay_fmv_confirm.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e6ae0f5f..23a2e207 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -53,6 +53,9 @@ if(BUILD_TESTING) target_include_directories(netplay_sim_pad_cache_test PRIVATE include) target_compile_definitions(netplay_sim_pad_cache_test PRIVATE PSX_MAX_PLAYERS=4) add_test(NAME netplay_sim_pad_cache_test COMMAND netplay_sim_pad_cache_test) + add_executable(netplay_fmv_confirm_test tests/test_netplay_fmv_confirm.c) + target_include_directories(netplay_fmv_confirm_test PRIVATE include) + add_test(NAME netplay_fmv_confirm_test COMMAND netplay_fmv_confirm_test) add_executable(netplay_local_view_test tests/test_netplay_local_view.c) target_include_directories(netplay_local_view_test PRIVATE include) add_test(NAME netplay_local_view_test COMMAND netplay_local_view_test) diff --git a/runtime/include/netplay_fmv_confirm.h b/runtime/include/netplay_fmv_confirm.h new file mode 100644 index 00000000..edf9c171 --- /dev/null +++ b/runtime/include/netplay_fmv_confirm.h @@ -0,0 +1,21 @@ +/* netplay_fmv_confirm.h - post-movie agreement counting for rollback netplay. + * + * After a movie, both peers must agree for RB_FMV_LOCKSTEP_CONFIRM ticks + * before prediction is unlocked. Agreement is measured in hash-confirmed + * ticks: each newly confirmed tick past the movie's end counts once, however + * late its confirmation arrives. A confirmation is always about one RTT + * behind the tick it confirms, so testing "is sim-1 confirmed yet" every tick + * never succeeds on the peer that runs ahead. */ +#ifndef PSX_NETPLAY_FMV_CONFIRM_H +#define PSX_NETPLAY_FMV_CONFIRM_H + +#include + +/* Confirmed ticks gained when the confirmed frontier moves from `from` (the + * highest tick already counted, or the movie's end) to `resolved`. */ +static inline uint32_t rb_fmv_confirm_gain(uint32_t from, uint32_t resolved) +{ + return resolved > from ? resolved - from : 0u; +} + +#endif diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index a4db587c..0affe9d7 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_fmv_confirm.h" #include "netplay_input_hist.h" #include "netplay_rb_post.h" #include "netplay_snap_ring.h" @@ -715,6 +716,8 @@ static uint32_t g_fmv_settle_until; static uint32_t g_fmv_lockstep_until; static uint32_t g_fmv_media_end_sim; static uint32_t g_fmv_core_match_streak; +/* Highest hash-confirmed tick already counted into the streak. */ +static uint32_t g_fmv_confirm_seen; 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; @@ -1037,21 +1040,31 @@ static int rb_fmv_media_active(void) /* After media: grow/shrink lockstep_until from hash_confirm agreement. */ 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()) return; - if (g_b.hc && !netplay_hc_peek_mismatch(g_b.hc, NULL, NULL, NULL)) { - uint32_t need = (sim > 0u) ? (sim - 1u) : 0u; - if (netplay_hc_confirm_through(g_b.hc, need)) - matched = 1; + /* Agreement is counted in CONFIRMED ticks, not in "sim-1 is already + * confirmed" ticks. The peer's digest for a tick arrives one RTT after + * the tick; the peer that runs ahead (usually the host, cross-machine) + * never has sim-1 confirmed when it asks, so the old per-tick test reset + * the streak every tick and fired a false "MAX unmatched" DESYNC after + * every movie even though every digest matched. Only a real mismatch + * resets the streak now; a confirm that is merely late leaves it. */ + { + uint32_t gained = 0u; + if (g_b.hc && netplay_hc_peek_mismatch(g_b.hc, NULL, NULL, NULL)) { + g_fmv_core_match_streak = 0; + } else if (g_b.hc && netplay_hc_confirm_through(g_b.hc, g_fmv_media_end_sim)) { + const uint32_t rt = netplay_hc_resolved_through(g_b.hc); + const uint32_t from = (g_fmv_confirm_seen > g_fmv_media_end_sim) + ? g_fmv_confirm_seen : g_fmv_media_end_sim; + gained = rb_fmv_confirm_gain(from, rt); + if (rt > g_fmv_confirm_seen) g_fmv_confirm_seen = rt; + } + g_fmv_core_match_streak += gained; } - if (matched) - g_fmv_core_match_streak++; - 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. */ @@ -1237,6 +1250,7 @@ static void rb_fmv_tick_settle(void) if (sim >= g_fmv_media_hi) g_fmv_media_hi = sim; g_fmv_core_match_streak = 0; + g_fmv_confirm_seen = 0; g_fmv_lockstep_released = 0; g_fmv_settle_until = sim + RB_FMV_SETTLE_TICKS; g_fmv_lockstep_until = sim + RB_FMV_LOCKSTEP_MIN; @@ -6498,6 +6512,7 @@ void psx_netplay_rb_cold_reset(void) g_fmv_settle_until = 0; g_fmv_lockstep_until = 0; g_fmv_media_end_sim = 0; + g_fmv_confirm_seen = 0; g_fmv_core_match_streak = 0; g_fmv_lockstep_released = 0; g_fmv_dense_through = 0; @@ -6551,6 +6566,7 @@ void psx_netplay_rb_shutdown(void) g_fmv_settle_until = 0; g_fmv_lockstep_until = 0; g_fmv_media_end_sim = 0; + g_fmv_confirm_seen = 0; g_fmv_core_match_streak = 0; g_fmv_lockstep_released = 0; g_fmv_dense_through = 0; diff --git a/runtime/tests/test_netplay_fmv_confirm.c b/runtime/tests/test_netplay_fmv_confirm.c new file mode 100644 index 00000000..1721d52c --- /dev/null +++ b/runtime/tests/test_netplay_fmv_confirm.c @@ -0,0 +1,31 @@ +#include "netplay_fmv_confirm.h" +#undef NDEBUG +#include + +/* Model the gate's counting: the peer's digests trail by `lag` ticks. */ +static uint32_t streak_after(uint32_t media_end, uint32_t ticks, uint32_t lag) +{ + uint32_t seen = 0, streak = 0, sim; + for (sim = media_end; sim < media_end + ticks; ++sim) { + const uint32_t resolved = sim > lag ? sim - lag : 0; + if (resolved < media_end) continue; /* nothing past the movie yet */ + streak += rb_fmv_confirm_gain(seen > media_end ? seen : media_end, resolved); + if (resolved > seen) seen = resolved; + } + return streak; +} + +int main(void) +{ + assert(rb_fmv_confirm_gain(10, 10) == 0); + assert(rb_fmv_confirm_gain(10, 9) == 0); + assert(rb_fmv_confirm_gain(10, 13) == 3); + /* Same machine (no lag) and cross-machine (2-6 ticks of lag) both reach + * the 16-tick confirmation well inside the 300-tick cap. */ + assert(streak_after(1000, 300, 0) >= 16); + assert(streak_after(1000, 300, 2) >= 16); + assert(streak_after(1000, 300, 6) >= 16); + /* Bursty confirmations (a frontier jump) still count every tick once. */ + assert(streak_after(1000, 40, 30) == 9); + return 0; +}