From 64097606fbca9c535da86b7f6acdf882e86940cb Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Tue, 28 Jul 2026 00:04:20 -0700 Subject: [PATCH] runtime: fix game-owned display and controller mods --- recompiler/CMakeLists.txt | 2 ++ runtime/include/mod_plugins.h | 16 ++++++++- runtime/src/main.cpp | 35 +++++++++++++++++-- .../test_frame_interpolation_display_rate.py | 27 ++++++++++++++ runtime/tests/test_mod_controller_override.py | 33 +++++++++++++++++ 5 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 runtime/tests/test_frame_interpolation_display_rate.py create mode 100644 runtime/tests/test_mod_controller_override.py diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index c1c3dd37..ff08ecd3 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -384,7 +384,9 @@ if(BUILD_TESTING) default_renderer_guards fmv_quiet_guards fps_telemetry_opt_in + frame_interpolation_display_rate launcher_vulkan_option + mod_controller_override overlay_candidate_capacity overlay_decodable_fallback overlay_init_guard diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 1a5016da..c5a416b4 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -73,11 +73,25 @@ int psx_mod_set_native_vblank_rate(uint32_t frames_per_second); * Enable presentation-only frame interpolation while leaving guest VBlank, * game logic, timers, and audio at their stock cadence. The OpenGL presenter * blends between completed guest frames at the requested output rate. - * A value of zero selects an uncapped presentation loop. + * A value of zero follows the measured host-display refresh rate. */ int psx_mod_set_frame_interpolation(uint32_t frames_per_second); int psx_mod_set_auto_skip_fmv(int enabled); +/* + * Override one player's resolved controller presentation mode for this launch. + * This is intentionally a trusted-plugin API, not a generic launcher setting: + * games may hide Hybrid from their normal selector while offering it as an + * explicit game-owned mod. + */ +enum { + PSX_MOD_CONTROLLER_HYBRID = 0, + PSX_MOD_CONTROLLER_ANALOG = 1, + PSX_MOD_CONTROLLER_DIGITAL = 2 +}; +int psx_mod_set_controller_mode_override(uint32_t player, + uint32_t controller_mode); + /* * Register a C plugin before main() on the compilers supported by the runtime. * The registry itself uses function-local initialization, so constructor order diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 63fb8bea..6cc0b88e 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -57,6 +57,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "freeze_heartbeat.h" #include "config_loader.h" #include "game_options.h" +#include "mod_plugins.h" #include "mod_runtime.h" #include "crc32.h" #include "disc_identity.h" @@ -537,6 +538,13 @@ static int g_low_latency_input = 1; static int g_video_vsync = 1; static int g_frame_interpolation = 0; static int g_frame_interpolation_fps = 0; +static int g_mod_controller_mode_override[2] = { -1, -1 }; +static_assert((int)PSX_MOD_CONTROLLER_HYBRID == + (int)PSXRecompV4::PAD_MODE_HYBRID); +static_assert((int)PSX_MOD_CONTROLLER_ANALOG == + (int)PSXRecompV4::PAD_MODE_ANALOG); +static_assert((int)PSX_MOD_CONTROLLER_DIGITAL == + (int)PSXRecompV4::PAD_MODE_DIGITAL); static double g_host_refresh_hz = 0.0; static constexpr double PSX_FRAME_PERIOD_MS = 1000.0 / 59.94; static double g_frame_period_ms = PSX_FRAME_PERIOD_MS; @@ -677,7 +685,7 @@ extern "C" int psx_mod_set_frame_interpolation( g_video_vsync = 0; g_frame_interpolation = 1; g_frame_interpolation_fps = - frames_per_second ? (int)frames_per_second : -1; + frames_per_second ? (int)frames_per_second : 0; if (frames_per_second) { std::fprintf(stdout, @@ -686,7 +694,7 @@ extern "C" int psx_mod_set_frame_interpolation( (unsigned)frames_per_second); } else { std::fprintf(stdout, - "psxrecomp: mod selected uncapped presentation-only " + "psxrecomp: mod selected display-refresh presentation-only " "interpolation; guest timing remains stock\n"); } return 1; @@ -705,6 +713,20 @@ extern "C" int psx_mod_set_auto_skip_fmv(int enabled) { return 1; } +extern "C" int psx_mod_set_controller_mode_override( + uint32_t player, uint32_t controller_mode) { + if (player >= 2 || + controller_mode > (uint32_t)PSXRecompV4::PAD_MODE_DIGITAL) { + std::fprintf(stderr, + "psxrecomp: mod rejected controller-mode override " + "(player %u, mode %u)\n", + (unsigned)player, (unsigned)controller_mode); + return 0; + } + g_mod_controller_mode_override[player] = (int)controller_mode; + return 1; +} + /* [widescreen] per-game hooks (see config_loader.h): anchor scratch addr for * tagged sprite prims + HUD SPRT center-squash. Inert at 0/false. */ static uint32_t g_ws_anchor_addr = 0; @@ -6123,7 +6145,16 @@ int main(int argc, char** argv) { return 1; } } + /* Activation callbacks are re-run after every launcher session. Clear + * game-owned controller overrides first so disabling a package cannot + * leave its prior mode latched across a soft return to the launcher. */ + g_mod_controller_mode_override[0] = -1; + g_mod_controller_mode_override[1] = -1; mod_runtime_activate_plugins(); + if (g_mod_controller_mode_override[0] >= 0) + p1_mode = g_mod_controller_mode_override[0]; + if (g_mod_controller_mode_override[1] >= 0) + p2_mode = g_mod_controller_mode_override[1]; /* Re-apply the resolved language to the translation layer. text_xlate_init * (at config load) only saw the game.toml default; this folds in the diff --git a/runtime/tests/test_frame_interpolation_display_rate.py b/runtime/tests/test_frame_interpolation_display_rate.py new file mode 100644 index 00000000..59e42b7b --- /dev/null +++ b/runtime/tests/test_frame_interpolation_display_rate.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Guard the mod API's zero sentinel: follow the display, never busy-loop.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +HEADER = (ROOT / "runtime" / "include" / "mod_plugins.h").read_text( + encoding="utf-8" +) + +EXPECTED_ASSIGNMENT = """g_frame_interpolation_fps = + frames_per_second ? (int)frames_per_second : 0;""" + +assert EXPECTED_ASSIGNMENT in MAIN, ( + "psx_mod_set_frame_interpolation(0) must preserve the 0 sentinel so " + "gl_renderer_set_interpolation resolves it to the measured host refresh" +) +assert "frames_per_second ? (int)frames_per_second : -1" not in MAIN, ( + "the -1 sentinel selects the uncapped interpolation busy loop" +) +assert "zero follows the measured host-display refresh rate" in HEADER, ( + "the public plugin contract must document the display-rate sentinel" +) + +print("frame interpolation display-rate sentinel guard passed") diff --git a/runtime/tests/test_mod_controller_override.py b/runtime/tests/test_mod_controller_override.py new file mode 100644 index 00000000..9e71aacd --- /dev/null +++ b/runtime/tests/test_mod_controller_override.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Guard the game-owned controller-mode override lifecycle.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "runtime" / "src" / "main.cpp").read_text(encoding="utf-8") +HEADER = (ROOT / "runtime" / "include" / "mod_plugins.h").read_text( + encoding="utf-8" +) + +for symbol in ( + "PSX_MOD_CONTROLLER_HYBRID", + "PSX_MOD_CONTROLLER_ANALOG", + "PSX_MOD_CONTROLLER_DIGITAL", + "psx_mod_set_controller_mode_override", +): + assert symbol in HEADER, f"missing trusted-plugin controller API: {symbol}" + +reset = """g_mod_controller_mode_override[0] = -1; + g_mod_controller_mode_override[1] = -1; + mod_runtime_activate_plugins();""" +apply = """if (g_mod_controller_mode_override[0] >= 0) + p1_mode = g_mod_controller_mode_override[0]; + if (g_mod_controller_mode_override[1] >= 0) + p2_mode = g_mod_controller_mode_override[1];""" + +assert reset in MAIN, "controller overrides must reset before every activation pass" +assert apply in MAIN, "controller overrides must replace the resolved launch modes" +assert MAIN.index(reset) < MAIN.index(apply), "reset/activate must precede application" + +print("mod controller override lifecycle guard passed")