From cd9cb8c48b8f3f3c40cf548bc327fcbb7ea820f6 Mon Sep 17 00:00:00 2001 From: Beman Date: Sun, 4 Oct 2026 23:57:16 +1000 Subject: [PATCH] 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 | 39 +++ 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 | 285 +++++++++++------- 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, 1203 insertions(+), 124 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 2bfaae4d..b8e1a215 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -23,6 +23,10 @@ psxrecomp_add_runtime_target(psx-runtime # (docs/ENHANCEMENTS.md G1.10). Titles get their _pgxp the same way. if(BUILD_TESTING) + 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 @@ -345,7 +349,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 @@ -354,6 +358,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 043b6e46..91071774 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -586,6 +586,45 @@ int psx_mod_set_controller_presentation_policy( uint32_t initial_mode, int config_capable); +/* 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. * The registry itself uses function-local initialization, so constructor order 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 6df1d524..83cd48a4 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -340,6 +340,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 6bee3760..0186ab15 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -29,6 +29,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" @@ -1042,6 +1043,7 @@ extern "C" void psx_frontend_on_savestate_notify(int is_load, int slot, int ok) 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(); @@ -1539,6 +1541,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(); g_mod_texfilter = -1; gr_set_texture_filter(g_video_texfilter); PSXModSessionScalars live; @@ -3646,6 +3649,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(); @@ -4999,6 +5003,7 @@ static int effective_player_mode_for_sio(const PlayerInput& p, int sio_slot) { * 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]; @@ -5512,6 +5517,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; @@ -5637,7 +5643,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 @@ -5663,6 +5672,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; @@ -5709,6 +5719,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; @@ -6235,6 +6246,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; } @@ -6715,6 +6727,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) { @@ -6878,8 +6891,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; } @@ -6984,10 +6999,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(); @@ -7028,10 +7046,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(); @@ -7084,6 +7105,138 @@ struct NetplayVblankEpilogue { }; /* Called from gpu_vblank_tick() at each simulated vblank. */ +// 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_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{}; /* Guest quantum for this vblank is complete. Drop top-level-resume armed @@ -7118,6 +7271,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; @@ -7240,116 +7394,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_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(); @@ -7566,8 +7612,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(); @@ -7697,7 +7745,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();