From 138b44a58e5741e1b2383cf7c33c40c8ea0663c3 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:50:44 -0700 Subject: [PATCH 01/25] Host pause API (in-game overlay P1) psx_host_pause_push/pop: a nesting, host-owned pause. While it is held the runtime freezes the guest at the vblank present (as the save-state menu does), keeps presenting the last frame and pauses audio. Push refuses during netplay. Nothing pushes unless an overlay provider (or the debug server's new host_pause command) asks, so behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 3 ++ runtime/include/host_overlay.h | 30 +++++++++++++++++ runtime/include/psx_sdl_audio.h | 3 ++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 13 ++++++++ runtime/src/host_overlay.c | 29 +++++++++++++++++ runtime/src/main.cpp | 54 ++++++++++++++++++++++++++++++- runtime/src/psx_sdl_audio.cpp | 12 +++++++ runtime/tests/test_host_overlay.c | 38 ++++++++++++++++++++++ 9 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 runtime/include/host_overlay.h create mode 100644 runtime/src/host_overlay.c create mode 100644 runtime/tests/test_host_overlay.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 517121c6..4e645678 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -793,6 +793,9 @@ if(BUILD_TESTING) tests/test_pad_external_input.c src/pad_external_input.c) target_include_directories(pad_external_input_test PRIVATE include src) add_test(NAME pad_external_input_test COMMAND pad_external_input_test) + add_executable(host_overlay_test tests/test_host_overlay.c src/host_overlay.c) + target_include_directories(host_overlay_test PRIVATE include src) + add_test(NAME host_overlay_test COMMAND host_overlay_test) add_executable(gte_register_access_test tests/test_gte_register_access.cpp diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h new file mode 100644 index 00000000..b965e587 --- /dev/null +++ b/runtime/include/host_overlay.h @@ -0,0 +1,30 @@ +/* host_overlay.h -- hooks for an optional in-game menu ("overlay provider"). + * + * Every hook is inert until something registers with it: a title with no + * overlay provider runs exactly as before. Sections are added by the + * docs/IN_GAME_OVERLAY.md PR stack (P1 pause, P2 draw, P3 input, ...). + */ +#ifndef PSX_HOST_OVERLAY_H +#define PSX_HOST_OVERLAY_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- P1: host pause -------------------------------------------------------- + * A nesting pause owned by the host (not the guest): while depth > 0 the + * runtime holds the guest at the vblank present, keeps presenting the last + * frame and pauses audio. Push refuses (returns 0) while the refusal probe + * says so -- the runtime installs psx_netplay_active() there, because pausing + * one peer would stall the session. Main thread only. */ +typedef int (*PsxHostPauseRefuseFn)(void); +int psx_host_pause_push(const char *reason); +void psx_host_pause_pop(void); +int psx_host_pause_depth(void); +const char *psx_host_pause_reason(void); +void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/include/psx_sdl_audio.h b/runtime/include/psx_sdl_audio.h index a2055e83..1be1a538 100644 --- a/runtime/include/psx_sdl_audio.h +++ b/runtime/include/psx_sdl_audio.h @@ -21,6 +21,9 @@ typedef struct PsxSdlAudioSpec { SDL_AudioDeviceID psx_sdl_audio_open( const PsxSdlAudioSpec *want, PsxSdlAudioSpec *have); int psx_sdl_audio_resume(SDL_AudioDeviceID device); +/* Host pause (host_overlay.h P1): stop/start the device without dropping + * queued samples. */ +void psx_sdl_audio_set_paused(SDL_AudioDeviceID device, int paused); void psx_sdl_audio_close(SDL_AudioDeviceID device); void psx_sdl_audio_clear(SDL_AudioDeviceID device); Uint32 psx_sdl_audio_queued_size(SDL_AudioDeviceID device); diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index af0abbde..0d37811a 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -390,6 +390,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/psx_openxr.c ${PSXRECOMP_ROOT}/runtime/src/mod_controller_source.c ${PSXRECOMP_ROOT}/runtime/src/pad_external_input.c + ${PSXRECOMP_ROOT}/runtime/src/host_overlay.c ${PSXRECOMP_ROOT}/runtime/src/mod_pad_transform.c ${PSXRECOMP_ROOT}/runtime/src/vr_pose_math.c ${PSXRECOMP_ROOT}/runtime/src/gpu_vk_renderer.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index c04836f5..a5240ff9 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9246,6 +9246,18 @@ static void handle_debug_key(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":%s,\"state\":\"%s\"}", id, ok ? "true" : "false", msg); } +/* host_pause on=<1|0>: push/pop a host pause (host_overlay.h P1). */ +#include "host_overlay.h" +static void handle_host_pause(int id, const char *json) +{ + int on = json_get_int(json, "on", -1); + int ok = 1; + if (on == 1) ok = psx_host_pause_push("debug"); + else if (on == 0) psx_host_pause_pop(); + send_fmt("{\"id\":%d,\"ok\":%s,\"depth\":%d}", id, ok ? "true" : "false", + psx_host_pause_depth()); +} + static void handle_ws_nw(int id, const char *json) { int on = json_get_int(json, "on", -1); @@ -15907,6 +15919,7 @@ static const CmdEntry s_commands[] = { { "window_size", handle_window_size }, { "ws_nw", handle_ws_nw }, { "debug_key", handle_debug_key }, + { "host_pause", handle_host_pause }, { "scanline", handle_scanline }, { "ws_backdrop_ring", handle_ws_backdrop_ring }, { "ws_ui_groups", handle_ws_ui_groups }, diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c new file mode 100644 index 00000000..f4221426 --- /dev/null +++ b/runtime/src/host_overlay.c @@ -0,0 +1,29 @@ +/* host_overlay.c -- see host_overlay.h. */ +#include "host_overlay.h" + +#include + +/* ---- P1: host pause ------------------------------------------------------ */ +#define PSX_HOST_PAUSE_MAX 8 +static int s_pause_depth = 0; +static const char *s_pause_reason[PSX_HOST_PAUSE_MAX]; +static PsxHostPauseRefuseFn s_pause_refuse = NULL; + +int psx_host_pause_push(const char *reason) { + if (s_pause_refuse && s_pause_refuse()) return 0; + if (s_pause_depth >= PSX_HOST_PAUSE_MAX) return 0; + s_pause_reason[s_pause_depth++] = reason ? reason : "host"; + return 1; +} + +void psx_host_pause_pop(void) { + if (s_pause_depth > 0) s_pause_reason[--s_pause_depth] = NULL; +} + +int psx_host_pause_depth(void) { return s_pause_depth; } + +const char *psx_host_pause_reason(void) { + return s_pause_depth > 0 ? s_pause_reason[s_pause_depth - 1] : NULL; +} + +void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn) { s_pause_refuse = fn; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index db52ae0b..798ac7a5 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -125,7 +125,8 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "sbi_setup.h" #include "disc_path.h" #include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ -#include "psx_keybinds.h" /* configurable keyboard->DualShock keybinds (keybinds.ini) */ +#include "psx_keybinds.h" +#include "host_overlay.h" /* optional in-game menu hooks (P1 pause, ...) */ /* configurable keyboard->DualShock keybinds (keybinds.ini) */ #include "psx_window_icon.h" #if defined(RECOMP_LAUNCHER) @@ -8021,6 +8022,53 @@ static void rewind_host_pause_loop(void) { savestate_input_guard_arm(); } +/* Host pause (host_overlay.h, P1). Same freeze as the save-state menu: the + * guest is held at the vblank present, the last frame keeps presenting and + * audio is paused, until every psx_host_pause_push() has been popped. Nothing + * pushes unless an overlay provider is registered, so titles without one never + * enter this loop. */ +static void host_pause_service(void); /* P6 fills this in; no-op until then */ +static void host_pause_service(void) {} +static bool host_pause_event(const SDL_Event& ev); /* P3: input sink */ +static bool host_pause_event(const SDL_Event&) { return false; } + +static void host_pause_loop(void) { + psx_local_mouse_begin(sdl_window, false); + psx_local_mouse_reset(); + freeze_heartbeat_set_paused(1); + psx_sdl_audio_set_paused(sdl_audio_device, 1); + while (psx_host_pause_depth() > 0) { +#ifndef PSX_NO_DEBUG_TOOLS + debug_server_poll(); +#endif + SDL_Event ev; + while (!g_headless && SDL_PollEvent(&ev)) { + (void)psx_local_mouse_event(ev); + if (host_pause_event(ev)) continue; + if (ev.type == SDL_QUIT) { + psx_crash_trace_set_exit_origin("sdl_window_close"); + shutdown_runtime(); + std::exit(0); + } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { + refresh_player_devices(); + } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { + close_controller(); + refresh_player_devices(); + } + } + host_pause_service(); + if (!g_headless) rewind_pause_present(); + starvation_watchdog_heartbeat(); + SDL_Delay(g_headless ? 1 : 8); + } + psx_sdl_audio_set_paused(sdl_audio_device, 0); + freeze_heartbeat_set_paused(0); + /* Swallow the still-held resume press so it doesn't reach the game. */ + savestate_input_guard_arm(); +} + +static int host_pause_refuse_netplay(void) { return psx_netplay_active() ? 1 : 0; } + /* Freeze guest in vblank present while the save-state slot menu is open. */ static void savestate_menu_host_pause_loop(void) { psx_local_mouse_begin(sdl_window, false); @@ -8486,6 +8534,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { savestate_menu_host_pause_loop(); if (psx_rewind_is_open()) rewind_host_pause_loop(); + if (psx_host_pause_depth() > 0) + host_pause_loop(); } else if (host_pad_layer(nullptr, nullptr, nullptr, nullptr)) { /* Headless with the debug host layer: the host shortcuts that need no * window (Rewind capture/open, fast-forward latch) poll as they would @@ -8872,6 +8922,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { // A hotkey acquired during pacing may open a host menu. if (savestate_menu_open) savestate_menu_host_pause_loop(); if (psx_rewind_is_open()) rewind_host_pause_loop(); + if (psx_host_pause_depth() > 0) host_pause_loop(); psx_local_mouse_begin(sdl_window, local_mouse_live(override)); } else { SDL_PumpEvents(); // retain native timing when no policy exists @@ -15888,6 +15939,7 @@ int main(int argc, char** argv) { #if defined(RECOMP_LAUNCHER) launcher_boot_timing_mark("host:main_enter"); #endif + psx_host_pause_set_refuse_probe(host_pause_refuse_netplay); /* --audit-mod-plugins ...: build-time check, no emulation. * Every trusted implementation this executable registered must be named diff --git a/runtime/src/psx_sdl_audio.cpp b/runtime/src/psx_sdl_audio.cpp index f19d0688..95f49772 100644 --- a/runtime/src/psx_sdl_audio.cpp +++ b/runtime/src/psx_sdl_audio.cpp @@ -67,6 +67,13 @@ extern "C" int psx_sdl_audio_resume(SDL_AudioDeviceID) : -1; } +extern "C" void psx_sdl_audio_set_paused(SDL_AudioDeviceID, int paused) +{ + if (!s_audio_stream) return; + if (paused) (void)SDL_PauseAudioStreamDevice(s_audio_stream); + else (void)SDL_ResumeAudioStreamDevice(s_audio_stream); +} + extern "C" void psx_sdl_audio_close(SDL_AudioDeviceID) { if (s_audio_stream) SDL_DestroyAudioStream(s_audio_stream); @@ -140,6 +147,11 @@ extern "C" int psx_sdl_audio_resume(SDL_AudioDeviceID device) return 0; } +extern "C" void psx_sdl_audio_set_paused(SDL_AudioDeviceID device, int paused) +{ + SDL_PauseAudioDevice(device, paused ? 1 : 0); +} + extern "C" void psx_sdl_audio_close(SDL_AudioDeviceID device) { SDL_CloseAudioDevice(device); diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c new file mode 100644 index 00000000..c20842b7 --- /dev/null +++ b/runtime/tests/test_host_overlay.c @@ -0,0 +1,38 @@ +/* test_host_overlay.c -- unit tests for host_overlay.c hooks. */ +#include "host_overlay.h" + +#include +#include + +static int g_fail = 0; +#define CHECK(c) do { if (!(c)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #c); g_fail++; } } while (0) + +static int refuse_on = 0; +static int refuse(void) { return refuse_on; } + +static void test_pause(void) { + CHECK(psx_host_pause_depth() == 0 && psx_host_pause_reason() == NULL); + CHECK(psx_host_pause_push("overlay")); + CHECK(psx_host_pause_push("other")); + CHECK(psx_host_pause_depth() == 2 && strcmp(psx_host_pause_reason(), "other") == 0); + psx_host_pause_pop(); + CHECK(strcmp(psx_host_pause_reason(), "overlay") == 0); + psx_host_pause_pop(); + psx_host_pause_pop(); /* extra pop is harmless */ + CHECK(psx_host_pause_depth() == 0); + psx_host_pause_set_refuse_probe(refuse); + refuse_on = 1; + CHECK(!psx_host_pause_push("netplay") && psx_host_pause_depth() == 0); + refuse_on = 0; + CHECK(psx_host_pause_push(NULL) && strcmp(psx_host_pause_reason(), "host") == 0); + psx_host_pause_pop(); + for (int i = 0; i < 8; i++) CHECK(psx_host_pause_push("n")); + CHECK(!psx_host_pause_push("overflow")); + for (int i = 0; i < 8; i++) psx_host_pause_pop(); +} + +int main(void) { + test_pause(); + printf("host_overlay_test: %s\n", g_fail ? "FAIL" : "ok"); + return g_fail ? 1 : 0; +} From 5c8a0c2d6034ecef4cda3d3eb22f04c2c48aa9da Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:24:31 -0700 Subject: [PATCH 02/25] Host pause: re-anchor pacing on resume; hold the guest headless too Review (#598): resuming left the frame pacer's deadline in the past, so it paid a pause back as up to ~12 unpaced frames. Resume now runs present_session_reset, an audio cycle resync and a dynamic-resolution hold, as a savestate load does. host_pause on=1 headless was a no-op that answered ok; the headless frame path now enters host_pause_loop too. Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 798ac7a5..a27b0d93 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -8063,6 +8063,14 @@ static void host_pause_loop(void) { } psx_sdl_audio_set_paused(sdl_audio_device, 0); freeze_heartbeat_set_paused(0); + /* Resume the way a savestate load does: the pacer's deadline is now in + * the past, and without a re-anchor it pays the pause back as up to ~12 + * unpaced frames (a visible catch-up burst). Re-anchor presentation and + * the FPS baseline, resync guest-cycle audio budgeting, and hold dynamic + * resolution so the pause's wall time is not read as an overrun. */ + present_session_reset(); + g_audio_cycle_resync = 1; + dynres_note_savestate_loaded(); /* Swallow the still-held resume press so it doesn't reach the game. */ savestate_input_guard_arm(); } @@ -8546,6 +8554,11 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (psx_rewind_is_open()) rewind_host_pause_loop(); } + /* Headless: a host pause (debug host_pause, scripted overlay tests) + * holds the guest exactly as it does windowed; the loop polls the debug + * server and needs no window. */ + if (g_headless && psx_host_pause_depth() > 0) + host_pause_loop(); /* Sample each player's device and feed the matching SIO pad slot. * Debug server input override (when active) drives port 1 only. With From bff226bea7e86a0b60359e35cab856b6082beb18 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:34:15 -0700 Subject: [PATCH 03/25] Docs: regenerate TCP_COMMANDS.md for the new debug commands Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 8131a409..77cdf3fb 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**371 commands registered** — 358 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**373 commands registered** — 360 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -83 of 371 have prose above; **288 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. +84 of 373 have prose above; **289 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. @@ -659,6 +659,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `hle_dump` | ✓ | | ✓ | | `host_launch_timings` | ✓ | | ✓ | | `host_osd_shot` | ✓ | | | +| `host_pause` | ✓ | | | | `host_profile` | ✓ | | | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | From 153279824a283fbafe2ca45605a203cc535d14f2 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:51:00 -0700 Subject: [PATCH 04/25] Overlay draw callback (in-game overlay P2) psx_host_overlay_set_draw_cb: the OpenGL backend calls it after the game frame and host OSD layers are composed, before present_shot capture and the swap, on the thread that owns the GL context. Without a registered callback nothing changes. Co-Authored-By: Claude Opus 5.5 --- runtime/include/host_overlay.h | 12 ++++++++++++ runtime/src/gpu_gl_renderer.c | 4 ++++ runtime/src/host_overlay.c | 18 ++++++++++++++++++ runtime/tests/test_host_overlay.c | 20 ++++++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index b965e587..b144c5a1 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -24,6 +24,18 @@ int psx_host_pause_depth(void); const char *psx_host_pause_reason(void); void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn); +/* ---- P2: overlay draw callback --------------------------------------------- + * Called once per presented frame by the OpenGL backend after the game image + * and the host OSD layers are composed, before present_shot capture and the + * swap, with the drawable size in pixels. It runs on the thread that owns the + * GL context (the render thread when it is on, else the main thread), with the + * window framebuffer bound. The callback must leave GL state as it found it. + * Vulkan and the SDL renderer never call it. NULL unregisters. */ +typedef void (*PsxHostOverlayDrawFn)(int width, int height, void *ctx); +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 */ + #ifdef __cplusplus } #endif diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 3fadadf0..f038644e 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -73,6 +73,7 @@ * returns 0 and the runtime falls back to the pure software renderer — no * half-GL hybrid. */ +#include "host_overlay.h" #include "gpu.h" #include "gpu_render.h" #include "gl_scale_limits.h" @@ -9680,6 +9681,9 @@ 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. */ + psx_host_overlay_draw(ww, wh); } } if (!rth_replaying()) host_osd_present_done(); /* replay: done at record */ diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index f4221426..19208cde 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -27,3 +27,21 @@ const char *psx_host_pause_reason(void) { } void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn) { s_pause_refuse = fn; } + +/* ---- P2: overlay draw callback -------------------------------------------- */ +/* Written on the main thread before the overlay can open, read on the GL + * thread; a pointer-sized store, and registration happens once at startup. */ +static PsxHostOverlayDrawFn volatile s_draw_fn = NULL; +static void *volatile s_draw_ctx = NULL; + +void psx_host_overlay_set_draw_cb(PsxHostOverlayDrawFn fn, void *ctx) { + s_draw_ctx = ctx; + s_draw_fn = fn; +} + +int psx_host_overlay_has_draw(void) { return s_draw_fn != NULL; } + +void psx_host_overlay_draw(int width, int height) { + PsxHostOverlayDrawFn fn = s_draw_fn; + if (fn && width > 0 && height > 0) fn(width, height, s_draw_ctx); +} diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index c20842b7..17d6bd6d 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -31,7 +31,27 @@ static void test_pause(void) { for (int i = 0; i < 8; i++) psx_host_pause_pop(); } +static int draws = 0, last_w = 0, last_h = 0; +static void *last_ctx = NULL; +static void on_draw(int w, int h, void *ctx) { draws++; last_w = w; last_h = h; last_ctx = ctx; } + +static void test_draw(void) { + int tag = 7; + CHECK(!psx_host_overlay_has_draw()); + psx_host_overlay_draw(640, 480); /* no callback: nothing happens */ + psx_host_overlay_set_draw_cb(on_draw, &tag); + CHECK(psx_host_overlay_has_draw()); + psx_host_overlay_draw(1280, 800); + CHECK(draws == 1 && last_w == 1280 && last_h == 800 && last_ctx == &tag); + psx_host_overlay_draw(0, 800); /* minimised window: skipped */ + CHECK(draws == 1); + psx_host_overlay_set_draw_cb(NULL, NULL); + psx_host_overlay_draw(1280, 800); + CHECK(draws == 1 && !psx_host_overlay_has_draw()); +} + int main(void) { + test_draw(); test_pause(); printf("host_overlay_test: %s\n", g_fail ? "FAIL" : "ok"); return g_fail ? 1 : 0; From 8b003a591dfbb06a09eaee1a88822acdb02e2c80 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:25:21 -0700 Subject: [PATCH 05/25] Overlay draw callback: publish fn+ctx atomically; link it in GL fixtures Review (#599): fn and ctx were two separate stores read on the render thread, so an unregister could pair a live fn with a NULL ctx. They are now one immutable record behind an acquire/release atomic pointer. The GL fixture runners compile a fixed source list; gpu_gl_renderer.c now calls psx_host_overlay_draw, so every run_gl_*.py links host_overlay.c. Co-Authored-By: Claude Opus 5.5 --- runtime/src/host_overlay.c | 36 ++++++++++++++++++------ runtime/tests/run_gl_frame_gen.py | 3 ++ runtime/tests/run_gl_readback_region.py | 3 ++ runtime/tests/run_gl_render_thread.py | 3 ++ runtime/tests/run_gl_scale_invariance.py | 3 ++ runtime/tests/run_gl_texture_filter.py | 3 ++ 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 19208cde..9374c11e 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -1,7 +1,9 @@ /* host_overlay.c -- see host_overlay.h. */ #include "host_overlay.h" +#include #include +#include /* ---- P1: host pause ------------------------------------------------------ */ #define PSX_HOST_PAUSE_MAX 8 @@ -29,19 +31,35 @@ const char *psx_host_pause_reason(void) { void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn) { s_pause_refuse = fn; } /* ---- P2: overlay draw callback -------------------------------------------- */ -/* Written on the main thread before the overlay can open, read on the GL - * thread; a pointer-sized store, and registration happens once at startup. */ -static PsxHostOverlayDrawFn volatile s_draw_fn = NULL; -static void *volatile s_draw_ctx = NULL; +/* Registered on the main thread, read on the render / present thread. The + * function and its context are published together as one immutable record + * behind an atomic pointer (release on store, acquire on load), so a reader + * can never pair a live function with a stale or NULL context. Records are + * never freed: registration happens a handful of times per process, and a + * reader may still hold the previous record when it is replaced. */ +typedef struct { + PsxHostOverlayDrawFn fn; + void *ctx; +} PsxHostOverlayDraw; + +static _Atomic(PsxHostOverlayDraw *) s_draw = NULL; void psx_host_overlay_set_draw_cb(PsxHostOverlayDrawFn fn, void *ctx) { - s_draw_ctx = ctx; - s_draw_fn = fn; + PsxHostOverlayDraw *rec = NULL; + if (fn) { + rec = (PsxHostOverlayDraw *)malloc(sizeof(*rec)); + if (!rec) return; + rec->fn = fn; + rec->ctx = ctx; + } + atomic_store_explicit(&s_draw, rec, memory_order_release); } -int psx_host_overlay_has_draw(void) { return s_draw_fn != NULL; } +int psx_host_overlay_has_draw(void) { + return atomic_load_explicit(&s_draw, memory_order_acquire) != NULL; +} void psx_host_overlay_draw(int width, int height) { - PsxHostOverlayDrawFn fn = s_draw_fn; - if (fn && width > 0 && height > 0) fn(width, height, s_draw_ctx); + const PsxHostOverlayDraw *rec = atomic_load_explicit(&s_draw, memory_order_acquire); + if (rec && rec->fn && width > 0 && height > 0) rec->fn(width, height, rec->ctx); } diff --git a/runtime/tests/run_gl_frame_gen.py b/runtime/tests/run_gl_frame_gen.py index 215ae438..e57ec21d 100644 --- a/runtime/tests/run_gl_frame_gen.py +++ b/runtime/tests/run_gl_frame_gen.py @@ -95,6 +95,9 @@ def main(): if WINDOWS: sections.append("-flto") objs = [] + # The renderer calls the in-game overlay hook (host_overlay.c) at present. + if (src / "host_overlay.c").exists(): + sources.append(("hov", src / "host_overlay.c")) for name, path in sources: o = dest / (name + ".o") r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", diff --git a/runtime/tests/run_gl_readback_region.py b/runtime/tests/run_gl_readback_region.py index fbc0f768..39c18680 100644 --- a/runtime/tests/run_gl_readback_region.py +++ b/runtime/tests/run_gl_readback_region.py @@ -77,6 +77,9 @@ def main(): sources.append(("pth", framework / "runtime/src/present_thread.c")) if (framework / "runtime/src/frame_gen.c").exists(): sources.append(("fg", framework / "runtime/src/frame_gen.c")) + # The renderer calls the in-game overlay hook (host_overlay.c) at present. + if (framework / "runtime/src" / "host_overlay.c").exists(): + sources.append(("hov", framework / "runtime/src" / "host_overlay.c")) for name, source in sources: if run([compiler / "gcc.exe", "-std=c11", "-O2", "-flto", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", *includes, "-c", source, diff --git a/runtime/tests/run_gl_render_thread.py b/runtime/tests/run_gl_render_thread.py index 7bd3c578..cf088417 100644 --- a/runtime/tests/run_gl_render_thread.py +++ b/runtime/tests/run_gl_render_thread.py @@ -94,6 +94,9 @@ def main(): if WINDOWS: sections.append("-flto") objs = [] + # The renderer calls the in-game overlay hook (host_overlay.c) at present. + if (src / "host_overlay.c").exists(): + sources.append(("hov", src / "host_overlay.c")) for name, path in sources: o = dest / (name + ".o") r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", diff --git a/runtime/tests/run_gl_scale_invariance.py b/runtime/tests/run_gl_scale_invariance.py index b1d09cab..d0e055c7 100644 --- a/runtime/tests/run_gl_scale_invariance.py +++ b/runtime/tests/run_gl_scale_invariance.py @@ -154,6 +154,9 @@ def main(): if passes: sections.append("-DPSX_TEST_RENDER_PASSES=1") objs = [] + # The renderer calls the in-game overlay hook (host_overlay.c) at present. + if (framework / "runtime/src" / "host_overlay.c").exists(): + sources.append(("hov", framework / "runtime/src" / "host_overlay.c")) for name, src in sources: o = dest / (name + ".o") r = run([args.cc, "-std=gnu11", "-O1", "-DPSX_SDL3=1", "-DPSX_NO_DEBUG_TOOLS=1", diff --git a/runtime/tests/run_gl_texture_filter.py b/runtime/tests/run_gl_texture_filter.py index 75c379a4..1c0a4291 100644 --- a/runtime/tests/run_gl_texture_filter.py +++ b/runtime/tests/run_gl_texture_filter.py @@ -52,6 +52,9 @@ def main(): if (fw/'runtime/src/frame_gen.c').exists(): sources.append(('fg',fw/'runtime/src/frame_gen.c')) objects=[] + # The renderer calls the in-game overlay hook (host_overlay.c) at present. + if (fw/'runtime/src' / "host_overlay.c").exists(): + sources.append(("hov", fw/'runtime/src' / "host_overlay.c")) for name,source in sources: obj=out/(name+'.o'); run([args.cc,*flags,'-c',source,'-o',obj]); objects.append(obj) if args.hd_pack: From facdf01ac6c4941e3e5a0a54154be0bf02071307 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:10:16 -0700 Subject: [PATCH 06/25] Overlay draw callback: unregister waits for in-flight draws (lifetime contract) Review (#599): who keeps the callback context alive until render-thread calls are quiescent. Draw calls now run inside an in-flight count, and set_draw_cb (replace or NULL) swaps the record and waits until no call that read the old one is running, then frees it. When it returns, the registrant may free ctx and its GL objects; it must unregister before destroying them or the context. A change from inside the callback does not wait. Documented in host_overlay.h. Tests: a reader thread drawing while the owner unregisters and frees (300 rounds; fails without the wait), and self-unregister from the callback. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 2 + runtime/include/host_overlay.h | 10 ++++- runtime/src/host_overlay.c | 54 ++++++++++++++++++++++---- runtime/tests/test_host_overlay.c | 63 +++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 9 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 4e645678..69f7b9f8 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -795,6 +795,8 @@ if(BUILD_TESTING) add_test(NAME pad_external_input_test COMMAND pad_external_input_test) add_executable(host_overlay_test tests/test_host_overlay.c src/host_overlay.c) target_include_directories(host_overlay_test PRIVATE include src) + find_package(Threads REQUIRED) + target_link_libraries(host_overlay_test PRIVATE Threads::Threads) add_test(NAME host_overlay_test COMMAND host_overlay_test) add_executable(gte_register_access_test diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index b144c5a1..89e44d62 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -30,7 +30,15 @@ void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn); * swap, with the drawable size in pixels. It runs on the thread that owns the * GL context (the render thread when it is on, else the main thread), with the * window framebuffer bound. The callback must leave GL state as it found it. - * Vulkan and the SDL renderer never call it. NULL unregisters. */ + * Vulkan and the SDL renderer never call it. NULL unregisters. + * + * Lifetime contract: the registrant owns ctx. psx_host_overlay_set_draw_cb + * (replace or NULL) returns only after any draw call that may still be using + * the previous fn/ctx has finished, so the registrant may free ctx and the GL + * objects it draws with right after it returns, and must keep them alive + * until then. Unregister before destroying those objects or the GL context + * (e.g. on renderer shutdown). Called from inside the callback it does not + * wait; the current call is then the last one with the old ctx. */ typedef void (*PsxHostOverlayDrawFn)(int width, int height, void *ctx); void psx_host_overlay_set_draw_cb(PsxHostOverlayDrawFn fn, void *ctx); int psx_host_overlay_has_draw(void); diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 9374c11e..3b6f1223 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -4,6 +4,11 @@ #include #include #include +#if defined(_WIN32) +#include +#else +#include +#endif /* ---- P1: host pause ------------------------------------------------------ */ #define PSX_HOST_PAUSE_MAX 8 @@ -33,26 +38,50 @@ void psx_host_pause_set_refuse_probe(PsxHostPauseRefuseFn fn) { s_pause_refuse = /* ---- P2: overlay draw callback -------------------------------------------- */ /* Registered on the main thread, read on the render / present thread. The * function and its context are published together as one immutable record - * behind an atomic pointer (release on store, acquire on load), so a reader - * can never pair a live function with a stale or NULL context. Records are - * never freed: registration happens a handful of times per process, and a - * reader may still hold the previous record when it is replaced. */ + * behind an atomic pointer, so a reader never pairs a live function with a + * stale context. + * + * Lifetime: every call runs inside an in-flight count. Replacing or removing + * the callback swaps the record and then waits until no call that could have + * read the old record is still running, so when psx_host_overlay_set_draw_cb + * returns, the previous fn will not run again and its ctx (and the GL objects + * it draws with) may be freed by the caller. The old record is freed there + * too. A change made from inside the callback itself does not wait (it would + * wait for itself); that caller's own frame is the only one still using the + * old ctx and it returns before the next frame reads the new record. */ typedef struct { PsxHostOverlayDrawFn fn; void *ctx; } PsxHostOverlayDraw; static _Atomic(PsxHostOverlayDraw *) s_draw = NULL; +static atomic_int s_draw_inflight = 0; +#if defined(_MSC_VER) +static __declspec(thread) int s_in_draw = 0; +#else +static _Thread_local int s_in_draw = 0; +#endif + +static void draw_yield(void) { +#if defined(_WIN32) + Sleep(0); +#else + sched_yield(); +#endif +} void psx_host_overlay_set_draw_cb(PsxHostOverlayDrawFn fn, void *ctx) { - PsxHostOverlayDraw *rec = NULL; + PsxHostOverlayDraw *rec = NULL, *old; if (fn) { rec = (PsxHostOverlayDraw *)malloc(sizeof(*rec)); if (!rec) return; rec->fn = fn; rec->ctx = ctx; } - atomic_store_explicit(&s_draw, rec, memory_order_release); + old = atomic_exchange(&s_draw, rec); /* seq_cst: ordered with the readers' count */ + if (s_in_draw) return; /* from inside the callback: see above; old stays (rare) */ + while (atomic_load(&s_draw_inflight) > 0) draw_yield(); + free(old); } int psx_host_overlay_has_draw(void) { @@ -60,6 +89,15 @@ int psx_host_overlay_has_draw(void) { } void psx_host_overlay_draw(int width, int height) { - const PsxHostOverlayDraw *rec = atomic_load_explicit(&s_draw, memory_order_acquire); - if (rec && rec->fn && width > 0 && height > 0) rec->fn(width, height, rec->ctx); + PsxHostOverlayDraw *rec; + if (width <= 0 || height <= 0) return; + atomic_fetch_add(&s_draw_inflight, 1); /* seq_cst, before reading the record */ + rec = atomic_load(&s_draw); + if (rec && rec->fn) { + s_in_draw = 1; + rec->fn(width, height, rec->ctx); + s_in_draw = 0; + } + atomic_fetch_sub(&s_draw_inflight, 1); } + diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index 17d6bd6d..cf2ad265 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -2,6 +2,7 @@ #include "host_overlay.h" #include +#include #include static int g_fail = 0; @@ -50,7 +51,69 @@ static void test_draw(void) { CHECK(draws == 1 && !psx_host_overlay_has_draw()); } +/* Lifetime: once set_draw_cb(NULL) returns, the old callback never runs + * again, so its context can be freed. A reader thread draws continuously; + * the main thread unregisters and immediately marks the context dead. */ +#if !defined(_WIN32) || defined(__MINGW32__) +#include +#include +typedef struct { atomic_int alive; atomic_int calls; atomic_int used_dead; } LifeCtx; +static atomic_int g_stop; +static void life_draw(int w, int h, void* p) { + LifeCtx* c = (LifeCtx*)p; + (void)w; (void)h; + for (volatile int i = 0; i < 2000; ++i) {} /* a frame's worth of work */ + if (!atomic_load(&c->alive)) atomic_fetch_add(&c->used_dead, 1); + atomic_fetch_add(&c->calls, 1); +} +static void* life_reader(void* arg) { + (void)arg; + while (!atomic_load(&g_stop)) psx_host_overlay_draw(64, 64); + return NULL; +} +static void test_draw_lifetime(void) { + pthread_t t; + atomic_store(&g_stop, 0); + pthread_create(&t, NULL, life_reader, NULL); + int dead_uses = 0, total = 0; + for (int round = 0; round < 300; ++round) { + LifeCtx* c = (LifeCtx*)calloc(1, sizeof(*c)); + atomic_store(&c->alive, 1); + psx_host_overlay_set_draw_cb(life_draw, c); + while (atomic_load(&c->calls) < 2) {} + psx_host_overlay_set_draw_cb(NULL, NULL); /* must wait for the reader */ + atomic_store(&c->alive, 0); /* the owner tears down */ + for (volatile int i = 0; i < 20000; ++i) {} + dead_uses += atomic_load(&c->used_dead); + total += atomic_load(&c->calls); + free(c); + } + atomic_store(&g_stop, 1); + pthread_join(t, NULL); + CHECK(total > 0); + CHECK(dead_uses == 0); +} +#else +static void test_draw_lifetime(void) {} +#endif + +/* Changing the callback from inside it must not deadlock. */ +static void self_unregister(int w, int h, void* p) { + (void)w; (void)h; + ++*(int*)p; + psx_host_overlay_set_draw_cb(NULL, NULL); +} +static void test_draw_reentrant(void) { + int n = 0; + psx_host_overlay_set_draw_cb(self_unregister, &n); + psx_host_overlay_draw(64, 64); + psx_host_overlay_draw(64, 64); + CHECK(n == 1 && !psx_host_overlay_has_draw()); +} + int main(void) { + test_draw_lifetime(); + test_draw_reentrant(); test_draw(); test_pause(); printf("host_overlay_test: %s\n", g_fail ? "FAIL" : "ok"); From e21226feb74a0a3c583aa3c58f8bc222e120bbda Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:51:52 -0700 Subject: [PATCH 07/25] Overlay input sink and UI capture gate (in-game overlay P3) psx_host_set_input_sink: an overlay provider sees host SDL events first, in the event drain and the host pause loop, and may consume them. psx_host_set_ui_capture: while on, local game input reads released and centred (netplay included, so peers receive "nothing pressed") and the external-input gate stays closed. Debug builds gain host_key, which pushes a real SDL key event for scripted runs. Inert without a provider. Co-Authored-By: Claude Opus 5.5 --- runtime/include/host_overlay.h | 13 +++++++++ runtime/src/debug_server.c | 13 +++++++++ runtime/src/host_overlay.c | 16 +++++++++++ runtime/src/main.cpp | 37 ++++++++++++++++++++++++- runtime/src/pad_external_input.c | 2 +- runtime/src/pad_external_input.h | 1 + runtime/tests/test_host_overlay.c | 19 +++++++++++++ runtime/tests/test_pad_external_input.c | 2 +- 8 files changed, 100 insertions(+), 3 deletions(-) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 89e44d62..3bb5e8c9 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -44,6 +44,19 @@ 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 */ +/* ---- 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. + * While ui_capture is on, local game input reads as released/centred, in + * netplay too (the peer then simply receives "nothing pressed"), so a menu + * open over a running session never leaks presses into the game. + * `event` is an SDL_Event*. NULL unregisters. */ +typedef int (*PsxHostInputSinkFn)(const void *event, void *ctx); +void psx_host_set_input_sink(PsxHostInputSinkFn fn, void *ctx); +int psx_host_input_sink_dispatch(const void *event); +void psx_host_set_ui_capture(int on); +int psx_host_ui_capture_active(void); + #ifdef __cplusplus } #endif diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index a5240ff9..a95cb426 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9258,6 +9258,18 @@ static void handle_host_pause(int id, const char *json) psx_host_pause_depth()); } +/* host_key name= down=<1|0> (both when omitted). */ +extern int psx_debug_push_host_key(const char *name, int down); +static void handle_host_key(int id, const char *json) +{ + char name[64] = ""; + json_get_str(json, "name", name, sizeof name); + int down = json_get_int(json, "down", -1); + int ok = down < 0 ? (psx_debug_push_host_key(name, 1) && psx_debug_push_host_key(name, 0)) + : psx_debug_push_host_key(name, down); + send_fmt("{\"id\":%d,\"ok\":%s}", id, ok ? "true" : "false"); +} + static void handle_ws_nw(int id, const char *json) { int on = json_get_int(json, "on", -1); @@ -15920,6 +15932,7 @@ static const CmdEntry s_commands[] = { { "ws_nw", handle_ws_nw }, { "debug_key", handle_debug_key }, { "host_pause", handle_host_pause }, + { "host_key", handle_host_key }, { "scanline", handle_scanline }, { "ws_backdrop_ring", handle_ws_backdrop_ring }, { "ws_ui_groups", handle_ws_ui_groups }, diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 3b6f1223..78fe52e3 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -101,3 +101,19 @@ void psx_host_overlay_draw(int width, int height) { atomic_fetch_sub(&s_draw_inflight, 1); } +/* ---- P3: input sink -------------------------------------------------------- */ +static PsxHostInputSinkFn s_sink_fn = NULL; +static void *s_sink_ctx = NULL; +static volatile int s_ui_capture = 0; + +void psx_host_set_input_sink(PsxHostInputSinkFn fn, void *ctx) { + s_sink_fn = fn; + s_sink_ctx = ctx; +} + +int psx_host_input_sink_dispatch(const void *event) { + return (s_sink_fn && event) ? (s_sink_fn(event, s_sink_ctx) != 0) : 0; +} + +void psx_host_set_ui_capture(int on) { s_ui_capture = on ? 1 : 0; } +int psx_host_ui_capture_active(void) { return s_ui_capture; } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a27b0d93..ac7c6160 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6176,6 +6176,8 @@ static int capture_host_layer_pad(PsxNetPad* out, uint16_t word, static int capture_pad_slot_view(int s, PsxNetPad* out, bool guarded, bool host_view) { if (!out) return 0; + /* An open overlay menu owns the controls: the game sees released pads. */ + if (psx_host_ui_capture_active()) guarded = true; out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; out->analog = 0; @@ -6276,6 +6278,12 @@ static int capture_pad_slot_view(int s, PsxNetPad* out, bool guarded, out->buttons = btn; out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; + if (psx_host_ui_capture_active()) { + /* Overlay open during netplay: publish "nothing pressed", which every + * peer simulates identically. */ + out->buttons = 0xFFFFu; + out->lx = out->ly = out->rx = out->ry = 0x80u; + } out->analog = (uint8_t)frame_type; out->connected = 1; return 1; @@ -8030,7 +8038,9 @@ static void rewind_host_pause_loop(void) { static void host_pause_service(void); /* P6 fills this in; no-op until then */ static void host_pause_service(void) {} static bool host_pause_event(const SDL_Event& ev); /* P3: input sink */ -static bool host_pause_event(const SDL_Event&) { return false; } +static bool host_pause_event(const SDL_Event& ev) { + return psx_host_input_sink_dispatch(&ev) != 0; +} static void host_pause_loop(void) { psx_local_mouse_begin(sdl_window, false); @@ -8075,6 +8085,28 @@ static void host_pause_loop(void) { savestate_input_guard_arm(); } +/* Debug: inject a host key / gamepad button as an SDL event, so scripted + * runs can drive host UI (the overlay) through the real event path. */ +extern "C" int psx_debug_push_host_key(const char *name, int down) { + const SDL_Scancode sc = SDL_GetScancodeFromName(name ? name : ""); + if (sc == SDL_SCANCODE_UNKNOWN) return 0; + SDL_Event ev; + SDL_zero(ev); +#if defined(PSX_SDL3) + ev.type = down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP; + ev.key.scancode = sc; + ev.key.key = SDL_GetKeyFromScancode(sc, SDL_KMOD_NONE, false); + ev.key.down = down != 0; + ev.key.windowID = sdl_window ? SDL_GetWindowID(sdl_window) : 0; +#else + ev.type = down ? SDL_KEYDOWN : SDL_KEYUP; + ev.key.keysym.scancode = sc; + ev.key.keysym.sym = SDL_GetKeyFromScancode(sc); + ev.key.state = down ? SDL_PRESSED : SDL_RELEASED; +#endif + return SDL_PushEvent(&ev) ? 1 : 0; +} + static int host_pause_refuse_netplay(void) { return psx_netplay_active() ? 1 : 0; } /* Freeze guest in vblank present while the save-state slot menu is open. */ @@ -8220,6 +8252,8 @@ static bool drain_host_events() { SDL_Event ev; while (SDL_PollEvent(&ev)) { if (psx_local_mouse_event(ev)) continue; + /* In-game overlay provider sees events first (host_overlay.h P3). */ + if (psx_host_input_sink_dispatch(&ev)) continue; if (ev.type == SDL_QUIT) { if (psx_netplay_active()) { netplay_soft_exit("sdl_window_close"); @@ -8366,6 +8400,7 @@ static bool local_mouse_live(int override_word) { g.savestate_menu_open = savestate_menu_open; g.rewind_open = psx_rewind_is_open(); g.input_guard = savestate_input_guard_active(); + g.ui_capture = psx_host_ui_capture_active(); return pad_ext_live(&g) != 0; } diff --git a/runtime/src/pad_external_input.c b/runtime/src/pad_external_input.c index 04c51898..e0ebec74 100644 --- a/runtime/src/pad_external_input.c +++ b/runtime/src/pad_external_input.c @@ -5,7 +5,7 @@ int pad_ext_live(const PadExtGate *g) { return g && !g->injected_input && !g->headless && !g->netplay_active && !g->netplay_resim && !g->selfcheck_locked && !g->selfcheck_resim && !g->render_pass && !g->savestate_menu_open && !g->rewind_open && - !g->input_guard; + !g->input_guard && !g->ui_capture; } /* Stage 2b: run the title transform over the resolved pad and pack its diff --git a/runtime/src/pad_external_input.h b/runtime/src/pad_external_input.h index 6f7598fb..9679f9cd 100644 --- a/runtime/src/pad_external_input.h +++ b/runtime/src/pad_external_input.h @@ -85,6 +85,7 @@ typedef struct PadExtGate { int savestate_menu_open; int rewind_open; int input_guard; /* savestate input guard armed */ + int ui_capture; /* host overlay owns input (host_overlay.h P3) */ } PadExtGate; int pad_ext_live(const PadExtGate *g); diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index cf2ad265..da8ef608 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -111,9 +111,28 @@ static void test_draw_reentrant(void) { CHECK(n == 1 && !psx_host_overlay_has_draw()); } +static int sunk = 0; +static int sink(const void *ev, void *ctx) { (void)ctx; sunk++; return *(const int *)ev == 1; } + +static void test_sink(void) { + int a = 1, b = 2; + CHECK(!psx_host_input_sink_dispatch(&a)); /* none registered */ + psx_host_set_input_sink(sink, NULL); + CHECK(psx_host_input_sink_dispatch(&a) && !psx_host_input_sink_dispatch(&b) && sunk == 2); + CHECK(!psx_host_input_sink_dispatch(NULL) && sunk == 2); + psx_host_set_input_sink(NULL, NULL); + CHECK(!psx_host_input_sink_dispatch(&a)); + CHECK(!psx_host_ui_capture_active()); + psx_host_set_ui_capture(5); + CHECK(psx_host_ui_capture_active() == 1); + psx_host_set_ui_capture(0); + CHECK(!psx_host_ui_capture_active()); +} + int main(void) { test_draw_lifetime(); test_draw_reentrant(); + test_sink(); test_draw(); test_pause(); printf("host_overlay_test: %s\n", g_fail ? "FAIL" : "ok"); diff --git a/runtime/tests/test_pad_external_input.c b/runtime/tests/test_pad_external_input.c index c36fd424..04d9d8c8 100644 --- a/runtime/tests/test_pad_external_input.c +++ b/runtime/tests/test_pad_external_input.c @@ -280,7 +280,7 @@ int main(void) { int *f[] = { &g.injected_input, &g.headless, &g.netplay_active, &g.netplay_resim, &g.selfcheck_locked, &g.selfcheck_resim, &g.render_pass, &g.savestate_menu_open, &g.rewind_open, - &g.input_guard }; + &g.input_guard, &g.ui_capture }; for (unsigned i = 0; i < sizeof f / sizeof f[0]; i++) { *f[i] = 1; CHECK(pad_ext_live(&g) == 0); *f[i] = 0; } From 475a3ad6c5d7be43f6aa4c33df073a946cea36ad Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:05:06 -0700 Subject: [PATCH 08/25] Debug host_mouse: inject a click or wheel step at a window point Co-Authored-By: Claude Opus 5.5 --- runtime/src/debug_server.c | 11 +++++++++++ runtime/src/main.cpp | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index a95cb426..fb4457ad 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9270,6 +9270,16 @@ static void handle_host_key(int id, const char *json) send_fmt("{\"id\":%d,\"ok\":%s}", id, ok ? "true" : "false"); } +/* host_mouse x= y= [wheel=]: click (or scroll) at a window point. */ +extern int psx_debug_push_host_mouse(float x, float y, int wheel); +static void handle_host_mouse(int id, const char *json) +{ + int ok = psx_debug_push_host_mouse((float)json_get_int(json, "x", 0), + (float)json_get_int(json, "y", 0), + json_get_int(json, "wheel", 0)); + send_fmt("{\"id\":%d,\"ok\":%s}", id, ok ? "true" : "false"); +} + static void handle_ws_nw(int id, const char *json) { int on = json_get_int(json, "on", -1); @@ -15933,6 +15943,7 @@ static const CmdEntry s_commands[] = { { "debug_key", handle_debug_key }, { "host_pause", handle_host_pause }, { "host_key", handle_host_key }, + { "host_mouse", handle_host_mouse }, { "scanline", handle_scanline }, { "ws_backdrop_ring", handle_ws_backdrop_ring }, { "ws_ui_groups", handle_ws_ui_groups }, diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index ac7c6160..352af851 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -8107,6 +8107,35 @@ extern "C" int psx_debug_push_host_key(const char *name, int down) { return SDL_PushEvent(&ev) ? 1 : 0; } +/* Debug: inject a mouse click (x, y in window points) or wheel step. */ +extern "C" int psx_debug_push_host_mouse(float x, float y, int wheel) { + SDL_Event ev; + const Uint32 win = sdl_window ? SDL_GetWindowID(sdl_window) : 0; +#if defined(PSX_SDL3) + SDL_zero(ev); + ev.type = SDL_EVENT_MOUSE_MOTION; + ev.motion.windowID = win; ev.motion.x = x; ev.motion.y = y; + if (!SDL_PushEvent(&ev)) return 0; + if (wheel) { + SDL_zero(ev); + ev.type = SDL_EVENT_MOUSE_WHEEL; + ev.wheel.windowID = win; ev.wheel.y = (float)wheel; ev.wheel.mouse_x = x; ev.wheel.mouse_y = y; + return SDL_PushEvent(&ev) ? 1 : 0; + } + for (int down = 1; down >= 0; --down) { + SDL_zero(ev); + ev.type = down ? SDL_EVENT_MOUSE_BUTTON_DOWN : SDL_EVENT_MOUSE_BUTTON_UP; + ev.button.windowID = win; ev.button.button = SDL_BUTTON_LEFT; + ev.button.down = down != 0; ev.button.clicks = 1; ev.button.x = x; ev.button.y = y; + if (!SDL_PushEvent(&ev)) return 0; + } + return 1; +#else + (void)ev; (void)win; (void)x; (void)y; (void)wheel; + return 0; +#endif +} + static int host_pause_refuse_netplay(void) { return psx_netplay_active() ? 1 : 0; } /* Freeze guest in vblank present while the save-state slot menu is open. */ From c73b34d57822d1961ce81dcd06fe759bd25fc935 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:25:40 -0700 Subject: [PATCH 09/25] Overlay input: service the overlay in the low-latency re-drain Review (#600): with low_latency_input on, the re-drain after the pacer wait sampled the pad without host_pause_service, so ui_capture was a frame stale and the overlay's opening chord (Start+Select) could reach the guest for one frame. The re-drain now services the overlay before the sample. Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 352af851..492678c3 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -8996,6 +8996,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (psx_local_mouse_installed()) { psx_local_mouse_begin(sdl_window, local_mouse_live(override)); if (!drain_host_events()) return ep; + /* The overlay may have opened during the pacer wait (its + * Start+Select chord): settle ui_capture before the pad + * sample below, or the chord reaches the guest for a frame. */ + host_pause_service(); // A hotkey acquired during pacing may open a host menu. if (savestate_menu_open) savestate_menu_host_pause_loop(); if (psx_rewind_is_open()) rewind_host_pause_loop(); From 73bb197a03e66768370355fa67e33f66a12d85f3 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:34:16 -0700 Subject: [PATCH 10/25] Docs: regenerate TCP_COMMANDS.md for the new debug commands Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 77cdf3fb..486a7733 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -493,9 +493,9 @@ between Play and TCP availability. ## Complete command index (generated) -**373 commands registered** — 360 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**375 commands registered** — 362 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -84 of 373 have prose above; **289 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. +84 of 375 have prose above; **291 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. @@ -657,7 +657,9 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `hd_textures` | ✓ | | ✓ | | `history` | ✓ | ✓ | ✓ | | `hle_dump` | ✓ | | ✓ | +| `host_key` | ✓ | | | | `host_launch_timings` | ✓ | | ✓ | +| `host_mouse` | ✓ | | | | `host_osd_shot` | ✓ | | | | `host_pause` | ✓ | | | | `host_profile` | ✓ | | | From f739d1feb78b85f5a0e1ed49670aa99596a452db Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:54:03 -0700 Subject: [PATCH 11/25] Live mod option changes (in-game overlay P5) psx_mod_set_option_live records a session override for a committed feature's option and calls the plugin's new option-changed callback in that plugin's context; psx_mod_option_value answers the override. It returns 1 when applied live, 0 when the change waits for a restart, -1 during netplay (the plan is negotiated between peers). Nothing changes unless an overlay calls it. Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 18 ++++++++++ runtime/src/mod_runtime.cpp | 53 ++++++++++++++++++++++++++++-- runtime/tests/test_mod_runtime.cpp | 30 +++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 81e92287..a4af918e 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -372,6 +372,24 @@ int psx_mod_option_value(const char* package_id, const char* feature_id, * The same committed-plan and buffer rules as psx_mod_option_value apply. */ int psx_mod_current_option_value(const char* option_id, char* out, uint32_t out_size); +/* + * Live option changes (in-game overlay P5). A trusted plugin that can re-read + * an option on a running game registers an option-changed callback under its + * plugin id; it runs in that plugin's context (psx_mod_current_option_value + * already answers the new value) and returns 1 if the change took effect now. + * + * psx_mod_set_option_live records a new value for a committed feature's + * option for the rest of the session and notifies its plugin. Returns 1 when + * a plugin applied it live, 0 when it only takes effect after a restart (or + * the feature is not in the plan), -1 when refused: during netplay the plan is + * negotiated between peers and must not change. Persisting the choice stays + * the mod provider's job (mods/state.toml). + */ +typedef int (*PSXModOptionChangedCallback)(const char* option_id, const char* value); +int psx_mod_register_option_changed_plugin(const char* id, + PSXModOptionChangedCallback callback); +int psx_mod_set_option_live(const char* package_id, const char* feature_id, + const char* option_id, const char* value); /* * Read the committed owner-selected path for a resource declared by the * package feature whose trusted plugin is currently running. Returns 0 when diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 92eab572..f1b78fbe 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2824,6 +2824,51 @@ extern "C" int psx_mod_game_started(void) { return fntrace_is_game_started(); } +/* ---- Live option overrides (in-game overlay P5) ------------------------- */ +namespace { +std::map& live_option_values() { + static std::map m; + return m; +} +std::map& option_changed_plugins() { + static std::map m; + return m; +} +std::string live_option_key(const char* p, const char* f, const char* o) { + return std::string(p) + '\x1f' + f + '\x1f' + o; +} +} // namespace + +extern "C" int psx_mod_register_option_changed_plugin(const char* id, + PSXModOptionChangedCallback cb) { + if (!id || !*id || !cb) return 0; + option_changed_plugins()[id] = cb; + return 1; +} + +extern "C" int psx_mod_set_option_live(const char* package_id, const char* feature_id, + const char* option_id, const char* value) { + using namespace PSXRecompV4; + if (!package_id || !feature_id || !option_id || !value) return 0; + if (psx_netplay_active()) return -1; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return 0; + bool in_plan = false; + for (const ModResolution::Plugin& plugin : s.plan.plugins) + if (plugin.package_id == package_id && plugin.feature_id == feature_id) in_plan = true; + if (!in_plan) return 0; + live_option_values()[live_option_key(package_id, feature_id, option_id)] = value; + int live = 0; + for (const ModResolution::Plugin& plugin : s.plan.plugins) { + if (plugin.package_id != package_id || plugin.feature_id != feature_id) continue; + auto it = option_changed_plugins().find(plugin.id); + if (it == option_changed_plugins().end()) continue; + PluginCallbackScope scope(s, &plugin); + if (it->second(option_id, value)) live = 1; + } + return live; +} + extern "C" int psx_mod_option_value(const char* package_id, const char* feature_id, const char* option_id, @@ -2839,9 +2884,11 @@ extern "C" int psx_mod_option_value(const char* package_id, * treat an empty string as a value. */ if (!s.initialized || !s.plan.ok) return 0; /* Read the committed plan's selection, which includes features its - * [[requirement]]s activated, not live launcher state. */ - const std::string value = s.manager.feature_option_value( - s.plan, package_id, feature_id, option_id); + * [[requirement]]s activated, not live launcher state -- unless the + * in-game menu changed it this session (psx_mod_set_option_live). */ + auto live = live_option_values().find(live_option_key(package_id, feature_id, option_id)); + const std::string value = live != live_option_values().end() ? live->second : + s.manager.feature_option_value(s.plan, package_id, feature_id, option_id); if (value.empty()) return 0; if (value.size() + 1 > (size_t)out_size) return 0; std::memcpy(out, value.c_str(), value.size() + 1); diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 0398f7d8..e796afc4 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -318,6 +318,18 @@ int iso_read_cdda_sector(void*,uint32_t,uint8_t*,int); } static int big_ram_activations; +static int option_changed_hits = 0; +static std::string option_changed_value; +static int test_option_changed(const char* option_id, const char* value) { + char cur[32]; + option_changed_hits++; + /* Runs in the plugin's context: current_option_value answers the new value. */ + if (std::string(option_id) == "size" && + psx_mod_current_option_value("size", cur, sizeof(cur)) == 1) + option_changed_value = cur; + (void)value; + return 1; +} static void test_big_ram_activation(void) { big_ram_activations++; } @@ -1082,6 +1094,24 @@ int main(int argc, char** argv) { sizeof(value)) == 1 && std::string(value) == "eight", "an implicit feature's plugin must read its option values"); + /* In-game overlay P5: a live option change reaches the plugin and + * every later read, and is refused while netplay owns the plan. */ + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == 0, + "without an option-changed callback a live set waits for restart"); + check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && + std::string(value) == "four", + "a live override must answer later reads"); + check(psx_mod_register_option_changed_plugin("runtime.big-ram", test_option_changed), + "option-changed hook must register"); + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "eight") == 1 && + option_changed_hits == 1 && option_changed_value == "eight", + "the plugin must see the change in its own context"); + check(psx_mod_set_option_live("runtime.ram", "no-such-feature", "size", "x") == 0, + "features outside the plan are not live-settable"); + test_netplay_active = 1; + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == -1, + "netplay must refuse live option changes"); + test_netplay_active = 0; /* commit rewrites state.toml in canonical form; what matters is that * the player's choice survives and the derived one is never added. */ const std::string after = read_state(); From 00671ce8457efd10dbd7a1890aeb4b9b85178404 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:55:51 -0700 Subject: [PATCH 12/25] Live mod options: psx_mod_option_live_capable Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 3 +++ runtime/src/mod_runtime.cpp | 12 ++++++++++++ runtime/tests/test_mod_runtime.cpp | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index a4af918e..78515797 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -390,6 +390,9 @@ int psx_mod_register_option_changed_plugin(const char* id, PSXModOptionChangedCallback callback); int psx_mod_set_option_live(const char* package_id, const char* feature_id, const char* option_id, const char* value); +/* 1 when the committed feature's plugin registered an option-changed callback + * (so a live set can take effect now). Read-only; any thread. */ +int psx_mod_option_live_capable(const char* package_id, const char* feature_id); /* * Read the committed owner-selected path for a resource declared by the * package feature whose trusted plugin is currently running. Returns 0 when diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index f1b78fbe..9f4d3bc7 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2846,6 +2846,18 @@ extern "C" int psx_mod_register_option_changed_plugin(const char* id, return 1; } +extern "C" int psx_mod_option_live_capable(const char* package_id, const char* feature_id) { + using namespace PSXRecompV4; + if (!package_id || !feature_id) return 0; + RuntimeMods& s = state(); + if (!s.initialized || !s.plan.ok) return 0; + for (const ModResolution::Plugin& plugin : s.plan.plugins) + if (plugin.package_id == package_id && plugin.feature_id == feature_id && + option_changed_plugins().count(plugin.id)) + return 1; + return 0; +} + extern "C" int psx_mod_set_option_live(const char* package_id, const char* feature_id, const char* option_id, const char* value) { using namespace PSXRecompV4; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index e796afc4..d9dc46bb 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1101,8 +1101,12 @@ int main(int argc, char** argv) { check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && std::string(value) == "four", "a live override must answer later reads"); + check(!psx_mod_option_live_capable("runtime.ram", "big-ram"), + "no callback yet: not live-capable"); check(psx_mod_register_option_changed_plugin("runtime.big-ram", test_option_changed), "option-changed hook must register"); + check(psx_mod_option_live_capable("runtime.ram", "big-ram"), + "a registered callback makes the feature live-capable"); check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "eight") == 1 && option_changed_hits == 1 && option_changed_value == "eight", "the plugin must see the change in its own context"); From a32471d5fdc91ea390a12006eee145dfa650af44 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:03:21 -0700 Subject: [PATCH 13/25] Live mod options: restart-only changes do not reach the running plugin Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 5 +++-- runtime/src/mod_runtime.cpp | 4 +++- runtime/tests/test_mod_runtime.cpp | 11 +++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 78515797..ef0f345a 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -380,8 +380,9 @@ int psx_mod_current_option_value(const char* option_id, * * psx_mod_set_option_live records a new value for a committed feature's * option for the rest of the session and notifies its plugin. Returns 1 when - * a plugin applied it live, 0 when it only takes effect after a restart (or - * the feature is not in the plan), -1 when refused: during netplay the plan is + * a plugin applied it live; 0 when the feature has no live-capable plugin (or + * is not in the plan) -- then nothing changes until the next start; -1 when + * refused: during netplay the plan is * negotiated between peers and must not change. Persisting the choice stays * the mod provider's job (mods/state.toml). */ diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index 9f4d3bc7..be4295f7 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2868,7 +2868,9 @@ extern "C" int psx_mod_set_option_live(const char* package_id, const char* featu bool in_plan = false; for (const ModResolution::Plugin& plugin : s.plan.plugins) if (plugin.package_id == package_id && plugin.feature_id == feature_id) in_plan = true; - if (!in_plan) return 0; + if (!in_plan || !psx_mod_option_live_capable(package_id, feature_id)) return 0; + /* Only a feature whose plugin re-reads options live sees the new value; + * anything else keeps its committed value until the next start. */ live_option_values()[live_option_key(package_id, feature_id, option_id)] = value; int live = 0; for (const ModResolution::Plugin& plugin : s.plan.plugins) { diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index d9dc46bb..3d4d62d6 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1099,17 +1099,20 @@ int main(int argc, char** argv) { check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == 0, "without an option-changed callback a live set waits for restart"); check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && - std::string(value) == "four", - "a live override must answer later reads"); + std::string(value) == "eight", + "a restart-only change must not reach the running plugin"); check(!psx_mod_option_live_capable("runtime.ram", "big-ram"), "no callback yet: not live-capable"); check(psx_mod_register_option_changed_plugin("runtime.big-ram", test_option_changed), "option-changed hook must register"); check(psx_mod_option_live_capable("runtime.ram", "big-ram"), "a registered callback makes the feature live-capable"); - check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "eight") == 1 && - option_changed_hits == 1 && option_changed_value == "eight", + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == 1 && + option_changed_hits == 1 && option_changed_value == "four", "the plugin must see the change in its own context"); + check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && + std::string(value) == "four", + "a live override must answer later reads"); check(psx_mod_set_option_live("runtime.ram", "no-such-feature", "size", "x") == 0, "features outside the plan are not live-settable"); test_netplay_active = 1; From ca3994e86dc98ce955be923a918b0950794c9c14 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:52:54 -0700 Subject: [PATCH 14/25] Live video apply (in-game overlay P4) psx_video_apply_live applies the [video] keys a running game can change (fullscreen, texture and FMV filter, scanlines, linear present, dynamic resolution, frame generation) through the existing live setters, saves them to settings.toml, and reports renderer / internal resolution / thread changes as restart-only. Nothing calls it without a provider. Co-Authored-By: Claude Opus 5.5 --- runtime/include/host_overlay.h | 44 ++++++++++++++++++ runtime/src/host_overlay.c | 30 +++++++++++++ runtime/src/main.cpp | 74 +++++++++++++++++++++++++++++++ runtime/tests/test_host_overlay.c | 18 ++++++++ 4 files changed, 166 insertions(+) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 3bb5e8c9..f03edfff 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -57,6 +57,50 @@ int psx_host_input_sink_dispatch(const void *event); void psx_host_set_ui_capture(int on); int psx_host_ui_capture_active(void); +/* ---- P4: live video settings --------------------------------------------- + * The [video] keys an in-game menu may change, in settings.toml terms. + * psx_video_apply_live() applies what can change on a running game, saves + * every field to settings.toml, and returns PSX_VIDEO_RESTART_* bits for the + * fields that only take effect on the next start (compared with boot). Main + * thread only. */ +typedef struct PsxHostVideoSettings { + int fullscreen; /* 0 windowed, 1 borderless, 2 exclusive */ + int texture_filter; /* 0 nearest, 1 bilinear */ + int fmv_filter; /* VIDEO_FMV_FILTER_* (config value) */ + int scanlines; /* 0/1 */ + int scanline_strength_pct; /* 0..100 */ + int present_linear; /* [video] antialiasing (linear present) 0/1 */ + int dynamic_resolution; /* 0/1 */ + int frame_generation; /* 0/1 */ + int renderer; /* restart */ + int internal_resolution; /* restart */ + int supersampling; /* restart */ + int render_thread; /* restart */ + int present_thread; /* restart */ +} PsxHostVideoSettings; + +#define PSX_VIDEO_RESTART_RENDERER (1u << 0) +#define PSX_VIDEO_RESTART_RESOLUTION (1u << 1) +#define PSX_VIDEO_RESTART_THREADS (1u << 2) + +#define PSX_VIDEO_LIVE_FULLSCREEN (1u << 0) +#define PSX_VIDEO_LIVE_TEXTURE_FILTER (1u << 1) +#define PSX_VIDEO_LIVE_FMV_FILTER (1u << 2) +#define PSX_VIDEO_LIVE_SCANLINES (1u << 3) +#define PSX_VIDEO_LIVE_PRESENT_LINEAR (1u << 4) +#define PSX_VIDEO_LIVE_DYNRES (1u << 5) +#define PSX_VIDEO_LIVE_FRAME_GEN (1u << 6) + +/* Pure helpers (unit-tested): which live fields differ, and which restart + * fields differ from the values the session booted with. */ +unsigned psx_host_video_live_changes(const PsxHostVideoSettings *prev, + const PsxHostVideoSettings *next); +unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, + const PsxHostVideoSettings *next); +/* Runtime side (main.cpp). */ +unsigned psx_video_apply_live(const PsxHostVideoSettings *next); +void psx_video_current(PsxHostVideoSettings *out); + #ifdef __cplusplus } #endif diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 78fe52e3..12410352 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -117,3 +117,33 @@ int psx_host_input_sink_dispatch(const void *event) { void psx_host_set_ui_capture(int on) { s_ui_capture = on ? 1 : 0; } int psx_host_ui_capture_active(void) { return s_ui_capture; } + +/* ---- P4: live video settings ------------------------------------------------ */ +unsigned psx_host_video_live_changes(const PsxHostVideoSettings *a, + const PsxHostVideoSettings *b) { + unsigned m = 0; + if (!a || !b) return 0; + if (a->fullscreen != b->fullscreen) m |= PSX_VIDEO_LIVE_FULLSCREEN; + if (a->texture_filter != b->texture_filter) m |= PSX_VIDEO_LIVE_TEXTURE_FILTER; + if (a->fmv_filter != b->fmv_filter) m |= PSX_VIDEO_LIVE_FMV_FILTER; + if (a->scanlines != b->scanlines || a->scanline_strength_pct != b->scanline_strength_pct) + m |= PSX_VIDEO_LIVE_SCANLINES; + 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; + return m; +} + +unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, + const PsxHostVideoSettings *next) { + 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) + m |= PSX_VIDEO_RESTART_RESOLUTION; + if (boot->render_thread != next->render_thread || + boot->present_thread != next->present_thread) + m |= PSX_VIDEO_RESTART_THREADS; + return m; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 35d33cd4..2aa122b7 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9764,6 +9764,79 @@ static void debug_toggle_summary(char *buf, size_t cap) { seam_names[gl_renderer_get_pgxp_seam()], pgxp_cpu_mode() ? "on" : "off"); } +/* ---- Live video apply (host_overlay.h P4) ------------------------------- */ +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; + +extern "C" void psx_video_current(PsxHostVideoSettings *v) { + if (!v) return; + std::memset(v, 0, sizeof *v); + v->fullscreen = s_window_fullscreen.mode; + v->texture_filter = g_video_texfilter; + v->fmv_filter = g_video_fmv_filter; + v->scanlines = g_video_scanlines ? 1 : 0; + v->scanline_strength_pct = (int)(g_video_scanline_strength * 100.0f + 0.5f); + v->present_linear = g_video_aa ? 1 : 0; + v->dynamic_resolution = g_video_dynres ? 1 : 0; + 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; + v->render_thread = g_render_thread ? 1 : 0; + v->present_thread = g_present_thread ? 1 : 0; + if (!g_video_boot_valid) { g_video_boot = *v; g_video_boot_valid = true; } +} + +extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { + if (!next) return 0; + PsxHostVideoSettings cur; + psx_video_current(&cur); + const unsigned m = psx_host_video_live_changes(&cur, next); + if (m & PSX_VIDEO_LIVE_FULLSCREEN) + (void)psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, next->fullscreen); + if (m & PSX_VIDEO_LIVE_TEXTURE_FILTER) { + g_video_texfilter = next->texture_filter ? 1 : 0; + if (g_mod_texfilter < 0) gr_set_texture_filter(g_video_texfilter); + } + if (m & PSX_VIDEO_LIVE_FMV_FILTER) { + g_video_fmv_filter = next->fmv_filter; + if (g_gl_active) gl_renderer_set_fmv_filter(g_video_fmv_filter); + } + if (m & PSX_VIDEO_LIVE_SCANLINES) + psx_video_set_scanlines(next->scanlines, next->scanline_strength_pct / 100.0f); + if (m & PSX_VIDEO_LIVE_PRESENT_LINEAR) g_video_aa = next->present_linear != 0; + if (m & PSX_VIDEO_LIVE_DYNRES) { + g_video_dynres = next->dynamic_resolution ? 1 : 0; + 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_FRAME_GEN) && g_gl_active) { + g_frame_generation = next->frame_generation ? 1 : 0; + gl_renderer_set_frame_generation(g_frame_generation); + } + /* Persist everything the menu shows, live or not, like the launcher. */ + if (!g_overlay_settings_path.empty()) { + try { + PSXRecompV4::UserSettings us = PSXRecompV4::load_user_settings(g_overlay_settings_path); + us.fullscreen = next->fullscreen; us.has_fullscreen = true; + us.texture_filter = next->texture_filter; us.has_texture_filter = true; + us.fmv_filter = next->fmv_filter; us.has_fmv_filter = true; + us.scanlines = next->scanlines != 0; us.has_scanlines = true; + us.scanline_strength = next->scanline_strength_pct / 100.0; us.has_scanline_strength = true; + us.antialiasing = next->present_linear != 0; us.has_antialiasing = true; + us.dynamic_resolution = next->dynamic_resolution != 0; us.has_dynamic_resolution = true; + us.frame_generation = next->frame_generation != 0; us.has_frame_generation = true; + us.renderer = next->renderer; us.has_renderer = true; + us.supersampling = next->supersampling; us.has_supersampling = true; + us.render_thread = next->render_thread != 0; us.has_render_thread = true; + (void)PSXRecompV4::save_user_settings(g_overlay_settings_path, us); + } catch (...) { + } + } + return psx_host_video_restart_bits(&g_video_boot, next); +} + static int debug_toggle_key(int key, char *out, int cap) { char msg[256]; switch (key) { @@ -16924,6 +16997,7 @@ int main(int argc, char** argv) { exe_dir_from_argv(argv[0]) / "settings.toml"; #if defined(RECOMP_LAUNCHER) g_lnch_settings_path = settings_path; + g_overlay_settings_path = settings_path; #endif const PSXRecompV4::UserSettings us = PSXRecompV4::load_user_settings(settings_path); diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index da8ef608..15ffc75c 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -129,9 +129,27 @@ static void test_sink(void) { CHECK(!psx_host_ui_capture_active()); } +static void test_video(void) { + PsxHostVideoSettings a, b; + memset(&a, 0, sizeof a); + a.fmv_filter = 2; a.renderer = 1; a.render_thread = 1; a.scanline_strength_pct = 50; + b = a; + CHECK(psx_host_video_live_changes(&a, &b) == 0 && psx_host_video_restart_bits(&a, &b) == 0); + b.scanline_strength_pct = 60; b.present_linear = 1; b.fullscreen = 1; + 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; + 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); + CHECK(psx_host_video_live_changes(NULL, &b) == 0 && psx_host_video_restart_bits(&a, NULL) == 0); +} + int main(void) { test_draw_lifetime(); test_draw_reentrant(); + test_video(); test_sink(); test_draw(); test_pause(); From 5b454f3db0395f99e7c792eab1b6f58f6289aa1f Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:49:02 -0700 Subject: [PATCH 15/25] Live mod options: psx_mod_save_selection writes state.toml without re-planning Co-Authored-By: Claude Opus 5.5 --- runtime/include/mod_plugins.h | 4 ++++ runtime/src/mod_runtime.cpp | 12 ++++++++++++ runtime/tests/test_mod_runtime.cpp | 1 + 3 files changed, 17 insertions(+) diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index ef0f345a..534e74e3 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -394,6 +394,10 @@ int psx_mod_set_option_live(const char* package_id, const char* feature_id, /* 1 when the committed feature's plugin registered an option-changed callback * (so a live set can take effect now). Read-only; any thread. */ int psx_mod_option_live_capable(const char* package_id, const char* feature_id); +/* Write the player's current selection (feature switches and option values, + * as edited through the launcher mod provider) to mods/state.toml now, + * without re-resolving the running plan. Main thread. 1 on success. */ +int psx_mod_save_selection(void); /* * Read the committed owner-selected path for a resource declared by the * package feature whose trusted plugin is currently running. Returns 0 when diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index be4295f7..e5667c7c 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2858,6 +2858,18 @@ extern "C" int psx_mod_option_live_capable(const char* package_id, const char* f return 0; } +extern "C" int psx_mod_save_selection(void) { + using namespace PSXRecompV4; + RuntimeMods& s = state(); + if (!s.initialized) return 0; + std::string error; + if (!s.manager.save_state(&error)) { + std::fprintf(stderr, "psxrecomp: mods: could not save state.toml: %s\n", error.c_str()); + return 0; + } + return 1; +} + extern "C" int psx_mod_set_option_live(const char* package_id, const char* feature_id, const char* option_id, const char* value) { using namespace PSXRecompV4; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 3d4d62d6..1ac9ebfe 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1115,6 +1115,7 @@ int main(int argc, char** argv) { "a live override must answer later reads"); check(psx_mod_set_option_live("runtime.ram", "no-such-feature", "size", "x") == 0, "features outside the plan are not live-settable"); + check(psx_mod_save_selection() == 1, "saving the selection mid-session must succeed"); test_netplay_active = 1; check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "four") == -1, "netplay must refuse live option changes"); From 09144c8c310d18a12e88c9fe9913b64c419d3037 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:26:31 -0700 Subject: [PATCH 16/25] Live video apply: netplay guard, persist every field, build in Release Review (#601): - psx_video_apply_live had no netplay guard. In a match only presentation (fullscreen, scanlines, linear present) applies live; dynamic resolution, frame generation and the filters are saved and reported with the new PSX_VIDEO_RESTART_NETPLAY bit (next session). - internal_resolution and present_thread are written to UserSettings like the other restart-only fields, so the choice survives a restart. - The live-apply code sat inside #ifndef PSX_NO_DEBUG_TOOLS but is used by the launcher path, so Release + launcher did not compile. It now lives outside the debug-tools block. Co-Authored-By: Claude Opus 5.5 --- runtime/include/host_overlay.h | 4 + runtime/src/main.cpp | 159 ++++++++++++++++++--------------- 2 files changed, 90 insertions(+), 73 deletions(-) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index f03edfff..2cfc9ba4 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -82,6 +82,10 @@ typedef struct PsxHostVideoSettings { #define PSX_VIDEO_RESTART_RENDERER (1u << 0) #define PSX_VIDEO_RESTART_RESOLUTION (1u << 1) #define PSX_VIDEO_RESTART_THREADS (1u << 2) +/* Netplay is running: live changes beyond presentation (dynamic resolution, + * frame generation, filters) were saved but not applied; they take effect in + * the next session. */ +#define PSX_VIDEO_RESTART_NETPLAY (1u << 3) #define PSX_VIDEO_LIVE_FULLSCREEN (1u << 0) #define PSX_VIDEO_LIVE_TEXTURE_FILTER (1u << 1) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 2aa122b7..a51bbf07 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9717,6 +9717,92 @@ static void dynres_setup(void) { floor_s * g_video_ref_lines, ceiling * g_video_ref_lines); } +/* ---- Live video apply (host_overlay.h P4) ------------------------------- */ +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; + +extern "C" void psx_video_current(PsxHostVideoSettings *v) { + if (!v) return; + std::memset(v, 0, sizeof *v); + v->fullscreen = s_window_fullscreen.mode; + v->texture_filter = g_video_texfilter; + v->fmv_filter = g_video_fmv_filter; + v->scanlines = g_video_scanlines ? 1 : 0; + v->scanline_strength_pct = (int)(g_video_scanline_strength * 100.0f + 0.5f); + v->present_linear = g_video_aa ? 1 : 0; + v->dynamic_resolution = g_video_dynres ? 1 : 0; + 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; + v->render_thread = g_render_thread ? 1 : 0; + v->present_thread = g_present_thread ? 1 : 0; + if (!g_video_boot_valid) { g_video_boot = *v; g_video_boot_valid = true; } +} + +extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { + if (!next) return 0; + PsxHostVideoSettings cur; + psx_video_current(&cur); + unsigned m = psx_host_video_live_changes(&cur, next); + unsigned deferred = 0; + /* Netplay: dynamic resolution, frame generation and the filters change + * how a peer renders mid-match; only the presentation of the frame we + * already show may change. The rest is saved for the next session. */ + if (psx_netplay_active()) { + const unsigned present_only = PSX_VIDEO_LIVE_FULLSCREEN | + PSX_VIDEO_LIVE_SCANLINES | + PSX_VIDEO_LIVE_PRESENT_LINEAR; + 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_TEXTURE_FILTER) { + g_video_texfilter = next->texture_filter ? 1 : 0; + if (g_mod_texfilter < 0) gr_set_texture_filter(g_video_texfilter); + } + if (m & PSX_VIDEO_LIVE_FMV_FILTER) { + g_video_fmv_filter = next->fmv_filter; + if (g_gl_active) gl_renderer_set_fmv_filter(g_video_fmv_filter); + } + if (m & PSX_VIDEO_LIVE_SCANLINES) + psx_video_set_scanlines(next->scanlines, next->scanline_strength_pct / 100.0f); + if (m & PSX_VIDEO_LIVE_PRESENT_LINEAR) g_video_aa = next->present_linear != 0; + if (m & PSX_VIDEO_LIVE_DYNRES) { + g_video_dynres = next->dynamic_resolution ? 1 : 0; + 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_FRAME_GEN) && g_gl_active) { + g_frame_generation = next->frame_generation ? 1 : 0; + gl_renderer_set_frame_generation(g_frame_generation); + } + /* Persist everything the menu shows, live or not, like the launcher. */ + if (!g_overlay_settings_path.empty()) { + try { + PSXRecompV4::UserSettings us = PSXRecompV4::load_user_settings(g_overlay_settings_path); + us.fullscreen = next->fullscreen; us.has_fullscreen = true; + us.texture_filter = next->texture_filter; us.has_texture_filter = true; + us.fmv_filter = next->fmv_filter; us.has_fmv_filter = true; + us.scanlines = next->scanlines != 0; us.has_scanlines = true; + us.scanline_strength = next->scanline_strength_pct / 100.0; us.has_scanline_strength = true; + us.antialiasing = next->present_linear != 0; us.has_antialiasing = true; + us.dynamic_resolution = next->dynamic_resolution != 0; us.has_dynamic_resolution = true; + us.frame_generation = next->frame_generation != 0; us.has_frame_generation = true; + us.renderer = next->renderer; us.has_renderer = true; + us.supersampling = next->supersampling; us.has_supersampling = true; + 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; + (void)PSXRecompV4::save_user_settings(g_overlay_settings_path, us); + } catch (...) { + } + } + return psx_host_video_restart_bits(&g_video_boot, next) | deferred; +} + #ifndef PSX_NO_DEBUG_TOOLS /* ---- Live rendering A/B keys (debug-tools builds) -------------------------- * PSX_DEBUG_TOGGLES=0 turns them off. Each key flips one rendering feature @@ -9764,79 +9850,6 @@ static void debug_toggle_summary(char *buf, size_t cap) { seam_names[gl_renderer_get_pgxp_seam()], pgxp_cpu_mode() ? "on" : "off"); } -/* ---- Live video apply (host_overlay.h P4) ------------------------------- */ -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; - -extern "C" void psx_video_current(PsxHostVideoSettings *v) { - if (!v) return; - std::memset(v, 0, sizeof *v); - v->fullscreen = s_window_fullscreen.mode; - v->texture_filter = g_video_texfilter; - v->fmv_filter = g_video_fmv_filter; - v->scanlines = g_video_scanlines ? 1 : 0; - v->scanline_strength_pct = (int)(g_video_scanline_strength * 100.0f + 0.5f); - v->present_linear = g_video_aa ? 1 : 0; - v->dynamic_resolution = g_video_dynres ? 1 : 0; - 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; - v->render_thread = g_render_thread ? 1 : 0; - v->present_thread = g_present_thread ? 1 : 0; - if (!g_video_boot_valid) { g_video_boot = *v; g_video_boot_valid = true; } -} - -extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { - if (!next) return 0; - PsxHostVideoSettings cur; - psx_video_current(&cur); - const unsigned m = psx_host_video_live_changes(&cur, next); - if (m & PSX_VIDEO_LIVE_FULLSCREEN) - (void)psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, next->fullscreen); - if (m & PSX_VIDEO_LIVE_TEXTURE_FILTER) { - g_video_texfilter = next->texture_filter ? 1 : 0; - if (g_mod_texfilter < 0) gr_set_texture_filter(g_video_texfilter); - } - if (m & PSX_VIDEO_LIVE_FMV_FILTER) { - g_video_fmv_filter = next->fmv_filter; - if (g_gl_active) gl_renderer_set_fmv_filter(g_video_fmv_filter); - } - if (m & PSX_VIDEO_LIVE_SCANLINES) - psx_video_set_scanlines(next->scanlines, next->scanline_strength_pct / 100.0f); - if (m & PSX_VIDEO_LIVE_PRESENT_LINEAR) g_video_aa = next->present_linear != 0; - if (m & PSX_VIDEO_LIVE_DYNRES) { - g_video_dynres = next->dynamic_resolution ? 1 : 0; - 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_FRAME_GEN) && g_gl_active) { - g_frame_generation = next->frame_generation ? 1 : 0; - gl_renderer_set_frame_generation(g_frame_generation); - } - /* Persist everything the menu shows, live or not, like the launcher. */ - if (!g_overlay_settings_path.empty()) { - try { - PSXRecompV4::UserSettings us = PSXRecompV4::load_user_settings(g_overlay_settings_path); - us.fullscreen = next->fullscreen; us.has_fullscreen = true; - us.texture_filter = next->texture_filter; us.has_texture_filter = true; - us.fmv_filter = next->fmv_filter; us.has_fmv_filter = true; - us.scanlines = next->scanlines != 0; us.has_scanlines = true; - us.scanline_strength = next->scanline_strength_pct / 100.0; us.has_scanline_strength = true; - us.antialiasing = next->present_linear != 0; us.has_antialiasing = true; - us.dynamic_resolution = next->dynamic_resolution != 0; us.has_dynamic_resolution = true; - us.frame_generation = next->frame_generation != 0; us.has_frame_generation = true; - us.renderer = next->renderer; us.has_renderer = true; - us.supersampling = next->supersampling; us.has_supersampling = true; - us.render_thread = next->render_thread != 0; us.has_render_thread = true; - (void)PSXRecompV4::save_user_settings(g_overlay_settings_path, us); - } catch (...) { - } - } - return psx_host_video_restart_bits(&g_video_boot, next); -} - static int debug_toggle_key(int key, char *out, int cap) { char msg[256]; switch (key) { From e5542c6879c78a1db77964bdde5e25289e36fac5 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:27:06 -0700 Subject: [PATCH 17/25] Live mod options: keep a value only after a callback accepts Review (#602): psx_mod_set_option_live stored the new value before any callback ran, so a refusal ("nothing changes until next start") still changed what psx_mod_option_value answered. The value is visible to the callbacks while they run and is rolled back unless one accepts. New test_mod_runtime case: a refusing callback leaves the running value as is. Co-Authored-By: Claude Opus 5.5 --- runtime/src/mod_runtime.cpp | 17 ++++++++++++++--- runtime/tests/test_mod_runtime.cpp | 13 ++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index e5667c7c..ad35da1f 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -2881,9 +2881,16 @@ extern "C" int psx_mod_set_option_live(const char* package_id, const char* featu for (const ModResolution::Plugin& plugin : s.plan.plugins) if (plugin.package_id == package_id && plugin.feature_id == feature_id) in_plan = true; if (!in_plan || !psx_mod_option_live_capable(package_id, feature_id)) return 0; - /* Only a feature whose plugin re-reads options live sees the new value; - * anything else keeps its committed value until the next start. */ - live_option_values()[live_option_key(package_id, feature_id, option_id)] = value; + /* The value is visible to the callbacks while they run (a plugin may + * re-read it through psx_mod_current_option_value), but it is kept only + * if one of them accepts. Refused by all = nothing changes this session: + * the previous value (live or committed) is restored. */ + auto& values = live_option_values(); + const std::string key = live_option_key(package_id, feature_id, option_id); + auto prev_it = values.find(key); + const bool had_prev = prev_it != values.end(); + const std::string prev = had_prev ? prev_it->second : std::string(); + values[key] = value; int live = 0; for (const ModResolution::Plugin& plugin : s.plan.plugins) { if (plugin.package_id != package_id || plugin.feature_id != feature_id) continue; @@ -2892,6 +2899,10 @@ extern "C" int psx_mod_set_option_live(const char* package_id, const char* featu PluginCallbackScope scope(s, &plugin); if (it->second(option_id, value)) live = 1; } + if (!live) { + if (had_prev) values[key] = prev; + else values.erase(key); + } return live; } diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 1ac9ebfe..ad8ff7d8 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -320,6 +320,7 @@ int iso_read_cdda_sector(void*,uint32_t,uint8_t*,int); static int big_ram_activations; static int option_changed_hits = 0; static std::string option_changed_value; +static int option_changed_refuse = 0; /* 1 = the plugin answers "next start" */ static int test_option_changed(const char* option_id, const char* value) { char cur[32]; option_changed_hits++; @@ -328,7 +329,7 @@ static int test_option_changed(const char* option_id, const char* value) { psx_mod_current_option_value("size", cur, sizeof(cur)) == 1) option_changed_value = cur; (void)value; - return 1; + return option_changed_refuse ? 0 : 1; } static void test_big_ram_activation(void) { big_ram_activations++; @@ -1113,6 +1114,16 @@ int main(int argc, char** argv) { check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && std::string(value) == "four", "a live override must answer later reads"); + /* A callback that refuses ("nothing changes until next start") must + * leave the value the session runs with untouched. */ + option_changed_refuse = 1; + check(psx_mod_set_option_live("runtime.ram", "big-ram", "size", "two") == 0 && + option_changed_hits == 2, + "a refused live set reports refused after asking the plugin"); + check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && + std::string(value) == "four", + "a refused live set must not change the value later reads see"); + option_changed_refuse = 0; check(psx_mod_set_option_live("runtime.ram", "no-such-feature", "size", "x") == 0, "features outside the plan are not live-settable"); check(psx_mod_save_selection() == 1, "saving the selection mid-session must succeed"); From 0f09935c23627aef0fa6a7b63948818b25fd5268 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:45:22 -0700 Subject: [PATCH 18/25] Live video apply: one texture-filter enum for settings, handoff and menu Review (#601/#603): the live path and the launcher handoff both collapsed the texture filter to a boolean (value ? 1 : 0), so neither could carry xBR (#586) and they could drift from settings.toml. PSX_TEXTURE_FILTER_* is now the one enum (equal to [video] texture_filtering / gr_set_texture_filter), and psx_texture_filter_from_ui is the one translation used by the launcher handoff (seed, UserSettings, live globals) and the in-game apply. Unit- tested in host_overlay_test. Co-Authored-By: Claude Opus 5.5 --- runtime/include/host_overlay.h | 14 +++++++++++++- runtime/src/host_overlay.c | 5 +++++ runtime/src/main.cpp | 2 +- runtime/tests/test_host_overlay.c | 10 ++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 2cfc9ba4..49e956b2 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -65,7 +65,7 @@ int psx_host_ui_capture_active(void); * thread only. */ typedef struct PsxHostVideoSettings { int fullscreen; /* 0 windowed, 1 borderless, 2 exclusive */ - int texture_filter; /* 0 nearest, 1 bilinear */ + int texture_filter; /* PSX_TEXTURE_FILTER_* (the [video] enum) */ int fmv_filter; /* VIDEO_FMV_FILTER_* (config value) */ int scanlines; /* 0/1 */ int scanline_strength_pct; /* 0..100 */ @@ -79,6 +79,18 @@ typedef struct PsxHostVideoSettings { int present_thread; /* restart */ } PsxHostVideoSettings; +/* Texture filter: ONE enum for settings.toml, the launcher handoff and the + * in-game menu, so they cannot drift apart (#586 added xBR; a boolean UI + * cannot represent it). Values match [video] texture_filtering and + * gr_set_texture_filter(). STABLE_WORLD is set by mods, not by settings. */ +#define PSX_TEXTURE_FILTER_NEAREST 0 +#define PSX_TEXTURE_FILTER_BILINEAR 1 +#define PSX_TEXTURE_FILTER_STABLE_WORLD 2 +#define PSX_TEXTURE_FILTER_XBR 3 +/* A launcher / menu value to the enum: in-range values pass through, and + * anything else is nearest (the native default). Older launchers send 0/1. */ +int psx_texture_filter_from_ui(int value); + #define PSX_VIDEO_RESTART_RENDERER (1u << 0) #define PSX_VIDEO_RESTART_RESOLUTION (1u << 1) #define PSX_VIDEO_RESTART_THREADS (1u << 2) diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 12410352..ad122a9d 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -147,3 +147,8 @@ unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, m |= PSX_VIDEO_RESTART_THREADS; return m; } + +int psx_texture_filter_from_ui(int value) { + return (value >= PSX_TEXTURE_FILTER_NEAREST && value <= PSX_TEXTURE_FILTER_XBR) + ? value : PSX_TEXTURE_FILTER_NEAREST; +} diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index a51bbf07..9f1ed712 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -9760,7 +9760,7 @@ extern "C" unsigned psx_video_apply_live(const PsxHostVideoSettings *next) { if (m & PSX_VIDEO_LIVE_FULLSCREEN) (void)psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, next->fullscreen); if (m & PSX_VIDEO_LIVE_TEXTURE_FILTER) { - g_video_texfilter = next->texture_filter ? 1 : 0; + g_video_texfilter = psx_texture_filter_from_ui(next->texture_filter); if (g_mod_texfilter < 0) gr_set_texture_filter(g_video_texfilter); } if (m & PSX_VIDEO_LIVE_FMV_FILTER) { diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index 15ffc75c..53601398 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -146,10 +146,20 @@ static void test_video(void) { CHECK(psx_host_video_live_changes(NULL, &b) == 0 && psx_host_video_restart_bits(&a, NULL) == 0); } +/* One texture-filter enum for settings, handoff and menu (xBR included). */ +static void test_texture_filter(void) { + CHECK(psx_texture_filter_from_ui(0) == PSX_TEXTURE_FILTER_NEAREST); + CHECK(psx_texture_filter_from_ui(1) == PSX_TEXTURE_FILTER_BILINEAR); + CHECK(psx_texture_filter_from_ui(3) == PSX_TEXTURE_FILTER_XBR); + CHECK(psx_texture_filter_from_ui(9) == PSX_TEXTURE_FILTER_NEAREST); + CHECK(psx_texture_filter_from_ui(-1) == PSX_TEXTURE_FILTER_NEAREST); +} + int main(void) { test_draw_lifetime(); test_draw_reentrant(); test_video(); + test_texture_filter(); test_sink(); test_draw(); test_pause(); From 83f1468860366568d8f263054908074a9bb37432 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:43:20 -0700 Subject: [PATCH 19/25] In-game menu host: a provider-neutral contract (psx_menu_host.h) psxrecomp owns the contract between the runtime and any in-game menu frontend. A provider (PsxMenuProvider: attach, event, is_open, set_open, draw, detach) is registered by the title with psx_menu_set_provider(); none is registered by default, so a title's UI and pin are unchanged. The runtime offers every provider the same host (PsxMenuHost): pause (P1, refused in netplay), video get/apply on PsxHostVideoSettings (P4), mod switch/option set (P5, via the new psx_mod_feature_enable / psx_mod_feature_set_option shared with the launcher ABI provider), reload bindings, exit (quit, back to the frontend loop in-process, or a quick relaunch with the original arguments), disc/tray slots filled by the tray policy (psx_menu_set_disc_ops), and optional helpers on the shared launcher ABI both frontends already speak (game info with the mod catalogue and lobby, settings get/apply with the handoff's filter translations). Policies live here, the same for every provider: the lobby is pumped during play and a started match ends the local session and starts in the frontend loop (pending launch, seats and signals kept, pumping stopped so the match port stays free), the menu is closed and its pause dropped first; the provider detaches before its GL context goes away at session teardown and attaches to the next one. --launcher sets RECOMP_LAUNCHER_PREGAME for frontends that boot into the game; PSX_HIDDEN_WINDOW is a hidden-window test hook that keeps the frontend. Nothing here includes or names a frontend's own headers or types. Tests: psx_menu_host_test, host_overlay_test (relaunch quoting). Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 6 + runtime/include/host_overlay.h | 12 + runtime/include/mod_plugins.h | 6 + runtime/include/psx_menu_host.h | 118 +++++++++ runtime/runtime.cmake | 3 +- runtime/src/host_overlay.c | 162 ++++++++++++ runtime/src/main.cpp | 412 ++++++++++++++++++++++++++++- runtime/src/mod_runtime.cpp | 96 ++++--- runtime/src/psx_menu_host.c | 18 ++ runtime/tests/test_host_overlay.c | 82 ++++++ runtime/tests/test_psx_menu_host.c | 33 +++ 11 files changed, 901 insertions(+), 47 deletions(-) create mode 100644 runtime/include/psx_menu_host.h create mode 100644 runtime/src/psx_menu_host.c create mode 100644 runtime/tests/test_psx_menu_host.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 6306a96a..c48ff8d2 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -797,7 +797,13 @@ if(BUILD_TESTING) target_include_directories(host_overlay_test PRIVATE include src) find_package(Threads REQUIRED) target_link_libraries(host_overlay_test PRIVATE Threads::Threads) + if(WIN32) + target_link_libraries(host_overlay_test PRIVATE shell32) + endif() add_test(NAME host_overlay_test COMMAND host_overlay_test) + add_executable(psx_menu_host_test tests/test_psx_menu_host.c src/psx_menu_host.c) + target_include_directories(psx_menu_host_test PRIVATE include src) + add_test(NAME psx_menu_host_test COMMAND psx_menu_host_test) add_executable(gte_register_access_test tests/test_gte_register_access.cpp diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 060ea4e7..06c2e504 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -7,6 +7,7 @@ #ifndef PSX_HOST_OVERLAY_H #define PSX_HOST_OVERLAY_H +#include #include "video_texture_filter.h" #ifdef __cplusplus extern "C" { @@ -109,6 +110,17 @@ unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, unsigned psx_video_apply_live(const PsxHostVideoSettings *next); void psx_video_current(PsxHostVideoSettings *out); +/* Replace this process with a fresh copy started with the same arguments + * (the in-game menu's quick relaunch for restart-only settings). Returns only + * on failure. */ +void psx_relaunch_self(int argc, char** argv); +/* Windows command-line quoting for one argument (CommandLineToArgvW rules); + * returns the quoted length, writing at most cap-1 chars plus NUL to out + * (out may be NULL to measure). Portable, for tests. */ +size_t psx_win_cmdline_quote(const char* arg, char* out, size_t cap); +/* All arguments quoted and space-joined; malloc'd (free it), NULL on OOM. */ +char* psx_win_cmdline_join(int argc, const char* const* argv); + #ifdef __cplusplus } #endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 534e74e3..3b1cc33c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -398,6 +398,12 @@ int psx_mod_option_live_capable(const char* package_id, const char* feature_id); * as edited through the launcher mod provider) to mods/state.toml now, * without re-resolving the running plan. Main thread. 1 on success. */ int psx_mod_save_selection(void); +/* Switch a feature on/off or set one of its options in the selection (not + * the committed plan; psx_mod_save_selection persists it). Main thread. + * Returns 1 on success. Used by the in-game menu host. */ +int psx_mod_feature_enable(const char* package_id, const char* feature_id, int enabled); +int psx_mod_feature_set_option(const char* package_id, const char* feature_id, + const char* option_id, const char* value); /* * Read the committed owner-selected path for a resource declared by the * package feature whose trusted plugin is currently running. Returns 0 when diff --git a/runtime/include/psx_menu_host.h b/runtime/include/psx_menu_host.h new file mode 100644 index 00000000..01c77310 --- /dev/null +++ b/runtime/include/psx_menu_host.h @@ -0,0 +1,118 @@ +/* psx_menu_host.h -- the runtime's contract with an in-game menu provider. + * + * psxrecomp owns this header. A frontend (recomp-ui, recomp-launcher, or any + * other) implements PsxMenuProvider; the runtime implements PsxMenuHost. The + * runtime keeps every policy (pause, live settings, mod changes, disc/tray, + * netplay session start/exit) on its side, so all providers get the same + * behaviour. Nothing here is specific to one frontend. + * + * Selection: no provider is registered by default, so a title's existing UI + * (recomp-ui) and its pin are unchanged. A title that wants a provider + * registers it with psx_menu_set_provider() before the game window opens + * (for example from a static initializer in the title's own code). + * + * Threads: attach/event/is_open/set_open run on the main thread; draw runs on + * the thread that owns the GL context (host_overlay.h P2) with the window + * framebuffer bound. PsxMenuHost calls may come from either; the runtime + * applies them on the main thread at the next frame. */ +#ifndef PSX_MENU_HOST_H +#define PSX_MENU_HOST_H + +#include "host_overlay.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define PSX_MENU_ABI_VERSION 1 + +/* request_exit */ +enum { + PSX_MENU_EXIT_QUIT = 0, /* leave the game (netplay: back to the lobby) */ + PSX_MENU_EXIT_TO_FRONTEND = 1, /* end the session, re-enter the frontend loop in-process */ + PSX_MENU_EXIT_RELAUNCH = 2 /* restart this process with its original arguments */ +}; + +/* video_apply returns PSX_VIDEO_RESTART_* bits (host_overlay.h) plus this one: + * during a netplay match only presentation applies; the rest waits. */ +#define PSX_MENU_RESTART_AFTER_MATCH (1u << 31) + +/* The shared launcher C ABI (recomp_launcher.h) that recomp-ui and + * recomp-launcher both speak for the pre-game launcher. Optional: a provider + * that uses it gets the same game info, mod catalogue and lobby objects the + * pre-game launcher gets; one that does not ignores these entries. */ +struct RecompLauncherCGameInfo; +struct RecompLauncherCSettings; + +typedef struct PsxMenuHost { + int abi_version; /* PSX_MENU_ABI_VERSION */ + /* Pause the guest and audio (host_overlay.h P1). Returns 0 when refused + * (netplay): the menu then stays open without pausing. */ + int (*set_paused)(int paused); + int (*netplay_active)(void); + /* Video settings: current values, and a live apply (P4) that also saves + * settings.toml. Returns the restart bits of the fields that wait. */ + void (*video_get)(PsxHostVideoSettings *out); + unsigned (*video_apply)(const PsxHostVideoSettings *next); + /* Mod feature switch (option == NULL, value "true"/"false") or option. + * Saved to mods/state.toml; returns 1 when it applied live (P5), 0 when it + * waits for the next start. Refused (0, unsaved) during netplay. */ + int (*mod_set)(const char *package_id, const char *feature_id, + const char *option_id, const char *value); + /* Reload keybinds.ini / input.ini / config.ini after the menu wrote them. */ + void (*reload_bindings)(void); + /* PSX_MENU_EXIT_*. Applied on the main thread; the menu is closed and its + * pause dropped before the session ends. */ + void (*request_exit)(int how); + /* Disc/tray: the runtime's production tray policy (psx_disc_tray.h), + * shared with its own disc menu and hotkeys; count is 0 without a + * multi-disc roster or a policy. insert queues the lid cycle for the main + * thread: 0 = queued, -1 = no such disc, 3 = refused (netplay). */ + int (*disc_count)(void); + int (*disc_mounted)(void); /* 1-based, 0 = none */ + const char *(*disc_path)(int disc_1based); + int (*disc_insert)(int disc_1based); + /* Shared launcher ABI helpers (see above); NULL in builds without it. */ + void (*launcher_game_info)(struct RecompLauncherCGameInfo *out); + void (*launcher_settings_get)(struct RecompLauncherCSettings *out); + unsigned (*launcher_settings_apply)(const struct RecompLauncherCSettings *next); + const char *assets_dir; /* the title's assets folder */ +} PsxMenuHost; + +typedef struct PsxMenuProvider { + int abi_version; /* PSX_MENU_ABI_VERSION */ + const char *name; + void *ctx; + /* Once per GL context, with it current. Returns 0 to decline. */ + int (*attach)(void *ctx, void *sdl_window, void *gl_context, const PsxMenuHost *host); + /* Every SDL event first; 1 = consumed (the game does not see it). The + * provider opens itself (its own key/pad binding) from here. */ + int (*event)(void *ctx, const void *sdl_event); + int (*is_open)(void *ctx); + void (*set_open)(void *ctx, int open); + /* Draw over the presented frame (P2). */ + void (*draw)(void *ctx, int framebuffer_w, int framebuffer_h); + /* Before its GL context goes away (session end, rematch), with it current; + * attach follows for the next context. */ + void (*detach)(void *ctx); +} PsxMenuProvider; + +/* Register (or clear, NULL) the in-game menu provider. Main thread, before + * the game window opens; a provider with another ABI version is refused. */ +int psx_menu_set_provider(const PsxMenuProvider *provider); +const PsxMenuProvider *psx_menu_provider(void); + +/* The disc/tray policy publishes itself here (PsxMenuHost disc_* entries). */ +typedef struct PsxMenuDiscOps { + int (*count)(void); + int (*mounted)(void); + const char *(*path)(int disc_1based); + int (*insert)(int disc_1based); +} PsxMenuDiscOps; +void psx_menu_set_disc_ops(const PsxMenuDiscOps *ops); +const PsxMenuDiscOps *psx_menu_disc_ops(void); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 0d37811a..1e1a0fbc 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -391,6 +391,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/mod_controller_source.c ${PSXRECOMP_ROOT}/runtime/src/pad_external_input.c ${PSXRECOMP_ROOT}/runtime/src/host_overlay.c + ${PSXRECOMP_ROOT}/runtime/src/psx_menu_host.c ${PSXRECOMP_ROOT}/runtime/src/mod_pad_transform.c ${PSXRECOMP_ROOT}/runtime/src/vr_pose_math.c ${PSXRECOMP_ROOT}/runtime/src/gpu_vk_renderer.c @@ -2287,7 +2288,7 @@ function(psxrecomp_add_runtime_target target) # by opengl32; Phase 2b will load modern GL via SDL_GL_GetProcAddress. # iphlpapi: GetAdaptersAddresses in the launcher's netplay # local-address discovery (MSVC does not link it implicitly). - target_link_libraries(${target} PRIVATE ws2_32 iphlpapi dbghelp comdlg32 opengl32) + target_link_libraries(${target} PRIVATE ws2_32 iphlpapi dbghelp comdlg32 opengl32 shell32) # Newer mingw-w64 maps clock_gettime → clock_gettime64 in libwinpthread. # Link it even when netplay code prefers Win32 clocks, so any residual # POSIX time refs (third-party / debug tools) resolve under -static. diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index 12410352..100f7a97 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -147,3 +147,165 @@ unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, m |= PSX_VIDEO_RESTART_THREADS; return m; } + +/* ---- seamless relaunch ----------------------------------------------------- */ +#include +#include + +/* Windows command-line quoting (the rules CommandLineToArgvW and the MSVC CRT + * parse with): an argument is quoted when empty or holding a space, tab or + * quote; inside quotes, backslashes are literal except before a quote, so a + * run of n backslashes before a quote becomes 2n+1 plus the quote, and a run + * before the closing quote becomes 2n. */ +size_t psx_win_cmdline_quote(const char* arg, char* out, size_t cap) { + size_t n = 0, i, bs; + const char* p; +#define PUT(c) do { if (out && n + 1 < cap) out[n] = (c); ++n; } while (0) + if (!arg) arg = ""; + if (*arg && !strpbrk(arg, " \t\n\v\"")) { + for (p = arg; *p; ++p) PUT(*p); + } else { + PUT('"'); + for (p = arg;; ++p) { + bs = 0; + while (*p == '\\') { ++bs; ++p; } + if (!*p) { for (i = 0; i < bs * 2; ++i) PUT('\\'); break; } + if (*p == '"') { for (i = 0; i < bs * 2 + 1; ++i) PUT('\\'); PUT('"'); continue; } + for (i = 0; i < bs; ++i) PUT('\\'); + PUT(*p); + } + PUT('"'); + } +#undef PUT + if (out && cap) out[n < cap ? n : cap - 1] = '\0'; + return n; +} + +char* psx_win_cmdline_join(int argc, const char* const* argv) { + size_t total = 1; + int i; + char* out; + char* w; + for (i = 0; i < argc; ++i) total += psx_win_cmdline_quote(argv[i], NULL, 0) + 1; + out = (char*)malloc(total); + if (!out) return NULL; + w = out; + for (i = 0; i < argc; ++i) { + if (i) *w++ = ' '; + w += psx_win_cmdline_quote(argv[i], w, total - (size_t)(w - out)); + } + *w = '\0'; + return out; +} + +#if defined(_WIN32) +#include +#include +#include +/* Re-run this exe with the original arguments. The arguments come from + * GetCommandLineW (full Unicode, no ANSI code-page loss, any count or length), + * are re-quoted by the rules above, and the new process is started before + * this one exits. argc/argv are only a fallback. */ +static wchar_t* utf8_to_wide(const char* s) { + int n = MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0); + wchar_t* w = n > 0 ? (wchar_t*)malloc(sizeof(wchar_t) * (size_t)n) : NULL; + if (w && MultiByteToWideChar(CP_UTF8, 0, s, -1, w, n) <= 0) { free(w); w = NULL; } + return w; +} +static char* wide_to_utf8(const wchar_t* s) { + int n = WideCharToMultiByte(CP_UTF8, 0, s, -1, NULL, 0, NULL, NULL); + char* u = n > 0 ? (char*)malloc((size_t)n) : NULL; + if (u && WideCharToMultiByte(CP_UTF8, 0, s, -1, u, n, NULL, NULL) <= 0) { free(u); u = NULL; } + return u; +} +void psx_relaunch_self(int argc, char** argv) { + int wargc = 0, i, ok = 1; + wchar_t** wargv = CommandLineToArgvW(GetCommandLineW(), &wargc); + const char** args = NULL; + char* line = NULL; + wchar_t* wline = NULL; + wchar_t* exe = NULL; + DWORD cap = MAX_PATH, got = 0; + STARTUPINFOW si; + PROCESS_INFORMATION pi; + if (wargv && wargc > 0) { + args = (const char**)calloc((size_t)wargc, sizeof(*args)); + for (i = 0; args && i < wargc; ++i) + if (!(args[i] = wide_to_utf8(wargv[i]))) ok = 0; + if (args && ok) line = psx_win_cmdline_join(wargc, args); + for (i = 0; args && i < wargc; ++i) free((void*)args[i]); + free((void*)args); + LocalFree(wargv); + } else if (argv) { + line = psx_win_cmdline_join(argc, (const char* const*)argv); + } + if (!line) return; + wline = utf8_to_wide(line); + free(line); + for (;;) { /* long paths: grow until the module path fits */ + free(exe); + exe = (wchar_t*)malloc(sizeof(wchar_t) * cap); + if (!exe) break; + got = GetModuleFileNameW(NULL, exe, cap); + if (got && got < cap) break; + if (!got || cap >= 32768) { free(exe); exe = NULL; break; } + cap *= 2; + } + if (wline && exe) { + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + memset(&pi, 0, sizeof(pi)); + fflush(NULL); + if (CreateProcessW(exe, wline, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi)) { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + free(wline); + free(exe); + _exit(0); + } + } + free(wline); + free(exe); +} +#else +#include +#include +#include +#include +#include +#if defined(__APPLE__) +#include +#endif +void psx_relaunch_self(int argc, char** argv) { + char path[4096]; + (void)argc; + if (!argv || !argv[0]) return; + path[0] = '\0'; +#if defined(__APPLE__) + { + uint32_t cap = sizeof(path); + if (_NSGetExecutablePath(path, &cap) != 0) path[0] = '\0'; + } +#elif defined(__linux__) + { + ssize_t n = readlink("/proc/self/exe", path, sizeof(path) - 1); + path[n > 0 ? n : 0] = '\0'; + } +#endif + /* Interval timers and the signal mask survive exec: a still-armed + * profiling/watchdog timer would kill the new image (default SIGPROF) + * before it reaches main. */ + { + struct itimerval off; + sigset_t none; + memset(&off, 0, sizeof(off)); + setitimer(ITIMER_REAL, &off, NULL); + setitimer(ITIMER_VIRTUAL, &off, NULL); + setitimer(ITIMER_PROF, &off, NULL); + sigemptyset(&none); + sigprocmask(SIG_SETMASK, &none, NULL); + } + fflush(NULL); + execv(path[0] ? path : argv[0], argv); +} +#endif diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 9f1ed712..37b6494f 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -126,7 +126,8 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "disc_path.h" #include "iso_reader.h" /* text-image guard: extract the boot EXE from the disc */ #include "psx_keybinds.h" -#include "host_overlay.h" /* optional in-game menu hooks (P1 pause, ...) */ /* configurable keyboard->DualShock keybinds (keybinds.ini) */ +#include "host_overlay.h" +#include "psx_menu_host.h" /* optional in-game menu hooks (P1 pause, ...) */ /* configurable keyboard->DualShock keybinds (keybinds.ini) */ #include "psx_window_icon.h" #if defined(RECOMP_LAUNCHER) @@ -3470,6 +3471,11 @@ static std::filesystem::path default_memcard_dir(const char* argv0) { static void close_controller(void); static void shutdown_runtime(void); +/* The in-game menu's lobby started a match while a local game ran: the game + * session ends but the pending launch (and its seats/signals) must survive + * into the launcher loop that starts it. */ +static int g_ovl_launch_to_start = 0; +static void recomp_overlay_detach(void); /* in-game menu host, below */ /* Set when this process started netplay from the lobby room — soft-exit returns * there instead of killing the process. */ @@ -4002,6 +4008,11 @@ static void teardown_game_session_keep_lobby(void) { g_vk_active = false; } if (g_gl_active) { + /* The in-game menu's GL objects belong to this context: stop the + * render thread (the context comes back here), let the provider free + * them, then shut the renderer down. The next session attaches again. */ + gl_renderer_render_thread_stop(); + recomp_overlay_detach(); gl_renderer_shutdown(); g_gl_active = false; } @@ -4018,13 +4029,15 @@ static void teardown_game_session_keep_lobby(void) { s_netplay_sim_native_scale = 0; gl_renderer_set_cpu_auth_dual(0); sw_set_faithful_authority(0); - psx_lobby_set_ready(0); - psx_lobby_clear_launch_pending(); + if (!g_ovl_launch_to_start) { + psx_lobby_set_ready(0); + psx_lobby_clear_launch_pending(); #if defined(PSX_HAS_LOBBY_CLIENT) - /* Drop prior-match ICE SDP/candidates; ignore new ones until next launch. */ - psx_lobby_clear_signals(); - psx_lobby_set_ice_signal_accept(0); + /* Drop prior-match ICE SDP/candidates; ignore new ones until next launch. */ + psx_lobby_clear_signals(); + psx_lobby_set_ice_signal_accept(0); #endif + } psx_clear_return_to_lobby(); } @@ -8039,8 +8052,7 @@ static void rewind_host_pause_loop(void) { * audio is paused, until every psx_host_pause_push() has been popped. Nothing * pushes unless an overlay provider is registered, so titles without one never * enter this loop. */ -static void host_pause_service(void); /* P6 fills this in; no-op until then */ -static void host_pause_service(void) {} +static void host_pause_service(void); /* in-game overlay glue (P6), below */ static bool host_pause_event(const SDL_Event& ev); /* P3: input sink */ static bool host_pause_event(const SDL_Event& ev) { return psx_host_input_sink_dispatch(&ev) != 0; @@ -8601,6 +8613,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { /* Pump SDL events to prevent window freeze. */ psx_local_mouse_begin(sdl_window, local_mouse_live(override)); if (!drain_host_events()) return ep; + host_pause_service(); savestate_menu_poll_toggle_buttons(); rewind_poll_toggle_buttons(); fast_forward_toggle_poll_buttons(); @@ -16099,7 +16112,372 @@ static uint32_t gcs_gate_read_ram(uint32_t phys, uint32_t size) { return v; } +/* ---- In-game menu host (psx_menu_host.h) ----------------------------------- + * The runtime side of the provider-neutral in-game menu contract. A provider + * (registered by the title with psx_menu_set_provider; none by default) draws + * over the game (P2), takes input first (P3), and asks the host for pause (P1; + * refused, so non-pausing, in netplay), live video (P4), mod changes (P5), + * disc/tray actions, and session exits. Requests from the GL thread are posted + * here and applied on the main thread in host_pause_service(). The same + * policies apply to every provider. PSX_OVERLAY=0 turns the menu off. */ +#include +#if defined(__has_include) +#if __has_include("psx_jogcon_ffb_sdl.h") +#include "psx_jogcon_ffb_sdl.h" /* psxrecomp#609: wheel FFB player setting */ +#endif +#endif + +/* main()'s arguments, for the in-game menu's seamless relaunch. */ +static int g_main_argc = 0; +static char** g_main_argv = nullptr; + +namespace { +std::mutex g_ovl_mu; +const PsxMenuProvider* g_ovl = nullptr; /* attached provider */ +bool g_ovl_on = false; +std::atomic g_ovl_pause_wanted{0}; +bool g_ovl_pause_held = false; +std::atomic g_ovl_reload_binds{0}; +std::atomic g_ovl_exit{0}; /* PSX_MENU_EXIT_* + 1; 0 = none */ +bool g_ovl_video_pending = false; +PsxHostVideoSettings g_ovl_video_post{}; +#if defined(RECOMP_LAUNCHER) +bool g_ovl_ls_pending = false; +RecompLauncherCSettings g_ovl_ls_post{}; +#endif +std::string g_ovl_argv0, g_ovl_name, g_ovl_keybinds, g_ovl_config, g_ovl_assets; + +bool ovl_open(void) { return g_ovl && g_ovl->is_open && g_ovl->is_open(g_ovl->ctx); } + +int mh_set_paused(int paused) { + if (paused && psx_netplay_active()) return 0; + g_ovl_pause_wanted = paused ? 1 : 0; + return 1; +} +int mh_netplay_active(void) { return psx_netplay_active() ? 1 : 0; } +void mh_video_get(PsxHostVideoSettings* out) { if (out) psx_video_current(out); } +unsigned mh_restart_bits(const PsxHostVideoSettings& next) { + PsxHostVideoSettings now; + psx_video_current(&now); + unsigned out = psx_host_video_restart_bits(&g_video_boot, &next); + /* In a netplay match only presentation applies live (psx_video_apply_live); + * the rest waits for the next session. */ + if (psx_netplay_active() && + (psx_host_video_live_changes(&now, &next) & + ~(PSX_VIDEO_LIVE_FULLSCREEN | PSX_VIDEO_LIVE_SCANLINES | PSX_VIDEO_LIVE_PRESENT_LINEAR))) + out |= PSX_MENU_RESTART_AFTER_MATCH; + return out; +} +unsigned mh_video_apply(const PsxHostVideoSettings* next) { + if (!next) return 0; + { + std::lock_guard lk(g_ovl_mu); + g_ovl_video_post = *next; + g_ovl_video_pending = true; + } + return mh_restart_bits(*next); +} +struct OvlModSet { std::string p, f, o, v; }; +std::vector g_ovl_mod_posts; +/* The provider never touches the mod catalogue itself (it may run on the GL + * thread): every change is posted here, recorded in the catalogue and saved + * to mods/state.toml on the main thread, and handed to a live-capable plugin. + * option == NULL is the feature switch (value "true"/"false"): restart. */ +int mh_mod_set(const char* p, const char* f, const char* o, const char* v) { + if (!p || !f || !v) return 0; + if (psx_netplay_active()) return 0; /* the plan is negotiated with peers */ + std::lock_guard lk(g_ovl_mu); + g_ovl_mod_posts.push_back({p, f, o ? o : "", v}); + return o ? psx_mod_option_live_capable(p, f) : 0; +} +void mh_reload_bindings(void) { g_ovl_reload_binds = 1; } +void mh_request_exit(int how) { + g_ovl_exit = (how < PSX_MENU_EXIT_QUIT || how > PSX_MENU_EXIT_RELAUNCH ? PSX_MENU_EXIT_QUIT : how) + 1; +} +/* Disc/tray: the runtime's production tray policy (psx_disc_tray.h, #652) + * when the build has it -- the same lid cycle, netplay refusal, persistence + * and event ring as the runtime's own disc menu and hotkeys -- else whatever + * published itself through psx_menu_set_disc_ops. */ +#if defined(__has_include) +#if __has_include("psx_disc_tray.h") +#include "psx_disc_tray.h" +#define PSX_MENU_HAS_DISC_TRAY 1 +#endif +#endif +#if defined(PSX_MENU_HAS_DISC_TRAY) +int mh_disc_count(void) { return psx_disc_tray_disc_count(); } +int mh_disc_mounted(void) { return psx_disc_tray_mounted_disc(); } +const char* mh_disc_path(int n) { return psx_disc_tray_disc_path(n); } +int disc_cycle_now(int n) { return psx_disc_tray_cycle(n, PSX_DISC_TRAY_ORIGIN_MENU); } +#else +int mh_disc_count(void) { const PsxMenuDiscOps* d = psx_menu_disc_ops(); return d && d->count ? d->count() : 0; } +int mh_disc_mounted(void) { const PsxMenuDiscOps* d = psx_menu_disc_ops(); return d && d->mounted ? d->mounted() : 0; } +const char* mh_disc_path(int n) { const PsxMenuDiscOps* d = psx_menu_disc_ops(); return d && d->path ? d->path(n) : nullptr; } +int disc_cycle_now(int n) { const PsxMenuDiscOps* d = psx_menu_disc_ops(); return d && d->insert ? d->insert(n) : -1; } +#endif +/* The provider may ask from its draw (GL) thread; the lid cycle runs on the + * main/emulation thread at the next host_pause_service. Returns 0 when + * queued, -1 when there is no disc to change to, or the policy's netplay + * refusal up front. */ +std::atomic g_ovl_disc_req{0}; +int mh_disc_insert(int n) { + if (n < 1 || n > mh_disc_count()) return -1; + if (psx_netplay_active()) return 3; /* PSX_DISC_TRAY_ERR_NETPLAY */ + g_ovl_disc_req = n; + return 0; +} + +#if defined(RECOMP_LAUNCHER) +/* Shared launcher ABI helpers: the same translations the pre-game launcher + * handoff uses (texture filter, FMV filter), so the two cannot drift. */ +const char* const kOvlRenderers[] = {"Software", "OpenGL", "Vulkan"}; +PsxHostVideoSettings ovl_video_from(const RecompLauncherCSettings& ls) { + PsxHostVideoSettings v; + psx_video_current(&v); /* keeps fields the menu does not show */ + v.fullscreen = ls.fullscreen ? (g_fullscreen ? g_fullscreen : 1) : 0; + v.texture_filter = psx_texture_filter_from_ui(ls.texture_filter); + v.fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); + v.scanlines = ls.scanlines ? 1 : 0; + v.scanline_strength_pct = ls.scanline_strength_pct; + v.present_linear = ls.antialiasing ? 1 : 0; + v.dynamic_resolution = ls.dynamic_resolution ? 1 : 0; + v.frame_generation = ls.frame_generation ? 1 : 0; + v.renderer = ls.renderer; + v.render_thread = ls.render_thread ? 1 : 0; + v.present_thread = ls.present_thread ? 1 : 0; + return v; +} +void mh_launcher_settings_get(RecompLauncherCSettings* out) { + if (!out) return; + PsxHostVideoSettings v; + psx_video_current(&v); + RecompLauncherCSettings& ls = *out; + ls.fullscreen = v.fullscreen; + ls.texture_filter = v.texture_filter; + ls.fmv_filter = cfg_fmv_filter_to_launcher(v.fmv_filter); + ls.scanlines = v.scanlines; + ls.scanline_strength_pct = v.scanline_strength_pct; + ls.antialiasing = v.present_linear; + ls.dynamic_resolution = v.dynamic_resolution; + ls.frame_generation = v.frame_generation; + ls.renderer = v.renderer; + ls.render_thread = v.render_thread; + ls.present_thread = v.present_thread; +#if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) && \ + defined(PSX_HAS_JOGCON_FFB_PLAYER_SETTING) + ls.jogcon_force_feedback = psx_jogcon_ffb_player_enabled(); +#endif + for (int i = 0; i < PSX_MAX_PLAYERS && i < RECOMP_LAUNCHER_MAX_PLAYERS; ++i) { + ls.player_src[i] = g_players[i].kind; + ls.pad_mode[i] = PSXRecompV4::PAD_MODE_ANALOG; + } +} +unsigned mh_launcher_settings_apply(const RecompLauncherCSettings* s) { + if (!s) return 0; + const PsxHostVideoSettings next = ovl_video_from(*s); + { + std::lock_guard lk(g_ovl_mu); + g_ovl_ls_post = *s; + g_ovl_ls_pending = true; + } + return mh_restart_bits(next); +} +void mh_launcher_game_info(RecompLauncherCGameInfo* out) { + if (!out) return; + RecompLauncherCGameInfo& gi = *out; + launcher_profile_apply("psx", &gi); + gi.name = g_ovl_name.c_str(); + gi.keybinds_path = g_ovl_keybinds.c_str(); + gi.config_path = g_ovl_config.c_str(); + gi.num_players = PSX_MAX_PLAYERS < 4 ? PSX_MAX_PLAYERS : 4; + gi.renderer_labels = kOvlRenderers; + gi.num_renderers = 2; + gi.has_supersampling = 0; /* restart-only; set before boot */ + gi.has_dynamic_resolution = 1; + gi.has_render_pipeline = 1; + gi.has_vsync = 0; + gi.has_rewind_depth = 0; + gi.mods = PSXRecompV4::mod_runtime_launcher_provider(); +#if defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) + /* The same lobby the pre-game launcher drives, so the menu can host/join. */ + gi.netplay_supported = g_lnch_netplay_available ? 1 : 0; + gi.netplay = g_lnch_netplay_available ? &g_lnch_netplay_callbacks : nullptr; +#endif +} +#endif /* RECOMP_LAUNCHER */ + +PsxMenuHost g_menu_host = { + PSX_MENU_ABI_VERSION, mh_set_paused, mh_netplay_active, mh_video_get, mh_video_apply, + mh_mod_set, mh_reload_bindings, mh_request_exit, + mh_disc_count, mh_disc_mounted, mh_disc_path, mh_disc_insert, +#if defined(RECOMP_LAUNCHER) + reinterpret_cast(mh_launcher_game_info), + reinterpret_cast(mh_launcher_settings_get), + reinterpret_cast(mh_launcher_settings_apply), +#else + nullptr, nullptr, nullptr, +#endif + nullptr, +}; + +int ovl_sink(const void* ev, void*) { return g_ovl && g_ovl->event ? g_ovl->event(g_ovl->ctx, ev) : 0; } +void ovl_draw(int w, int h, void*) { if (g_ovl && g_ovl->draw) g_ovl->draw(g_ovl->ctx, w, h); } +} // namespace + +/* Main thread, once per GL context (windowed OpenGL only). */ +static void recomp_overlay_setup(const char* argv0, const std::string& title) { + const char* e = std::getenv("PSX_OVERLAY"); + const PsxMenuProvider* p = psx_menu_provider(); + if (!p || g_ovl_on || !g_gl_active || g_headless || (e && *e == '0')) return; + const std::filesystem::path dir = exe_dir_from_argv(argv0); + g_ovl_name = title; + g_ovl_argv0 = argv0 ? argv0 : ""; + g_ovl_keybinds = (dir / "keybinds.ini").string(); + g_ovl_config = (dir / "config.ini").string(); + g_ovl_assets = (dir / "assets").string(); + g_menu_host.assets_dir = g_ovl_assets.c_str(); + if (!p->attach || !p->attach(p->ctx, sdl_window, SDL_GL_GetCurrentContext(), &g_menu_host)) return; + g_ovl = p; + psx_host_set_input_sink(ovl_sink, nullptr); + psx_host_overlay_set_draw_cb(ovl_draw, nullptr); + g_ovl_on = true; + std::fprintf(stdout, "psxrecomp: in-game menu ready (%s)\n", p->name ? p->name : "provider"); +} + +/* Before the GL context goes away (session teardown), with it current: the + * draw hook is removed first (it waits for an in-flight draw), then the + * provider frees its GL objects. setup attaches again on the next context. */ +static void recomp_overlay_detach(void) { + if (!g_ovl_on) return; + psx_host_overlay_set_draw_cb(nullptr, nullptr); + psx_host_set_input_sink(nullptr, nullptr); + psx_host_set_ui_capture(0); + if (g_ovl_pause_held) { psx_host_pause_pop(); g_ovl_pause_held = false; } + g_ovl_pause_wanted = 0; + if (g_ovl->detach) g_ovl->detach(g_ovl->ctx); + g_ovl = nullptr; + g_ovl_on = false; +} + +static void host_pause_service(void) { + if (!g_ovl_on) return; + psx_host_set_ui_capture(ovl_open()); + const bool want = g_ovl_pause_wanted != 0 && ovl_open(); + if (want && !g_ovl_pause_held) g_ovl_pause_held = psx_host_pause_push("overlay") != 0; + else if (!want && g_ovl_pause_held) { psx_host_pause_pop(); g_ovl_pause_held = false; } + PsxHostVideoSettings video{}; + bool have_video = false; +#if defined(RECOMP_LAUNCHER) + RecompLauncherCSettings post{}; + bool have_ls = false; +#endif + std::vector mods; + { + std::lock_guard lk(g_ovl_mu); + if (g_ovl_video_pending) { video = g_ovl_video_post; have_video = true; g_ovl_video_pending = false; } +#if defined(RECOMP_LAUNCHER) + if (g_ovl_ls_pending) { post = g_ovl_ls_post; have_ls = true; g_ovl_ls_pending = false; } +#endif + mods.swap(g_ovl_mod_posts); + } + if (have_video) (void)psx_video_apply_live(&video); +#if defined(RECOMP_LAUNCHER) + if (have_ls) { + const PsxHostVideoSettings v = ovl_video_from(post); + (void)psx_video_apply_live(&v); +#if defined(RECOMP_LAUNCHER_HAS_JOGCON_FORCE_FEEDBACK) && \ + defined(PSX_HAS_JOGCON_FFB_PLAYER_SETTING) + /* Controls > Wheel force feedback (experimental): live, and saved to + * settings.toml [controller] jogcon_force_feedback when it changed. */ + if ((post.jogcon_force_feedback != 0) != (psx_jogcon_ffb_player_enabled() != 0)) { + psx_jogcon_ffb_set_player_enabled(post.jogcon_force_feedback); + const auto settings_path = + exe_dir_from_argv(g_ovl_argv0.c_str()) / "settings.toml"; + PSXRecompV4::UserSettings us = PSXRecompV4::load_user_settings(settings_path); + us.jogcon_force_feedback = post.jogcon_force_feedback != 0; + us.has_jogcon_force_feedback = true; + (void)PSXRecompV4::save_user_settings(settings_path, us); + } +#endif + } +#endif + if (!mods.empty()) { + for (const OvlModSet& m : mods) { + if (m.o.empty()) { + (void)psx_mod_feature_enable(m.p.c_str(), m.f.c_str(), m.v == "true"); + } else { + (void)psx_mod_feature_set_option(m.p.c_str(), m.f.c_str(), m.o.c_str(), m.v.c_str()); + (void)psx_mod_set_option_live(m.p.c_str(), m.f.c_str(), m.o.c_str(), m.v.c_str()); + } + } + (void)psx_mod_save_selection(); + } + if (const int disc = g_ovl_disc_req.exchange(0)) { + const int rc = disc_cycle_now(disc); + std::fprintf(stdout, "psxrecomp: in-game menu disc %d -> %d\n", disc, rc); + } + if (g_ovl_reload_binds.exchange(0)) { + psx_keybinds_init(g_ovl_argv0.c_str()); + load_input_config(g_ovl_argv0.c_str()); + } +#if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_RECOMP_NET) && defined(PSX_HAS_LOBBY_CLIENT) + /* The frontend loop is not running during a game: pump the lobby here, + * for every provider. Once a launch is pending, stop pumping: the pending + * check freed the lobby's UDP port for the match and a pump would bind it + * again; the session ends and the frontend loop starts the match. */ + if (g_lnch_netplay_available && !psx_netplay_active() && !g_ovl_launch_to_start) { + const RecompLauncherCNetplayCallbacks* np = &g_lnch_netplay_callbacks; + if (np->launch_pending && np->launch_pending(np->ctx)) { + if (!g_ovl_exit.load()) g_ovl_exit = PSX_MENU_EXIT_TO_FRONTEND + 1; + } else if (np->pump) { + np->pump(np->ctx); + } + } +#endif + if (const int code = g_ovl_exit.exchange(0)) { + const int how = code - 1; + if (how == PSX_MENU_EXIT_TO_FRONTEND) { + /* The menu does not come along into the next session: close it + * and drop its pause, or the match would start paused. */ + g_ovl_pause_wanted = 0; + if (g_ovl_pause_held) { psx_host_pause_pop(); g_ovl_pause_held = false; } + if (g_ovl->set_open) g_ovl->set_open(g_ovl->ctx, 0); + if (psx_netplay_active()) { + netplay_soft_exit("menu_to_frontend"); + } else { + /* End this session and re-enter the frontend loop in-process + * (the netplay soft-return path); it then starts whatever the + * menu asked for, e.g. the lobby's match. */ + std::fprintf(stdout, "psxrecomp: in-game menu -> frontend loop\n"); + g_netplay_from_lobby = 1; + g_ovl_launch_to_start = 1; + psx_request_return_to_lobby(); + } + return; + } + if (how == PSX_MENU_EXIT_RELAUNCH) { + std::fprintf(stdout, "psxrecomp: in-game menu -> relaunch for restart-only settings\n"); + std::fflush(stdout); + psx_crash_trace_set_exit_origin("menu_relaunch"); + shutdown_runtime(); + std::fprintf(stderr, "psxrecomp: relaunching\n"); + psx_relaunch_self(g_main_argc, g_main_argv); + std::fprintf(stderr, "psxrecomp: relaunch failed (%s)\n", std::strerror(errno)); + std::exit(0); /* relaunch failed: leave rather than run torn down */ + } + if (psx_netplay_active()) { + netplay_soft_exit("menu_quit"); + } else { + psx_crash_trace_set_exit_origin("menu_quit"); + shutdown_runtime(); + std::exit(0); + } + } +} + int main(int argc, char** argv) { + g_main_argc = argc; + g_main_argv = argv; /* Force line-buffered output so messages appear even if killed. */ std::setvbuf(stdout, nullptr, _IOLBF, BUFSIZ); std::setvbuf(stderr, nullptr, _IOLBF, BUFSIZ); @@ -16255,6 +16633,13 @@ int main(int argc, char** argv) { force_launcher = true; } else if (std::strcmp(argv[i], "--launcher") == 0) { force_launcher = true; + /* Launchers that boot into the game (pages in the in-game menu) + * show their pre-game window again when asked for explicitly. */ +#if defined(_WIN32) + _putenv_s("RECOMP_LAUNCHER_PREGAME", "1"); +#else + setenv("RECOMP_LAUNCHER_PREGAME", "1", 1); +#endif } else if (std::strcmp(argv[i], "--no-launcher") == 0) { force_no_launcher = true; } else if (std::strcmp(argv[i], "--headless-opengl") == 0) { @@ -16310,6 +16695,12 @@ int main(int argc, char** argv) { } } + /* Test hook: a hidden game window that still runs the launcher (and its + * in-game menu), unlike --hidden-window. */ + if (const char *e = std::getenv("PSX_HIDDEN_WINDOW")) { + if (*e && *e != '0') g_hidden_window = 1; + } + std::string default_game_config_storage; if (!game_config_path) { std::filesystem::path default_game_config = @@ -19567,6 +19958,8 @@ session_reboot: gl_renderer_set_post_shader(g_video_post_shader.c_str()); g_gl_active = (gl_renderer_init_context(sdl_window) != 0); dynres_setup(); + psx_video_current(&g_video_boot); /* boot snapshot for restart-only rows */ + recomp_overlay_setup(argv[0], s_picker_game_name); if (dbg_live_scale && g_gl_active) { if (!dynres_requested()) g_dynres.active = false; const int ceil_s = gl_renderer_dynamic_resolution_ceiling(); @@ -20326,7 +20719,8 @@ soft_return_lobby: teardown_game_session_keep_lobby(); PSXRecompV4::mod_runtime_end_netplay(); #if defined(RECOMP_LAUNCHER) && defined(PSX_HAS_LOBBY_CLIENT) - ae_np_prepare_lobby_rematch(); + if (!g_ovl_launch_to_start) ae_np_prepare_lobby_rematch(); + g_ovl_launch_to_start = 0; /* Say why the match ended on the launcher's status line; the * room and browser views both show last_error. Cleared when the player * ended the match themselves. */ diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index cd93d301..67846edc 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -927,6 +927,53 @@ bool materialize_derived_disc(RuntimeMods& s, const ModResolution& plan, return true; } +template +int mutate(Callback callback) { + state().ticket_ready = false; + ++state().preparation_revision; + std::string error; + if (!callback(error)) { + set_error(error); + return 0; + } + if (!state().disc_path.empty()) + state().validation = state().manager.resolve( + state().game_id, state().exe_sha256, state().disc_sha256, true); + else + state().validation = {}; + state().error.clear(); + return 1; +} + +/* Feature switch / option edits shared by the launcher ABI provider and the + * in-game menu host (psx_mod_feature_enable / psx_mod_feature_set_option). */ +int feature_enable_impl(const char* package_id, const char* feature_id, int enabled) { + if (!package_id || !feature_id) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_enabled( + package_id, enabled != 0, &error); + return state().manager.set_feature_enabled( + package_id, feature_id, enabled != 0, &error); + }); +} + +int feature_set_option_impl(const char* package_id, const char* feature_id, + const char* option_id, const char* value) { + if (!package_id || !feature_id || !option_id || !value) return 0; + return mutate([&](std::string& error) { + const ModFeature* feature = + state().manager.selected_feature(package_id, feature_id); + if (feature && feature->legacy) + return state().manager.set_option( + package_id, option_id, value, &error); + return state().manager.set_feature_option( + package_id, feature_id, option_id, value, &error); + }); +} + #if defined(RECOMP_LAUNCHER) void copy_text(char* out, size_t capacity, const std::string& value) { if (!out || capacity == 0) return; @@ -1166,32 +1213,14 @@ int provider_feature_choice_get(void*, const char* package_id, int provider_feature_enable(void*, const char* package_id, const char* feature_id, int enabled) { - if (!package_id || !feature_id) return 0; - return mutate([&](std::string& error) { - const ModFeature* feature = - state().manager.selected_feature(package_id, feature_id); - if (feature && feature->legacy) - return state().manager.set_enabled( - package_id, enabled != 0, &error); - return state().manager.set_feature_enabled( - package_id, feature_id, enabled != 0, &error); - }); + return feature_enable_impl(package_id, feature_id, enabled); } int provider_feature_set_option(void*, const char* package_id, const char* feature_id, const char* option_id, const char* value) { - if (!package_id || !feature_id || !option_id || !value) return 0; - return mutate([&](std::string& error) { - const ModFeature* feature = - state().manager.selected_feature(package_id, feature_id); - if (feature && feature->legacy) - return state().manager.set_option( - package_id, option_id, value, &error); - return state().manager.set_feature_option( - package_id, feature_id, option_id, value, &error); - }); + return feature_set_option_impl(package_id, feature_id, option_id, value); } int provider_feature_resource_count(void*, const char* package_id, @@ -1317,23 +1346,6 @@ int provider_version_get(void*, const char* package_id, int index, return 1; } -template -int mutate(Callback callback) { - state().ticket_ready = false; - ++state().preparation_revision; - std::string error; - if (!callback(error)) { - set_error(error); - return 0; - } - if (!state().disc_path.empty()) - state().validation = state().manager.resolve( - state().game_id, state().exe_sha256, state().disc_sha256, true); - else - state().validation = {}; - state().error.clear(); - return 1; -} int provider_install(void*, const char* path) { if (!path) return 0; @@ -3517,3 +3529,13 @@ extern "C" void mod_runtime_patch_disc_sector(uint32_t lba, int raw_sector, if (!PSXRecompV4::state().disc_enabled) return; patch_committed_disc_sector(lba, raw_sector, bytes, size); } + +/* In-game menu host (psx_menu_host.h): the same feature edits as the launcher + * ABI provider, without its types. Main thread. */ +extern "C" int psx_mod_feature_enable(const char* package_id, const char* feature_id, int enabled) { + return PSXRecompV4::feature_enable_impl(package_id, feature_id, enabled); +} +extern "C" int psx_mod_feature_set_option(const char* package_id, const char* feature_id, + const char* option_id, const char* value) { + return PSXRecompV4::feature_set_option_impl(package_id, feature_id, option_id, value); +} diff --git a/runtime/src/psx_menu_host.c b/runtime/src/psx_menu_host.c new file mode 100644 index 00000000..98ec98a1 --- /dev/null +++ b/runtime/src/psx_menu_host.c @@ -0,0 +1,18 @@ +/* psx_menu_host.c -- provider and disc-op registration (psx_menu_host.h). */ +#include "psx_menu_host.h" + +#include + +static const PsxMenuProvider *s_provider = NULL; +static const PsxMenuDiscOps *s_disc = NULL; + +int psx_menu_set_provider(const PsxMenuProvider *provider) { + if (provider && provider->abi_version != PSX_MENU_ABI_VERSION) return 0; + s_provider = provider; + return 1; +} + +const PsxMenuProvider *psx_menu_provider(void) { return s_provider; } + +void psx_menu_set_disc_ops(const PsxMenuDiscOps *ops) { s_disc = ops; } +const PsxMenuDiscOps *psx_menu_disc_ops(void) { return s_disc; } diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c index 53601398..1aaecd67 100644 --- a/runtime/tests/test_host_overlay.c +++ b/runtime/tests/test_host_overlay.c @@ -2,6 +2,10 @@ #include "host_overlay.h" #include +#if defined(_WIN32) +#include +#include +#endif #include #include @@ -155,9 +159,87 @@ static void test_texture_filter(void) { CHECK(psx_texture_filter_from_ui(-1) == PSX_TEXTURE_FILTER_NEAREST); } +/* Windows relaunch quoting: a parser for the CommandLineToArgvW rules (the + * real one too, on Windows) must give every argument back unchanged. */ +static int parse_cmdline(const char* line, char out[][256], int max) { + int n = 0; + const char* p = line; + while (*p) { + while (*p == ' ' || *p == '\t') ++p; + if (!*p || n >= max) break; + char* o = out[n]; + size_t len = 0; + int inq = 0; + for (;;) { + size_t bs = 0; + while (*p == '\\') { ++bs; ++p; } + if (*p == '"') { + for (size_t i = 0; i < bs / 2; ++i) o[len++] = '\\'; + if (bs & 1) { o[len++] = '"'; ++p; continue; } + ++p; + if (inq && *p == '"') { o[len++] = '"'; ++p; continue; } + inq = !inq; + continue; + } + for (size_t i = 0; i < bs; ++i) o[len++] = '\\'; + if (!*p || (!inq && (*p == ' ' || *p == '\t'))) break; + o[len++] = *p++; + } + o[len] = '\0'; + ++n; + } + return n; +} + +static void test_cmdline_quote(void) { + const char* args[] = { + "C:\\Games\\R4 Recomp\\r4-runtime.exe", + "--disc", + "D:\\Discs\\R4 - Ridge Racer Type 4 (USA).cue", + "--memcard-dir", + "C:\\Users\\Me\\Saved Games\\R4\\", /* trailing separator */ + "C:\\path\\with \"quote\".bin", /* embedded quote */ + "C:\\a\\b\\\\\"c", /* backslashes before a quote */ + "", /* empty argument */ + "plain", + "tab\there", + "trailing\\\\", /* backslashes, no spaces */ + }; + const int n = (int)(sizeof(args) / sizeof(args[0])); + char* line = psx_win_cmdline_join(n, args); + char got[16][256]; + CHECK(line != NULL); + CHECK(parse_cmdline(line, got, 16) == n); + for (int i = 0; i < n; ++i) CHECK(strcmp(got[i], args[i]) == 0); + /* No quoting when not needed; measuring matches writing. */ + char small[8]; + CHECK(psx_win_cmdline_quote("abc", small, sizeof small) == 3 && strcmp(small, "abc") == 0); + CHECK(psx_win_cmdline_quote("a b", NULL, 0) == 5); + CHECK(psx_win_cmdline_quote("", NULL, 0) == 2); +#if defined(_WIN32) + { + int wn = 0; + int len = MultiByteToWideChar(CP_UTF8, 0, line, -1, NULL, 0); + wchar_t* wl = (wchar_t*)malloc(sizeof(wchar_t) * (size_t)len); + MultiByteToWideChar(CP_UTF8, 0, line, -1, wl, len); + wchar_t** wv = CommandLineToArgvW(wl, &wn); + CHECK(wv && wn == n); + for (int i = 1; wv && i < n && i < wn; ++i) { /* argv[0] has its own rules; no quotes in exe paths */ + char u[512]; + WideCharToMultiByte(CP_UTF8, 0, wv[i], -1, u, sizeof u, NULL, NULL); + CHECK(strcmp(u, args[i]) == 0); + } + LocalFree(wv); + free(wl); + } +#endif + free(line); +} + int main(void) { test_draw_lifetime(); test_draw_reentrant(); + test_cmdline_quote(); test_video(); test_texture_filter(); test_sink(); diff --git a/runtime/tests/test_psx_menu_host.c b/runtime/tests/test_psx_menu_host.c new file mode 100644 index 00000000..cb046bac --- /dev/null +++ b/runtime/tests/test_psx_menu_host.c @@ -0,0 +1,33 @@ +/* psx_menu_host_test: provider and disc-op registration (psx_menu_host.h). */ +#include "psx_menu_host.h" + +#include + +static int fails = 0; +#define CHECK(c) do { if (!(c)) { fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #c); ++fails; } } while (0) + +static int attach(void* c, void* w, void* g, const PsxMenuHost* h) { (void)c; (void)w; (void)g; return h != NULL; } +static int count3(void) { return 3; } +static int insert_ok(int d) { return d >= 1 && d <= 3 ? 0 : 2; } + +int main(void) { + /* Default: no provider, so a title's own UI is unchanged. */ + CHECK(psx_menu_provider() == NULL); + PsxMenuProvider p = {PSX_MENU_ABI_VERSION, "test", NULL, attach, NULL, NULL, NULL, NULL, NULL}; + CHECK(psx_menu_set_provider(&p) == 1 && psx_menu_provider() == &p); + /* Another ABI version is refused and leaves the registration alone. */ + PsxMenuProvider old = p; + old.abi_version = PSX_MENU_ABI_VERSION + 1; + CHECK(psx_menu_set_provider(&old) == 0 && psx_menu_provider() == &p); + CHECK(psx_menu_set_provider(NULL) == 1 && psx_menu_provider() == NULL); + /* Disc ops: none until the tray policy publishes them. */ + CHECK(psx_menu_disc_ops() == NULL); + PsxMenuDiscOps d = {count3, NULL, NULL, insert_ok}; + psx_menu_set_disc_ops(&d); + CHECK(psx_menu_disc_ops() == &d && psx_menu_disc_ops()->count() == 3 && + psx_menu_disc_ops()->insert(2) == 0 && psx_menu_disc_ops()->insert(9) != 0); + psx_menu_set_disc_ops(NULL); + CHECK(psx_menu_disc_ops() == NULL); + if (!fails) printf("psx_menu_host_test: ok\n"); + return fails ? 1 : 0; +} From df0f36addee8f43b17fdcfaaba0cc3689eca6a16 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:44:40 -0700 Subject: [PATCH 20/25] Live mod options: overrides end with their session's plan Review (#602): live_option_values() was process-static and never cleared, so an override set in one session outlived mod_runtime_initialize and a new committed plan (committed 'eight', API still answered the old 'four'). Every plan change -- initialise, commit, the netplay plan (vanilla or negotiated) -- now drops the overrides. test_mod_runtime: a second session answers its own committed value. Co-Authored-By: Claude Opus 5.5 --- runtime/src/mod_runtime.cpp | 14 ++++++++++++++ runtime/tests/test_mod_runtime.cpp | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/runtime/src/mod_runtime.cpp b/runtime/src/mod_runtime.cpp index ad35da1f..cd93d301 100644 --- a/runtime/src/mod_runtime.cpp +++ b/runtime/src/mod_runtime.cpp @@ -1462,6 +1462,12 @@ bool mod_runtime_netplay_content_negotiation() { return netplay_content_negotiation_enabled; } +/* Live option overrides (psx_mod_set_option_live) belong to one session's + * plan: initialising the runtime, committing a plan or switching to the + * netplay plan drops them, so a new plan's committed values are what the + * API answers (defined with the overrides below). */ +void reset_live_option_overrides(); + bool mod_runtime_initialize(const std::filesystem::path& root, const std::string& game_id, uint32_t game_entry_pc, @@ -1480,6 +1486,7 @@ bool mod_runtime_initialize(const std::filesystem::path& root, s.audio_tracks.clear(); s.extent_start = 0; s.plan = {}; + reset_live_option_overrides(); /* a new plan ends the last session's live values */ s.validation = {}; s.raw_disc_index.clear(); s.user_disc_index.clear(); @@ -1586,6 +1593,7 @@ bool mod_runtime_clear_for_netplay(std::string* error) { s.audio_tracks.clear(); s.extent_start = 0; s.plan = {}; + reset_live_option_overrides(); /* a new plan ends the last session's live values */ s.validation = {}; s.raw_disc_index.clear(); s.user_disc_index.clear(); @@ -1740,6 +1748,7 @@ bool mod_runtime_commit_netplay(const std::filesystem::path& disc_path, if (renderer) plan.plugins.push_back({renderer, "", ""}); s.disc_path = disc_path; s.plan = std::move(plan); + reset_live_option_overrides(); /* a new plan ends the last session's live values */ mod_runtime_set_session_plan_fp({}); psx_mod_netplay_set_active(1); std::fprintf(stdout, "psxrecomp: title netplay profile %s (%s)\n", @@ -2067,6 +2076,7 @@ static bool install_plan(RuntimeMods& s, ModResolution plan, s.audio_tracks.clear(); s.extent_start = 0; s.plan = std::move(plan); + reset_live_option_overrides(); /* a new plan ends the last session's live values */ { HostLaunchTimingScope index_timing(HOST_LAUNCH_BUILD_DISC_INDEX); build_disc_index(s); @@ -2870,6 +2880,10 @@ extern "C" int psx_mod_save_selection(void) { return 1; } +namespace PSXRecompV4 { +void reset_live_option_overrides() { live_option_values().clear(); } +} // namespace PSXRecompV4 + extern "C" int psx_mod_set_option_live(const char* package_id, const char* feature_id, const char* option_id, const char* value) { using namespace PSXRecompV4; diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index ad8ff7d8..08ee4d9f 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -1124,6 +1124,17 @@ int main(int argc, char** argv) { std::string(value) == "four", "a refused live set must not change the value later reads see"); option_changed_refuse = 0; + /* A new session (initialise + commit) answers its own committed + * plan, not the previous session's live override. */ + check(PSXRecompV4::mod_runtime_initialize( + req_root, "SLUS-REQ", 0x80002000, {}, &error), + error.c_str()); + check(PSXRecompV4::mod_runtime_commit(stock_path, &error), + "the second session must commit"); + mod_runtime_activate_plugins(); + check(psx_mod_option_value("runtime.ram", "big-ram", "size", value, sizeof(value)) == 1 && + std::string(value) == "eight", + "a live override must not outlive its session's plan"); check(psx_mod_set_option_live("runtime.ram", "no-such-feature", "size", "x") == 0, "features outside the plan are not live-settable"); check(psx_mod_save_selection() == 1, "saving the selection mid-session must succeed"); From 3534e21aa56188f7da4855aedb43384d15e3dd72 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:32:58 -0700 Subject: [PATCH 21/25] Live video apply: use master's texture-filter enum (#655) instead of a copy #655 landed the same PSX_TEXTURE_FILTER_* enum and translation in video_texture_filter.h/.c; drop this PR's duplicate (link error) and include that header. Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 2 +- runtime/include/host_overlay.h | 14 +++----------- runtime/src/host_overlay.c | 5 ----- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 69f7b9f8..6306a96a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -793,7 +793,7 @@ if(BUILD_TESTING) tests/test_pad_external_input.c src/pad_external_input.c) target_include_directories(pad_external_input_test PRIVATE include src) add_test(NAME pad_external_input_test COMMAND pad_external_input_test) - add_executable(host_overlay_test tests/test_host_overlay.c src/host_overlay.c) + add_executable(host_overlay_test tests/test_host_overlay.c src/host_overlay.c src/video_texture_filter.c) target_include_directories(host_overlay_test PRIVATE include src) find_package(Threads REQUIRED) target_link_libraries(host_overlay_test PRIVATE Threads::Threads) diff --git a/runtime/include/host_overlay.h b/runtime/include/host_overlay.h index 49e956b2..060ea4e7 100644 --- a/runtime/include/host_overlay.h +++ b/runtime/include/host_overlay.h @@ -7,6 +7,7 @@ #ifndef PSX_HOST_OVERLAY_H #define PSX_HOST_OVERLAY_H +#include "video_texture_filter.h" #ifdef __cplusplus extern "C" { #endif @@ -79,17 +80,8 @@ typedef struct PsxHostVideoSettings { int present_thread; /* restart */ } PsxHostVideoSettings; -/* Texture filter: ONE enum for settings.toml, the launcher handoff and the - * in-game menu, so they cannot drift apart (#586 added xBR; a boolean UI - * cannot represent it). Values match [video] texture_filtering and - * gr_set_texture_filter(). STABLE_WORLD is set by mods, not by settings. */ -#define PSX_TEXTURE_FILTER_NEAREST 0 -#define PSX_TEXTURE_FILTER_BILINEAR 1 -#define PSX_TEXTURE_FILTER_STABLE_WORLD 2 -#define PSX_TEXTURE_FILTER_XBR 3 -/* A launcher / menu value to the enum: in-range values pass through, and - * anything else is nearest (the native default). Older launchers send 0/1. */ -int psx_texture_filter_from_ui(int value); +/* Texture filter values: PSX_TEXTURE_FILTER_* and psx_texture_filter_from_ui + * (video_texture_filter.h, #655), shared with settings and the handoff. */ #define PSX_VIDEO_RESTART_RENDERER (1u << 0) #define PSX_VIDEO_RESTART_RESOLUTION (1u << 1) diff --git a/runtime/src/host_overlay.c b/runtime/src/host_overlay.c index ad122a9d..12410352 100644 --- a/runtime/src/host_overlay.c +++ b/runtime/src/host_overlay.c @@ -147,8 +147,3 @@ unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *boot, m |= PSX_VIDEO_RESTART_THREADS; return m; } - -int psx_texture_filter_from_ui(int value) { - return (value >= PSX_TEXTURE_FILTER_NEAREST && value <= PSX_TEXTURE_FILTER_XBR) - ? value : PSX_TEXTURE_FILTER_NEAREST; -} From 4cda11d207521463ddaa0a2e70b249e1589e18de Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:02:57 -0700 Subject: [PATCH 22/25] Input sink: the netplay admit wait routes events to the overlay first The admit barrier polls SDL every frame of a netplay match and treated Esc as "leave the match" before the in-game overlay saw it, so opening the menu online could end the session (netplay_barrier_escape). Co-Authored-By: Claude Opus 5.5 --- runtime/src/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 492678c3..35d33cd4 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -6902,6 +6902,10 @@ static void netplay_barrier_admit(int override) { if (!g_headless) { SDL_Event ev; while (SDL_PollEvent(&ev)) { + /* The in-game overlay sees events first here too: the netplay + * admit wait polls every frame, and an Esc meant for the menu + * must not end the match. */ + if (psx_host_input_sink_dispatch(&ev)) continue; if (ev.type == SDL_QUIT) { netplay_soft_exit("sdl_window_close"); if (psx_return_to_lobby_requested()) goto done; From 934e6c96b657890cc244e18eb1bf0e045c6d3d07 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 14 Oct 2026 11:29:13 -0700 Subject: [PATCH 23/25] Synchronize shared menu video and netplay getters across runtime threads --- runtime/src/main.cpp | 45 ++++++++++---- runtime/tests/test_menu_video_snapshot.py | 71 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 runtime/tests/test_menu_video_snapshot.py diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 37b6494f..b50232ff 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -16141,6 +16141,25 @@ std::atomic g_ovl_reload_binds{0}; std::atomic g_ovl_exit{0}; /* PSX_MENU_EXIT_* + 1; 0 = none */ bool g_ovl_video_pending = false; PsxHostVideoSettings g_ovl_video_post{}; +PsxHostVideoSettings g_ovl_video_current{}, g_ovl_video_boot{}; +bool g_ovl_netplay = false; + +/* Only the main thread reads live runtime state. Provider draw callbacks + * consume a coherent snapshot under the same mutex as their posted edits. */ +void mh_refresh_snapshot() { + PsxHostVideoSettings current; + psx_video_current(¤t); + const bool netplay = psx_netplay_active() != 0; + std::lock_guard lk(g_ovl_mu); + g_ovl_video_current = current; + g_ovl_video_boot = g_video_boot; + g_ovl_netplay = netplay; +} +int mh_netplay_active(void) { + std::lock_guard lk(g_ovl_mu); + return g_ovl_netplay ? 1 : 0; +} + #if defined(RECOMP_LAUNCHER) bool g_ovl_ls_pending = false; RecompLauncherCSettings g_ovl_ls_post{}; @@ -16150,19 +16169,22 @@ std::string g_ovl_argv0, g_ovl_name, g_ovl_keybinds, g_ovl_config, g_ovl_assets; bool ovl_open(void) { return g_ovl && g_ovl->is_open && g_ovl->is_open(g_ovl->ctx); } int mh_set_paused(int paused) { - if (paused && psx_netplay_active()) return 0; + if (paused && mh_netplay_active()) return 0; g_ovl_pause_wanted = paused ? 1 : 0; return 1; } -int mh_netplay_active(void) { return psx_netplay_active() ? 1 : 0; } -void mh_video_get(PsxHostVideoSettings* out) { if (out) psx_video_current(out); } +void mh_video_get(PsxHostVideoSettings* out) { + if (!out) return; + std::lock_guard lk(g_ovl_mu); + *out = g_ovl_video_current; +} unsigned mh_restart_bits(const PsxHostVideoSettings& next) { - PsxHostVideoSettings now; - psx_video_current(&now); - unsigned out = psx_host_video_restart_bits(&g_video_boot, &next); + std::lock_guard lk(g_ovl_mu); + const PsxHostVideoSettings now = g_ovl_video_current; + unsigned out = psx_host_video_restart_bits(&g_ovl_video_boot, &next); /* In a netplay match only presentation applies live (psx_video_apply_live); * the rest waits for the next session. */ - if (psx_netplay_active() && + if (g_ovl_netplay && (psx_host_video_live_changes(&now, &next) & ~(PSX_VIDEO_LIVE_FULLSCREEN | PSX_VIDEO_LIVE_SCANLINES | PSX_VIDEO_LIVE_PRESENT_LINEAR))) out |= PSX_MENU_RESTART_AFTER_MATCH; @@ -16233,8 +16255,8 @@ int mh_disc_insert(int n) { const char* const kOvlRenderers[] = {"Software", "OpenGL", "Vulkan"}; PsxHostVideoSettings ovl_video_from(const RecompLauncherCSettings& ls) { PsxHostVideoSettings v; - psx_video_current(&v); /* keeps fields the menu does not show */ - v.fullscreen = ls.fullscreen ? (g_fullscreen ? g_fullscreen : 1) : 0; + mh_video_get(&v); /* keeps fields the menu does not show */ + v.fullscreen = ls.fullscreen ? (v.fullscreen ? v.fullscreen : 1) : 0; v.texture_filter = psx_texture_filter_from_ui(ls.texture_filter); v.fmv_filter = launcher_fmv_filter_to_cfg(ls.fmv_filter); v.scanlines = ls.scanlines ? 1 : 0; @@ -16250,7 +16272,7 @@ PsxHostVideoSettings ovl_video_from(const RecompLauncherCSettings& ls) { void mh_launcher_settings_get(RecompLauncherCSettings* out) { if (!out) return; PsxHostVideoSettings v; - psx_video_current(&v); + mh_video_get(&v); RecompLauncherCSettings& ls = *out; ls.fullscreen = v.fullscreen; ls.texture_filter = v.texture_filter; @@ -16336,6 +16358,7 @@ static void recomp_overlay_setup(const char* argv0, const std::string& title) { g_ovl_config = (dir / "config.ini").string(); g_ovl_assets = (dir / "assets").string(); g_menu_host.assets_dir = g_ovl_assets.c_str(); + mh_refresh_snapshot(); if (!p->attach || !p->attach(p->ctx, sdl_window, SDL_GL_GetCurrentContext(), &g_menu_host)) return; g_ovl = p; psx_host_set_input_sink(ovl_sink, nullptr); @@ -16361,6 +16384,7 @@ static void recomp_overlay_detach(void) { static void host_pause_service(void) { if (!g_ovl_on) return; + mh_refresh_snapshot(); psx_host_set_ui_capture(ovl_open()); const bool want = g_ovl_pause_wanted != 0 && ovl_open(); if (want && !g_ovl_pause_held) g_ovl_pause_held = psx_host_pause_push("overlay") != 0; @@ -16434,6 +16458,7 @@ static void host_pause_service(void) { } } #endif + mh_refresh_snapshot(); if (const int code = g_ovl_exit.exchange(0)) { const int how = code - 1; if (how == PSX_MENU_EXIT_TO_FRONTEND) { diff --git a/runtime/tests/test_menu_video_snapshot.py b/runtime/tests/test_menu_video_snapshot.py new file mode 100644 index 00000000..80539212 --- /dev/null +++ b/runtime/tests/test_menu_video_snapshot.py @@ -0,0 +1,71 @@ +"""Execute production menu getters while the main thread publishes settings.""" +import argparse, subprocess, tempfile +from pathlib import Path +from test_netplay_load_apply_flow import function_body + +ap=argparse.ArgumentParser() +ap.add_argument('--cxx',required=True) +args=ap.parse_args() +runtime=Path(__file__).resolve().parents[1] +source=(runtime/'src/main.cpp').read_text(encoding='utf-8') +fixture=r''' +#include "host_overlay.h" +#include +#include +#include +#include +#include +static std::mutex g_ovl_mu; +static PsxHostVideoSettings g_ovl_video_current{},g_ovl_video_boot{},g_video_boot{}, live{}; +static bool g_ovl_netplay=false; +static std::atomic netplay{0},wrong_thread_reads{0}; +static std::thread::id main_id; +static bool g_ovl_video_pending=false; +static PsxHostVideoSettings g_ovl_video_post{}; +#define PSX_MENU_RESTART_AFTER_MATCH (1u<<31) +static int psx_netplay_active() { return netplay.load(); } +extern "C" void psx_video_current(PsxHostVideoSettings *out) { + if (std::this_thread::get_id()!=main_id) { ++wrong_thread_reads; return; } + *out=live; +} +extern "C" unsigned psx_host_video_restart_bits(const PsxHostVideoSettings *b,const PsxHostVideoSettings *n) { + return b->renderer!=n->renderer ? PSX_VIDEO_RESTART_RENDERER : 0; +} +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; +} +''' +for signature in ['void mh_refresh_snapshot()', 'void mh_video_get(PsxHostVideoSettings* out)', + 'unsigned mh_restart_bits(const PsxHostVideoSettings& next)', + 'unsigned mh_video_apply(const PsxHostVideoSettings* next)']: + fixture += signature+' {'+function_body(source,signature)+'}\n' +fixture += r''' +static PsxHostVideoSettings values(int v) { return {v,v,v,v,v,v,v,v,v,v,v,v,v}; } +int main() { + main_id=std::this_thread::get_id(); + live=values(1); g_video_boot=values(1); mh_refresh_snapshot(); + std::atomic reads{0}; + std::thread draw([&] { + for (int i=0;i<50000;++i) { + PsxHostVideoSettings got{}; + mh_video_get(&got); + assert(got.fullscreen==got.texture_filter && got.texture_filter==got.renderer && + got.renderer==got.frame_generation && got.frame_generation==got.supersampling && + got.supersampling==got.render_thread && got.render_thread==got.present_thread); + const auto next=values(2); + const unsigned restart=mh_video_apply(&next); + assert(restart & PSX_VIDEO_RESTART_RENDERER); + ++reads; + } + }); + for (int i=0;i<50000;++i) { live=values(1+i%8); netplay=i%2; mh_refresh_snapshot(); } + draw.join(); + assert(reads==50000 && wrong_thread_reads==0 && g_ovl_video_pending); + puts("menu video snapshot: coherent draw-thread reads and main-thread ownership PASS"); +} +''' +with tempfile.TemporaryDirectory(prefix='psx-menu-snapshot-') as d: + d=Path(d); cpp=d/'snapshot.cpp'; exe=d/'snapshot.exe' + cpp.write_text(fixture,encoding='utf-8') + subprocess.run([args.cxx,'-std=c++17','-O2','-pthread','-I'+str(runtime/'include'),str(cpp),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) From 656aa1d35f6fdf8c9381147aebe552f482ae46f6 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 14 Oct 2026 11:31:46 -0700 Subject: [PATCH 24/25] Register shared menu snapshot coherence regression --- runtime/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index c48ff8d2..d79fc4dd 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -214,6 +214,11 @@ if(BUILD_TESTING) set(PSX_GL_READBACK_COMPILER_BIN "${_gl_test_default_bin}" CACHE PATH "GCC/MinGW bin for GL fixture") find_package(Python3 QUIET COMPONENTS Interpreter) if(Python3_Interpreter_FOUND) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + add_test(NAME menu_video_snapshot_test COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_video_snapshot.py + --cxx ${CMAKE_CXX_COMPILER}) + endif() add_test(NAME guest_implementation_binding_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_guest_implementation.py" --cmake "${CMAKE_COMMAND}" --compiler "${CMAKE_C_COMPILER}" From f4c5d6c93f7216b612862380180de752942e7781 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 14 Oct 2026 11:47:04 -0700 Subject: [PATCH 25/25] Draw paused host menus even before the first held game frame --- runtime/CMakeLists.txt | 3 + runtime/src/main.cpp | 5 +- .../tests/test_host_pause_first_present.py | 66 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 runtime/tests/test_host_pause_first_present.py diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index d79fc4dd..2c7ef907 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -218,6 +218,9 @@ if(BUILD_TESTING) add_test(NAME menu_video_snapshot_test COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_menu_video_snapshot.py --cxx ${CMAKE_CXX_COMPILER}) + add_test(NAME host_pause_first_present_test COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_host_pause_first_present.py + --cxx ${CMAKE_CXX_COMPILER}) endif() add_test(NAME guest_implementation_binding_test COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_guest_implementation.py" diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index b50232ff..8ce9cd94 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -7974,7 +7974,10 @@ static void rewind_pause_present(void) { #ifndef PSX_SDL_NO_RENDER if (g_gl_active) { gl_renderer_set_interpolation_suspended(1); - (void)gl_renderer_present_hold_last(); + /* A menu can pause before the first guest present. Give its draw + * callback a blank host frame when no held frame exists, or input + * handled during draw (including Resume) can never release pause. */ + if (!gl_renderer_present_hold_last()) gl_renderer_present_blank(); } else if (g_vk_active) { vk_renderer_present_blank(); } else if (sdl_renderer) { diff --git a/runtime/tests/test_host_pause_first_present.py b/runtime/tests/test_host_pause_first_present.py new file mode 100644 index 00000000..e3e80bb1 --- /dev/null +++ b/runtime/tests/test_host_pause_first_present.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Execute the production host-pause presenter before and after a held frame.""" +import argparse +from pathlib import Path +import subprocess +import tempfile +from test_netplay_load_apply_flow import function_body + +PRELUDE = r""" +#include +#include +int g_gl_active, g_vk_active, s_sw_hold_valid; +void *sdl_renderer, *sdl_texture; +int s_sw_hold_src, s_sw_hold_dst; +static int held, holds, blanks, vk_blanks, ticks, suspended; +uint32_t SDL_GetTicks() { return 7; } +void psx_rewind_present_tick(uint32_t t) { ticks += t == 7; } +void gl_renderer_set_interpolation_suspended(int x) { suspended = x; } +int gl_renderer_present_hold_last() { ++holds; return held; } +void gl_renderer_present_blank() { ++blanks; } +void vk_renderer_present_blank() { ++vk_blanks; } +void SDL_SetRenderDrawColor(void*,int,int,int,int) {} +void SDL_RenderClear(void*) {} +void SDL_RenderCopy(void*,void*,void*,void*) {} +void host_osd_draw_sdl(void*) {} +void SDL_RenderPresent(void*) {} +""" +CHECKS = r""" +int main() { + g_gl_active = 1; + held = 0; rewind_pause_present(); + if (holds != 1 || blanks != 1 || ticks != 1 || !suspended) { + fprintf(stderr, "No held frame: menu draw must still get a blank present\n"); return 1; + } + held = 1; rewind_pause_present(); + if (holds != 2 || blanks != 1 || ticks != 2) return 2; + g_gl_active = 0; g_vk_active = 1; rewind_pause_present(); + if (holds != 2 || blanks != 1 || vk_blanks != 1) return 3; + puts("host pause: first-frame fallback and existing held-frame path PASS"); + return 0; +} +""" + +def main(): + a=argparse.ArgumentParser();a.add_argument('--cxx',required=True);args=a.parse_args() + source=(Path(__file__).resolve().parents[1]/'src/main.cpp').read_text(encoding='utf-8') + body=function_body(source,'static void rewind_pause_present(void)') + fixed='static void rewind_pause_present(void) {'+body+'}' + old=fixed.replace('if (!gl_renderer_present_hold_last()) gl_renderer_present_blank();', + '(void)gl_renderer_present_hold_last();') + assert old != fixed + with tempfile.TemporaryDirectory() as folder: + p=Path(folder) + for label,code in [('fixed',fixed),('before',old)]: + cpp=p/(label+'.cpp');exe=p/(label+'.exe') + cpp.write_text(PRELUDE+code+CHECKS,encoding='utf-8') + subprocess.run([args.cxx,'-std=c++17',str(cpp),'-o',str(exe)],check=True) + r=subprocess.run([str(exe)],capture_output=True,text=True) + if label=='fixed': + assert r.returncode==0,r.stdout+r.stderr + print(r.stdout.strip()) + else: + assert r.returncode==1,'Regression must reject the old missing fallback' + print('pre-fix presenter: regression rejects missing first-frame draw') + +if __name__=='__main__':main()