diff --git a/crates/lumen-host/src/gamestream/control.rs b/crates/lumen-host/src/gamestream/control.rs index 3584cbe..489e157 100644 --- a/crates/lumen-host/src/gamestream/control.rs +++ b/crates/lumen-host/src/gamestream/control.rs @@ -23,9 +23,10 @@ //! Runs on its own native thread for the host's lifetime. use super::{AppState, CONTROL_PORT}; +use crate::inject::gamepad::GamepadManager; use crate::inject::InputInjector; use anyhow::{anyhow, Context, Result}; -use rusty_enet::{Event, Host, HostSettings}; +use rusty_enet::{Event, Host, HostSettings, Packet, PeerID}; use std::net::UdpSocket; use std::sync::Arc; use std::time::Duration; @@ -40,7 +41,9 @@ pub fn spawn(state: Arc) -> Result<()> { socket, HostSettings { peer_limit: 4, - channel_limit: 8, + // Moonlight connects with CTRL_CHANNEL_COUNT (0x30) channels and sends gamepad + // input on channel 0x10+n — a smaller limit silently discards controller input. + channel_limit: 0x30, ..Default::default() }, ) @@ -56,16 +59,24 @@ pub fn spawn(state: Arc) -> Result<()> { let mut detected: Option = None; // Lazily opened on the first input event (Sway's Wayland socket is up by then). let mut injector: Option> = None; + // Virtual gamepads (uinput) + the host→client rumble sequence counter. + let mut pads = GamepadManager::new(); + let mut rumble_seq: u32 = 0; + let mut peer: Option = None; loop { loop { match host.service() { Ok(Some(event)) => match event { - Event::Connect { .. } => { + Event::Connect { peer: p, .. } => { tracing::info!("control: client connected"); + peer = Some(p.id()); } Event::Disconnect { .. } => { tracing::info!("control: client disconnected"); detected = None; + peer = None; + // Unplug the session's virtual pads. + pads = GamepadManager::new(); } Event::Receive { channel_id, packet, .. @@ -76,6 +87,7 @@ pub fn spawn(state: Arc) -> Result<()> { packet.data(), &mut detected, &mut injector, + &mut pads, ); } }, @@ -86,6 +98,28 @@ pub fn spawn(state: Arc) -> Result<()> { } } } + // Service the pads' force-feedback protocol every tick (games block inside + // EVIOCSFF until answered) and relay mixed rumble levels to the client. + if let (Some(pid), Some(scheme)) = (peer, detected) { + let key = state.launch.lock().unwrap().map(|s| s.gcm_key); + if let Some(key) = key { + let mut out: Vec> = Vec::new(); + pads.pump_rumble(|index, low, high| { + let pt = super::gamepad::rumble_plaintext(index, low, high); + out.push(encrypt_control(&key, &scheme, rumble_seq, &pt)); + rumble_seq = rumble_seq.wrapping_add(1); + }); + for wire in out { + if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..])) + { + tracing::warn!(error = %format!("{e:?}"), "rumble send failed"); + } + } + } + } else { + // No client/scheme yet: still answer FF uploads so games don't block. + pads.pump_rumble(|_, _, _| {}); + } // ENet needs frequent servicing for handshake/keepalive/retransmit. std::thread::sleep(Duration::from_millis(2)); } @@ -102,6 +136,7 @@ fn on_receive( d: &[u8], detected: &mut Option, injector: &mut Option>, + pads: &mut GamepadManager, ) { let Some(key) = state.launch.lock().unwrap().map(|s| s.gcm_key) else { return; // control traffic before /launch — no key yet @@ -141,6 +176,12 @@ fn on_receive( } } + // Controller events go to the uinput virtual pads (created on demand per the mask). + if let Some(gp) = super::gamepad::decode(&pt) { + pads.handle(&gp); + return; + } + let events = super::input::decode(&pt); if events.is_empty() { return; // keepalive / QoS / unhandled input kind @@ -310,6 +351,60 @@ fn decrypt_control( None } +/// Seal a host→client control message, mirroring the client's `detected` scheme with the +/// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy +/// nonces keep their construction with our own independent `seq` counter. Wire layout matches +/// what the client sends us: `[0x0001][length][seq][tag|ct per scheme.tag_first]`. +fn encrypt_control(key: &[u8; 16], scheme: &Scheme, seq: u32, pt: &[u8]) -> Vec { + let nonce_kind = match scheme.nonce { + NonceKind::V2 { seq_be, marker } => NonceKind::V2 { + seq_be, + marker: [b'H', marker[1]], + }, + other => other, + }; + let length = (4 + 16 + pt.len()) as u16; + let mut wire = Vec::with_capacity(8 + 16 + pt.len()); + wire.extend_from_slice(&0x0001u16.to_le_bytes()); + wire.extend_from_slice(&length.to_le_bytes()); + wire.extend_from_slice(&seq.to_le_bytes()); + let aad: Vec = match scheme.aad { + Aad::None => Vec::new(), + Aad::Header4 => wire[0..4].to_vec(), + }; + let ct_tag = gcm_seal(&scheme.key(key), &nonce_kind.nonce(seq), pt, &aad); + let (ct, tag) = ct_tag.split_at(ct_tag.len() - 16); + if scheme.tag_first { + wire.extend_from_slice(tag); + wire.extend_from_slice(ct); + } else { + wire.extend_from_slice(ct); + wire.extend_from_slice(tag); + } + wire +} + +/// AES-128-GCM seal (companion to [`gcm_open`]); returns `ciphertext || tag`. +fn gcm_seal(key: &[u8; 16], nonce: &[u8], pt: &[u8], aad: &[u8]) -> Vec { + use aes_gcm::aead::consts::{U12, U16}; + use aes_gcm::aead::generic_array::GenericArray; + use aes_gcm::aead::{Aead, KeyInit, Payload}; + use aes_gcm::{aes::Aes128, AesGcm}; + + let p = Payload { msg: pt, aad }; + match nonce.len() { + 12 => AesGcm::::new_from_slice(key) + .unwrap() + .encrypt(GenericArray::from_slice(nonce), p) + .expect("GCM seal"), + 16 => AesGcm::::new_from_slice(key) + .unwrap() + .encrypt(GenericArray::from_slice(nonce), p) + .expect("GCM seal"), + _ => unreachable!("nonce length"), + } +} + /// AES-128-GCM open with a 12- or 16-byte nonce and explicit AAD. Returns the plaintext iff /// the tag authenticates. `ct_tag` is `ciphertext || tag` (aes-gcm's expected order). fn gcm_open(key: &[u8; 16], nonce: &[u8], ct_tag: &[u8], aad: &[u8]) -> Option> { diff --git a/crates/lumen-host/src/gamestream/gamepad.rs b/crates/lumen-host/src/gamestream/gamepad.rs new file mode 100644 index 0000000..01410a2 --- /dev/null +++ b/crates/lumen-host/src/gamestream/gamepad.rs @@ -0,0 +1,203 @@ +//! Decode GameStream controller packets (carried on the same encrypted control stream as +//! mouse/keyboard — see [`super::input`]) into [`GamepadFrame`]s for the uinput virtual pads. +//! +//! Layouts mirror moonlight-common-c `Input.h` (all `#pragma pack(1)`; the `size` header field +//! is big-endian, everything else little-endian). We implement the Gen5+ `MULTI_CONTROLLER` +//! event (magic `0x0C`) — the only controller event Sunshine-class hosts receive — plus the +//! Sunshine-extension `CONTROLLER_ARRIVAL` (`0x55000004`). Because our serverinfo advertises a +//! Sunshine appversion (4th component negative), clients also send `buttonFlags2` (paddles / +//! touchpad-click / Share) inside the MC packet. + +/// Inner control-message type for input (same as [`super::input`]). +const INPUT_DATA_TYPE: u16 = 0x0206; + +/// `NV_INPUT_HEADER.magic` for the Gen5+ multi-controller event. +const MAGIC_MULTI_CONTROLLER: u32 = 0x0C; +/// Sunshine extension: controller arrival metadata (type/capabilities). +const MAGIC_CONTROLLER_ARRIVAL: u32 = 0x5500_0004; + +/// Most controllers a session tracks (Sunshine's MAX_GAMEPADS). +pub const MAX_PADS: usize = 16; + +/// One decoded controller event. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GamepadEvent { + /// Full state of one controller + the set of attached controllers. + State(GamepadFrame), + /// Sunshine arrival metadata (precedes the first State for that pad). + Arrival { + index: u8, + /// 0 unknown, 1 xbox, 2 ps, 3 nintendo. + kind: u8, + /// LI_CCAP_* bits (0x02 = rumble). + capabilities: u16, + }, +} + +/// Snapshot of one controller's inputs (Moonlight conventions: sticks −32768..32767 with +Y +/// up, triggers 0..255, buttons = `buttonFlags | buttonFlags2 << 16`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GamepadFrame { + pub index: i16, + /// Bit n set = controller n attached; a clear bit for an allocated pad means unplug. + pub active_mask: u16, + pub buttons: u32, + pub left_trigger: u8, + pub right_trigger: u8, + pub ls_x: i16, + pub ls_y: i16, + pub rs_x: i16, + pub rs_y: i16, +} + +// buttonFlags bits (Limelight.h). +pub const BTN_DPAD_UP: u32 = 0x0001; +pub const BTN_DPAD_DOWN: u32 = 0x0002; +pub const BTN_DPAD_LEFT: u32 = 0x0004; +pub const BTN_DPAD_RIGHT: u32 = 0x0008; +pub const BTN_START: u32 = 0x0010; +pub const BTN_BACK: u32 = 0x0020; +pub const BTN_LS_CLK: u32 = 0x0040; +pub const BTN_RS_CLK: u32 = 0x0080; +pub const BTN_LB: u32 = 0x0100; +pub const BTN_RB: u32 = 0x0200; +pub const BTN_GUIDE: u32 = 0x0400; +pub const BTN_A: u32 = 0x1000; +pub const BTN_B: u32 = 0x2000; +pub const BTN_X: u32 = 0x4000; +pub const BTN_Y: u32 = 0x8000; + +/// Decode one decrypted control plaintext into a controller event, if it is one. Mouse, +/// keyboard, keepalives etc. yield `None` (they're handled by [`super::input::decode`]). +pub fn decode(plaintext: &[u8]) -> Option { + if plaintext.len() < 4 || u16::from_le_bytes([plaintext[0], plaintext[1]]) != INPUT_DATA_TYPE { + return None; + } + let p = &plaintext[4..]; + if p.len() < 8 { + return None; + } + let magic = u32::from_le_bytes([p[4], p[5], p[6], p[7]]); + let b = &p[8..]; // body after NV_INPUT_HEADER + let le16 = |o: usize| -> Option { Some(i16::from_le_bytes([*b.get(o)?, *b.get(o + 1)?])) }; + + match magic { + MAGIC_MULTI_CONTROLLER => { + // Body: headerB@0, controllerNumber@2, activeGamepadMask@4, midB@6, buttonFlags@8, + // LT@10, RT@11, lsX@12, lsY@14, rsX@16, rsY@18, tailA@20, buttonFlags2@22, tailB@24. + // The constants (headerB/midB/tail*) are never validated, mirroring Sunshine. + let buttons_lo = le16(8)? as u16 as u32; + // buttonFlags2 is absent on pre-extension clients (shorter packet) — treat as 0. + let buttons_hi = le16(22).map(|v| v as u16 as u32).unwrap_or(0); + Some(GamepadEvent::State(GamepadFrame { + index: le16(2)?, + active_mask: le16(4)? as u16, + buttons: buttons_lo | (buttons_hi << 16), + left_trigger: *b.get(10)?, + right_trigger: *b.get(11)?, + ls_x: le16(12)?, + ls_y: le16(14)?, + rs_x: le16(16)?, + rs_y: le16(18)?, + })) + } + MAGIC_CONTROLLER_ARRIVAL => Some(GamepadEvent::Arrival { + index: *b.first()?, + kind: *b.get(1)?, + capabilities: le16(2)? as u16, + }), + _ => None, + } +} + +/// Build the host→client rumble plaintext (type `0x010B`): `[type][len=10][u32 filler] +/// [controllerNumber][lowFreqMotor][highFreqMotor]` (all LE; motors 0..0xFFFF). The caller +/// seals it with the host-direction GCM scheme and sends it on the ENet control peer. +pub fn rumble_plaintext(index: u16, low: u16, high: u16) -> Vec { + let mut pt = Vec::with_capacity(14); + pt.extend_from_slice(&0x010Bu16.to_le_bytes()); + pt.extend_from_slice(&10u16.to_le_bytes()); + pt.extend_from_slice(&0x00C0_FFEEu32.to_le_bytes()); // filler — present but ignored + pt.extend_from_slice(&index.to_le_bytes()); + pt.extend_from_slice(&low.to_le_bytes()); + pt.extend_from_slice(&high.to_le_bytes()); + pt +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wrap(magic: u32, body: &[u8]) -> Vec { + let mut inp = Vec::new(); + inp.extend_from_slice(&((4 + body.len()) as u32).to_be_bytes()); + inp.extend_from_slice(&magic.to_le_bytes()); + inp.extend_from_slice(body); + let mut pt = Vec::new(); + pt.extend_from_slice(&INPUT_DATA_TYPE.to_le_bytes()); + pt.extend_from_slice(&(inp.len() as u16).to_le_bytes()); + pt.extend_from_slice(&inp); + pt + } + + #[test] + fn decodes_multi_controller() { + // Pad 1 attached (mask 0b10), A+RB held, LT=10 RT=200, LS=(1000,-2000), RS=(-1,32767), + // paddle1 via buttonFlags2. + let mut body = Vec::new(); + body.extend_from_slice(&0x001Ai16.to_le_bytes()); // headerB + body.extend_from_slice(&1i16.to_le_bytes()); // controllerNumber + body.extend_from_slice(&0b10i16.to_le_bytes()); // activeGamepadMask + body.extend_from_slice(&0x0014i16.to_le_bytes()); // midB + body.extend_from_slice(&((BTN_A | BTN_RB) as u16).to_le_bytes()); + body.push(10); // LT + body.push(200); // RT + body.extend_from_slice(&1000i16.to_le_bytes()); + body.extend_from_slice(&(-2000i16).to_le_bytes()); + body.extend_from_slice(&(-1i16).to_le_bytes()); + body.extend_from_slice(&32767i16.to_le_bytes()); + body.extend_from_slice(&0x009Ci16.to_le_bytes()); // tailA + body.extend_from_slice(&0x0001u16.to_le_bytes()); // buttonFlags2 (paddle1) + body.extend_from_slice(&0x0055i16.to_le_bytes()); // tailB + + let Some(GamepadEvent::State(f)) = decode(&wrap(MAGIC_MULTI_CONTROLLER, &body)) else { + panic!("expected State"); + }; + assert_eq!(f.index, 1); + assert_eq!(f.active_mask, 0b10); + assert_eq!(f.buttons, BTN_A | BTN_RB | 0x0001_0000); + assert_eq!((f.left_trigger, f.right_trigger), (10, 200)); + assert_eq!((f.ls_x, f.ls_y, f.rs_x, f.rs_y), (1000, -2000, -1, 32767)); + } + + #[test] + fn decodes_arrival() { + let body = [0u8, 1, 0x02, 0x00, 0xFF, 0xFF, 0x0F, 0x00]; // pad 0, xbox, rumble cap + let Some(GamepadEvent::Arrival { + index, + kind, + capabilities, + }) = decode(&wrap(MAGIC_CONTROLLER_ARRIVAL, &body)) + else { + panic!("expected Arrival"); + }; + assert_eq!((index, kind, capabilities), (0, 1, 0x0002)); + } + + #[test] + fn ignores_mouse_and_short_packets() { + assert!(decode(&wrap(0x07, &[0, 1, 0, 2])).is_none()); // relative mouse + assert!(decode(&[0u8; 3]).is_none()); + } + + #[test] + fn rumble_layout() { + let pt = rumble_plaintext(2, 0x1234, 0xBEEF); + assert_eq!(pt.len(), 14); + assert_eq!(u16::from_le_bytes([pt[0], pt[1]]), 0x010B); + assert_eq!(u16::from_le_bytes([pt[2], pt[3]]), 10); + assert_eq!(u16::from_le_bytes([pt[8], pt[9]]), 2); + assert_eq!(u16::from_le_bytes([pt[10], pt[11]]), 0x1234); + assert_eq!(u16::from_le_bytes([pt[12], pt[13]]), 0xBEEF); + } +} diff --git a/crates/lumen-host/src/gamestream/mod.rs b/crates/lumen-host/src/gamestream/mod.rs index da1948a..41c7d21 100644 --- a/crates/lumen-host/src/gamestream/mod.rs +++ b/crates/lumen-host/src/gamestream/mod.rs @@ -10,6 +10,7 @@ mod audio; mod cert; mod control; mod crypto; +pub mod gamepad; mod input; mod mdns; mod nvhttp; diff --git a/crates/lumen-host/src/inject.rs b/crates/lumen-host/src/inject.rs index f297857..ef0d685 100644 --- a/crates/lumen-host/src/inject.rs +++ b/crates/lumen-host/src/inject.rs @@ -251,6 +251,21 @@ fn gs_button_to_evdev(b: u32) -> Option { }) } +#[cfg(target_os = "linux")] +pub mod gamepad; +/// Stub — virtual gamepads need Linux uinput; events are dropped elsewhere. +#[cfg(not(target_os = "linux"))] +pub mod gamepad { + #[derive(Default)] + pub struct GamepadManager; + impl GamepadManager { + pub fn new() -> Self { + GamepadManager + } + pub fn handle(&mut self, _ev: &crate::gamestream::gamepad::GamepadEvent) {} + pub fn pump_rumble(&mut self, _send: impl FnMut(u16, u16, u16)) {} + } +} #[cfg(target_os = "linux")] mod libei; #[cfg(target_os = "linux")] diff --git a/crates/lumen-host/src/inject/gamepad.rs b/crates/lumen-host/src/inject/gamepad.rs new file mode 100644 index 0000000..f23305f --- /dev/null +++ b/crates/lumen-host/src/inject/gamepad.rs @@ -0,0 +1,515 @@ +//! 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-lumen.rules`); creation fails with a clear error otherwise. + +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), +]; + +#[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<()> { + 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<()> { + 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) -> Result { + use std::os::fd::FromRawFd; + 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-lumen.rules — and add the user to the 'input' group)", + std::io::Error::last_os_error() + ); + } + 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: 0x045e, + product: 0x028e, + version: 0x0110, + }, + name: [0; 80], + ff_effects_max: 16, // must be > 0 or FF uploads are never delivered + }; + let name = b"Microsoft X-Box 360 pad"; + 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, "virtual gamepad created (X-Box 360 pad via 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, + }; + 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. + 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 { + 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 + } + let ev: InputEventRaw = unsafe { std::ptr::read(buf.as_ptr() as *const _) }; + match (ev.type_, ev.code) { + (EV_UINPUT, UI_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) => { + 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) { + 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>, + /// Pad creation failed (e.g. /dev/uinput permissions) — warn once, drop events. + broken: bool, +} + +impl GamepadManager { + pub fn new() -> GamepadManager { + GamepadManager { + pads: (0..MAX_PADS).map(|_| None).collect(), + 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) { + 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); + } + } + } + } +} diff --git a/scripts/60-lumen.rules b/scripts/60-lumen.rules new file mode 100644 index 0000000..d68b4b7 --- /dev/null +++ b/scripts/60-lumen.rules @@ -0,0 +1,11 @@ +# udev rules for the lumen streaming host (mirrors Sunshine's 60-sunshine.rules). +# +# Grants the `input` group access to /dev/uinput so the host can create virtual gamepads +# (one X-Box-360-class pad per connected Moonlight controller). `static_node` makes the node +# exist before the uinput module loads. +# +# Install: +# sudo cp scripts/60-lumen.rules /etc/udev/rules.d/ +# sudo usermod -aG input $USER # then re-login (or reboot) +# sudo udevadm control --reload-rules && sudo udevadm trigger +KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"