use sdl3_sys::everything::*;
use std::ffi::CStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
#[derive(Clone)]
#[allow(dead_code)] pub struct InputAction {
pub setting_id: &'static str,
pub label: &'static str,
pub default_scancode: SDL_Scancode,
pub essential: bool,
pub keyboard: Option<CapturedInput>,
pub joystick: Option<CapturedInput>,
}
#[derive(Clone)]
pub enum CapturedInput {
Keyboard {
scancode: SDL_Scancode,
name: String,
},
JoystickButton {
device_id: String,
button: u8,
name: String,
},
#[allow(dead_code)]
JoystickAxis {
device_id: String,
axis: u8,
name: String,
},
}
impl CapturedInput {
pub fn to_mapping_string(&self) -> String {
match self {
Self::Keyboard { scancode, .. } => {
format!("Key;{}", scancode.0)
}
Self::JoystickButton {
device_id, button, ..
} => {
format!("{device_id};{button}")
}
Self::JoystickAxis {
device_id, axis, ..
} => {
format!("{device_id};{axis}")
}
}
}
pub fn display_name(&self) -> &str {
match self {
Self::Keyboard { name, .. }
| Self::JoystickButton { name, .. }
| Self::JoystickAxis { name, .. } => name,
}
}
}
#[derive(Clone)]
pub enum JoystickEvent {
ButtonDown {
device_id: String,
button: u8,
name: String,
},
ButtonUp {
#[allow(dead_code)] device_id: String,
button: u8,
},
#[allow(dead_code)]
AxisMotion {
device_id: String,
axis: u8,
name: String,
},
AccelUpdate { x: f32, y: f32 },
PinscapeDetected { vpx_id: String },
DudesCabDetected { vpx_id: String },
PinOneDetected { vpx_id: String },
GamepadDetected { vpx_id: String, name: String },
}
unsafe fn vpx_device_id(joy: *mut sdl3_sys::everything::SDL_Joystick) -> String {
unsafe {
let guid = SDL_GetJoystickGUID(joy);
let mut buf = [0u8; 64];
SDL_GUIDToString(guid, buf.as_mut_ptr() as *mut _, buf.len() as i32);
let guid_str = CStr::from_ptr(buf.as_ptr() as *const _).to_string_lossy();
let mut id_index: u32 = 1;
let mut count: i32 = 0;
let ids = SDL_GetJoysticks(&mut count);
if !ids.is_null() && count > 0 {
for i in 0..count as usize {
let jid = *ids.add(i);
if SDL_GetJoystickFromID(jid) == joy {
break;
}
let other = SDL_GetJoystickGUIDForID(jid);
if other == guid {
id_index += 1;
}
}
SDL_free(ids as *mut _);
}
format!("SDLJoy_{guid_str}_{id_index}")
}
}
pub fn scancode_name(scancode: SDL_Scancode) -> String {
let raw = unsafe {
let keycode = SDL_GetKeyFromScancode(scancode, SDL_KMOD_NONE, false);
let mut out = String::new();
if keycode.0 != 0 {
let name_ptr = SDL_GetKeyName(keycode);
if !name_ptr.is_null() {
let s = CStr::from_ptr(name_ptr).to_string_lossy().into_owned();
if !s.is_empty() {
out = s;
}
}
}
if out.is_empty() {
let name_ptr = SDL_GetScancodeName(scancode);
if !name_ptr.is_null() {
let s = CStr::from_ptr(name_ptr).to_string_lossy().into_owned();
if !s.is_empty() {
out = s;
}
}
}
out
};
if raw.is_empty() {
return format!("Key {}", scancode.0);
}
localize_key_name(&raw)
}
fn localize_key_name(raw: &str) -> String {
let Some(keycode) = sdl_keybridge::keycode_from_name(raw) else {
return raw.to_string();
};
let Some(scancode) = sdl_keybridge::scancode_for(keycode, "windows/en-t-k0-windows") else {
return raw.to_string();
};
let locale = rust_i18n::locale();
let resolved = sdl_keybridge::resolve(
scancode,
sdl_keybridge::KeyMod(0),
"windows/en-t-k0-windows",
&locale,
sdl_keybridge::LabelStyle::Textual,
&sdl_keybridge::MultiLocalizer::new(),
);
if resolved.glyph_local.is_empty() {
raw.to_string()
} else {
resolved.glyph_local.into_owned()
}
}
pub fn egui_key_to_scancode(key: egui::Key) -> Option<SDL_Scancode> {
use egui::Key;
let sc = match key {
Key::A => SDL_SCANCODE_A,
Key::B => SDL_SCANCODE_B,
Key::C => SDL_SCANCODE_C,
Key::D => SDL_SCANCODE_D,
Key::E => SDL_SCANCODE_E,
Key::F => SDL_SCANCODE_F,
Key::G => SDL_SCANCODE_G,
Key::H => SDL_SCANCODE_H,
Key::I => SDL_SCANCODE_I,
Key::J => SDL_SCANCODE_J,
Key::K => SDL_SCANCODE_K,
Key::L => SDL_SCANCODE_L,
Key::M => SDL_SCANCODE_M,
Key::N => SDL_SCANCODE_N,
Key::O => SDL_SCANCODE_O,
Key::P => SDL_SCANCODE_P,
Key::Q => SDL_SCANCODE_Q,
Key::R => SDL_SCANCODE_R,
Key::S => SDL_SCANCODE_S,
Key::T => SDL_SCANCODE_T,
Key::U => SDL_SCANCODE_U,
Key::V => SDL_SCANCODE_V,
Key::W => SDL_SCANCODE_W,
Key::X => SDL_SCANCODE_X,
Key::Y => SDL_SCANCODE_Y,
Key::Z => SDL_SCANCODE_Z,
Key::Num0 => SDL_SCANCODE_0,
Key::Num1 => SDL_SCANCODE_1,
Key::Num2 => SDL_SCANCODE_2,
Key::Num3 => SDL_SCANCODE_3,
Key::Num4 => SDL_SCANCODE_4,
Key::Num5 => SDL_SCANCODE_5,
Key::Num6 => SDL_SCANCODE_6,
Key::Num7 => SDL_SCANCODE_7,
Key::Num8 => SDL_SCANCODE_8,
Key::Num9 => SDL_SCANCODE_9,
Key::Escape => SDL_SCANCODE_ESCAPE,
Key::Tab => SDL_SCANCODE_TAB,
Key::Space => SDL_SCANCODE_SPACE,
Key::Enter => SDL_SCANCODE_RETURN,
Key::Backspace => SDL_SCANCODE_BACKSPACE,
Key::Delete => SDL_SCANCODE_DELETE,
Key::Home => SDL_SCANCODE_HOME,
Key::End => SDL_SCANCODE_END,
Key::PageUp => SDL_SCANCODE_PAGEUP,
Key::PageDown => SDL_SCANCODE_PAGEDOWN,
Key::ArrowUp => SDL_SCANCODE_UP,
Key::ArrowDown => SDL_SCANCODE_DOWN,
Key::ArrowLeft => SDL_SCANCODE_LEFT,
Key::ArrowRight => SDL_SCANCODE_RIGHT,
Key::F1 => SDL_SCANCODE_F1,
Key::F2 => SDL_SCANCODE_F2,
Key::F3 => SDL_SCANCODE_F3,
Key::F4 => SDL_SCANCODE_F4,
Key::F5 => SDL_SCANCODE_F5,
Key::F6 => SDL_SCANCODE_F6,
Key::F7 => SDL_SCANCODE_F7,
Key::F8 => SDL_SCANCODE_F8,
Key::F9 => SDL_SCANCODE_F9,
Key::F10 => SDL_SCANCODE_F10,
Key::F11 => SDL_SCANCODE_F11,
Key::F12 => SDL_SCANCODE_F12,
Key::Minus => SDL_SCANCODE_MINUS,
Key::Equals => SDL_SCANCODE_EQUALS,
Key::Comma => SDL_SCANCODE_COMMA,
Key::Period => SDL_SCANCODE_PERIOD,
Key::Slash => SDL_SCANCODE_SLASH,
Key::Semicolon => SDL_SCANCODE_SEMICOLON,
Key::Quote => SDL_SCANCODE_APOSTROPHE,
Key::Backslash => SDL_SCANCODE_BACKSLASH,
Key::Backtick => SDL_SCANCODE_GRAVE,
Key::OpenBracket => SDL_SCANCODE_LEFTBRACKET,
Key::CloseBracket => SDL_SCANCODE_RIGHTBRACKET,
Key::Exclamationmark => SDL_SCANCODE_1,
Key::Questionmark => SDL_SCANCODE_SLASH,
Key::Pipe => SDL_SCANCODE_BACKSLASH,
Key::Colon => SDL_SCANCODE_SEMICOLON,
Key::Plus => SDL_SCANCODE_EQUALS,
Key::OpenCurlyBracket => SDL_SCANCODE_LEFTBRACKET,
Key::CloseCurlyBracket => SDL_SCANCODE_RIGHTBRACKET,
Key::ShiftLeft => SDL_SCANCODE_LSHIFT,
Key::ShiftRight => SDL_SCANCODE_RSHIFT,
Key::ControlLeft => SDL_SCANCODE_LCTRL,
Key::ControlRight => SDL_SCANCODE_RCTRL,
Key::AltLeft => SDL_SCANCODE_LALT,
Key::AltRight => SDL_SCANCODE_RALT,
Key::SuperLeft => SDL_SCANCODE_LGUI,
Key::SuperRight => SDL_SCANCODE_RGUI,
Key::IntlBackslash => SDL_SCANCODE_NONUSBACKSLASH,
_ => return None,
};
Some(sc)
}
pub fn egui_modifiers_to_scancode(modifiers: &egui::Modifiers) -> Option<SDL_Scancode> {
if modifiers.shift && !modifiers.ctrl && !modifiers.alt {
Some(SDL_SCANCODE_LSHIFT)
} else if modifiers.ctrl && !modifiers.shift && !modifiers.alt {
Some(SDL_SCANCODE_LCTRL)
} else if modifiers.alt && !modifiers.shift && !modifiers.ctrl {
Some(SDL_SCANCODE_LALT)
} else {
None
}
}
pub fn default_actions() -> Vec<InputAction> {
vec![
InputAction {
setting_id: "LeftFlipper",
label: "input_left_flipper",
default_scancode: SDL_SCANCODE_LSHIFT,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "RightFlipper",
label: "input_right_flipper",
default_scancode: SDL_SCANCODE_RSHIFT,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "LeftStagedFlipper",
label: "input_left_staged_flipper",
default_scancode: SDL_SCANCODE_LSHIFT,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "RightStagedFlipper",
label: "input_right_staged_flipper",
default_scancode: SDL_SCANCODE_RSHIFT,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "LeftMagna",
label: "input_left_magna",
default_scancode: SDL_SCANCODE_LCTRL,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "RightMagna",
label: "input_right_magna",
default_scancode: SDL_SCANCODE_RCTRL,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Lockbar",
label: "input_lockbar",
default_scancode: SDL_SCANCODE_LALT,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "ExtraBall",
label: "input_extra_ball",
default_scancode: SDL_SCANCODE_B,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "LaunchBall",
label: "input_launch_ball",
default_scancode: SDL_SCANCODE_RETURN,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Start",
label: "input_start",
default_scancode: SDL_SCANCODE_1,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Credit1",
label: "input_add_credit",
default_scancode: SDL_SCANCODE_5,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "ExitGame",
label: "input_exit_game",
default_scancode: SDL_SCANCODE_ESCAPE,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Credit2",
label: "input_credit_2",
default_scancode: SDL_SCANCODE_4,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Credit3",
label: "input_credit_3",
default_scancode: SDL_SCANCODE_3,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Credit4",
label: "input_credit_4",
default_scancode: SDL_SCANCODE_6,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "CoinDoor",
label: "input_coin_door",
default_scancode: SDL_SCANCODE_END,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "SlamTilt",
label: "input_slam_tilt",
default_scancode: SDL_SCANCODE_HOME,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Reset",
label: "input_reset",
default_scancode: SDL_SCANCODE_F3,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service1",
label: "input_service1",
default_scancode: SDL_SCANCODE_7,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service2",
label: "input_service2",
default_scancode: SDL_SCANCODE_8,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service3",
label: "input_service3",
default_scancode: SDL_SCANCODE_9,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service4",
label: "input_service4",
default_scancode: SDL_SCANCODE_0,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service5",
label: "input_service5",
default_scancode: SDL_SCANCODE_6,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service6",
label: "input_service6",
default_scancode: SDL_SCANCODE_PAGEUP,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service7",
label: "input_service7",
default_scancode: SDL_SCANCODE_MINUS,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Service8",
label: "input_service8",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "LeftNudge",
label: "input_left_nudge",
default_scancode: SDL_SCANCODE_Z,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "RightNudge",
label: "input_right_nudge",
default_scancode: SDL_SCANCODE_SLASH,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "CenterNudge",
label: "input_center_nudge",
default_scancode: SDL_SCANCODE_SPACE,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Tilt",
label: "input_tilt",
default_scancode: SDL_SCANCODE_T,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Pause",
label: "input_pause",
default_scancode: SDL_SCANCODE_P,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "VolumeDown",
label: "input_volume_down",
default_scancode: SDL_SCANCODE_MINUS,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "VolumeUp",
label: "input_volume_up",
default_scancode: SDL_SCANCODE_EQUALS,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Custom1",
label: "input_custom1",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Custom2",
label: "input_custom2",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Custom3",
label: "input_custom3",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "Custom4",
label: "input_custom4",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
]
}
pub const PINSCAPE_PROFILES: &[&str] = &[
"KL25Z (KL Shield / Brain / Rig Master)",
"Pico (OpenPinballDevice)",
"DudesCab (Arnoz)",
"CSD PinOne",
];
pub const PINSCAPE_PROFILE_NONE: usize = PINSCAPE_PROFILES.len();
pub fn pinscape_button_defaults(profile: usize) -> &'static [(&'static str, u8)] {
match profile {
0 => &[
("Start", 0),
("ExtraBall", 1),
("Credit1", 2),
("Credit2", 3),
("LaunchBall", 4),
("ExitGame", 5),
("LeftFlipper", 7),
("RightFlipper", 8),
("LeftMagna", 9),
("RightMagna", 10),
("LeftStagedFlipper", 7), ("RightStagedFlipper", 8), ("Lockbar", 11),
("CoinDoor", 13),
("Service1", 14),
("Service2", 15),
("Service3", 16),
("Service4", 17),
("VolumeDown", 19),
("VolumeUp", 20),
],
1 => &[
("Start", 0),
("ExitGame", 1),
("ExtraBall", 2),
("Credit1", 3),
("Credit2", 4),
("Credit3", 5),
("Credit4", 6),
("LaunchBall", 7),
("Lockbar", 8),
("LeftFlipper", 9),
("RightFlipper", 10),
("LeftStagedFlipper", 11),
("RightStagedFlipper", 12),
("LeftMagna", 13),
("RightMagna", 14),
("SlamTilt", 16),
("CoinDoor", 17),
("Service1", 18),
("Service2", 19),
("Service3", 20),
("Service4", 21),
("LeftNudge", 22),
("CenterNudge", 23),
("RightNudge", 24),
("VolumeUp", 25),
("VolumeDown", 26),
],
2 => &[
("Start", 0), ("ExtraBall", 1), ("Credit1", 2), ("Credit2", 3), ("LaunchBall", 4), ("ExitGame", 5), ("LeftFlipper", 7), ("RightFlipper", 8), ("LeftMagna", 9), ("RightMagna", 10), ("LeftStagedFlipper", 7), ("RightStagedFlipper", 8), ("Lockbar", 12), ("CoinDoor", 13), ("Service1", 14), ("Service2", 15), ("Service3", 16), ("Service4", 17), ("VolumeDown", 18), ("VolumeUp", 19), ],
3 => &[
("RightFlipper", 0), ("RightMagna", 1), ("LeftFlipper", 2), ("LeftMagna", 3), ("ExtraBall", 4), ("Start", 5), ("Credit1", 6), ("ExitGame", 7), ("Lockbar", 8), ("VolumeUp", 15), ("VolumeDown", 16), ("CoinDoor", 17), ("Service1", 18), ("Service2", 19), ("Service3", 20), ("Service4", 21), ("LaunchBall", 23), ],
_ => &[],
}
}
const INTENTIONAL_COLLISIONS: &[(&str, &str)] = &[
("LeftFlipper", "LeftStagedFlipper"),
("RightFlipper", "RightStagedFlipper"),
];
fn is_intentional_collision(a: &str, b: &str) -> bool {
INTENTIONAL_COLLISIONS
.iter()
.any(|&(x, y)| (a == x && b == y) || (a == y && b == x))
}
pub fn find_conflicts(actions: &[InputAction]) -> Vec<(usize, usize)> {
let mut conflicts = Vec::new();
for i in 0..actions.len() {
for j in (i + 1)..actions.len() {
if is_intentional_collision(actions[i].setting_id, actions[j].setting_id) {
continue;
}
let a = effective_scancode(&actions[i]);
let b = effective_scancode(&actions[j]);
let keyboard_clash = a != SDL_SCANCODE_UNKNOWN && a == b;
let joystick_clash = match (&actions[i].joystick, &actions[j].joystick) {
(
Some(CapturedInput::JoystickButton {
device_id: da,
button: ba,
..
}),
Some(CapturedInput::JoystickButton {
device_id: db,
button: bb,
..
}),
) => da == db && ba == bb,
_ => false,
};
if keyboard_clash || joystick_clash {
conflicts.push((i, j));
}
}
}
conflicts
}
pub(crate) fn effective_scancode(action: &InputAction) -> SDL_Scancode {
match &action.keyboard {
Some(CapturedInput::Keyboard { scancode, .. }) => *scancode,
None => action.default_scancode,
_ => SDL_SCANCODE_UNKNOWN,
}
}
struct OpenedJoystick {
id: SDL_JoystickID,
handle: *mut sdl3_sys::everything::SDL_Joystick,
vpx_id: String,
num_buttons: i32,
num_axes: i32,
prev_buttons: Vec<bool>,
button_names: Vec<String>,
axis_names: Vec<String>,
}
fn normalize_axis(raw: i16) -> f32 {
const REST_EPSILON: i16 = 196; if raw.abs() <= REST_EPSILON {
0.0
} else {
raw as f32 / 32767.0
}
}
const JOYSTICK_RESCAN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
unsafe fn open_joystick(
jid: SDL_JoystickID,
evt_tx: &crossbeam_channel::Sender<JoystickEvent>,
) -> Option<OpenedJoystick> {
unsafe {
let joy = SDL_OpenJoystick(jid);
if joy.is_null() {
log::warn!(
"Could not open joystick {} ({}): {}",
jid.0,
name_or_placeholder(SDL_GetJoystickNameForID(jid), jid),
CStr::from_ptr(SDL_GetError()).to_string_lossy()
);
return None;
}
let name = name_or_placeholder(SDL_GetJoystickName(joy), jid);
let dev_id = vpx_device_id(joy);
let num_buttons = SDL_GetNumJoystickButtons(joy);
let num_axes = SDL_GetNumJoystickAxes(joy);
log::info!(
"Opened joystick: {} (vpx_id={}, buttons={}, axes={})",
name,
dev_id,
num_buttons,
num_axes
);
if num_buttons < 0 || num_axes < 0 {
log::warn!(
"Skipping joystick {}: SDL returned buttons={} axes={} (probable driver failure): {}",
dev_id,
num_buttons,
num_axes,
CStr::from_ptr(SDL_GetError()).to_string_lossy()
);
SDL_CloseJoystick(joy);
return None;
}
if name.contains("Pinscape") {
log::info!("Pinscape controller detected: {}", dev_id);
let _ = evt_tx.send(JoystickEvent::PinscapeDetected {
vpx_id: dev_id.clone(),
});
} else if name.contains("DudesCab") {
log::info!("DudesCab controller detected: {}", dev_id);
let _ = evt_tx.send(JoystickEvent::DudesCabDetected {
vpx_id: dev_id.clone(),
});
} else if name.contains("PinOne") {
log::info!("CSD PinOne controller detected: {}", dev_id);
let _ = evt_tx.send(JoystickEvent::PinOneDetected {
vpx_id: dev_id.clone(),
});
} else if SDL_IsGamepad(jid) {
log::info!("Gamepad detected: {} ({})", name, dev_id);
let _ = evt_tx.send(JoystickEvent::GamepadDetected {
vpx_id: dev_id.clone(),
name: name.clone(),
});
}
let button_names: Vec<String> = (0..num_buttons)
.map(|b| format!("{} Button {}", dev_id, b))
.collect();
let axis_names: Vec<String> = (0..num_axes)
.map(|a| format!("{} Axis {}", dev_id, a))
.collect();
Some(OpenedJoystick {
id: jid,
handle: joy,
vpx_id: dev_id,
num_buttons,
num_axes,
prev_buttons: vec![false; num_buttons as usize],
button_names,
axis_names,
})
}
}
unsafe fn name_or_placeholder(ptr: *const core::ffi::c_char, jid: SDL_JoystickID) -> String {
if ptr.is_null() {
format!("Joystick {}", jid.0)
} else {
unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned()
}
}
unsafe fn listed_joysticks() -> Vec<SDL_JoystickID> {
unsafe {
let mut count: i32 = 0;
let ids = SDL_GetJoysticks(&mut count);
if ids.is_null() {
return Vec::new();
}
let count = count.max(0) as usize;
let listed = (0..count).map(|i| *ids.add(i)).collect();
SDL_free(ids as *mut _);
listed
}
}
fn joysticks_to_open<Id: Copy + PartialEq>(listed: &[Id], opened: &[Id], failed: &[Id]) -> Vec<Id> {
listed
.iter()
.copied()
.filter(|id| !opened.contains(id) && !failed.contains(id))
.collect()
}
pub fn spawn_joystick_thread() -> (
crossbeam_channel::Receiver<JoystickEvent>,
Arc<AtomicBool>,
thread::JoinHandle<()>,
) {
let (evt_tx, evt_rx) = crossbeam_channel::unbounded::<JoystickEvent>();
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
unsafe {
SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, c"1".as_ptr());
}
#[cfg(target_os = "windows")]
unsafe {
if !SDL_InitSubSystem(SDL_INIT_JOYSTICK) {
log::error!(
"SDL_InitSubSystem(JOYSTICK) failed on the UI thread: {:?}",
CStr::from_ptr(SDL_GetError())
);
}
}
let handle = thread::spawn(move || {
unsafe {
if !SDL_InitSubSystem(SDL_INIT_JOYSTICK) {
log::error!(
"Joystick thread: SDL_InitSubSystem failed: {:?}",
CStr::from_ptr(SDL_GetError())
);
return;
}
let listed = listed_joysticks();
let mut joysticks: Vec<OpenedJoystick> = Vec::with_capacity(listed.len());
let mut failed: Vec<SDL_JoystickID> = Vec::new();
for jid in &listed {
match open_joystick(*jid, &evt_tx) {
Some(js) => joysticks.push(js),
None => failed.push(*jid),
}
}
log::info!(
"Joystick thread started, {} joystick(s) found ({} listed by SDL)",
joysticks.len(),
listed.len()
);
let mut next_rescan = std::time::Instant::now() + JOYSTICK_RESCAN_INTERVAL;
while running_thread.load(Ordering::Relaxed) {
SDL_UpdateJoysticks();
if std::time::Instant::now() >= next_rescan {
next_rescan = std::time::Instant::now() + JOYSTICK_RESCAN_INTERVAL;
joysticks.retain(|js| {
let connected = SDL_JoystickConnected(js.handle);
if !connected {
log::info!("Joystick disconnected: {}", js.vpx_id);
SDL_CloseJoystick(js.handle);
}
connected
});
let opened: Vec<SDL_JoystickID> = joysticks.iter().map(|js| js.id).collect();
for jid in joysticks_to_open(&listed_joysticks(), &opened, &failed) {
match open_joystick(jid, &evt_tx) {
Some(js) => joysticks.push(js),
None => failed.push(jid),
}
}
}
for js in &mut joysticks {
for b in 0..js.num_buttons {
let pressed = SDL_GetJoystickButton(js.handle, b);
let idx = b as usize;
let was_pressed = js.prev_buttons[idx];
if pressed && !was_pressed {
let _ = evt_tx.send(JoystickEvent::ButtonDown {
device_id: js.vpx_id.clone(),
button: b as u8,
name: js.button_names[idx].clone(),
});
} else if !pressed && was_pressed {
let _ = evt_tx.send(JoystickEvent::ButtonUp {
device_id: js.vpx_id.clone(),
button: b as u8,
});
}
js.prev_buttons[idx] = pressed;
}
for a in 0..js.num_axes {
let value = SDL_GetJoystickAxis(js.handle, a);
if value.abs() > 16000 {
let _ = evt_tx.send(JoystickEvent::AxisMotion {
device_id: js.vpx_id.clone(),
axis: a as u8,
name: js.axis_names[a as usize].clone(),
});
}
}
if js.num_axes >= 2 {
let ax = normalize_axis(SDL_GetJoystickAxis(js.handle, 0));
let ay = normalize_axis(SDL_GetJoystickAxis(js.handle, 1));
let _ = evt_tx.send(JoystickEvent::AccelUpdate { x: ax, y: ay });
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let _ = joysticks; log::info!("Joystick thread: exited polling loop");
}
});
(evt_rx, running, handle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joysticks_to_open_skips_open_and_failed_pads() {
assert_eq!(joysticks_to_open(&[1u32, 2, 3, 4], &[1], &[3]), vec![2, 4]);
}
#[test]
fn joysticks_to_open_reopens_a_pad_listed_again_after_vpx() {
assert_eq!(joysticks_to_open(&[7u32], &[], &[]), vec![7]);
assert!(joysticks_to_open::<u32>(&[], &[], &[]).is_empty());
}
#[test]
fn keyboard_mapping_string() {
let input = CapturedInput::Keyboard {
scancode: SDL_SCANCODE_LSHIFT,
name: "Left Shift".to_string(),
};
assert_eq!(
input.to_mapping_string(),
format!("Key;{}", SDL_SCANCODE_LSHIFT.0)
);
}
#[test]
fn joystick_button_mapping_string() {
let input = CapturedInput::JoystickButton {
device_id: "SDLJoy_PSC004".to_string(),
button: 7,
name: "Button 7".to_string(),
};
assert_eq!(input.to_mapping_string(), "SDLJoy_PSC004;7");
}
#[test]
fn axis_rest_quantisation_reads_as_centred() {
assert_eq!(normalize_axis(128), 0.0);
assert_eq!(normalize_axis(-128), 0.0);
assert_eq!(normalize_axis(0), 0.0);
assert!(normalize_axis(16000) > 0.48);
assert!((normalize_axis(32767) - 1.0).abs() < 1e-6);
assert!(normalize_axis(-16000) < -0.48);
}
#[test]
fn joystick_axis_mapping_string() {
let input = CapturedInput::JoystickAxis {
device_id: "SDLJoy_ABC".to_string(),
axis: 2,
name: "Axis 2".to_string(),
};
assert_eq!(input.to_mapping_string(), "SDLJoy_ABC;2");
}
#[test]
fn display_name_keyboard() {
let input = CapturedInput::Keyboard {
scancode: SDL_SCANCODE_A,
name: "A".to_string(),
};
assert_eq!(input.display_name(), "A");
}
#[test]
fn display_name_joystick() {
let input = CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 0,
name: "Start Button".to_string(),
};
assert_eq!(input.display_name(), "Start Button");
}
#[test]
fn default_actions_has_essential_and_advanced() {
let actions = default_actions();
assert!(!actions.is_empty());
let essential_count = actions.iter().filter(|a| a.essential).count();
let advanced_count = actions.iter().filter(|a| !a.essential).count();
assert!(
essential_count >= 10,
"expected >=10 essential actions, got {essential_count}"
);
assert!(
advanced_count >= 10,
"expected >=10 advanced actions, got {advanced_count}"
);
}
#[test]
fn default_actions_all_have_setting_id() {
for action in default_actions() {
assert!(!action.setting_id.is_empty(), "action has empty setting_id");
}
}
#[test]
fn default_actions_none_have_mapping() {
for action in default_actions() {
assert!(
action.keyboard.is_none() && action.joystick.is_none(),
"{} should have no mapping by default",
action.setting_id
);
}
}
#[test]
fn effective_scancode_default() {
let action = InputAction {
setting_id: "Test",
label: "test",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: None,
};
assert!(effective_scancode(&action) == SDL_SCANCODE_A);
}
#[test]
fn effective_scancode_with_keyboard_mapping() {
let action = InputAction {
setting_id: "Test",
label: "test",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: Some(CapturedInput::Keyboard {
scancode: SDL_SCANCODE_B,
name: "B".to_string(),
}),
joystick: None,
};
assert!(effective_scancode(&action) == SDL_SCANCODE_B);
}
#[test]
fn effective_scancode_ignores_joystick_slot() {
let action = InputAction {
setting_id: "Test",
label: "test",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: Some(CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 0,
name: "B0".to_string(),
}),
};
assert!(effective_scancode(&action) == SDL_SCANCODE_A);
}
#[test]
fn find_conflicts_no_conflicts() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_B,
essential: true,
keyboard: None,
joystick: None,
},
];
assert!(find_conflicts(&actions).is_empty());
}
#[test]
fn find_conflicts_detects_default_conflict() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_LSHIFT,
essential: true,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_LSHIFT,
essential: true,
keyboard: None,
joystick: None,
},
];
let conflicts = find_conflicts(&actions);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0], (0, 1));
}
#[test]
fn find_conflicts_unknown_scancode_not_conflicting() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_UNKNOWN,
essential: false,
keyboard: None,
joystick: None,
},
];
assert!(find_conflicts(&actions).is_empty());
}
#[test]
fn find_conflicts_with_mapped_override() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: Some(CapturedInput::Keyboard {
scancode: SDL_SCANCODE_Z,
name: "Z".to_string(),
}),
joystick: None,
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_Z,
essential: true,
keyboard: None,
joystick: None,
},
];
let conflicts = find_conflicts(&actions);
assert_eq!(conflicts.len(), 1);
}
#[test]
fn find_conflicts_keyboard_default_stays_active_alongside_joystick() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: Some(CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 0,
name: "B0".to_string(),
}),
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: None,
},
];
assert_eq!(find_conflicts(&actions).len(), 1);
}
#[test]
fn find_conflicts_detects_same_joystick_button() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: Some(CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 3,
name: "B3".to_string(),
}),
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_B,
essential: true,
keyboard: None,
joystick: Some(CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 3,
name: "B3".to_string(),
}),
},
];
assert_eq!(find_conflicts(&actions).len(), 1);
}
#[test]
fn find_conflicts_different_joystick_buttons_ok() {
let actions = vec![
InputAction {
setting_id: "A",
label: "a",
default_scancode: SDL_SCANCODE_A,
essential: true,
keyboard: None,
joystick: Some(CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 3,
name: "B3".to_string(),
}),
},
InputAction {
setting_id: "B",
label: "b",
default_scancode: SDL_SCANCODE_B,
essential: true,
keyboard: None,
joystick: Some(CapturedInput::JoystickButton {
device_id: "dev".to_string(),
button: 4,
name: "B4".to_string(),
}),
},
];
assert!(find_conflicts(&actions).is_empty());
}
#[test]
fn egui_key_letters() {
assert!(egui_key_to_scancode(egui::Key::A) == Some(SDL_SCANCODE_A));
assert!(egui_key_to_scancode(egui::Key::Z) == Some(SDL_SCANCODE_Z));
}
#[test]
fn egui_key_numbers() {
assert!(egui_key_to_scancode(egui::Key::Num0) == Some(SDL_SCANCODE_0));
assert!(egui_key_to_scancode(egui::Key::Num9) == Some(SDL_SCANCODE_9));
}
#[test]
fn egui_key_special() {
assert!(egui_key_to_scancode(egui::Key::Escape) == Some(SDL_SCANCODE_ESCAPE));
assert!(egui_key_to_scancode(egui::Key::Enter) == Some(SDL_SCANCODE_RETURN));
assert!(egui_key_to_scancode(egui::Key::Space) == Some(SDL_SCANCODE_SPACE));
}
#[test]
fn egui_key_function_keys() {
assert!(egui_key_to_scancode(egui::Key::F1) == Some(SDL_SCANCODE_F1));
assert!(egui_key_to_scancode(egui::Key::F12) == Some(SDL_SCANCODE_F12));
}
#[test]
fn egui_key_unmapped_returns_none() {
assert!(egui_key_to_scancode(egui::Key::Insert).is_none());
}
#[test]
fn modifiers_shift_only() {
let m = egui::Modifiers {
shift: true,
ctrl: false,
alt: false,
..Default::default()
};
assert!(egui_modifiers_to_scancode(&m) == Some(SDL_SCANCODE_LSHIFT));
}
#[test]
fn modifiers_ctrl_only() {
let m = egui::Modifiers {
ctrl: true,
shift: false,
alt: false,
..Default::default()
};
assert!(egui_modifiers_to_scancode(&m) == Some(SDL_SCANCODE_LCTRL));
}
#[test]
fn modifiers_alt_only() {
let m = egui::Modifiers {
alt: true,
shift: false,
ctrl: false,
..Default::default()
};
assert!(egui_modifiers_to_scancode(&m) == Some(SDL_SCANCODE_LALT));
}
#[test]
fn modifiers_multiple_returns_none() {
let m = egui::Modifiers {
shift: true,
ctrl: true,
alt: false,
..Default::default()
};
assert!(egui_modifiers_to_scancode(&m).is_none());
}
#[test]
fn modifiers_none_returns_none() {
let m = egui::Modifiers::default();
assert!(egui_modifiers_to_scancode(&m).is_none());
}
#[test]
fn default_actions_known_conflicts() {
let actions = default_actions();
let conflicts = find_conflicts(&actions);
assert!(
!conflicts.is_empty(),
"expected known default conflicts (MINUS, 6) inherited from VPX"
);
let find_pair = |id_a: &str, id_b: &str| -> bool {
conflicts.iter().any(|&(i, j)| {
(actions[i].setting_id == id_a && actions[j].setting_id == id_b)
|| (actions[i].setting_id == id_b && actions[j].setting_id == id_a)
})
};
assert!(
!find_pair("LeftFlipper", "LeftStagedFlipper"),
"LSHIFT flipper+staged collision should be whitelisted (intentional)"
);
assert!(
!find_pair("RightFlipper", "RightStagedFlipper"),
"RSHIFT flipper+staged collision should be whitelisted (intentional)"
);
assert!(
find_pair("VolumeDown", "Service7"),
"MINUS conflict missing"
);
assert!(find_pair("Credit4", "Service5"), "6 key conflict missing");
}
}