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