From 91d780bc0a57d2457c38729b139e3760b3284d66 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:43:18 -0700 Subject: [PATCH 1/4] Let a title claim a direct host shortcut; add a debug host-pad layer psx_mod_allow_direct_shortcut(PSX_MOD_SHORTCUT_*) lets a host shortcut bound to one controller button act on that button alone. Without it a one-button binding means Select + button, like the legacy encoding, so a title can default to a one-button binding safely whether or not its mod is enabled. While allowed (Rewind: while Rewind is enabled) the runtime claims the host button: it never reaches P1's guest pad, and a claim made while it is held lasts until release. Allowances clear at every mod session start. Multi-button bindings behave as before. game.toml [controller] direct_shortcut / direct_shortcut_button lets the title default that shortcut to one button and tells the launcher to capture it as one button (RECOMP_LAUNCHER_HAS_DIRECT_ASSIST_BIND). No title id is checked anywhere. Debug: set_input {"layer":"host", buttons, lx..ry, lt, rt} arms a virtual P1 gamepad that feeds stage 1 of the offline input path (so sources, title transforms and trigger values see it) and shortcut polling. A plain set_input override still wins. Headless runs with the layer armed resolve P1 through the same path and poll Rewind and the fast-forward latch; the Rewind pause loop keeps the debug endpoint live and runs without a window. Binding semantics and the claim latch are pure (psx_hotkey_pad.h) and covered by psx_hotkey_pad_test. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 2 +- recompiler/src/config_loader.cpp | 16 ++ recompiler/src/config_loader.h | 9 + runtime/CMakeLists.txt | 3 + runtime/include/debug_server.h | 7 + runtime/include/mod_plugins.h | 16 ++ runtime/include/psx_hotkey_pad.h | 71 +++++ runtime/src/debug_server.c | 54 +++- runtime/src/main.cpp | 314 +++++++++++++++++++--- runtime/tests/test_psx_hotkey_pad.c | 66 +++++ runtime/tests/test_rewind_toggle_combo.py | 8 +- 11 files changed, 521 insertions(+), 45 deletions(-) create mode 100644 runtime/include/psx_hotkey_pad.h create mode 100644 runtime/tests/test_psx_hotkey_pad.c diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index 2296d2c2..eb3bca82 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -72,7 +72,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `cdrom_sector_history_clear` | ✓ | | — | Reset the CD-ROM sector history ring | | `watch` | ✓ | ✓ | `addr` | Set byte-level memory watchpoint (fires per-frame on change) | | `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint | -| `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry`, `pad_type` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Debug builds also accept `pad_type` (0 digital, 1 DualShock, 2 JogCon) to test an emulated device identity. Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames | +| `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry`, `pad_type` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Debug builds also accept `pad_type` (0 digital, 1 DualShock, 2 JogCon) to test an emulated device identity. Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames. Runtime: `layer="host"` instead arms a virtual P1 gamepad (`buttons`, `lx`..`ry`, `lt`/`rt` 0-255) that feeds the normal offline input path (controller source, title pad transform, trigger values) and host shortcut polling, also headless; a plain override still wins while armed; `clear_input` disarms it | | `clear_input` | ✓ | ✓ | — | Remove input and analog axis overrides | | `turbo` | ✓ | | `enabled` | Enable/disable TCP-controlled frontend turbo for fast-forward validation | | `turbo_state` | ✓ | | — | Query TCP-controlled turbo state | diff --git a/recompiler/src/config_loader.cpp b/recompiler/src/config_loader.cpp index 40f2e106..8af49cd4 100644 --- a/recompiler/src/config_loader.cpp +++ b/recompiler/src/config_loader.cpp @@ -904,6 +904,22 @@ static RuntimeConfig parse_runtime_block(const toml::value& cfg, const fs::path& if (ct.contains("legacy_pad_config")) { rt.legacy_pad_config = toml::find(ct, "legacy_pad_config"); } + if (ct.contains("direct_shortcut")) { + rt.direct_shortcut = toml::find(ct, "direct_shortcut"); + if (rt.direct_shortcut != "rewind" && + rt.direct_shortcut != "save_state_menu" && + rt.direct_shortcut != "fast_forward" && + rt.direct_shortcut != "fast_forward_toggle") + throw std::runtime_error(fmt::format( + "[controller] direct_shortcut must be rewind, " + "save_state_menu, fast_forward or fast_forward_toggle, " + "got '{}'", rt.direct_shortcut)); + if (!ct.contains("direct_shortcut_button")) + throw std::runtime_error( + "[controller] direct_shortcut needs direct_shortcut_button"); + rt.direct_shortcut_button = + toml::find(ct, "direct_shortcut_button"); + } if (ct.contains("anti_deadzone")) { const auto n = toml::find(ct, "anti_deadzone"); if (n < 0 || n > 32767) diff --git a/recompiler/src/config_loader.h b/recompiler/src/config_loader.h index aa7b3e26..b40ad982 100644 --- a/recompiler/src/config_loader.h +++ b/recompiler/src/config_loader.h @@ -706,6 +706,15 @@ struct RuntimeConfig { bool has_multitap_port = false; int multitap_port = 1; + // direct_shortcut / direct_shortcut_button: one host shortcut ("rewind", + // "save_state_menu", "fast_forward", "fast_forward_toggle") the launcher + // captures as a single controller button, with that SDL GameController + // button name (e.g. "y") as its default binding. At run time a + // one-button binding acts alone only while the title's mod allows it + // (psx_mod_allow_direct_shortcut); otherwise it means Select + button. + std::string direct_shortcut; + std::string direct_shortcut_button; + // multitap_analog: DualShock-on-tap hack (default true). When true, // multitap bulk seats may report 0x73 + stick bytes; when false (faithful), // tap seats stay plain digital. Overridable by settings.toml / match_caps. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index b1f85e62..530552e9 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -464,6 +464,9 @@ if(BUILD_TESTING) tests/test_mod_pad_transform.c src/mod_pad_transform.c) target_include_directories(mod_pad_transform_test PRIVATE include) add_test(NAME mod_pad_transform_test COMMAND mod_pad_transform_test) + add_executable(psx_hotkey_pad_test tests/test_psx_hotkey_pad.c) + target_include_directories(psx_hotkey_pad_test PRIVATE include) + add_test(NAME psx_hotkey_pad_test COMMAND psx_hotkey_pad_test) add_executable(psx_trigger_values_test tests/test_psx_trigger_values.c) target_include_directories(psx_trigger_values_test PRIVATE include) add_test(NAME psx_trigger_values_test COMMAND psx_trigger_values_test) diff --git a/runtime/include/debug_server.h b/runtime/include/debug_server.h index 59ff00bc..1c78ed0c 100644 --- a/runtime/include/debug_server.h +++ b/runtime/include/debug_server.h @@ -209,6 +209,13 @@ int debug_server_get_axis_override(unsigned char st[4]); * 2=JogCon. */ int debug_server_get_pad_type_override(void); +/* Host-pad layer (set_input {"layer":"host", buttons, lx..ry, lt, rt}): a + * virtual P1 gamepad feeding the normal offline input path and host shortcut + * polling. Returns 1 while armed (until clear_input) and fills the PSX + * active-low word, sticks and trigger values (0..255). */ +int debug_server_get_host_pad(uint16_t *buttons, uint8_t st[4], uint8_t *lt, + uint8_t *rt); + /* TCP-controlled turbo mode. When enabled the frontend skips presentation and * wall-clock pacing at vblank, matching the keyboard TAB turbo path. */ int debug_server_turbo_enabled(void); diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index d99b4e34..a6c7717b 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -860,6 +860,22 @@ typedef struct PSXModPadTransform { } PSXModPadTransform; int psx_mod_set_pad_transform(uint32_t player, const PSXModPadTransform *transform); +/* Let a host shortcut bound to ONE controller button act on that button + * alone. Without this, a one-button binding means Select + button (the + * legacy rule), so a title's default one-button binding is safe whether or + * not its mod is enabled. While allowed (and, for Rewind, while Rewind is + * enabled) the runtime claims that host button: it is removed from P1's + * guest pad, and a claim made while it is held lasts until it is released. + * Call at mod activation; cleared at every mod/session reset. Returns 0 for + * an unknown shortcut. Multi-button bindings are unaffected. */ +enum { + PSX_MOD_SHORTCUT_REWIND = 0, + PSX_MOD_SHORTCUT_SAVE_STATE_MENU = 1, + PSX_MOD_SHORTCUT_FAST_FORWARD = 2, + PSX_MOD_SHORTCUT_FAST_FORWARD_TOGGLE = 3 +}; +int psx_mod_allow_direct_shortcut(uint32_t shortcut); + /* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner * (main) thread, owns relative capture and folds the resulting right-stick * bytes after native input/presentation (and after any controller source), diff --git a/runtime/include/psx_hotkey_pad.h b/runtime/include/psx_hotkey_pad.h new file mode 100644 index 00000000..cd93331f --- /dev/null +++ b/runtime/include/psx_hotkey_pad.h @@ -0,0 +1,71 @@ +#pragma once +/* Host controller shortcut bindings (launcher "assist" binds), pure logic. + * + * Encodings (shared with recomp-ui): 0 unbound; 1..99 one button (code+1), + * always chorded with Select (legacy); 100..999 an axis direction, chorded + * with Select; >= 1000 a button combination (1000 + mask of SDL + * GameController button codes), every button held. + * + * A ONE-button combination is a direct shortcut only while the running title + * allows it for that shortcut (psx_mod_allow_direct_shortcut); otherwise it + * means Select + that button, like the legacy one-button encoding. A direct + * shortcut claims its host button: the button is removed from the guest pad + * while claimed and, once claimed while held, until it is released. */ +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PSX_HOTKEY_PAD_COMBO_BASE 1000 + +typedef int (*PsxHotkeyButtonDown)(void *ctx, int sdl_button); +typedef int (*PsxHotkeyAxisValue)(void *ctx, int sdl_axis); + +/* SDL button code of a one-button combination, else -1. */ +static inline int psx_hotkey_pad_single_button(int binding) { + if (binding < PSX_HOTKEY_PAD_COMBO_BASE) return -1; + const uint32_t mask = (uint32_t)(binding - PSX_HOTKEY_PAD_COMBO_BASE); + if (!mask || (mask & (mask - 1u))) return -1; + int code = 0; + while (!(mask & (1u << code))) code++; + return code; +} + +/* Whether `binding` is held. `back` is the Select (Back) button code. */ +static inline int psx_hotkey_pad_down(int binding, int direct_allowed, int back, + PsxHotkeyButtonDown button_down, + PsxHotkeyAxisValue axis_value, void *ctx) { + if (binding <= 0) return 0; + if (binding >= PSX_HOTKEY_PAD_COMBO_BASE) { + const uint32_t mask = (uint32_t)(binding - PSX_HOTKEY_PAD_COMBO_BASE); + if (!mask) return 0; + if (psx_hotkey_pad_single_button(binding) >= 0 && !direct_allowed && + !button_down(ctx, back)) + return 0; + for (int code = 0; code < 32; ++code) + if ((mask & (1u << code)) && !button_down(ctx, code)) return 0; + return 1; + } + if (!button_down(ctx, back)) return 0; + if (binding < 100) return button_down(ctx, binding - 1); + { + const int axis = (binding - 100) / 2; + const int v = axis_value ? axis_value(ctx, axis) : 0; + return ((binding - 100) & 1) ? (v > 16000) : (v < -16000); + } +} + +/* Per-frame claim of direct-shortcut host buttons. `claimed` is the mask of + * buttons claimed this frame, `held` the buttons physically held. Returns + * the mask to remove from the guest pad; *latched carries a claim through + * release after the shortcut stops claiming the button. */ +static inline uint32_t psx_hotkey_claim_update(uint32_t *latched, + uint32_t claimed, uint32_t held) { + *latched = (*latched | claimed) & held; + return claimed | *latched; +} + +#ifdef __cplusplus +} +#endif diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 7d7a47ee..defc25fa 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -7826,10 +7826,40 @@ static void handle_unwatch(int id, const char *json) send_err(id, "watchpoint not found"); } +/* Host-pad layer (set_input {"layer":"host"}): a virtual P1 gamepad that + * feeds stage 1 of the normal offline input path (and host shortcut + * polling) instead of replacing it, so title transforms, direct shortcuts + * and trigger values see it like a physical pad. Persistent until + * clear_input. A plain override still wins while armed. */ +static int s_host_layer = 0; +static uint16_t s_host_buttons = 0xFFFF; +static uint8_t s_host_axes[4] = { 0x80, 0x80, 0x80, 0x80 }; +static uint8_t s_host_lt = 0, s_host_rt = 0; + +static uint8_t clamp_u8_arg(int v, uint8_t dflt) { + if (v < 0) return dflt; + return (uint8_t)(v > 255 ? 255 : v); +} + static void handle_set_input(int id, const char *json) { char val_str[32]; + char layer[16]; const int pad_type = json_get_int(json, "pad_type", -1); + if (json_get_str(json, "layer", layer, sizeof(layer)) && + strcmp(layer, "host") == 0) { + s_host_buttons = json_get_str(json, "buttons", val_str, sizeof(val_str)) + ? (uint16_t)hex_to_u32(val_str) : (uint16_t)0xFFFF; + s_host_axes[0] = clamp_u8_arg(json_get_int(json, "lx", -1), 0x80); + s_host_axes[1] = clamp_u8_arg(json_get_int(json, "ly", -1), 0x80); + s_host_axes[2] = clamp_u8_arg(json_get_int(json, "rx", -1), 0x80); + s_host_axes[3] = clamp_u8_arg(json_get_int(json, "ry", -1), 0x80); + s_host_lt = clamp_u8_arg(json_get_int(json, "lt", -1), 0); + s_host_rt = clamp_u8_arg(json_get_int(json, "rt", -1), 0); + s_host_layer = 1; + send_ok(id); + return; + } if (!json_get_str(json, "buttons", val_str, sizeof(val_str))) { send_err(id, "missing buttons"); return; } @@ -7898,7 +7928,9 @@ static void handle_pad_status(int id, const char *json) "\"slot1\":{\"buttons\":\"0x%04X\",\"connected\":%s,\"analog\":%s,\"type\":%d,\"sticks\":[%u,%u,%u,%u]," "\"negcon\":[%u,%u,%u],\"mode_locked\":%s}," "\"override\":%d,\"override_frames\":%d,\"override_pad_type\":%d," - "\"override_axes\":[%u,%u,%u,%u],\"override_axes_valid\":%s}\n", + "\"override_axes\":[%u,%u,%u,%u],\"override_axes_valid\":%s," + "\"host_layer\":%s,\"host_buttons\":\"0x%04X\",\"host_axes\":[%u,%u,%u,%u]," + "\"host_lt\":%u,\"host_rt\":%u}\n", id, pad0, pad0, sio_get_pad_connected(0) ? "true" : "false", sio_get_pad_analog(0) ? "true" : "false", sio_get_pad_analog(0), @@ -7910,7 +7942,10 @@ static void handle_pad_status(int id, const char *json) neg1[0], neg1[1], neg1[2], sio_get_pad_mode_locked(1) ? "true" : "false", s_input_override, s_input_frames, s_pad_type_override, s_axis_st[0], s_axis_st[1], s_axis_st[2], s_axis_st[3], - s_axis_override ? "true" : "false"); + s_axis_override ? "true" : "false", + s_host_layer ? "true" : "false", s_host_buttons, + s_host_axes[0], s_host_axes[1], s_host_axes[2], s_host_axes[3], + s_host_lt, s_host_rt); } static void handle_clear_input(int id, const char *json) @@ -7924,6 +7959,10 @@ static void handle_clear_input(int id, const char *json) s_axis_override = 0; s_pad_type_override = -1; s_axis_st[0] = s_axis_st[1] = s_axis_st[2] = s_axis_st[3] = 0x80; + s_host_layer = 0; + s_host_buttons = 0xFFFF; + s_host_axes[0] = s_host_axes[1] = s_host_axes[2] = s_host_axes[3] = 0x80; + s_host_lt = s_host_rt = 0; send_ok(id); } @@ -16111,6 +16150,17 @@ int debug_server_get_axis_override(unsigned char st[4]) return 1; } +int debug_server_get_host_pad(uint16_t *buttons, uint8_t st[4], uint8_t *lt, + uint8_t *rt) +{ + if (!s_host_layer) return 0; + if (buttons) *buttons = s_host_buttons; + if (st) memcpy(st, s_host_axes, 4); + if (lt) *lt = s_host_lt; + if (rt) *rt = s_host_rt; + return 1; +} + int debug_server_get_pad_type_override(void) { return s_pad_type_override; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 568d6e38..0b49aeaa 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -69,6 +69,7 @@ extern "C" void psx_event_step_conservative_env_init(void); #include "psx_stick.h" /* radial SDL-stick -> DualShock response transform */ #include "psx_trigger.h" /* continuous SDL trigger -> 0..255 magnitude */ #include "mod_pad_transform.h" +#include "psx_hotkey_pad.h" /* host shortcut bindings, direct claims */ #include "psx_controller_type.h" /* mapped wheel-name classification */ #include "psx_netplay_rb.h" #include "psx_selfcheck.h" @@ -1339,6 +1340,23 @@ static int g_hotkey_pad_rewind = 1272; /* select + r3 */ static int g_hotkey_pad_save_state_menu = 2040;/* select + r1 */ static int g_hotkey_pad_fast_forward = 1528; /* select + l1 (hold) */ static int g_hotkey_pad_fast_forward_toggle = 0; /* unbound: latch fast-forward */ +/* Direct host shortcuts the running title allows (bit per PSX_MOD_SHORTCUT_*, + * psx_mod_allow_direct_shortcut); cleared at every mod session start. */ +static uint32_t g_direct_shortcut_allowed = 0; +/* SDL buttons of P1's gamepad (or the debug host layer) currently claimed by + * a direct shortcut and removed from the guest pad; see psx_hotkey_pad.h. */ +static uint32_t g_p1_claimed_buttons = 0; +static uint32_t g_p1_claim_latched = 0; +/* Title-declared direct shortcut ([controller] direct_shortcut / _button): + * the launcher captures that action as one button and defaults it there. */ +static int g_title_direct_shortcut = -1; +static int g_title_direct_button = -1; + +extern "C" int psx_mod_allow_direct_shortcut(uint32_t shortcut) { + if (shortcut > PSX_MOD_SHORTCUT_FAST_FORWARD_TOGGLE) return 0; + g_direct_shortcut_allowed |= 1u << shortcut; + return 1; +} static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ @@ -5222,12 +5240,20 @@ static bool source_is_stick_axis(const ControllerSource& s) { static uint16_t controller_pad_buttons(const ControllerMap& map, SDL_GameController* h, bool suppress_stick_axes, - int deadzone_raw) { + int deadzone_raw, + uint32_t claimed_buttons = 0) { uint16_t buttons = 0xFFFF; /* all released */ if (!h) return buttons; for (const auto& entry : map) { for (const auto& source : entry.sources) { if (suppress_stick_axes && source_is_stick_axis(source)) continue; + /* A host button claimed by a direct shortcut never reaches the + * guest (psx_hotkey_pad.h). */ + if (claimed_buttons && + source.kind == ControllerSource::Kind::Button && + source.id >= 0 && source.id < 32 && + (claimed_buttons & (1u << source.id))) + continue; if (!controller_source_pressed_h(h, source, deadzone_raw)) continue; if (entry.bit) buttons &= (uint16_t)~entry.bit; @@ -5265,7 +5291,8 @@ static uint16_t pad_buttons_for(const PlayerInput& p, int player, bool suppress_ if (p.kind == 1) return pad_from_keyboard(player); if (p.kind == 2) return controller_pad_buttons(controller_map_for(p), p.handle, - suppress_stick_axes, p.deadzone); + suppress_stick_axes, p.deadzone, + player == 1 ? g_p1_claimed_buttons : 0u); return 0xFFFF; } @@ -5757,6 +5784,70 @@ static void apply_input_override_to_sio(int override_word) { * disconnected. `guarded` (savestate input guard) delivers neutral buttons and * sticks but still resolves presence/type. No mouse or source side effects: * those are layered on top by pad_ext_resolve(). */ +/* Debug host-pad layer (set_input {"layer":"host"}): a virtual P1 gamepad. */ +static int host_pad_layer(uint16_t* buttons, uint8_t st[4], uint8_t* lt, + uint8_t* rt) { +#ifndef PSX_NO_DEBUG_TOOLS + return debug_server_get_host_pad(buttons, st, lt, rt); +#else + (void)buttons; (void)st; (void)lt; (void)rt; + return 0; +#endif +} + +/* The host layer's PSX word expressed as the Xbox-layout SDL buttons a + * default-mapped gamepad would press (L2/R2 are trigger axes). */ +static const struct { int sdl; uint16_t psx; } kHostLayerButtons[] = { + { SDL_CONTROLLER_BUTTON_A, PAD_CROSS }, + { SDL_CONTROLLER_BUTTON_B, PAD_CIRCLE }, + { SDL_CONTROLLER_BUTTON_X, PAD_SQUARE }, + { SDL_CONTROLLER_BUTTON_Y, PAD_TRIANGLE }, + { SDL_CONTROLLER_BUTTON_BACK, PAD_SELECT }, + { SDL_CONTROLLER_BUTTON_START, PAD_START }, + { SDL_CONTROLLER_BUTTON_LEFTSTICK, PAD_L3 }, + { SDL_CONTROLLER_BUTTON_RIGHTSTICK, PAD_R3 }, + { SDL_CONTROLLER_BUTTON_LEFTSHOULDER, PAD_L1 }, + { SDL_CONTROLLER_BUTTON_RIGHTSHOULDER, PAD_R1 }, + { SDL_CONTROLLER_BUTTON_DPAD_UP, PAD_UP }, + { SDL_CONTROLLER_BUTTON_DPAD_DOWN, PAD_DOWN }, + { SDL_CONTROLLER_BUTTON_DPAD_LEFT, PAD_LEFT }, + { SDL_CONTROLLER_BUTTON_DPAD_RIGHT, PAD_RIGHT }, +}; + +static uint32_t host_layer_sdl_held(uint16_t word) { + uint32_t held = 0; + for (const auto& b : kHostLayerButtons) + if ((word & b.psx) == 0) held |= 1u << b.sdl; + return held; +} + +static uint16_t host_layer_release_claimed(uint16_t word, uint32_t claimed) { + for (const auto& b : kHostLayerButtons) + if (claimed & (1u << b.sdl)) word |= b.psx; + return word; +} + +/* Stage 1 for P1 while the host layer is armed: buttons less any claimed + * shortcut button; DualShock with the layer's sticks unless P1 is digital + * (host_view, the transform's host pad: the sticks in either mode). */ +static int capture_host_layer_pad(PsxNetPad* out, uint16_t word, + const uint8_t st[4], bool guarded, + bool host_view) { + const int mode = effective_player_mode_for_sio(g_players[0], 0); + const bool analog = mode != PSXRecompV4::PAD_MODE_DIGITAL; + const bool sticks = analog || host_view; + out->buttons = host_layer_release_claimed(word, g_p1_claimed_buttons); + out->lx = sticks ? st[0] : 0x80; out->ly = sticks ? st[1] : 0x80; + out->rx = sticks ? st[2] : 0x80; out->ry = sticks ? st[3] : 0x80; + out->analog = analog ? SIO_PAD_DUALSHOCK : SIO_PAD_DIGITAL; + out->connected = 1; + if (guarded) { + out->buttons = 0xFFFFu; + out->lx = out->ly = out->rx = out->ry = 0x80u; + } + return 1; +} + /* host_view: the port's host pad before presentation, for a title pad * transform (PadExtHooks.host_pad): real sticks and buttons without the * digital stick->D-pad fold, whatever the configured mode; type unchanged. */ @@ -5768,6 +5859,14 @@ static int capture_pad_slot_view(int s, PsxNetPad* out, bool guarded, out->analog = 0; out->connected = 0; + if (s == 0) { + uint16_t host_word; + uint8_t host_st[4], host_lt, host_rt; + if (host_pad_layer(&host_word, host_st, &host_lt, &host_rt)) + return capture_host_layer_pad(out, host_word, host_st, guarded, + host_view); + } + PlayerInput& p = g_players[s]; const int player = s + 1; /* keybinds.ini section (1..5) */ /* Opt-in dev merge: P1 is driven by the keyboard AND every connected @@ -6448,6 +6547,16 @@ static void pad_ext_host_extras(void*, int s, uint32_t* flags, uint32_t* lt, uint32_t* rt) { *flags = *lt = *rt = 0; if (s < 0 || s >= PSX_MAX_PLAYERS) return; + if (s == 0) { + uint16_t word; + uint8_t st[4], hl, hr; + if (host_pad_layer(&word, st, &hl, &hr)) { + *flags = PSX_MOD_PAD_HOST_GAMEPAD | PSX_MOD_PAD_HOST_LT | + PSX_MOD_PAD_HOST_RT; + *lt = hl; *rt = hr; + return; + } + } SDL_GameController* handle = g_players[s].handle; if (g_players[s].kind != 2 || !handle) return; *flags = PSX_MOD_PAD_HOST_GAMEPAD; @@ -6545,6 +6654,16 @@ static void sample_headless_pad_into_sio(int override) { apply_input_override_to_sio(override); return; } + if (host_pad_layer(nullptr, nullptr, nullptr, nullptr)) { + /* Debug host layer: P1 goes through the full offline resolution + * (source, title transform) exactly as a windowed gamepad would. */ + PadExtHooks hooks = pad_ext_main_hooks(); + PsxNetPad pad; + if (pad_ext_resolve(&hooks, 0, &pad)) + apply_pad_slot_to_sio(0, pad); + sio_set_pad_state_slot(1, 0xFFFFu); + return; + } #ifdef PSX_COSIM /* Input-driven cosim (EXPERIMENTAL): the coordinator sets a HELD pad state via the * `setpad` TCP command; the headless sampler applies it every frame so the same @@ -6931,42 +7050,109 @@ static int normalize_hotkey_pad_binding(int binding, int fallback) { return fallback; } -static int hotkey_pad_binding_down(int binding) { - SDL_GameController *h = g_players[0].handle; - if (!h || binding == 0) - return 0; - if (PSX_HOTKEY_PAD_IS_BUTTON_COMBO(binding)) { - uint32_t mask = (uint32_t)PSX_HOTKEY_PAD_BUTTON_COMBO_MASK(binding); - if (!mask) - return 0; - for (int code = 0; code < SDL_CONTROLLER_BUTTON_MAX && code < 32; ++code) { - if ((mask & ((uint32_t)1u << code)) == 0) - continue; - if (!SDL_GameControllerGetButton( - h, (SDL_GameControllerButton)code)) - return 0; +/* P1 host controller state for shortcut polling: the debug host layer while + * armed, else P1's assigned SDL gamepad. */ +static int p1_host_button_down(void*, int code) { + uint16_t word; + uint8_t st[4], lt, rt; + if (host_pad_layer(&word, st, <, &rt)) + return (host_layer_sdl_held(word) >> code) & 1u; + SDL_GameController* h = g_players[0].handle; + if (!h || code < 0 || code >= SDL_CONTROLLER_BUTTON_MAX) return 0; + return SDL_GameControllerGetButton(h, (SDL_GameControllerButton)code) != 0; +} + +static int p1_host_axis_value(void*, int axis) { + uint16_t word; + uint8_t st[4], lt, rt; + if (host_pad_layer(&word, st, <, &rt)) { + switch (axis) { + case SDL_CONTROLLER_AXIS_LEFTX: return ((int)st[0] - 0x80) * 256; + case SDL_CONTROLLER_AXIS_LEFTY: return ((int)st[1] - 0x80) * 256; + case SDL_CONTROLLER_AXIS_RIGHTX: return ((int)st[2] - 0x80) * 256; + case SDL_CONTROLLER_AXIS_RIGHTY: return ((int)st[3] - 0x80) * 256; + case SDL_CONTROLLER_AXIS_TRIGGERLEFT: return (int)lt * 128; + case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: return (int)rt * 128; + default: return 0; } - return 1; } - if (!SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_BACK)) + SDL_GameController* h = g_players[0].handle; + if (!h || axis < 0 || axis >= SDL_CONTROLLER_AXIS_MAX) return 0; + return SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)axis); +} + +static int p1_host_present(void) { + uint16_t word; + uint8_t st[4], lt, rt; + return host_pad_layer(&word, st, <, &rt) || g_players[0].handle; +} + +/* Whether host shortcut `shortcut` (PSX_ASSIST_BIND_*) bound to `binding` is + * held. A one-button combination is direct only while the title allows that + * shortcut; otherwise it is Select + button, like the legacy encoding. */ +static int hotkey_shortcut_down(int shortcut, int binding) { + if (!p1_host_present() || binding == 0) return 0; - if (PSX_HOTKEY_PAD_IS_BUTTON(binding)) { - int code = PSX_HOTKEY_PAD_BUTTON_CODE(binding); - if (code < 0 || code >= SDL_CONTROLLER_BUTTON_MAX) - return 0; - return SDL_GameControllerGetButton(h, (SDL_GameControllerButton)code) != 0; + const int direct = shortcut >= 0 && + (g_direct_shortcut_allowed & (1u << shortcut)) != 0; + return psx_hotkey_pad_down(binding, direct, SDL_CONTROLLER_BUTTON_BACK, + p1_host_button_down, p1_host_axis_value, + nullptr); +} + +/* Per frame, before P1 is sampled: the SDL buttons claimed by allowed direct + * shortcuts leave the guest pad (claimed, and through release once claimed + * while held). Rewind claims its button only while Rewind is enabled. */ +static void direct_shortcut_claim_update(void) { + const int bindings[PSX_ASSIST_BIND_COUNT] = { + g_hotkey_pad_rewind, g_hotkey_pad_save_state_menu, + g_hotkey_pad_fast_forward, g_hotkey_pad_fast_forward_toggle, + }; + uint32_t claimed = 0, held = 0; + for (int i = 0; i < PSX_ASSIST_BIND_COUNT; ++i) { + if (!(g_direct_shortcut_allowed & (1u << i))) continue; + if (i == PSX_ASSIST_BIND_REWIND && !g_rewind_enabled) continue; + const int b = psx_hotkey_pad_single_button(bindings[i]); + if (b >= 0) claimed |= 1u << b; } - if (PSX_HOTKEY_PAD_IS_AXIS(binding)) { - int code = PSX_HOTKEY_PAD_AXIS_CODE(binding); - Sint16 v; - if (code < 0 || code >= SDL_CONTROLLER_AXIS_MAX) - return 0; - v = SDL_GameControllerGetAxis(h, (SDL_GameControllerAxis)code); - return PSX_HOTKEY_PAD_AXIS_POSITIVE(binding) - ? (v > 16000) - : (v < -16000); + for (int code = 0; code < SDL_CONTROLLER_BUTTON_MAX && code < 32; ++code) + if (p1_host_button_down(nullptr, code)) held |= 1u << code; + g_p1_claimed_buttons = + psx_hotkey_claim_update(&g_p1_claim_latched, claimed, held); +} + +static int* hotkey_pad_binding_slot(int shortcut) { + switch (shortcut) { + case PSX_ASSIST_BIND_REWIND: return &g_hotkey_pad_rewind; + case PSX_ASSIST_BIND_SAVE_STATE_MENU: return &g_hotkey_pad_save_state_menu; + case PSX_ASSIST_BIND_FAST_FORWARD: return &g_hotkey_pad_fast_forward; + case PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE: return &g_hotkey_pad_fast_forward_toggle; + default: return nullptr; } - return 0; +} + +/* game.toml [controller] direct_shortcut(_button): that shortcut's default + * becomes the one-button combination (saved settings still win) and the + * launcher captures it as one button. */ +static void apply_title_direct_shortcut(const std::string& shortcut, + const std::string& button) { + static const char* const kNames[PSX_ASSIST_BIND_COUNT] = { + "rewind", "save_state_menu", "fast_forward", "fast_forward_toggle", + }; + int index = -1; + for (int i = 0; i < PSX_ASSIST_BIND_COUNT; ++i) + if (shortcut == kNames[i]) index = i; + const int code = (int)SDL_GameControllerGetButtonFromString(button.c_str()); + if (index < 0 || code < 0 || code >= SDL_CONTROLLER_BUTTON_MAX || code >= 32) { + std::fprintf(stderr, + "psxrecomp: [controller] direct_shortcut '%s' / button '%s' " + "not recognised; ignored\n", shortcut.c_str(), button.c_str()); + return; + } + g_title_direct_shortcut = index; + g_title_direct_button = code; + *hotkey_pad_binding_slot(index) = + PSX_HOTKEY_PAD_BUTTON_COMBO((uint32_t)1u << code); } static int savestate_menu_open = 0; @@ -7108,7 +7294,7 @@ static void savestate_menu_poll_nav(uint32_t now_ms) { cancel = 1; } - const int toggle = hotkey_pad_binding_down(g_hotkey_pad_save_state_menu); + const int toggle = hotkey_shortcut_down(PSX_ASSIST_BIND_SAVE_STATE_MENU, g_hotkey_pad_save_state_menu); if (savestate_menu_ignore_toggle_release) { if (!toggle) savestate_menu_ignore_toggle_release = 0; @@ -7143,7 +7329,7 @@ static void savestate_menu_poll_nav(uint32_t now_ms) { } static int rewind_toggle_buttons_down(void) { - return hotkey_pad_binding_down(g_hotkey_pad_rewind); + return hotkey_shortcut_down(PSX_ASSIST_BIND_REWIND, g_hotkey_pad_rewind); } static void rewind_poll_toggle_buttons(void) { @@ -7158,7 +7344,7 @@ static void rewind_poll_toggle_buttons(void) { static void savestate_menu_poll_toggle_buttons(void) { static int was_down; - int down = hotkey_pad_binding_down(g_hotkey_pad_save_state_menu); + int down = hotkey_shortcut_down(PSX_ASSIST_BIND_SAVE_STATE_MENU, g_hotkey_pad_save_state_menu); if (down && !was_down && !psx_rewind_is_open()) savestate_menu_toggle(0); was_down = down; @@ -7187,7 +7373,7 @@ static void fast_forward_toggle_flip(void) { static void fast_forward_toggle_poll_buttons(void) { static int was_down; - int down = hotkey_pad_binding_down(g_hotkey_pad_fast_forward_toggle); + int down = hotkey_shortcut_down(PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE, g_hotkey_pad_fast_forward_toggle); if (down && !was_down) fast_forward_toggle_flip(); was_down = down; @@ -7205,6 +7391,12 @@ static void rewind_poll_nav(uint32_t now_ms) { /* Honor remapped Cross/Circle (and Select/R3) via the same pad path as * gameplay — GameController A/B alone miss keyboard-as-pad and remaps. */ uint16_t btn = pad_buttons_for(g_players[0], 1, true); + { + uint16_t host_word; + uint8_t host_st[4], host_lt, host_rt; + if (host_pad_layer(&host_word, host_st, &host_lt, &host_rt)) + btn = host_word; + } if ((btn & PAD_LEFT) == 0) left = 1; if ((btn & PAD_RIGHT) == 0) @@ -7261,6 +7453,17 @@ static void rewind_host_pause_loop(void) { psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); while (psx_rewind_is_open()) { +#ifndef PSX_NO_DEBUG_TOOLS + /* The guest is frozen; keep the debug endpoint live so injected + * input, screenshots and status reads can drive the filmstrip. */ + debug_server_poll(); +#endif + if (g_headless) { + rewind_poll_nav((uint32_t)SDL_GetTicks()); + starvation_watchdog_heartbeat(); + SDL_Delay(1); + continue; + } SDL_Event ev; while (SDL_PollEvent(&ev)) { (void)psx_local_mouse_event(ev); @@ -7707,6 +7910,9 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { &g_runtime_perf.provider_poll_ticks); } + /* Direct-shortcut claims are settled before any shortcut poll or pad + * sample this frame (no-op unless a title allowed a direct shortcut). */ + direct_shortcut_claim_update(); if (!g_headless) { /* Pump SDL events to prevent window freeze. */ psx_local_mouse_begin(sdl_window, local_mouse_live(override)); @@ -7720,6 +7926,15 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { savestate_menu_host_pause_loop(); if (psx_rewind_is_open()) rewind_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 + * windowed, so scripted runs can drive them. */ + rewind_poll_toggle_buttons(); + fast_forward_toggle_poll_buttons(); + psx_rewind_note_frame(); + if (psx_rewind_is_open()) + rewind_host_pause_loop(); } /* Sample each player's device and feed the matching SIO pad slot. @@ -7955,7 +8170,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { const bool kb_turbo = host_hotkey_input_focused() && host_keymap_down(HOST_KEYMAP_TURBO, host_keys, (int)SDL_GetModState()); if (kb_turbo || g_manual_turbo_latched || - hotkey_pad_binding_down(g_hotkey_pad_fast_forward)) { + hotkey_shortcut_down(PSX_ASSIST_BIND_FAST_FORWARD, g_hotkey_pad_fast_forward)) { const int mult = manual_fast_forward_multiplier(); const int present_every = (mult < 0) ? 4 : (mult <= 4 ? 2 : 4); manual_turbo_active = true; @@ -13683,6 +13898,24 @@ namespace { gi->settings_bindings = 1; gi->assist_binding_labels = kPsxHostShortcutLabels; gi->assist_binding_count = PSX_ASSIST_BIND_COUNT; +#if defined(RECOMP_LAUNCHER_HAS_DIRECT_ASSIST_BIND) + /* Title-declared direct shortcut: captured as one button, defaulted + * to the title's button; the other shortcuts keep their defaults. */ + if (g_title_direct_shortcut >= 0) { + /* recomp-ui copies RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS entries. */ + static int pad_defaults[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS]; + static const int key_defaults[RECOMP_LAUNCHER_MAX_ASSIST_BINDINGS] = {}; + pad_defaults[PSX_ASSIST_BIND_REWIND] = PSX_HOTKEY_PAD_SELECT_R3; + pad_defaults[PSX_ASSIST_BIND_SAVE_STATE_MENU] = PSX_HOTKEY_PAD_SELECT_R1; + pad_defaults[PSX_ASSIST_BIND_FAST_FORWARD] = PSX_HOTKEY_PAD_SELECT_L1; + pad_defaults[PSX_ASSIST_BIND_FAST_FORWARD_TOGGLE] = 0; + pad_defaults[g_title_direct_shortcut] = PSX_HOTKEY_PAD_BUTTON_COMBO( + (uint32_t)1u << g_title_direct_button); + gi->assist_default_pad_bind = pad_defaults; + gi->assist_default_key_bind = key_defaults; + gi->assist_direct_pad_bind_action = g_title_direct_shortcut + 1; + } +#endif gi->has_skip_fmv = skip_fmv_offered_b ? 1 : 0; gi->has_turbo_loads = turbo_loads_offered_b ? 1 : 0; /* The Perspective textures row. Hidden for a title that ships PGXP @@ -14499,6 +14732,9 @@ int main(int argc, char** argv) { g_auto_skip_fmv = gc.runtime.video_auto_skip_fmv ? 1 : 0; /* [controller] game-declared input defaults (settings.toml/launcher * still override below). */ + if (!gc.runtime.direct_shortcut.empty()) + apply_title_direct_shortcut(gc.runtime.direct_shortcut, + gc.runtime.direct_shortcut_button); if (gc.runtime.has_default_mode) { for (int i = 0; i < PSX_MAX_PLAYERS; ++i) { player_mode[i] = (i == 0) ? gc.runtime.default_p1_mode @@ -16232,6 +16468,8 @@ int main(int argc, char** argv) { g_mod_controller_mode_override.fill(-1); mod_controller_source_reset(); mod_pad_transform_reset(); + g_direct_shortcut_allowed = 0; + g_p1_claimed_buttons = 0; for (auto& policy : g_mod_controller_policy) policy = ModControllerPresentationPolicy{}; g_mod_load_wall_multiplier = -1; diff --git a/runtime/tests/test_psx_hotkey_pad.c b/runtime/tests/test_psx_hotkey_pad.c new file mode 100644 index 00000000..e909ea3c --- /dev/null +++ b/runtime/tests/test_psx_hotkey_pad.c @@ -0,0 +1,66 @@ +/* Host shortcut binding semantics: legacy Select chords, multi-button combos, + * one-button combos that are direct only when the title allows it, and the + * claim-through-release mask for direct shortcut buttons. */ +#include +#include "psx_hotkey_pad.h" + +#define CHECK(expr) do { if (!(expr)) { \ + fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); return 1; } } while (0) + +enum { A = 0, B = 1, BACK = 4, Y = 3, RS = 8, LEFTX = 0 }; +static uint32_t held; +static int axis; +static int down(void *c, int code) { (void)c; return (held >> code) & 1u; } +static int axis_value(void *c, int a) { (void)c; return a == LEFTX ? axis : 0; } +static int combo(uint32_t mask) { return PSX_HOTKEY_PAD_COMBO_BASE + (int)mask; } +static int pressed(int binding, int direct) { + return psx_hotkey_pad_down(binding, direct, BACK, down, axis_value, NULL); +} + +int main(void) { + CHECK(psx_hotkey_pad_single_button(combo(1u << Y)) == Y); + CHECK(psx_hotkey_pad_single_button(combo((1u << BACK) | (1u << RS))) == -1); + CHECK(psx_hotkey_pad_single_button(1 + Y) == -1); /* legacy encoding */ + CHECK(psx_hotkey_pad_single_button(combo(0)) == -1); + CHECK(psx_hotkey_pad_single_button(0) == -1); + + /* One-button combination: Select + Y unless direct is allowed. */ + held = 1u << Y; + CHECK(!pressed(combo(1u << Y), 0)); + CHECK(pressed(combo(1u << Y), 1)); + held = (1u << Y) | (1u << BACK); + CHECK(pressed(combo(1u << Y), 0)); + + /* Multi-button combination: all held, direct flag irrelevant. */ + held = (1u << BACK) | (1u << RS); + CHECK(pressed(combo((1u << BACK) | (1u << RS)), 0)); + held = 1u << RS; + CHECK(!pressed(combo((1u << BACK) | (1u << RS)), 1)); + + /* Legacy one-button and axis encodings always chord with Select. */ + held = 1u << Y; + CHECK(!pressed(1 + Y, 1)); + held |= 1u << BACK; + CHECK(pressed(1 + Y, 0)); + axis = -20000; + CHECK(pressed(100 + LEFTX * 2 + 0, 0)); + CHECK(!pressed(100 + LEFTX * 2 + 1, 0)); + held = 0; + CHECK(!pressed(100 + LEFTX * 2 + 0, 1)); + CHECK(!pressed(0, 1)); + + /* Claims: a claimed button is suppressed; once claimed while held it + * stays suppressed through release even after the claim ends. */ + { + uint32_t latched = 0; + CHECK(psx_hotkey_claim_update(&latched, 1u << Y, 0) == (1u << Y)); + CHECK(psx_hotkey_claim_update(&latched, 1u << Y, 1u << Y) == (1u << Y)); + CHECK(psx_hotkey_claim_update(&latched, 0, 1u << Y) == (1u << Y)); + CHECK(psx_hotkey_claim_update(&latched, 0, 1u << Y) == (1u << Y)); + CHECK(psx_hotkey_claim_update(&latched, 0, 0) == 0); + CHECK(psx_hotkey_claim_update(&latched, 0, 1u << Y) == 0); /* fresh press */ + CHECK(psx_hotkey_claim_update(&latched, 0, 0) == 0 && latched == 0); + } + fprintf(stderr, "psx_hotkey_pad: passed\n"); + return 0; +} diff --git a/runtime/tests/test_rewind_toggle_combo.py b/runtime/tests/test_rewind_toggle_combo.py index 2ea4b422..1d533dcb 100644 --- a/runtime/tests/test_rewind_toggle_combo.py +++ b/runtime/tests/test_rewind_toggle_combo.py @@ -11,9 +11,9 @@ assert "static int g_hotkey_pad_rewind = 1272;" in MAIN assert "static int g_hotkey_pad_save_state_menu = 2040;" in MAIN assert "PSX_HOTKEY_PAD_IS_BUTTON_COMBO" in MAIN assert "PSX_HOTKEY_PAD_SELECT_R3" in MAIN -assert "static int hotkey_pad_binding_down(int binding)" in MAIN -assert "SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_BACK)" in MAIN -assert "return hotkey_pad_binding_down(g_hotkey_pad_rewind);" in MAIN +assert "static int hotkey_shortcut_down(int shortcut, int binding)" in MAIN +assert "psx_hotkey_pad_down(binding, direct, SDL_CONTROLLER_BUTTON_BACK," in MAIN +assert "return hotkey_shortcut_down(PSX_ASSIST_BIND_REWIND, g_hotkey_pad_rewind);" in MAIN assert "(btn & PAD_SELECT) == 0 && (btn & PAD_L3) == 0" not in MAIN assert ( "SDL_GameControllerGetButton(h, SDL_CONTROLLER_BUTTON_BACK) &&\n" @@ -47,7 +47,7 @@ assert "g_frame_period_ms / (double)mult" in MAIN assert "static int g_hotkey_pad_fast_forward = 1528;" in MAIN assert "PSX_HOTKEY_PAD_SELECT_L1" in MAIN assert "PSX_ASSIST_BIND_FAST_FORWARD" in MAIN -assert " hotkey_pad_binding_down(g_hotkey_pad_fast_forward)) {" in MAIN +assert "hotkey_shortcut_down(PSX_ASSIST_BIND_FAST_FORWARD, g_hotkey_pad_fast_forward)) {" in MAIN assert MAIN.count("ls.assist_pad_bind[PSX_ASSIST_BIND_FAST_FORWARD]") == \ MAIN.count("ls.assist_pad_bind[PSX_ASSIST_BIND_SAVE_STATE_MENU]") assert '"Fast-forward",' in MAIN From 31d438e76b480d34ee00f8df8c8eb338fbfc0451 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:26:25 -0700 Subject: [PATCH 2/4] Refresh the OpenBIOS emitter stamp after the config loader change The direct_shortcut keys touch recompiler/src/config_loader.*, which the BIOS emitter fingerprint covers, so configure warned that OpenBIOS was stale. tools/regen_bios.sh --config bios/OpenBIOS.toml regenerates byte-identical OpenBIOS_*.c; only the stamp changes. (Master 78f60d59 was already stale against its own emitter; this stamp covers that too.) Co-Authored-By: Claude Opus 5.5 --- generated/OpenBIOS.emitter.sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generated/OpenBIOS.emitter.sha b/generated/OpenBIOS.emitter.sha index 8c2ec8ca..cf3d565d 100644 --- a/generated/OpenBIOS.emitter.sha +++ b/generated/OpenBIOS.emitter.sha @@ -1 +1 @@ -ce9236c0e4e157e673c66063e9327f214ae88286e23fc9714af1b243d4334046 +696b254221854cd0c50c61e019a2a350cbb951c69c714bbca13dbe2254cf61c5 From 812465df58c0db5225bd902a7a2195d7b9efceb7 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:44:18 -0700 Subject: [PATCH 3/4] Let a title block local Rewind (psx_mod_set_rewind_blocked) A title calls psx_mod_set_rewind_blocked(1) while it is in a mode that must not be rewound, such as local split-screen multiplayer, and 0 when it ends. While blocked, opening Rewind is refused with an OSD note, no history is captured (the ring keeps what it has; a capture already due is dropped and an open panel closes unloaded), and a title-allowed direct Rewind shortcut is not in force: its button is not claimed and reaches the guest pad / title transform as if Rewind were off. Netplay already refuses Rewind on its own. The block clears at every mod/session start, so a title that never calls it is unchanged. The direct-allowance decision (allowed, and for Rewind: enabled and not blocked) is now one pure helper, psx_hotkey_direct_active, used for both the claim and the shortcut press, so an unclaimed Rewind button is never a one-button shortcut (with Rewind off it falls back to Select + button rather than popping the "Rewind is off" note on every press). Tests: psx_hotkey_pad_test covers the allowance and a block taking effect mid-press; rewind_toggle_combo_test guards the capture/open gates and reset. Co-Authored-By: Claude Opus 5.5 --- docs/config_schema.md | 9 +++++++ runtime/include/mod_plugins.h | 10 ++++++++ runtime/include/psx_hotkey_pad.h | 13 ++++++++++ runtime/include/psx_rewind.h | 5 ++++ runtime/src/main.cpp | 25 +++++++++++++++---- runtime/src/psx_rewind.c | 30 +++++++++++++++++++++-- runtime/tests/test_psx_hotkey_pad.c | 22 +++++++++++++++++ runtime/tests/test_rewind_toggle_combo.py | 12 +++++++++ 8 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/config_schema.md b/docs/config_schema.md index dbae78b3..562265bf 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -690,6 +690,15 @@ ignores all of these. Netplay rollback is a separate subsystem with its own ring and is unaffected by this setting; rewind is in fact suppressed while a netplay session is active. +A title can also block Rewind while it is in a mode that must not be rewound, +such as local split-screen multiplayer: a trusted mod plugin calls +`psx_mod_set_rewind_blocked(1)` when the mode starts and `0` when it ends +(`runtime/include/mod_plugins.h`). While blocked, opening Rewind is refused +with an OSD note, no snapshots are captured (the ring keeps what it already +has), and a title-allowed direct Rewind button (`psx_mod_allow_direct_shortcut`) +is not claimed, so it reaches the game as if Rewind were off. The block clears +at every mod/session reset; nothing changes for a title that never calls it. + Bezel artwork is intentionally not a `[video]` key. It is exposed as the disabled-by-default `psx.presentation.bezel` mod package, which draws a user-selected image resource behind the game image in OpenGL letterbox or diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index a6c7717b..03f2201a 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -876,6 +876,16 @@ enum { }; int psx_mod_allow_direct_shortcut(uint32_t shortcut); +/* Refuse local Rewind while the title is in a mode it must not rewind (e.g. + * local split-screen multiplayer). While blocked, opening Rewind is refused + * with an OSD note, no history is captured (snapshots already in the ring are + * kept), and an allowed direct Rewind shortcut is not in force: its button is + * not claimed and reaches the guest pad / title transform as if Rewind were + * disabled. Netplay already refuses Rewind on its own. Opt-in: call with 1 + * when the mode starts and 0 when it ends (setting it every frame from the + * title's state is fine). Cleared at every mod/session reset. */ +void psx_mod_set_rewind_blocked(int blocked); + /* Local P1 mouse policy. The runtime delivers ordered events on the SDL owner * (main) thread, owns relative capture and folds the resulting right-stick * bytes after native input/presentation (and after any controller source), diff --git a/runtime/include/psx_hotkey_pad.h b/runtime/include/psx_hotkey_pad.h index cd93331f..3efecb43 100644 --- a/runtime/include/psx_hotkey_pad.h +++ b/runtime/include/psx_hotkey_pad.h @@ -56,6 +56,19 @@ static inline int psx_hotkey_pad_down(int binding, int direct_allowed, int back, } } +/* Whether the direct allowance for `shortcut` is in force: allowed by the + * title and, for Rewind (`is_rewind`), Rewind enabled and not blocked by the + * title (psx_mod_set_rewind_blocked). When not in force the button is not + * claimed and a one-button binding falls back to Select + button. */ +static inline int psx_hotkey_direct_active(uint32_t allowed_mask, int shortcut, + int is_rewind, int rewind_enabled, + int rewind_blocked) { + if (shortcut < 0 || shortcut >= 32 || !(allowed_mask & (1u << shortcut))) + return 0; + if (is_rewind && (!rewind_enabled || rewind_blocked)) return 0; + return 1; +} + /* Per-frame claim of direct-shortcut host buttons. `claimed` is the mask of * buttons claimed this frame, `held` the buttons physically held. Returns * the mask to remove from the guest pad; *latched carries a claim through diff --git a/runtime/include/psx_rewind.h b/runtime/include/psx_rewind.h index 23cf98b3..186ddbd0 100644 --- a/runtime/include/psx_rewind.h +++ b/runtime/include/psx_rewind.h @@ -40,6 +40,11 @@ void psx_rewind_configure(uint32_t bios_checksum, uint32_t entry_pc); void psx_rewind_shutdown(void); int psx_rewind_enabled(void); +/* Title block (mod API psx_mod_set_rewind_blocked): while set, Rewind refuses + * to open and captures no history (the ring keeps what it has). Cleared at + * every mod session start. */ +void psx_rewind_set_title_blocked(int blocked); +int psx_rewind_title_blocked(void); int psx_rewind_is_open(void); /* 1 while open or slide animation still visible. */ int psx_rewind_needs_present(void); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 0b49aeaa..673a833d 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1357,6 +1357,10 @@ extern "C" int psx_mod_allow_direct_shortcut(uint32_t shortcut) { g_direct_shortcut_allowed |= 1u << shortcut; return 1; } + +extern "C" void psx_mod_set_rewind_blocked(int blocked) { + psx_rewind_set_title_blocked(blocked); +} static uint32_t g_savestate_input_guard_min_until = 0; static uint32_t g_savestate_input_guard_max_until = 0; static int g_headless = 0; /* debug/CI frontend: no SDL window/audio */ @@ -7087,14 +7091,24 @@ static int p1_host_present(void) { return host_pad_layer(&word, st, <, &rt) || g_players[0].handle; } +/* Whether the title's direct allowance for `shortcut` is in force. Rewind's + * needs Rewind enabled and not blocked by the title; otherwise its button is + * neither claimed nor a one-button shortcut and reaches the guest pad. */ +static int direct_shortcut_active(int shortcut) { + if (shortcut < 0 || shortcut >= 32) return 0; + return psx_hotkey_direct_active( + g_direct_shortcut_allowed, shortcut, + shortcut == PSX_ASSIST_BIND_REWIND, g_rewind_enabled, + psx_rewind_title_blocked()); +} + /* Whether host shortcut `shortcut` (PSX_ASSIST_BIND_*) bound to `binding` is * held. A one-button combination is direct only while the title allows that * shortcut; otherwise it is Select + button, like the legacy encoding. */ static int hotkey_shortcut_down(int shortcut, int binding) { if (!p1_host_present() || binding == 0) return 0; - const int direct = shortcut >= 0 && - (g_direct_shortcut_allowed & (1u << shortcut)) != 0; + const int direct = direct_shortcut_active(shortcut); return psx_hotkey_pad_down(binding, direct, SDL_CONTROLLER_BUTTON_BACK, p1_host_button_down, p1_host_axis_value, nullptr); @@ -7102,7 +7116,8 @@ static int hotkey_shortcut_down(int shortcut, int binding) { /* Per frame, before P1 is sampled: the SDL buttons claimed by allowed direct * shortcuts leave the guest pad (claimed, and through release once claimed - * while held). Rewind claims its button only while Rewind is enabled. */ + * while held). Rewind claims its button only while Rewind is enabled and the + * title has not blocked it. */ static void direct_shortcut_claim_update(void) { const int bindings[PSX_ASSIST_BIND_COUNT] = { g_hotkey_pad_rewind, g_hotkey_pad_save_state_menu, @@ -7110,8 +7125,7 @@ static void direct_shortcut_claim_update(void) { }; uint32_t claimed = 0, held = 0; for (int i = 0; i < PSX_ASSIST_BIND_COUNT; ++i) { - if (!(g_direct_shortcut_allowed & (1u << i))) continue; - if (i == PSX_ASSIST_BIND_REWIND && !g_rewind_enabled) continue; + if (!direct_shortcut_active(i)) continue; const int b = psx_hotkey_pad_single_button(bindings[i]); if (b >= 0) claimed |= 1u << b; } @@ -16470,6 +16484,7 @@ int main(int argc, char** argv) { mod_pad_transform_reset(); g_direct_shortcut_allowed = 0; g_p1_claimed_buttons = 0; + psx_rewind_set_title_blocked(0); for (auto& policy : g_mod_controller_policy) policy = ModControllerPresentationPolicy{}; g_mod_load_wall_multiplier = -1; diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index ef1f99c0..21ed8f72 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -100,8 +100,18 @@ static const uint8_t FONT8[59][8] = { }; #endif +/* Title block (psx_mod_set_rewind_blocked): set by the running title while + * a mode it cannot rewind is live, cleared at every mod session start. */ +static int s_title_blocked; + +int psx_rewind_title_blocked(void) +{ + return s_title_blocked; +} + #if !defined(PSX_HAS_RBENGINE_SNAP) +void psx_rewind_set_title_blocked(int blocked) { s_title_blocked = blocked ? 1 : 0; } void psx_rewind_set_depth(uint32_t depth) { (void)depth; } void psx_rewind_set_interval(uint32_t interval) { (void)interval; } void psx_rewind_set_enabled(int enabled) { (void)enabled; } @@ -470,7 +480,8 @@ int psx_rewind_needs_present(void) void psx_rewind_note_frame(void) { uint32_t iv; - if (!psx_rewind_enabled() || s_open || psx_netplay_active()) + if (!psx_rewind_enabled() || s_open || psx_netplay_active() || + s_title_blocked) return; s_frame++; iv = rewind_capture_interval(); @@ -552,7 +563,7 @@ void psx_rewind_poll(CPUState *cpu, uint32_t resume_pc) (void)do_load(cpu, tick); return; } - if (s_capture_due && !s_open && !psx_netplay_active()) + if (s_capture_due && !s_open && !psx_netplay_active() && !s_title_blocked) (void)do_capture(cpu, resume_pc); } @@ -569,6 +580,10 @@ int psx_rewind_toggle(void) host_osd_push("Rewind off during netplay", 1500); return 0; } + if (s_title_blocked && !s_open) { + host_osd_push("Rewind is off in this mode", 1500); + return 0; + } if (s_open) { s_open = 0; s_anim_dir = -1; @@ -588,6 +603,17 @@ int psx_rewind_toggle(void) return 1; } +void psx_rewind_set_title_blocked(int blocked) +{ + s_title_blocked = blocked ? 1 : 0; + if (!s_title_blocked) + return; + /* Captures stop here; one already scheduled is dropped so nothing from + * the blocked mode lands in history. An open panel closes unloaded. */ + s_capture_due = 0; + (void)psx_rewind_cancel(); +} + int psx_rewind_cancel(void) { if (!s_open) diff --git a/runtime/tests/test_psx_hotkey_pad.c b/runtime/tests/test_psx_hotkey_pad.c index e909ea3c..5f011c97 100644 --- a/runtime/tests/test_psx_hotkey_pad.c +++ b/runtime/tests/test_psx_hotkey_pad.c @@ -61,6 +61,28 @@ int main(void) { CHECK(psx_hotkey_claim_update(&latched, 0, 1u << Y) == 0); /* fresh press */ CHECK(psx_hotkey_claim_update(&latched, 0, 0) == 0 && latched == 0); } + /* Direct allowance in force: title-allowed, and for Rewind only while + * Rewind is enabled and the title has not blocked it. */ + { + const uint32_t allowed = 1u << 0; /* shortcut 0 (Rewind) */ + CHECK(psx_hotkey_direct_active(allowed, 0, 1, 1, 0)); + CHECK(!psx_hotkey_direct_active(allowed, 0, 1, 0, 0)); /* disabled */ + CHECK(!psx_hotkey_direct_active(allowed, 0, 1, 1, 1)); /* blocked */ + CHECK(!psx_hotkey_direct_active(0, 0, 1, 1, 0)); /* not allowed */ + CHECK(psx_hotkey_direct_active(1u << 2, 2, 0, 0, 1)); /* not Rewind */ + CHECK(!psx_hotkey_direct_active(~0u, -1, 0, 1, 0)); + CHECK(!psx_hotkey_direct_active(~0u, 32, 0, 1, 0)); + /* Blocked mid-press: the claim stops but the held button stays + * suppressed until release, then reaches the guest. */ + uint32_t latched = 0; + CHECK(psx_hotkey_claim_update(&latched, 1u << Y, 1u << Y) == (1u << Y)); + CHECK(psx_hotkey_claim_update(&latched, 0, 1u << Y) == (1u << Y)); + CHECK(psx_hotkey_claim_update(&latched, 0, 0) == 0); + CHECK(psx_hotkey_claim_update(&latched, 0, 1u << Y) == 0); + /* Not in force: a one-button binding needs Select, like legacy. */ + held = 1u << Y; + CHECK(!pressed(combo(1u << Y), psx_hotkey_direct_active(allowed, 0, 1, 1, 1))); + } fprintf(stderr, "psx_hotkey_pad: passed\n"); return 0; } diff --git a/runtime/tests/test_rewind_toggle_combo.py b/runtime/tests/test_rewind_toggle_combo.py index 1d533dcb..1f9b74c3 100644 --- a/runtime/tests/test_rewind_toggle_combo.py +++ b/runtime/tests/test_rewind_toggle_combo.py @@ -83,3 +83,15 @@ assert "s_actions[HOST_KEYMAP_TURBO].count == 0" not in KEYMAP_C assert "if (want_default(HOST_KEYMAP_TURBO))" in KEYMAP_C print("host shortcut guard passed") + +# Title Rewind block (psx_mod_set_rewind_blocked): refuses opening, stops +# capture, drops the direct Rewind claim, and clears at every mod session. +REW = (ROOT / "runtime" / "src" / "psx_rewind.c").read_text(encoding="utf-8") +assert 'extern "C" void psx_mod_set_rewind_blocked(int blocked) {' in MAIN +assert " psx_rewind_set_title_blocked(0);\n" in MAIN +assert "if (!direct_shortcut_active(i)) continue;" in MAIN +assert "const int direct = direct_shortcut_active(shortcut);" in MAIN +assert "psx_rewind_title_blocked());" in MAIN +assert " if (!psx_rewind_enabled() || s_open || psx_netplay_active() ||\n s_title_blocked)\n return;" in REW +assert "!psx_netplay_active() && !s_title_blocked)\n (void)do_capture" in REW +assert 'host_osd_push("Rewind is off in this mode", 1500);' in REW From 3654593e162c5cf44788dc56a68ebf3c34c8067b Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:49:55 -0700 Subject: [PATCH 4/4] Add a rewind_status debug command Reports whether local Rewind is enabled and open, the title block (psx_mod_set_rewind_blocked) and the snapshots held, so scripted headless runs can check that a blocked title captures no history and refuses to open. Co-Authored-By: Claude Opus 5.5 --- docs/TCP_COMMANDS.md | 6 ++++-- runtime/include/psx_rewind.h | 2 ++ runtime/src/debug_server.c | 15 +++++++++++++++ runtime/src/psx_rewind.c | 6 ++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/TCP_COMMANDS.md b/docs/TCP_COMMANDS.md index eb3bca82..b035ead8 100644 --- a/docs/TCP_COMMANDS.md +++ b/docs/TCP_COMMANDS.md @@ -74,6 +74,7 @@ Columns: **N** = native, **D** = DuckStation oracle. | `unwatch` | ✓ | ✓ | `addr` | Remove memory watchpoint | | `set_input` | ✓ | ✓ | `buttons`, optional `frames`, optional `lx`, `ly`, `rx`, `ry`, `pad_type` | Override pad1 buttons and optional analog axes (PS1 inverted bitmask, 0 = pressed; axes 0-255). Debug builds also accept `pad_type` (0 digital, 1 DualShock, 2 JogCon) to test an emulated device identity. Holds until `clear_input` on both backends; pass `frames=N` (beetle) to auto-release after N frames. Runtime: `layer="host"` instead arms a virtual P1 gamepad (`buttons`, `lx`..`ry`, `lt`/`rt` 0-255) that feeds the normal offline input path (controller source, title pad transform, trigger values) and host shortcut polling, also headless; a plain override still wins while armed; `clear_input` disarms it | | `clear_input` | ✓ | ✓ | — | Remove input and analog axis overrides | +| `rewind_status` | ✓ | | — | Local Rewind: `enabled`, `open`, `title_blocked` (`psx_mod_set_rewind_blocked`) and `snaps` held in the ring | | `turbo` | ✓ | | `enabled` | Enable/disable TCP-controlled frontend turbo for fast-forward validation | | `turbo_state` | ✓ | | — | Query TCP-controlled turbo state | | `pause` | ✓ | | — | **REMOVED** — still registered, but always returns an error. Query a ring buffer (`fn_entry_tail`, `wtrace_dump`, `gpu_frame_dump`) instead of synthesizing a snapshot | @@ -431,9 +432,9 @@ The TCP server is the canonical instrumentation surface. Rule 3 in `CLAUDE.md` i ## Complete command index (generated) -**344 commands registered** — 331 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). +**345 commands registered** — 332 on the native server (`runtime/src/debug_server.c`), 61 on the Beetle server (`runtime/src/beetle_debug_server.c`). -63 of 344 have prose above; **281 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. +64 of 345 have prose above; **281 are index-only**. An index-only command still works — it just has no description here yet. Send it `{"cmd":""}` and read the reply, or find its `handle_*` function in the server source. Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this block has drifted from the code. @@ -672,6 +673,7 @@ Regenerate with `python tools/gen_tcp_commands.py`; `--check` fails if this bloc | `restore_trace` | ✓ | | | | `restore_trace_clear` | ✓ | | | | `restore_trace_window` | ✓ | | | +| `rewind_status` | ✓ | | ✓ | | `rtrace_arm` | ✓ | ✓ | | | `rtrace_clear` | ✓ | | | | `rtrace_disarm` | | ✓ | | diff --git a/runtime/include/psx_rewind.h b/runtime/include/psx_rewind.h index 186ddbd0..cb1acfe5 100644 --- a/runtime/include/psx_rewind.h +++ b/runtime/include/psx_rewind.h @@ -45,6 +45,8 @@ int psx_rewind_enabled(void); * every mod session start. */ void psx_rewind_set_title_blocked(int blocked); int psx_rewind_title_blocked(void); +/* Snapshots in the ring (debug/status). */ +uint32_t psx_rewind_snap_count(void); int psx_rewind_is_open(void); /* 1 while open or slide animation still visible. */ int psx_rewind_needs_present(void); diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index defc25fa..54e60414 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -61,6 +61,7 @@ #include "guest_tty.h" #include "frame_fingerprint.h" #include "psx_segment_miss.h" +#include "psx_rewind.h" #include #include @@ -7912,6 +7913,19 @@ extern int sio_get_pad_analog(int slot); extern void sio_get_pad_sticks(int slot, uint8_t out[4]); extern void sio_get_pad_negcon(int slot, uint8_t out[3]); extern int sio_get_pad_mode_locked(int slot); +/* Local Rewind: enabled, panel open, title block (psx_mod_set_rewind_blocked) + * and the snapshots held, so scripted runs can check capture and opening. */ +static void handle_rewind_status(int id, const char *json) +{ + (void)json; + send_fmt("{\"id\":%d,\"ok\":true,\"enabled\":%s,\"open\":%s," + "\"title_blocked\":%s,\"snaps\":%u}\n", + id, psx_rewind_enabled() ? "true" : "false", + psx_rewind_is_open() ? "true" : "false", + psx_rewind_title_blocked() ? "true" : "false", + (unsigned)psx_rewind_snap_count()); +} + static void handle_pad_status(int id, const char *json) { (void)json; @@ -15144,6 +15158,7 @@ static const CmdEntry s_commands[] = { { "set_input", handle_set_input }, { "press", handle_press }, { "pad_status", handle_pad_status }, + { "rewind_status", handle_rewind_status }, { "clear_input", handle_clear_input }, { "input_route_clear", handle_input_route_clear }, { "input_route_append",handle_input_route_append }, diff --git a/runtime/src/psx_rewind.c b/runtime/src/psx_rewind.c index 21ed8f72..e7be4598 100644 --- a/runtime/src/psx_rewind.c +++ b/runtime/src/psx_rewind.c @@ -112,6 +112,7 @@ int psx_rewind_title_blocked(void) #if !defined(PSX_HAS_RBENGINE_SNAP) void psx_rewind_set_title_blocked(int blocked) { s_title_blocked = blocked ? 1 : 0; } +uint32_t psx_rewind_snap_count(void) { return 0; } void psx_rewind_set_depth(uint32_t depth) { (void)depth; } void psx_rewind_set_interval(uint32_t interval) { (void)interval; } void psx_rewind_set_enabled(int enabled) { (void)enabled; } @@ -472,6 +473,11 @@ int psx_rewind_is_open(void) return s_open; } +uint32_t psx_rewind_snap_count(void) +{ + return s_count; +} + int psx_rewind_needs_present(void) { return s_open || s_slide > 0.01f || s_anim_dir != 0;