From b5566c7b252e4379a8ea5daa412b7c5ebd8f54bf Mon Sep 17 00:00:00 2001 From: Beman Date: Sun, 4 Oct 2026 23:57:16 +1000 Subject: [PATCH 1/6] Support optional held mouse input policies for local P1 (cherry picked from commit 00ee1f40b6e3f5ce58d99b23aedcb1b36ed95a97) --- docs/LOCAL_MOUSE_POLICY.md | 86 +++++ runtime/CMakeLists.txt | 13 +- runtime/include/mod_plugins.h | 38 +++ runtime/include/psx_keybinds.h | 17 + runtime/runtime.cmake | 2 + runtime/src/local_mouse_policy.cpp | 161 +++++++++ runtime/src/local_mouse_policy.h | 47 +++ runtime/src/local_mouse_sdl.cpp | 206 ++++++++++++ runtime/src/local_mouse_sdl.h | 11 + runtime/src/main.cpp | 306 +++++++++++------- runtime/src/psx_keybinds.c | 43 ++- runtime/tests/test_keyboard_pad_chord.c | 85 +++++ runtime/tests/test_local_mouse_policy.cpp | 182 +++++++++++ .../tests/test_mouse_binding_suppression.c | 89 +++++ runtime/tests/test_sio_card_protocol.c | 61 +++- 15 files changed, 1212 insertions(+), 135 deletions(-) create mode 100644 docs/LOCAL_MOUSE_POLICY.md create mode 100644 runtime/src/local_mouse_policy.cpp create mode 100644 runtime/src/local_mouse_policy.h create mode 100644 runtime/src/local_mouse_sdl.cpp create mode 100644 runtime/src/local_mouse_sdl.h create mode 100644 runtime/tests/test_local_mouse_policy.cpp create mode 100644 runtime/tests/test_mouse_binding_suppression.c diff --git a/docs/LOCAL_MOUSE_POLICY.md b/docs/LOCAL_MOUSE_POLICY.md new file mode 100644 index 00000000..b5d0cade --- /dev/null +++ b/docs/LOCAL_MOUSE_POLICY.md @@ -0,0 +1,86 @@ +# Optional local mouse policy + +The SDL-free `PSXModMousePolicy` interface provides one trusted local P1 policy. +Register it from an opt-in activation plugin. Registration copies the policy, +rejects incompatible struct sizes, missing callbacks and a second policy, +and returns success/failure. Existing const-input controller presentation is +unchanged. The generic runtime has no game addresses or gesture recognizer. + +`eligible(native_buttons)` supplies the game's current persistent-state +predicate. `event()` receives ordered reset/acquired/motion/hold edges with +nanosecond event times and fractional displacement. `sample(now, output)` is +a non-consuming query and may override only the right axes. The output is +initialized to neutral; its size, override flag and byte bounds are checked. + +SDL event delivery and relative capture remain on the existing SDL owner +thread. SDL3 timestamps share SDL_GetTicksNS's epoch. SDL2 millisecond event +timestamps are lifted into the nearest current 64-bit era; older SDL2 versions +use a private owner-thread tick extension. No SDL public types enter the game +interface. No secondary event queue or generalized arbitration is added. + +Only a fresh game-window LEFT mouse-button down can acquire capture, with focused, +visible, eligible, connected analog P1 local gameplay. The acquisition source +and captured Escape are suppressed through release before keyboard/mouse +binding folding. LEFT release immediately releases capture and resets the +policy, including a separately held direction; it is never timestamp-gated. +Both normal edges and emergency Escape are silent. Only genuine capture/config +failures produce bounded diagnostics. Suppression is per source/player, so a controller holding +the same PSX action remains intact. Primary and alternate P1 bindings are +inspected for hold-control conflicts without rewriting them. Ordinary bindings +remain live; a conflict disables only hold interpretation with a notice. +Physical down-state and accepted hold-state are separate. Only an accepted +hold receives HOLD_RELEASE; conflicting or acquisition-blocked releases leave +a free flick intact. A polled host-hotkey keyboard view also excludes consumed +P1 sources, so captured Escape cannot activate a configured Turbo=Escape. +The original SDL state, controller shortcuts and P2 folds remain available. + +Local P1 RX/RY are applied after all native folding and presentation, before +the existing PsxNetPad/SIO publication. Whole-vector native right-stick +activity releases mouse capture and retains the native bytes. Other seats, +buttons, left axes and report type/order are not rewritten. With no policy, +the existing post-pacer SDL_PumpEvents timing is retained. + +Focus loss, hide/minimize, host or game menus, reset, state load, rewind, +routing/removal, invalid data, capture failure and >250 ms host stalls release +capture and reset the policy. Stale, backwards and future motion cannot +extend a pulse. Releases remain immediate even with anomalous timestamps; +their watermark cannot admit old motion after a cleared held target. A +stall's backlog cannot acquire capture again. Eligibility recovery needs a +release/repress of LEFT and fresh motion. Reset retains physical LEFT down-state, +so holding it across an interruption cannot recapture. Debug input injection, headless, netplay, replay/resimulation and +render-only passes inhibit this policy. + +`psx_local_mouse_clear()` unregisters the policy during existing mod/session +reset. Source suppression already owed through release survives unregister, +so disabling a mod cannot leak a held Escape into a native binding. There is +one P1 policy; physical device locking and P2 mouse controls are out of scope. + +## Validation status and targets + +The preceding click-toggle candidate passed a full SDL3 Release link, focused +tests, both SDL backend input checks and a private cold-start smoke check. +The LEFT-held activation revision passes the focused policy/reducer integration, +both SDL backend input checks, native SIO checks and a full incremental SDL3 +Release link with five registered game tests. Its new private candidate passes +static import/profile checks; physical startup/interruption validation remains +pending. `local_mouse_policy_test` supplies a deterministic fake backend and +covers capture, neutral acquisition, releases, host/guest gates, bad times, +stalls, conflicting hold, failure fallback and registration validation. +`mouse_binding_suppression_test` folds the real keybind code with a controlled +mouse poll, covering primary/alternate/button/axis consumption through release +and independent controller/P2 actions. `keyboard_pad_chord_test` includes +captured-Escape suppression and fresh native re-presses. +It also checks repeated Turbo=Escape polls and the independent P2 report. + +Run these and the existing controller presentation, stick mapping, netplay +pad codec and SIO tests with both supported SDL backends. Compile the actual +adapter/main path with debug tools enabled and disabled. Observe relative +capture/focus/menu/removal and normal report delivery in a private game copy; +pure policy tests do not exercise the OS or game dispatcher. + +The reviewed implementation base is release framework commit +065888f50f9131839bcbbc8814debf58b4624b16, also the canonical +`release/ape-v0.5.0-hle-support` branch. Current upstream master has differing +existing mod APIs and needs separate compatibility validation. Final game-pin +reachability/CI and physical interruption checks are pending; this is not a +public-quality or merge-readiness claim. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 4f350a94..82da891a 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -27,6 +27,10 @@ if(BUILD_TESTING) ../recompiler/src/mips_decoder.cpp) target_include_directories(disasm_test PRIVATE include ../recompiler/include) add_test(NAME disasm_test COMMAND disasm_test) + add_executable(local_mouse_policy_test + tests/test_local_mouse_policy.cpp src/local_mouse_policy.cpp) + target_include_directories(local_mouse_policy_test PRIVATE include src) + add_test(NAME local_mouse_policy_test COMMAND local_mouse_policy_test) # Netplay: which BIOS a match boots, and its wire helpers. add_executable(netplay_bios_settle_test tests/test_netplay_bios_settle.c @@ -364,7 +368,7 @@ if(BUILD_TESTING) add_executable(keyboard_pad_chord_test tests/test_keyboard_pad_chord.c - src/psx_keybinds.c) + src/psx_keybinds.c src/host_keymap.c) target_include_directories(keyboard_pad_chord_test PRIVATE include ${PSX_SDL_INCLUDE_DIRS}) target_compile_definitions(keyboard_pad_chord_test PRIVATE @@ -373,6 +377,13 @@ if(BUILD_TESTING) target_link_libraries(keyboard_pad_chord_test PRIVATE ${PSX_SDL_LIBRARIES}) add_test(NAME keyboard_pad_chord_test COMMAND keyboard_pad_chord_test) + add_executable(mouse_binding_suppression_test tests/test_mouse_binding_suppression.c) + target_include_directories(mouse_binding_suppression_test PRIVATE include ${PSX_SDL_INCLUDE_DIRS}) + target_compile_definitions(mouse_binding_suppression_test PRIVATE + SDL_MAIN_HANDLED $<$:PSX_SDL3=1>) + target_link_libraries(mouse_binding_suppression_test PRIVATE ${PSX_SDL_LIBRARIES}) + add_test(NAME mouse_binding_suppression_test COMMAND mouse_binding_suppression_test) + # Generic external replacement-pack core. The renderer integration is a # separate consumer; this target pins pack discovery, deterministic keying, # VRAM residency, portable tracking state, and bounded asynchronous decode. diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index 2d6d758e..e121cf74 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -774,6 +774,44 @@ typedef struct PSXModControllerState { } PSXModControllerState; typedef int (*PSXModControllerSource)(PSXModControllerState *state); int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source); +/* Optional local P1 mouse policy. The runtime delivers ordered events on the + * SDL owner thread, owns relative capture and folds the resulting right-stick + * bytes after native input/presentation, before normal SIO delivery. No SDL + * type, guest address, gesture, or game setting belongs in this interface. + * This first version is inert during netplay, injected input and replay. + * Exactly one trusted activation plugin may register per session. */ +enum { + PSX_MOD_MOUSE_HOLD_NONE = 0, + PSX_MOD_MOUSE_HOLD_RIGHT = 1, /* Mouse3 in keybinds.ini */ + PSX_MOD_MOUSE_HOLD_LEFT_ALT = 2, + PSX_MOD_MOUSE_RESET = 0, + PSX_MOD_MOUSE_ACQUIRED = 1, + PSX_MOD_MOUSE_MOTION = 2, + PSX_MOD_MOUSE_HOLD_PRESS = 3, + PSX_MOD_MOUSE_HOLD_RELEASE = 4 +}; +typedef struct PSXModMouseEvent { + uint32_t struct_size; + uint32_t type; + uint64_t time_ns; + double dx, dy; +} PSXModMouseEvent; +typedef struct PSXModMouseOutput { + uint32_t struct_size; + uint32_t override_right; + uint32_t rx, ry; +} PSXModMouseOutput; +typedef struct PSXModMousePolicy { + uint32_t struct_size; + uint32_t hold_control; + /* Re-read persistent guest context, including pause/menus, on every event + * and local sample. Must not infer it from a host Start toggle. */ + int (*eligible)(uint32_t native_buttons); + void (*event)(const PSXModMouseEvent* event); + /* A query: must not consume motion, change the event anchor or deadlines. */ + void (*sample)(uint64_t now_ns, PSXModMouseOutput* output); +} PSXModMousePolicy; +int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy); /* * 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 9d9072bd..be227b40 100644 --- a/runtime/include/psx_keybinds.h +++ b/runtime/include/psx_keybinds.h @@ -31,6 +31,13 @@ extern "C" { #define PSXKB_MAX_PLAYERS 5 +/* Shared pseudo-scancodes for INI Mouse1..Mouse5. Keep them outside SDL's + * keyboard array and check them before indexing it. Mouse3 is right. */ +#define PSXKB_MOUSE_SC_BASE 512 +#define PSXKB_MOUSE_SC(btn) ((SDL_Scancode)(PSXKB_MOUSE_SC_BASE + (btn))) +#define PSXKB_IS_MOUSE_SC(sc) \ + ((int)(sc) > PSXKB_MOUSE_SC_BASE && (int)(sc) <= PSXKB_MOUSE_SC_BASE + 5) + /* Button indices — stable order, matches the order keybinds.ini writes and the * order the launcher's rebind chips are laid out. Keep in sync with kButtons[] * in psx_keybinds.c. */ @@ -67,6 +74,16 @@ void psx_keybinds_init(const char *exe_path); /* Read-only view of the current bindings. */ const PsxKeyBinds *psx_keybinds_get(void); +/* Runtime-owned host controls consumed by optional local input capture. + * Suppression is per source/player, before PSX folding, and lasts through + * release. It never clears a merged PSX button held by another source. */ +void psx_keybinds_suppress_control(int player, SDL_Scancode sc); +void psx_keybinds_release_control(int player, SDL_Scancode sc); +int psx_keybinds_control_suppressed(int player, SDL_Scancode sc); +/* Host hotkey polling gets a keyboard view excluding consumed P1 sources. + * The original SDL state and other players' PSX folds remain unchanged. */ +void psx_keybinds_host_keys(const uint8_t *keys, uint8_t out[SDL_NUM_SCANCODES]); + /* ── Runtime read helpers (keyboard -> PSX pad), player is 1..PSXKB_MAX_PLAYERS */ /* Build the 16-bit ACTIVE-LOW PSX button word for `player` from the SDL diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 8d69a623..6fca3a85 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -341,6 +341,8 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/psx_window_icon.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_sdl_audio.cpp ${PSXRECOMP_ROOT}/runtime/src/psx_stick.c + ${PSXRECOMP_ROOT}/runtime/src/local_mouse_policy.cpp + ${PSXRECOMP_ROOT}/runtime/src/local_mouse_sdl.cpp ${PSXRECOMP_ROOT}/runtime/src/memory.c ${PSXRECOMP_ROOT}/runtime/src/psx_ram_geometry.c ${PSXRECOMP_ROOT}/runtime/src/kernel_patch_ranges.c diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp new file mode 100644 index 00000000..0c23535e --- /dev/null +++ b/runtime/src/local_mouse_policy.cpp @@ -0,0 +1,161 @@ +#include "local_mouse_policy.h" +#include +#include + +namespace psx { +namespace { constexpr uint64_t StallNs = 250000000; } + +bool LocalMousePolicy::install(const PSXModMousePolicy* policy) { + if (installed() || !policy || policy->struct_size != sizeof(*policy) || + policy->hold_control > PSX_MOD_MOUSE_HOLD_LEFT_ALT || + !policy->eligible || !policy->event || !policy->sample) return false; + policy_ = *policy; + reset(); + return true; +} +void LocalMousePolicy::clear() { + reset(); + policy_ = {}; + host_ = {}; + hold_down_ = hold_blocked_ = hold_active_ = false; + activation_down_ = activation_owned_ = false; + event_valid_ = false; + clock_valid_ = false; + clock_ = event_clock_ = release_clock_ = 0; + conflict_noticed_ = capture_failure_noticed_ = false; +} +void LocalMousePolicy::dispatch(uint32_t type, uint64_t t, double dx, double dy) { + if (!installed()) return; + const PSXModMouseEvent event{sizeof(PSXModMouseEvent), type, t, dx, dy}; + policy_.event(&event); +} +void LocalMousePolicy::reset() { + if (captured_) backend_.capture(backend_.context, false); + captured_ = false; + release_clock_ = clock_; + event_valid_ = false; + hold_blocked_ = hold_down_; + hold_active_ = false; + dispatch(PSX_MOD_MOUSE_RESET); // releases remain immediate even with bad time +} +MouseControl LocalMousePolicy::hold_control() const { + if (policy_.hold_control == PSX_MOD_MOUSE_HOLD_RIGHT) return MouseControl::Right; + if (policy_.hold_control == PSX_MOD_MOUSE_HOLD_LEFT_ALT) return MouseControl::LeftAlt; + return MouseControl::None; +} +bool LocalMousePolicy::allowed() const { + return installed() && host_.live && host_.focused && host_.connected && + host_.analog && !host_.native_right && !host_.start && policy_.eligible(host_.buttons); +} +void LocalMousePolicy::update(uint64_t now, LocalMouseHost host) { + const bool stalled = clock_valid_ && (now < clock_ || now - clock_ > StallNs); + clock_ = now; + clock_valid_ = true; + host_ = host; + if (stalled || !allowed()) reset(); + if (host.hold_conflict && !conflict_noticed_) { + backend_.notice(backend_.context, "Mouse hold disabled: control is bound to P1"); + conflict_noticed_ = true; + } + if (!host.hold_conflict) conflict_noticed_ = false; + if (captured_ && host.hold_conflict && !hold_blocked_) { + hold_blocked_ = true; + if (hold_active_) { + hold_active_ = false; + dispatch(PSX_MOD_MOUSE_HOLD_RELEASE); + event_clock_ = std::max(event_clock_, now); + event_valid_ = true; + } + } +} +bool LocalMousePolicy::valid_time(uint64_t t, uint64_t now) { + if (t > now || t < release_clock_ || now - t > StallNs || + (event_valid_ && t < event_clock_)) { + reset(); + return false; + } + event_clock_ = t; + event_valid_ = true; + return true; +} +bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, + bool down, bool repeat) { + if (control == MouseControl::Left) { + const bool fresh = down && !activation_down_ && !repeat; + activation_down_ = down; + if (!down) { + // Release is unconditional: a future/backwards event time cannot + // leave capture or the reducer's separate direction hold active. + const bool owned = activation_owned_; + activation_owned_ = false; + if (captured_ || owned) reset(); + return owned; + } + // reset() retains physical down-state. A held button after a menu, + // focus loss, native takeover or bad input is never a new activation. + if (!fresh || captured_ || !allowed() || !valid_time(t, now)) return false; + backend_.suppress(backend_.context, control); + activation_owned_ = true; + if (!backend_.capture(backend_.context, true)) { + (void)backend_.capture(backend_.context, false); + reset(); + if (!capture_failure_noticed_) { + backend_.notice(backend_.context, "Mouse capture failed; native controls remain active"); + capture_failure_noticed_ = true; + } + return true; + } + captured_ = true; + hold_blocked_ = hold_down_; + dispatch(PSX_MOD_MOUSE_ACQUIRED, t); + return true; + } + // Hold-release is never timestamp-gated. Track while released too so a + // held control across acquisition/focus loss cannot become a new press. + if (control != MouseControl::None && control == hold_control()) { + const bool fresh = down && !hold_down_ && !repeat; + hold_down_ = down; + if (!down) { + hold_blocked_ = false; + if (captured_ && hold_active_) { + hold_active_ = false; + dispatch(PSX_MOD_MOUSE_HOLD_RELEASE); + // Release is immediate, but cannot move the event watermark + // backwards and let older motion refill the cleared target. + const uint64_t release_time = t <= now && now - t <= StallNs ? t : clock_; + event_clock_ = std::max(event_clock_, release_time); + event_valid_ = true; + } + } else if (captured_ && fresh && !host_.hold_conflict && !hold_blocked_) { + if (valid_time(t, now)) { + hold_active_ = true; + dispatch(PSX_MOD_MOUSE_HOLD_PRESS, t); + } + } + } + if (down && control == MouseControl::Escape && captured_) { + backend_.suppress(backend_.context, control); + reset(); + return true; + } + return false; +} +void LocalMousePolicy::motion(uint64_t t, uint64_t now, double dx, double dy) { + if (!captured_) return; + if (!allowed() || !std::isfinite(dx) || !std::isfinite(dy) || + std::abs(dx) > 1e9 || std::abs(dy) > 1e9) { reset(); return; } + if (!valid_time(t, now) || (dx == 0.0 && dy == 0.0)) return; + dispatch(PSX_MOD_MOUSE_MOTION, t, dx, dy); +} +void LocalMousePolicy::sample(uint64_t now, uint8_t& rx, uint8_t& ry) { + if (!captured_) return; + if (!allowed()) { reset(); return; } + PSXModMouseOutput out{sizeof(PSXModMouseOutput), 0, 128, 128}; + policy_.sample(now, &out); + if (out.struct_size != sizeof(out) || out.override_right > 1 || out.rx > 255 || out.ry > 255) { + reset(); + return; + } + if (out.override_right) { rx = uint8_t(out.rx); ry = uint8_t(out.ry); } +} +} // namespace psx diff --git a/runtime/src/local_mouse_policy.h b/runtime/src/local_mouse_policy.h new file mode 100644 index 00000000..c8eec2e8 --- /dev/null +++ b/runtime/src/local_mouse_policy.h @@ -0,0 +1,47 @@ +#pragma once +#include "mod_plugins.h" + +namespace psx { + +enum class MouseControl { None, Left = 1, Middle, Right, X1, X2, Escape, LeftAlt }; +struct LocalMouseHost { + uint32_t buttons = 0xFFFF; + bool live = false, focused = false, connected = false, analog = false; + bool native_right = false, start = false, hold_conflict = false; +}; +// Private backend seam also used by deterministic tests. No second event queue: +// the existing owner-thread event drain dispatches synchronously in order. +struct LocalMouseBackend { + void* context; + bool (*capture)(void*, bool); + void (*suppress)(void*, MouseControl); + void (*notice)(void*, const char*); +}; +class LocalMousePolicy { +public: + explicit LocalMousePolicy(LocalMouseBackend backend) : backend_(backend) {} + bool install(const PSXModMousePolicy* policy); + void clear(); + void reset(); + void update(uint64_t now, LocalMouseHost host); + bool control(uint64_t t, uint64_t now, MouseControl control, bool down, bool repeat); + void motion(uint64_t t, uint64_t now, double dx, double dy); + void sample(uint64_t now, uint8_t& rx, uint8_t& ry); + bool captured() const { return captured_; } + bool installed() const { return policy_.event != nullptr; } + MouseControl hold_control() const; +private: + bool allowed() const; + bool valid_time(uint64_t t, uint64_t now); + void dispatch(uint32_t type, uint64_t t = 0, double dx = 0, double dy = 0); + LocalMouseBackend backend_; + PSXModMousePolicy policy_{}; + LocalMouseHost host_; + uint64_t clock_ = 0, event_clock_ = 0, release_clock_ = 0; + bool clock_valid_ = false, event_valid_ = false; + bool captured_ = false, hold_down_ = false, hold_blocked_ = false; + 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; +}; +} // namespace psx diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp new file mode 100644 index 00000000..6014ad7e --- /dev/null +++ b/runtime/src/local_mouse_sdl.cpp @@ -0,0 +1,206 @@ +#include "local_mouse_sdl.h" +#include "local_mouse_policy.h" +#include "psx_keybinds.h" +#include "host_osd.h" + +namespace { +SDL_Window* window_ = nullptr; +psx::LocalMouseHost host_; + +uint64_t now_ns() { +#if defined(PSX_SDL3) + return SDL_GetTicksNS(); +#else +#if SDL_VERSION_ATLEAST(2, 0, 18) + return SDL_GetTicks64() * 1000000; +#else + // Preserve the framework's older SDL2 build path without requiring a + // new dependency. This owner-thread clock extends the 32-bit tick era. + static uint32_t previous = 0; + static uint64_t era = 0; + const uint32_t ticks = SDL_GetTicks(); + if (ticks < previous) era += uint64_t(1) << 32; + previous = ticks; + return (era + ticks) * 1000000; +#endif +#endif +} +uint64_t event_ns(const SDL_Event& event, uint64_t now) { +#if defined(PSX_SDL3) + return event.common.timestamp; +#else + // Lift wrapping SDL2 millisecond timestamps into the nearest current era. + const uint64_t ms = now / 1000000; + const int32_t delta = static_cast(event.common.timestamp - uint32_t(ms)); + if (delta < 0 && uint64_t(-int64_t(delta)) > ms) return UINT64_MAX; + return uint64_t(int64_t(ms) + delta) * 1000000; +#endif +} +SDL_Scancode scancode(psx::MouseControl control) { + if (control == psx::MouseControl::Escape) return SDL_SCANCODE_ESCAPE; + if (control == psx::MouseControl::LeftAlt) return SDL_SCANCODE_LALT; + if (control >= psx::MouseControl::Left && control <= psx::MouseControl::X2) + return PSXKB_MOUSE_SC(int(control)); + return SDL_SCANCODE_UNKNOWN; +} +bool capture(void*, bool on) { + if (!window_) return !on; +#if defined(PSX_SDL3) + return SDL_SetWindowRelativeMouseMode(window_, on); +#else + return SDL_SetRelativeMouseMode(on ? SDL_TRUE : SDL_FALSE) == 0; +#endif +} +void suppress(void*, psx::MouseControl control) { + psx_keybinds_suppress_control(1, scancode(control)); +} +void notice(void*, const char* message) { host_osd_push(message, 2000); } +psx::LocalMousePolicy& state() { + static psx::LocalMousePolicy policy({nullptr, capture, suppress, notice}); + return policy; +} +bool focus() { + if (!window_ || SDL_GetKeyboardFocus() != window_) return false; + const auto flags = SDL_GetWindowFlags(window_); +#if defined(PSX_SDL3) + return (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) == 0; +#else + return (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) == 0; +#endif +} +bool conflict() { + const SDL_Scancode sc = scancode(state().hold_control()); + if (sc == SDL_SCANCODE_UNKNOWN) return false; + for (int i = 0; i < PSX_KB_COUNT; ++i) + if (psx_keybinds_get_button(1, i) == sc || psx_keybinds_get_button_alt(1, i) == sc) + return true; + return false; +} +void update(uint64_t now) { + host_.focused = focus(); + host_.hold_conflict = conflict(); + state().update(now, host_); +} +bool control(uint64_t t, uint64_t now, psx::MouseControl control, bool down, bool repeat) { + const SDL_Scancode sc = scancode(control); + const bool swallowed = psx_keybinds_control_suppressed(1, sc) != 0; + if (!down) psx_keybinds_release_control(1, sc); + return state().control(t, now, control, down, repeat) || swallowed; +} +} // namespace + +extern "C" int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy) { + return state().install(policy) ? 1 : 0; +} +void psx_local_mouse_clear() { state().clear(); host_ = {}; window_ = nullptr; } +bool psx_local_mouse_installed() { return state().installed(); } +void psx_local_mouse_reset() { + state().reset(); + host_.connected = host_.analog = false; +} +void psx_local_mouse_begin(SDL_Window* window, bool live) { + if (window_ && window_ != window) state().reset(); + window_ = window; + host_.live = live; + update(now_ns()); + // Windows global state survives focus clearing SDL's cached window state. + // Other backends may not support global mouse polling (e.g. Wayland), so + // there catch a lost release only while focused and captured. Neither path + // manufactures a down or unblocks a held activation across focus loss. +#if defined(_WIN32) + const bool left_released = !(SDL_GetGlobalMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)); +#else + const bool left_released = host_.focused && state().captured() && + !(SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)); +#endif + if (left_released) + state().control(0, now_ns(), psx::MouseControl::Left, false, false); + // Catch a lost hold release, including one made outside the game window. + const auto hold = state().hold_control(); + bool down = false; + if (hold == psx::MouseControl::Right) + down = (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_RIGHT)) != 0; + else if (hold == psx::MouseControl::LeftAlt) + down = SDL_GetKeyboardState(nullptr)[SDL_SCANCODE_LALT] != 0; + if (hold != psx::MouseControl::None && !down) + state().control(0, now_ns(), hold, false, false); +} +bool psx_local_mouse_event(const SDL_Event& ev) { + if (!state().installed()) { + // Ownership of an already consumed source lasts through release even + // when its plugin has just been disabled/unregistered. + SDL_Scancode sc = SDL_SCANCODE_UNKNOWN; + bool down = false; + if (ev.type == SDL_MOUSEBUTTONDOWN || ev.type == SDL_MOUSEBUTTONUP) { + if (ev.button.button >= 1 && ev.button.button <= 5) + sc = PSXKB_MOUSE_SC(ev.button.button); + down = ev.type == SDL_MOUSEBUTTONDOWN; + } else if (ev.type == SDL_KEYDOWN || ev.type == SDL_KEYUP) { +#if defined(PSX_SDL3) + sc = ev.key.scancode; +#else + sc = ev.key.keysym.scancode; +#endif + down = ev.type == SDL_KEYDOWN; + } + const bool swallowed = psx_keybinds_control_suppressed(1, sc) != 0; + if (!down) psx_keybinds_release_control(1, sc); + return swallowed; + } + const uint64_t now = now_ns(), t = event_ns(ev, now); + update(now); +#if defined(PSX_SDL3) + if (ev.type == SDL_EVENT_WINDOW_FOCUS_LOST || ev.type == SDL_EVENT_WINDOW_HIDDEN || + ev.type == SDL_EVENT_WINDOW_MINIMIZED) { + if (window_ && ev.window.windowID == SDL_GetWindowID(window_)) state().reset(); + } else if (ev.type == SDL_EVENT_MOUSE_REMOVED) { + state().reset(); + } +#else + if (window_ && ev.type == SDL_WINDOWEVENT && ev.window.windowID == SDL_GetWindowID(window_) && + (ev.window.event == SDL_WINDOWEVENT_FOCUS_LOST || ev.window.event == SDL_WINDOWEVENT_HIDDEN || + ev.window.event == SDL_WINDOWEVENT_MINIMIZED)) state().reset(); +#endif + if (ev.type == SDL_MOUSEMOTION) { + if (!window_ || ev.motion.windowID != SDL_GetWindowID(window_)) return false; + if (ev.motion.which == SDL_TOUCH_MOUSEID) return false; +#if defined(PSX_SDL3) + if (ev.motion.which == SDL_PEN_MOUSEID) return false; +#endif + state().motion(t, now, ev.motion.xrel, ev.motion.yrel); + } else if (ev.type == SDL_MOUSEBUTTONDOWN || ev.type == SDL_MOUSEBUTTONUP) { + if (ev.button.which == SDL_TOUCH_MOUSEID) return false; +#if defined(PSX_SDL3) + if (ev.button.which == SDL_PEN_MOUSEID) return false; +#endif + // An owned activation release stays immediate even outside the game + // window. Only a fresh down in this window may acquire capture. + if (!window_ || (ev.button.windowID != SDL_GetWindowID(window_) && + ev.type != SDL_MOUSEBUTTONUP)) return false; + if (ev.button.button >= 1 && ev.button.button <= 5) + return control(t, now, psx::MouseControl(ev.button.button), ev.type == SDL_MOUSEBUTTONDOWN, false); + } else if (ev.type == SDL_KEYDOWN || ev.type == SDL_KEYUP) { +#if defined(PSX_SDL3) + if (!window_ || ev.key.windowID != SDL_GetWindowID(window_)) return false; + const auto sc = ev.key.scancode; +#else + if (!window_ || ev.key.windowID != SDL_GetWindowID(window_)) return false; + const auto sc = ev.key.keysym.scancode; +#endif + const auto c = sc == SDL_SCANCODE_ESCAPE ? psx::MouseControl::Escape : + sc == SDL_SCANCODE_LALT ? psx::MouseControl::LeftAlt : psx::MouseControl::None; + if (c != psx::MouseControl::None) + return control(t, now, c, ev.type == SDL_KEYDOWN, ev.key.repeat != 0); + } + return false; +} +void psx_local_mouse_pad(bool connected, bool analog, uint16_t buttons, uint8_t& rx, uint8_t& ry) { + host_.connected = connected; + host_.analog = analog; + host_.native_right = rx != 128 || ry != 128; + host_.start = (buttons & 0x0008) == 0; + host_.buttons = buttons; + const uint64_t now = now_ns(); + update(now); + state().sample(now, rx, ry); +} diff --git a/runtime/src/local_mouse_sdl.h b/runtime/src/local_mouse_sdl.h new file mode 100644 index 00000000..47e91b31 --- /dev/null +++ b/runtime/src/local_mouse_sdl.h @@ -0,0 +1,11 @@ +#pragma once +#include "psx_sdl.h" + +/* All calls, including reset/release, belong to the SDL owner thread. */ +void psx_local_mouse_clear(); +bool psx_local_mouse_installed(); +void psx_local_mouse_reset(); +void psx_local_mouse_begin(SDL_Window* window, bool live); +bool psx_local_mouse_event(const SDL_Event& event); +void psx_local_mouse_pad(bool connected, bool analog, uint16_t buttons, + uint8_t& rx, uint8_t& ry); diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 4e0635a3..6cc27483 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -32,6 +32,7 @@ #include "psx_rewind.h" #include "psx_savestate_menu.h" #include "host_osd.h" +#include "local_mouse_sdl.h" #include "host_keymap.h" #include "png_write.h" /* png_write_rgb — present_shot readback */ #include "overlay_capture.h" @@ -1054,6 +1055,7 @@ extern "C" void psx_frontend_on_savestate_refused(int is_load, int slot, extern "C" void psx_frontend_on_savestate_loaded(void) { psx_projection_reset_session(); + psx_local_mouse_reset(); mod_runtime_on_savestate_loaded(); #ifndef PSX_NO_DEBUG_TOOLS debug_server_note_savestate_loaded(); @@ -1557,6 +1559,7 @@ extern "C" int psx_mod_set_adaptive_display_aspect( static PSXModSessionBaseline g_mod_owned_baseline; static void reset_mod_owned_presentation(void) { + psx_local_mouse_clear(); PSXModSessionScalars live; live.video_vsync = g_video_vsync; live.frame_interpolation = g_frame_interpolation; @@ -3710,6 +3713,7 @@ static const char* netplay_start_failure(int nrc, const PsxNetplayConfig& cfg) { } static void shutdown_runtime(void) { + psx_local_mouse_clear(); /* (sljit removed 2026-07-15: overlay_compile_worker_stop joined the * off-thread JIT worker here; the worker no longer exists.) */ psx_netplay_shutdown(); @@ -5083,6 +5087,7 @@ static int assert_sio_pad_profile(int s, bool dev_here) { * While delay-sync netplay is active, SIO connection/type are owned by * psx_netplay (session slots stay plugged); only refresh host SDL handles. */ static void refresh_player_devices(void) { + psx_local_mouse_reset(); const int netplay = psx_netplay_active(); for (int s = 0; s < PSX_MAX_PLAYERS; s++) { PlayerInput& p = g_players[s]; @@ -5583,6 +5588,7 @@ static void dev_any_controller_sticks(uint8_t st[4]) { } static void savestate_input_guard_arm(void) { + psx_local_mouse_reset(); uint32_t now = (uint32_t)SDL_GetTicks(); g_savestate_input_guard_min_until = now + 90u; g_savestate_input_guard_max_until = now + 700u; @@ -5708,7 +5714,10 @@ static int capture_pad_slot(int s, PsxNetPad* out) { /* Opt-in dev merge: P1 is driven by the keyboard AND every connected * controller (PSX_DEV_INPUT=1). Default is strict per-slot routing. */ const bool dev_here = (dev_any_input_enabled() && s == 0); - if (p.kind == 0 && !dev_here) return 0; /* no device in this port */ + if (p.kind == 0 && !dev_here) { + if (s == 0) psx_local_mouse_reset(); + return 0; /* no device in this port */ + } /* Resolve the pad type this frame FIRST — the effective analog/digital * state gates how the left stick is read for BOTH the button word and the @@ -5734,6 +5743,7 @@ static int capture_pad_slot(int s, PsxNetPad* out) { const int eff_analog = effective_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; if (savestate_input_guard_active()) { + if (s == 0) psx_local_mouse_reset(); out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; out->analog = eff_analog ? 1u : 0u; @@ -5780,6 +5790,7 @@ static int capture_pad_slot(int s, PsxNetPad* out) { st[0] = st[1] = st[2] = st[3] = 0x80; } + if (s == 0) psx_local_mouse_pad(true, eff_analog != 0, btn, st[2], st[3]); out->buttons = btn; out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; out->analog = eff_analog ? 1u : 0u; @@ -6306,6 +6317,7 @@ static void sample_pad_into_sio(int override) { override = (int)mash; } if (override >= 0) { + psx_local_mouse_reset(); apply_input_override_to_sio(override); return; } @@ -6844,6 +6856,7 @@ static void savestate_menu_close(void) { } static void savestate_menu_toggle(SDL_Keycode opened_by_key) { + psx_local_mouse_reset(); if (psx_rewind_is_open()) return; if (savestate_menu_open) { @@ -7007,8 +7020,10 @@ static int rewind_toggle_buttons_down(void) { static void rewind_poll_toggle_buttons(void) { static int was_down; int down = rewind_toggle_buttons_down(); - if (down && !was_down && !psx_rewind_is_open()) + if (down && !was_down && !psx_rewind_is_open()) { + psx_local_mouse_reset(); psx_rewind_toggle(); + } was_down = down; } @@ -7113,10 +7128,13 @@ static void rewind_pause_present(void) { /* Freeze guest in vblank present while the rewind filmstrip is open. */ static void rewind_host_pause_loop(void) { + psx_local_mouse_begin(sdl_window, false); + psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); while (psx_rewind_is_open()) { SDL_Event ev; while (SDL_PollEvent(&ev)) { + (void)psx_local_mouse_event(ev); if (ev.type == SDL_QUIT) { psx_crash_trace_set_exit_origin("sdl_window_close"); shutdown_runtime(); @@ -7157,10 +7175,13 @@ static void rewind_host_pause_loop(void) { /* Freeze guest in vblank present while the save-state slot menu is open. */ static void savestate_menu_host_pause_loop(void) { + psx_local_mouse_begin(sdl_window, false); + psx_local_mouse_reset(); freeze_heartbeat_set_paused(1); while (savestate_menu_open) { SDL_Event ev; while (SDL_PollEvent(&ev)) { + (void)psx_local_mouse_event(ev); if (ev.type == SDL_QUIT) { psx_crash_trace_set_exit_origin("sdl_window_close"); shutdown_runtime(); @@ -7253,6 +7274,148 @@ static void headless_present_image_ring_capture(void) { present_image_ring_push_argb((uint32_t)s_frame_count, buf.data(), w, h, w); } #endif +// Shared by early and post-pacer sampling: do not PumpEvents without draining +// ordered motion/control events and the existing hotkeys before folding binds. +static bool drain_host_events() { + SDL_Event ev; + while (SDL_PollEvent(&ev)) { + if (psx_local_mouse_event(ev)) continue; + if (ev.type == SDL_QUIT) { + if (psx_netplay_active()) { + netplay_soft_exit("sdl_window_close"); + return false; + } + psx_crash_trace_set_exit_origin("sdl_window_close"); + shutdown_runtime(); + std::exit(0); + } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { + refresh_player_devices(); + } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { + bool ours = false; + for (int s = 0; s < PSX_MAX_PLAYERS; s++) { +#if defined(PSX_SDL3) + if (ev.gdevice.which == g_players[s].instance) { ours = true; break; } +#else + if (ev.cdevice.which == g_players[s].instance) { ours = true; break; } +#endif + } + if (ours) { + close_controller(); + refresh_player_devices(); + } + } else if (ev.type == SDL_KEYDOWN) { +#if defined(PSX_SDL3) + const SDL_Keymod mod = ev.key.mod; + const SDL_Keycode key = ev.key.key; + const SDL_Scancode scancode = ev.key.scancode; + const int key_repeat = ev.key.repeat ? 1 : 0; +#else + const Uint16 mod = ev.key.keysym.mod; + const SDL_Keycode key = ev.key.keysym.sym; + const SDL_Scancode scancode = ev.key.keysym.scancode; + const int key_repeat = ev.key.repeat ? 1 : 0; +#endif + if (key == SDLK_ESCAPE && psx_netplay_active()) { + netplay_soft_exit("netplay_escape"); + return false; + } + if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_REWIND, (int)key, + (int)scancode, (int)mod)) { + psx_local_mouse_reset(); + psx_rewind_toggle(); + } + else if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_SAVE_STATE_MENU, + (int)key, (int)scancode, + (int)mod)) { + psx_local_mouse_reset(); + savestate_menu_toggle(key); + } + else if (key == SDLK_c && (mod & KMOD_CTRL)) { + std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); + debug_force_cd_reinsert(); + host_osd_push("CD reinsert", 1500); + } + else if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_TURBO_TOGGLE, + (int)key, (int)scancode, + (int)mod)) { + fast_forward_toggle_flip(); + } + else if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_CAPTURE_MARK, + (int)key, (int)scancode, + (int)mod)) { +#ifndef PSX_NO_DEBUG_TOOLS + debug_server_capture_mark(); + host_osd_push("Capture marked (debug rings frozen)", 2000); +#else + host_osd_push("Capture mark needs a debug-tools build", 2000); +#endif + } + else if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_DISPLAY_PERF, + (int)key, (int)scancode, + (int)mod)) { + fps_telemetry_toggle(); + } + else if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_SCANLINES, + (int)key, (int)scancode, + (int)mod)) { + psx_video_set_scanlines(g_video_scanlines ? 0 : 1, + g_video_scanline_strength); + char msg[48]; + std::snprintf(msg, sizeof(msg), "Scanlines %s", + g_video_scanlines ? "on" : "off"); + host_osd_push(msg, 1200); + } + /* Host volume: config.ini [KeyMap] VolumeUp/VolumeDown + * (defaults: keypad +/-). 5% steps; shows right-side bar. */ + else if (host_keymap_match_event(HOST_KEYMAP_VOLUME_UP, + (int)key, (int)scancode, + (int)mod)) { + host_volume_adjust(+5); + } else if (host_keymap_match_event(HOST_KEYMAP_VOLUME_DOWN, + (int)key, (int)scancode, + (int)mod)) { + host_volume_adjust(-5); + } + /* Fullscreen toggle: Alt+Enter or Cmd/Ctrl+F. Toggles between + * windowed and the CONFIGURED tri-state mode (g_fullscreen: 1 + * borderless desktop fullscreen keeping the desktop resolution + * and letterboxing the image, or 2 exclusive fullscreen — a + * real display-mode change). Track the selected live mode: + * borderless deliberately leaves SDL's fullscreen bit clear. */ + else if (!key_repeat && + host_keymap_match_event(HOST_KEYMAP_FULLSCREEN, + (int)key, (int)scancode, + (int)mod)) { + const int target = s_window_fullscreen.mode ? 0 : + (g_fullscreen ? g_fullscreen : 1); + if (psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, target) == 0) + host_osd_push(target ? "Fullscreen" : "Windowed", 1500); + else + host_osd_push("Fullscreen change failed", 1500); + } + } + } + return true; +} + +static bool local_mouse_live(int override_word) { + bool injected = override_word >= 0; +#ifndef PSX_NO_DEBUG_TOOLS + uint8_t axes[4]; + injected = injected || debug_server_get_axis_override(axes) != 0; +#endif + return !injected && !g_headless && + !psx_netplay_active() && !psx_netplay_is_resimulating() && + !psx_selfcheck_input_locked() && !psx_selfcheck_resim_active() && + !g_psx_render_pass_active && !savestate_menu_open && + !psx_rewind_is_open() && !savestate_input_guard_active(); +} static NetplayVblankEpilogue sdl_vblank_present_body(void) { NetplayVblankEpilogue ep{}; @@ -7288,6 +7451,7 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { int override = -1; #endif + psx_local_mouse_begin(sdl_window, local_mouse_live(override)); { /* Outside every debug guard on purpose: production must be measurable. */ extern uint64_t s_frame_count; @@ -7410,127 +7574,8 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { if (!g_headless) { /* Pump SDL events to prevent window freeze. */ - SDL_Event ev; - while (SDL_PollEvent(&ev)) { - if (ev.type == SDL_QUIT) { - if (psx_netplay_active()) { - netplay_soft_exit("sdl_window_close"); - return ep; - } - psx_crash_trace_set_exit_origin("sdl_window_close"); - shutdown_runtime(); - std::exit(0); - } else if (ev.type == SDL_CONTROLLERDEVICEADDED) { - refresh_player_devices(); - } else if (ev.type == SDL_CONTROLLERDEVICEREMOVED) { - bool ours = false; - for (int s = 0; s < PSX_MAX_PLAYERS; s++) { -#if defined(PSX_SDL3) - if (ev.gdevice.which == g_players[s].instance) { ours = true; break; } -#else - if (ev.cdevice.which == g_players[s].instance) { ours = true; break; } -#endif - } - if (ours) { - close_controller(); - refresh_player_devices(); - } - } else if (ev.type == SDL_KEYDOWN) { -#if defined(PSX_SDL3) - const SDL_Keymod mod = ev.key.mod; - const SDL_Keycode key = ev.key.key; - const SDL_Scancode scancode = ev.key.scancode; - const int key_repeat = ev.key.repeat ? 1 : 0; -#else - const Uint16 mod = ev.key.keysym.mod; - const SDL_Keycode key = ev.key.keysym.sym; - const SDL_Scancode scancode = ev.key.keysym.scancode; - const int key_repeat = ev.key.repeat ? 1 : 0; -#endif - if (key == SDLK_ESCAPE && psx_netplay_active()) { - netplay_soft_exit("netplay_escape"); - return ep; - } - if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_REWIND, (int)key, - (int)scancode, (int)mod)) { - psx_rewind_toggle(); - } - else if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_SAVE_STATE_MENU, - (int)key, (int)scancode, - (int)mod)) { - savestate_menu_toggle(key); - } - else if (key == SDLK_c && (mod & KMOD_CTRL)) { - std::fprintf(stdout, "[DEBUG] Forzando reinserción de CD...\n"); - debug_force_cd_reinsert(); - host_osd_push("CD reinsert", 1500); - } - else if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_TURBO_TOGGLE, - (int)key, (int)scancode, - (int)mod)) { - fast_forward_toggle_flip(); - } - else if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_CAPTURE_MARK, - (int)key, (int)scancode, - (int)mod)) { -#ifndef PSX_NO_DEBUG_TOOLS - debug_server_capture_mark(); - host_osd_push("Capture marked (debug rings frozen)", 2000); -#else - host_osd_push("Capture mark needs a debug-tools build", 2000); -#endif - } - else if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_DISPLAY_PERF, - (int)key, (int)scancode, - (int)mod)) { - fps_telemetry_toggle(); - } - else if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_SCANLINES, - (int)key, (int)scancode, - (int)mod)) { - psx_video_set_scanlines(g_video_scanlines ? 0 : 1, - g_video_scanline_strength); - char msg[48]; - std::snprintf(msg, sizeof(msg), "Scanlines %s", - g_video_scanlines ? "on" : "off"); - host_osd_push(msg, 1200); - } - /* Host volume: config.ini [KeyMap] VolumeUp/VolumeDown - * (defaults: keypad +/-). 5% steps; shows right-side bar. */ - else if (host_keymap_match_event(HOST_KEYMAP_VOLUME_UP, - (int)key, (int)scancode, - (int)mod)) { - host_volume_adjust(+5); - } else if (host_keymap_match_event(HOST_KEYMAP_VOLUME_DOWN, - (int)key, (int)scancode, - (int)mod)) { - host_volume_adjust(-5); - } - /* Fullscreen toggle: Alt+Enter or Cmd/Ctrl+F. Toggles between - * windowed and the CONFIGURED tri-state mode (g_fullscreen: 1 - * borderless desktop fullscreen keeping the desktop resolution - * and letterboxing the image, or 2 exclusive fullscreen — a - * real display-mode change). Track the selected live mode: - * borderless deliberately leaves SDL's fullscreen bit clear. */ - else if (!key_repeat && - host_keymap_match_event(HOST_KEYMAP_FULLSCREEN, - (int)key, (int)scancode, - (int)mod)) { - const int target = s_window_fullscreen.mode ? 0 : - (g_fullscreen ? g_fullscreen : 1); - if (psx_window_fullscreen_set(sdl_window, &s_window_fullscreen, target) == 0) - host_osd_push(target ? "Fullscreen" : "Windowed", 1500); - else - host_osd_push("Fullscreen change failed", 1500); - } - } - } + psx_local_mouse_begin(sdl_window, local_mouse_live(override)); + if (!drain_host_events()) return ep; savestate_menu_poll_toggle_buttons(); rewind_poll_toggle_buttons(); fast_forward_toggle_poll_buttons(); @@ -7770,8 +7815,10 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * Rewind / Save states so the launcher's binding editor covers it. * g_manual_turbo_latched is the press-to-lock twin (TurboToggle / * fast_forward_toggle_pad) and drives the same path. */ + uint8_t host_keys[SDL_NUM_SCANCODES]; + psx_keybinds_host_keys(keys, host_keys); const bool kb_turbo = host_hotkey_input_focused() && - host_keymap_down(HOST_KEYMAP_TURBO, keys, (int)SDL_GetModState()); + 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)) { const int mult = manual_fast_forward_multiplier(); @@ -7901,7 +7948,16 @@ static NetplayVblankEpilogue sdl_vblank_present_body(void) { * semantics) — the mid-frame re-sample would fork the resim. */ if (g_low_latency_input && !psx_selfcheck_input_locked()) { SDL_GameControllerUpdate(); /* refresh pad state after the wait */ - SDL_PumpEvents(); /* refresh keyboard state */ + if (psx_local_mouse_installed()) { + psx_local_mouse_begin(sdl_window, local_mouse_live(override)); + if (!drain_host_events()) return ep; + // A hotkey acquired during pacing may open a host menu. + if (savestate_menu_open) savestate_menu_host_pause_loop(); + if (psx_rewind_is_open()) rewind_host_pause_loop(); + psx_local_mouse_begin(sdl_window, local_mouse_live(override)); + } else { + SDL_PumpEvents(); // retain native timing when no policy exists + } sample_pad_into_sio(override); latency_ring_restamp_input(); } diff --git a/runtime/src/psx_keybinds.c b/runtime/src/psx_keybinds.c index 2cdea345..74019573 100644 --- a/runtime/src/psx_keybinds.c +++ b/runtime/src/psx_keybinds.c @@ -19,10 +19,6 @@ * bind/save/load/rebind machinery as ordinary SDL_Scancode values while * held() resolves them against SDL_GetMouseState() instead of the keyboard * array. INI names: Mouse1 (left) .. Mouse5 (X2), plus LMB/RMB/MMB aliases. */ -#define PSXKB_MOUSE_SC_BASE 512 /* + SDL_BUTTON_* (1..5) */ -#define PSXKB_MOUSE_SC(btn) ((SDL_Scancode)(PSXKB_MOUSE_SC_BASE + (btn))) -#define PSXKB_IS_MOUSE_SC(sc) \ - ((int)(sc) > PSXKB_MOUSE_SC_BASE && (int)(sc) <= PSXKB_MOUSE_SC_BASE + 5) /* PSX pad word bits (active-low), standard DualShock layout. Matches the * PAD_* masks in main.cpp / beetle_main.cpp. */ @@ -350,12 +346,43 @@ static PsxPlayerBinds *player_binds(int player) { /* Is this single scancode (keyboard or mouse pseudo-code) currently held? * Mouse pseudo-scancodes MUST be checked before indexing keys[] — they sit * beyond the keyboard state array (SDL_NUM_SCANCODES entries). */ -static int held_sc(const uint8_t *keys, SDL_Scancode sc) { +static uint8_t s_suppressed[PSXKB_MAX_PLAYERS][PSXKB_MOUSE_SC_BASE + 6]; + +void psx_keybinds_suppress_control(int player, SDL_Scancode sc) { + if (player >= 1 && player <= PSXKB_MAX_PLAYERS && (int)sc > 0 && + (int)sc < PSXKB_MOUSE_SC_BASE + 6) + s_suppressed[player - 1][(int)sc] = 1; +} +void psx_keybinds_release_control(int player, SDL_Scancode sc) { + if (player >= 1 && player <= PSXKB_MAX_PLAYERS && (int)sc > 0 && + (int)sc < PSXKB_MOUSE_SC_BASE + 6) + s_suppressed[player - 1][(int)sc] = 0; +} +int psx_keybinds_control_suppressed(int player, SDL_Scancode sc) { + return player >= 1 && player <= PSXKB_MAX_PLAYERS && (int)sc > 0 && + (int)sc < PSXKB_MOUSE_SC_BASE + 6 && s_suppressed[player - 1][(int)sc]; +} +void psx_keybinds_host_keys(const uint8_t *keys, uint8_t out[SDL_NUM_SCANCODES]) { + if (!out) return; + if (!keys) { memset(out, 0, SDL_NUM_SCANCODES); return; } + memcpy(out, keys, SDL_NUM_SCANCODES); + for (int sc = 1; sc < SDL_NUM_SCANCODES; ++sc) + if (s_suppressed[0][sc]) out[sc] = 0; +} +static int held_sc(const uint8_t *keys, SDL_Scancode sc, int slot) { + int down; if (PSXKB_IS_MOUSE_SC(sc)) { Uint32 m = SDL_GetMouseState(NULL, NULL); - return (m & SDL_BUTTON((int)sc - PSXKB_MOUSE_SC_BASE)) != 0; + down = (m & SDL_BUTTON((int)sc - PSXKB_MOUSE_SC_BASE)) != 0; + } else { + down = sc != SDL_SCANCODE_UNKNOWN && (int)sc >= 0 && + (int)sc < SDL_NUM_SCANCODES && keys[sc]; } - return sc != SDL_SCANCODE_UNKNOWN && (int)sc < SDL_NUM_SCANCODES && keys[sc]; + if (psx_keybinds_control_suppressed(slot + 1, sc)) { + if (!down) psx_keybinds_release_control(slot + 1, sc); + return 0; + } + return down; } /* Is the input at button-def index i held for this player, via either its @@ -367,7 +394,7 @@ 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); - return held_sc(keys, p) || held_sc(keys, a); + return held_sc(keys, p, (int)slot) || held_sc(keys, a, (int)slot); } uint16_t psx_keybinds_pad_word(const uint8_t *keys, int player) { diff --git a/runtime/tests/test_keyboard_pad_chord.c b/runtime/tests/test_keyboard_pad_chord.c index 08faa853..c056c8cd 100644 --- a/runtime/tests/test_keyboard_pad_chord.c +++ b/runtime/tests/test_keyboard_pad_chord.c @@ -1,8 +1,15 @@ #include "psx_keybinds.h" +#include "host_keymap.h" #include #include #include +#ifdef _WIN32 +#include +#else +#include +#include +#endif static int failures; @@ -51,6 +58,84 @@ int main(int argc, char **argv) { expect_word("start re-pressed with select held", 0xFFF6u, psx_keybinds_pad_word(keys, 1)); + /* Captured Escape must be swallowed before folding; a primary/alternate + * binding and a simultaneously held controller action are independent. */ + memset(keys, 0, sizeof(keys)); + /* Rebinding intentionally moves a key from its previous input. The INI + * loader permits shared sources, so construct this fixture through it. */ + { +#ifdef _WIN32 + if (_mkdir("keyboard_pad_chord_fixture") != 0) return 1; +#else + if (mkdir("keyboard_pad_chord_fixture", 0755) != 0) return 1; +#endif + FILE *config = fopen("keyboard_pad_chord_fixture/keybinds.ini", "wb"); + if (!config) return 1; + fputs("[player1]\ncross = Escape\ncircle = S, Escape\n" + "[player2]\ncross = Escape\n", config); + fclose(config); + psx_keybinds_init("keyboard_pad_chord_fixture/fixture.exe"); + remove("keyboard_pad_chord_fixture/keybinds.ini"); +#ifdef _WIN32 + _rmdir("keyboard_pad_chord_fixture"); +#else + rmdir("keyboard_pad_chord_fixture"); +#endif + expect_scancode("shared INI primary Escape", SDL_SCANCODE_ESCAPE, + psx_keybinds_get_button(1, PSX_KB_CROSS)); + expect_scancode("shared INI alternate Escape", SDL_SCANCODE_ESCAPE, + psx_keybinds_get_button_alt(1, PSX_KB_CIRCLE)); + } + { + FILE *config = fopen("keyboard_consumed_host_test.ini", "wb"); + if (!config) return 1; + fputs("[KeyMap]\nTurbo = Escape\n", config); + fclose(config); + host_keymap_load("keyboard_consumed_host_test.ini"); + remove("keyboard_consumed_host_test.ini"); + } + keys[SDL_SCANCODE_ESCAPE] = 1; + expect_word("configured Turbo=Escape is normally live", 1, + (uint16_t)host_keymap_down(HOST_KEYMAP_TURBO, keys, 0)); + psx_keybinds_suppress_control(1, SDL_SCANCODE_ESCAPE); + expect_word("Escape primary and alternate consumed", 0xFFFFu, + psx_keybinds_pad_word(keys, 1)); + expect_word("held Escape remains consumed", 0xFFFFu, + psx_keybinds_pad_word(keys, 1)); + for (int poll = 0; poll < 3; ++poll) { + uint8_t host_keys[SDL_NUM_SCANCODES]; + psx_keybinds_host_keys(keys, host_keys); + expect_word("captured Escape does not activate polled Turbo", 0, + (uint16_t)host_keymap_down(HOST_KEYMAP_TURBO, host_keys, 0)); + expect_word("host filter preserves physical SDL state", 1, keys[SDL_SCANCODE_ESCAPE]); + expect_word("simultaneous P2 Escape action stays live", 0xBFFFu, + psx_keybinds_pad_word(keys, 2)); + } + expect_word("controller Cross preserved through source merge", 0xBFFFu, + (uint16_t)(0xBFFFu & psx_keybinds_pad_word(keys, 1))); + keys[SDL_SCANCODE_ESCAPE] = 0; + expect_word("release clears source suppression", 0xFFFFu, + psx_keybinds_pad_word(keys, 1)); + { + uint8_t host_keys[SDL_NUM_SCANCODES]; + psx_keybinds_host_keys(keys, host_keys); + expect_word("released Escape is not Turbo", 0, + (uint16_t)host_keymap_down(HOST_KEYMAP_TURBO, host_keys, 0)); + } + keys[SDL_SCANCODE_ESCAPE] = 1; + expect_word("fresh Escape press is native", 0x9FFFu, + psx_keybinds_pad_word(keys, 1)); + { + uint8_t host_keys[SDL_NUM_SCANCODES]; + psx_keybinds_host_keys(keys, host_keys); + expect_word("fresh post-release native Escape activates Turbo", 1, + (uint16_t)host_keymap_down(HOST_KEYMAP_TURBO, host_keys, 0)); + } + psx_keybinds_suppress_control(1, PSXKB_MOUSE_SC(SDL_BUTTON_LEFT)); + expect_word("mouse suppression is P1 only", 0, + (uint16_t)psx_keybinds_control_suppressed(2, PSXKB_MOUSE_SC(SDL_BUTTON_LEFT))); + psx_keybinds_release_control(1, PSXKB_MOUSE_SC(SDL_BUTTON_LEFT)); + if (failures) { fprintf(stderr, "keyboard pad chord: %d failure(s)\n", failures); return 1; diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp new file mode 100644 index 00000000..9a34267f --- /dev/null +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -0,0 +1,182 @@ +#include "local_mouse_policy.h" +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { std::printf("FAIL line %d: %s\n", __LINE__, #x); return 1; } } while (0) +namespace { +constexpr uint64_t Ms=1000000; +bool guest=true, capture_ok=true, active=false, held=false; +unsigned captures=0, releases=0, notices=0, resets=0; +unsigned hold_presses=0, hold_releases=0; +psx::MouseControl suppressed=psx::MouseControl::None; +bool capture(void*,bool on) { if(on) ++captures; else ++releases; return !on || capture_ok; } +void suppress(void*,psx::MouseControl c) { suppressed=c; } +void notice(void*,const char*) { ++notices; } +int eligible(uint32_t buttons) { return guest && (buttons&9)==9; } +void event(const PSXModMouseEvent* e) { + if(e->type==PSX_MOD_MOUSE_RESET || e->type==PSX_MOD_MOUSE_ACQUIRED) { active=held=false; ++resets; } + if(e->type==PSX_MOD_MOUSE_MOTION) active=true; + if(e->type==PSX_MOD_MOUSE_HOLD_PRESS) { ++hold_presses;held=true;active=false; } + if(e->type==PSX_MOD_MOUSE_HOLD_RELEASE) { ++hold_releases;active=held=false; } +} +void sample(uint64_t,PSXModMouseOutput* out) { + out->override_right=1; out->rx=active?250:128; out->ry=active?64:128; +} +} +int main() { + psx::LocalMousePolicy policy({nullptr,capture,suppress,notice}); + psx::LocalMouseHost host; + host.live=host.focused=host.connected=host.analog=true; + 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)); + 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); + policy.sample(10*Ms,x,y); CHECK(x==128 && y==128 && notices==0); + policy.motion(11*Ms,11*Ms,0.2,0.1); policy.sample(11*Ms,x,y); CHECK(x==250 && y==64); + policy.sample(11*Ms,x,y); CHECK(x==250 && y==64); + CHECK(policy.control(0,11*Ms,psx::MouseControl::Escape,true,false)); + CHECK(!policy.captured() && !active && suppressed==psx::MouseControl::Escape); + CHECK(notices==0); // Normal activation, movement and emergency release are silent. + CHECK(!policy.control(12*Ms,12*Ms,psx::MouseControl::Escape,true,false)); + uint64_t t=20*Ms; + auto acquire=[&]() { + t+=10*Ms;policy.update(t,host); + policy.control(t,t,psx::MouseControl::Left,false,false); + return policy.control(t,t,psx::MouseControl::Left,true,false); + }; + CHECK(acquire()); + policy.control(t+Ms,t+Ms,psx::MouseControl::Right,true,false); CHECK(held && !active); + policy.motion(t+2*Ms,t+2*Ms,30,0); CHECK(active); + policy.control(UINT64_MAX,0,psx::MouseControl::Right,false,false); CHECK(!held && !active); + policy.motion(t+3*Ms,t+3*Ms,30,0); CHECK(active); + policy.control(t+4*Ms,t+4*Ms,psx::MouseControl::Right,true,false); + policy.control(t+6*Ms,t+6*Ms,psx::MouseControl::Right,false,false); CHECK(!active); + policy.motion(t+5*Ms,t+6*Ms,30,0); CHECK(!policy.captured() && !active); + // Every lifecycle host gate neutralizes; restored eligibility needs a click. + for(int gate=0;gate<7;++gate) { + policy.reset();CHECK(acquire());policy.motion(t+Ms,t+Ms,30,0); + auto off=host; + if(gate==0)off.focused=false; if(gate==1)off.live=false; + if(gate==2)off.connected=false;if(gate==3)off.analog=false; + if(gate==4)off.native_right=true;if(gate==5)off.start=true; + if(gate==6)guest=false; + policy.update(t+2*Ms,off); CHECK(!policy.captured() && !active); + guest=true;policy.update(t+3*Ms,host); + CHECK(!policy.control(t+3*Ms,t+3*Ms,psx::MouseControl::Left,true,false)); + policy.motion(t+4*Ms,t+4*Ms,60,0); CHECK(!active && !policy.captured()); + x=1;y=255;policy.sample(t+4*Ms,x,y); CHECK(x==1 && y==255); + } + policy.reset();CHECK(acquire()); + policy.motion(t+2*Ms,t+Ms,10,0);CHECK(!policy.captured()); // future + CHECK(acquire());policy.motion(t+Ms,t+Ms,10,0); + policy.motion(t,t+Ms,10,0);CHECK(!policy.captured()); // backwards + CHECK(acquire());policy.motion(t,t+251*Ms,10,0);CHECK(!policy.captured()); // stale + CHECK(acquire());policy.motion(t,t,std::numeric_limits::quiet_NaN(),0);CHECK(!policy.captured()); + CHECK(acquire());policy.motion(t+Ms,t+Ms,10,0); + t+=300*Ms;policy.update(t,host);CHECK(!policy.captured() && !active); + CHECK(!policy.control(t-Ms,t,psx::MouseControl::Left,true,false)); // no stall backlog rearm + CHECK(acquire()); policy.control(t+Ms,t+Ms,psx::MouseControl::Right,true,false); + policy.reset(); CHECK(acquire()); CHECK(!held); + policy.motion(t+Ms,t+Ms,20,0); CHECK(active && !held); // held across reset cannot latch + policy.control(t+2*Ms,t+2*Ms,psx::MouseControl::Right,false,false); + policy.control(t+3*Ms,t+3*Ms,psx::MouseControl::Right,true,false);CHECK(held); + policy.reset(); host.hold_conflict=true;CHECK(acquire()); + policy.control(t+Ms,t+Ms,psx::MouseControl::Right,false,false); + policy.control(t+2*Ms,t+2*Ms,psx::MouseControl::Right,true,false);CHECK(!held); + const unsigned before=notices;policy.update(t+3*Ms,host);CHECK(notices==before); + + // Exact conflict -> acquire OTHER button -> hold down -> motion -> up. + // A rejected physical edge must never send HOLD_RELEASE or kill a flick. + policy.reset(); + policy.control(t+4*Ms,t+4*Ms,psx::MouseControl::Right,false,false); + CHECK(acquire()); + const unsigned conflict_presses=hold_presses, conflict_releases=hold_releases; + policy.control(t+Ms,t+Ms,psx::MouseControl::Right,true,false); + policy.motion(t+2*Ms,t+2*Ms,30,0);CHECK(active && !held); + policy.control(t+3*Ms,t+3*Ms,psx::MouseControl::Right,false,false); + policy.sample(t+3*Ms,x,y); + CHECK(policy.captured() && active && x==250 && y==64); + CHECK(hold_presses==conflict_presses && hold_releases==conflict_releases); + + // A physical hold already down before acquisition is also unaccepted. + policy.reset();host.hold_conflict=false; + auto unfocused=host;unfocused.focused=false; + policy.update(t+4*Ms,unfocused); + policy.control(t+4*Ms,t+4*Ms,psx::MouseControl::Right,true,false); + CHECK(!policy.captured()); + CHECK(acquire()); + const unsigned blocked_presses=hold_presses, blocked_releases=hold_releases; + policy.motion(t+Ms,t+Ms,30,0);CHECK(active && !held); + policy.control(t+2*Ms,t+2*Ms,psx::MouseControl::Right,false,false); + CHECK(policy.captured() && active && !held); + CHECK(hold_presses==blocked_presses && hold_releases==blocked_releases); + policy.control(t+3*Ms,t+3*Ms,psx::MouseControl::Right,true,false); + CHECK(held && !active && hold_presses==blocked_presses+1); + policy.motion(t+4*Ms,t+4*Ms,30,0);CHECK(active && held); + policy.control(UINT64_MAX,0,psx::MouseControl::Right,false,false); + CHECK(!active && !held && hold_releases==blocked_releases+1); + + // If an accepted hold becomes conflicting, release it exactly once. + policy.reset();CHECK(acquire()); + policy.control(t+Ms,t+Ms,psx::MouseControl::Right,true,false); + policy.motion(t+2*Ms,t+2*Ms,30,0);CHECK(held && active); + const unsigned accepted_releases=hold_releases; + auto conflicting=host;conflicting.hold_conflict=true; + policy.update(t+3*Ms,conflicting); + CHECK(!held && !active && hold_releases==accepted_releases+1); + policy.motion(t+4*Ms,t+4*Ms,30,0);CHECK(active && !held); + policy.control(t+5*Ms,t+5*Ms,psx::MouseControl::Right,false,false); + CHECK(active && hold_releases==accepted_releases+1); + + // Activation belongs only to LEFT, and lasts only through its release. + policy.reset();host.hold_conflict=false; + policy.control(t+6*Ms,t+6*Ms,psx::MouseControl::Right,false,false); + for (auto button : {psx::MouseControl::Middle, psx::MouseControl::Right, + psx::MouseControl::X1, psx::MouseControl::X2}) { + CHECK(!policy.control(t+7*Ms,t+7*Ms,button,true,false)); + CHECK(!policy.captured()); + policy.control(t+8*Ms,t+8*Ms,button,false,false); + } + const unsigned normal_notices=notices; + CHECK(acquire());CHECK(!active); + policy.motion(t+Ms,t+Ms,30,0);CHECK(active); + CHECK(policy.control(UINT64_MAX,0,psx::MouseControl::Left,false,false)); + CHECK(!policy.captured() && !active && !held); + x=99;y=77;policy.sample(t+Ms,x,y);CHECK(x==99 && y==77); + CHECK(policy.control(t+2*Ms,t+2*Ms,psx::MouseControl::Left,true,false)); + policy.sample(t+2*Ms,x,y);CHECK(x==128 && y==128); // rapid re-press has no stale motion + policy.motion(t+3*Ms,t+3*Ms,30,0);CHECK(active); + policy.control(t+4*Ms,t+4*Ms,psx::MouseControl::Right,true,false);CHECK(held && !active); + policy.motion(t+5*Ms,t+5*Ms,30,0);CHECK(held && active); + CHECK(policy.control(t+6*Ms,t+6*Ms,psx::MouseControl::Left,false,false)); + CHECK(!policy.captured() && !active && !held); // RIGHT can never outlive LEFT + CHECK(policy.control(t+7*Ms,t+7*Ms,psx::MouseControl::Left,true,false)); + CHECK(!held && !active); // RIGHT remains physically down, so requires a fresh edge + policy.motion(t+8*Ms,t+8*Ms,30,0);CHECK(active && !held); + policy.control(t+9*Ms,t+9*Ms,psx::MouseControl::Right,false,false); + CHECK(active);CHECK(policy.control(t+10*Ms,t+10*Ms,psx::MouseControl::Left,false,false)); + CHECK(notices==normal_notices); + + // A press while ineligible cannot activate later merely because eligibility returns. + auto absent=host;absent.focused=false; + policy.update(t+11*Ms,absent); + CHECK(!policy.control(t+11*Ms,t+11*Ms,psx::MouseControl::Left,true,false)); + policy.update(t+12*Ms,host); + CHECK(!policy.control(t+12*Ms,t+12*Ms,psx::MouseControl::Left,true,false)); + CHECK(!policy.captured()); + policy.control(t+13*Ms,t+13*Ms,psx::MouseControl::Left,false,false); + CHECK(policy.control(t+14*Ms,t+14*Ms,psx::MouseControl::Left,true,false)); + CHECK(policy.captured() && !active); + CHECK(policy.control(t+15*Ms,t+15*Ms,psx::MouseControl::Left,false,false)); + t+=20*Ms; + + policy.reset();host.hold_conflict=false;capture_ok=false;CHECK(acquire());CHECK(!policy.captured()); + const unsigned failure_notices=notices;CHECK(acquire());CHECK(notices==failure_notices); + policy.clear(); CHECK(!policy.installed() && !policy.captured()); + callbacks.struct_size=0;CHECK(!policy.install(&callbacks)); + std::printf("ordered local mouse policy safety: %u acquisitions, %u releases, %u resets\n",captures,releases,resets); + return 0; +} diff --git a/runtime/tests/test_mouse_binding_suppression.c b/runtime/tests/test_mouse_binding_suppression.c new file mode 100644 index 00000000..f8ab460f --- /dev/null +++ b/runtime/tests/test_mouse_binding_suppression.c @@ -0,0 +1,89 @@ +/* Exercise the real binding fold with a controlled physical mouse poll. + * The adapter is tested separately; no production polling hook is added. */ +#include "psx_keybinds.h" +#include +#include +#ifdef _WIN32 +#include +#else +#include +#include +#endif +static Uint32 mouse_buttons; +static Uint32 test_mouse_state(void *x, void *y) { (void)x; (void)y; return mouse_buttons; } +#define SDL_GetMouseState test_mouse_state +#include "../src/psx_keybinds.c" +#undef SDL_GetMouseState + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); return 1; } } while (0) +int main(void) { + uint8_t keys[SDL_NUM_SCANCODES] = {0}; + const SDL_Scancode left = PSXKB_MOUSE_SC(SDL_BUTTON_LEFT); + psx_keybinds_reset_player(1); psx_keybinds_reset_player(2); + /* Rebinding moves an assigned source; duplicate sources are a valid INI + * state. Load that state without changing the real rebinding behavior. */ +#ifdef _WIN32 + CHECK(_mkdir("mouse_binding_suppression_fixture") == 0); +#else + CHECK(mkdir("mouse_binding_suppression_fixture", 0755) == 0); +#endif + FILE *config = fopen("mouse_binding_suppression_fixture/keybinds.ini", "wb"); + CHECK(config != NULL); + fputs("[player1]\ncross = Mouse1\ncircle = S, Mouse1\n" + "rs_right = Mouse1\nr1 = Mouse3\n[player2]\ncross = Mouse1\n", config); + fclose(config); + psx_keybinds_init("mouse_binding_suppression_fixture/fixture.exe"); + remove("mouse_binding_suppression_fixture/keybinds.ini"); +#ifdef _WIN32 + _rmdir("mouse_binding_suppression_fixture"); +#else + rmdir("mouse_binding_suppression_fixture"); +#endif + CHECK(psx_keybinds_get_button(1, PSX_KB_CROSS) == left); + CHECK(psx_keybinds_get_button_alt(1, PSX_KB_CIRCLE) == left); + CHECK(psx_keybinds_get_button(1, PSX_KB_RS_RIGHT) == left); + mouse_buttons = SDL_BUTTON(SDL_BUTTON_LEFT); + CHECK(psx_keybinds_pad_word(keys, 1) == 0x9FFF); + psx_keybinds_suppress_control(1, left); + for (int i = 0; i < 3; ++i) { + uint8_t axes[4] = {128,128,128,128}; + CHECK(psx_keybinds_pad_word(keys, 1) == 0xFFFF); + CHECK((0xBFFF & psx_keybinds_pad_word(keys, 1)) == 0xBFFF); + psx_keybinds_sticks(keys, 1, axes); + CHECK(axes[0] == 128 && axes[1] == 128 && axes[2] == 128 && axes[3] == 128); + CHECK(psx_keybinds_pad_word(keys, 2) == 0xBFFF); + } + mouse_buttons = 0; + CHECK(psx_keybinds_pad_word(keys, 1) == 0xFFFF); + CHECK(!psx_keybinds_control_suppressed(1, left)); + mouse_buttons = SDL_BUTTON(SDL_BUTTON_LEFT); + CHECK(psx_keybinds_pad_word(keys, 1) == 0x9FFF); + uint8_t axes[4] = {128,128,128,128}; + psx_keybinds_sticks(keys, 1, axes); CHECK(axes[2] == 255); + // Rapid owned re-press consumes LEFT again, while independent native + // keyboard and RIGHT mouse bindings remain live before controller merging. + psx_keybinds_suppress_control(1, left); + keys[SDL_SCANCODE_S] = 1; + mouse_buttons |= SDL_BUTTON(SDL_BUTTON_RIGHT); + CHECK(psx_keybinds_pad_word(keys, 1) == 0xD7FF); + CHECK((0xBFFF & psx_keybinds_pad_word(keys, 1)) == 0x97FF); + // P2 retains both LEFT/Cross and its native S/Circle binding. + CHECK(psx_keybinds_pad_word(keys, 2) == 0x9FFF); + // Every poll starts with the new native report. The fold preserves axes + // already supplied by a controller; it does not erase the previous query. + axes[0] = axes[1] = axes[2] = axes[3] = 128; + psx_keybinds_sticks(keys, 1, axes);CHECK(axes[2] == 128 && axes[3] == 128); + axes[0] = 90; axes[1] = 160; axes[2] = 99; axes[3] = 77; + psx_keybinds_sticks(keys, 1, axes); + CHECK(axes[0] == 90 && axes[1] == 160 && axes[2] == 99 && axes[3] == 77); + keys[SDL_SCANCODE_S] = 0; + mouse_buttons = SDL_BUTTON(SDL_BUTTON_RIGHT); + psx_keybinds_release_control(1, left); + CHECK(psx_keybinds_pad_word(keys, 1) == 0xF7FF); + CHECK(psx_keybinds_pad_word(keys, 2) == 0xFFFF); + CHECK(!psx_keybinds_control_suppressed(1, left)); + mouse_buttons = 0; + CHECK(psx_keybinds_pad_word(keys, 1) == 0xFFFF); + puts("LEFT activation suppression survives polls/repress, preserves ordinary bindings/P2/controller, and releases"); + return 0; +} diff --git a/runtime/tests/test_sio_card_protocol.c b/runtime/tests/test_sio_card_protocol.c index 35de303f..36fdd289 100644 --- a/runtime/tests/test_sio_card_protocol.c +++ b/runtime/tests/test_sio_card_protocol.c @@ -30,11 +30,17 @@ #include "sio.h" #include "memcard.h" +#include "psx_netplay.h" #include #include #include #include #include +#ifdef _WIN32 +#include +#else +#include +#endif /* ---- Stubs for sio.c's external dependencies ---- */ uint32_t i_stat = 0; @@ -249,7 +255,11 @@ static void test_card_read_sector_0(void) { * only public entry. Let's symlink/copy our file as card1.mcd. */ f = fopen("test_dir/card1.mcd", "wb"); if (!f) { - system("mkdir test_dir 2>/dev/null || mkdir -p test_dir"); +#ifdef _WIN32 + (void)_mkdir("test_dir"); +#else + (void)mkdir("test_dir", 0755); +#endif f = fopen("test_dir/card1.mcd", "wb"); } assert(f); @@ -414,8 +424,57 @@ static void test_cycle_paced_exact_boundaries(void) { EXPECT_EQ("paced.tick.irq", irq_sio0, i_stat & irq_sio0); } +/* Local input publishes RX/RY through the ordinary pad state/stick/type APIs. + * Pin the actual wire order, P2 independence and idle-boundary type change. */ +static void test_local_pad_report_delivery(void) { + PsxNetPad p1 = {0xA55A, 0x11, 0xE7, 0xF1, 0x17, 1, 1}; + PsxNetPad p2 = {0x3C81, 0x33, 0x55, 0x99, 0xAA, 1, 1}; + EXPECT_EQ("pad.blob.size", 8, sizeof(PsxNetPad)); + sio_init(); + sio_connect_pad(0); sio_connect_pad(1); + sio_set_pad_analog(0, 1, 128, 128, 128, 128); + sio_set_pad_analog(1, 1, 128, 128, 128, 128); + sio_set_pad_state_slot(0, p1.buttons); + sio_set_pad_sticks(0, p1.lx, p1.ly, p1.rx, p1.ry); + sio_request_pad_type(0, p1.analog); + sio_set_pad_state_slot(1, p2.buttons); + sio_set_pad_sticks(1, p2.lx, p2.ly, p2.rx, p2.ry); + sio_request_pad_type(1, p2.analog); + card_deselect(0); + EXPECT_EQ("pad.p1.prefix", 0xFF, card_xchg(0x01, 0)); + EXPECT_EQ("pad.p1.id", 0x73, card_xchg(0x42, 0)); + /* A frame's requested type cannot truncate an already-started report. */ + sio_request_pad_type(0, 0); + EXPECT_EQ("pad.p1.marker", 0x5A, card_xchg(0, 0)); + EXPECT_EQ("pad.p1.buttons.low", 0x5A, card_xchg(0, 0)); + EXPECT_EQ("pad.p1.buttons.high", 0xA5, card_xchg(0, 0)); + EXPECT_EQ("pad.p1.rx", 0xF1, card_xchg(0, 0)); + EXPECT_EQ("pad.p1.ry", 0x17, card_xchg(0, 0)); + EXPECT_EQ("pad.p1.lx", 0x11, card_xchg(0, 0)); + EXPECT_EQ("pad.p1.ly", 0xE7, card_xchg(0, 0)); + card_deselect(0); + EXPECT_EQ("pad.digital.prefix", 0xFF, card_xchg(0x01, 0)); + EXPECT_EQ("pad.digital.id", 0x41, card_xchg(0x42, 0)); + EXPECT_EQ("pad.digital.marker", 0x5A, card_xchg(0, 0)); + EXPECT_EQ("pad.digital.buttons.low", 0x5A, card_xchg(0, 0)); + EXPECT_EQ("pad.digital.buttons.high", 0xA5, card_xchg(0, 0)); + card_deselect(1); + EXPECT_EQ("pad.p2.prefix", 0xFF, card_xchg(0x01, 1)); + EXPECT_EQ("pad.p2.id", 0x73, card_xchg(0x42, 1)); + EXPECT_EQ("pad.p2.marker", 0x5A, card_xchg(0, 1)); + EXPECT_EQ("pad.p2.buttons.low", 0x81, card_xchg(0, 1)); + EXPECT_EQ("pad.p2.buttons.high", 0x3C, card_xchg(0, 1)); + EXPECT_EQ("pad.p2.rx", 0x99, card_xchg(0, 1)); + EXPECT_EQ("pad.p2.ry", 0xAA, card_xchg(0, 1)); + EXPECT_EQ("pad.p2.lx", 0x33, card_xchg(0, 1)); + EXPECT_EQ("pad.p2.ly", 0x55, card_xchg(0, 1)); + card_deselect(1); +} + int main(int argc, char **argv) { fprintf(stderr, "=== sio card protocol tests ===\n"); + test_local_pad_report_delivery(); + fprintf(stderr, "test_local_pad_report_delivery: %d/%d ok\n", g_checks-g_failures, g_checks); if (argc == 2 && strcmp(argv[1], "exact-boundaries") == 0) { test_cycle_paced_exact_boundaries(); From 93371b17828b41b198dfb105fb65f3bbf669a794 Mon Sep 17 00:00:00 2001 From: Beman Date: Mon, 5 Oct 2026 01:07:18 +1000 Subject: [PATCH 2/6] Preserve ordered mouse re-press after delayed release (cherry picked from commit 50ec9191c2e082a3c68c15b370059f8916b75ff1) --- docs/LOCAL_MOUSE_POLICY.md | 12 +++- runtime/src/local_mouse_policy.cpp | 9 +++ runtime/tests/test_local_mouse_policy.cpp | 67 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/LOCAL_MOUSE_POLICY.md b/docs/LOCAL_MOUSE_POLICY.md index b5d0cade..d43587c3 100644 --- a/docs/LOCAL_MOUSE_POLICY.md +++ b/docs/LOCAL_MOUSE_POLICY.md @@ -45,6 +45,11 @@ routing/removal, invalid data, capture failure and >250 ms host stalls release capture and reset the policy. Stale, backwards and future motion cannot extend a pulse. Releases remain immediate even with anomalous timestamps; their watermark cannot admit old motion after a cleared held target. A +valid ordered LEFT release while capture remains eligible uses its actual +event timestamp as the acquisition boundary. A later physical press in the +same delayed SDL drain can therefore acquire a neutral new session. Lifecycle, +stall and malformed-release resets retain the processing-time barrier; a release +after such a reset cannot lower it to admit queued input. A stall's backlog cannot acquire capture again. Eligibility recovery needs a release/repress of LEFT and fresh motion. Reset retains physical LEFT down-state, so holding it across an interruption cannot recapture. Debug input injection, headless, netplay, replay/resimulation and @@ -81,6 +86,9 @@ pure policy tests do not exercise the OS or game dispatcher. The reviewed implementation base is release framework commit 065888f50f9131839bcbbc8814debf58b4624b16, also the canonical `release/ape-v0.5.0-hle-support` branch. Current upstream master has differing -existing mod APIs and needs separate compatibility validation. Final game-pin -reachability/CI and physical interruption checks are pending; this is not a +existing mod APIs and needs separate compatibility validation. The linked game +draft pins this runtime draft's reachable head through its configured framework +upstream; final-pin checkout, build and local test validation are recorded with +the drafts. Hosted CI status and physical interruption checks are reported +separately; this is not a public-quality or merge-readiness claim. diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp index 0c23535e..ba76fa4b 100644 --- a/runtime/src/local_mouse_policy.cpp +++ b/runtime/src/local_mouse_policy.cpp @@ -86,9 +86,18 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, if (!down) { // 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 + // boundary, rather than the later time at which SDL drains it. + // After an interruption or malformed release, retain reset()'s + // processing-time barrier so queued input cannot rearm capture. + const bool ordered_release = captured_ && allowed() && + t <= now && now - t <= StallNs && t >= release_clock_ && + (!event_valid_ || t >= event_clock_) && + (!clock_valid_ || (now >= clock_ && now - clock_ <= StallNs)); const bool owned = activation_owned_; activation_owned_ = false; if (captured_ || owned) reset(); + if (ordered_release) release_clock_ = t; return owned; } // reset() retains physical down-state. A held button after a menu, diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp index 9a34267f..de7c77de 100644 --- a/runtime/tests/test_local_mouse_policy.cpp +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -173,6 +173,73 @@ int main() { CHECK(policy.control(t+15*Ms,t+15*Ms,psx::MouseControl::Left,false,false)); t+=20*Ms; + // SDL may drain several ordered edges after their physical timestamps. + // A normal release must not reject a later re-press in that same drain. + for (uint64_t delay : {10*Ms, 50*Ms, 200*Ms}) { + psx::LocalMousePolicy batch({nullptr,capture,suppress,notice}); + CHECK(batch.install(&callbacks)); + const uint64_t base=1000*Ms+delay*10; + batch.update(base+10*Ms,host); + CHECK(batch.control(base+10*Ms,base+10*Ms,psx::MouseControl::Left,true,false)); + batch.motion(base+15*Ms,base+15*Ms,30,0); + batch.control(base+16*Ms,base+16*Ms,psx::MouseControl::Right,true,false); + batch.motion(base+17*Ms,base+17*Ms,30,0); CHECK(active && held); + batch.update(base+20*Ms+delay,host); + CHECK(batch.control(base+20*Ms,base+20*Ms+delay,psx::MouseControl::Left,false,false)); + CHECK(!batch.captured() && !active && !held); + x=99;y=77;batch.sample(base+20*Ms+delay,x,y);CHECK(x==99 && y==77); + batch.update(base+21*Ms+delay,host); + CHECK(batch.control(base+25*Ms,base+21*Ms+delay,psx::MouseControl::Left,true,false)); + CHECK(batch.captured() && !active && !held); + batch.sample(base+21*Ms+delay,x,y);CHECK(x==128 && y==128); + batch.motion(base+26*Ms,base+22*Ms+delay,30,0);CHECK(active && !held); + batch.control(base+27*Ms,base+23*Ms+delay,psx::MouseControl::Right,false,false); + CHECK(active && !held); // A physically held RIGHT cannot latch or cancel this flick. + CHECK(batch.control(base+28*Ms,base+24*Ms+delay,psx::MouseControl::Left,false,false)); + CHECK(!batch.captured() && !active && !held); + batch.clear(); + } + + // A release after an interruption cannot lower its processing-time barrier. + for (int gate=0;gate<4;++gate) { + psx::LocalMousePolicy batch({nullptr,capture,suppress,notice}); + CHECK(batch.install(&callbacks)); + const uint64_t base=5000*Ms+gate*1000*Ms; + batch.update(base+10*Ms,host); + CHECK(batch.control(base+10*Ms,base+10*Ms,psx::MouseControl::Left,true,false)); + batch.motion(base+15*Ms,base+15*Ms,30,0); + auto off=host;const uint64_t barrier=base+(gate==3?300:30)*Ms; + if(gate==0)off.focused=false;if(gate==1)guest=false;if(gate==2)off.native_right=true; + batch.update(barrier,off);CHECK(!batch.captured() && !active); + guest=true;batch.update(barrier+Ms,host); + CHECK(batch.control(base+20*Ms,barrier+2*Ms,psx::MouseControl::Left,false,false)); + CHECK(!batch.control(base+25*Ms,barrier+3*Ms,psx::MouseControl::Left,true,false)); + CHECK(!batch.captured() && !active && !held); + batch.control(barrier+4*Ms,barrier+4*Ms,psx::MouseControl::Left,false,false); + batch.update(barrier+5*Ms,host); + CHECK(batch.control(barrier+5*Ms,barrier+5*Ms,psx::MouseControl::Left,true,false)); + batch.sample(barrier+5*Ms,x,y);CHECK(x==128 && y==128); + batch.motion(barrier+6*Ms,barrier+6*Ms,30,0);CHECK(active); + batch.clear(); + } + + // Malformed releases are still immediate, but cannot admit a queued press. + for (uint64_t bad : {UINT64_MAX, uint64_t(14*Ms), uint64_t(0)}) { + psx::LocalMousePolicy batch({nullptr,capture,suppress,notice}); + CHECK(batch.install(&callbacks));batch.update(10*Ms,host); + CHECK(batch.control(10*Ms,10*Ms,psx::MouseControl::Left,true,false)); + batch.motion(15*Ms,15*Ms,30,0);batch.update(30*Ms,host); + CHECK(batch.control(bad,30*Ms,psx::MouseControl::Left,false,false)); + CHECK(!batch.captured() && !active && !held); + batch.update(31*Ms,host); + CHECK(!batch.control(25*Ms,31*Ms,psx::MouseControl::Left,true,false)); + CHECK(!batch.captured()); + batch.control(32*Ms,32*Ms,psx::MouseControl::Left,false,false); + batch.update(33*Ms,host); + CHECK(batch.control(33*Ms,33*Ms,psx::MouseControl::Left,true,false)); + batch.sample(33*Ms,x,y);CHECK(x==128 && y==128);batch.clear(); + } + policy.reset();host.hold_conflict=false;capture_ok=false;CHECK(acquire());CHECK(!policy.captured()); const unsigned failure_notices=notices;CHECK(acquire());CHECK(notices==failure_notices); policy.clear(); CHECK(!policy.installed() && !policy.captured()); From f7ca7237cd8b9ea2797bd3751613d5d0e75076e9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:52:32 -0700 Subject: [PATCH 3/6] Reconcile a LEFT release missed while unfocused on focus regain Non-Windows only reconciled while focused AND captured; focus loss drops capture, so a release outside the window left activation_down_ stuck. Decision moved to LocalMousePolicy::sync_left (tested). Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit f012cf16932648797f023dbc3269f678b87eb24f) --- runtime/src/local_mouse_policy.cpp | 4 ++++ runtime/src/local_mouse_policy.h | 2 ++ runtime/src/local_mouse_sdl.cpp | 14 +++++++------- runtime/tests/test_local_mouse_policy.cpp | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/runtime/src/local_mouse_policy.cpp b/runtime/src/local_mouse_policy.cpp index ba76fa4b..e5df8e04 100644 --- a/runtime/src/local_mouse_policy.cpp +++ b/runtime/src/local_mouse_policy.cpp @@ -149,6 +149,10 @@ bool LocalMousePolicy::control(uint64_t t, uint64_t now, MouseControl control, } return false; } +void LocalMousePolicy::sync_left(uint64_t now, bool physically_down) { + if (!physically_down && activation_down_) + control(0, now, MouseControl::Left, false, false); +} void LocalMousePolicy::motion(uint64_t t, uint64_t now, double dx, double dy) { if (!captured_) return; if (!allowed() || !std::isfinite(dx) || !std::isfinite(dy) || diff --git a/runtime/src/local_mouse_policy.h b/runtime/src/local_mouse_policy.h index c8eec2e8..ddb0082b 100644 --- a/runtime/src/local_mouse_policy.h +++ b/runtime/src/local_mouse_policy.h @@ -25,6 +25,8 @@ public: void reset(); void update(uint64_t now, LocalMouseHost host); bool control(uint64_t t, uint64_t now, MouseControl control, bool down, bool repeat); + // Reconcile a LEFT release the host never delivered (e.g. made while unfocused). + void sync_left(uint64_t now, bool physically_down); void motion(uint64_t t, uint64_t now, double dx, double dy); void sample(uint64_t now, uint8_t& rx, uint8_t& ry); bool captured() const { return captured_; } diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp index 6014ad7e..ccfcd00c 100644 --- a/runtime/src/local_mouse_sdl.cpp +++ b/runtime/src/local_mouse_sdl.cpp @@ -105,16 +105,16 @@ void psx_local_mouse_begin(SDL_Window* window, bool live) { update(now_ns()); // Windows global state survives focus clearing SDL's cached window state. // Other backends may not support global mouse polling (e.g. Wayland), so - // there catch a lost release only while focused and captured. Neither path - // manufactures a down or unblocks a held activation across focus loss. + // there reconcile whenever focused (captured or not), so a release made + // while unfocused is caught on focus regain. Neither path manufactures a + // down or unblocks a held activation across focus loss. #if defined(_WIN32) - const bool left_released = !(SDL_GetGlobalMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)); + const bool left_down = (SDL_GetGlobalMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0; + state().sync_left(now_ns(), left_down); #else - const bool left_released = host_.focused && state().captured() && - !(SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)); + if (host_.focused) + state().sync_left(now_ns(), (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0); #endif - if (left_released) - state().control(0, now_ns(), psx::MouseControl::Left, false, false); // Catch a lost hold release, including one made outside the game window. const auto hold = state().hold_control(); bool down = false; diff --git a/runtime/tests/test_local_mouse_policy.cpp b/runtime/tests/test_local_mouse_policy.cpp index de7c77de..04131566 100644 --- a/runtime/tests/test_local_mouse_policy.cpp +++ b/runtime/tests/test_local_mouse_policy.cpp @@ -240,6 +240,27 @@ int main() { batch.sample(33*Ms,x,y);CHECK(x==128 && y==128);batch.clear(); } + // LEFT released while unfocused is never delivered; reconcile on regain so + // the next click activates. A still-held button is not a new activation. + { + psx::LocalMousePolicy lost({nullptr,capture,suppress,notice}); + CHECK(lost.install(&callbacks)); + const uint64_t base=9000*Ms; + lost.update(base,host); + CHECK(lost.control(base,base,psx::MouseControl::Left,true,false) && lost.captured()); + auto away=host;away.focused=false; + lost.update(base+10*Ms,away);CHECK(!lost.captured()); + lost.update(base+20*Ms,host); + lost.sync_left(base+20*Ms,true); + CHECK(!lost.control(base+21*Ms,base+21*Ms,psx::MouseControl::Left,true,false)); + lost.sync_left(base+22*Ms,false); + lost.update(base+23*Ms,host); + CHECK(lost.control(base+23*Ms,base+23*Ms,psx::MouseControl::Left,true,false)); + CHECK(lost.captured()); + lost.sync_left(base+24*Ms,true);CHECK(lost.captured()); // down: no-op + lost.clear(); + } + policy.reset();host.hold_conflict=false;capture_ok=false;CHECK(acquire());CHECK(!policy.captured()); const unsigned failure_notices=notices;CHECK(acquire());CHECK(notices==failure_notices); policy.clear(); CHECK(!policy.installed() && !policy.captured()); From c00a09e556a24a1fead1799f460727fae43025f3 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sun, 4 Oct 2026 10:52:32 -0700 Subject: [PATCH 4/6] Collapse identical SDL2/SDL3 arms in local mouse focus() Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 12d98c220216a671f41cea6dccecd68c8dfa7042) --- runtime/src/local_mouse_sdl.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp index ccfcd00c..a66c76be 100644 --- a/runtime/src/local_mouse_sdl.cpp +++ b/runtime/src/local_mouse_sdl.cpp @@ -62,11 +62,7 @@ psx::LocalMousePolicy& state() { bool focus() { if (!window_ || SDL_GetKeyboardFocus() != window_) return false; const auto flags = SDL_GetWindowFlags(window_); -#if defined(PSX_SDL3) return (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) == 0; -#else - return (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) == 0; -#endif } bool conflict() { const SDL_Scancode sc = scancode(state().hold_control()); From 6f9bdd94f24f417f2b47affba707512c7846f786 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 5 Oct 2026 13:49:07 -0700 Subject: [PATCH 5/6] Resolve controller source and mouse policy in one ordered external-input layer Mouse policy now folds onto the resolved pad (source included) instead of a discarded capture; no-policy path does no mouse polling. Co-Authored-By: Claude Opus 5.5 (1M context) --- runtime/CMakeLists.txt | 5 + runtime/include/mod_plugins.h | 61 ++++++--- runtime/runtime.cmake | 1 + runtime/src/local_mouse_sdl.cpp | 11 +- runtime/src/main.cpp | 174 +++++++++++++----------- runtime/src/pad_external_input.c | 38 ++++++ runtime/src/pad_external_input.h | 73 ++++++++++ runtime/tests/test_pad_external_input.c | 158 +++++++++++++++++++++ 8 files changed, 425 insertions(+), 96 deletions(-) create mode 100644 runtime/src/pad_external_input.c create mode 100644 runtime/src/pad_external_input.h create mode 100644 runtime/tests/test_pad_external_input.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 82da891a..22b5d11f 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -436,6 +436,11 @@ if(BUILD_TESTING) target_include_directories(mod_controller_source_test PRIVATE include) add_test(NAME mod_controller_source_test COMMAND mod_controller_source_test) + add_executable(pad_external_input_test + tests/test_pad_external_input.c src/pad_external_input.c) + target_include_directories(pad_external_input_test PRIVATE include src) + add_test(NAME pad_external_input_test COMMAND pad_external_input_test) + add_executable(gte_register_access_test tests/test_gte_register_access.cpp src/gte.cpp diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index e121cf74..bf4b15b7 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -756,30 +756,53 @@ int psx_mod_set_controller_presentation_policy( uint32_t initial_mode, int config_capable); -/* Offline controller source owns a player's pad at normal input sampling. - * Mod-trusted like any mod code (arbitrary native code; the setter does no - * caller check). A declined/invalid sample delivers neutral, not the previous - * held input. - * Existing TCP overrides take priority; netplay/resim and eye redraws never - * invoke the source. The runtime keeps coherent SIO type requests/recording. - * Pass NULL to detach. Local keyboard/pad buttons remain merged for menus; - * the source owns sticks and type. No source leaves faithful defaults intact. - * Source and setter are main (emulation) thread only. Sources are sampled once - * per frame, only by the normal offline sampler: not in headless mode, not in - * netplay. The source's `analog` is the pad's capability; the final type still - * goes through the multitap rule, mod mode override and presentation policy. - * A bad struct_size or out-of-range value delivers neutral and logs to stderr. */ +/* ---- External offline input: ONE ordered resolution per player ---------- + * Two optional, mod-supplied inputs can act on an offline player's pad. The + * runtime resolves them in a fixed order in pad_external_input.h: + * 1. physical/local capture (keyboard, controllers: buttons, sticks, type) + * 2. offline controller source (psx_mod_set_controller_source): buttons = + * source AND physical; sticks/type come from the source, through the + * same mode override / multitap rule / presentation policy as a physical + * pad. A declined/invalid sample delivers neutral, not the last input. + * 3. local mouse policy (psx_mod_set_local_mouse_policy), P1 only: may + * override ONLY the right analog axes of the pad resolved by 1-2. It sees + * the final buttons/analog flag/right stick, so it composes with a + * source (a source never suppresses it) and no capture is taken to be + * discarded. A deflected source right stick, a digital pad, or Start + * held disables it exactly as for a physical pad; a source release, + * no device, or an armed input guard resets (releases) the capture. + * Gating. Neither input reaches the guest under netplay or rollback resim, + * selfcheck replay, headless, or a debug-server input override (which wins and + * is never mixed with external input). The mouse policy is additionally live + * only outside selfcheck input lock/resim, render passes, rewind, the + * savestate menu and the savestate input guard (pad_ext_live). A source is + * still sampled while the input guard is armed but its output is discarded + * (neutral). With no source and no mouse policy registered, pad bytes and + * host event handling are exactly the faithful defaults. + * + * ONE registration rule for both: call only on the main (emulation) thread; + * struct arguments are validated at registration (struct_size) and a bad one + * is rejected (return 0, logged to stderr); results of each callback are + * validated per use and fall back to neutral. These are mod-trusted APIs -- + * arbitrary native code, no caller check -- not launcher settings. A source + * is per player (NULL detaches, with one neutral release frame); the mouse + * policy is one per session (a second registration returns 0). Callbacks run + * on the main thread only: sources once per frame, in the normal offline + * sampler. + * The source's `analog` is the pad capability; the final type still goes + * through the multitap rule, mod mode override and presentation policy. + */ typedef struct PSXModControllerState { uint32_t struct_size, buttons, lx, ly, rx, ry, analog; } PSXModControllerState; typedef int (*PSXModControllerSource)(PSXModControllerState *state); int psx_mod_set_controller_source(uint32_t player, PSXModControllerSource source); -/* Optional local P1 mouse policy. The runtime delivers ordered events on the - * SDL owner thread, owns relative capture and folds the resulting right-stick - * bytes after native input/presentation, before normal SIO delivery. No SDL - * type, guest address, gesture, or game setting belongs in this interface. - * This first version is inert during netplay, injected input and replay. - * Exactly one trusted activation plugin may register per session. */ + +/* 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), + * before normal SIO delivery. No SDL type, guest address, gesture, or game + * setting belongs in this interface. */ enum { PSX_MOD_MOUSE_HOLD_NONE = 0, PSX_MOD_MOUSE_HOLD_RIGHT = 1, /* Mouse3 in keybinds.ini */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 6fca3a85..04e588b2 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -356,6 +356,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/gpu_gl_renderer.c ${PSXRECOMP_ROOT}/runtime/src/psx_openxr.c ${PSXRECOMP_ROOT}/runtime/src/mod_controller_source.c + ${PSXRECOMP_ROOT}/runtime/src/pad_external_input.c ${PSXRECOMP_ROOT}/runtime/src/vr_pose_math.c ${PSXRECOMP_ROOT}/runtime/src/gpu_vk_renderer.c ${PSXRECOMP_ROOT}/runtime/src/dma_gpu_ll.c diff --git a/runtime/src/local_mouse_sdl.cpp b/runtime/src/local_mouse_sdl.cpp index a66c76be..80f2dca3 100644 --- a/runtime/src/local_mouse_sdl.cpp +++ b/runtime/src/local_mouse_sdl.cpp @@ -2,6 +2,7 @@ #include "local_mouse_policy.h" #include "psx_keybinds.h" #include "host_osd.h" +#include namespace { SDL_Window* window_ = nullptr; @@ -86,7 +87,12 @@ bool control(uint64_t t, uint64_t now, psx::MouseControl control, bool down, boo } // namespace extern "C" int psx_mod_set_local_mouse_policy(const PSXModMousePolicy* policy) { - return state().install(policy) ? 1 : 0; + if (state().install(policy)) return 1; + std::fprintf(stderr, "psxrecomp: local mouse policy rejected (null/invalid, " + "struct_size %u expected %u, or one is already registered)\n", + policy ? (unsigned)policy->struct_size : 0u, + (unsigned)sizeof(PSXModMousePolicy)); + return 0; } void psx_local_mouse_clear() { state().clear(); host_ = {}; window_ = nullptr; } bool psx_local_mouse_installed() { return state().installed(); } @@ -95,6 +101,8 @@ void psx_local_mouse_reset() { host_.connected = host_.analog = false; } void psx_local_mouse_begin(SDL_Window* window, bool live) { + // No policy: no host state to track, no per-frame global mouse polling. + if (!state().installed()) return; if (window_ && window_ != window) state().reset(); window_ = window; host_.live = live; @@ -191,6 +199,7 @@ bool psx_local_mouse_event(const SDL_Event& ev) { return false; } void psx_local_mouse_pad(bool connected, bool analog, uint16_t buttons, uint8_t& rx, uint8_t& ry) { + if (!state().installed()) return; host_.connected = connected; host_.analog = analog; host_.native_right = rx != 128 || ry != 128; diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 6cc27483..c78729b9 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -33,6 +33,7 @@ #include "psx_savestate_menu.h" #include "host_osd.h" #include "local_mouse_sdl.h" +#include "pad_external_input.h" #include "host_keymap.h" #include "png_write.h" /* png_write_rgb — present_shot readback */ #include "overlay_capture.h" @@ -5700,9 +5701,12 @@ static void apply_input_override_to_sio(int override_word) { (uint8_t)(eff_analog ? 1 : 0)); } -/* Capture one SIO slot's host pad into a netplay/local blob. Returns 1 if a - * device (or dev-any P1) is present; 0 leaves *out as released/disconnected. */ -static int capture_pad_slot(int s, PsxNetPad* out) { +/* Capture one SIO slot's PHYSICAL host pad into a netplay/local blob. Returns + * 1 if a device (or dev-any P1) is present; 0 leaves *out as released/ + * 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(). */ +static int capture_pad_slot(int s, PsxNetPad* out, bool guarded) { if (!out) return 0; out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; @@ -5714,10 +5718,7 @@ static int capture_pad_slot(int s, PsxNetPad* out) { /* Opt-in dev merge: P1 is driven by the keyboard AND every connected * controller (PSX_DEV_INPUT=1). Default is strict per-slot routing. */ const bool dev_here = (dev_any_input_enabled() && s == 0); - if (p.kind == 0 && !dev_here) { - if (s == 0) psx_local_mouse_reset(); - return 0; /* no device in this port */ - } + if (p.kind == 0 && !dev_here) return 0; /* no device in this port */ /* Resolve the pad type this frame FIRST — the effective analog/digital * state gates how the left stick is read for BOTH the button word and the @@ -5742,8 +5743,7 @@ static int capture_pad_slot(int s, PsxNetPad* out) { s, player, mode, src, p, policy_buttons, st); const int eff_analog = effective_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; - if (savestate_input_guard_active()) { - if (s == 0) psx_local_mouse_reset(); + if (guarded) { out->buttons = 0xFFFFu; out->lx = out->ly = out->rx = out->ry = 0x80u; out->analog = eff_analog ? 1u : 0u; @@ -5790,7 +5790,6 @@ static int capture_pad_slot(int s, PsxNetPad* out) { st[0] = st[1] = st[2] = st[3] = 0x80; } - if (s == 0) psx_local_mouse_pad(true, eff_analog != 0, btn, st[2], st[3]); out->buttons = btn; out->lx = st[0]; out->ly = st[1]; out->rx = st[2]; out->ry = st[3]; out->analog = eff_analog ? 1u : 0u; @@ -6309,6 +6308,80 @@ done: freeze_heartbeat_set_paused(0); } +/* Main-thread implementation of the PadExtHooks seams (see + * pad_external_input.h for the resolution order and precedence rule). */ +static int pad_ext_guard_active(void*) { return savestate_input_guard_active(); } +static int pad_ext_capture_local(void*, int s, PsxNetPad* out, int guarded) { + return capture_pad_slot(s, out, guarded != 0); +} +static int pad_ext_source_sample(void*, int s, PSXModControllerState* st, int* released) { + return mod_controller_source_sample((uint32_t)s, st, released); +} +static void pad_ext_source_release(void*, int s, PsxNetPad* pad) { + /* Source detached/reset: deliver one neutral frame (even on a port with no + * device) and give the port back its real SIO connection/config state; a + * device resumes next frame. */ + const bool dev_here = (dev_any_input_enabled() && s == 0); + const int boot_mode = assert_sio_pad_profile(s, dev_here); + pad->buttons = 0xffff; + pad->lx = pad->ly = pad->rx = pad->ry = 128; + pad->analog = pad_mode_boot_analog(boot_mode) ? 1 : 0; + pad->connected = 1; +} +static void pad_ext_source_resolve(void*, int s, const PSXModControllerState* source, + int guarded, PsxNetPad* pad) { + 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; + pad->connected = 1; + /* Presentation goes through the same resolution as a physical pad: the + * source's `analog` is the device capability, then mod mode override, + * multitap rule and presentation policy. */ + const PlayerInput& p = g_players[s]; + int mode = effective_player_mode_for_sio(p, s); + if (g_mod_controller_mode_override[s] >= 0 && + !(sio_pad_on_multitap(s) && !sio_get_multitap_analog())) + mode = g_mod_controller_mode_override[s]; + if (!source->analog) mode = PSXRecompV4::PAD_MODE_DIGITAL; + uint8_t st[4] = { pad->lx, pad->ly, pad->rx, pad->ry }; + int eff_mode = controller_policy_resolve_mode( + s, s + 1, mode, pad_sources_for(p, dev_here), p, pad->buttons, st); + if (!source->analog || + (sio_pad_on_multitap(s) && !sio_get_multitap_analog())) + eff_mode = PSXRecompV4::PAD_MODE_DIGITAL; + pad->analog = eff_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; + if (!pad->analog) + pad->lx = pad->ly = pad->rx = pad->ry = 128; + if (guarded) { + pad->buttons = 0xffff; + pad->lx = pad->ly = pad->rx = pad->ry = 128; + } + /* The source occupies the port; config capability follows the same rule + * as a physical pad (apply_pad_slot_to_sio and the SIO layer keep multitap + * taps digital). */ + const ModControllerPresentationPolicy& policy = g_mod_controller_policy[s]; + sio_set_pad_connected(s, 1); + sio_set_pad_config_capable( + s, policy.callback ? policy.config_capable + : mode != PSXRecompV4::PAD_MODE_DIGITAL); +} +static void pad_ext_mouse_reset(void*) { psx_local_mouse_reset(); } +static void pad_ext_mouse_fold(void*, int connected, int analog, uint16_t buttons, + uint8_t* rx, uint8_t* ry) { + psx_local_mouse_pad(connected != 0, analog != 0, buttons, *rx, *ry); +} +static PadExtHooks pad_ext_main_hooks(void) { + PadExtHooks h{}; + h.guard_active = pad_ext_guard_active; + h.capture_local = pad_ext_capture_local; + h.source_sample = pad_ext_source_sample; + h.source_release = pad_ext_source_release; + h.source_resolve = pad_ext_source_resolve; + h.mouse_reset = pad_ext_mouse_reset; + h.mouse_fold = pad_ext_mouse_fold; + return h; +} + static void sample_pad_into_sio(int override) { /* Selfcheck fighter mash owns P1 when enabled (headless-safe). */ if (override < 0) { @@ -6326,67 +6399,10 @@ static void sample_pad_into_sio(int override) { if (n > PSX_MAX_PLAYERS) n = PSX_MAX_PLAYERS; const uint32_t consumer_sim = psx_start_consumer_enabled() ? psx_start_consumer_offline_frame() : 0u; + PadExtHooks hooks = pad_ext_main_hooks(); for (int s = 0; s < n; s++) { PsxNetPad pad; - PSXModControllerState source; - int released = 0; - if (mod_controller_source_sample((uint32_t)s, &source, &released)) { - const bool dev_here = (dev_any_input_enabled() && s == 0); - if (released) { - /* Source detached/reset: deliver one neutral frame (even on a - * port with no device) and give the port back its real SIO - * connection/config state; a device resumes next frame. */ - const int boot_mode = assert_sio_pad_profile(s, dev_here); - pad.buttons = 0xffff; - pad.lx = pad.ly = pad.rx = pad.ry = 128; - pad.analog = pad_mode_boot_analog(boot_mode) ? 1 : 0; - pad.connected = 1; - } else { - /* Sample physical buttons separately; its axes/type cannot - * steer the source's virtual pad. Guards release every source - * channel. */ - PsxNetPad local; - const int have_local = capture_pad_slot(s, &local); - pad.buttons = (uint16_t)source.buttons; - if (have_local) pad.buttons &= local.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.connected = 1; - /* Presentation goes through the same resolution as a physical - * pad: the source's `analog` is the device capability, then - * mod mode override, multitap rule and presentation policy. */ - const PlayerInput& p = g_players[s]; - int mode = effective_player_mode_for_sio(p, s); - if (g_mod_controller_mode_override[s] >= 0 && - !(sio_pad_on_multitap(s) && !sio_get_multitap_analog())) - mode = g_mod_controller_mode_override[s]; - if (!source.analog) mode = PSXRecompV4::PAD_MODE_DIGITAL; - uint8_t st[4] = { pad.lx, pad.ly, pad.rx, pad.ry }; - int eff_mode = controller_policy_resolve_mode( - s, s + 1, mode, pad_sources_for(p, dev_here), p, - pad.buttons, st); - if (!source.analog || - (sio_pad_on_multitap(s) && !sio_get_multitap_analog())) - eff_mode = PSXRecompV4::PAD_MODE_DIGITAL; - pad.analog = eff_mode == PSXRecompV4::PAD_MODE_ANALOG ? 1 : 0; - if (!pad.analog) - pad.lx = pad.ly = pad.rx = pad.ry = 128; - if (savestate_input_guard_active()) { - pad.buttons = 0xffff; - pad.lx = pad.ly = pad.rx = pad.ry = 128; - } - /* The source occupies the port; config capability follows the - * same rule as a physical pad (apply_pad_slot_to_sio and the - * SIO layer keep multitap taps digital). */ - const ModControllerPresentationPolicy& policy = - g_mod_controller_policy[s]; - sio_set_pad_connected(s, 1); - sio_set_pad_config_capable( - s, policy.callback - ? policy.config_capable - : mode != PSXRecompV4::PAD_MODE_DIGITAL); - } - } else if (!capture_pad_slot(s, &pad)) continue; /* no device in this port */ + if (!pad_ext_resolve(&hooks, s, &pad)) continue; /* no device in this port */ /* Push sticks every frame; request the pad type (digital/analog) through * the coherent channel so a policy switch is applied only at an idle, * non-config bus boundary (never mid-poll / mid-handshake). This is the @@ -7405,16 +7421,22 @@ static bool drain_host_events() { } static bool local_mouse_live(int override_word) { - bool injected = override_word >= 0; + PadExtGate g{}; + g.injected_input = override_word >= 0; #ifndef PSX_NO_DEBUG_TOOLS uint8_t axes[4]; - injected = injected || debug_server_get_axis_override(axes) != 0; + g.injected_input = g.injected_input || debug_server_get_axis_override(axes) != 0; #endif - return !injected && !g_headless && - !psx_netplay_active() && !psx_netplay_is_resimulating() && - !psx_selfcheck_input_locked() && !psx_selfcheck_resim_active() && - !g_psx_render_pass_active && !savestate_menu_open && - !psx_rewind_is_open() && !savestate_input_guard_active(); + g.headless = g_headless; + g.netplay_active = psx_netplay_active(); + g.netplay_resim = psx_netplay_is_resimulating(); + g.selfcheck_locked = psx_selfcheck_input_locked(); + g.selfcheck_resim = psx_selfcheck_resim_active(); + g.render_pass = g_psx_render_pass_active; + g.savestate_menu_open = savestate_menu_open; + g.rewind_open = psx_rewind_is_open(); + g.input_guard = savestate_input_guard_active(); + return pad_ext_live(&g) != 0; } static NetplayVblankEpilogue sdl_vblank_present_body(void) { diff --git a/runtime/src/pad_external_input.c b/runtime/src/pad_external_input.c new file mode 100644 index 00000000..d317b639 --- /dev/null +++ b/runtime/src/pad_external_input.c @@ -0,0 +1,38 @@ +#include "pad_external_input.h" + +int pad_ext_live(const PadExtGate *g) { + return g && !g->injected_input && !g->headless && !g->netplay_active && + !g->netplay_resim && !g->selfcheck_locked && !g->selfcheck_resim && + !g->render_pass && !g->savestate_menu_open && !g->rewind_open && + !g->input_guard; +} + +int pad_ext_resolve(const PadExtHooks *h, int s, PsxNetPad *out) { + PSXModControllerState src; + int released = 0; + const int guarded = h->guard_active ? h->guard_active(h->ctx) : 0; + const int has_source = h->source_sample && + h->source_sample(h->ctx, s, &src, &released); + + if (has_source && released) { + h->source_release(h->ctx, s, out); + if (s == 0) h->mouse_reset(h->ctx); + return 1; + } + if (has_source) { + PsxNetPad local; + const int have_local = h->capture_local(h->ctx, s, &local, guarded); + out->buttons = (uint16_t)src.buttons; + if (have_local) out->buttons &= local.buttons; + h->source_resolve(h->ctx, s, &src, guarded, out); + } else if (!h->capture_local(h->ctx, s, out, guarded)) { + if (s == 0) h->mouse_reset(h->ctx); + return 0; + } + if (s == 0) { + if (guarded) h->mouse_reset(h->ctx); + else h->mouse_fold(h->ctx, out->connected, out->analog, out->buttons, + &out->rx, &out->ry); + } + return 1; +} diff --git a/runtime/src/pad_external_input.h b/runtime/src/pad_external_input.h new file mode 100644 index 00000000..293c35d6 --- /dev/null +++ b/runtime/src/pad_external_input.h @@ -0,0 +1,73 @@ +#pragma once +/* Ordered external-input resolution for one offline player's pad. + * + * (1) physical/local capture -> buttons, device presence + * (2) offline controller source (psx_mod_set_controller_source), if any: + * buttons = source & physical; sticks/type from the source, through + * the controller presentation policy (hook) + * (3) local mouse policy (psx_mod_set_local_mouse_policy), P1 only: may + * override ONLY the right analog axes of whatever (1)/(2) produced. + * + * Precedence: a source never suppresses the mouse policy. The mouse sees the + * RESOLVED pad (final buttons, final analog flag, final right stick), so a + * capture is never taken and then discarded, and an active source whose right + * stick is deflected, whose pad is digital, or whose Start is held disables + * the mouse exactly as a physical stick/digital pad/Start would. The mouse + * is reset (capture released) on a released/declined frame, no device, or an + * armed savestate input guard. + * + * Pure: every side effect goes through PadExtHooks, so the order is testable + * without SDL/SIO. Main (emulation) thread only. */ +#include +#include "mod_plugins.h" +#include "psx_netplay.h" +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct PadExtHooks { + void *ctx; + /* Input guard (savestate close/load): all external input delivers + * neutral and the mouse is reset. Queried once per resolve. */ + int (*guard_active)(void *ctx); + /* (1) Physical capture of port `s`; NO mouse side effects. Returns 1 if a + * device is present, else 0 (*out released/disconnected). With + * `guarded`, buttons/sticks are neutral but type/presence still resolve. */ + int (*capture_local)(void *ctx, int s, PsxNetPad *out, int guarded); + /* (2) Source for port `s`: returns 0 when none; else fills *state and + * *released (a one-frame neutral release after detach/reset). */ + int (*source_sample)(void *ctx, int s, PSXModControllerState *state, int *released); + /* Release frame: neutral pad + the port's real SIO state restored. */ + void (*source_release)(void *ctx, int s, PsxNetPad *out); + /* Live source frame: pad->buttons is already source&physical; fill + * sticks/analog (presentation policy, multitap rule) and claim the port. */ + void (*source_resolve)(void *ctx, int s, const PSXModControllerState *state, + int guarded, PsxNetPad *pad); + /* (3) Mouse policy. Only P1 (s == 0) is ever offered to it. */ + void (*mouse_reset)(void *ctx); + void (*mouse_fold)(void *ctx, int connected, int analog, uint16_t buttons, + uint8_t *rx, uint8_t *ry); +} PadExtHooks; + +/* Resolve one port. Returns 1 = deliver *out, 0 = no device/source here. */ +int pad_ext_resolve(const PadExtHooks *h, int s, PsxNetPad *out); + +/* Host conditions under which NO external input (mouse capture) may be live. + * Mirrors the gates every other local-only feature obeys. 1 = live. */ +typedef struct PadExtGate { + int injected_input; /* debug-server set_input / axis override */ + int headless; + int netplay_active; + int netplay_resim; /* rollback resimulation */ + int selfcheck_locked; /* selfcheck record/replay input lock */ + int selfcheck_resim; + int render_pass; /* eye / extra render pass */ + int savestate_menu_open; + int rewind_open; + int input_guard; /* savestate input guard armed */ +} PadExtGate; +int pad_ext_live(const PadExtGate *g); + +#ifdef __cplusplus +} +#endif diff --git a/runtime/tests/test_pad_external_input.c b/runtime/tests/test_pad_external_input.c new file mode 100644 index 00000000..321b5bad --- /dev/null +++ b/runtime/tests/test_pad_external_input.c @@ -0,0 +1,158 @@ +/* Resolution order of external offline input (physical -> source -> mouse). */ +#include "pad_external_input.h" +#include +#include + +#define CHECK(expr) do { if (!(expr)) { \ + fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); return 1; } } while (0) + +static struct { + int guard, have_device, have_source, released; + PsxNetPad local; PSXModControllerState src; + int mouse_override; uint8_t mouse_rx, mouse_ry; + char log[64]; int n; + int capture_calls, source_calls, resolve_calls, release_calls; + int mouse_resets, mouse_folds, last_guarded_cap, last_guarded_res; + int fold_connected, fold_analog; uint16_t fold_buttons; uint8_t fold_rx, fold_ry; + int fold_slot_calls[2]; + int src_analog_out; +} T; + +static void L(char c) { T.log[T.n++] = c; T.log[T.n] = 0; } +static int h_guard(void *c) { (void)c; L('g'); return T.guard; } +static int h_cap(void *c, int s, PsxNetPad *o, int g) { + (void)c; (void)s; L('c'); T.capture_calls++; T.last_guarded_cap = g; + if (!T.have_device) { memset(o, 0, sizeof *o); return 0; } + *o = T.local; if (g) { o->buttons = 0xffff; o->rx = o->ry = 0x80; } + return 1; +} +static int h_src(void *c, int s, PSXModControllerState *st, int *rel) { + (void)c; (void)s; L('s'); T.source_calls++; + if (!T.have_source) return 0; + *st = T.src; *rel = T.released; return 1; +} +static void h_rel(void *c, int s, PsxNetPad *o) { + (void)c; (void)s; L('R'); T.release_calls++; + o->buttons = 0xffff; o->lx = o->ly = o->rx = o->ry = 128; o->analog = 1; o->connected = 1; +} +static void h_res(void *c, int s, const PSXModControllerState *st, int g, PsxNetPad *o) { + (void)c; (void)s; L('r'); T.resolve_calls++; T.last_guarded_res = g; + o->lx = (uint8_t)st->lx; o->ly = (uint8_t)st->ly; + o->rx = (uint8_t)st->rx; o->ry = (uint8_t)st->ry; + o->analog = (uint8_t)T.src_analog_out; o->connected = 1; + if (!o->analog) o->lx = o->ly = o->rx = o->ry = 128; + if (g) { o->buttons = 0xffff; o->lx = o->ly = o->rx = o->ry = 128; } +} +static void h_mreset(void *c) { (void)c; L('x'); T.mouse_resets++; } +static void h_mfold(void *c, int con, int an, uint16_t b, uint8_t *rx, uint8_t *ry) { + (void)c; L('m'); T.mouse_folds++; + T.fold_connected = con; T.fold_analog = an; T.fold_buttons = b; + T.fold_rx = *rx; T.fold_ry = *ry; + if (T.mouse_override) { *rx = T.mouse_rx; *ry = T.mouse_ry; } +} + +static PadExtHooks hooks(void) { + PadExtHooks h; memset(&h, 0, sizeof h); + h.guard_active = h_guard; h.capture_local = h_cap; h.source_sample = h_src; + h.source_release = h_rel; h.source_resolve = h_res; + h.mouse_reset = h_mreset; h.mouse_fold = h_mfold; + return h; +} +static void reset(void) { + memset(&T, 0, sizeof T); + T.have_device = 1; T.src_analog_out = 1; + T.local.buttons = 0xfffe; /* SELECT held */ + T.local.lx = 10; T.local.ly = 20; T.local.rx = 30; T.local.ry = 40; + T.local.analog = 1; T.local.connected = 1; + T.src.struct_size = sizeof T.src; T.src.buttons = 0xfeff; + T.src.lx = 200; T.src.ly = 5; T.src.rx = 128; T.src.ry = 128; T.src.analog = 1; +} + +int main(void) { + PadExtHooks h = hooks(); + PsxNetPad o; + + /* No source, no mouse effect: physical pad passes through bit-identical. */ + reset(); + CHECK(pad_ext_resolve(&h, 0, &o) == 1); + CHECK(o.buttons == 0xfffe && o.lx == 10 && o.ly == 20 && o.rx == 30 && + o.ry == 40 && o.analog == 1 && o.connected == 1); + CHECK(T.capture_calls == 1 && T.resolve_calls == 0 && T.release_calls == 0); + CHECK(T.mouse_folds == 1 && T.fold_connected == 1 && T.fold_analog == 1 && + T.fold_buttons == 0xfffe && T.fold_rx == 30 && T.fold_ry == 40); + CHECK(strcmp(T.log, "gscm") == 0); + + /* Other ports never reach the mouse; no device: 0 and P1 resets it. */ + reset(); + CHECK(pad_ext_resolve(&h, 1, &o) == 1 && T.mouse_folds == 0 && T.mouse_resets == 0); + reset(); T.have_device = 0; + CHECK(pad_ext_resolve(&h, 1, &o) == 0 && T.mouse_resets == 0); + reset(); T.have_device = 0; + CHECK(pad_ext_resolve(&h, 0, &o) == 0 && T.mouse_resets == 1 && T.mouse_folds == 0); + + /* Mouse only: overrides the right stick, left stick/buttons untouched. */ + reset(); T.local.rx = T.local.ry = 128; T.mouse_override = 1; T.mouse_rx = 250; T.mouse_ry = 7; + CHECK(pad_ext_resolve(&h, 0, &o) == 1); + CHECK(o.rx == 250 && o.ry == 7 && o.lx == 10 && o.ly == 20 && o.buttons == 0xfffe); + + /* Source only: buttons ANDed with physical, sticks/type from the source, + * physical capture sampled exactly once, mouse offered the RESOLVED pad. */ + reset(); + T.have_source = 1; T.src.rx = 90; T.src.ry = 100; + CHECK(pad_ext_resolve(&h, 0, &o) == 1); + CHECK(o.buttons == (0xfeff & 0xfffe) && o.lx == 200 && o.ly == 5 && + o.rx == 90 && o.ry == 100 && o.analog == 1 && o.connected == 1); + CHECK(T.capture_calls == 1 && T.resolve_calls == 1); + CHECK(T.fold_buttons == (0xfeff & 0xfffe) && T.fold_analog == 1 && + T.fold_rx == 90 && T.fold_ry == 100); /* not the physical 30/40 */ + CHECK(strcmp(T.log, "gscrm") == 0); + + /* Source + mouse together: mouse overrides ONLY the right axes. */ + reset(); + T.have_source = 1; T.mouse_override = 1; T.mouse_rx = 33; T.mouse_ry = 44; + CHECK(pad_ext_resolve(&h, 0, &o) == 1); + CHECK(o.rx == 33 && o.ry == 44 && o.lx == 200 && o.ly == 5 && + o.buttons == (0xfeff & 0xfffe) && T.mouse_resets == 0); + + /* Source on a port with no physical device: source buttons as-is. */ + reset(); T.have_source = 1; T.have_device = 0; + CHECK(pad_ext_resolve(&h, 0, &o) == 1 && o.buttons == 0xfeff && o.connected == 1); + CHECK(T.mouse_folds == 1 && T.mouse_resets == 0); + + /* Digital source: sticks neutral, mouse sees analog=0. */ + reset(); T.have_source = 1; T.src_analog_out = 0; + CHECK(pad_ext_resolve(&h, 0, &o) == 1 && o.analog == 0 && o.lx == 128 && + T.fold_analog == 0); + + /* Source release frame: neutral pad, mouse released, no physical capture. */ + reset(); T.have_source = 1; T.released = 1; + CHECK(pad_ext_resolve(&h, 0, &o) == 1 && o.buttons == 0xffff && o.lx == 128); + CHECK(T.release_calls == 1 && T.capture_calls == 0 && T.resolve_calls == 0); + CHECK(T.mouse_resets == 1 && T.mouse_folds == 0); + reset(); T.have_source = 1; T.released = 1; + CHECK(pad_ext_resolve(&h, 1, &o) == 1 && T.mouse_resets == 0); + + /* Input guard: external input is neutral and the mouse is released; the + * guard is forwarded to capture (physical) and resolve (source). */ + reset(); T.guard = 1; + CHECK(pad_ext_resolve(&h, 0, &o) == 1 && T.last_guarded_cap == 1); + CHECK(o.buttons == 0xffff && o.rx == 128 && T.mouse_resets == 1 && T.mouse_folds == 0); + reset(); T.guard = 1; T.have_source = 1; + CHECK(pad_ext_resolve(&h, 0, &o) == 1 && T.last_guarded_res == 1); + CHECK(o.buttons == 0xffff && o.lx == 128 && T.mouse_resets == 1 && T.mouse_folds == 0); + + /* Gating predicate: live only when every blocking condition is clear. */ + { + PadExtGate g; memset(&g, 0, sizeof g); + CHECK(pad_ext_live(&g) == 1 && pad_ext_live(NULL) == 0); + int *f[] = { &g.injected_input, &g.headless, &g.netplay_active, + &g.netplay_resim, &g.selfcheck_locked, &g.selfcheck_resim, + &g.render_pass, &g.savestate_menu_open, &g.rewind_open, + &g.input_guard }; + for (unsigned i = 0; i < sizeof f / sizeof f[0]; i++) { + *f[i] = 1; CHECK(pad_ext_live(&g) == 0); *f[i] = 0; + } + CHECK(pad_ext_live(&g) == 1); + } + return 0; +} From c3dc3465d6ad8777fc3a33385788dfd49d64bc80 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 5 Oct 2026 13:49:07 -0700 Subject: [PATCH 6/6] Document external-input precedence and unified registration rule Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/LOCAL_MOUSE_POLICY.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/LOCAL_MOUSE_POLICY.md b/docs/LOCAL_MOUSE_POLICY.md index d43587c3..f891bbab 100644 --- a/docs/LOCAL_MOUSE_POLICY.md +++ b/docs/LOCAL_MOUSE_POLICY.md @@ -1,7 +1,8 @@ # Optional local mouse policy -The SDL-free `PSXModMousePolicy` interface provides one trusted local P1 policy. -Register it from an opt-in activation plugin. Registration copies the policy, +The SDL-free `PSXModMousePolicy` interface provides one local P1 policy. +Register it, like a controller source, from mod code on the main thread (see +`mod_plugins.h`: one registration rule, mod-trusted, no caller check). Registration copies the policy, rejects incompatible struct sizes, missing callbacks and a second policy, and returns success/failure. Existing const-input controller presentation is unchanged. The generic runtime has no game addresses or gesture recognizer. @@ -55,6 +56,22 @@ release/repress of LEFT and fresh motion. Reset retains physical LEFT down-state so holding it across an interruption cannot recapture. Debug input injection, headless, netplay, replay/resimulation and render-only passes inhibit this policy. +## Order with offline controller sources + +Per player the runtime resolves, in `pad_external_input.c`: (1) physical +capture, (2) an offline controller source (`psx_mod_set_controller_source`: +buttons = source AND physical, sticks/type from the source), (3) this mouse +policy, P1 only, which may override only the right axes of that result. A +source never suppresses the mouse policy: it sees the final buttons, analog +flag and right stick, so capture is never taken and then discarded, and a +deflected source right stick, a digital pad or Start held disable it just as a +physical pad would. A source release/decline frame, no device, or an armed +input guard resets the capture. `pad_ext_live` is the single mouse gate list +(debug override, headless, netplay/resim, selfcheck lock/resim, render pass, +savestate menu, rewind, input guard). With no policy registered +`psx_local_mouse_begin/pad` return immediately: no per-frame mouse or focus +polling. + `psx_local_mouse_clear()` unregisters the policy during existing mod/session reset. Source suppression already owed through release survives unregister, so disabling a mod cannot leak a held Escape into a native binding. There is