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] 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;