From 320d94869cc9382d2db7405b629cbae42356df40 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 8 Oct 2026 21:33:46 -0700 Subject: [PATCH 01/21] Allow trusted graphics mods to request a session-only internal resolution --- docs/MOD_PACKAGES.md | 7 ++++++ runtime/CMakeLists.txt | 6 +++++ runtime/include/mod_internal_resolution.h | 17 +++++++++++++ runtime/include/mod_plugins.h | 7 ++++++ runtime/runtime.cmake | 1 + runtime/src/main.cpp | 8 ++++--- runtime/src/mod_internal_resolution.c | 19 +++++++++++++++ runtime/tests/test_mod_internal_resolution.c | 25 ++++++++++++++++++++ 8 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 runtime/include/mod_internal_resolution.h create mode 100644 runtime/src/mod_internal_resolution.c create mode 100644 runtime/tests/test_mod_internal_resolution.c diff --git a/docs/MOD_PACKAGES.md b/docs/MOD_PACKAGES.md index 9fad8eca..2c2ea70a 100644 --- a/docs/MOD_PACKAGES.md +++ b/docs/MOD_PACKAGES.md @@ -757,6 +757,13 @@ some selections of one feature is a `[[requirement]]`, not a dependency. ## Implicit requirements across packages +Trusted graphics plugins can call `psx_mod_set_internal_resolution(lines)` to +request a rendering height for their session. Values from 120 through 8192 use +the existing integer-scale resolver and backend allocation limits; zero clears +the request. `PSX_INTERNAL_RESOLUTION` takes precedence. The player's saved +resolution stays unchanged, and every new session clears the request before +its mod plan activates, including rematches and sessions without that mod. + The shared PGXP plugin uses `psx_mod_set_pgxp_precision(enabled, cpu_mode)`. This stores a session selection as well as setting live correction flags, so later renderer initialization cannot erase mod activation with the base video diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e324baef..a8c1d80a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1248,6 +1248,12 @@ if(BUILD_TESTING) endif() add_test(NAME render_pass_plan_test COMMAND render_pass_plan_test) + add_executable(mod_internal_resolution_test + tests/test_mod_internal_resolution.c + src/mod_internal_resolution.c) + target_include_directories(mod_internal_resolution_test PRIVATE include) + add_test(NAME mod_internal_resolution_test COMMAND mod_internal_resolution_test) + # Motion kit and per-frame driver for render-pass plugins: blending, # identity matching, limits and the crossfade fallback, on mock RAM. add_executable(render_pass_motion_test diff --git a/runtime/include/mod_internal_resolution.h b/runtime/include/mod_internal_resolution.h new file mode 100644 index 00000000..e81c6a4a --- /dev/null +++ b/runtime/include/mod_internal_resolution.h @@ -0,0 +1,17 @@ +#ifndef PSX_MOD_INTERNAL_RESOLUTION_H +#define PSX_MOD_INTERNAL_RESOLUTION_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Runtime session plumbing; plugins use mod_plugins.h. Zero means no request. */ +uint32_t psx_mod_internal_resolution_request(void); +void psx_mod_internal_resolution_reset(void); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 353d298b..61fbbf4c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -431,6 +431,13 @@ int psx_mod_set_adaptive_display_aspect(uint32_t max_numerator, * title's setting before activation. */ int psx_mod_set_widescreen_hud_size(int proportional); +/* Request an internal rendering height for this mod session (120..8192). + * The existing resolution resolver selects an integer raster scale and the + * backend applies its normal allocation limits. Zero clears the request. + * Player settings are preserved, explicit PSX_INTERNAL_RESOLUTION overrides + * win, and each session clears this request before activating its committed + * mod plan. */ +int psx_mod_set_internal_resolution(uint32_t target_lines); /* * Set the wall-clock cadence of simulated guest VBlanks. A value of zero * removes frontend pacing; 60 and higher request that many native guest diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 06ed5efd..57ceae34 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -472,6 +472,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/game_options.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_speed.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_pgxp.c + ${PSXRECOMP_ROOT}/runtime/src/mod_internal_resolution.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_bezel.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_ram.c ${PSXRECOMP_ROOT}/runtime/src/mod_builtin_hd_textures.c diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c94e99de..d6dfb162 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -11,6 +11,7 @@ #include "mod_controller_source.h" #include "window_size.h" /* default game-window size */ #include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ +#include "mod_internal_resolution.h" #include "dynamic_resolution.h" /* [video] dynamic_resolution: the step controller */ #include "render_thread.h" /* rt_get_stats: queue backpressure (dynres) */ #include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ @@ -1362,11 +1363,11 @@ static int video_scale_ceiling(void) { return g_video_renderer == 1 ? GL_MAX_INTERNAL_SCALE : SW_MAX_INTERNAL_SCALE; } -/* The preset in effect this run: the environment override, else the - * configured one. */ +/* Session priority: environment override, active mod request, player setting. */ static int effective_internal_resolution(void) { + const uint32_t mod_lines = psx_mod_internal_resolution_request(); return g_video_internal_res_env != PSX_IR_UNSET ? g_video_internal_res_env - : g_video_internal_res; + : mod_lines ? (int)mod_lines : g_video_internal_res; } /* Resolve the Internal resolution preset into g_video_scale. display_px_h is @@ -1703,6 +1704,7 @@ extern "C" int psx_mod_set_adaptive_display_aspect( static PSXModSessionBaseline g_mod_owned_baseline; static void reset_mod_owned_presentation(void) { + psx_mod_internal_resolution_reset(); psx_local_mouse_clear(); PSXModSessionScalars live; live.video_vsync = g_video_vsync; diff --git a/runtime/src/mod_internal_resolution.c b/runtime/src/mod_internal_resolution.c new file mode 100644 index 00000000..b272c902 --- /dev/null +++ b/runtime/src/mod_internal_resolution.c @@ -0,0 +1,19 @@ +#include "mod_plugins.h" +#include "mod_internal_resolution.h" + +static uint32_t requested_lines; + +int psx_mod_set_internal_resolution(uint32_t target_lines) { + if (target_lines && (target_lines < 120u || target_lines > 8192u)) + return 0; + requested_lines = target_lines; + return 1; +} + +uint32_t psx_mod_internal_resolution_request(void) { + return requested_lines; +} + +void psx_mod_internal_resolution_reset(void) { + requested_lines = 0; +} diff --git a/runtime/tests/test_mod_internal_resolution.c b/runtime/tests/test_mod_internal_resolution.c new file mode 100644 index 00000000..f33ce426 --- /dev/null +++ b/runtime/tests/test_mod_internal_resolution.c @@ -0,0 +1,25 @@ +#include "mod_plugins.h" +#include "mod_internal_resolution.h" +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "Failed: %s\n", #x); return 1; } } while (0) + +int main(void) { + /* Disabled sessions retain the configured baseline; a later launch must + * not inherit an earlier active graphics mod's requested height. */ + CHECK(psx_mod_internal_resolution_request() == 0u); + CHECK(psx_mod_set_internal_resolution(1080u)); + CHECK(psx_mod_internal_resolution_request() == 1080u); + CHECK(!psx_mod_set_internal_resolution(119u)); + CHECK(!psx_mod_set_internal_resolution(8193u)); + CHECK(!psx_mod_set_internal_resolution(UINT32_MAX)); + CHECK(psx_mod_internal_resolution_request() == 1080u); + psx_mod_internal_resolution_reset(); + CHECK(psx_mod_internal_resolution_request() == 0u); + CHECK(psx_mod_set_internal_resolution(120u)); + CHECK(psx_mod_set_internal_resolution(8192u)); + CHECK(psx_mod_set_internal_resolution(0u)); + CHECK(psx_mod_internal_resolution_request() == 0u); + puts("session-scoped mod resolution validation passed"); + return 0; +} From ee56f790875aa4377f648273c9072af005620058 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:25:25 -0700 Subject: [PATCH 02/21] Mod API: title-supplied host rumble (psx_mod_set_host_rumble) Some titles compute vibration for every pad but only send it over SIO to a DualShock (R4 with a presented NeGcon). A trusted mod can now hand the runtime DualShock motor values per seat; the host pad gets the louder of that and the guest's SIO motors. Values lapse 8 VBlanks after the last call and are cleared at every session reset, so nothing can stick on. Opt-in: no title that does not call it changes. PSX_RUMBLE_TRACE now also logs headless. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 7 +++++ runtime/CMakeLists.txt | 5 ++++ runtime/include/mod_plugins.h | 9 +++++++ runtime/include/psx_host_rumble.h | 44 +++++++++++++++++++++++++++++++ runtime/src/main.cpp | 24 ++++++++++++++++- runtime/tests/test_host_rumble.c | 37 ++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 runtime/include/psx_host_rumble.h create mode 100644 runtime/tests/test_host_rumble.c diff --git a/docs/config_schema.md b/docs/config_schema.md index cd9141f3..81891d98 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -810,6 +810,13 @@ has), and a title-allowed direct Rewind button (`psx_mod_allow_direct_shortcut`) is not claimed, so it reaches the game as if Rewind were off. The block clears at every mod/session reset; nothing changes for a title that never calls it. +A title that computes vibration but cannot send it over SIO to the presented +pad (a NeGcon has no motors) can call `psx_mod_set_host_rumble(player, small, +large)` every frame with DualShock motor values. The runtime drives the host +pad with the louder of that and the guest's SIO motors; a value lapses 8 +VBlanks after the last call and all are cleared at every mod/session reset. +`PSX_RUMBLE_TRACE=1` logs every change, headless included. + Bezel artwork is intentionally not a `[video]` key. It is exposed as the disabled-by-default `psx.presentation.bezel` mod package, which draws a user-selected image resource behind the game image in OpenGL letterbox or diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e6ae0f5f..ab0ca926 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -609,6 +609,11 @@ if(BUILD_TESTING) target_include_directories(psx_pad_apply_test PRIVATE include) add_test(NAME psx_pad_apply_test COMMAND psx_pad_apply_test) + # Title-supplied host rumble (psx_mod_set_host_rumble) merge and expiry. + add_executable(host_rumble_test tests/test_host_rumble.c) + target_include_directories(host_rumble_test PRIVATE include) + add_test(NAME host_rumble_test COMMAND host_rumble_test) + add_executable(controller_type_test tests/test_controller_type.cpp) target_include_directories(controller_type_test PRIVATE include) add_test(NAME controller_type_test COMMAND controller_type_test) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 353d298b..6ef289f6 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -1006,6 +1006,15 @@ int psx_mod_allow_direct_shortcut(uint32_t shortcut); * title's state is fine). Cleared at every mod/session reset. */ void psx_mod_set_rewind_blocked(int blocked); +/* Title-supplied host rumble for one player (0-based seat): DualShock motor + * values (small: 0 off / nonzero on, large: 0..255) for a title that + * computes vibration but cannot send it over SIO to the presented pad (e.g. a + * NeGcon has no motors). The runtime drives the host pad with the louder of + * this and the guest's own SIO motors. Opt-in: call every frame while it + * applies; a value lapses 8 VBlanks after the last call, and all are cleared + * at every mod/session reset (psx_host_rumble.h). Returns 0 for a bad seat. */ +int psx_mod_set_host_rumble(uint32_t player, uint32_t small, uint32_t large); + /* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner * (main) thread, owns relative capture and folds the resulting right-stick * bytes after native input/presentation (and after any controller source), diff --git a/runtime/include/psx_host_rumble.h b/runtime/include/psx_host_rumble.h new file mode 100644 index 00000000..9016d4ad --- /dev/null +++ b/runtime/include/psx_host_rumble.h @@ -0,0 +1,44 @@ +/* psx_host_rumble.h - title-supplied host rumble (psx_mod_set_host_rumble). + * + * Some titles compute vibration for every pad but only send it over SIO to a + * DualShock (R4 with a presented NeGcon keeps running its vibration patterns + * and writes them nowhere useful). A trusted mod can hand the runtime the + * DualShock-equivalent motor values instead; the runtime forwards the louder + * of that and the guest's own SIO motors to the host pad. A value lapses + * PSX_HOST_RUMBLE_TTL VBlanks after the last call, so a mod that stops + * calling (menus, unloaded) can never leave a pad vibrating. Pure, so tests + * link it directly. Nothing changes for a title that never calls it. */ +#ifndef PSX_HOST_RUMBLE_H +#define PSX_HOST_RUMBLE_H + +#include + +#define PSX_HOST_RUMBLE_TTL 8u + +typedef struct PsxHostRumbleSlot { + uint8_t small; /* DualShock small motor: 0 off, nonzero on */ + uint8_t large; /* DualShock large motor strength 0..255 */ + uint8_t valid; + uint32_t stamp; /* VBlank of the last call */ +} PsxHostRumbleSlot; + +static inline void psx_host_rumble_set(PsxHostRumbleSlot *slot, uint32_t now, + uint32_t small, uint32_t large) { + if (!slot) return; + slot->small = small ? 1u : 0u; + slot->large = (uint8_t)(large > 255u ? 255u : large); + slot->valid = 1u; + slot->stamp = now; +} + +/* Merge into the guest's SIO motor values (in/out). */ +static inline void psx_host_rumble_merge(const PsxHostRumbleSlot *slot, + uint32_t now, uint8_t *small, + uint8_t *large) { + if (!slot || !slot->valid || !small || !large) return; + if ((uint32_t)(now - slot->stamp) >= PSX_HOST_RUMBLE_TTL) return; + if (slot->small && !*small) *small = 0xFFu; + if (slot->large > *large) *large = slot->large; +} + +#endif /* PSX_HOST_RUMBLE_H */ diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c7990bd6..004c1af7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -64,6 +64,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "frame_pacing.h" #include "latency_ring.h" #include "sio.h" +#include "psx_host_rumble.h" #ifndef PSX_MAX_PLAYERS #define PSX_MAX_PLAYERS 2 #endif @@ -1427,6 +1428,18 @@ extern "C" int psx_mod_allow_direct_shortcut(uint32_t shortcut) { extern "C" void psx_mod_set_rewind_blocked(int blocked) { psx_rewind_set_title_blocked(blocked); } + +/* psx_mod_set_host_rumble: per-seat title rumble, stamped with the rumble + * VBlank so a value the mod stops refreshing lapses (psx_host_rumble.h). */ +static PsxHostRumbleSlot g_mod_rumble[PSX_MAX_PLAYERS]; +static uint32_t g_rumble_vblank = 0; + +extern "C" int psx_mod_set_host_rumble(uint32_t player, uint32_t small, + uint32_t large) { + if (player >= PSX_MAX_PLAYERS) return 0; + psx_host_rumble_set(&g_mod_rumble[player], g_rumble_vblank, small, large); + return 1; +} static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ @@ -5219,11 +5232,18 @@ static void update_controller_rumble(void) { const char* e = std::getenv("PSX_RUMBLE_TRACE"); return e && e[0] && e[0] != '0'; }(); + ++g_rumble_vblank; for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; uint8_t small = 0, large = 0; sio_get_pad_rumble(s, &small, &large); + psx_host_rumble_merge(&g_mod_rumble[s], g_rumble_vblank, &small, &large); if (!p.handle) { + if (trace && (!p.rumble_known || p.rumble_small != small || + p.rumble_large != large)) + std::fprintf(stdout, + "psxrecomp rumble: slot=%d small=%u large=%u host=none\n", + s + 1, (unsigned)small, (unsigned)large); p.rumble_small = small; p.rumble_large = large; p.rumble_known = true; @@ -8441,7 +8461,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { psx_selfcheck_finish_frame( (g_offline_pad_count >= 3 && !sio_get_multitap()) ? 1 : 0); } - if (!g_headless) update_controller_rumble(); + /* Headless has no pads; PSX_RUMBLE_TRACE still logs what would be sent. */ + if (!g_headless || std::getenv("PSX_RUMBLE_TRACE")) update_controller_rumble(); /* Latency ring: open this present cycle's slot, stamping when input was * sampled into SIO. Always-on; queried via the debug server "latency". */ @@ -18139,6 +18160,7 @@ int main(int argc, char** argv) { mod_controller_source_reset(); mod_pad_transform_reset(); g_direct_shortcut_allowed = 0; + std::memset(g_mod_rumble, 0, sizeof g_mod_rumble); g_p1_claimed_buttons = 0; psx_rewind_set_title_blocked(0); for (auto& policy : g_mod_controller_policy) diff --git a/runtime/tests/test_host_rumble.c b/runtime/tests/test_host_rumble.c new file mode 100644 index 00000000..87419b65 --- /dev/null +++ b/runtime/tests/test_host_rumble.c @@ -0,0 +1,37 @@ +/* psx_host_rumble.h: title-supplied rumble merge and expiry. */ +#include +#include "psx_host_rumble.h" + +static int s_fail; +#define CHECK(c, m) do { if (!(c)) { fprintf(stderr, "FAIL %s\n", m); ++s_fail; } } while (0) + +int main(void) { + PsxHostRumbleSlot slot = {0, 0, 0, 0}; + uint8_t small = 0, large = 0; + psx_host_rumble_merge(&slot, 5, &small, &large); + CHECK(small == 0 && large == 0, "never set: guest motors unchanged"); + + psx_host_rumble_set(&slot, 10, 1, 150); + psx_host_rumble_merge(&slot, 10, &small, &large); + CHECK(small == 0xFF && large == 150, "set: small on, large 150"); + + small = 0; large = 200; + psx_host_rumble_merge(&slot, 12, &small, &large); + CHECK(large == 200 && small == 0xFF, "louder guest large motor wins"); + + small = 0; large = 0; + psx_host_rumble_merge(&slot, 10 + PSX_HOST_RUMBLE_TTL - 1, &small, &large); + CHECK(large == 150, "still live just before the TTL"); + small = 0; large = 0; + psx_host_rumble_merge(&slot, 10 + PSX_HOST_RUMBLE_TTL, &small, &large); + CHECK(small == 0 && large == 0, "lapses after the TTL without a refresh"); + + psx_host_rumble_set(&slot, 0xFFFFFFFEu, 0, 999); + small = 0; large = 0; + psx_host_rumble_merge(&slot, 1u, &small, &large); + CHECK(large == 255 && small == 0, "clamped to 255; VBlank wrap handled"); + + if (s_fail) return 1; + puts("host rumble tests passed"); + return 0; +} From e52679771ad8ab26a4a94072aca708cb424b529b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:44:30 -0700 Subject: [PATCH 03/21] Host rumble online: local port only, never during a resim In a netplay match psx_mod_set_host_rumble now takes only this peer's own guest port (psx_netplay_local_port) and drives this peer's input pad with it; other ports and calls made while rolling back are ignored, so a netplay = "host_output" plugin can report every port each tick. Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 8 +++++--- runtime/src/main.cpp | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 6ef289f6..5c5784f7 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -1006,12 +1006,14 @@ int psx_mod_allow_direct_shortcut(uint32_t shortcut); * title's state is fine). Cleared at every mod/session reset. */ void psx_mod_set_rewind_blocked(int blocked); -/* Title-supplied host rumble for one player (0-based seat): DualShock motor +/* Title-supplied host rumble for one guest port (0-based): DualShock motor * values (small: 0 off / nonzero on, large: 0..255) for a title that * computes vibration but cannot send it over SIO to the presented pad (e.g. a * NeGcon has no motors). The runtime drives the host pad with the louder of - * this and the guest's own SIO motors. Opt-in: call every frame while it - * applies; a value lapses 8 VBlanks after the last call, and all are cleared + * this and the guest's own SIO motors. Online only the local seat's value + * is used (this peer's local port, on its pad) and calls during a rollback resim are + * ignored, so a netplay = "host_output" plugin can call it for every seat. + * Opt-in: call every frame while it applies; a value lapses 8 VBlanks after the last call, and all are cleared * at every mod/session reset (psx_host_rumble.h). Returns 0 for a bad seat. */ int psx_mod_set_host_rumble(uint32_t player, uint32_t small, uint32_t large); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 004c1af7..8509df1b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1437,6 +1437,15 @@ static uint32_t g_rumble_vblank = 0; extern "C" int psx_mod_set_host_rumble(uint32_t player, uint32_t small, uint32_t large) { if (player >= PSX_MAX_PLAYERS) return 0; + if (psx_netplay_active()) { + /* Online: only this peer's own guest port drives its own pad, once per + * real tick (a rollback resim replays ticks the pad already felt). */ + if (psx_netplay_is_resimulating() || + (int)player != psx_netplay_local_port()) + return 1; + const int card = psx_netplay_input_player(); + player = card >= 0 && card < PSX_MAX_PLAYERS ? (uint32_t)card : 0u; + } psx_host_rumble_set(&g_mod_rumble[player], g_rumble_vblank, small, large); return 1; } From 46452e1e2c489a90e5dc205e2d54c98cf8e25f84 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:35:08 -0700 Subject: [PATCH 04/21] Launcher V-Sync row round-trips to [video] vsync The PSX launcher profile offers a V-Sync row (has_vsync) but the host never seeded RecompLauncherCSettings.vsync or read it back, so the row changed nothing. Map [video] vsync (1 on, 0 off, -1 adaptive, 2 VRR) to the row (On / Off / Adaptive) and back; VRR shows as On and is kept unless changed. Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c7990bd6..8e09567d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -17372,6 +17372,14 @@ int main(int argc, char** argv) { ls.scanlines = seed.scanlines ? 1 : 0; ls.scanline_strength_pct = seed.has_scanline_strength ? (int)(seed.scanline_strength * 100.0 + 0.5) : 50; +#endif +#if defined(RECOMP_LAUNCHER_VSYNC_ADAPTIVE) + /* [video] vsync (1 on, 0 off/immediate, -1 adaptive, 2 VRR) <-> + * the launcher's V-Sync row (1 On, 2 Off, 3 Adaptive). VRR has no + * row; it shows as On and is kept unless the player changes it. */ + ls.vsync = g_video_vsync == 0 ? RECOMP_LAUNCHER_VSYNC_OFF + : g_video_vsync == -1 ? RECOMP_LAUNCHER_VSYNC_ADAPTIVE + : RECOMP_LAUNCHER_VSYNC_ON; #endif ls.frame_interp = seed.frame_interpolation ? 1 : 0; ls.frame_interp_fps = seed.frame_interpolation_fps; @@ -17727,6 +17735,16 @@ int main(int argc, char** argv) { seed.scanline_strength = ls.scanline_strength_pct / 100.0; seed.has_scanline_strength = true; } +#endif +#if defined(RECOMP_LAUNCHER_VSYNC_ADAPTIVE) + if (ls.vsync >= RECOMP_LAUNCHER_VSYNC_ON && ls.vsync <= RECOMP_LAUNCHER_VSYNC_ADAPTIVE) { + const int was = g_video_vsync; + g_video_vsync = ls.vsync == RECOMP_LAUNCHER_VSYNC_OFF ? 0 + : ls.vsync == RECOMP_LAUNCHER_VSYNC_ADAPTIVE ? -1 + : (was == 2 ? 2 : 1); + seed.vsync = g_video_vsync; + seed.has_vsync = true; + } #endif seed.frame_interpolation = ls.frame_interp != 0; seed.has_frame_interpolation = true; seed.frame_interpolation_fps = ls.frame_interp_fps; seed.has_frame_interpolation_fps = true; 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 05/21] 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; +} 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 06/21] 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; +} From a069f1ca0c50d0be4621412d58fe8753791551fa Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:31:47 -0700 Subject: [PATCH 07/21] Netplay rb: followers apply the dual-initiation tie-break With three or more seats two guests can mispredict the same tick and both BEGIN. The tie-break (lower initiator slot wins) ran only on initiators, so a follower followed whichever BEGIN arrived first and stashed the rest. On a 4-seat WAN race the host followed slot 3's epoch while slot 1's won; the seats split across epochs, each waited for baselines that never came, and the match ended after 20 s at rb_baseline. The epoch id names its initiator, so a follower now applies the same rule to the episode it follows (netplay_rb_concurrent_begin): yield during the handshake and follow the winner, or drop the loser's BEGIN. A follower seeing a new epoch from the initiator it already follows (abort and reopen, the only case two seats can produce) is unchanged. Co-Authored-By: Claude Opus 5.5 --- runtime/include/netplay_peer_frontier.h | 28 ++++++++++++++++++++++ runtime/src/psx_netplay_rb.c | 17 ++++++++++--- runtime/tests/test_netplay_peer_frontier.c | 14 +++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/runtime/include/netplay_peer_frontier.h b/runtime/include/netplay_peer_frontier.h index 2d2fb9d0..6ec78f32 100644 --- a/runtime/include/netplay_peer_frontier.h +++ b/runtime/include/netplay_peer_frontier.h @@ -80,4 +80,32 @@ static inline int netplay_fork_cap_retired(uint32_t fork_cap, uint32_t agreed_th return fork_cap > 0u && agreed_through >= fork_cap; } +/* Concurrent BEGIN while an episode is active. Wire epoch ids carry their + * initiator in the low bits (`slot_mask`); the lower initiator slot wins. + * Returns +1 when the incoming episode wins (yield ours if still in the + * handshake, then follow it), -1 when ours wins (drop the incoming BEGIN), + * 0 when the rule does not apply (same epoch, or a follower seeing another + * epoch from the initiator it already follows -- an abort-and-reopen). + * + * A follower applies it too: with three or more seats two guests can both + * BEGIN on the same mispredict, and a follower that followed whichever + * arrived first split the seats across epochs (4-seat WAN: host following + * slot 3's epoch while slot 1's won; every seat then waited on baselines + * that never came). */ +static inline int netplay_rb_concurrent_begin(uint32_t ours, uint32_t theirs, + int following, uint32_t slot_mask) +{ + uint32_t our_slot = ours & slot_mask; + uint32_t their_slot = theirs & slot_mask; + if (ours == theirs) + return 0; + if (following && our_slot == their_slot) + return 0; + if (their_slot < our_slot) + return 1; + if (their_slot > our_slot) + return -1; + return 0; +} + #endif /* NETPLAY_PEER_FRONTIER_H */ diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 61492900..90a609ac 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -8578,9 +8578,20 @@ static void begin_follower(uint32_t epoch, uint32_t mismatch, uint32_t load, uin * after local snap apply. Gating on !snap_applied left the loser * stashing the winner's BEGIN forever while both sides waited on * peer baseline (2026-08-02 soak: epoch 16 vs 17, 404× WIN drop). - * Replay/Verify still refuse — abort/NACK owns those. */ - if (g_rb && rnet_rb_is_active(g_rb) && !rnet_rb_is_from_peer_notify(g_rb) && - epoch != rnet_rb_get_epoch_id(g_rb)) { + * Replay/Verify still refuse — abort/NACK owns those. + * With three or more seats a FOLLOWER sees the race too: two guests + * mispredict the same tick and both BEGIN. The follower used to follow + * whichever BEGIN landed first and stash the other, so with three + * initiators the seats split across epochs and each waited forever for + * baselines that never came (4-seat WAN: host on epoch 43 from slot 3, + * slot 1 winning with 41). The epoch id names its initiator, so a + * follower applies the same rule to the episode it follows + * (netplay_rb_concurrent_begin). Two seats never reach this for a + * follower: the only other initiator is the one it already follows. */ + if (g_rb && rnet_rb_is_active(g_rb) && + netplay_rb_concurrent_begin(rnet_rb_get_epoch_id(g_rb), epoch, + rnet_rb_is_from_peer_notify(g_rb), + RB_EPOCH_SLOT_MASK) != 0) { uint32_t our_epoch = rnet_rb_get_epoch_id(g_rb); uint32_t our_slot = our_epoch & RB_EPOCH_SLOT_MASK; uint32_t their_slot = epoch & RB_EPOCH_SLOT_MASK; diff --git a/runtime/tests/test_netplay_peer_frontier.c b/runtime/tests/test_netplay_peer_frontier.c index 72234fa9..4fa958a4 100644 --- a/runtime/tests/test_netplay_peer_frontier.c +++ b/runtime/tests/test_netplay_peer_frontier.c @@ -55,6 +55,20 @@ int main(void) 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)"); + /* Epoch ids: (counter << 3) | initiator slot. */ +#define EP(n, slot) (((uint32_t)(n) << 3) | (uint32_t)(slot)) + CHECK(netplay_rb_concurrent_begin(EP(5, 1), EP(5, 1), 0, 7u) == 0, "same epoch: no contest"); + CHECK(netplay_rb_concurrent_begin(EP(5, 2), EP(5, 1), 0, 7u) == 1, + "initiator: lower peer slot wins, we yield"); + CHECK(netplay_rb_concurrent_begin(EP(5, 1), EP(5, 2), 0, 7u) == -1, + "initiator: higher peer slot loses, drop it"); + CHECK(netplay_rb_concurrent_begin(EP(5, 3), EP(5, 1), 1, 7u) == 1, + "follower of slot 3 switches to slot 1 (4-seat WAN split)"); + CHECK(netplay_rb_concurrent_begin(EP(5, 1), EP(5, 3), 1, 7u) == -1, + "follower of slot 1 drops slot 3's BEGIN"); + CHECK(netplay_rb_concurrent_begin(EP(5, 0), EP(6, 0), 1, 7u) == 0, + "follower: initiator reopened -> not a contest (two-seat path)"); + if (failures) { printf("%d failure(s)\n", failures); return 1; From 55000de820e471e0de9769f42c6f1d4a53bca0a6 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:28:04 -0700 Subject: [PATCH 08/21] Netplay rb: an hc-fork heal is not blocked by an empty bisect window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a resim divergence the §55 fork cap bounds the next load from above and the §62 peer NACK floor bounds it from below. On a 4-seat WAN race the two met (cap 3296, floor 3280): hc-fork recovery refused with "no mutually-loadable snap", nothing else could open an episode, and two pairs of seats finished the race on different states. Bisecting cannot heal that fork, but a MEDIA_KF heal (which hc-fork recovery requests) can: it transfers the initiator's pin to every follower, so any recent mutual snap works. When the window is empty and that heal is requested, choose_load now falls back to the uncapped choice. Co-Authored-By: Claude Opus 5.5 --- runtime/src/psx_netplay_rb.c | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 90a609ac..34f77c4f 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -3928,6 +3928,31 @@ static int choose_load_tick_inner(uint32_t mismatch, uint32_t *out_load) return 0; } +/* The bisect window (below the fork cap, above the peer NACK floor) is + * empty or holds no loadable snap. Bisecting cannot heal the fork, but a + * MEDIA_KF heal can: it transfers the initiator's pin to every follower, so + * any recent mutual snap works. Without this an hc-fork recovery on a + * 4-seat WAN race refused for the rest of the session (cap 3296, floor 3280: + * "no mutually-loadable snap") and two pairs of seats finished the race on + * different states. */ +static int choose_load_fork_cap_heal_bypass(uint32_t mismatch, uint32_t *out_load) +{ + static uint32_t s_log_cap; + if (!rb_media_kf_enabled() || !(g_request_post_fmv_heal || g_post_fmv_heal_kf)) + return 0; + if (!choose_load_tick_inner(mismatch, out_load)) + return 0; + if (s_log_cap != g_bl_fork_cap) { + fprintf(stderr, + "psxrecomp: rb choose_load fork cap %u bypass load=%u " + "(bisect window empty; MEDIA_KF heal transfers the pin)\n", + (unsigned)g_bl_fork_cap, (unsigned)*out_load); + fflush(stderr); + s_log_cap = g_bl_fork_cap; + } + return 1; +} + static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) { /* §55: after a baseline core mismatch at load L the fork provably predates @@ -3938,7 +3963,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) if (m > g_bl_fork_cap) m = g_bl_fork_cap; /* inner's shared walk returns load < m */ if (!choose_load_tick_inner(m, out_load)) - return 0; + return choose_load_fork_cap_heal_bypass(mismatch, out_load); /* §62: peer proved it cannot follow ≤ floor (snap evicted). If the * bisect window (must be < fork cap AND > peer floor) is empty, no * mutually-loadable snap covers the fork — refuse to open rather @@ -3954,7 +3979,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) fflush(stderr); s_floor_log = *out_load; } - return 0; + return choose_load_fork_cap_heal_bypass(mismatch, out_load); } if (*out_load >= g_bl_fork_cap) { static uint32_t s_refuse_log; @@ -3966,7 +3991,7 @@ static int choose_load_tick(uint32_t mismatch, uint32_t *out_load) fflush(stderr); s_refuse_log = g_bl_fork_cap; } - return 0; + return choose_load_fork_cap_heal_bypass(mismatch, out_load); } { static uint32_t s_bisect_log; From 09ac81e920b623765db0a92d4459789c0fca7a94 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:57:46 -0700 Subject: [PATCH 09/21] Netplay rb: resim audit prints sealed analog bytes Equal buttons hid sealed-row stick differences when chasing a 4-seat resim divergence (36 guest cycles on the last replayed tick). Co-Authored-By: Claude Opus 5.5 --- runtime/src/psx_netplay_rb.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 34f77c4f..415f9983 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -4794,6 +4794,12 @@ static void log_resim_tick_audit(uint32_t sim, const char *tag) if (rnet_rb_get_sealed_frame(g_rb, slot, sim, &row) && row.is_valid) fprintf(stderr, " s%d=%04x%s", slot, (unsigned)row.buttons, row.analog ? "A" : ""); + /* Analog bytes too: equal buttons hid a sealed-row stick difference. */ + if (rnet_rb_get_sealed_frame(g_rb, slot, sim, &row) && row.is_valid && + row.analog) + fprintf(stderr, ":%02x%02x%02x%02x", (unsigned)(uint8_t)row.stick_x, + (unsigned)(uint8_t)row.stick_y, (unsigned)row.rx, + (unsigned)row.ry); else fprintf(stderr, " s%d=----", slot); } From b55d92124dce6c5aaabc310a8a4911331dd11ffd Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:03:49 -0700 Subject: [PATCH 10/21] GL: opt-in xBR edge-directed texture filter (texture_filtering = "xbr") Filter mode 3 magnifies textures with an edge-directed rule after Hyllian's xBR (written from the published description, no code reused): for the texel corner nearest a sample, weighted YUV colour distances over a 5x5 neighbourhood decide whether an edge runs along the corner's diagonal; if so the corner is cut with an anti-aliased boundary and takes the closer side neighbour. Flat areas, straight edges, gradients, cutouts and 1:1 draws stay nearest. Selectable from [video] texture_filtering, settings.toml, PSX_TEXTURE_FILTER and the new texture_filter TCP command. Default unchanged. Co-Authored-By: Claude Opus 5.5 --- THIRD_PARTY_ATTRIBUTION.md | 30 ++++++++ docs/TCP_COMMANDS.md | 5 +- docs/config_schema.md | 22 ++++++ generated/OpenBIOS.emitter.sha | 2 +- recompiler/src/config_loader.cpp | 6 +- recompiler/src/config_loader.h | 1 + runtime/CMakeLists.txt | 12 +++ runtime/src/debug_server.c | 10 +++ runtime/src/gpu_gl_renderer.c | 51 ++++++++++++- runtime/src/main.cpp | 5 +- runtime/tests/test_gl_xbr.c | 122 +++++++++++++++++++++++++++++++ 11 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 runtime/tests/test_gl_xbr.c diff --git a/THIRD_PARTY_ATTRIBUTION.md b/THIRD_PARTY_ATTRIBUTION.md index ccbe9a2c..64ee340a 100644 --- a/THIRD_PARTY_ATTRIBUTION.md +++ b/THIRD_PARTY_ATTRIBUTION.md @@ -58,6 +58,36 @@ into `licenses/`. Developers can use the verified archive or an explicit `FETCHCONTENT_SOURCE_DIR_PSX_LIBWEBP` / `PSX_LIBWEBP_SOURCE_DIR` source override. PNG and JPEG continue to use the existing shared stb_image implementation. +## xBR — edge-directed texture filter (Hyllian) + +The `texture_filtering = "xbr"` mode in `runtime/src/gpu_gl_renderer.c` +(`xbr_d`, `xbr_sample`) follows Hyllian's xBR algorithm: its 5x5 neighbourhood, +YUV-weighted colour distance and the `4*d(H,F)` / `4*d(E,I)` edge sums. It is +an adaptation (CLUT texel fetches, primitive UV clamping, an anti-aliased corner +cut), not a copy of a shader file, but it derives from that work, licensed MIT: + +``` +Copyright (C) 2011-2016 Hyllian - sergiogdb@gmail.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + ## Vendored libraries These are checked in under `recompiler/lib/` and `runtime/third_party/` with diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index a2cf15bb..2cde629f 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -486,9 +486,9 @@ between Play and TCP availability. ## Complete command index (generated) -**367 commands registered** — 354 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**368 commands registered** — 355 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -82 of 367 have prose above; **285 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +82 of 368 have prose above; **286 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -799,6 +799,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `stereo_dump` | ✓ | | | | `stereo_stats` | ✓ | | | | `synth_recurse` | ✓ | | | +| `texture_filter` | ✓ | | | | `texture_lod` | ✓ | | ✓ | | `thread_ctx_ring` | ✓ | | | | `thread_trace` | ✓ | | | diff --git a/docs/config_schema.md b/docs/config_schema.md index cd9141f3..9d262db7 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -698,6 +698,28 @@ keep their exact texels, and `off` is the historical path. Cost is up to 64 fetches per minified pixel. OpenGL only. `PSX_TEXTURE_LOD=0|1` and `PSX_ANISO=N` override them for one run; the `texture_lod` TCP command switches them live. +### Texture filtering (`texture_filtering`) + +```toml +[video] +texture_filtering = "nearest" # nearest (default) | bilinear | xbr +``` + +`nearest` is the PS1's point sampling. `bilinear` blends the four nearest +texels (opacity-gated, so cutout edges keep their colour). `xbr` (OpenGL +only) is edge-directed magnification after Hyllian's xBR (MIT; see +`THIRD_PARTY_ATTRIBUTION.md`): for the texel +corner nearest each sample it compares weighted colour distances over a 5x5 +neighbourhood, and where an edge runs along that corner's diagonal the corner +is cut and takes the closer side neighbour, with an anti-aliased boundary one +output pixel wide. Flat areas, straight edges and gradients stay crisp +texels; diagonals and curves lose their staircase. It acts only where a +texel spans two or more output pixels (high internal scale or a magnified +texture), so a 1:1 draw is exactly nearest; cutouts and STP stay the centre +texel's. It costs up to 13 texel fetches per pixel. JINC2 is not offered. +`PSX_TEXTURE_FILTER=0..3` overrides it for one run (2 is the mod-only "stable +world" minification filter) and the `texture_filter` TCP command switches it +live. The launcher's toggle still offers nearest and bilinear. The runtime clamps N per backend: diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index f361b1b8..858e9e48 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -27437f9269d367ba4ad2e17fc66f3523c2c1e6fcd2ee5a39f6c07ee6516c84a2 \ No newline at end of file +35fe14259a39a6d39ecb53fc996bd16455934f90a805dd3062d14bf03cc028de diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 85fcf467..c1af57f5 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -776,8 +776,9 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& const auto mode = toml::find(video, "texture_filtering"); if (mode == "nearest") rt.video_texture_filter = 0; else if (mode == "bilinear") rt.video_texture_filter = 1; + else if (mode == "xbr") rt.video_texture_filter = 3; else throw std::runtime_error(fmt::format( - "[video] texture_filtering must be \"nearest\" or \"bilinear\": {}", mode)); + "[video] texture_filtering must be \"nearest\", \"bilinear\" or \"xbr\": {}", mode)); } if (video.contains("fmv_filter")) { const auto mode = toml::find(video, "fmv_filter"); @@ -2867,6 +2868,7 @@ UserSettings load_user_settings(const fs::path& path) { const auto m = toml::find(v, "texture_filtering"); if (m == "nearest") { s.texture_filter = 0; s.has_texture_filter = true; } else if (m == "bilinear") { s.texture_filter = 1; s.has_texture_filter = true; } + else if (m == "xbr") { s.texture_filter = 3; s.has_texture_filter = true; } }); if (v.contains("fmv_filter")) try_get([&]{ const auto m = toml::find(v, "fmv_filter"); @@ -3268,7 +3270,7 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { if (s.has_antialiasing) f << "antialiasing = " << (s.antialiasing ? "true" : "false") << "\n"; if (s.has_texture_filter) - f << "texture_filtering = \"" << (s.texture_filter ? "bilinear" : "nearest") << "\"\n"; + f << "texture_filtering = \"" << (s.texture_filter == 3 ? "xbr" : s.texture_filter ? "bilinear" : "nearest") << "\"\n"; if (s.has_fmv_filter) f << "fmv_filter = \"" << video_fmv_filter_name(s.fmv_filter) << "\"\n"; if (s.has_geometry_correction) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 4e3461c1..eb8ac4ab 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -460,6 +460,7 @@ struct RuntimeConfig { int video_supersample_milli = 1000; // texture_filtering: "nearest" (default, native PSX look) | "bilinear" + // | "xbr" (3: edge-directed magnification, OpenGL only) // (smooths textures and 2D backgrounds). Stored as 0/1. int video_texture_filter = 0; diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index ab2abb6b..40ef7653 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -322,6 +322,18 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") set_tests_properties(gl_texture_lod_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) + # texture_filtering = "xbr": diagonals lose the staircase, straight + # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. + add_test(NAME gl_xbr_filter_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") + set_tests_properties(gl_xbr_filter_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) endif() add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 391248c4..0b5191d0 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -8629,6 +8629,15 @@ static void handle_texture_lod(int id, const char *json) gl_renderer_texture_lod(), gl_renderer_anisotropy()); } +/* texture_filter: {"cmd":"texture_filter"} reads, {"cmd":"texture_filter", + * "mode":0..3} sets it live (nearest, bilinear, stable world, xbr). */ +static void handle_texture_filter(int id, const char *json) +{ + int mode = json_get_int(json, "mode", -1); + if (mode >= 0) gr_set_texture_filter(mode); + send_fmt("{\"id\":%d,\"ok\":true,\"mode\":%d}", id, gr_texture_filter()); +} + static void handle_frame_perf(int id, const char *json) { (void)json; @@ -15854,6 +15863,7 @@ static const CmdEntry s_commands[] = { { "post_aa", handle_post_aa }, { "fmv_chroma", handle_fmv_chroma }, { "texture_lod", handle_texture_lod }, + { "texture_filter", handle_texture_filter }, { "gl_ws_ablate", handle_gl_ws_ablate }, { "gl_interp", handle_gl_interp }, { "render_pass_stats", handle_render_pass_stats }, diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index d05df396..9cd1fbae 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -1921,7 +1921,7 @@ static const char *TEX_FS = "uniform int u_semipass; /* 0=all texels, 1=STP=0 only, 2=STP=1 only */\n" "uniform int u_semimode; /* PS1 blend mode; drives dual-source factors */\n" "uniform int u_maskset; /* GP0(E6h) set-mask: OR bit15 into output */\n" - "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable; bit 4: LOD */\n" + "uniform int u_filter; /* bits 0-3: 0 nearest, 1 bilinear, 2 stable, 3 xbr; bit 4: LOD */\n" "uniform int u_aniso; /* [video] anisotropic_filtering, 1..16 */\n" "uniform float u_shift;\n" "int vram_at(int x, int y){\n" @@ -2015,6 +2015,47 @@ static const char *TEX_FS = " }\n" " return sum.a > 0.0 ? sum.rgb / sum.a : base;\n" "}\n" + /* [video] texture_filtering = "xbr": edge-directed magnification after + * Hyllian's xBR (MIT; notice in THIRD_PARTY_ATTRIBUTION.md): the same + * E/F/H/I neighbourhood and 4x-weighted edge sums, adapted to CLUT + * texels and an anti-aliased corner cut. For the + * texel corner nearest the sample, two weighted colour-distance sums + * across a 5x5 neighbourhood decide whether an edge runs along the + * corner's anti-diagonal; if so, the corner is cut by that diagonal and + * takes the closer of the two side neighbours, with an anti-aliased + * boundary one output pixel wide. Smooth gradients and straight edges are + * left as nearest texels, so pixel art keeps its crisp blocks while + * diagonals and curves lose their staircase. Transparent (0) texels never + * supply a colour; cutout and STP stay the centre texel's. */ + "vec3 xbr_c(int raw){ return col5(raw); }\n" + "float xbr_d(int a, int b){\n" + " vec3 x = xbr_c(a), y = xbr_c(b);\n" + " vec3 dv = x - y;\n" + " float yy = dot(dv, vec3(0.299, 0.587, 0.114));\n" + " float u = dot(dv, vec3(-0.169, -0.331, 0.5)), v = dot(dv, vec3(0.5, -0.419, -0.081));\n" + " return 48.0*abs(yy) + 7.0*abs(u) + 6.0*abs(v) + (((a==0)!=(b==0)) ? 64.0 : 0.0);\n" + "}\n" + "int xbr_at(ivec2 e, ivec2 s, int x, int y){ return stable_texel(e + ivec2(x*s.x, y*s.y)); }\n" + "vec3 xbr_sample(vec2 uv, int e_raw, vec2 fw){\n" + " ivec2 e = ivec2(floor(uv)); vec2 f = fract(uv);\n" + " ivec2 s = ivec2(f.x < 0.5 ? -1 : 1, f.y < 0.5 ? -1 : 1);\n" + " vec2 l = vec2(s.x > 0 ? f.x : 1.0 - f.x, s.y > 0 ? f.y : 1.0 - f.y); /* 0.5..1 toward the corner */\n" + " int E=e_raw;\n" + " if (max(fw.x, fw.y) >= 0.5) return xbr_c(E); /* under 2x: stay nearest, no fetches */\n" + " int F=xbr_at(e,s,1,0), H=xbr_at(e,s,0,1);\n" + " if (E == F || E == H) return xbr_c(E);\n" + " int I=xbr_at(e,s,1,1);\n" + " int B=xbr_at(e,s,0,-1), C=xbr_at(e,s,1,-1), D=xbr_at(e,s,-1,0), G=xbr_at(e,s,-1,1);\n" + " int F4=xbr_at(e,s,2,0), I4=xbr_at(e,s,2,1), H5=xbr_at(e,s,0,2), I5=xbr_at(e,s,1,2);\n" + " float we = xbr_d(E,C)+xbr_d(E,G)+xbr_d(I,H5)+xbr_d(I,F4)+4.0*xbr_d(H,F);\n" + " float wi = xbr_d(H,D)+xbr_d(H,I5)+xbr_d(F,I4)+xbr_d(F,B)+4.0*xbr_d(E,I);\n" + " if (!(we < wi)) return xbr_c(E);\n" + " int px = xbr_d(E,F) <= xbr_d(E,H) ? F : H;\n" + " if (px == 0) return xbr_c(E);\n" + " float w = max(fw.x + fw.y, 1e-3);\n" + " float a = clamp((l.x + l.y - 1.5) / w + 0.5, 0.0, 1.0);\n" + " return mix(xbr_c(E), xbr_c(px), a);\n" + "}\n" "void main(){\n" " int stp; vec3 rgb;\n" " int fmode = u_filter & 15;\n" @@ -2044,6 +2085,12 @@ static const char *TEX_FS = " if (raw == 0) discard;\n" " rgb = col5(raw);\n" " stp = (raw >> 15) & 1;\n" + " } else if (fmode == 3) {\n" + " /* The nearest texel stays the PS1 one (floor of the unshifted uv). */\n" + " int raw = stable_texel(ivec2(floor(uv)));\n" + " if (raw == 0) discard;\n" + " stp = (raw >> 15) & 1;\n" + " rgb = xbr_sample(uv, raw, abs(dx) + abs(dy));\n" " } else if (fmode == 2) {\n" " uv+=vec2(u_shift); int raw=stable_texel(ivec2(floor(uv)));\n" " if(raw==0) discard; stp=(raw>>15)&1;\n" @@ -4941,7 +4988,7 @@ void gl_renderer_set_texture_lod(int mode, int aniso) { } int gl_renderer_texture_lod(void) { return s_tex_lod; } int gl_renderer_anisotropy(void) { return s_tex_aniso; } -static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 2 ? b : 0; sw_set_texture_filter(b != 0); } +static void glb_set_texture_filter(int b) { s_tex_filter = b >= 0 && b <= 3 ? b : 0; sw_set_texture_filter(b != 0); } static int glb_texture_filter(void) { return s_tex_filter; } static void glb_set_semi_transparency(int e, int m) { s_semi_en = e; s_semi_mode = m & 3; sw_set_semi_transparency(e, m); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index e74a632f..da0e4172 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9489,7 +9489,7 @@ static int debug_toggles_on(void) { } static int s_dbg_scale_target = 0; static const char *dbg_texfilter_name(int f) { - return f == 1 ? "bilinear" : f == 2 ? "stable world" : "nearest"; + return f == 1 ? "bilinear" : f == 2 ? "stable world" : f == 3 ? "xbr" : "nearest"; } static void debug_toggle_summary(char *buf, size_t cap) { GlDynresStats st; gl_renderer_dynres_stats(&st); @@ -9545,7 +9545,7 @@ static int debug_toggle_key(int key, char *out, int cap) { on && !g_render_thread ? " (inert: render thread off)" : ""); break; } case SDLK_5: { - const int f = (gr_texture_filter() + 1) % 3; + const int f = (gr_texture_filter() + 1) % 4; gr_set_texture_filter(f); std::snprintf(msg, sizeof msg, "Texture filter: %s", dbg_texfilter_name(f)); break; } @@ -18417,6 +18417,7 @@ session_reboot: (net_cfg.enabled && s_netplay_gl_present && gl_renderer_cpu_auth_dual()); g_video_scale = gr_scale(); /* reflect any clamp / alloc fallback */ + if (const char* e = std::getenv("PSX_TEXTURE_FILTER")) g_video_texfilter = std::atoi(e); gr_set_texture_filter(g_mod_texfilter < 0 ? g_video_texfilter : g_mod_texfilter); /* Sub-pixel vertex precision + perspective-correct UVs. Both default off; * with both off every setter below leaves the tracking caches disabled and diff --git a/runtime/tests/test_gl_xbr.c b/runtime/tests/test_gl_xbr.c new file mode 100644 index 00000000..5001c967 --- /dev/null +++ b/runtime/tests/test_gl_xbr.c @@ -0,0 +1,122 @@ +/* [video] texture_filtering = "xbr" on a real, hidden OpenGL context, driven + * through the renderer facade (gr_*). + * + * - a 45-degree red/blue edge magnified 8x loses its 8-pixel staircase: the + * boundary column in each row follows the diagonal closely; + * - straight edges and flat areas stay exactly the nearest-texel image, and + * a 1:1 (unmagnified) draw is byte-identical to nearest; + * - cutouts (texel 0) stay cutouts and never lend their colour; + * - the render thread gives the same pixels as the direct path; + * - the other filter modes are unchanged by the new one (nearest after xbr + * restores the exact image). + * Original source-owned scene; no retail payload. */ +#define main rt_fixture_main +#include "test_gl_render_thread.c" +#undef main + +static uint16_t nat[1024 * 512]; +#define TP 0x0108 /* 15-bit page at (512,0) */ + +static void pages(void) { + static uint16_t pg[32 * 16]; + for (int y = 0; y < 16; y++) + for (int x = 0; x < 32; x++) { + uint16_t c; + if (x < 16) c = x > y ? 0x001f : 0x7c00; /* diagonal edge */ + else if (x < 24) c = x < 20 ? 0x001f : 0x7c00; /* vertical edge */ + else c = (x == 28 && y == 8) ? 0x0000 : 0x03e0; /* green with a hole */ + pg[y * 32 + x] = c; + } + cpu_upload(512, 0, 32, 16, pg); +} +/* Each triangle carries GTE provenance (precise vertices), as world geometry + * does, so [video] texture_lod sets its LOD bit on these draws. */ +static void quad(int x, int y, int w, int h, int u0, int v0, int uw, int vh) { + gr_set_precise_triangle(1, x << 16, y << 16, (x + w) << 16, y << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x, y, u0, v0, x + w, y, u0 + uw, v0, x, y + h, u0, v0 + vh, 0, 0, TP); + gr_set_precise_triangle(1, (x + w) << 16, y << 16, (x + w) << 16, (y + h) << 16, x << 16, (y + h) << 16); + gr_draw_textured_triangle(x + w, y, u0 + uw, v0, x + w, y + h, u0 + uw, v0 + vh, x, y + h, u0, v0 + vh, 0, 0, TP); +} +static void scene(void) { + gr_set_draw_area(0, 0, 319, 239); + gr_set_draw_offset(0, 0); + mask_bits(0, 0); + gr_set_semi_transparency(0, 0); + gr_set_color_modulation(128, 128, 128, 1); + gr_fill_rect(0, 0, 320, 240, 0x1084); + quad(0, 0, 128, 128, 0, 0, 16, 16); /* diagonal, 8x */ + quad(140, 0, 64, 128, 16, 0, 8, 16); /* vertical edge, 8x */ + quad(210, 0, 64, 128, 24, 0, 8, 16); /* flat green with a hole, 8x */ + quad(0, 150, 32, 16, 0, 0, 32, 16); /* 1:1 */ + gl_renderer_render_thread_frame_boundary(); + gr_vram_transfer_out(0, 0, 1024, 512, nat); +} +/* Mean distance between each row's first red-dominant column and the ideal + * diagonal (x = y + 0.5 texel, in pixels). */ +static double stair_error(void) { + double e = 0; int n = 0; + for (int y = 4; y < 124; y++) { + int edge = 128; + for (int x = 0; x < 128; x++) { + uint16_t p = nat[y * 1024 + x]; + if ((p & 31) > ((p >> 10) & 31)) { edge = x; break; } + } + e += fabs((double)edge - ((double)y + 4.0)); n++; + } + return e / n; +} +static int region_equal(const uint16_t *a, const uint16_t *b, int x, int y, int w, int h) { + for (int r = y; r < y + h; r++) + if (memcmp(a + r * 1024 + x, b + r * 1024 + x, (size_t)w * 2)) return 0; + return 1; +} + +int main(void) { + if (SDL_Init(SDL_INIT_VIDEO) != 0) { fprintf(stderr, "SKIP no video\n"); return 77; } + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); + SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); +#if defined(__APPLE__) + SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG); +#endif + SDL_Window *win = SDL_CreateWindow("xBR hidden test", 0, 0, 320, 240, + SDL_WINDOW_OPENGL | SDL_WINDOW_HIDDEN); + if (!win) { fprintf(stderr, "SKIP no window\n"); return 77; } + gr_set_backend(GR_BACKEND_OPENGL); + gr_init(vram); + gr_set_scale(1); + gl_renderer_set_swap_interval(0); + if (!gl_renderer_init_context(win)) { fprintf(stderr, "SKIP no GL context\n"); return 77; } + pages(); + static uint16_t ref[1024 * 512], x[1024 * 512]; + gr_set_texture_filter(0); + scene(); memcpy(ref, nat, sizeof ref); + const double e_near = stair_error(); + gr_set_texture_filter(3); + check(gr_texture_filter() == 3, "xbr selectable"); + scene(); memcpy(x, nat, sizeof x); + const double e_xbr = stair_error(); + printf("stair error nearest=%.2f xbr=%.2f\n", e_near, e_xbr); + check(e_xbr < e_near * 0.7, "diagonal staircase smoothed"); + check(region_equal(ref, x, 140, 0, 64, 128), "straight edge unchanged"); + check(region_equal(ref, x, 210, 0, 64, 128), "flat area and cutout unchanged"); + check(region_equal(ref, x, 0, 150, 32, 16), "1:1 draw identical to nearest"); + /* texture_lod adds bit 4 to the filter key; xbr must still run (the + * magnified quads keep their texels under LOD). */ + gl_renderer_set_texture_lod(1, 16); + scene(); + check(memcmp(nat, x, sizeof x) == 0, "xbr unchanged with texture_lod on"); + gl_renderer_set_texture_lod(0, 1); + check(gl_renderer_render_thread_start(2) == 1, "render thread started"); + scene(); + gl_renderer_render_thread_sync("xbr"); + check(memcmp(nat, x, sizeof x) == 0, "render thread gives the same pixels"); + gl_renderer_render_thread_stop(); + gr_set_texture_filter(0); + scene(); + check(memcmp(nat, ref, sizeof ref) == 0, "nearest after xbr restores the exact image"); + check(glGetError() == GL_NO_ERROR, "GL errors"); + printf("checks=%d failures=%d\n", checks, failures); + gl_renderer_shutdown(); SDL_DestroyWindow(win); SDL_Quit(); + return failures ? 1 : 0; +} From c22ac50b9aca0a82f97ff6d65c6b74cb958aa029 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:34:16 -0700 Subject: [PATCH 11/21] xBR: Hyllian MIT attribution, fmode == 3 with the LOD bitfield, early-out before fetches - THIRD_PARTY_ATTRIBUTION.md carries Hyllian's MIT notice; the shader comment says the filter derives from xBR instead of 'written from the published description'. - Rebased on #585: the branch tests fmode == 3 so xBR still runs when the LOD bit is set; gl_xbr_filter_test now draws tracked quads with texture_lod on and fails on the old u_filter == 3 test. - The under-2x early-out runs before any neighbour fetch, and the other ten fetches wait for the E==F/E==H test. - OpenBIOS emitter stamp regenerated. Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 858e9e48..fa951347 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -35fe14259a39a6d39ecb53fc996bd16455934f90a805dd3062d14bf03cc028de +fcb3412a5245142593c6d5cdaabfda1f5d58ff878e09b175fd8aa10dee1554db From ff573219bf2bab8f758114134e70cd7130325fd5 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:07:59 -0700 Subject: [PATCH 12/21] CMake: register the facade GL fixtures where their SDL include and target exist gl_texture_lod_test (#585) and gl_xbr_filter_test sat in the SDL3::SDL3-static block after _gl_scale_sdl_inc was unset, so they got an empty --sdl-include and were not registered where the static target is absent. Move them into the gl_scale_invariance block that defines both. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 50 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 40ef7653..757f33c9 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -262,6 +262,31 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-frame-gen-results") set_tests_properties(gl_frame_gen_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) + # [video] texture_lod / anisotropic_filtering: off and untracked/ + # magnified prims are unchanged; minified world textures average + # inside their UV bounds; render thread and PGXP depth agree. + add_test(NAME gl_texture_lod_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_texture_lod.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") + set_tests_properties(gl_texture_lod_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) + # texture_filtering = "xbr": diagonals lose the staircase, straight + # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. + add_test(NAME gl_xbr_filter_test + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single + --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" + --cc "${CMAKE_C_COMPILER}" + --sdl-include "${_gl_scale_sdl_inc}" + --sdl-library "$" + --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") + set_tests_properties(gl_xbr_filter_test PROPERTIES + LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) unset(_gl_scale_sdl_inc) else() message(STATUS "gl_scale_invariance_test: not registered (needs macOS, Linux or MinGW, Python 3 and SDL3)") @@ -309,31 +334,6 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-fmv-chroma-results") set_tests_properties(gl_fmv_chroma_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) - # [video] texture_lod / anisotropic_filtering: off and untracked/ - # magnified prims are unchanged; minified world textures average - # inside their UV bounds; render thread and PGXP depth agree. - add_test(NAME gl_texture_lod_test - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single - --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_texture_lod.c" - --cc "${CMAKE_C_COMPILER}" - --sdl-include "${_gl_scale_sdl_inc}" - --sdl-library "$" - --output "${CMAKE_CURRENT_BINARY_DIR}/gl-texture-lod-results") - set_tests_properties(gl_texture_lod_test PROPERTIES - LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) - # texture_filtering = "xbr": diagonals lose the staircase, straight - # edges, flat areas, cutouts and 1:1 draws stay nearest; RT agrees. - add_test(NAME gl_xbr_filter_test - COMMAND "${Python3_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_gl_render_thread.py" --single - --fixture "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_gl_xbr.c" - --cc "${CMAKE_C_COMPILER}" - --sdl-include "${_gl_scale_sdl_inc}" - --sdl-library "$" - --output "${CMAKE_CURRENT_BINARY_DIR}/gl-xbr-results") - set_tests_properties(gl_xbr_filter_test PROPERTIES - LABELS "gpu;opengl;hardware" TIMEOUT 180 SKIP_RETURN_CODE 77) endif() add_executable(launcher_device_roundtrip_test tests/test_launcher_device_roundtrip.cpp) From e88823ee4fec4868132526be41f974fddee7e40e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:14 -0700 Subject: [PATCH 13/21] Netplay rb: admit the Live tick a realign resumes into A Live realign restores the snap at T, sets the session to T+1 and longjmps back into the guest from the admit spin. The resume ran before T+1 was admitted, so that frame latched no inputs and did not advance; the next frame was then counted as T+1 and the realigned seat ran one frame ahead of its tick label from there on (ring saves after a NACK realign carried the next tick's clock, 2x the cycles in the first tick). Only the realigned seat shifted, so it forked from every other seat; with four seats this produced the "resim core diverge" splits and the later hc-fork / hang cascade. flush_resume now admits the tick first on the Live realign path and keeps spinning until it can. Co-Authored-By: Claude Opus 5.5 --- runtime/include/psx_netplay_rb.h | 2 ++ runtime/src/psx_netplay.c | 15 +++++++++++++++ runtime/src/psx_netplay_rb.c | 5 +++++ 3 files changed, 22 insertions(+) diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index e3780b12..4d40d512 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -52,6 +52,8 @@ void psx_netplay_rb_poll(struct CPUState *cpu, uint32_t resume_pc); * (same as savestate_poll). No-op when nothing is pending. Never returns on * success. */ void psx_netplay_rb_flush_resume(void); +/* psx_netplay.c: admit the Live tick a realign resumes into (1 = admitted). */ +int psx_netplay_live_realign_admit(void); /* 1 after flush_resume longjmp until finish_frame / abort — top-level * dispatch has no native call chain under the resume PC. */ diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index d4a912ff..81c66a36 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -5016,6 +5016,21 @@ static int np_try_admit_load_barrier_unconfirmed(void) return 1; } +/* A Live realign restores the snap at T and resumes the guest at the frame + * boundary after it, with the session already at T+1. Admit T+1 before the + * resume: resuming first ran frame T+1 unadmitted (no inputs latched, no + * advance), so the next frame was counted as T+1 and this peer ran one frame + * ahead of its tick label (4-seat race: L-saves after a NACK realign carried + * the next tick's clock; the realigned seat forked from every other). */ +int psx_netplay_live_realign_admit(void) +{ + if (!psx_netplay_active() || !g_np.rollback || g_np.xfer != NP_XFER_NONE) + return 1; + if (g_np.needs_advance) + return 1; + return np_try_admit_rollback(); +} + int psx_netplay_poll_admit(void) { rnet_u32 sim; diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 415f9983..be3f282e 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -7117,6 +7117,11 @@ void psx_netplay_rb_flush_resume(void) live_realign_resume = !g_rb || !rnet_rb_is_active(g_rb); if (!live_realign_resume && rnet_rb_get_phase(g_rb) != nRNetRbPhaseReplay) return; + /* Live realign: the resumed frame must be an admitted tick, or it runs + * uncounted and this seat ends one frame ahead of its label. Stay + * spinning until the tick's inputs admit. */ + if (live_realign_resume && !psx_netplay_live_realign_admit()) + return; pc = g_pending_resume_pc; if (!rb_resume_pc_ok(pc)) { uint32_t alt = pick_snap_resume_pc(cpu(), pc); From 2337f5676400dcfe74f382d0a5c9566d70ed32e4 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:40:09 -0700 Subject: [PATCH 14/21] Netplay rb: fix resim audit row print (else bound to the analog line) Co-Authored-By: Claude Opus 5.5 --- runtime/src/psx_netplay_rb.c | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index be3f282e..22e12e6a 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -4792,12 +4792,11 @@ static void log_resim_tick_audit(uint32_t sim, const char *tag) for (slot = 0; slot < n; ++slot) { RNetRbFrame row; if (rnet_rb_get_sealed_frame(g_rb, slot, sim, &row) && row.is_valid) - fprintf(stderr, " s%d=%04x%s", slot, (unsigned)row.buttons, - row.analog ? "A" : ""); - /* Analog bytes too: equal buttons hid a sealed-row stick difference. */ - if (rnet_rb_get_sealed_frame(g_rb, slot, sim, &row) && row.is_valid && - row.analog) - fprintf(stderr, ":%02x%02x%02x%02x", (unsigned)(uint8_t)row.stick_x, + /* Device type and axes too: equal buttons hid a sealed-row + * difference (a digital idle fill vs the seat's analog row). */ + fprintf(stderr, " s%d=%04x%s:t%u:%02x%02x%02x%02x", slot, + (unsigned)row.buttons, row.analog ? "A" : "", + (unsigned)row.analog, (unsigned)(uint8_t)row.stick_x, (unsigned)(uint8_t)row.stick_y, (unsigned)row.rx, (unsigned)row.ry); else From 1751c6973b35c1390e4155b845b42f2b5ed7cbf1 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:40:50 -0700 Subject: [PATCH 15/21] Netplay rb: PSX_RB_SNAP_AUDIT replay-determinism log + diff tool Per ring save, the section CRCs (and small sections in hex) of the boot_state blob, marked live or replay. tools/rb_snap_audit_diff.py checks a seat's replay against its own live save and seats against each other, naming the first differing section and bytes. Off unless set. Co-Authored-By: Claude Opus 5.5 --- runtime/src/netplay_snap_ring.c | 63 ++++++++++++++++++++++++++++++ tools/rb_snap_audit_diff.py | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100755 tools/rb_snap_audit_diff.py diff --git a/runtime/src/netplay_snap_ring.c b/runtime/src/netplay_snap_ring.c index d9936de8..90454222 100644 --- a/runtime/src/netplay_snap_ring.c +++ b/runtime/src/netplay_snap_ring.c @@ -12,12 +12,74 @@ #include "boot_state.h" #include +#include +#include +#include #if defined(_WIN32) # include #else # include #endif +/* PSX_RB_SNAP_AUDIT=: one line per ring save -- mode (L live, R replay), + * tick, and per boot_state section the CRC32 (+ hex for sections <= 4 KiB). + * tools/rb_snap_audit_diff.py compares seats tick by tick and a seat's replay + * against its own live save, naming the first section/byte that differs. It + * is the replay-determinism check: same snap + same rows must give the same + * machine, field by field. Off (zero cost) unless the variable is set. */ +static void netplay_snap_audit_note(uint32_t tick, const uint8_t *d, size_t n) +{ + extern int psx_netplay_local_slot(void); + extern int psx_netplay_is_resimulating(void); + static FILE *f; + static int init; + size_t start; + if (!init) { + const char *dir = getenv("PSX_RB_SNAP_AUDIT"); + char nm[600]; + init = 1; + if (dir && *dir) { + snprintf(nm, sizeof(nm), "%s/snap-audit-p%d.txt", dir, psx_netplay_local_slot()); + f = fopen(nm, "w"); + } + } + if (!f || !d) + return; + for (start = 0; start < 256; start += 4) { + size_t o = start; + int cnt = 0, ok = 1; + while (o + 16 <= n) { + uint32_t tag; + uint64_t ln; + memcpy(&tag, d + o, 4); + memcpy(&ln, d + o + 8, 8); + if (ln > n || tag == 0 || tag > 0x7fffffffu) { ok = 0; break; } + o += 16 + (size_t)ln; + cnt++; + } + if (!ok || o != n || cnt < 6) + continue; + fprintf(f, "%c %u", psx_netplay_is_resimulating() ? 'R' : 'L', (unsigned)tick); + o = start; + while (o + 16 <= n) { + uint32_t tag; + uint64_t ln, i; + memcpy(&tag, d + o, 4); + memcpy(&ln, d + o + 8, 8); + fprintf(f, " %x:%08lx", (unsigned)tag, + (unsigned long)crc32(0L, d + o + 16, (uInt)ln)); + if (ln <= 4096u && tag != 0x10u) { + fputc('=', f); + for (i = 0; i < ln; i++) + fprintf(f, "%02x", d[o + 16 + i]); + } + o += 16 + (size_t)ln; + } + fputc('\n', f); + return; + } +} + static double snap_mono_ms(void) { #if defined(_WIN32) @@ -69,6 +131,7 @@ int netplay_snap_ring_save(NetplaySnapRing *r, uint32_t tick, 100.0 * (double)s_tele_incr_n / (double)s_tele_n); fflush(stderr); } + netplay_snap_audit_note(tick, data, len); if (!netplay_snap_ring_store(r, tick, data, len)) return 0; return 1; diff --git a/tools/rb_snap_audit_diff.py b/tools/rb_snap_audit_diff.py new file mode 100755 index 00000000..cd3a0834 --- /dev/null +++ b/tools/rb_snap_audit_diff.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Replay-determinism diff for PSX_RB_SNAP_AUDIT logs. + +usage: rb_snap_audit_diff.py (dir holds snap-audit-p.txt per seat) + +1. Same seat, replay vs live: every R save at tick T must equal the seat's + live L save at T section by section (same snap + same rows => same machine). +2. Across seats: the last save of each tick (after any rollback) must agree. +Prints the first differing section/byte offsets; exit 1 on any difference. +""" +import collections, glob, re, sys + +NAMES = {1: 'CPU', 2: 'RAM', 3: 'SPAD', 4: 'IRQ', 5: 'TIMER', 6: 'CLOCK', 7: 'GPU', + 8: 'VRAM', 9: 'SPU', 0xa: 'SPURAM', 0xb: 'CDROM', 0xc: 'DMA', 0xd: 'SIO', + 0xe: 'DIRTY', 0xf: 'MDEC', 0x10: 'ICACHE', 0x11: 'MODMEM', 0x13: 'GCS'} + + +def parse(path): + live, replay = {}, collections.defaultdict(list) + for line in open(path): + p = line.split() + if len(p) < 3: + continue + secs = {} + for x in p[2:]: + tag, rest = x.split(':', 1) + crc, _, hexd = rest.partition('=') + secs[int(tag, 16)] = (crc, hexd) + (live.__setitem__(int(p[1]), secs) if p[0] == 'L' + else replay[int(p[1])].append(secs)) + return live, replay + + +def bytes_diff(a, b): + if not a or not b: + return [] + x, y = bytes.fromhex(a), bytes.fromhex(b) + return [(i, x[i], y[i]) for i in range(min(len(x), len(y))) if x[i] != y[i]][:8] + + +def main(d): + seats = {int(re.search(r'p(-?\d+)', f)[1]): parse(f) + for f in glob.glob(f'{d}/snap-audit-p*.txt')} + bad = 0 + for s, (live, rep) in sorted(seats.items()): + for t, rows in sorted(rep.items()): + for r in rows: + for tag, (crc, h) in r.items(): + if t in live and tag in live[t] and live[t][tag][0] != crc: + bad += 1 + if bad <= 10: + print(f'seat {s} tick {t} replay!=live {NAMES.get(tag, hex(tag))}', + bytes_diff(live[t][tag][1], h)) + if len(seats) > 1: + common = set.intersection(*(set(v[0]) for v in seats.values())) + for t in sorted(common): + ref = min(seats) + for tag in seats[ref][0][t]: + vals = {s: v[0][t].get(tag, ('', ''))[0] for s, v in seats.items()} + if len(set(vals.values())) > 1: + bad += 1 + if bad <= 20: + print(f'tick {t} seats differ {NAMES.get(tag, hex(tag))} {vals}') + print('differences:', bad) + return 1 if bad else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1])) From 56f1e7bd3a019da00708ddb42dafc8ac314d35af Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:34:14 -0700 Subject: [PATCH 16/21] Netplay rb: PSX_RB_DET_SELFTEST forces no-op rollbacks for the determinism diff Every K ticks the host rewinds 16 ticks with nothing mispredicted, so each seat replays confirmed rows over ticks it ran live; with PSX_RB_SNAP_AUDIT the diff tool then compares replayed saves with live ones field by field. On R4 4P it shows replay saves taken at a different guest point than the live save of the same tick (CPU pc, cycles_since_vblank, root counters). Co-Authored-By: Claude Opus 5.5 --- runtime/src/psx_netplay.c | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 81c66a36..5edc61bb 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -779,6 +779,37 @@ static void np_log_live_digest(uint32_t tick, const NetplayCoreParts *parts, * episode reloads from the newest provably-safe snap (≤ fork tick — HC is * confirmed through fork-1 by definition) and reseals both seats from * wire-authoritative history, converging the states. */ +/* PSX_RB_DET_SELFTEST=: the replay-determinism test. Every K ticks the + * slot-0 host opens a rollback episode 16 ticks back although nothing was + * mispredicted, so every seat reloads a snap and replays confirmed rows over + * ticks it already ran live. Same snap + same rows must give the same + * machine: with PSX_RB_SNAP_AUDIT set, tools/rb_snap_audit_diff.py compares + * each replayed save with the live save of that tick section by section + * (CPU, RAM, timers, DMA, SIO/multitap, SPU, ...) and names the first field + * that differs. Off unless set. */ +static void np_det_selftest_tick(uint32_t done) +{ + static int k = -1; + static uint32_t last; + if (k < 0) { + const char *e = getenv("PSX_RB_DET_SELFTEST"); + k = (e && *e) ? atoi(e) : 0; + if (k < 0) k = 0; + } + if (k <= 0 || g_np.local_slot != 0 || !g_np.rollback || done < 64u) + return; + if (done - last < (uint32_t)k) + return; + if (psx_netplay_rb_active() || psx_netplay_rb_tip_holding() || + psx_netplay_rb_load_pending()) + return; + last = done; + fprintf(stderr, "psxrecomp: rb det-selftest rewind mismatch=%u\n", + (unsigned)(done - 16u)); + fflush(stderr); + (void)psx_netplay_rb_begin_rewind(done - 16u, g_np.slot_count > 1 ? 1 : 0); +} + static void np_try_hc_fork_recovery(uint32_t fork_tick) { uint32_t sim; @@ -5276,6 +5307,7 @@ void psx_netplay_finish_frame(void) np_rollback_reconcile_wire(); psx_netplay_rb_pump(); psx_netplay_rb_request_snap(done); + np_det_selftest_tick(done); /* Flush at vblank: MotK IRQ fast/mid paths used to skip poll_snap, so * deferred BB-edge saves never ran and the ring stayed empty. Prefer * IRQ BB-edge PCs — cpu->pc is often 0 during present/finish_frame. */ From 0ae1725627ad23a0bad36f3bb68859a9805b9924 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:15:57 -0700 Subject: [PATCH 17/21] Netplay rb: Live and Replay tick snaps use one resume-PC rule Replay's finish_frame saved its tick snap with no PC hint while Live used the IRQ resume / last check / cpu->pc chain; the same tick's snap could carry a different resume PC (psx_netplay_snap_resume_hint now serves both). Co-Authored-By: Claude Opus 5.5 --- runtime/include/psx_netplay_rb.h | 2 ++ runtime/src/psx_netplay.c | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/runtime/include/psx_netplay_rb.h b/runtime/include/psx_netplay_rb.h index 4d40d512..276704de 100644 --- a/runtime/include/psx_netplay_rb.h +++ b/runtime/include/psx_netplay_rb.h @@ -47,6 +47,8 @@ void psx_netplay_rb_cold_reset(void); * without longjmp. Call psx_netplay_rb_flush_resume() afterward from a C BB-edge * (or after all C++ RAII in the vblank path has been destroyed). */ void psx_netplay_rb_poll(struct CPUState *cpu, uint32_t resume_pc); +/* psx_netplay.c: the one resume-PC rule for Live and Replay tick snaps. */ +uint32_t psx_netplay_snap_resume_hint(const struct CPUState *cpu); /* If a baseline snap was applied without resume, longjmp to the scheduler * (same as savestate_poll). No-op when nothing is pending. Never returns on diff --git a/runtime/src/psx_netplay.c b/runtime/src/psx_netplay.c index 5edc61bb..d388115c 100644 --- a/runtime/src/psx_netplay.c +++ b/runtime/src/psx_netplay.c @@ -5268,6 +5268,22 @@ int psx_netplay_poll_admit(void) return 0; } +/* The resume PC a tick snapshot is saved with. Live finish_frame and the + * Replay finish_frame must use the same rule: Replay passed no hint, so its + * snap often fell back to a different PC or was deferred to a later BB edge, + * and the replayed save of tick T sat at another guest point than the live + * save of T (pc, cycles_since_vblank, root counters all differed -- + * PSX_RB_DET_SELFTEST: 106 of 262 replayed saves). */ +uint32_t psx_netplay_snap_resume_hint(const CPUState *cpu) +{ + uint32_t hint = psx_compiled_irq_resume_pc(); + if (!psx_is_dispatchable(hint)) + hint = psx_last_irq_check_pc(); + if (!psx_is_dispatchable(hint) && cpu) + hint = cpu->pc; + return hint; +} + void psx_netplay_finish_frame(void) { rnet_u32 done; @@ -5312,11 +5328,7 @@ void psx_netplay_finish_frame(void) * deferred BB-edge saves never ran and the ring stayed empty. Prefer * IRQ BB-edge PCs — cpu->pc is often 0 during present/finish_frame. */ if (g_np.cpu) { - resume_hint = psx_compiled_irq_resume_pc(); - if (!psx_is_dispatchable(resume_hint)) - resume_hint = psx_last_irq_check_pc(); - if (!psx_is_dispatchable(resume_hint)) - resume_hint = g_np.cpu->pc; + resume_hint = psx_netplay_snap_resume_hint(g_np.cpu); psx_netplay_rb_poll(g_np.cpu, resume_hint); } } From c7d5b873cd26ce6a973491990b9a78f25690ec7f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:16:06 -0700 Subject: [PATCH 18/21] Rollback snaps: the canonical machine at the tick boundary PSX_RB_DET_SELFTEST on R4 4P showed a replayed save of tick T differing from the live save of T. Each cause, and the fix: - Replay's finish_frame poll passed no PC hint (Live used the IRQ resume chain); it now uses psx_netplay_snap_resume_hint. - Devices are serviced lazily up to the next event, so root counters and cycles_since_vblank lagged the clock by a host-path amount at save; a load re-anchors them at the clock and drops the lag. The save now publishes pending cycle charges and services every device to the clock first, and renders the SPU up to it. - GTE / mul-div completion stamps and the load-delay give-back were re-anchored at the restored clock on every load, so a stall in flight at the snap point cost the live run cycles but the replay none. New BS_SEC_CPUTIM carries them relative to the clock. - The SPU CD-input FIFO was outside the snap and wiped on rollback loads; new BS_SEC_SPUCD carries the queued frames and the load keeps them. - LZCR is serialized as derived from LZCS (the raw backing stays 0 until the first LZCS write; guests only ever read the derived value). Older snaps without the new sections load as before. Co-Authored-By: Claude Opus 5.5 --- runtime/include/boot_state.h | 6 +++ runtime/src/boot_state.c | 88 ++++++++++++++++++++++++++++++++++-- runtime/src/psx_cycles.c | 22 +++++---- runtime/src/psx_netplay_rb.c | 22 +++++++-- runtime/src/spu.c | 35 ++++++++++++++ 5 files changed, 157 insertions(+), 16 deletions(-) diff --git a/runtime/include/boot_state.h b/runtime/include/boot_state.h index 2229c1f9..d47e6e4d 100644 --- a/runtime/include/boot_state.h +++ b/runtime/include/boot_state.h @@ -107,6 +107,12 @@ enum { BS_SEC_SIO = 0x0D, /* SIO regs + pad-config FSM + memcard FSM */ BS_SEC_DIRTY = 0x0E, /* dirty-RAM page bitmap (guest-written code pages) */ BS_SEC_MDEC = 0x0F, /* MDEC command/FIFOs/quant/scale (FMV decode resume) */ + BS_SEC_SPUCD = 0x15, /* SPU CD-input frames queued (u32 n + n*(l,r) s16) */ + BS_SEC_CPUTIM = 0x14, /* [timing] in-flight CPU stall state, relative to + the clock: GTE / mul-div cycles left, the load- + delay give-back (read_absorb[33], which, fudge, + ld_which_t, ld_absorb). Absent => anchored at the + restored clock (pre-0x14 behaviour). */ BS_SEC_GCS = 0x13, /* [timing] guest_cycle_scale: carried fraction + gate state (3 u32). Written only when the title's scale is not 1, so faithful states are unchanged; diff --git a/runtime/src/boot_state.c b/runtime/src/boot_state.c index 33a850a1..6195d9ba 100644 --- a/runtime/src/boot_state.c +++ b/runtime/src/boot_state.c @@ -110,6 +110,10 @@ static uint32_t boot_state_layout_cookie(void) { #define CPU_REGS_WIRE_BYTES (524u) /* Timer wire: 3*u16 + 3*u32 + 3*u16 + 3*i32 + 3*u32 = 48 bytes (no pad holes). */ #define TIMER_REGS_WIRE_BYTES (48u) +/* CPU stall wire: 2*u32 + 33 + 3*u8 + u32 = 48 bytes. */ +#define BS_CPUTIM_BYTES (48u) +/* Set by a BS_SEC_CPUTIM load; psx_cycles_resync_after_restore consumes it. */ +int g_boot_state_cpu_timing_restored; /* ---- deferred capture state (armed before first boot, fired at handoff) ---- */ static char s_capture_path[512]; @@ -322,8 +326,21 @@ static int write_cpu_section(BsOut* o, const CPUState* cpu) { return 0; for (int i = 0; i < 32; i++) if (!pst_w_u32(&w, cpu->cop0[i])) return 0; - for (int i = 0; i < 32; i++) - if (!pst_w_u32(&w, cpu->gte_data[i])) return 0; + for (int i = 0; i < 32; i++) { + uint32_t v = cpu->gte_data[i]; + /* LZCR is derived from LZCS (what MFC2 31 returns, and what a load + * recomputes). Serialize the derived value: the raw backing stays 0 + * until the first LZCS write, so a live save and a replayed save of + * the same tick differed here although no guest could tell. */ + if (i == 31) { + uint32_t bits = (cpu->gte_data[30] & 0x80000000u) ? ~cpu->gte_data[30] + : cpu->gte_data[30]; + v = 0; + if (bits == 0) v = 32; + else while (!(bits & 0x80000000u)) { bits <<= 1; ++v; } + } + if (!pst_w_u32(&w, v)) return 0; + } for (int i = 0; i < 32; i++) if (!pst_w_u32(&w, cpu->gte_ctrl[i])) return 0; if (w.written != CPU_REGS_WIRE_BYTES) return 0; @@ -399,9 +416,27 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, h.codegen_hash = (uint32_t)PSX_OVERLAY_CODEGEN_HASH; h.abi_tag = (int32_t)PSX_OVERLAY_ABI_TAG; h.codegen_ver = BOOT_STATE_IMPLEMENTATION_KEY; - h.section_count = 17 + (psx_mod_memory_snapshot_bytes() ? 1u : 0u) + + h.section_count = 18 + (cpu ? 1u : 0u) + (psx_mod_memory_snapshot_bytes() ? 1u : 0u) + (psx_guest_cycle_scale_config() != 1u ? 1u : 0u); + /* Publish deferred cycle charges before ANY section: IRQ (cycles since + * VBlank) and the root counters were written before the old flush point + * (just ahead of CLOCK), so they lagged the saved clock by whatever batch + * was pending -- a host-path quantity (live vs rollback replay). */ + psx_cyc_batch_flush(); + /* Devices (root counters, VBlank phase, CD/SPU/DMA) are serviced lazily + * up to the next event deadline, so between deadlines they lag the clock + * by a host-path amount. A load re-anchors them at the restored clock, + * dropping that lag: live and replay saves of one tick then differed in + * cycles_since_vblank and the counters at the same clock. Bring every + * device to the clock first so the snap is the canonical machine at it + * (service_to_now replays the exact event-bounded timeline). */ + psx_devices_service_to_now(); + /* The SPU renders in whole samples when the host audio pump or a guest + * SPU access asks; between those it lags the clock by a path-dependent + * amount (live pumps at VBlank, rollback replay suppresses output). */ + { extern void spu_catch_up(void); spu_catch_up(); } + ok = write_header_le(o, &h); if (ok) ok = write_cpu_section(o, cpu); @@ -498,6 +533,31 @@ static int boot_state_save_to(BsOut* o, const CPUState* cpu, ok = pst_w_u32(&w, fntrace_is_game_started() ? 1u : 0u) && write_section(o, BS_SEC_GAME_START, handoff, sizeof handoff); } + if (ok) { + extern uint32_t spu_cd_snapshot_bytes(void); + extern void spu_cd_snapshot_write(uint8_t *p); + ok = write_module_section(o, BS_SEC_SPUCD, spu_cd_snapshot_bytes, spu_cd_snapshot_write); + } + if (ok && cpu) { + /* In-flight CPU stalls, relative to the clock. They used to be + * re-anchored at the restored clock on every load, so a GTE / mul-div + * op or load give-back in flight at the snap point cost the live run + * its remaining stall but the replay nothing: replaying a snap with + * the same rows ran a few cycles short of the live tick + * (PSX_RB_DET_SELFTEST: IRQ/TIMER drift at equal clock). */ + uint8_t tb[BS_CPUTIM_BYTES]; + PstW w; + uint64_t now = psx_cycle_count; + uint32_t gte_rem = cpu->gte_ts_done > now ? (uint32_t)(cpu->gte_ts_done - now) : 0u; + uint32_t md_rem = cpu->muldiv_ts_done > now ? (uint32_t)(cpu->muldiv_ts_done - now) : 0u; + pst_w_init(&w, tb, sizeof tb); + ok = pst_w_u32(&w, gte_rem) && pst_w_u32(&w, md_rem) && + pst_w_bytes(&w, cpu->read_absorb, sizeof cpu->read_absorb) && + pst_w_u8(&w, cpu->read_absorb_which) && pst_w_u8(&w, cpu->read_fudge) && + pst_w_u8(&w, cpu->ld_which_t) && pst_w_u32(&w, cpu->ld_absorb) && + w.written == BS_CPUTIM_BYTES && + write_section(o, BS_SEC_CPUTIM, tb, sizeof tb); + } if (ok && psx_guest_cycle_scale_config() != 1u) { /* Rollback re-simulation must resume with the same carried fraction * and gate verdicts, or scaled charges land a cycle apart. */ @@ -725,6 +785,26 @@ static int apply_section(uint32_t tag, const uint8_t* p, uint32_t len, case BS_SEC_GAME_START: fntrace_restore_game_started(p[0]); return 1; + case BS_SEC_SPUCD: { + extern int spu_cd_snapshot_read(const uint8_t *p, uint32_t len); + return spu_cd_snapshot_read(p, len); + } + case BS_SEC_CPUTIM: { + PstR r; + uint32_t gte_rem, md_rem; + if (len != BS_CPUTIM_BYTES || !cpu) return 0; + pst_r_init(&r, p, len); + if (!pst_r_u32(&r, >e_rem) || !pst_r_u32(&r, &md_rem) || + !pst_r_bytes(&r, cpu->read_absorb, sizeof cpu->read_absorb) || + !pst_r_u8(&r, &cpu->read_absorb_which) || !pst_r_u8(&r, &cpu->read_fudge) || + !pst_r_u8(&r, &cpu->ld_which_t) || !pst_r_u32(&r, &cpu->ld_absorb)) + return 0; + /* CLOCK precedes this section, so psx_cycle_count is restored. */ + cpu->gte_ts_done = psx_cycle_count + gte_rem; + cpu->muldiv_ts_done = psx_cycle_count + md_rem; + g_boot_state_cpu_timing_restored = 1; + return 1; + } case BS_SEC_GCS: { PstR r; uint32_t g[3]; @@ -795,6 +875,8 @@ static int validate_section(uint32_t tag, const uint8_t* p, uint32_t len) { return len == 4u && p[0] <= 1u && !p[1] && !p[2] && !p[3]; case BS_SEC_ICACHE: return len == 1024u * 4u; case BS_SEC_GCS: return len == PSX_GCS_SNAPSHOT_BYTES; + case BS_SEC_CPUTIM: return len == BS_CPUTIM_BYTES; + case BS_SEC_SPUCD: return len >= 4u && (len - 4u) % 4u == 0u; default: return 1; } } diff --git a/runtime/src/psx_cycles.c b/runtime/src/psx_cycles.c index 5fd078ff..76467c2e 100644 --- a/runtime/src/psx_cycles.c +++ b/runtime/src/psx_cycles.c @@ -766,14 +766,20 @@ void psx_cycles_resync_after_restore(CPUState *cpu) { * would then advance (live_ts - restored_cycle) in one shot — tens of * millions of cycles / N nested presents with zero IRQ checks (MotK * transform CTC2 path). Anchor them at the restored clock. */ - if (cpu) { - cpu->gte_ts_done = psx_cycle_count; - cpu->muldiv_ts_done = psx_cycle_count; - memset(cpu->read_absorb, 0, sizeof(cpu->read_absorb)); - cpu->read_absorb_which = 0; - cpu->read_fudge = 0x20u; /* no committed predecessor load */ - cpu->ld_which_t = 0x20u; /* no pending load dest */ - cpu->ld_absorb = 0; + { + /* A snap with BS_SEC_CPUTIM carried them relative to its clock -- + * keep those; older snaps anchor at the restored clock as before. */ + extern int g_boot_state_cpu_timing_restored; + if (cpu && !g_boot_state_cpu_timing_restored) { + cpu->gte_ts_done = psx_cycle_count; + cpu->muldiv_ts_done = psx_cycle_count; + memset(cpu->read_absorb, 0, sizeof(cpu->read_absorb)); + cpu->read_absorb_which = 0; + cpu->read_fudge = 0x20u; /* no committed predecessor load */ + cpu->ld_which_t = 0x20u; /* no pending load dest */ + cpu->ld_absorb = 0; + } + g_boot_state_cpu_timing_restored = 0; } /* Dirty-RAM interpreter load-delay writebacks live in host statics, not * BS_SEC_CPU. Discard (do not flush): snap GPRs are already architectural. diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index 22e12e6a..f0aba22c 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -6818,8 +6818,14 @@ static int try_apply_pending_load(CPUState *cpu_in) psx_cycles_resync_after_restore(cpu_in); interrupts_resync_after_restore(); cdrom_resync_deadlines_after_restore(); - if (!cdrom_xa_stream_active() && !cdrom_fmv_stream_pending()) - spu_cd_audio_reset(); + { + /* The snap carried the CD input FIFO (BS_SEC_SPUCD): keep it. */ + extern int g_spu_cd_restored; + if (!g_spu_cd_restored && !cdrom_xa_stream_active() && + !cdrom_fmv_stream_pending()) + spu_cd_audio_reset(); + g_spu_cd_restored = 0; + } { extern void overlay_loader_clear_lazy_miss(void); overlay_loader_clear_lazy_miss(); @@ -6953,8 +6959,14 @@ static int try_apply_pending_load(CPUState *cpu_in) cdrom_resync_deadlines_after_restore(); /* Wipe SPU CD FIFO only when XA is idle — clearing mid-stream * left MotK FMV silent/black after tip loads into media. */ - if (!cdrom_xa_stream_active() && !cdrom_fmv_stream_pending()) - spu_cd_audio_reset(); + { + /* The snap carried the CD input FIFO (BS_SEC_SPUCD): keep it. */ + extern int g_spu_cd_restored; + if (!g_spu_cd_restored && !cdrom_xa_stream_active() && + !cdrom_fmv_stream_pending()) + spu_cd_audio_reset(); + g_spu_cd_restored = 0; + } { extern void overlay_loader_clear_lazy_miss(void); overlay_loader_clear_lazy_miss(); @@ -10046,7 +10058,7 @@ void psx_netplay_rb_finish_frame(void) log_resim_tick_audit(done, "fin"); psx_netplay_rb_request_snap(done); if (c) - psx_netplay_rb_poll(c, pick_snap_resume_pc(c, 0u)); + psx_netplay_rb_poll(c, psx_netplay_snap_resume_hint(c)); rnet_session_advance(s); g_needs_advance = 0; diff --git a/runtime/src/spu.c b/runtime/src/spu.c index 5239bad0..ebb21347 100644 --- a/runtime/src/spu.c +++ b/runtime/src/spu.c @@ -466,6 +466,41 @@ void spu_cd_audio_push(const int16_t* stereo, int frames) { cd_frame_count); } +/* CD input frames queued for the SPU are guest state: they come from the + * CD-ROM decoder at guest time and the SPU consumes them sample by sample + * (output and the CD capture area of SPU RAM). They used to be outside the + * snapshot and were wiped on rollback loads, so a replay mixed different CD + * audio than the live run (SPU voices / SPU RAM split at R4's race start). */ +int g_spu_cd_restored; +uint32_t spu_cd_snapshot_bytes(void) { return 4u + cd_frame_count * 4u; } +void spu_cd_snapshot_write(uint8_t *p) { + uint32_t n = cd_frame_count, pos = cd_read_pos; + p[0] = (uint8_t)n; p[1] = (uint8_t)(n >> 8); p[2] = (uint8_t)(n >> 16); p[3] = (uint8_t)(n >> 24); + p += 4; + for (uint32_t i = 0; i < n; i++) { + uint16_t l = (uint16_t)cd_ring[pos * 2u], r = (uint16_t)cd_ring[pos * 2u + 1u]; + p[0] = (uint8_t)l; p[1] = (uint8_t)(l >> 8); p[2] = (uint8_t)r; p[3] = (uint8_t)(r >> 8); + p += 4; + pos = (pos + 1u) % SPU_CD_RING_FRAMES; + } +} +int spu_cd_snapshot_read(const uint8_t *p, uint32_t len) { + uint32_t n; + if (!p || len < 4u) return 0; + n = (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + if (n > SPU_CD_RING_FRAMES || len != 4u + n * 4u) return 0; + p += 4; + for (uint32_t i = 0; i < n; i++, p += 4) { + cd_ring[i * 2u] = (int16_t)(uint16_t)(p[0] | (p[1] << 8)); + cd_ring[i * 2u + 1u] = (int16_t)(uint16_t)(p[2] | (p[3] << 8)); + } + cd_read_pos = 0; + cd_write_pos = n % SPU_CD_RING_FRAMES; + cd_frame_count = n; + g_spu_cd_restored = 1; + return 1; +} + static int cd_audio_pop(int16_t* left, int16_t* right) { if (cd_frame_count == 0) return 0; *left = cd_ring[cd_read_pos * 2u + 0u]; From c2408e5d9aef363874748b041cdcdf0f9b56d389 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 10 Oct 2026 01:03:12 -0400 Subject: [PATCH 19/21] sync --- lib/recomp-net | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/recomp-net b/lib/recomp-net index b1eab87b..ae567615 160000 --- a/lib/recomp-net +++ b/lib/recomp-net @@ -1 +1 @@ -Subproject commit b1eab87bf3c011da8c22890601c0e659f1f89fbb +Subproject commit ae567615760b07d469776a6f6e1873b9aef54915 From 1d2f4f95d3039e9b5d9d35fdca3391da8403ad64 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Sat, 10 Oct 2026 01:38:45 -0400 Subject: [PATCH 20/21] sync --- runtime/src/main.cpp | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4557c0a1..3550507e 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -83,6 +83,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "host_time.h" #if defined(PSX_HAS_RECOMP_NET) #include "recomp_net/auth.h" +#include "recomp_netplay_account.h" /* shared recomp-ui account sync */ #include "recomp_net/host_relay.h" /* RNetHostRelayStatus for the launcher relay line */ #include "recomp_net/chat_filter.h" /* chat profanity mask, LAN rooms too */ #endif @@ -12833,28 +12834,22 @@ namespace { int ae_np_account_sign_out(void*) { return rnet_account_sign_out(); } int ae_np_account_set_handle(void*, const char* h) { return rnet_account_set_handle(h); } - /* Point the account client at the lobby host -- once per URL, not once - * per pump: the login worker thread reads the host while a sign-in is in - * flight, and re-initialising it 60 times a second under that read is a - * data race for no gain. The secret is anchored to the EXECUTABLE - * directory before the first init: its default is the bare relative name - * "netplay_secret", resolved against the working directory, so the same - * install signed itself out depending on where it was launched from. - * rnet_auth migrates an old CWD-relative file into this path on first - * load, so nobody is signed out by the move. Same shape as the SNES - * host (snes_host_lobby.c cb_pump). */ + /* Point the account client at the lobby host. The rule (resolved URL, + * once per URL, secret anchored to the exe dir) lives in recomp-ui, shared + * with recomp_netplay_host.c. The URL is ae_np_default_url, NOT the raw + * g_lnch_lobby_url: that is empty until a player saves one, which left the + * auth host blank and every sign-in "could not reach the lobby server". */ + static int ae_np_exe_dir_path(void*, const char* leaf, char* out, size_t cap) { + const std::string p = + (exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / leaf).string(); + if (p.size() + 1 > cap) return 0; + std::memcpy(out, p.c_str(), p.size() + 1); + return 1; + } + const char* ae_np_default_url(void*); /* defined below */ void ae_np_account_sync(void) { - static std::string s_auth_url; - const std::string& url = g_lnch_lobby_url; - if (url.empty() || url == s_auth_url) return; - if (s_auth_url.empty()) { - const std::string secret = - (exe_dir_from_argv(g_lnch_argv0 ? g_lnch_argv0 : "") / - "netplay_secret").string(); - rnet_account_set_secret_path(secret.c_str()); - } - s_auth_url = url; - rnet_account_init(url.c_str()); + recomp_netplay_account_sync(ae_np_default_url(nullptr), + ae_np_exe_dir_path, nullptr); } #endif /* PSX_HAS_RECOMP_NET: account client is not linked in offline builds */ From bd0425d7ab986f56b99b0a1016d572debe210a94 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:40:01 -0700 Subject: [PATCH 21/21] Netplay rb: re-anchor dirty-RAM IRQ poll stride each tick; av skips d24 helpers - The dirty-RAM interpreter's IRQ poll stride is host-only; a load resets it to phase 0 while Live carried an arbitrary phase, so a replay could take the VBlank in RAM code at another instruction. It is now re-anchored at every tick boundary in Live and Replay alike. - The av digest zeroes the three depth-24 present helpers at the end of the GPU snapshot (upload span, present hold, previous height): host presentation state a suppressed-present replay leaves different, not guest-visible. Co-Authored-By: Claude Opus 5.5 --- runtime/src/netplay_state_digest.c | 6 ++++++ runtime/src/psx_netplay_rb.c | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/runtime/src/netplay_state_digest.c b/runtime/src/netplay_state_digest.c index 7a60e1d5..5c4ace73 100644 --- a/runtime/src/netplay_state_digest.c +++ b/runtime/src/netplay_state_digest.c @@ -93,6 +93,12 @@ uint32_t netplay_av_digest(void) gcap = gn; } gpu_snapshot_write(gbuf); + /* The last 12 bytes are depth-24 present helpers (upload span, + * present hold, previous display height): host presentation state + * that a suppressed-present replay leaves different from Live. Not + * guest-visible; keep them out of the av comparison. */ + if (gn >= 12u) + memset(gbuf + gn - 12u, 0, 12u); crc = crc32_update(crc, gbuf, gn); } if (vram) diff --git a/runtime/src/psx_netplay_rb.c b/runtime/src/psx_netplay_rb.c index f0aba22c..8991c431 100644 --- a/runtime/src/psx_netplay_rb.c +++ b/runtime/src/psx_netplay_rb.c @@ -6701,6 +6701,15 @@ static int rb_fmv_dense_snap_window(void) void psx_netplay_rb_request_snap(uint32_t tick) { uint32_t iv; + /* Every tick boundary, Live and Replay alike: re-anchor the dirty-RAM + * interpreter's host-only IRQ poll stride. A load resets it to phase 0; + * Live otherwise carried an arbitrary phase, so a replay took the VBlank + * in RAM code at another instruction (EPC / clock split at R4's race + * start under PSX_RB_DET_SELFTEST). */ + { + extern void dirty_ram_irq_ambient_resync_after_restore(void); + dirty_ram_irq_ambient_resync_after_restore(); + } if (!g_snaps) return; /* Never overwrite the frozen episode baseline (or its ring slot). */