Merge pull request #510 from RetroPortingToolKit/feat/unified-external-input

Unified external input: controller sources and P1 mouse policy in one ordered layer
This commit is contained in:
Matt Stanley
2026-10-05 14:06:17 -07:00
committed by GitHub
18 changed files with 1740 additions and 210 deletions
+111
View File
@@ -0,0 +1,111 @@
# Optional local mouse 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.
`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
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
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
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. 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.
+17 -1
View File
@@ -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 $<$<BOOL:${PSX_SDL3}>: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.
@@ -425,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
+74 -13
View File
@@ -756,25 +756,86 @@ 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);
/* 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 */
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
+17
View File
@@ -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
+3
View File
@@ -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
@@ -354,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
+174
View File
@@ -0,0 +1,174 @@
#include "local_mouse_policy.h"
#include <algorithm>
#include <cmath>
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.
// 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,
// 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::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) ||
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
+49
View File
@@ -0,0 +1,49 @@
#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);
// 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_; }
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
+211
View File
@@ -0,0 +1,211 @@
#include "local_mouse_sdl.h"
#include "local_mouse_policy.h"
#include "psx_keybinds.h"
#include "host_osd.h"
#include <cstdio>
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<int32_t>(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_);
return (flags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED)) == 0;
}
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) {
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(); }
void psx_local_mouse_reset() {
state().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;
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 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_down = (SDL_GetGlobalMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0;
state().sync_left(now_ns(), left_down);
#else
if (host_.focused)
state().sync_left(now_ns(), (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON(SDL_BUTTON_LEFT)) != 0);
#endif
// 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) {
if (!state().installed()) return;
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);
}
+11
View File
@@ -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);
+265 -187
View File
@@ -32,6 +32,8 @@
#include "psx_rewind.h"
#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"
@@ -1054,6 +1056,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 +1560,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 +3714,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 +5088,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 +5589,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;
@@ -5694,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;
@@ -5733,7 +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 (guarded) {
out->buttons = 0xFFFFu;
out->lx = out->ly = out->rx = out->ry = 0x80u;
out->analog = eff_analog ? 1u : 0u;
@@ -6298,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) {
@@ -6306,6 +6390,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;
}
@@ -6314,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
@@ -6844,6 +6872,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 +7036,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 +7144,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 +7191,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 +7290,154 @@ 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) {
PadExtGate g{};
g.injected_input = override_word >= 0;
#ifndef PSX_NO_DEBUG_TOOLS
uint8_t axes[4];
g.injected_input = g.injected_input || debug_server_get_axis_override(axes) != 0;
#endif
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) {
NetplayVblankEpilogue ep{};
@@ -7288,6 +7473,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 +7596,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 +7837,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 +7970,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();
}
+38
View File
@@ -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;
}
+73
View File
@@ -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 <stdint.h>
#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
+35 -8
View File
@@ -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) {
+85
View File
@@ -1,8 +1,15 @@
#include "psx_keybinds.h"
#include "host_keymap.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#ifdef _WIN32
#include <direct.h>
#else
#include <sys/stat.h>
#include <unistd.h>
#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;
+270
View File
@@ -0,0 +1,270 @@
#include "local_mouse_policy.h"
#include <cstdio>
#include <initializer_list>
#include <limits>
#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<double>::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;
// 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();
}
// 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());
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;
}
@@ -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 <stdio.h>
#include <string.h>
#ifdef _WIN32
#include <direct.h>
#else
#include <sys/stat.h>
#include <unistd.h>
#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;
}
+158
View File
@@ -0,0 +1,158 @@
/* Resolution order of external offline input (physical -> source -> mouse). */
#include "pad_external_input.h"
#include <stdio.h>
#include <string.h>
#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;
}
+60 -1
View File
@@ -30,11 +30,17 @@
#include "sio.h"
#include "memcard.h"
#include "psx_netplay.h"
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <direct.h>
#else
#include <sys/stat.h>
#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();