diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 8131a409..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) -**371 commands registered** — 358 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`). -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 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,8 +657,11 @@ 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` | ✓ | | | | `idle_skip` | ✓ | | | | `imask_trace` | ✓ | | | diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index abf634b1..dc367597 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -224,6 +224,14 @@ 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}) + 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" --cmake "${CMAKE_COMMAND}" --compiler "${CMAKE_C_COMPILER}" @@ -803,6 +811,17 @@ 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 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) + 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 new file mode 100644 index 00000000..06c2e504 --- /dev/null +++ b/runtime/include/host_overlay.h @@ -0,0 +1,127 @@ +/* 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 + +#include +#include "video_texture_filter.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); + +/* ---- 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. + * + * 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); +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); + +/* ---- 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; /* PSX_TEXTURE_FILTER_* (the [video] enum) */ + 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; + +/* 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) +#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) +#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); + +/* 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 +#endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 81e92287..3b1cc33c 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -372,6 +372,38 @@ 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 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). + */ +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); +/* 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); +/* 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/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..1e1a0fbc 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -390,6 +390,8 @@ 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/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 @@ -2286,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/debug_server.c b/runtime/src/debug_server.c index b8028672..3a7ee181 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9255,6 +9255,40 @@ 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()); +} + +/* 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"); +} + +/* 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); @@ -15916,6 +15950,9 @@ 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 }, + { "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/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 new file mode 100644 index 00000000..100f7a97 --- /dev/null +++ b/runtime/src/host_overlay.c @@ -0,0 +1,311 @@ +/* host_overlay.c -- see host_overlay.h. */ +#include "host_overlay.h" + +#include +#include +#include +#if defined(_WIN32) +#include +#else +#include +#endif + +/* ---- 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; } + +/* ---- 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, 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, *old; + if (fn) { + rec = (PsxHostOverlayDraw *)malloc(sizeof(*rec)); + if (!rec) return; + rec->fn = fn; + rec->ctx = ctx; + } + 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) { + return atomic_load_explicit(&s_draw, memory_order_acquire) != NULL; +} + +void psx_host_overlay_draw(int width, int height) { + 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); +} + +/* ---- 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; } + +/* ---- 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; +} + +/* ---- 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 fe049601..796a46e1 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -125,7 +125,9 @@ 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" +#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) @@ -3471,6 +3473,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. */ @@ -4003,6 +4010,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; } @@ -4019,13 +4031,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(); } @@ -6177,6 +6191,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; @@ -6277,6 +6293,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; @@ -6895,6 +6917,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; @@ -7915,7 +7941,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) { @@ -7988,6 +8017,113 @@ 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); /* 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; +} + +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); + /* 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(); +} + +/* 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; +} + +/* 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. */ static void savestate_menu_host_pause_loop(void) { psx_local_mouse_begin(sdl_window, false); @@ -8131,6 +8267,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"); @@ -8277,6 +8415,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; } @@ -8444,6 +8583,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(); @@ -8453,6 +8593,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 @@ -8463,6 +8605,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 @@ -8836,9 +8983,14 @@ 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(); + 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 @@ -9547,6 +9699,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 = 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) { + 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 @@ -15843,7 +16081,397 @@ 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{}; +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{}; +#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 && mh_netplay_active()) return 0; + g_ovl_pause_wanted = paused ? 1 : 0; + return 1; +} +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) { + 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 (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; + 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; + 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; + 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; + mh_video_get(&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(); + 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); + 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; + 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; + 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 + mh_refresh_snapshot(); + 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); @@ -15854,6 +16482,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 @@ -15998,6 +16627,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) { @@ -16053,6 +16689,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 = @@ -16753,6 +17395,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); @@ -19309,6 +19952,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(); @@ -20068,7 +20713,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 92eab572..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; @@ -1462,6 +1474,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 +1498,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 +1605,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 +1760,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 +2088,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); @@ -2824,6 +2846,92 @@ 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_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_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; +} + +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; + 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 || !psx_mod_option_live_capable(package_id, feature_id)) return 0; + /* 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; + 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; + } + if (!live) { + if (had_prev) values[key] = prev; + else values.erase(key); + } + return live; +} + extern "C" int psx_mod_option_value(const char* package_id, const char* feature_id, const char* option_id, @@ -2839,9 +2947,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); @@ -3419,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/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/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/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/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: diff --git a/runtime/tests/test_host_overlay.c b/runtime/tests/test_host_overlay.c new file mode 100644 index 00000000..1aaecd67 --- /dev/null +++ b/runtime/tests/test_host_overlay.c @@ -0,0 +1,250 @@ +/* test_host_overlay.c -- unit tests for host_overlay.c hooks. */ +#include "host_overlay.h" + +#include +#if defined(_WIN32) +#include +#include +#endif +#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(); +} + +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()); +} + +/* 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()); +} + +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()); +} + +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); +} + +/* 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); +} + +/* 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(); + test_draw(); + test_pause(); + printf("host_overlay_test: %s\n", g_fail ? "FAIL" : "ok"); + return g_fail ? 1 : 0; +} 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() 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) diff --git a/runtime/tests/test_mod_runtime.cpp b/runtime/tests/test_mod_runtime.cpp index 0398f7d8..08ee4d9f 100644 --- a/runtime/tests/test_mod_runtime.cpp +++ b/runtime/tests/test_mod_runtime.cpp @@ -318,6 +318,19 @@ 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++; + /* 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 option_changed_refuse ? 0 : 1; +} static void test_big_ram_activation(void) { big_ram_activations++; } @@ -1082,6 +1095,53 @@ 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) == "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", "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"); + /* 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; + /* 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"); + 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(); 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; } 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; +}