From fedb5319d22e33aa9f1bbb3a088e9a453d0b8610 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 17 Oct 2026 09:19:31 -0700 Subject: [PATCH] Support scoped modern controls and replay controller presence Keep temporary keyboard profiles separate from saved bindings. Add optional persistent mouse capture with ordered release/focus/reset handling, and let offline sources represent an unplugged port without falling back to physical input. Existing capture and source defaults remain intact. Beads: beads-9wg8 and beads-eio.19.18. --- runtime/include/mod_controller_source.h | 1 + runtime/include/mod_plugins.h | 7 +++++++ runtime/include/psx_keybinds.h | 4 ++++ runtime/src/local_mouse_policy.cpp | 15 +++++++++++++++ runtime/src/local_mouse_policy.h | 2 ++ runtime/src/local_mouse_sdl.cpp | 3 +++ runtime/src/main.cpp | 12 ++++++++++++ runtime/src/mod_controller_source.c | 12 ++++++++++++ runtime/src/psx_keybinds.c | 15 +++++++++++++++ runtime/tests/test_keyboard_pad_chord.c | 21 +++++++++++++++++++++ runtime/tests/test_local_mouse_policy.cpp | 17 +++++++++++++++++ runtime/tests/test_mod_controller_source.c | 9 +++++++++ 12 files changed, 118 insertions(+) diff --git a/runtime/include/mod_controller_source.h b/runtime/include/mod_controller_source.h index 201f53a5..e7f8dfa6 100644 --- a/runtime/include/mod_controller_source.h +++ b/runtime/include/mod_controller_source.h @@ -14,6 +14,7 @@ void mod_controller_source_reset(void); void mod_controller_source_begin_frame(void); int mod_controller_source_sample(uint32_t player, PSXModControllerState *state, int *released); int mod_controller_source_present(uint32_t player); +int mod_controller_source_connected(uint32_t player); #ifdef __cplusplus } #endif diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 85a7ae47..d9fc3954 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -1004,6 +1004,10 @@ typedef struct PSXModControllerState { } PSXModControllerState; typedef int (*PSXModControllerSource)(PSXModControllerState *state); int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source); +/* Presence of an attached offline source (default connected). A diagnostic + * source may explicitly emulate an unplugged pad without falling back to a + * physical controller assigned to that port. Cleared on detach/session reset. */ +int psx_mod_set_controller_source_connected(uint32_t player, int connected); /* Title pad transform (stage 2b above). Offline local play only: never under * netplay or rollback resim (the session is vanilla), selfcheck replay or a @@ -1140,6 +1144,9 @@ typedef struct PSXModMousePolicy { void (*sample)(uint64_t now_ns, PSXModMouseOutput* output); } PSXModMousePolicy; int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy); +/* Optional action-game capture: click once to capture, Escape to release. + * Later clicks reach the normal bindings. Existing hold-to-look is default. */ +int psx_mod_set_local_mouse_persistent_capture(int enabled); /* * Register a C plugin before main() on the compilers supported by the runtime. diff --git a/runtime/include/psx_keybinds.h b/runtime/include/psx_keybinds.h index be227b40..dd3139fd 100644 --- a/runtime/include/psx_keybinds.h +++ b/runtime/include/psx_keybinds.h @@ -73,6 +73,10 @@ void psx_keybinds_init(const char *exe_path); /* Read-only view of the current bindings. */ const PsxKeyBinds *psx_keybinds_get(void); +/* Trusted title mod's temporary sampling profile. Does not edit or save the + * user's primary/alternate bindings. Cleared at each session boundary. */ +int psx_keybinds_set_session_player(int player, const PsxPlayerBinds *profile); +void psx_keybinds_clear_session_players(void); /* Runtime-owned host controls consumed by optional local input capture. * Suppression is per source/player, before PSX folding, and lasts through diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp index e5df8e04..93171fd1 100644 --- a/runtime/src/local_mouse_policy.cpp +++ b/runtime/src/local_mouse_policy.cpp @@ -23,6 +23,13 @@ void LocalMousePolicy::clear() { clock_valid_ = false; clock_ = event_clock_ = release_clock_ = 0; conflict_noticed_ = capture_failure_noticed_ = false; + persistent_ = false; +} +bool LocalMousePolicy::persistent_capture(bool enabled) { + if (!installed()) return false; + reset(); + persistent_ = enabled; + return true; } void LocalMousePolicy::dispatch(uint32_t type, uint64_t t, double dx, double dy) { if (!installed()) return; @@ -84,6 +91,14 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, const bool fresh = down && !activation_down_ && !repeat; activation_down_ = down; if (!down) { + if (persistent_ && captured_ && allowed()) { + const bool owned = activation_owned_; + activation_owned_ = false; + // t=0 is the backend's release reconciliation, not a queued + // event. Malformed real event times still release capture. + if (t) (void)valid_time(t, now); + return owned; + } // Release is unconditional: a future/backwards event time cannot // leave capture or the reducer's separate direction hold active. // A valid ordered release while still eligible owns its event diff --git a/runtime/src/local_mouse_policy.h b/runtime/src/local_mouse_policy.h index ddb0082b..cf3febd4 100644 --- a/runtime/src/local_mouse_policy.h +++ b/runtime/src/local_mouse_policy.h @@ -21,6 +21,7 @@ class LocalMousePolicy { public: explicit LocalMousePolicy(LocalMouseBackend backend) : backend_(backend) {} bool install(const PSXModMousePolicy* policy); + bool persistent_capture(bool enabled); void clear(); void reset(); void update(uint64_t now, LocalMouseHost host); @@ -45,5 +46,6 @@ private: bool activation_down_ = false, activation_owned_ = false; bool hold_active_ = false; // accepted interpretation, separate from physical state bool conflict_noticed_ = false, capture_failure_noticed_ = false; + bool persistent_ = false; }; } // namespace psx diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp index 80f2dca3..1548b0a1 100644 --- a/runtime/src/local_mouse_sdl.cpp +++ b/runtime/src/local_mouse_sdl.cpp @@ -94,6 +94,9 @@ extern "C" int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy) { (unsigned)sizeof(PSXModMousePolicy)); return 0; } +extern "C" int psx_mod_set_local_mouse_persistent_capture(int enabled) { + return (enabled == 0 || enabled == 1) && state().persistent_capture(enabled != 0); +} void psx_local_mouse_clear() { state().clear(); host_ = {}; window_ = nullptr; } bool psx_local_mouse_installed() { return state().installed(); } void psx_local_mouse_reset() { diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 00f1faf3..6f4af156 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -1950,6 +1950,7 @@ static PSXModSessionBaseline g_mod_owned_baseline; static void reset_mod_owned_presentation(void) { psx_mod_internal_resolution_reset(); psx_local_mouse_clear(); + psx_keybinds_clear_session_players(); PSXModSessionScalars live; live.video_vsync = g_video_vsync; live.frame_interpolation = g_frame_interpolation; @@ -7240,6 +7241,17 @@ static void pad_ext_source_release(void*, int s, PsxNetPad* pad) { } static void pad_ext_source_resolve(void*, int s, const PSXModControllerState* source, int guarded, PsxNetPad* pad) { + if (!mod_controller_source_connected((uint32_t)s)) { + /* Preserve recorded latch bytes/type even when the serial endpoint is + * absent. A real unplug can leave these at their last analog sample. */ + pad->buttons = (uint16_t)source->buttons; + pad->lx = (uint8_t)source->lx; pad->ly = (uint8_t)source->ly; + pad->rx = (uint8_t)source->rx; pad->ry = (uint8_t)source->ry; + pad->analog = source->analog ? 1 : 0; + pad->connected = 0; + sio_set_pad_connected(s, 0); + return; + } const bool dev_here = (dev_any_input_enabled() && s == 0); pad->lx = (uint8_t)source->lx; pad->ly = (uint8_t)source->ly; pad->rx = (uint8_t)source->rx; pad->ry = (uint8_t)source->ry; diff --git a/runtime/src/mod_controller_source.c b/runtime/src/mod_controller_source.c index f7754082..35361c1b 100644 --- a/runtime/src/mod_controller_source.c +++ b/runtime/src/mod_controller_source.c @@ -3,6 +3,7 @@ #include #include static PSXModControllerSource s_source[PSX_MAX_PLAYERS]; +static uint8_t s_connected[PSX_MAX_PLAYERS]; static uint8_t s_release[PSX_MAX_PLAYERS]; /* One sample per player per frame: the early and the low-latency re-sample in * main.cpp share the result so a stateful callback runs once. */ @@ -16,16 +17,27 @@ int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source if (s_source[player] && !source) s_release[player] = 1; else if (source) s_release[player] = 0; s_source[player] = source; + s_connected[player] = source ? 1 : 0; s_cache_valid[player] = 0; s_bad_count[player] = 0; return 1; } +int psx_mod_set_controller_source_connected(uint32_t player, int connected) { + if (player >= PSX_MAX_PLAYERS || !s_source[player] || + (connected != 0 && connected != 1)) return 0; + s_connected[player] = (uint8_t)connected; + return 1; +} +int mod_controller_source_connected(uint32_t player) { + return player < PSX_MAX_PLAYERS && s_source[player] && s_connected[player]; +} /* Detach every source but keep a neutral release pending for each port that * had one, so held buttons/sticks are cleared even on a port with no device. */ void mod_controller_source_reset(void) { for (uint32_t i = 0; i < PSX_MAX_PLAYERS; i++) { if (s_source[i]) s_release[i] = 1; s_source[i] = NULL; + s_connected[i] = 0; } memset(s_cache_valid, 0, sizeof s_cache_valid); memset(s_bad_count, 0, sizeof s_bad_count); diff --git a/runtime/src/psx_keybinds.c b/runtime/src/psx_keybinds.c index 74019573..82b1a2dd 100644 --- a/runtime/src/psx_keybinds.c +++ b/runtime/src/psx_keybinds.c @@ -81,6 +81,17 @@ static const PsxKeyBinds s_default_binds = PSXKB_DEFAULTS; * Primary OR alt asserts the input; both persist to keybinds.ini as * "primary, alt" on one line. */ static PsxKeyBinds s_alt_binds; +static PsxPlayerBinds s_session_binds[PSXKB_MAX_PLAYERS]; +static uint8_t s_session_active[PSXKB_MAX_PLAYERS]; +int psx_keybinds_set_session_player(int player, const PsxPlayerBinds *profile) { + if (player < 1 || player > PSXKB_MAX_PLAYERS || !profile) return 0; + s_session_binds[player - 1] = *profile; + s_session_active[player - 1] = 1; + return 1; +} +void psx_keybinds_clear_session_players(void) { + memset(s_session_active, 0, sizeof s_session_active); +} typedef struct { const char *name; /* ini key */ @@ -394,6 +405,10 @@ static int held(const uint8_t *keys, const PsxPlayerBinds *pb, int i) { const PsxPlayerBinds *alt = &s_alt_binds.player[slot]; SDL_Scancode p = *(const SDL_Scancode *)((const char *)pb + s_buttons[i].offset); SDL_Scancode a = *(const SDL_Scancode *)((const char *)alt + s_buttons[i].offset); + if (s_session_active[slot]) { + p = *(const SDL_Scancode *)((const char *)&s_session_binds[slot] + s_buttons[i].offset); + a = SDL_SCANCODE_UNKNOWN; + } return held_sc(keys, p, (int)slot) || held_sc(keys, a, (int)slot); } diff --git a/runtime/tests/test_keyboard_pad_chord.c b/runtime/tests/test_keyboard_pad_chord.c index c056c8cd..cd558783 100644 --- a/runtime/tests/test_keyboard_pad_chord.c +++ b/runtime/tests/test_keyboard_pad_chord.c @@ -41,6 +41,27 @@ int main(int argc, char **argv) { psx_keybinds_get_button(1, PSX_KB_SELECT)); expect_word("released", 0xFFFFu, psx_keybinds_pad_word(keys, 1)); + { + PsxPlayerBinds temporary = {0}; + temporary.up = temporary.ls_up = SDL_SCANCODE_W; + temporary.circle = SDL_SCANCODE_SPACE; + expect_word("install temporary profile", 1, + (uint16_t)psx_keybinds_set_session_player(1, &temporary)); + keys[SDL_SCANCODE_W] = 1; + expect_word("W moves without stock R1", 0xFFEFu, + psx_keybinds_pad_word(keys, 1)); + uint8_t axes[4] = {128,128,128,128}; + psx_keybinds_sticks(keys, 1, axes); + expect_word("W analog forward", 0, axes[1]); + expect_scancode("user R1 binding preserved", SDL_SCANCODE_W, + psx_keybinds_get_button(1, PSX_KB_R1)); + keys[SDL_SCANCODE_W] = 0; keys[SDL_SCANCODE_SPACE] = 1; + expect_word("Space jumps with Circle", 0xDFFFu, psx_keybinds_pad_word(keys, 1)); + psx_keybinds_clear_session_players(); + keys[SDL_SCANCODE_SPACE] = 0; keys[SDL_SCANCODE_W] = 1; + expect_word("clear restores stock W/R1", 0xF7FFu, psx_keybinds_pad_word(keys, 1)); + keys[SDL_SCANCODE_W] = 0; + } keys[SDL_SCANCODE_RSHIFT] = 1; expect_word("select held", 0xFFFEu, psx_keybinds_pad_word(keys, 1)); diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp index 04131566..e483a9b5 100644 --- a/runtime/tests/test_local_mouse_policy.cpp +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -31,6 +31,23 @@ int main() { uint8_t x=99,y=77; policy.update(0,host); policy.sample(0,x,y); CHECK(x==99 && y==77); PSXModMousePolicy callbacks{sizeof(PSXModMousePolicy),PSX_MOD_MOUSE_HOLD_RIGHT,eligible,event,sample}; CHECK(policy.install(&callbacks)); CHECK(!policy.install(&callbacks)); + CHECK(policy.persistent_capture(true)); + policy.update(5*Ms,host); + CHECK(policy.control(5*Ms,5*Ms,psx::MouseControl::Left,true,false)); + CHECK(policy.control(6*Ms,6*Ms,psx::MouseControl::Left,false,false)); + CHECK(policy.captured()); + CHECK(!policy.control(7*Ms,7*Ms,psx::MouseControl::Left,true,false)); + CHECK(!policy.control(8*Ms,8*Ms,psx::MouseControl::Left,false,false)); + CHECK(policy.captured()); + CHECK(!policy.control(9*Ms,9*Ms,psx::MouseControl::Left,true,false)); + CHECK(!policy.control(11*Ms,10*Ms,psx::MouseControl::Left,false,false)); + CHECK(!policy.captured()); + policy.update(11*Ms,host); + CHECK(policy.control(11*Ms,11*Ms,psx::MouseControl::Left,true,false)); + CHECK(policy.control(0,12*Ms,psx::MouseControl::Left,false,false)); + CHECK(policy.captured()); // lost up reconciles without turning capture off + CHECK(policy.control(12*Ms,12*Ms,psx::MouseControl::Escape,true,false)); + CHECK(policy.persistent_capture(false)); policy.update(10*Ms,host); CHECK(policy.control(10*Ms,10*Ms,psx::MouseControl::Left,true,false)); CHECK(policy.captured() && suppressed==psx::MouseControl::Left && !active); diff --git a/runtime/tests/test_mod_controller_source.c b/runtime/tests/test_mod_controller_source.c index b412290d..766c3618 100644 --- a/runtime/tests/test_mod_controller_source.c +++ b/runtime/tests/test_mod_controller_source.c @@ -25,6 +25,14 @@ int main(void) { CHECK(!psx_mod_set_controller_source(PSX_MAX_PLAYERS,source)); CHECK(!mod_controller_source_sample(0,&p,&rel) && rel==0); CHECK(psx_mod_set_controller_source(0,source)); + CHECK(mod_controller_source_connected(0)); + CHECK(!psx_mod_set_controller_source_connected(PSX_MAX_PLAYERS,0)); + CHECK(!psx_mod_set_controller_source_connected(1,0)); + CHECK(!psx_mod_set_controller_source_connected(0,2)); + CHECK(psx_mod_set_controller_source_connected(0,0)); + CHECK(!mod_controller_source_connected(0)); + CHECK(mod_controller_source_present(0)); /* absent source still owns port */ + CHECK(psx_mod_set_controller_source_connected(0,1)); CHECK(mod_controller_source_sample(0,&p,&rel) && p.lx==200 && p.ly==5 && !rel); mod_controller_source_begin_frame(); mode=1;CHECK(mod_controller_source_sample(0,&p,NULL) && p.buttons==0xffff && p.lx==128); @@ -45,6 +53,7 @@ int main(void) { CHECK(mod_controller_source_sample(0,&p,NULL) && calls==2); /* Detach: one neutral release frame, shared by both passes of that frame. */ CHECK(psx_mod_set_controller_source(0,0)); + CHECK(!mod_controller_source_connected(0)); CHECK(mod_controller_source_sample(0,&p,&rel) && rel==1 && p.buttons==0xffff && p.lx==128); CHECK(mod_controller_source_sample(0,&p,&rel) && rel==1); mod_controller_source_begin_frame();