Merge pull request #598 from RetroPortingToolKit/feat/host-pause

Host pause API (in-game overlay P1)
This commit is contained in:
Matt Stanley
2026-10-14 11:55:36 -07:00
committed by GitHub
27 changed files with 2001 additions and 53 deletions
+5 -2
View File
@@ -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":"<name>"}` 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":"<name>"}` 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` | ✓ | | |
+19
View File
@@ -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
+127
View File
@@ -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 <stddef.h>
#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
+32
View File
@@ -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
+118
View File
@@ -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
+3
View File
@@ -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);
+3 -1
View File
@@ -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.
+37
View File
@@ -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=<SDL scancode 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=<pt> y=<pt> [wheel=<steps>]: 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 },
+4
View File
@@ -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 */
+311
View File
@@ -0,0 +1,311 @@
/* host_overlay.c -- see host_overlay.h. */
#include "host_overlay.h"
#include <stdatomic.h>
#include <stddef.h>
#include <stdlib.h>
#if defined(_WIN32)
#include <windows.h>
#else
#include <sched.h>
#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 <stdlib.h>
#include <string.h>
/* 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 <stdio.h>
#include <windows.h>
#include <shellapi.h>
/* 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 <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/time.h>
#include <unistd.h>
#if defined(__APPLE__)
#include <mach-o/dyld.h>
#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
+654 -8
View File
@@ -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 <mutex>
#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<int> g_ovl_pause_wanted{0};
bool g_ovl_pause_held = false;
std::atomic<int> g_ovl_reload_binds{0};
std::atomic<int> 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(&current);
const bool netplay = psx_netplay_active() != 0;
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lk(g_ovl_mu);
*out = g_ovl_video_current;
}
unsigned mh_restart_bits(const PsxHostVideoSettings& next) {
std::lock_guard<std::mutex> 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<std::mutex> 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<OvlModSet> 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<std::mutex> 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<int> 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<std::mutex> 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<void (*)(struct RecompLauncherCGameInfo*)>(mh_launcher_game_info),
reinterpret_cast<void (*)(struct RecompLauncherCSettings*)>(mh_launcher_settings_get),
reinterpret_cast<unsigned (*)(const struct RecompLauncherCSettings*)>(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<OvlModSet> mods;
{
std::lock_guard<std::mutex> 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 <manifest-root>...: 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. */
+160 -40
View File
@@ -927,6 +927,53 @@ bool materialize_derived_disc(RuntimeMods& s, const ModResolution& plan,
return true;
}
template <typename Callback>
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 <typename Callback>
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<std::string, std::string>& live_option_values() {
static std::map<std::string, std::string> m;
return m;
}
std::map<std::string, PSXModOptionChangedCallback>& option_changed_plugins() {
static std::map<std::string, PSXModOptionChangedCallback> 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);
}
+1 -1
View File
@@ -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
+1
View File
@@ -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);
+18
View File
@@ -0,0 +1,18 @@
/* psx_menu_host.c -- provider and disc-op registration (psx_menu_host.h). */
#include "psx_menu_host.h"
#include <stddef.h>
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; }
+12
View File
@@ -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);
+3
View File
@@ -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",
+3
View File
@@ -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,
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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:
+250
View File
@@ -0,0 +1,250 @@
/* test_host_overlay.c -- unit tests for host_overlay.c hooks. */
#include "host_overlay.h"
#include <stdio.h>
#if defined(_WIN32)
#include <windows.h>
#include <shellapi.h>
#endif
#include <stdlib.h>
#include <string.h>
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 <pthread.h>
#include <stdatomic.h>
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;
}
@@ -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 <stdint.h>
#include <stdio.h>
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()
+71
View File
@@ -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 <mutex>
#include <atomic>
#include <thread>
#include <cassert>
#include <cstdio>
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<int> 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<int> 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)
+60
View File
@@ -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();
+1 -1
View File
@@ -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;
}
+33
View File
@@ -0,0 +1,33 @@
/* psx_menu_host_test: provider and disc-op registration (psx_menu_host.h). */
#include "psx_menu_host.h"
#include <stdio.h>
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;
}