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.
This commit is contained in:
Matthew Stanley
2026-10-17 09:19:31 -07:00
parent 8bee2b14f2
commit fedb5319d2
12 changed files with 118 additions and 0 deletions
+1
View File
@@ -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
+7
View File
@@ -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.
+4
View File
@@ -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
+15
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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() {
+12
View File
@@ -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;
+12
View File
@@ -3,6 +3,7 @@
#include <stdio.h>
#include <string.h>
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);
+15
View File
@@ -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);
}
+21
View File
@@ -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));
+17
View File
@@ -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);
@@ -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();