From 03e635e86dceb540b7b6421ada7ee32cc28ca62b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 19 Oct 2026 17:59:05 -0700 Subject: [PATCH] Preserve stock-frame VSync on faster displays --- runtime/CMakeLists.txt | 3 +++ runtime/include/frame_pacing.h | 12 +++++------ runtime/include/present_sync.h | 22 +++++++++++++++++++++ runtime/src/main.cpp | 28 ++++++++++++++------------ runtime/tests/test_present_sync.c | 33 +++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 19 deletions(-) create mode 100644 runtime/include/present_sync.h create mode 100644 runtime/tests/test_present_sync.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index e324baef..231096a1 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1194,6 +1194,9 @@ if(BUILD_TESTING) # Bug B: the pacing decision must take ONE counter sample, so the unsigned # subtraction cannot underflow into a ~24.7-day SDL_Delay. add_executable(frame_pacing_test tests/test_frame_pacing.c) + add_executable(present_sync_test tests/test_present_sync.c) + target_include_directories(present_sync_test PRIVATE include) + add_test(NAME present_sync_test COMMAND present_sync_test) target_include_directories(frame_pacing_test PRIVATE include) add_test(NAME frame_pacing_test COMMAND frame_pacing_test) # Render thread core ([video] render_thread, docs/RENDER_THREAD.md): diff --git a/runtime/include/frame_pacing.h b/runtime/include/frame_pacing.h index 44eb4313..66e4ad3e 100644 --- a/runtime/include/frame_pacing.h +++ b/runtime/include/frame_pacing.h @@ -45,14 +45,14 @@ uint32_t frame_pacing_sleep_ms(uint64_t now, uint64_t deadline, * period in milliseconds (e.g. 1000.0 / 59.94). */ void frame_pacer_wait(FramePacer *p, double period_ms); -/* 1 while the display driver's swap block — not the pacer above — is what holds - * the guest to its vblank cadence (present_vsync_owns_cadence() in main.cpp). - * The two are XOR: when this returns 1 the frontend does not call - * frame_pacer_wait(), so the ONLY thing throttling the guest is the swap. +/* 1 when the panel matches guest cadence and synchronized swaps can supply + * the guest's VBlank clock (present_vsync_owns_cadence() in main.cpp). The + * deadline pacer still caps speed if the driver does not actually block. + * Faster panels can also synchronize swaps while the pacer owns guest timing; + * this query then returns 0. * * A renderer that elides a redundant swap (unchanged front buffer) must - * therefore not do so while this is 1: the elided frame blocks on nothing, and - * a game that presents every other vblank runs at 2x. */ + * therefore retain it while this is 1 to preserve display-clock pacing. */ int psx_present_vsync_owns_cadence(void); #ifdef __cplusplus diff --git a/runtime/include/present_sync.h b/runtime/include/present_sync.h new file mode 100644 index 00000000..b86891c7 --- /dev/null +++ b/runtime/include/present_sync.h @@ -0,0 +1,22 @@ +#ifndef PSX_PRESENT_SYNC_H +#define PSX_PRESENT_SYNC_H + +/* Swap synchronization prevents tearing; the wall-clock pacer limits guest + * speed. They need not have the same rate. A faster panel can synchronize + * stock frames without becoming the guest's clock. A slower/unknown panel + * cannot block every guest frame without slowing emulation. + * + * blocked covers interpolation's own scheduler, netplay, driver recovery, + * and hosts whose swap blocking is unreliable. Mode 2 is the existing VRR + * frame-generation mode, which uses immediate swaps. */ +static inline int psx_present_sync_interval(int requested, int blocked, + double guest_hz, double host_hz) { + if (blocked || (requested != 1 && requested != -1)) + return 0; + if (!(guest_hz > 0.0) || !(host_hz > 0.0)) + return 0; + /* Same 2% tolerance as the frontend's host/guest cadence match. */ + return host_hz >= guest_hz * 0.98 ? requested : 0; +} + +#endif diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index c94e99de..346c2092 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -62,6 +62,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "../third_party/stb_image.h" #include "gpu_vk_renderer.h" #include "frame_pacing.h" +#include "present_sync.h" #include "latency_ring.h" #include "sio.h" #ifndef PSX_MAX_PLAYERS @@ -1459,10 +1460,10 @@ static int g_fmv_skip_no_xa_hold = 4; * (1=vsync/tear-free, 0=immediate/lowest display latency+tearing, -1=adaptive); * it trims the display-side scanout latency the CPU-side ring can't see. * - * Driver vsync and the wall-clock pacer are XOR. Waiting on both (pacer then - * FIFO SwapBuffers) double-blocks on Linux compositors: ~16.7 ms + ~16.7 ms - * = 30 Hz / 0.50x with the CPU idle. ~60 Hz panels may use vsync as the clock; - * otherwise the pacer holds 59.94 Hz and present must not wait on the swap. */ + * A deadline pacer caps guest speed even if the driver ignores swap blocking. + * A matching panel can supply the guest clock; a faster panel synchronizes + * scanout while the pacer retains the guest cadence. Do not equate clock + * ownership with the swap interval or high-refresh fullscreen can tear. */ static int g_low_latency_input = 1; /* [video] render_thread (docs/RENDER_THREAD.md): the GL backend runs on its * own thread. Started at the first vblank once the context and every startup @@ -3611,15 +3612,12 @@ extern "C" int psx_present_vsync_owns_cadence(void) { } static int present_effective_swap_interval(void) { - if (g_netplay_vsync_forced_off || psx_netplay_active()) - return 0; - if (g_frame_interpolation) - return 0; - if (g_frame_period_ms <= 0.0) - return 0; - if (present_vsync_owns_cadence()) - return g_video_vsync; - return 0; + const int blocked = g_netplay_vsync_forced_off || psx_netplay_active() || + g_frame_interpolation || g_present_vsync_disabled || + host_driver_vsync_unreliable(); + return psx_present_sync_interval(g_video_vsync, blocked, + g_frame_period_ms > 0.0 ? 1000.0 / g_frame_period_ms : 0.0, + g_host_refresh_hz); } static int present_should_wall_pace(void) { @@ -3678,6 +3676,10 @@ static void log_present_cadence(void) { std::printf("psxrecomp: present cadence: wall-clock pacer " "(%.4f ms/frame); driver vsync off under WSL\n", g_frame_period_ms); + } else if (present_effective_swap_interval() != 0) { + std::printf("psxrecomp: present cadence: wall-clock pacer " + "(%.4f ms/frame); synchronized swaps on %.0f Hz panel\n", + g_frame_period_ms, g_host_refresh_hz); } else if (g_host_refresh_hz > 0.0) { std::printf("psxrecomp: present cadence: wall-clock pacer " "(%.4f ms/frame); driver vsync off on %.0f Hz panel\n", diff --git a/runtime/tests/test_present_sync.c b/runtime/tests/test_present_sync.c new file mode 100644 index 00000000..f864d26f --- /dev/null +++ b/runtime/tests/test_present_sync.c @@ -0,0 +1,33 @@ +#include "present_sync.h" +#include + +static int failures; +static void check(int requested, int blocked, double guest, double host, + int expected) { + const int actual = psx_present_sync_interval(requested, blocked, guest, host); + if (actual != expected) { + fprintf(stderr, "sync(%d,%d,%.3f,%.3f): got %d expected %d\n", + requested, blocked, guest, host, actual, expected); + ++failures; + } +} + +int main(void) { + const double panels[] = {60, 75, 120, 144, 165, 240}; + for (unsigned i = 0; i < sizeof panels / sizeof panels[0]; ++i) { + check(1, 0, 59.94, panels[i], 1); + check(1, 0, 50, panels[i], 1); /* PAL on a faster panel */ + check(-1, 0, 59.94, panels[i], -1); /* explicit adaptive */ + check(0, 0, 59.94, panels[i], 0); /* explicit VSync off */ + check(2, 0, 59.94, panels[i], 0); /* VRR frame generation */ + check(1, 1, 59.94, panels[i], 0); /* scheduler/netplay/recovery */ + } + check(1, 0, 50, 50, 1); + check(1, 0, 60, 59, 1); + check(1, 0, 59.94, 50, 0); /* do not slow the guest to the panel */ + check(1, 0, 120, 60, 0); + check(1, 0, 59.94, 0, 0); /* unknown display */ + check(1, 0, 0, 144, 0); /* uncapped guest */ + puts(failures ? "present sync FAILED" : "present sync passed (42 cases)"); + return failures ? 1 : 0; +}