//! Virtual gamepads via `/dev/uinput`, cloning the kernel `xpad` identity ("Microsoft X-Box //! 360 pad", `045e:028e`) so SDL/Steam/Proton match their built-in mapping with zero //! configuration — exactly what Sunshine emulates. One [`VirtualPad`] per attached client //! controller, managed by [`GamepadManager`] from decoded //! [`GamepadFrame`](punktfunk_core::input::GamepadFrame)s. //! //! Rumble flows the *other* way on the same fd: games upload force-feedback effects //! (`EV_UINPUT`/`UI_FF_UPLOAD` → `UI_BEGIN/END_FF_UPLOAD` ioctls) and trigger them with //! `EV_FF` writes; [`GamepadManager::pump_rumble`] services that protocol non-blockingly //! (the control thread calls it every tick) and reports mixed `(low, high)` motor levels for //! the host to send to the client. Note: a game's `EVIOCSFF` ioctl BLOCKS until we answer //! `UI_END_FF_UPLOAD`, so the pump must run regularly. //! //! All ioctl numbers/struct layouts below were verified against this generation's //! `` on x86_64. `/dev/uinput` needs a udev rule + `input` group membership //! (see `scripts/60-punktfunk.rules`); creation fails with a clear error otherwise. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] use crate::pad_slots::PadSlots; use anyhow::{bail, Result}; use punktfunk_core::input::{gamepad, GamepadFrame, MAX_PADS}; use std::collections::HashMap; use std::os::fd::{AsRawFd, OwnedFd}; use std::time::{Duration, Instant}; // ioctls (x86_64). const UI_DEV_CREATE: libc::c_ulong = 0x5501; const UI_DEV_DESTROY: libc::c_ulong = 0x5502; const UI_DEV_SETUP: libc::c_ulong = 0x405c_5503; const UI_ABS_SETUP: libc::c_ulong = 0x401c_5504; const UI_SET_EVBIT: libc::c_ulong = 0x4004_5564; const UI_SET_KEYBIT: libc::c_ulong = 0x4004_5565; const UI_SET_FFBIT: libc::c_ulong = 0x4004_556b; const UI_BEGIN_FF_UPLOAD: libc::c_ulong = 0xc068_55c8; const UI_END_FF_UPLOAD: libc::c_ulong = 0x4068_55c9; const UI_BEGIN_FF_ERASE: libc::c_ulong = 0xc00c_55ca; const UI_END_FF_ERASE: libc::c_ulong = 0x400c_55cb; // Event types/codes. const EV_SYN: u16 = 0x00; const EV_KEY: u16 = 0x01; const EV_ABS: u16 = 0x03; const EV_FF: u16 = 0x15; const EV_UINPUT: u16 = 0x0101; const SYN_REPORT: u16 = 0; const UI_FF_UPLOAD: u16 = 1; const UI_FF_ERASE: u16 = 2; const FF_RUMBLE: u16 = 0x50; const FF_GAIN: u16 = 0x60; const ABS_X: u16 = 0x00; const ABS_Y: u16 = 0x01; const ABS_Z: u16 = 0x02; const ABS_RX: u16 = 0x03; const ABS_RY: u16 = 0x04; const ABS_RZ: u16 = 0x05; const ABS_HAT0X: u16 = 0x10; const ABS_HAT0Y: u16 = 0x11; const BTN_SOUTH: u16 = 0x130; // A const BTN_EAST: u16 = 0x131; // B const BTN_NORTH: u16 = 0x133; // X (kernel calls it BTN_NORTH/BTN_X) const BTN_WEST: u16 = 0x134; // Y const BTN_TL: u16 = 0x136; const BTN_TR: u16 = 0x137; const BTN_SELECT: u16 = 0x13a; const BTN_START: u16 = 0x13b; const BTN_MODE: u16 = 0x13c; const BTN_THUMBL: u16 = 0x13d; const BTN_THUMBR: u16 = 0x13e; // Xbox-Elite paddle codes (the xpad convention SDL / Steam Input recognize). A client's back grips — // and the GameStream `buttonFlags2` paddle bits, which were silently dropped before — land here, so // the virtual X-Box pad exposes paddles like an Elite controller. PADDLE1/2/3/4 = R4/L4/R5/L5. const BTN_TRIGGER_HAPPY5: u16 = 0x2c4; const BTN_TRIGGER_HAPPY6: u16 = 0x2c5; const BTN_TRIGGER_HAPPY7: u16 = 0x2c6; const BTN_TRIGGER_HAPPY8: u16 = 0x2c7; /// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead. const BUTTON_MAP: [(u32, u16); 15] = [ (gamepad::BTN_A, BTN_SOUTH), (gamepad::BTN_B, BTN_EAST), (gamepad::BTN_X, BTN_NORTH), (gamepad::BTN_Y, BTN_WEST), (gamepad::BTN_LB, BTN_TL), (gamepad::BTN_RB, BTN_TR), (gamepad::BTN_BACK, BTN_SELECT), (gamepad::BTN_START, BTN_START), (gamepad::BTN_GUIDE, BTN_MODE), (gamepad::BTN_LS_CLICK, BTN_THUMBL), (gamepad::BTN_RS_CLICK, BTN_THUMBR), (gamepad::BTN_PADDLE1, BTN_TRIGGER_HAPPY5), (gamepad::BTN_PADDLE2, BTN_TRIGGER_HAPPY6), (gamepad::BTN_PADDLE3, BTN_TRIGGER_HAPPY7), (gamepad::BTN_PADDLE4, BTN_TRIGGER_HAPPY8), ]; /// The USB identity a virtual uinput pad presents. SDL/Steam/Proton key their built-in mapping off /// `bustype/vendor/product/version` (+ name), and games pick button glyphs from it. The button/axis /// layout this backend emits is the same XInput one regardless — only the identity differs between an /// X-Box 360 pad and an X-Box One/Series pad (which is why "Xbox One" buys glyphs, not new capability; /// impulse-trigger rumble is unreachable through evdev FF either way). #[derive(Clone, Copy)] pub struct PadIdentity { vendor: u16, product: u16, version: u16, name: &'static [u8], /// Short label for the creation log line. log: &'static str, } impl PadIdentity { /// "Microsoft X-Box 360 pad" (`045e:028e`) — the universal default; matches the kernel `xpad` /// table verbatim so SDL/Steam map it with zero config. pub const fn xbox360() -> PadIdentity { PadIdentity { vendor: 0x045e, product: 0x028e, version: 0x0110, name: b"Microsoft X-Box 360 pad", log: "X-Box 360 pad", } } /// "Microsoft X-Box One S pad" (`045e:02ea`) — an `xpad`-table entry, so games show One/Series /// glyphs. XInput-identical to the 360 pad otherwise. pub const fn xbox_one() -> PadIdentity { PadIdentity { vendor: 0x045e, product: 0x02ea, version: 0x0408, name: b"Microsoft X-Box One S pad", log: "X-Box One S pad", } } } impl Default for PadIdentity { fn default() -> PadIdentity { PadIdentity::xbox360() } } #[repr(C)] struct InputId { bustype: u16, vendor: u16, product: u16, version: u16, } #[repr(C)] struct UinputSetup { id: InputId, name: [u8; 80], ff_effects_max: u32, } #[repr(C)] #[derive(Default, Clone, Copy)] struct AbsInfo { value: i32, minimum: i32, maximum: i32, fuzz: i32, flat: i32, resolution: i32, } #[repr(C)] struct UinputAbsSetup { code: u16, _pad: u16, absinfo: AbsInfo, } #[repr(C)] #[derive(Clone, Copy)] struct InputEventRaw { time: libc::timeval, type_: u16, code: u16, value: i32, } /// `struct ff_effect` (48 bytes; the union starts 8-aligned at offset 16). #[repr(C)] #[derive(Clone, Copy)] struct FfEffect { type_: u16, id: i16, direction: u16, trigger_button: u16, trigger_interval: u16, replay_length: u16, replay_delay: u16, _pad: u16, /// Union; for `FF_RUMBLE`: `u16 strong_magnitude` at [0..2], `u16 weak_magnitude` at [2..4]. u: [u8; 32], } #[repr(C)] #[derive(Clone, Copy)] struct UinputFfUpload { request_id: u32, retval: i32, effect: FfEffect, old: FfEffect, } #[repr(C)] #[derive(Clone, Copy)] struct UinputFfErase { request_id: u32, retval: i32, effect_id: u32, } // Layouts verified by compiling a probe against this generation's (x86_64). const _: () = { assert!(std::mem::size_of::() == 92); assert!(std::mem::size_of::() == 28); assert!(std::mem::size_of::() == 24); assert!(std::mem::size_of::() == 48); assert!(std::mem::size_of::() == 104); assert!(std::mem::size_of::() == 12); }; fn ioctl_int(fd: i32, req: libc::c_ulong, arg: libc::c_int, what: &str) -> Result<()> { // SAFETY: every caller passes one of UI_SET_EVBIT/KEYBIT/FFBIT/UI_DEV_CREATE/UI_DEV_DESTROY as // `req` — all integer-argument ioctls whose third arg the kernel takes BY VALUE, so nothing is // dereferenced through `arg` and no memory must outlive the call. The only precondition is `fd` // being a valid open descriptor; callers pass the live `/dev/uinput` fd, and even a stale fd // would merely return -1/EBADF (reported below), never UB. if unsafe { libc::ioctl(fd, req, arg) } < 0 { bail!("{what}: {}", std::io::Error::last_os_error()); } Ok(()) } fn ioctl_ptr(fd: i32, req: libc::c_ulong, arg: *mut T, what: &str) -> Result<()> { // SAFETY: `fd` is the caller's live `/dev/uinput` fd. Every call site passes `&mut x` for a live, // uniquely-borrowed `#[repr(C)]` `x: T` whose size matches the struct the request number encodes // (UI_DEV_SETUP=0x405c_5503 → 0x5c=92=size_of::(); UI_ABS_SETUP → 0x1c=28; the FF // upload/erase ioctls → 0x68/0x0c — all pinned by the `size_of` asserts above). The kernel copies // exactly that many bytes in/out through `arg`; the `&mut` keeps the pointee alive and unaliased // for the whole synchronous call. if unsafe { libc::ioctl(fd, req, arg) } < 0 { bail!("{what}: {}", std::io::Error::last_os_error()); } Ok(()) } /// One FF effect a game uploaded: rumble magnitudes + playback state. struct Effect { strong: u16, weak: u16, /// `Some(deadline)` while playing (replay length 0 = until stopped). playing: Option>, replay_ms: u16, } /// The force-feedback half of a virtual pad — the game-side effect table plus the mixdown policy /// (finite-replay expiry + the abandoned-INFINITE-effect force-off), split from [`VirtualPad`] so /// the policy is pure and unit-testable without a live uinput fd. struct FfState { effects: HashMap, next_effect_id: i16, gain: u32, /// Last `(low, high)` reported, to dedup. last_mix: (u16, u16), /// When a game last touched the FF plane (upload / erase / play / stop / gain). An /// infinite-replay effect still playing past the shared idle window against this is a residual /// the game abandoned (kernel auto-erase only covers a game whose fd CLOSED) — finite effects /// are untouched: their declared replay deadline is the contract, exactly as a real pad honors /// it. SDL-class writers re-play held rumble every ~2 s, refreshing this clock. last_activity: Instant, } impl FfState { fn new() -> FfState { FfState { effects: HashMap::new(), next_effect_id: 0, gain: 0xFFFF, last_mix: (0, 0), last_activity: Instant::now(), } } /// The game touched the FF plane — refresh the abandoned-effect clock. fn note_activity(&mut self) { self.last_activity = Instant::now(); } /// Mix: sum playing effects (expiring finished ones, force-stopping abandoned infinite ones), /// scale by gain. Returns the new `(low, high)` only when it changed since the last call. fn mix(&mut self, now: Instant, idle: Option) -> Option<(u16, u16)> { let stale = idle.is_some_and(|t| now.duration_since(self.last_activity) >= t); let (mut strong, mut weak) = (0u32, 0u32); for e in self.effects.values_mut() { let Some(deadline) = e.playing else { continue }; match deadline { Some(d) if now >= d => e.playing = None, // An infinite-replay effect the game stopped driving (no FF traffic for the whole // idle window) — the alive-but-abandoned case the kernel's close-time auto-erase // cannot see. Stop it once; a later EV_FF play re-arms it (and refreshes the // clock). Mirrors the XUSB/UHID abandoned-rumble force-off. None if stale => { tracing::info!( strong = e.strong, weak = e.weak, "rumble: stale infinite FF effect (game stopped driving the pad) — forcing off" ); e.playing = None; } _ => { strong = strong.saturating_add(e.strong as u32); weak = weak.saturating_add(e.weak as u32); } } } // Linux FF: strong = low-frequency (big) motor, weak = high-frequency motor. let low = ((strong.min(0xFFFF) * self.gain) >> 16) as u16; let high = ((weak.min(0xFFFF) * self.gain) >> 16) as u16; (self.last_mix != (low, high)).then(|| { self.last_mix = (low, high); (low, high) }) } } /// One virtual X-Box-360 pad backed by a uinput device. pub struct VirtualPad { fd: OwnedFd, ff: FfState, } impl VirtualPad { pub fn create(index: usize, identity: PadIdentity) -> Result { use std::os::fd::FromRawFd; // SAFETY: `c"/dev/uinput"` is a 'static NUL-terminated C string literal; `as_ptr()` yields a // valid pointer the kernel only reads as a filesystem path. `open` returns a fresh fd (or -1) // and retains nothing; no Rust memory is aliased or handed to the kernel beyond that 'static path. let raw = unsafe { libc::open( c"/dev/uinput".as_ptr(), libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC, ) }; if raw < 0 { bail!( "open /dev/uinput: {} (install the udev rule granting the 'input' group access \ — see scripts/60-punktfunk.rules — and add the user to the 'input' group)", std::io::Error::last_os_error() ); } // SAFETY: `raw >= 0` here (the `< 0` branch above already bailed), so it is a freshly-opened fd // from `libc::open` that is not stored or owned anywhere else. Transferring it to `OwnedFd` makes // this the unique owner, which will `close` it exactly once on drop (no double-close, no leak). let fd = unsafe { OwnedFd::from_raw_fd(raw) }; ioctl_int(raw, UI_SET_EVBIT, EV_KEY as i32, "UI_SET_EVBIT(EV_KEY)")?; ioctl_int(raw, UI_SET_EVBIT, EV_ABS as i32, "UI_SET_EVBIT(EV_ABS)")?; ioctl_int(raw, UI_SET_EVBIT, EV_FF as i32, "UI_SET_EVBIT(EV_FF)")?; for (_, key) in BUTTON_MAP { ioctl_int(raw, UI_SET_KEYBIT, key as i32, "UI_SET_KEYBIT")?; } ioctl_int( raw, UI_SET_FFBIT, FF_RUMBLE as i32, "UI_SET_FFBIT(FF_RUMBLE)", )?; ioctl_int(raw, UI_SET_FFBIT, FF_GAIN as i32, "UI_SET_FFBIT(FF_GAIN)")?; let stick = AbsInfo { minimum: -32768, maximum: 32767, fuzz: 16, flat: 128, ..Default::default() }; let trigger = AbsInfo { minimum: 0, maximum: 255, ..Default::default() }; let hat = AbsInfo { minimum: -1, maximum: 1, ..Default::default() }; for (code, info) in [ (ABS_X, stick), (ABS_Y, stick), (ABS_RX, stick), (ABS_RY, stick), (ABS_Z, trigger), (ABS_RZ, trigger), (ABS_HAT0X, hat), (ABS_HAT0Y, hat), ] { let mut a = UinputAbsSetup { code, _pad: 0, absinfo: info, }; ioctl_ptr(raw, UI_ABS_SETUP, &mut a, "UI_ABS_SETUP")?; } // The xpad identity: SDL keys its built-in mapping off bustype/vendor/product/version. let mut setup = UinputSetup { id: InputId { bustype: 0x0003, // BUS_USB vendor: identity.vendor, product: identity.product, version: identity.version, }, name: [0; 80], ff_effects_max: 16, // must be > 0 or FF uploads are never delivered }; let name = identity.name; setup.name[..name.len()].copy_from_slice(name); ioctl_ptr(raw, UI_DEV_SETUP, &mut setup, "UI_DEV_SETUP")?; ioctl_int(raw, UI_DEV_CREATE, 0, "UI_DEV_CREATE")?; tracing::info!( index, pad = identity.log, "virtual gamepad created (uinput)" ); Ok(VirtualPad { fd, ff: FfState::new(), }) } fn emit(&self, type_: u16, code: u16, value: i32) { let ev = InputEventRaw { time: libc::timeval { tv_sec: 0, tv_usec: 0, }, type_, code, value, }; // SAFETY: `ev` is a live local `#[repr(C)]` struct of all-integer fields with no padding bytes // (timeval=16 + u16 + u16 + i32 = 24, the size asserted above), so every byte is initialized and // valid to read as `u8`. The pointer is non-null and `u8`-aligned (align 1), the length is exactly // `size_of::()` so the slice spans precisely `ev`'s bytes (in bounds), and `ev` // outlives `bytes` (used immediately below) with no concurrent mutation (single-threaded local). let bytes = unsafe { std::slice::from_raw_parts( &ev as *const _ as *const u8, std::mem::size_of::(), ) }; // Best-effort: a full kernel queue drops the event; the next frame re-syncs state. // SAFETY: `self.fd` is the live uinput `OwnedFd` (borrowed via `as_raw_fd`, so it stays open for // the call); `bytes` is the slice above backed by the still-live local `ev`. `write` only READS // exactly `bytes.len()` bytes from `bytes.as_ptr()` (in bounds) and retains nothing past return, // so the buffer outlives the synchronous call and the read-only access cannot race or alias. let _ = unsafe { libc::write( self.fd.as_raw_fd(), bytes.as_ptr() as *const libc::c_void, bytes.len(), ) }; } /// Apply one decoded frame: button state, axes, D-pad hat, one SYN_REPORT. pub fn apply(&mut self, f: &GamepadFrame) { // Re-assert every mapped button's absolute state each frame — exactly like the axes below — // instead of only writing XOR-changed edges. `emit` is best-effort (a full kernel queue drops // the write), so an edge-only scheme would strand a dropped press/release until that button // next toggles; re-asserting re-syncs it on the following frame. Restating an unchanged key is // free downstream: the kernel input core discards an EV_KEY whose value already matches the // device's current state (no duplicate event reaches consumers, and BTN_* keys don't autorepeat). for (bit, key) in BUTTON_MAP { self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32); } // Moonlight: +Y = up; evdev: +Y = down → negate (i32 math avoids -(-32768) overflow). self.emit(EV_ABS, ABS_X, f.ls_x as i32); self.emit(EV_ABS, ABS_Y, -(f.ls_y as i32)); self.emit(EV_ABS, ABS_RX, f.rs_x as i32); self.emit(EV_ABS, ABS_RY, -(f.rs_y as i32)); self.emit(EV_ABS, ABS_Z, f.left_trigger as i32); self.emit(EV_ABS, ABS_RZ, f.right_trigger as i32); let hat_x = ((f.buttons & gamepad::BTN_DPAD_RIGHT != 0) as i32) - ((f.buttons & gamepad::BTN_DPAD_LEFT != 0) as i32); let hat_y = ((f.buttons & gamepad::BTN_DPAD_DOWN != 0) as i32) - ((f.buttons & gamepad::BTN_DPAD_UP != 0) as i32); self.emit(EV_ABS, ABS_HAT0X, hat_x); self.emit(EV_ABS, ABS_HAT0Y, hat_y); self.emit(EV_SYN, SYN_REPORT, 0); } /// Service the FF protocol on this pad's fd (non-blocking). Returns the new mixed /// `(low, high)` motor levels if they changed since last call. fn pump_ff(&mut self) -> Option<(u16, u16)> { let raw = self.fd.as_raw_fd(); let mut buf = [0u8; std::mem::size_of::()]; loop { // SAFETY: `raw` is the live raw fd of `self.fd` (the non-blocking uinput device). `buf` is a // live local `[u8; size_of::()]`; `buf.as_mut_ptr()` is a valid writable pointer // to its `buf.len()` bytes. `read` writes AT MOST `buf.len()` bytes (in bounds), the buffer // outlives this synchronous call, and `buf` is borrowed uniquely here (no alias/race). let n = unsafe { libc::read(raw, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; if n != buf.len() as isize { break; // EAGAIN / short read — queue drained } // SAFETY: `buf` is exactly `size_of::()` bytes and fully written by the // `read` above. `read_unaligned` (not `read`) because the `[u8]` buffer is 1-aligned but // `InputEventRaw` needs 8 (it holds a `timeval`) — a plain `ptr::read` would be UB. let ev: InputEventRaw = unsafe { std::ptr::read_unaligned(buf.as_ptr() as *const InputEventRaw) }; match (ev.type_, ev.code) { (EV_UINPUT, UI_FF_UPLOAD) => { self.ff.note_activity(); // SAFETY: `UinputFfUpload` is `#[repr(C)]` over integers (`u32`, `i32`) and two // `FfEffect`s (integers + `[u8; 32]`); all-zero is a valid bit pattern for every field // (no bool/NonZero/enum/reference niche), so `zeroed` yields a fully-initialized valid // value — `request_id` is then set below and the rest filled by UI_BEGIN_FF_UPLOAD. let mut up: UinputFfUpload = unsafe { std::mem::zeroed() }; up.request_id = ev.value as u32; if ioctl_ptr(raw, UI_BEGIN_FF_UPLOAD, &mut up, "UI_BEGIN_FF_UPLOAD").is_ok() { let mut e = up.effect; if e.id == -1 { e.id = self.ff.next_effect_id; self.ff.next_effect_id = self.ff.next_effect_id.wrapping_add(1); } if e.type_ == FF_RUMBLE { let strong = u16::from_ne_bytes([e.u[0], e.u[1]]); let weak = u16::from_ne_bytes([e.u[2], e.u[3]]); let slot = self.ff.effects.entry(e.id).or_insert(Effect { strong: 0, weak: 0, playing: None, replay_ms: 0, }); slot.strong = strong; slot.weak = weak; slot.replay_ms = e.replay_length; } up.effect.id = e.id; // hand the assigned slot back to the kernel up.retval = 0; let _ = ioctl_ptr(raw, UI_END_FF_UPLOAD, &mut up, "UI_END_FF_UPLOAD"); } } (EV_UINPUT, UI_FF_ERASE) => { self.ff.note_activity(); // SAFETY: `UinputFfErase` is `#[repr(C)]` over three integer fields (`u32`, `i32`, // `u32`); all-zero is a valid bit pattern for each, so `zeroed` produces a fully-valid // initialized value — `request_id` is set below and `effect_id` filled by the ioctl. let mut er: UinputFfErase = unsafe { std::mem::zeroed() }; er.request_id = ev.value as u32; if ioctl_ptr(raw, UI_BEGIN_FF_ERASE, &mut er, "UI_BEGIN_FF_ERASE").is_ok() { self.ff.effects.remove(&(er.effect_id as i16)); er.retval = 0; let _ = ioctl_ptr(raw, UI_END_FF_ERASE, &mut er, "UI_END_FF_ERASE"); } } (EV_FF, FF_GAIN) => { self.ff.note_activity(); self.ff.gain = (ev.value as u32).min(0xFFFF); } (EV_FF, code) => { self.ff.note_activity(); if let Some(e) = self.ff.effects.get_mut(&(code as i16)) { e.playing = if ev.value != 0 { Some((e.replay_ms > 0).then(|| { Instant::now() + std::time::Duration::from_millis(e.replay_ms as u64) })) } else { None }; } } _ => {} } } self.ff .mix(Instant::now(), crate::uhid_manager::rumble_idle_timeout()) } } impl Drop for VirtualPad { fn drop(&mut self) { // SAFETY: `self.fd` is still the live owned uinput fd here (the `OwnedFd` field is closed only // AFTER this `drop` body returns), borrowed by `as_raw_fd`. UI_DEV_DESTROY takes its argument // (0) BY VALUE, so nothing is dereferenced or aliased; the ioctl just tears down the device. let _ = unsafe { libc::ioctl(self.fd.as_raw_fd(), UI_DEV_DESTROY, 0) }; } } /// All virtual pads of a session, driven from decoded controller events. Stateless per frame /// (uinput/evdev holds last-known state kernel-side), so it rides [`PadSlots`] directly — no state /// vec, heartbeat, or rich plane like the UHID managers. pub struct GamepadManager { slots: PadSlots, /// The USB identity every pad in this session presents (X-Box 360 by default, One/Series when /// the client asked for `XboxOne`). All pads in a session share one identity. identity: PadIdentity, } impl Default for GamepadManager { fn default() -> GamepadManager { GamepadManager::new() } } impl GamepadManager { /// A manager that creates X-Box 360 pads (the universal default). pub fn new() -> GamepadManager { GamepadManager::with_identity(PadIdentity::xbox360()) } /// A manager whose pads present `identity` (see [`PadIdentity::xbox_one`]). pub fn with_identity(identity: PadIdentity) -> GamepadManager { GamepadManager { slots: PadSlots::new(identity.log, "gamepad", ""), identity, } } /// Handle one decoded controller event (create/destroy by mask, then apply state). pub fn handle(&mut self, ev: &punktfunk_core::input::GamepadEvent) { use punktfunk_core::input::GamepadEvent; match ev { GamepadEvent::Arrival { index, kind, .. } => { tracing::info!(index, kind, "controller arrival ({})", self.slots.label()); self.ensure(*index as usize); } GamepadEvent::State(f) => { let idx = f.index as usize; if idx >= MAX_PADS { return; } // Unplugs: drop any allocated pad whose mask bit cleared (no per-index sibling // state to reset — the pads mix rumble internally). self.slots.sweep(f.active_mask); if f.active_mask & (1 << idx) == 0 { return; // this event WAS the unplug } self.ensure(idx); if let Some(pad) = self.slots.get_mut(idx) { pad.apply(f); } } } } fn ensure(&mut self, idx: usize) { let identity = self.identity; // `VirtualPad::create` logs its own success line (it knows the identity + transport). self.slots .ensure(idx, |i| VirtualPad::create(i as usize, identity)); } /// Service every pad's FF protocol; `send(index, low, high)` is invoked for each pad whose /// mixed rumble level changed. Call frequently (games block in `EVIOCSFF` until answered). pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { for (i, pad) in self.slots.iter_mut() { if let Some((low, high)) = pad.pump_ff() { send(i as u16, low, high); } } } } #[cfg(test)] mod tests { use super::*; use std::time::Duration; /// The FF-capable evdev node whose input-device name contains `name`. fn find_ff_node(name: &str) -> Option { let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); let mut cur = String::new(); let mut node = None; for line in s.lines() { if let Some(n) = line.strip_prefix("N: Name=") { cur = n.trim_matches('"').to_string(); } else if let Some(h) = line.strip_prefix("H: Handlers=") { if cur.contains(name) { node = h .split_whitespace() .find(|t| t.starts_with("event")) .map(|ev| format!("/dev/input/{ev}")); } } else if line.starts_with("B: FF=") && cur.contains(name) && node.is_some() && !line.trim_end().ends_with("FF=0") { return node; } } node } /// Upload + play an FF_RUMBLE like SDL's evdev haptic backend. Returns the OPEN fd (closing /// it erases the process's effects, stopping the rumble) with the kernel-assigned id. /// NOTE: EVIOCSFF BLOCKS until the uinput owner answers UI_FF_UPLOAD — the caller must be a /// separate thread from the one running [`VirtualPad::pump_ff`], exactly like a real game vs /// the host input loop. fn evdev_rumble(node: &str, strong: u16, weak: u16) -> std::io::Result<(std::fs::File, i16)> { use std::io::Write as _; let mut f = std::fs::OpenOptions::new() .read(true) .write(true) .open(node)?; let mut eff = [0u8; 48]; // struct ff_effect; union (rumble magnitudes) at offset 16 eff[0..2].copy_from_slice(&FF_RUMBLE.to_ne_bytes()); eff[2..4].copy_from_slice(&(-1i16).to_ne_bytes()); // id: kernel assigns eff[10..12].copy_from_slice(&5000u16.to_ne_bytes()); // replay.length ms eff[16..18].copy_from_slice(&strong.to_ne_bytes()); eff[18..20].copy_from_slice(&weak.to_ne_bytes()); // EVIOCSFF = _IOW('E', 0x80, struct ff_effect) let req: libc::c_ulong = (1 << 30) | (48 << 16) | (0x45 << 8) | 0x80; // SAFETY: EVIOCSFF reads/writes the 48-byte ff_effect behind the valid fd `f`; `eff` is // exactly sizeof(struct ff_effect) and outlives the synchronous call. let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, eff.as_mut_ptr()) }; if rc < 0 { return Err(std::io::Error::last_os_error()); } let id = i16::from_ne_bytes([eff[2], eff[3]]); let mut ev = [0u8; 24]; // struct input_event: timeval 16, type u16, code u16, value s32 ev[16..18].copy_from_slice(&EV_FF.to_ne_bytes()); ev[18..20].copy_from_slice(&(id as u16).to_ne_bytes()); ev[20..24].copy_from_slice(&1i32.to_ne_bytes()); // play f.write_all(&ev)?; Ok((f, id)) } /// On-box proof of the uinput FF back-channel, playing the GAME's role: an evdev FF_RUMBLE /// upload+play against the virtual X-Box 360 pad must surface through `pump_ff` (the /// EV_UINPUT UI_FF_UPLOAD protocol) — the path every `auto`-kind session's rumble rides on /// Linux — and erasing the effect (fd close) must surface the stop. #[test] #[ignore = "creates a real /dev/uinput device; needs the input group"] fn ff_upload_reaches_pump_and_stops_on_erase() { let mut pad = VirtualPad::create(0, PadIdentity::xbox360()).expect("create uinput pad"); std::thread::sleep(Duration::from_millis(700)); // let udev settle the node let node = find_ff_node("Microsoft X-Box 360 pad").expect("no X-Box 360 evdev node"); let game = std::thread::spawn(move || { let r = evdev_rumble(&node, 0xC000, 0x4000); std::thread::sleep(Duration::from_millis(1200)); // hold the effect, then erase r.expect("EVIOCSFF/play (fd held meanwhile)"); }); let start = Instant::now(); let mut seen = Vec::new(); while start.elapsed() < Duration::from_millis(2500) { if let Some(mix) = pad.pump_ff() { seen.push(mix); } std::thread::sleep(Duration::from_millis(4)); } game.join().unwrap(); // Requested magnitudes scaled by the 0xFFFF default gain (>> 16). assert!( seen.contains(&(0xBFFF, 0x3FFF)), "evdev FF rumble never surfaced through pump_ff: {seen:?}" ); assert_eq!( seen.last(), Some(&(0, 0)), "erase-on-close never produced a stop mix: {seen:?}" ); } } #[cfg(test)] mod ff_state_tests { use super::*; /// The default idle window the shared hatch resolves to when the env is unset. const IDLE: Option = Some(Duration::from_millis(2500)); /// `gain` is 0xFFFF (not a true 1.0 multiplier), so a magnitude loses 1 LSB in the mixdown. fn scaled(v: u16) -> u16 { ((v as u32 * 0xFFFF) >> 16) as u16 } fn ff_with(effect: Effect) -> FfState { let mut ff = FfState::new(); ff.effects.insert(0, effect); ff } #[test] fn abandoned_infinite_effect_is_forced_off_after_idle_window() { let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, playing: Some(None), replay_ms: 0, }); let now = Instant::now(); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); assert_eq!(ff.mix(now, IDLE), None); // unchanged level dedups, still playing // The game goes silent on the FF plane past the idle window: cut, exactly once. ff.last_activity = now - Duration::from_millis(2600); assert_eq!(ff.mix(now, IDLE), Some((0, 0))); assert_eq!(ff.mix(now, IDLE), None); // already off — no repeat } #[test] fn finite_effect_honors_its_replay_deadline_not_the_idle_window() { let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x4000, weak: 0, playing: Some(Some(now + Duration::from_secs(10))), replay_ms: 10_000, }); // FF plane long stale, but the effect declared a finite replay — the declared duration is // the contract (a real pad honors it too), so it keeps playing… ff.last_activity = now - Duration::from_secs(60); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x4000), 0))); // …and expires at its own deadline. assert_eq!(ff.mix(now + Duration::from_secs(11), IDLE), Some((0, 0))); } #[test] fn replay_after_cut_rearms_the_effect() { let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, playing: Some(None), replay_ms: 0, }); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); ff.last_activity = now - Duration::from_millis(3000); assert_eq!(ff.mix(now, IDLE), Some((0, 0))); // The game plays the effect again — an FF event refreshes the clock and re-arms playback. ff.last_activity = now; ff.effects.get_mut(&0).unwrap().playing = Some(None); assert_eq!(ff.mix(now, IDLE), Some((scaled(0x8000), 0))); } #[test] fn disabled_watchdog_never_cuts() { let now = Instant::now(); let mut ff = ff_with(Effect { strong: 0x8000, weak: 0, playing: Some(None), replay_ms: 0, }); ff.last_activity = now - Duration::from_secs(600); assert_eq!(ff.mix(now, None), Some((scaled(0x8000), 0))); } }