From 0127fcfc73048ad7f446096007e8820e1cad8328 Mon Sep 17 00:00:00 2001 From: yacineolivier-maker <234833416+yacineolivier-maker@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:58:39 +0200 Subject: [PATCH 01/16] Host HUD layer: psx_host_set_hud_draw_cb, drawn under the in-game menu A title can draw its own heads-up display on the host side (ImGui, like the in-game overlay provider), once per presented frame -- real and generated frames -- under the in-game menu and with the same thread and GL rules as psx_host_overlay_set_draw_cb. Registered once at title activation; NULL unregisters. No callback registered: no change in behaviour. Used by a modern race HUD for R4 (lap, timing, tachometer, track map) drawn at window resolution instead of the 320x240 native one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rkyma8rbyPiccs9n2bkyG6 --- runtime/include/host_overlay.h | 8 ++++++++ runtime/src/gpu_gl_renderer.c | 6 ++++-- runtime/src/host_overlay.c | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 06c2e504..d1bfcf3f 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -46,6 +46,14 @@ void psx_host_overlay_set_draw_cb(PsxHostOverlayDrawFn fn, void *ctx); int psx_host_overlay_has_draw(void); void psx_host_overlay_draw(int width, int height); /* renderer side */ +/* ---- Game HUD layer --------------------------------------------------------- + * A title's own heads-up display, drawn once per presented frame (real and + * generated) under the in-game menu, same thread and GL rules as the overlay + * callback above. Set once (title activation) and kept for the session; NULL + * unregisters (no wait: the registrant keeps fn and ctx alive for the session). */ +void psx_host_set_hud_draw_cb(PsxHostOverlayDrawFn fn, void *ctx); +void psx_host_hud_draw(int width, int height); /* renderer side */ + /* ---- P3: input sink ----------------------------------------------------- * The sink sees every host SDL event first (main thread), in the normal event * drain and in the host pause loop. Returning non-zero consumes the event. diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index dab81837..67b2f4a0 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -9908,8 +9908,10 @@ static void gl_swap_with_osd(void) { } if (ov_image(3, &px, &ow, &oh) && px) gl_draw_osd_image(px, ow, oh, ww, wh, 0, 0, ww, wh); - /* In-game overlay provider (host_overlay.h P2), over everything - * the host composes; no-op unless one registered. */ + /* The title's HUD layer, then the in-game overlay provider + * (host_overlay.h P2) over everything; each a no-op unless one + * registered. */ + psx_host_hud_draw(ww, wh); psx_host_overlay_draw(ww, wh); } } diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 100f7a97..dfc7536c 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -101,6 +101,20 @@ void psx_host_overlay_draw(int width, int height) { atomic_fetch_sub(&s_draw_inflight, 1); } +/* ---- Game HUD layer ------------------------------------------------------- */ +static PsxHostOverlayDrawFn s_hud_fn = NULL; +static void *s_hud_ctx = NULL; + +void psx_host_set_hud_draw_cb(PsxHostOverlayDrawFn fn, void *ctx) { + s_hud_ctx = ctx; + s_hud_fn = fn; +} + +void psx_host_hud_draw(int width, int height) { + PsxHostOverlayDrawFn fn = s_hud_fn; + if (fn && width > 0 && height > 0) fn(width, height, s_hud_ctx); +} + /* ---- P3: input sink -------------------------------------------------------- */ static PsxHostInputSinkFn s_sink_fn = NULL; static void *s_sink_ctx = NULL; From 594b507efd22394369460b23afe99aba761f6c34 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:02:19 -0700 Subject: [PATCH 02/16] Windows GL: wall-clock pacing instead of driver vsync unless exclusive Windowed or borderless, DWM composes the window and owns the display's vsync, so a driver swap interval only adds a stall. On NVIDIA (RTX 4080 SUPER, 610.88) a vsync'd SwapBuffers holds the GPU queue the render thread submits to: every render-thread frame cost a full refresh (16.7 ms "GPU" at any internal scale, render_busy 100 %), back-pressure slowed the guest to 52-57 Hz, and dynamic resolution dropped for nothing. Measured on R4 0.8 at 7680x4320, windowed and borderless: guest 60.0 Hz, render_busy 5-8 %, GPU 4.4 ms. Exclusive fullscreen keeps driver vsync; PSX_TRUST_DRIVER_VSYNC=1 restores it everywhere. Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b46655f8..221c3194 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -3951,7 +3951,18 @@ static void sync_guest_cadence_to_video_standard(void) { static int host_driver_vsync_unreliable(void) { #ifdef _WIN32 - return 0; + /* Windows OpenGL, windowed or borderless: DWM composes the window and + * owns the display's vsync, so a driver swap interval adds nothing but a + * stall. On NVIDIA (R4 0.8, RTX 4080, 610.88) a vsync'd SwapBuffers holds + * the GPU queue the render thread submits to: the render thread's frame + * then costs a whole refresh (16.7 ms "GPU" at any internal scale, + * render_busy 100 %), back-pressure slows the guest to 52-57 Hz, and + * dynamic resolution drops for nothing. Swap interval 0 under the + * wall-clock pacer: 60.0 Hz, 4.4 ms. Exclusive fullscreen (2) keeps + * driver vsync. PSX_TRUST_DRIVER_VSYNC=1 restores it everywhere. */ + if (const char *e = std::getenv("PSX_TRUST_DRIVER_VSYNC"); e && e[0] && e[0] != '0') + return 0; + return (g_video_renderer == 1 && g_fullscreen != 2) ? 1 : 0; #else static int cached = -1; if (cached >= 0) @@ -4062,8 +4073,14 @@ static void log_present_cadence(void) { !g_netplay_vsync_forced_off) { if (host_driver_vsync_unreliable()) { std::printf("psxrecomp: present cadence: wall-clock pacer " - "(%.4f ms/frame); driver vsync off under WSL\n", - g_frame_period_ms); + "(%.4f ms/frame); driver vsync off (%s)\n", + g_frame_period_ms, +#ifdef _WIN32 + "Windows GL: DWM composes" +#else + "under WSL" +#endif + ); } 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", From 37c1e5254c51ae801b1ae91135234e5c8a0904f8 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:43:34 -0700 Subject: [PATCH 03/16] Apply Internal resolution and Supersampling live Changing the internal resolution in the in-game menu did nothing until a relaunch: ovl_video_from() never copied it, and the restart bits forced a relaunch for it anyway. Supersampling (the [video] supersample multiplier) had no player setting at all. - PsxHostVideoSettings gains supersample_milli; internal resolution and supersample are live changes (PSX_VIDEO_LIVE_RESOLUTION). Only the legacy integer supersampling factor still needs a relaunch. - psx_video_apply_live resolves the new scale and calls gl_renderer_set_internal_scale_live(): it moves the step ceiling (clamped like the boot scale) and queues the step through the dynamic resolution machinery, recorded in stream order with the render thread. The dynamic-resolution controller is retargeted to the new ceiling. - settings.toml [video] supersample is the player's pick (1.0..4.0). - The in-game menu round-trips internal_resolution and, with a launcher that has RECOMP_LAUNCHER_HAS_SUPERSAMPLE, supersample_milli. Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 12 ++++++++++++ recompiler/src/config_loader.h | 3 +++ runtime/include/gpu_gl_renderer.h | 4 ++++ runtime/include/host_overlay.h | 7 +++++-- runtime/src/gpu_gl_renderer.c | 24 +++++++++++++++++++++++ runtime/src/host_overlay.c | 8 ++++++-- runtime/src/main.cpp | 32 +++++++++++++++++++++++++++++++ runtime/tests/test_host_overlay.c | 5 ++++- 8 files changed, 90 insertions(+), 5 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 46161cf4..e3a9ca04 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -3033,6 +3033,15 @@ UserSettings load_user_settings(const fs::path& path) { const auto n = toml::find(v, "supersampling"); if (n >= 1 && n <= 32) { s.supersampling = (int)n; s.has_supersampling = true; } }); + if (v.contains("supersample")) try_get([&]{ + const toml::value& ss = toml::find(v, "supersample"); + const double f = ss.is_integer() ? static_cast(ss.as_integer()) + : toml::get(ss); + if (f >= 1.0 && f <= 4.0) { + s.supersample_milli = static_cast(f * 1000.0 + 0.5); + s.has_supersample = true; + } + }); if (v.contains("internal_resolution")) try_get([&]{ const toml::value& ir = toml::find(v, "internal_resolution"); int value = 0; @@ -3485,6 +3494,9 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { else f << "internal_resolution = " << s.internal_resolution << "\n"; } + if (s.has_supersample) + f << "supersample = " << (s.supersample_milli / 1000.0) << " +"; if (s.has_dynamic_resolution) f << "dynamic_resolution = " << (s.dynamic_resolution ? "true" : "false") << "\n"; if (s.has_dynamic_resolution_min && diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index 7fb4375b..3b384701 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -1561,6 +1561,9 @@ struct UserSettings { // it wins over supersampling, which is still written (capped at 4) so an // older runtime reading the same file degrades gracefully. bool has_internal_resolution = false; int internal_resolution = 0; + // Supersampling on top of the internal resolution, in thousandths + // (1000 off .. 4000), the player's pick of [video] supersample. + bool has_supersample = false; int supersample_milli = 1000; // Dynamic resolution (RuntimeConfig::video_dynamic_resolution, _min). // Written only once the player changed them, so a title's default can // still move in a later release. diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 4a27bdb2..94e0e245 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -395,6 +395,10 @@ int gl_renderer_request_internal_scale(int scale); * 0 when refused or deferred (inside a render pass; it then applies after * the next present). Never call it inside a render pass. */ int gl_renderer_step_internal_scale_now(int scale); +/* Live Internal resolution / Supersampling change: the scale to step to at + * the next frame boundary (the new ceiling). Returns the scale applied, 0 when + * the backend cannot change it live. */ +int gl_renderer_set_internal_scale_live(int scale); typedef struct GlDynresStats { int level, ceiling, pending; /* pending: requested level, 0 = none */ uint64_t steps, deferred, refused; diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 06c2e504..e00d6020 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -75,10 +75,11 @@ typedef struct PsxHostVideoSettings { int dynamic_resolution; /* 0/1 */ int frame_generation; /* 0/1 */ int renderer; /* restart */ - int internal_resolution; /* restart */ - int supersampling; /* restart */ + int internal_resolution; /* live (psx_video_apply_live) */ + int supersampling; /* legacy factor; restart */ int render_thread; /* restart */ int present_thread; /* restart */ + int supersample_milli; /* live: 1000 off, 1250, 1500, 2000 */ } PsxHostVideoSettings; /* Texture filter values: PSX_TEXTURE_FILTER_* and psx_texture_filter_from_ui @@ -99,6 +100,8 @@ typedef struct PsxHostVideoSettings { #define PSX_VIDEO_LIVE_PRESENT_LINEAR (1u << 4) #define PSX_VIDEO_LIVE_DYNRES (1u << 5) #define PSX_VIDEO_LIVE_FRAME_GEN (1u << 6) +/* Internal resolution or supersampling: the scale moves at the next frame. */ +#define PSX_VIDEO_LIVE_RESOLUTION (1u << 7) /* Pure helpers (unit-tested): which live fields differ, and which restart * fields differ from the values the session booted with. */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 098646d3..5d01c7f9 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -11056,6 +11056,30 @@ int gl_renderer_request_internal_scale(int scale) { return 1; } +/* Settings -> Internal resolution / Supersampling changed in game: move to + * the new scale at the next frame boundary, without a relaunch. The step + * machinery dynamic resolution uses does the work; this only moves its + * ceiling (clamped like the boot scale to the full-VRAM limit) and queues the + * step. Returns the scale that will apply, 0 when not possible (windowed + * high-resolution mode, CPU-authoritative dual rendering, no context). */ +int gl_renderer_set_internal_scale_live(int scale) { + if (s_ctx) GL_RT_SYNC("set_internal_scale_live"); + if (!s_ctx || !s_raster_ok || s_hiw || s_cpu_auth_dual) return 0; + int reason = 0; + if (scale < 1) scale = 1; + scale = psx_gl_clamp_full_vram_scale(scale, GL_MAX_INTERNAL_SCALE, s_gl_max_dim, + s_vram_budget, &reason); + if (!s_alloc_scale) { + if (!dyn_resources()) return 0; + s_alloc_scale = s_hr_scale; + } + s_alloc_scale = scale; /* dyn_apply allocates beyond the old surface */ + s_req_scale = scale; + /* Recorded in stream order with the render thread, else queued for the + * next present. */ + return gl_renderer_request_internal_scale(scale) ? scale : 0; +} + int gl_renderer_step_internal_scale_now(int scale) { if (!s_alloc_scale || !s_raster_ok) return 0; if (scale > s_alloc_scale) scale = s_alloc_scale; diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 100f7a97..bd610f97 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -131,6 +131,9 @@ unsigned psx_host_video_live_changes(const PsxHostVideoSettings *a, if (a->present_linear != b->present_linear) m |= PSX_VIDEO_LIVE_PRESENT_LINEAR; if (a->dynamic_resolution != b->dynamic_resolution) m |= PSX_VIDEO_LIVE_DYNRES; if (a->frame_generation != b->frame_generation) m |= PSX_VIDEO_LIVE_FRAME_GEN; + if (a->internal_resolution != b->internal_resolution || + a->supersample_milli != b->supersample_milli) + m |= PSX_VIDEO_LIVE_RESOLUTION; return m; } @@ -139,8 +142,9 @@ unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, unsigned m = 0; if (!boot || !next) return 0; if (boot->renderer != next->renderer) m |= PSX_VIDEO_RESTART_RENDERER; - if (boot->internal_resolution != next->internal_resolution || - boot->supersampling != next->supersampling) + /* Internal resolution and supersample apply live; only the legacy + * integer factor still needs a relaunch. */ + if (boot->supersampling != next->supersampling) m |= PSX_VIDEO_RESTART_RESOLUTION; if (boot->render_thread != next->render_thread || boot->present_thread != next->present_thread) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 484a98f8..304a44c4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10257,6 +10257,7 @@ extern "C" void psx_video_current(PsxHostVideoSettings *v) { v->supersampling = g_video_scale; v->render_thread = g_render_thread ? 1 : 0; v->present_thread = g_present_thread ? 1 : 0; + v->supersample_milli = g_video_ss_milli; if (!g_video_boot_valid) { g_video_boot = *v; g_video_boot_valid = true; } } @@ -10298,6 +10299,23 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { if (!g_video_dynres) g_dynres.active = false; else { dynres_setup(); if (!g_dynres.active && gl_renderer_dynamic_resolution_ceiling() >= 2) g_dynres.active = true; } } + if (m & PSX_VIDEO_LIVE_RESOLUTION) { + /* Internal resolution / Supersampling: the scale moves at the next + * frame (gl_renderer_set_internal_scale_live), no relaunch. */ + if (next->internal_resolution != PSX_IR_UNSET) + g_video_internal_res = next->internal_resolution; + if (next->supersample_milli >= 1000 && next->supersample_milli <= 4000) + g_video_ss_milli = next->supersample_milli; + apply_internal_resolution(match_display_px_h(sdl_window)); + if (g_gl_active && g_video_scale_applies) { + const int applied = gl_renderer_set_internal_scale_live(g_video_scale); + if (applied) { + std::fprintf(stdout, "psxrecomp: internal scale %dx (live) +", applied); + dynres_setup(); /* the new scale is the controller's ceiling */ + } + } + } if ((m & PSX_VIDEO_LIVE_FRAME_GEN) && g_gl_active) { g_frame_generation = next->frame_generation ? 1 : 0; gl_renderer_set_frame_generation(g_frame_generation); @@ -10319,6 +10337,8 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { us.render_thread = next->render_thread != 0; us.has_render_thread = true; us.present_thread = next->present_thread != 0; us.has_present_thread = true; us.internal_resolution = next->internal_resolution; us.has_internal_resolution = true; + us.supersample_milli = next->supersample_milli; + us.has_supersample = next->supersample_milli >= 1000; (void)quality_save_user_settings(g_overlay_settings_path, us); } catch (...) { } @@ -16985,6 +17005,10 @@ PsxHostVideoSettings ovl_video_from(const RecompLauncherCSettings& ls) { v.renderer = ls.renderer; v.render_thread = ls.render_thread ? 1 : 0; v.present_thread = ls.present_thread ? 1 : 0; + if (ls.internal_resolution != 0) v.internal_resolution = ls.internal_resolution; +#if defined(RECOMP_LAUNCHER_HAS_SUPERSAMPLE) + if (ls.supersample_milli >= 1000) v.supersample_milli = ls.supersample_milli; +#endif return v; } void mh_launcher_settings_get(RecompLauncherCSettings* out) { @@ -17003,6 +17027,10 @@ void mh_launcher_settings_get(RecompLauncherCSettings* out) { ls.renderer = v.renderer; ls.render_thread = v.render_thread; ls.present_thread = v.present_thread; + ls.internal_resolution = internal_resolution_for_launcher(); +#if defined(RECOMP_LAUNCHER_HAS_SUPERSAMPLE) + ls.supersample_milli = v.supersample_milli; +#endif #if defined(RECOMP_LAUNCHER_HAS_QUALITY_PRESETS) /* Every row a preset governs, the hidden ones included, as the player * selected them (main-thread snapshot): a pick's next-launch rows and @@ -17050,6 +17078,9 @@ void mh_launcher_game_info(RecompLauncherCGameInfo* out) { gi.num_renderers = 2; gi.has_supersampling = 0; /* restart-only; set before boot */ gi.has_dynamic_resolution = 1; +#if defined(RECOMP_LAUNCHER_HAS_SUPERSAMPLE) + gi.has_supersample = 1; /* live with Internal resolution */ +#endif gi.has_render_pipeline = 1; gi.has_vsync = 0; gi.has_rewind_depth = 0; @@ -18407,6 +18438,7 @@ int main(int argc, char** argv) { g_video_internal_res = PSX_IR_UNSET; } if (us.has_internal_resolution) g_video_internal_res = us.internal_resolution; + if (us.has_supersample) g_video_ss_milli = us.supersample_milli; if (us.has_dynamic_resolution) g_video_dynres = us.dynamic_resolution ? 1 : 0; if (us.has_dynamic_resolution_min) g_video_dynres_min = us.dynamic_resolution_min; if (us.has_window_width) g_video_win_w = us.window_width; diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index 1aaecd67..a3a76be6 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -143,7 +143,10 @@ static void test_video(void) { CHECK(psx_host_video_live_changes(&a, &b) == (PSX_VIDEO_LIVE_SCANLINES | PSX_VIDEO_LIVE_PRESENT_LINEAR | PSX_VIDEO_LIVE_FULLSCREEN)); CHECK(psx_host_video_restart_bits(&a, &b) == 0); - b.internal_resolution = 4; b.present_thread = 1; + b.internal_resolution = 4; b.supersample_milli = 1500; b.present_thread = 1; + CHECK(psx_host_video_live_changes(&a, &b) & PSX_VIDEO_LIVE_RESOLUTION); + CHECK(psx_host_video_restart_bits(&a, &b) == PSX_VIDEO_RESTART_THREADS); + b.supersampling = 2; CHECK(psx_host_video_restart_bits(&a, &b) == (PSX_VIDEO_RESTART_RESOLUTION | PSX_VIDEO_RESTART_THREADS)); b.renderer = 0; CHECK(psx_host_video_restart_bits(&a, &b) & PSX_VIDEO_RESTART_RENDERER); From a8ff6be76960d9c71cc55a26c7b7881cbf453936 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:44:27 -0700 Subject: [PATCH 04/16] Fix the supersample settings writer's newline Co-Authored-By: Claude Opus 5.5 --- recompiler/src/config_loader.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index e3a9ca04..9d13df31 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -3495,8 +3495,7 @@ bool save_user_settings(const fs::path& path, const UserSettings& s) { f << "internal_resolution = " << s.internal_resolution << "\n"; } if (s.has_supersample) - f << "supersample = " << (s.supersample_milli / 1000.0) << " -"; + f << "supersample = " << (s.supersample_milli / 1000.0) << "\n"; if (s.has_dynamic_resolution) f << "dynamic_resolution = " << (s.dynamic_resolution ? "true" : "false") << "\n"; if (s.has_dynamic_resolution_min && From 0aa83bac8cae24da799fa2f9161a983a167499df Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:45:44 -0700 Subject: [PATCH 05/16] Fix the live-scale log line's newline Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 304a44c4..979bf95f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10310,8 +10310,7 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { if (g_gl_active && g_video_scale_applies) { const int applied = gl_renderer_set_internal_scale_live(g_video_scale); if (applied) { - std::fprintf(stdout, "psxrecomp: internal scale %dx (live) -", applied); + std::fprintf(stdout, "psxrecomp: internal scale %dx (live)\n", applied); dynres_setup(); /* the new scale is the controller's ceiling */ } } From 8003db07de1b291db97ab1f2672ffb5a19bb94a7 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:55:28 -0700 Subject: [PATCH 06/16] Live resolution: keep the legacy factor as booted; video_set debug command psx_video_current reported the live scale as the legacy supersampling factor, so a second live change looked like a legacy edit and asked for a relaunch. Report the boot value. Debug-tools builds gain video_set (internal_resolution, supersample) to drive the live path in tests. Co-Authored-By: Claude Opus 5.5 --- runtime/src/debug_server.c | 20 ++++++++++++++++++++ runtime/src/main.cpp | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index af45cb55..e2630733 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -10630,6 +10630,25 @@ static void handle_dynres_check(int id, const char *json) "\"restored\":%d,\"steps\":%s}", id, (unsigned long long)h0, bad, start, out); } +/* {"cmd":"video_set","internal_resolution":N,"supersample":M} - change the + * Internal resolution preset (lines, 1 native, -1 match display) and/or the + * supersample (thousandths) through the in-game menu's live path. Returns the + * restart bits the change still needs (0 = fully live). */ +static void handle_video_set(int id, const char *json) +{ + extern void psx_video_current(PsxHostVideoSettings *v); + extern unsigned psx_video_apply_live(const PsxHostVideoSettings *next); + PsxHostVideoSettings v; + psx_video_current(&v); + const int ir = json_get_int(json, "internal_resolution", 0); + const int ss = json_get_int(json, "supersample", 0); + if (ir) v.internal_resolution = ir; + if (ss) v.supersample_milli = ss; + const unsigned restart = psx_video_apply_live(&v); + send_fmt("{\"id\":%d,\"ok\":true,\"restart\":%u,\"internal_resolution\":%d," + "\"supersample\":%d}\n", id, restart, v.internal_resolution, v.supersample_milli); +} + static void handle_video_info(int id, const char *json) { (void)json; @@ -16446,6 +16465,7 @@ static const CmdEntry s_commands[] = { { "dump_buffer", handle_dump_buffer }, { "wide_full", handle_wide_full }, { "video_info", handle_video_info }, + { "video_set", handle_video_set }, { "hd_textures", handle_hd_textures }, { "dynres", handle_dynres }, { "dynres_force", handle_dynres_force }, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 979bf95f..22860cae 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10254,7 +10254,9 @@ extern "C" void psx_video_current(PsxHostVideoSettings *v) { v->frame_generation = g_frame_generation ? 1 : 0; v->renderer = g_video_renderer; v->internal_resolution = g_video_internal_res; - v->supersampling = g_video_scale; + /* The legacy factor as booted: the live scale moves with the internal + * resolution and supersample, which apply without a relaunch. */ + v->supersampling = g_video_boot_valid ? g_video_boot.supersampling : g_video_scale; v->render_thread = g_render_thread ? 1 : 0; v->present_thread = g_present_thread ? 1 : 0; v->supersample_milli = g_video_ss_milli; From 359019c0525ae67a0770fb4c666ae121f3812e2c Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:45:29 -0700 Subject: [PATCH 07/16] Restamp OpenBIOS for the config-loader change (output unchanged) 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 e8134784..2dd26cd0 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -75a8d9834efb1c994169a939fbe043073b1b4b133bfe7b0235b80338af7f2f08 +2001cd5a292b334da248cd9f6a67aada96973ae5b57479ee2d00d269b699c4b1 From cb17f2f2d4e3bca39412d2fd2d3de69e8a138761 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:17:33 -0700 Subject: [PATCH 08/16] Windows GL vsync workaround: scope to the measured case Narrow host_driver_vsync_unreliable() on Windows to the configuration the stall was measured on: GL active, render thread running, GL_VENDOR NVIDIA, not exclusive fullscreen. Other driver families and the unthreaded path keep driver vsync; PSX_TRUST_DRIVER_VSYNC=1 still forces it everywhere. Evaluated per call, so runtime renderer/fullscreen changes apply. Co-Authored-By: Claude Opus 5.5 --- runtime/include/gpu_gl_renderer.h | 1 + runtime/src/gpu_gl_renderer.c | 8 ++++++++ runtime/src/main.cpp | 15 +++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 4a27bdb2..96a08292 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -607,6 +607,7 @@ int gl_renderer_get_texture_window_batching(void); int gl_renderer_render_thread_start(int max_frames); void gl_renderer_render_thread_stop(void); int gl_renderer_render_thread_active(void); +int gl_renderer_vendor_is_nvidia(void); void gl_renderer_render_thread_frame_boundary(void); void gl_renderer_render_thread_sync(const char *reason); int gl_renderer_render_thread_json(char *out, size_t cap); diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index dab81837..62b9886b 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -397,6 +397,8 @@ static int load_modern_gl(void) { static SDL_Window *s_win = NULL; static SDL_GLContext s_ctx = NULL; static uint16_t *s_vram = NULL; /* CPU VRAM array (gpu.c's storage) */ +/* GL_VENDOR names NVIDIA (captured at context creation). */ +static int s_vendor_nvidia = 0; static int s_swap_interval = 1; /* SDL_GL swap interval (vsync mode) */ /* ---- render thread ([video] render_thread, docs/RENDER_THREAD.md) -------- @@ -6450,6 +6452,10 @@ int gl_renderer_init_context(SDL_Window *win) { const char *gpu = (const char *)glGetString(0x1F01 /* GL_RENDERER */); fprintf(stdout, "psxrecomp: OpenGL context created (%s; %s)\n", ver ? ver : "?", gpu ? gpu : "?"); + { + const char *vendor = (const char *)glGetString(0x1F00 /* GL_VENDOR */); + s_vendor_nvidia = (vendor && strstr(vendor, "NVIDIA")) ? 1 : 0; + } /* All-or-nothing: any missing entry point / failed shader / bad FBO means * the whole GL renderer is unavailable and the runtime stays on the pure @@ -14152,6 +14158,8 @@ void gl_renderer_render_thread_stop(void) { gr_refresh_backend(); } +int gl_renderer_vendor_is_nvidia(void) { return s_vendor_nvidia; } + int gl_renderer_render_thread_active(void) { return s_rth_on; } /* Frame boundary (once per vblank, after the present): hand the context back diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 221c3194..b91d724d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -3958,11 +3958,18 @@ static int host_driver_vsync_unreliable(void) { * then costs a whole refresh (16.7 ms "GPU" at any internal scale, * render_busy 100 %), back-pressure slows the guest to 52-57 Hz, and * dynamic resolution drops for nothing. Swap interval 0 under the - * wall-clock pacer: 60.0 Hz, 4.4 ms. Exclusive fullscreen (2) keeps - * driver vsync. PSX_TRUST_DRIVER_VSYNC=1 restores it everywhere. */ + * wall-clock pacer: 60.0 Hz, 4.4 ms. + * + * Scope is the measured case only: GL renderer active, the render thread + * running, GL_VENDOR NVIDIA, and not exclusive fullscreen (2). Other + * driver families, the unthreaded path and exclusive fullscreen keep + * driver vsync. Re-evaluated per call, so runtime fullscreen/renderer + * changes take effect. PSX_TRUST_DRIVER_VSYNC=1 keeps driver vsync. */ if (const char *e = std::getenv("PSX_TRUST_DRIVER_VSYNC"); e && e[0] && e[0] != '0') return 0; - return (g_video_renderer == 1 && g_fullscreen != 2) ? 1 : 0; + return (g_video_renderer == 1 && g_gl_active && g_fullscreen != 2 && + gl_renderer_render_thread_active() && + gl_renderer_vendor_is_nvidia()) ? 1 : 0; #else static int cached = -1; if (cached >= 0) @@ -4076,7 +4083,7 @@ static void log_present_cadence(void) { "(%.4f ms/frame); driver vsync off (%s)\n", g_frame_period_ms, #ifdef _WIN32 - "Windows GL: DWM composes" + "Windows GL, NVIDIA render thread: DWM composes" #else "under WSL" #endif From b1ce3be00a6181ea66c84134d2bf365a1b02cd94 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:20:55 -0700 Subject: [PATCH 09/16] Live resolution: truthful restart contract per backend Keep the requested internal resolution / supersample (shown and saved) apart from what the backend last applied. When the live change is unavailable (no GL, windowed high-resolution mode, CPU-authoritative dual rendering, no context) or refused, the session scale is left as applied and PSX_VIDEO_RESTART_RESOLUTION stays set, in both psx_video_apply_live and the menu's restart bits (psx_host_video_restart_bits_backend, unit-tested for the capable and refused cases). video_set rejects invalid presets and factors without applying or saving, and reports applied values, capability and pending restart. TCP command index regenerated. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 5 +- runtime/include/gpu_gl_renderer.h | 4 ++ runtime/include/host_overlay.h | 7 +++ runtime/src/debug_server.c | 35 +++++++++++--- runtime/src/gpu_gl_renderer.c | 7 +++ runtime/src/host_overlay.c | 11 +++++ runtime/src/main.cpp | 79 ++++++++++++++++++++++++++----- runtime/tests/test_host_overlay.c | 11 +++++ 8 files changed, 139 insertions(+), 20 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 0d11ca8c..f545a6f1 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**383 commands registered** — 370 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**384 commands registered** — 371 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -85 of 383 have prose above; **298 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. +85 of 384 have prose above; **299 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. @@ -835,6 +835,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `unwatch` | ✓ | | ✓ | | `vblank_rate` | ✓ | | | | `video_info` | ✓ | | ✓ | +| `video_set` | ✓ | | | | `vk_perf` | ✓ | | | | `vram_peek` | ✓ | ✓ | ✓ | | `vsync_query_hle` | ✓ | | | diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 94e0e245..d452a897 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -399,6 +399,10 @@ int gl_renderer_step_internal_scale_now(int scale); * the next frame boundary (the new ceiling). Returns the scale applied, 0 when * the backend cannot change it live. */ int gl_renderer_set_internal_scale_live(int scale); +/* 1 when gl_renderer_set_internal_scale_live can apply on this session (no + * windowed high-resolution mode, no CPU-authoritative dual rendering, a live + * raster context). 0 means a resolution change needs a relaunch. */ +int gl_renderer_internal_scale_live_capable(void); typedef struct GlDynresStats { int level, ceiling, pending; /* pending: requested level, 0 = none */ uint64_t steps, deferred, refused; diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index e00d6020..36eed867 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -109,6 +109,13 @@ unsigned psx_host_video_live_changes(const PsxHostVideoSettings *prev, const PsxHostVideoSettings *next); unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, const PsxHostVideoSettings *next); +/* psx_host_video_restart_bits for a backend that may not move the resolution + * live: when res_live_ok is 0 (backend incapable, or a live change was + * refused), an internal resolution / supersample that differs from boot keeps + * PSX_VIDEO_RESTART_RESOLUTION. */ +unsigned psx_host_video_restart_bits_backend(const PsxHostVideoSettings *boot, + const PsxHostVideoSettings *next, + int res_live_ok); /* Runtime side (main.cpp). */ unsigned psx_video_apply_live(const PsxHostVideoSettings *next); void psx_video_current(PsxHostVideoSettings *out); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index e2630733..d82b07fd 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -10632,21 +10632,42 @@ static void handle_dynres_check(int id, const char *json) /* {"cmd":"video_set","internal_resolution":N,"supersample":M} - change the * Internal resolution preset (lines, 1 native, -1 match display) and/or the - * supersample (thousandths) through the in-game menu's live path. Returns the - * restart bits the change still needs (0 = fully live). */ + * supersample (thousandths, 1000..4000) through the in-game menu's live path. + * Invalid values are rejected without applying or saving anything. Returns + * the restart bits the change still needs (0 = applied live), the requested + * values, and the values the backend actually applied. */ static void handle_video_set(int id, const char *json) { extern void psx_video_current(PsxHostVideoSettings *v); extern unsigned psx_video_apply_live(const PsxHostVideoSettings *next); + extern void psx_video_resolution_applied(int *ir, int *ss_milli, int *live_capable, + int *restart_pending); + const int k_absent = -1000000; + const int ir = json_get_int(json, "internal_resolution", k_absent); + const int ss = json_get_int(json, "supersample", k_absent); + if (ir == k_absent && ss == k_absent) { + send_err(id, "video_set needs internal_resolution and/or supersample"); + return; + } + if (ir != k_absent && !(ir == 1 || ir == -1 || (ir >= 2 && ir <= 8640))) { + send_err(id, "invalid internal_resolution (1 native, -1 match display, 2..8640 lines)"); + return; + } + if (ss != k_absent && (ss < 1000 || ss > 4000)) { + send_err(id, "invalid supersample (1000..4000 thousandths)"); + return; + } PsxHostVideoSettings v; psx_video_current(&v); - const int ir = json_get_int(json, "internal_resolution", 0); - const int ss = json_get_int(json, "supersample", 0); - if (ir) v.internal_resolution = ir; - if (ss) v.supersample_milli = ss; + if (ir != k_absent) v.internal_resolution = ir; + if (ss != k_absent) v.supersample_milli = ss; const unsigned restart = psx_video_apply_live(&v); + int air = 0, ass = 0, cap = 0, pend = 0; + psx_video_resolution_applied(&air, &ass, &cap, &pend); send_fmt("{\"id\":%d,\"ok\":true,\"restart\":%u,\"internal_resolution\":%d," - "\"supersample\":%d}\n", id, restart, v.internal_resolution, v.supersample_milli); + "\"supersample\":%d,\"applied_internal_resolution\":%d," + "\"applied_supersample\":%d,\"live_capable\":%d,\"restart_pending\":%d}\n", + id, restart, v.internal_resolution, v.supersample_milli, air, ass, cap, pend); } static void handle_video_info(int id, const char *json) diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 5d01c7f9..42af8ef5 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -11062,6 +11062,13 @@ int gl_renderer_request_internal_scale(int scale) { * ceiling (clamped like the boot scale to the full-VRAM limit) and queues the * step. Returns the scale that will apply, 0 when not possible (windowed * high-resolution mode, CPU-authoritative dual rendering, no context). */ +/* Whether gl_renderer_set_internal_scale_live can move the scale on this + * session at all: not in windowed high-resolution mode, not with + * CPU-authoritative dual rendering, and only with a live raster context. */ +int gl_renderer_internal_scale_live_capable(void) { + return (s_ctx && s_raster_ok && !s_hiw && !s_cpu_auth_dual) ? 1 : 0; +} + int gl_renderer_set_internal_scale_live(int scale) { if (s_ctx) GL_RT_SYNC("set_internal_scale_live"); if (!s_ctx || !s_raster_ok || s_hiw || s_cpu_auth_dual) return 0; diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index bd610f97..15d12219 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -152,6 +152,17 @@ unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, return m; } +unsigned psx_host_video_restart_bits_backend(const PsxHostVideoSettings *boot, + const PsxHostVideoSettings *next, + int res_live_ok) { + unsigned m = psx_host_video_restart_bits(boot, next); + if (boot && next && !res_live_ok && + (boot->internal_resolution != next->internal_resolution || + boot->supersample_milli != next->supersample_milli)) + m |= PSX_VIDEO_RESTART_RESOLUTION; + return m; +} + /* ---- seamless relaunch ----------------------------------------------------- */ #include #include diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 22860cae..fba5edf2 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10240,6 +10240,38 @@ static void dynres_setup(void) { static std::filesystem::path g_overlay_settings_path; /* settings.toml */ static PsxHostVideoSettings g_video_boot; /* as the session booted */ static bool g_video_boot_valid = false; +/* Live Internal resolution / Supersample. g_video_internal_res and + * g_video_ss_milli are what the player requested (shown and saved); these are + * what the backend last actually applied. g_video_res_live_failed holds a + * relaunch requirement after a live change the backend refused, and + * g_video_res_live_capable mirrors gl_renderer_internal_scale_live_capable() + * for the menu thread. */ +static int g_video_res_applied_ir = PSX_IR_UNSET; +static int g_video_res_applied_ss = 1000; +static std::atomic g_video_res_live_failed{false}; +static std::atomic g_video_res_live_capable{false}; + +static void video_res_refresh_capable(void) { + g_video_res_live_capable.store(g_gl_active && g_video_scale_applies && + gl_renderer_internal_scale_live_capable() != 0); +} + +/* psx_host_video_restart_bits plus the resolution group whenever the + * requested resolution differs from boot and cannot be (or was not) applied + * live on this backend. */ +static unsigned video_restart_bits(const PsxHostVideoSettings *boot, + const PsxHostVideoSettings *next) { + return psx_host_video_restart_bits_backend( + boot, next, g_video_res_live_capable.load() && !g_video_res_live_failed.load()); +} + +extern "C" void psx_video_resolution_applied(int *ir, int *ss_milli, int *live_capable, + int *restart_pending) { + if (ir) *ir = g_video_res_applied_ir; + if (ss_milli) *ss_milli = g_video_res_applied_ss; + if (live_capable) *live_capable = g_video_res_live_capable.load() ? 1 : 0; + if (restart_pending) *restart_pending = g_video_res_live_failed.load() ? 1 : 0; +} extern "C" void psx_video_current(PsxHostVideoSettings *v) { if (!v) return; @@ -10260,7 +10292,12 @@ extern "C" void psx_video_current(PsxHostVideoSettings *v) { v->render_thread = g_render_thread ? 1 : 0; v->present_thread = g_present_thread ? 1 : 0; v->supersample_milli = g_video_ss_milli; - if (!g_video_boot_valid) { g_video_boot = *v; g_video_boot_valid = true; } + video_res_refresh_capable(); + if (!g_video_boot_valid) { + g_video_boot = *v; g_video_boot_valid = true; + g_video_res_applied_ir = v->internal_resolution; + g_video_res_applied_ss = v->supersample_milli; + } } extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { @@ -10306,15 +10343,30 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { * frame (gl_renderer_set_internal_scale_live), no relaunch. */ if (next->internal_resolution != PSX_IR_UNSET) g_video_internal_res = next->internal_resolution; - if (next->supersample_milli >= 1000 && next->supersample_milli <= 4000) + if (next->supersample_milli >= PSX_SS_MILLI_MIN && + next->supersample_milli <= PSX_SS_MILLI_MAX) g_video_ss_milli = next->supersample_milli; - apply_internal_resolution(match_display_px_h(sdl_window)); - if (g_gl_active && g_video_scale_applies) { - const int applied = gl_renderer_set_internal_scale_live(g_video_scale); - if (applied) { - std::fprintf(stdout, "psxrecomp: internal scale %dx (live)\n", applied); - dynres_setup(); /* the new scale is the controller's ceiling */ - } + /* The request is kept (and saved) either way; the session's scale + * only moves when the backend takes it. Otherwise the change waits + * for a relaunch and the restart bits say so. */ + const int prev_scale = g_video_scale; + video_res_refresh_capable(); + int applied = 0; + if (g_video_res_live_capable.load()) { + apply_internal_resolution(match_display_px_h(sdl_window)); + applied = gl_renderer_set_internal_scale_live(g_video_scale); + } + if (applied) { + std::fprintf(stdout, "psxrecomp: internal scale %dx (live)\n", applied); + g_video_res_applied_ir = g_video_internal_res; + g_video_res_applied_ss = g_video_ss_milli; + g_video_res_live_failed.store(false); + dynres_setup(); /* the new scale is the controller's ceiling */ + } else { + g_video_scale = prev_scale; + g_video_res_live_failed.store(true); + std::fprintf(stdout, "psxrecomp: internal resolution change needs a " + "relaunch (live change unavailable)\n"); } } if ((m & PSX_VIDEO_LIVE_FRAME_GEN) && g_gl_active) { @@ -10344,7 +10396,7 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { } catch (...) { } } - return psx_host_video_restart_bits(&g_video_boot, next) | deferred; + return video_restart_bits(&g_video_boot, next) | deferred; } #ifndef PSX_NO_DEBUG_TOOLS @@ -16919,8 +16971,13 @@ unsigned mh_restart_bits(const PsxHostVideoSettings& next) { * with. The live internal resolution moves under dynamic resolution, so * comparing with boot alone flagged edits that never touched it (an * in-game texture filter change relaunched the game). */ - unsigned out = psx_host_video_restart_bits(&g_ovl_video_boot, &next) & + unsigned out = video_restart_bits(&g_ovl_video_boot, &next) & psx_host_video_restart_bits(&now, &next); + /* A resolution edit the backend cannot apply live waits for a relaunch + * (the pure helper treats resolution as live). */ + if ((now.internal_resolution != next.internal_resolution || + now.supersample_milli != next.supersample_milli)) + out |= video_restart_bits(&g_ovl_video_boot, &next) & PSX_VIDEO_RESTART_RESOLUTION; /* In a netplay match only presentation applies live (psx_video_apply_live); * the rest waits for the next session. */ if (g_ovl_netplay && diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index a3a76be6..a53dfbee 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -146,6 +146,17 @@ static void test_video(void) { b.internal_resolution = 4; b.supersample_milli = 1500; b.present_thread = 1; CHECK(psx_host_video_live_changes(&a, &b) & PSX_VIDEO_LIVE_RESOLUTION); CHECK(psx_host_video_restart_bits(&a, &b) == PSX_VIDEO_RESTART_THREADS); + /* Live-capable GL: no relaunch for resolution. Refused / non-GL (hiw, + * CPU-authoritative dual, no context, software): the change waits. */ + CHECK(psx_host_video_restart_bits_backend(&a, &b, 1) == PSX_VIDEO_RESTART_THREADS); + CHECK(psx_host_video_restart_bits_backend(&a, &b, 0) == + (PSX_VIDEO_RESTART_RESOLUTION | PSX_VIDEO_RESTART_THREADS)); + { + PsxHostVideoSettings c2 = a; + c2.supersample_milli = 1250; + CHECK(psx_host_video_restart_bits_backend(&a, &c2, 0) == PSX_VIDEO_RESTART_RESOLUTION); + CHECK(psx_host_video_restart_bits_backend(&a, &a, 0) == 0); + } b.supersampling = 2; CHECK(psx_host_video_restart_bits(&a, &b) == (PSX_VIDEO_RESTART_RESOLUTION | PSX_VIDEO_RESTART_THREADS)); b.renderer = 0; From 0ec7c76017f2100df18a900edf439a5123a3f85e Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:47:38 -0700 Subject: [PATCH 10/16] menu_video_snapshot_test: provide video_restart_bits Live resolution (#737) routes mh_restart_bits through main.cpp's backend-aware video_restart_bits; the fixture extracts mh_restart_bits alone, so it no longer compiled. Map it to the fixture's psx_host_video_restart_bits (no live resolution here). Co-Authored-By: Claude Opus 5.5 --- runtime/tests/test_menu_video_snapshot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/tests/test_menu_video_snapshot.py b/runtime/tests/test_menu_video_snapshot.py index fbb877fa..5fc0c20b 100644 --- a/runtime/tests/test_menu_video_snapshot.py +++ b/runtime/tests/test_menu_video_snapshot.py @@ -31,6 +31,10 @@ extern "C" void psx_video_current(PsxHostVideoSettings *out) { extern "C" unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *b,const PsxHostVideoSettings *n) { return b->renderer!=n->renderer ? PSX_VIDEO_RESTART_RENDERER : 0; } +/* main.cpp's backend-aware wrapper; no live resolution in this fixture. */ +static unsigned video_restart_bits(const PsxHostVideoSettings *b,const PsxHostVideoSettings *n) { + return psx_host_video_restart_bits(b,n); +} extern "C" unsigned psx_host_video_live_changes(const PsxHostVideoSettings *b,const PsxHostVideoSettings *n) { return b->texture_filter!=n->texture_filter ? PSX_VIDEO_LIVE_TEXTURE_FILTER : 0; } From d383ef210b0732b86224a1a5a3f49f4c84a1fecb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:03:06 -0700 Subject: [PATCH 11/16] Drain and reset session HUD callbacks --- runtime/CMakeLists.txt | 1 + runtime/include/host_overlay.h | 6 +++-- runtime/src/gpu_gl_renderer.c | 1 + runtime/src/host_overlay.c | 34 +++++++++++++++++++++++++- runtime/src/mod_runtime.cpp | 3 +++ runtime/tests/test_host_overlay.c | 38 ++++++++++++++++++++++++++++++ runtime/tests/test_mod_runtime.cpp | 6 +++++ 7 files changed, 86 insertions(+), 3 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 0340c49d..85843c06 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1396,6 +1396,7 @@ if(BUILD_TESTING) add_executable(mod_runtime_test src/mod_immediate.c + src/host_overlay.c tests/test_mod_runtime.cpp src/iso_reader_c.cpp src/host_span_ring.c diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index d1bfcf3f..0a2cd648 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -49,8 +49,10 @@ void psx_host_overlay_draw(int width, int height); /* renderer side */ /* ---- Game HUD layer --------------------------------------------------------- * A title's own heads-up display, drawn once per presented frame (real and * generated) under the in-game menu, same thread and GL rules as the overlay - * callback above. Set once (title activation) and kept for the session; NULL - * unregisters (no wait: the registrant keeps fn and ctx alive for the session). */ + * callback above. OpenGL only; Vulkan and the SDL renderer never invoke it. + * Replacing or NULL unregistering drains prior calls as for the overlay above; + * the owner may then release its old context. Registration is session-scoped: + * activation reset and GL shutdown clear it. Register again on activation. */ void psx_host_set_hud_draw_cb(PsxHostOverlayDrawFn fn, void *ctx); void psx_host_hud_draw(int width, int height); /* renderer side */ diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 67b2f4a0..87d2c5bc 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -6550,6 +6550,7 @@ static void pass_resources_release(void); static void present_image_ring_gl_forget(void); void gl_renderer_shutdown(void) { gl_renderer_render_thread_stop(); /* drains; the context comes back to this thread */ + psx_host_set_hud_draw_cb(NULL, NULL); /* After the worker has stopped (it presents, and captures, on its own * thread) and before the context dies: forget the ring's GL names. */ present_image_ring_gl_forget(); diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index dfc7536c..83463a74 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -104,15 +104,47 @@ void psx_host_overlay_draw(int width, int height) { /* ---- Game HUD layer ------------------------------------------------------- */ static PsxHostOverlayDrawFn s_hud_fn = NULL; static void *s_hud_ctx = NULL; +static atomic_flag s_hud_lock = ATOMIC_FLAG_INIT; +static atomic_int s_hud_inflight = 0; +#if defined(_MSC_VER) +static __declspec(thread) int s_in_hud = 0; +#else +static _Thread_local int s_in_hud = 0; +#endif + +static void hud_lock(void) { + while (atomic_flag_test_and_set_explicit(&s_hud_lock, memory_order_acquire)) + draw_yield(); +} +static void hud_unlock(void) { + atomic_flag_clear_explicit(&s_hud_lock, memory_order_release); +} void psx_host_set_hud_draw_cb(PsxHostOverlayDrawFn fn, void *ctx) { + hud_lock(); s_hud_ctx = ctx; s_hud_fn = fn; + hud_unlock(); + /* Match the overlay lifetime contract without allocating callback records. + * The single GL owner may unregister itself; its current call then ends + * before another frame can consume the replacement. */ + if (!s_in_hud) + while (atomic_load(&s_hud_inflight) > 0) draw_yield(); } void psx_host_hud_draw(int width, int height) { + if (width <= 0 || height <= 0) return; + hud_lock(); PsxHostOverlayDrawFn fn = s_hud_fn; - if (fn && width > 0 && height > 0) fn(width, height, s_hud_ctx); + void *ctx = s_hud_ctx; + atomic_fetch_add(&s_hud_inflight, 1); + hud_unlock(); + if (fn) { + s_in_hud = 1; + fn(width, height, ctx); + s_in_hud = 0; + } + atomic_fetch_sub(&s_hud_inflight, 1); } /* ---- P3: input sink -------------------------------------------------------- */ diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 5bfe6257..28792e85 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1,5 +1,6 @@ #include "mod_runtime.h" #include "host_launch_timing.h" +#include "host_overlay.h" #include "cpu_state.h" #include "disc_path.h" @@ -2963,6 +2964,8 @@ bool mod_runtime_read_disc_file_sectors(const std::string& path, uint32_t max_by extern "C" void mod_runtime_activate_plugins(void) { using namespace PSXRecompV4; HostLaunchTimingScope timing(HOST_LAUNCH_PLUGIN_ACTIVATION); + /* Drain session-owned presentation callbacks before their contexts reset. */ + psx_host_set_hud_draw_cb(nullptr, nullptr); mod_reset_plugin_sessions(); RuntimeMods& s = state(); s.ticket_ready = false; diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index 1aaecd67..4045d61e 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -75,6 +75,29 @@ static void* life_reader(void* arg) { while (!atomic_load(&g_stop)) psx_host_overlay_draw(64, 64); return NULL; } +static void* hud_life_reader(void* arg) { + (void)arg; + while (!atomic_load(&g_stop)) psx_host_hud_draw(64, 64); + return NULL; +} +static void test_hud_lifetime(void) { + pthread_t t; + atomic_store(&g_stop, 0); + pthread_create(&t, NULL, hud_life_reader, NULL); + for (int round = 0; round < 100; ++round) { + LifeCtx* c = (LifeCtx*)calloc(1, sizeof(*c)); + atomic_store(&c->alive, 1); + psx_host_set_hud_draw_cb(life_draw, c); + while (atomic_load(&c->calls) < 2) {} + /* Also models the activation-reset / renderer-shutdown clear. */ + psx_host_set_hud_draw_cb(NULL, NULL); + atomic_store(&c->alive, 0); + CHECK(atomic_load(&c->used_dead) == 0); + free(c); + } + atomic_store(&g_stop, 1); + pthread_join(t, NULL); +} static void test_draw_lifetime(void) { pthread_t t; atomic_store(&g_stop, 0); @@ -99,6 +122,7 @@ static void test_draw_lifetime(void) { } #else static void test_draw_lifetime(void) {} +static void test_hud_lifetime(void) {} #endif /* Changing the callback from inside it must not deadlock. */ @@ -114,6 +138,18 @@ static void test_draw_reentrant(void) { psx_host_overlay_draw(64, 64); CHECK(n == 1 && !psx_host_overlay_has_draw()); } +static void hud_self_unregister(int w, int h, void* p) { + (void)w; (void)h; + ++*(int*)p; + psx_host_set_hud_draw_cb(NULL, NULL); +} +static void test_hud_reentrant(void) { + int calls = 0; + psx_host_set_hud_draw_cb(hud_self_unregister, &calls); + psx_host_hud_draw(64, 64); + psx_host_hud_draw(64, 64); + CHECK(calls == 1); +} static int sunk = 0; static int sink(const void *ev, void *ctx) { (void)ctx; sunk++; return *(const int *)ev == 1; } @@ -237,6 +273,8 @@ static void test_cmdline_quote(void) { } int main(void) { + test_hud_lifetime(); + test_hud_reentrant(); test_draw_lifetime(); test_draw_reentrant(); test_cmdline_quote(); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 71680a21..ba605da2 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1,6 +1,7 @@ #include "mod_runtime.h" #include "mod_netplay.h" #include "host_launch_timing.h" +#include "host_overlay.h" #include "host_file_identity.h" #include "mod_packages.h" #include "mod_plugins.h" @@ -107,6 +108,8 @@ extern "C" uint32_t psx_mod_gpu_dma_memory_alloc(uint32_t, uint32_t) { } extern "C" void psx_ram_reset_size_request(void) {} static int hd_shutdown_calls, hd_configure_calls, hd_dump_value; +static int hud_calls; +static void old_session_hud(int, int, void*) { ++hud_calls; } static int hd_replacements_value, hd_reload_calls; static std::string hd_root; extern "C" void gpu_hd_textures_shutdown(void) { ++hd_shutdown_calls; hd_root.clear(); } @@ -724,7 +727,10 @@ int main(int argc, char** argv) { psx_mod_function_entry(&entry_cpu, 0x80003000u); check(g_psx_mod_function_entry_hooks == 0 && active_entry_hits == 0, "function-entry hooks must not run before plugin activation"); + psx_host_set_hud_draw_cb(old_session_hud, nullptr); mod_runtime_activate_plugins(); + psx_host_hud_draw(64, 64); + check(hud_calls == 0, "activation clears the prior session HUD before resetting plugins"); check(activation_calls == 1, "resolved trusted plugin must activate before runtime startup"); entry_cpu.pc = 0x80004000u; From b69502386a76b097208f990d94ad543be907adeb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:03:07 -0700 Subject: [PATCH 12/16] Apply NVIDIA swap policy at worker and fullscreen transitions --- runtime/src/main.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b91d724d..5983243b 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -3967,7 +3967,7 @@ static int host_driver_vsync_unreliable(void) { * changes take effect. PSX_TRUST_DRIVER_VSYNC=1 keeps driver vsync. */ if (const char *e = std::getenv("PSX_TRUST_DRIVER_VSYNC"); e && e[0] && e[0] != '0') return 0; - return (g_video_renderer == 1 && g_gl_active && g_fullscreen != 2 && + return (g_video_renderer == 1 && g_gl_active && s_window_fullscreen.mode != 2 && gl_renderer_render_thread_active() && gl_renderer_vendor_is_nvidia()) ? 1 : 0; #else @@ -8953,9 +8953,11 @@ static bool drain_host_events() { (int)mod)) { const int target = s_window_fullscreen.mode ? 0 : (g_fullscreen ? g_fullscreen : 1); - if (psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, target) == 0) + if (psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, target) == 0) { + apply_present_cadence(); + log_present_cadence(); host_osd_push(target ? "Fullscreen" : "Windowed", 1500); - else + } else host_osd_push("Fullscreen change failed", 1500); } } @@ -10141,6 +10143,10 @@ static void render_thread_vblank(void) { gl_renderer_set_present_thread(g_present_thread, g_present_thread_slots); if (g_gl_active && gr_backend() == GR_BACKEND_OPENGL && gl_renderer_render_thread_start(g_render_thread_frames)) { + /* The NVIDIA policy only becomes eligible once the worker owns + * GL. Update the actual driver interval at that transition. */ + apply_present_cadence(); + log_present_cadence(); std::fprintf(stdout, "psxrecomp: render thread on (OpenGL, %d frame(s) in flight)\n", g_render_thread_frames); if (g_frame_generation) { @@ -10282,8 +10288,11 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { if (m & ~present_only) deferred = PSX_VIDEO_RESTART_NETPLAY; m &= present_only; } - if (m & PSX_VIDEO_LIVE_FULLSCREEN) - (void)psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, next->fullscreen); + if ((m & PSX_VIDEO_LIVE_FULLSCREEN) && + psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, next->fullscreen) == 0) { + apply_present_cadence(); + log_present_cadence(); + } if (m & PSX_VIDEO_LIVE_TEXTURE_FILTER) { g_video_texfilter = psx_texture_filter_from_ui(next->texture_filter); if (g_mod_texfilter < 0) gr_set_texture_filter(g_video_texfilter); From 0980667752c3a30e74e174b8cf773d1ce428c931 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:03:12 -0700 Subject: [PATCH 13/16] Confirm live resolution allocation before reporting success --- runtime/include/gpu_gl_renderer.h | 2 +- runtime/src/gpu_gl_renderer.c | 24 +++++++++-------- runtime/src/main.cpp | 38 ++++++++++++++++++--------- runtime/tests/test_gl_render_thread.c | 23 ++++++++++++++++ runtime/tests/test_host_overlay.c | 8 ++++++ 5 files changed, 71 insertions(+), 24 deletions(-) diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index d452a897..d1cf0020 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -396,7 +396,7 @@ int gl_renderer_request_internal_scale(int scale); * the next present). Never call it inside a render pass. */ int gl_renderer_step_internal_scale_now(int scale); /* Live Internal resolution / Supersampling change: the scale to step to at - * the next frame boundary (the new ceiling). Returns the scale applied, 0 when + * a context-owned sync point (the new ceiling). Returns the scale applied, 0 when * the backend cannot change it live. */ int gl_renderer_set_internal_scale_live(int scale); /* 1 when gl_renderer_set_internal_scale_live can apply on this session (no diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 42af8ef5..6eab0ad8 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -11056,12 +11056,9 @@ int gl_renderer_request_internal_scale(int scale) { return 1; } -/* Settings -> Internal resolution / Supersampling changed in game: move to - * the new scale at the next frame boundary, without a relaunch. The step - * machinery dynamic resolution uses does the work; this only moves its - * ceiling (clamped like the boot scale to the full-VRAM limit) and queues the - * step. Returns the scale that will apply, 0 when not possible (windowed - * high-resolution mode, CPU-authoritative dual rendering, no context). */ +/* Settings -> Internal resolution / Supersampling changed in game. Drain the + * render queue and apply under the owned context before acknowledging it. + * A queued dynamic-resolution step is not proof its allocation succeeded. */ /* Whether gl_renderer_set_internal_scale_live can move the scale on this * session at all: not in windowed high-resolution mode, not with * CPU-authoritative dual rendering, and only with a live raster context. */ @@ -11071,7 +11068,7 @@ int gl_renderer_internal_scale_live_capable(void) { int gl_renderer_set_internal_scale_live(int scale) { if (s_ctx) GL_RT_SYNC("set_internal_scale_live"); - if (!s_ctx || !s_raster_ok || s_hiw || s_cpu_auth_dual) return 0; + if (!s_ctx || !s_raster_ok || s_hiw || s_cpu_auth_dual || s_pass_active) return 0; int reason = 0; if (scale < 1) scale = 1; scale = psx_gl_clamp_full_vram_scale(scale, GL_MAX_INTERNAL_SCALE, s_gl_max_dim, @@ -11080,11 +11077,16 @@ int gl_renderer_set_internal_scale_live(int scale) { if (!dyn_resources()) return 0; s_alloc_scale = s_hr_scale; } - s_alloc_scale = scale; /* dyn_apply allocates beyond the old surface */ + const int previous_ceiling = s_alloc_scale; + s_alloc_scale = scale; /* dyn_apply may allocate beyond the old surface */ + if (!dyn_apply(scale) || s_hr_scale != scale) { + s_alloc_scale = previous_ceiling; + return 0; + } + s_dyn_pending = 0; + s_rthm_scale = scale; s_req_scale = scale; - /* Recorded in stream order with the render thread, else queued for the - * next present. */ - return gl_renderer_request_internal_scale(scale) ? scale : 0; + return scale; } int gl_renderer_step_internal_scale_now(int scale) { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index fba5edf2..b23b3e63 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -10248,27 +10248,43 @@ static bool g_video_boot_valid = false; * for the menu thread. */ static int g_video_res_applied_ir = PSX_IR_UNSET; static int g_video_res_applied_ss = 1000; +static std::mutex g_video_res_applied_mutex; static std::atomic g_video_res_live_failed{false}; static std::atomic g_video_res_live_capable{false}; +static void video_res_set_applied(int ir, int ss) { + std::lock_guard lock(g_video_res_applied_mutex); + g_video_res_applied_ir = ir; + g_video_res_applied_ss = ss; +} + static void video_res_refresh_capable(void) { g_video_res_live_capable.store(g_gl_active && g_video_scale_applies && gl_renderer_internal_scale_live_capable() != 0); } -/* psx_host_video_restart_bits plus the resolution group whenever the - * requested resolution differs from boot and cannot be (or was not) applied - * live on this backend. */ +/* Other restart-only settings still compare against boot. Resolution compares + * against its last confirmed application, including after a live A -> B. */ static unsigned video_restart_bits(const PsxHostVideoSettings *boot, const PsxHostVideoSettings *next) { + if (!boot || !next) return 0; + PsxHostVideoSettings applied = *boot; + { + std::lock_guard lock(g_video_res_applied_mutex); + applied.internal_resolution = g_video_res_applied_ir; + applied.supersample_milli = g_video_res_applied_ss; + } return psx_host_video_restart_bits_backend( - boot, next, g_video_res_live_capable.load() && !g_video_res_live_failed.load()); + &applied, next, 0); } extern "C" void psx_video_resolution_applied(int *ir, int *ss_milli, int *live_capable, int *restart_pending) { - if (ir) *ir = g_video_res_applied_ir; - if (ss_milli) *ss_milli = g_video_res_applied_ss; + { + std::lock_guard lock(g_video_res_applied_mutex); + if (ir) *ir = g_video_res_applied_ir; + if (ss_milli) *ss_milli = g_video_res_applied_ss; + } if (live_capable) *live_capable = g_video_res_live_capable.load() ? 1 : 0; if (restart_pending) *restart_pending = g_video_res_live_failed.load() ? 1 : 0; } @@ -10295,8 +10311,7 @@ extern "C" void psx_video_current(PsxHostVideoSettings *v) { video_res_refresh_capable(); if (!g_video_boot_valid) { g_video_boot = *v; g_video_boot_valid = true; - g_video_res_applied_ir = v->internal_resolution; - g_video_res_applied_ss = v->supersample_milli; + video_res_set_applied(v->internal_resolution, v->supersample_milli); } } @@ -10339,8 +10354,7 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { else { dynres_setup(); if (!g_dynres.active && gl_renderer_dynamic_resolution_ceiling() >= 2) g_dynres.active = true; } } if (m & PSX_VIDEO_LIVE_RESOLUTION) { - /* Internal resolution / Supersampling: the scale moves at the next - * frame (gl_renderer_set_internal_scale_live), no relaunch. */ + /* The GL owner confirms allocation/application before this returns. */ if (next->internal_resolution != PSX_IR_UNSET) g_video_internal_res = next->internal_resolution; if (next->supersample_milli >= PSX_SS_MILLI_MIN && @@ -10358,8 +10372,8 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { } if (applied) { std::fprintf(stdout, "psxrecomp: internal scale %dx (live)\n", applied); - g_video_res_applied_ir = g_video_internal_res; - g_video_res_applied_ss = g_video_ss_milli; + g_video_scale = applied; + video_res_set_applied(g_video_internal_res, g_video_ss_milli); g_video_res_live_failed.store(false); dynres_setup(); /* the new scale is the controller's ceiling */ } else { diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 32d276e7..b55c9680 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -105,6 +105,28 @@ static void check(int ok, const char *label) { checks++; if (!ok) { fprintf(stderr, "FAIL %s\n", label); failures++; } } +/* Force the real allocation-failure branch without exhausting VRAM. The + * fixture includes the backend, so no production fault-injection API is needed. */ +static void (APIENTRY *saved_renderbuffer_storage)(GLenum, GLenum, GLsizei, GLsizei); +static void APIENTRY refuse_renderbuffer_storage(GLenum target, GLenum format, + GLsizei w, GLsizei h) { + (void)format; + saved_renderbuffer_storage(target, 0 /* invalid format */, w, h); +} +static void test_live_resolution(int original_scale) { + check(gl_renderer_set_internal_scale_live(2) == 2, "live scale A applied"); + check(gl_renderer_set_internal_scale_live(1) == 1, "live A to B applied"); + gl_renderer_render_thread_sync("fixture_allocation_refusal"); + saved_renderbuffer_storage = p_glRenderbufferStorage; + p_glRenderbufferStorage = refuse_renderbuffer_storage; + check(gl_renderer_set_internal_scale_live(2) == 0, "live B to A allocation refused"); + p_glRenderbufferStorage = saved_renderbuffer_storage; + check(s_hr_scale == 1 && s_req_scale == 1 && s_alloc_scale == 1, + "refusal preserves applied scale and ceiling"); + check(!s_dyn_pending, "refused live request cannot apply later"); + check(gl_renderer_set_internal_scale_live(original_scale) == original_scale, + "live resolution recovers after refusal"); +} static uint64_t fnv(const void *pp, size_t n, uint64_t h) { const uint8_t *p = (const uint8_t *)pp; for (size_t i = 0; i < n; i++) { h ^= p[i]; h *= 0x100000001b3ull; } @@ -278,6 +300,7 @@ int main(int argc, char **argv) { check(gl_renderer_render_thread_start(2) == 1, "render thread started"); check(gl_renderer_render_thread_active(), "render thread active"); } + test_live_resolution(scale); if (dynres) { check(gl_renderer_dynamic_resolution_ceiling() == scale, "dynres ceiling"); if (threaded) gl_renderer_render_thread_measure(1); diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index a53dfbee..4ebc4eb6 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -156,6 +156,14 @@ static void test_video(void) { c2.supersample_milli = 1250; CHECK(psx_host_video_restart_bits_backend(&a, &c2, 0) == PSX_VIDEO_RESTART_RESOLUTION); CHECK(psx_host_video_restart_bits_backend(&a, &a, 0) == 0); + /* A -> B succeeded, then B -> A was refused. Boot A cannot stand in + * for the applied baseline B when deciding whether restart is needed. */ + PsxHostVideoSettings applied = a; + applied.internal_resolution = c2.internal_resolution; + applied.supersample_milli = c2.supersample_milli; + CHECK(psx_host_video_restart_bits_backend(&applied, &a, 0) == + PSX_VIDEO_RESTART_RESOLUTION); + CHECK(psx_host_video_restart_bits_backend(&applied, &c2, 0) == 0); } b.supersampling = 2; CHECK(psx_host_video_restart_bits(&a, &b) == (PSX_VIDEO_RESTART_RESOLUTION | PSX_VIDEO_RESTART_THREADS)); From 3733036297addf26d7b0a9698163e9f0a991555b Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:25:02 -0700 Subject: [PATCH 14/16] Model non-unwinding guest callback escape in the Windows fixture --- runtime/tests/test_mod_runtime.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index ba605da2..b7331f56 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -181,7 +181,18 @@ static int disabled_entry_hits; static int unselected_entry_hits; static uint32_t active_entry_last; static int entry_test_mode, nested_result; +#if defined(_WIN32) && defined(__GNUC__) && !defined(__clang__) +/* Model a guest scheduler jump which abandons native C++ scopes. Asking the + * Windows CRT longjmp to unwind those scopes can itself fault in SEH, before + * this fixture gets to verify the stranded callback-context guard. */ +static intptr_t callback_escape[5]; +#define callback_setjmp() __builtin_setjmp(callback_escape) +#define callback_longjmp() __builtin_longjmp(callback_escape, 1) +#else static std::jmp_buf callback_escape; +#define callback_setjmp() setjmp(callback_escape) +#define callback_longjmp() std::longjmp(callback_escape, 1) +#endif static uint32_t nested_pc; static void test_active_entry(CPUState* cpu, uint32_t address) { active_entry_hits++; @@ -208,12 +219,12 @@ static void test_active_entry(CPUState* cpu, uint32_t address) { cpu->gpr[2] = 0x87654321u; } else if (entry_test_mode == 5) { /* Guest scheduling/RFE can abandon the callback's C++ scope. */ - std::longjmp(callback_escape, 1); + callback_longjmp(); } else if (entry_test_mode == 6) { if (!psx_mod_finish_function(cpu)) failures++; ModFunctionEntryContext interrupted{}; mod_runtime_function_entry_context_save(&interrupted); - if (setjmp(callback_escape) == 0) { + if (callback_setjmp() == 0) { CPUState nested{}; entry_test_mode = 5; psx_mod_function_entry(&nested, address); @@ -877,7 +888,7 @@ int main(int argc, char** argv) { entry_test_mode = 0; entry_test_mode = 5; - if (setjmp(callback_escape) == 0) { + if (callback_setjmp() == 0) { psx_mod_function_entry(&entry_cpu, 0x80003000u); check(false, "callback must escape without its native cleanup"); } From 62098e320f660b6cbcb49c0a8ab45287ad6dcde4 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:33:26 -0700 Subject: [PATCH 15/16] Separate live allocation probes from scripted dynamic scale counters --- runtime/tests/test_gl_render_thread.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/runtime/tests/test_gl_render_thread.c b/runtime/tests/test_gl_render_thread.c index 5554eff8..0d095002 100644 --- a/runtime/tests/test_gl_render_thread.c +++ b/runtime/tests/test_gl_render_thread.c @@ -306,6 +306,8 @@ int main(int argc, char **argv) { check(gl_renderer_render_thread_active(), "render thread active"); } test_live_resolution(scale); + GlDynresStats live_baseline; + gl_renderer_dynres_stats(&live_baseline); if (dynres) { check(gl_renderer_dynamic_resolution_ceiling() == scale, "dynres ceiling"); if (threaded) gl_renderer_render_thread_measure(1); @@ -332,8 +334,9 @@ int main(int argc, char **argv) { gl_renderer_render_thread_sync("test"); GlDynresStats ds; gl_renderer_dynres_stats(&ds); - printf("dyn_steps=%llu asked=%d\n", (unsigned long long)ds.steps, nsteps); - check(ds.steps == (uint64_t)nsteps && ds.level == scale, "every step applied"); + const uint64_t scripted_steps = ds.steps - live_baseline.steps; + printf("dyn_steps=%llu asked=%d\n", (unsigned long long)scripted_steps, nsteps); + check(scripted_steps == (uint64_t)nsteps && ds.level == scale, "every step applied"); if (threaded) { GlRthCosts co; gl_renderer_render_thread_costs(&co); From 5a8ded2538c88c0a96a52dd8461484d9531c06ae Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Thu, 22 Oct 2026 17:41:08 -0700 Subject: [PATCH 16/16] Reset applied video settings for each execution session --- runtime/src/main.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 459afe17..8fa46316 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -20313,6 +20313,12 @@ int main(int argc, char** argv) { DiscMenuSession disc_menu_session{}; session_reboot: + /* A new backend/session must not inherit the prior applied settings or + * a refused live request. Capture its boot baseline again on first read. */ + g_video_boot_valid = false; + video_res_set_applied(PSX_IR_UNSET, 1000); + g_video_res_live_failed.store(false); + g_video_res_live_capable.store(false); mod_runtime_set_mounted_disc_resolver(nullptr, nullptr); psx_disc_tray_reset_session(); /* Rematch after lobby soft-return re-enters here with updated net_cfg. */