//! 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`](crate::gamestream::gamepad::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::gamestream::gamepad::{self, GamepadFrame, MAX_PADS}; use anyhow::{bail, Result}; use std::collections::HashMap; use std::os::fd::{AsRawFd, OwnedFd}; use std::time::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; /// `(GameStream button bit, evdev key code)` — D-pad is emitted as HAT axes instead. const BUTTON_MAP: [(u32, u16); 11] = [ (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_CLK, BTN_THUMBL), (gamepad::BTN_RS_CLK, BTN_THUMBR), ]; /// 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, } /// One virtual X-Box-360 pad backed by a uinput device. pub struct VirtualPad { fd: OwnedFd, prev_buttons: u32, effects: HashMap, next_effect_id: i16, gain: u32, /// Last `(low, high)` reported, to dedup. last_mix: (u16, u16), } 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, prev_buttons: 0, effects: HashMap::new(), next_effect_id: 0, gain: 0xFFFF, last_mix: (0, 0), }) } 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 transitions, axes, D-pad hat, one SYN_REPORT. pub fn apply(&mut self, f: &GamepadFrame) { let changed = self.prev_buttons ^ f.buttons; for (bit, key) in BUTTON_MAP { if changed & bit != 0 { self.emit(EV_KEY, key, ((f.buttons & bit) != 0) as i32); } } self.prev_buttons = f.buttons; // 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) => { // 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.next_effect_id; self.next_effect_id = self.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.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) => { // 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.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.gain = (ev.value as u32).min(0xFFFF), (EV_FF, code) => { if let Some(e) = self.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 }; } } _ => {} } } // Mix: sum playing effects (expiring finished ones), scale by gain. let now = Instant::now(); let (mut strong, mut weak) = (0u32, 0u32); for e in self.effects.values_mut() { if let Some(deadline) = e.playing { if deadline.is_some_and(|d| now >= d) { e.playing = None; } else { 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) }) } } 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. #[derive(Default)] pub struct GamepadManager { pads: Vec>, /// 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, /// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events. broken: bool, } 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 { pads: (0..MAX_PADS).map(|_| None).collect(), identity, broken: false, } } /// Handle one decoded controller event (create/destroy by mask, then apply state). pub fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) { use crate::gamestream::gamepad::GamepadEvent; match ev { GamepadEvent::Arrival { index, kind, .. } => { tracing::info!(index, kind, "controller arrival"); 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. for (i, slot) in self.pads.iter_mut().enumerate() { if slot.is_some() && f.active_mask & (1 << i) == 0 { tracing::info!(index = i, "controller unplugged"); *slot = None; } } if f.active_mask & (1 << idx) == 0 { return; // this event WAS the unplug } self.ensure(idx); if let Some(pad) = self.pads[idx].as_mut() { pad.apply(f); } } } } fn ensure(&mut self, idx: usize) { if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { return; } match VirtualPad::create(idx, self.identity) { Ok(p) => self.pads[idx] = Some(p), Err(e) => { tracing::error!(error = %format!("{e:#}"), "virtual gamepad creation failed — controller input disabled"); self.broken = true; } } } /// 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, slot) in self.pads.iter_mut().enumerate() { if let Some(pad) = slot { if let Some((low, high)) = pad.pump_ff() { send(i as u16, low, high); } } } } }