diff --git a/crates/punktfunk-host/src/inject/linux/dualsense.rs b/crates/punktfunk-host/src/inject/linux/dualsense.rs index 4fd2a6a6..57881b93 100644 --- a/crates/punktfunk-host/src/inject/linux/dualsense.rs +++ b/crates/punktfunk-host/src/inject/linux/dualsense.rs @@ -13,9 +13,9 @@ //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. use super::dualsense_proto::{ - edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, DS_EDGE_PRODUCT, - DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, - DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC, + ds_pairing_reply, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, + DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT, + DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC, }; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use anyhow::{Context, Result}; @@ -33,6 +33,8 @@ const UHID_GET_REPORT: u32 = 9; const UHID_GET_REPORT_REPLY: u32 = 10; const UHID_CREATE2: u32 = 11; const UHID_INPUT2: u32 = 12; +const UHID_SET_REPORT: u32 = 13; +const UHID_SET_REPORT_REPLY: u32 = 14; const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) const BUS_USB: u16 = 0x03; @@ -106,6 +108,9 @@ impl DualSensePad { Ok(ds) } + /// Send UHID_CREATE2 under `id`'s identity. The uniq written here is cosmetic: + /// `hid-playstation` replaces it with the MAC from the pairing feature report (see + /// [`ds_pairing_reply`]) as soon as it binds. fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> { let mut ev = [0u8; UHID_EVENT_SIZE]; ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); @@ -161,15 +166,25 @@ impl DualSensePad { UHID_GET_REPORT => { // uhid_get_report_req: id u32 [4..8], rnum u8 [8]. let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + // Per-pad MAC: hid-playstation adopts it as the HID uniq, and SDL/Steam + // dedup controllers by that serial (see `ds_pairing_reply`). + let pairing = ds_pairing_reply(pad); let data: &[u8] = match ev[8] { 0x05 => DS_FEATURE_CALIBRATION, - 0x09 => DS_FEATURE_PAIRING, + 0x09 => &pairing, 0x20 => DS_FEATURE_FIRMWARE, _ => &[], }; let _ = self.reply_get_report(id, data); } - _ => {} // Start/Stop/Open/Close/SetReport — ignore + UHID_SET_REPORT => { + // Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s + // timeout. Nothing to parse: every known DualSense writer sends its feedback + // as OUTPUT reports (handled above), never SET_REPORT. + let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + let _ = self.reply_set_report(id); + } + _ => {} // Start/Stop/Open/Close — ignore } } fb @@ -189,6 +204,18 @@ impl DualSensePad { .context("write UHID_GET_REPORT_REPLY")?; Ok(()) } + + fn reply_set_report(&mut self, id: u32) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes()); + // uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10]. + ev[4..8].copy_from_slice(&id.to_ne_bytes()); + ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack) + self.fd + .write_all(&ev) + .context("write UHID_SET_REPORT_REPLY")?; + Ok(()) + } } impl Drop for DualSensePad { @@ -369,3 +396,257 @@ impl PadProto for DsEdgeLinuxProto { /// All virtual DualSense Edge pads of a session — `PUNKTFUNK_GAMEPAD=edge`, or the per-pad kind a /// client declares for a paddle-bearing physical controller. pub type DualSenseEdgeManager = UhidManager; + +#[cfg(test)] +mod tests { + use super::*; + use punktfunk_core::quic::HidOutput; + use std::os::unix::io::AsRawFd; + use std::time::{Duration, Instant}; + + /// evdev nodes whose input-device name contains `name`: (full name, /dev/input/eventN). + fn find_nodes(name: &str) -> Vec<(String, String)> { + let s = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default(); + let mut out = Vec::new(); + let mut cur = String::new(); + 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) { + if let Some(ev) = h.split_whitespace().find(|t| t.starts_with("event")) { + out.push((cur.clone(), format!("/dev/input/{ev}"))); + } + } + } + } + out + } + + /// Whether the evdev at `node` advertises EV_FF (0x15) — the rumble-capable gamepad node + /// (the touchpad / motion / headset siblings don't). + fn has_ff(node: &str) -> bool { + let Ok(f) = std::fs::OpenOptions::new().read(true).open(node) else { + return false; + }; + let mut bits = [0u8; 8]; + // EVIOCGBIT(0, 8): the device's event-type bitmap. + let req: libc::c_ulong = (2 << 30) | (8 << 16) | (0x45 << 8) | 0x20; + // SAFETY: EVIOCGBIT(0) copies at most 8 bytes (EV_MAX/8 < 8) into the live `bits` buffer + // behind the valid evdev fd `f`; the kernel never writes past the ioctl's size argument. + let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, bits.as_mut_ptr()) }; + rc >= 0 && (bits[0x15 / 8] >> (0x15 % 8)) & 1 == 1 + } + + /// Upload an FF_RUMBLE effect on `node` and play it, exactly like SDL's evdev haptic backend. + /// Returns the OPEN fd with the id — closing the fd erases the process's effects (stopping + /// the rumble), so the caller must hold it while asserting. + 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)?; + // struct ff_effect (48 B): type u16, id s16, direction u16, trigger, replay{len,delay}, + // pad to 16, union (ff_rumble_effect { strong, weak }). + let mut eff = [0u8; 48]; + eff[0..2].copy_from_slice(&0x50u16.to_ne_bytes()); // FF_RUMBLE + 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]]); + // struct input_event (24 B on 64-bit): timeval 16, type u16, code u16, value s32. + let mut ev = [0u8; 24]; + ev[16..18].copy_from_slice(&0x15u16.to_ne_bytes()); // EV_FF + 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)) + } + + /// `(HID_NAME, HID_UNIQ, /dev/hidrawN)` for every hidraw class device. + fn hidraw_devices() -> Vec<(String, String, String)> { + let mut out = Vec::new(); + let Ok(dir) = std::fs::read_dir("/sys/class/hidraw") else { + return out; + }; + for e in dir.flatten() { + let ue = std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default(); + let field = |k: &str| { + ue.lines() + .find_map(|l| l.strip_prefix(k)) + .unwrap_or_default() + .to_string() + }; + out.push(( + field("HID_NAME="), + field("HID_UNIQ="), + format!("/dev/{}", e.file_name().to_string_lossy()), + )); + } + out + } + + /// Service `pad` for `ms`, accumulating every captured feedback pass (all rumble levels in + /// order + all rich events) while keeping the input heartbeat going. + fn collect(pad: &mut DualSensePad, st: &DsState, ms: u64) -> (Vec<(u16, u16)>, Vec) { + let start = Instant::now(); + let (mut levels, mut hidout) = (Vec::new(), Vec::::new()); + while start.elapsed() < Duration::from_millis(ms) { + let fb = pad.service(0); + levels.extend(fb.rumble); + hidout.extend(fb.hidout); + let _ = pad.write_state(st); + std::thread::sleep(Duration::from_millis(4)); + } + (levels, hidout) + } + + /// On-box proof of the full Linux feedback surface, playing the GAME's role against a real + /// kernel: chain A drives rumble through evdev force feedback (`hid-playstation`'s ff-memless + /// → UHID_OUTPUT — what SDL/Steam fall back to without hidraw); chain B writes a raw DS5 + /// output report to the pad's hidraw node (SDL/Steam's real path, and the ONLY way adaptive + /// triggers can arrive) and expects rumble + lightbar + player LEDs + both trigger blocks + /// back verbatim. Also pins the per-pad pairing MAC: two pads must present distinct uniqs or + /// SDL/Steam dedup them into one controller. + #[test] + #[ignore = "creates real /dev/uhid devices; needs hid-playstation, the input group, and the 60-punktfunk.rules hidraw rules"] + fn feedback_flows_via_evdev_ff_and_hidraw() { + let mut pad0 = DualSensePad::open(0, &DsUhidIdentity::dualsense()).expect("open pad 0"); + let mut pad1 = DualSensePad::open(1, &DsUhidIdentity::dualsense()).expect("open pad 1"); + let st = DsState::neutral(); + // Let hid-playstation complete its GET_REPORT handshakes and register input devices. + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(1500) { + let _ = pad0.service(0); + let _ = pad1.service(1); + let _ = pad0.write_state(&st); + let _ = pad1.write_state(&st); + std::thread::sleep(Duration::from_millis(4)); + } + let nodes = find_nodes("Punktfunk DualSense 0"); + assert!( + !nodes.is_empty(), + "hid-playstation did not bind the uhid device" + ); + let ff_node = nodes + .iter() + .map(|(_, n)| n.as_str()) + .find(|n| has_ff(n)) + .expect("no FF-capable evdev among the pad's input devices"); + + // Per-pad MAC: hid-playstation adopts the pairing-report MAC as HID_UNIQ; the two pads + // must differ (the SDL/Steam serial-dedup regression, see `ds_pairing_reply`). + let hidraws = hidraw_devices(); + let uniq = |name: &str| { + hidraws + .iter() + .find(|(n, _, _)| n == name) + .map(|(_, u, _)| u.clone()) + .unwrap_or_else(|| panic!("no hidraw for {name} in {hidraws:?}")) + }; + assert_ne!( + uniq("Punktfunk DualSense 0"), + uniq("Punktfunk DualSense 1"), + "pads share one pairing MAC — SDL/Steam will dedup them into one controller" + ); + + // ---- Chain A: evdev force feedback ---- + let (ff_fd, _) = evdev_rumble(ff_node, 0xC000, 0x4000).expect("EVIOCSFF/play"); + let (levels, _) = collect(&mut pad0, &st, 1000); + assert!( + levels.iter().any(|&(l, h)| l > 0 || h > 0), + "evdev FF rumble never surfaced as UHID_OUTPUT: {levels:?}" + ); + drop(ff_fd); // closing erases the effect: the stop must surface too + let (levels, _) = collect(&mut pad0, &st, 800); + assert!( + levels.contains(&(0, 0)), + "erase-on-close never produced a rumble stop: {levels:?}" + ); + + // ---- Chain B: raw DS5 output report over hidraw ---- + let hr = hidraws + .iter() + .find(|(n, _, _)| n == "Punktfunk DualSense 0") + .map(|(_, _, d)| d.clone()) + .unwrap(); + let mut rep = [0u8; 48]; + rep[0] = 0x02; // USB output report id + rep[1] = 0x03 | 0x04 | 0x08; // flag0: compat vibration + haptics select + R2 + L2 + rep[2] = 0x04 | 0x10; // flag1: lightbar + player LEDs + rep[3] = 0x60; // motor right (high) + rep[4] = 0xA0; // motor left (low) + rep[11] = 0x21; // R2 trigger block: weapon mode + params + rep[12] = 0x04; + rep[13] = 0x07; + rep[22] = 0x26; // L2 trigger block: vibration mode + params + rep[23] = 0x02; + rep[44] = 0x04; // player LED middle + rep[45] = 0x10; + rep[46] = 0x20; + rep[47] = 0x30; + std::fs::OpenOptions::new() + .write(true) + .open(&hr) + .and_then(|mut f| std::io::Write::write_all(&mut f, &rep)) + .unwrap_or_else(|e| { + panic!( + "cannot write {hr} as this user ({e}) — Steam/SDL would be equally blocked; \ + are the 60-punktfunk.rules hidraw rules installed?" + ) + }); + let (levels, hidout) = collect(&mut pad0, &st, 1000); + assert!( + levels.contains(&(0xA000, 0x6000)), + "hidraw rumble did not surface: {levels:?}" + ); + let triggers: Vec<_> = hidout + .iter() + .filter_map(|h| match h { + HidOutput::Trigger { which, effect, .. } => Some((*which, effect.clone())), + _ => None, + }) + .collect(); + assert_eq!( + triggers.len(), + 2, + "expected both trigger blocks: {hidout:?}" + ); + assert!( + triggers.contains(&(1, rep[11..22].to_vec())), + "R2 block not verbatim" + ); + assert!( + triggers.contains(&(0, rep[22..33].to_vec())), + "L2 block not verbatim" + ); + assert!( + hidout.iter().any(|h| matches!( + h, + HidOutput::Led { + r: 0x10, + g: 0x20, + b: 0x30, + .. + } + )), + "lightbar not surfaced: {hidout:?}" + ); + assert!( + hidout + .iter() + .any(|h| matches!(h, HidOutput::PlayerLeds { bits: 0x04, .. })), + "player LEDs not surfaced: {hidout:?}" + ); + } +} diff --git a/crates/punktfunk-host/src/inject/linux/dualshock4.rs b/crates/punktfunk-host/src/inject/linux/dualshock4.rs index 394dc9e0..d24b7cbe 100644 --- a/crates/punktfunk-host/src/inject/linux/dualshock4.rs +++ b/crates/punktfunk-host/src/inject/linux/dualshock4.rs @@ -33,6 +33,8 @@ const UHID_GET_REPORT: u32 = 9; const UHID_GET_REPORT_REPLY: u32 = 10; const UHID_CREATE2: u32 = 11; const UHID_INPUT2: u32 = 12; +const UHID_SET_REPORT: u32 = 13; +const UHID_SET_REPORT_REPLY: u32 = 14; const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) const BUS_USB: u16 = 0x03; @@ -46,6 +48,17 @@ const BUS_USB: u16 = 0x03; const DS4_FEATURE_PAIRING: &[u8] = &[ // report 0x12 (MAC at bytes 1..7, LE → DE:AD:BE:EF:00:01) 0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; + +/// The pairing reply for wire pad `pad`: [`DS4_FEATURE_PAIRING`] with the MAC's low octet offset +/// by the pad index — same per-pad-serial contract as the DualSense's +/// [`ds_pairing_reply`](super::dualsense_proto::ds_pairing_reply): the kernel adopts the MAC as +/// the HID uniq, and SDL/Steam dedup controllers by that serial. +fn ds4_pairing_reply(pad: u8) -> [u8; 16] { + let mut r = [0u8; 16]; + r.copy_from_slice(DS4_FEATURE_PAIRING); + r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first + r +} #[rustfmt::skip] const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words) 0x02, @@ -204,9 +217,9 @@ impl DualShock4Pad { /// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing / /// calibration / firmware — the pairing reply is required during `hid-playstation` init, or no /// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a - /// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake - /// completes. - pub fn service(&mut self) -> Ds4Feedback { + /// [`Ds4Feedback`] for pad `pad`. Call frequently — especially right after [`open`] so the + /// init handshake completes. + pub fn service(&mut self, pad: u8) -> Ds4Feedback { let mut fb = Ds4Feedback::default(); let mut ev = [0u8; UHID_EVENT_SIZE]; while let Ok(n) = self.fd.read(&mut ev) { @@ -223,15 +236,22 @@ impl DualShock4Pad { UHID_GET_REPORT => { // uhid_get_report_req: id u32 [4..8], rnum u8 [8]. let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + let pairing = ds4_pairing_reply(pad); let data: &[u8] = match ev[8] { - 0x12 => DS4_FEATURE_PAIRING, + 0x12 => &pairing, 0x02 => DS4_FEATURE_CALIBRATION, 0xA3 => DS4_FEATURE_FIRMWARE, _ => &[], }; let _ = self.reply_get_report(id, data); } - _ => {} // Start/Stop/Open/Close/SetReport — ignore + UHID_SET_REPORT => { + // Ack (err=0) so a SET_REPORT writer doesn't block on the kernel's 5 s + // timeout; DS4 feedback arrives as OUTPUT reports (handled above). + let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]); + let _ = self.reply_set_report(id); + } + _ => {} // Start/Stop/Open/Close — ignore } } fb @@ -251,6 +271,18 @@ impl DualShock4Pad { .context("write UHID_GET_REPORT_REPLY")?; Ok(()) } + + fn reply_set_report(&mut self, id: u32) -> Result<()> { + let mut ev = [0u8; UHID_EVENT_SIZE]; + ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes()); + // uhid_set_report_reply_req: id u32 [4..8], err u16 [8..10]. + ev[4..8].copy_from_slice(&id.to_ne_bytes()); + ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack) + self.fd + .write_all(&ev) + .context("write UHID_SET_REPORT_REPLY")?; + Ok(()) + } } impl Drop for DualShock4Pad { @@ -338,7 +370,7 @@ impl PadProto for Ds4LinuxProto { /// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive /// triggers). fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback { - let fb = pad.service(); + let fb = pad.service(idx); PadFeedback { rumble: fb.rumble, hidout: fb @@ -375,4 +407,16 @@ mod tests { assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49); assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3); } + + /// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet — + /// distinct serials so SDL/Steam never dedup two virtual pads into one controller. + #[test] + fn pairing_reply_mac_is_per_pad() { + assert_eq!(ds4_pairing_reply(0).as_slice(), DS4_FEATURE_PAIRING); + let (a, b) = (ds4_pairing_reply(1), ds4_pairing_reply(2)); + assert_eq!(a[0], 0x12); // report id untouched + assert_eq!(a[1], DS4_FEATURE_PAIRING[1].wrapping_add(1)); + assert_eq!(b[1], DS4_FEATURE_PAIRING[1].wrapping_add(2)); + assert_eq!(a[2..], b[2..]); // everything but the low octet identical + } } diff --git a/crates/punktfunk-host/src/inject/linux/gamepad.rs b/crates/punktfunk-host/src/inject/linux/gamepad.rs index 0c21f66c..78017782 100644 --- a/crates/punktfunk-host/src/inject/linux/gamepad.rs +++ b/crates/punktfunk-host/src/inject/linux/gamepad.rs @@ -625,3 +625,105 @@ impl GamepadManager { } } } + +#[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:?}" + ); + } +} diff --git a/crates/punktfunk-host/src/inject/linux/steam_usbip.rs b/crates/punktfunk-host/src/inject/linux/steam_usbip.rs index a186f6da..7716fc13 100644 --- a/crates/punktfunk-host/src/inject/linux/steam_usbip.rs +++ b/crates/punktfunk-host/src/inject/linux/steam_usbip.rs @@ -730,4 +730,74 @@ mod tests { "device not torn down on drop" ); } + + /// On-box smoke test (needs root + `vhci_hcd`): rumble the attached virtual Deck exactly like + /// Steam does — a `0xEB` feature SET_REPORT on the hid-steam hidraw node — and confirm + /// [`SteamDeckUsbip::service`] surfaces `(left, right)` for the 0xCA plane. The Deck presents + /// 3 interfaces (0 mouse / 1 kbd / 2 controller); only the CONTROLLER interface's EP0 handler + /// parses feedback (the idle interfaces ACK silently, like real hardware), and Steam filters + /// on interface 2 — so the write must land there. `#[ignore]`d in CI. + #[test] + #[ignore = "attaches a real vhci_hcd device; needs root + vhci_hcd"] + fn usbip_deck_rumble_flows_via_controller_interface() { + use super::super::steam_proto::ID_TRIGGER_RUMBLE_CMD; + ensure_modules(); + let mut pad = SteamDeckUsbip::open(0).expect("open SteamDeckUsbip (root + vhci_hcd?)"); + let st = SteamState::from_gamepad(0, 0, 0, 0, 0, 0, 0); + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(1500) { + pad.write_state(&st); + let _ = pad.service(); + std::thread::sleep(Duration::from_millis(8)); + } + // The hid-steam hidraw node on USB interface 2 (bInterfaceNumber is the HID device's + // parent attribute). + let node = std::fs::read_dir("/sys/class/hidraw") + .expect("/sys/class/hidraw") + .flatten() + .find_map(|e| { + let ue = + std::fs::read_to_string(e.path().join("device/uevent")).unwrap_or_default(); + let iface = std::fs::read_to_string(e.path().join("device/../bInterfaceNumber")) + .ok() + .and_then(|s| u8::from_str_radix(s.trim(), 16).ok()); + (ue.lines().any(|l| l == "DRIVER=hid-steam") && iface == Some(2)) + .then(|| format!("/dev/{}", e.file_name().to_string_lossy())) + }) + .expect("no hid-steam hidraw on interface 2"); + let f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&node) + .expect("open hidraw"); + // steam_haptic_rumble: [report-id 0, 0xEB, len 9, 0, intensity(2), left(2), right(2), gain(2)] + let mut buf = [0u8; 12]; + buf[1] = ID_TRIGGER_RUMBLE_CMD; + buf[2] = 0x09; + buf[6..8].copy_from_slice(&0xC000u16.to_le_bytes()); + buf[8..10].copy_from_slice(&0x4000u16.to_le_bytes()); + // HIDIOCSFEATURE(12) + let req: libc::c_ulong = + (3 << 30) | ((buf.len() as libc::c_ulong) << 16) | (0x48 << 8) | 0x06; + // SAFETY: HIDIOCSFEATURE reads the 12-byte report from the live `buf` behind the valid + // hidraw fd `f`; the length is encoded in the request, so nothing is written past it. + let rc = unsafe { libc::ioctl(f.as_raw_fd(), req, buf.as_mut_ptr()) }; + assert!( + rc >= 0, + "HIDIOCSFEATURE: {}", + std::io::Error::last_os_error() + ); + let start = Instant::now(); + let mut got = None; + while got.is_none() && start.elapsed() < Duration::from_millis(1500) { + got = pad.service().rumble; + pad.write_state(&st); + std::thread::sleep(Duration::from_millis(8)); + } + assert_eq!( + got, + Some((0xC000, 0x4000)), + "Deck rumble never surfaced from the interface-2 SET_REPORT" + ); + } } diff --git a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs index 97020e2e..95b93d6e 100644 --- a/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs +++ b/crates/punktfunk-host/src/inject/proto/dualsense_proto.rs @@ -42,6 +42,18 @@ pub const DS_FEATURE_FIRMWARE: &[u8] = &[ // report 0x20 (firmware info / build 0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; +/// The pairing reply (report `0x09`) for wire pad `pad`: [`DS_FEATURE_PAIRING`] with the MAC's low +/// octet offset by the pad index. The MAC must be **unique per pad**: `hid-playstation` adopts it +/// as the HID `uniq` (replacing whatever uniq the device was created with), and SDL/Steam dedup +/// controllers by that serial — with identical MACs a second virtual pad reads as the *first* pad +/// re-appearing over another transport and is merged/ignored. +pub fn ds_pairing_reply(pad: u8) -> [u8; 20] { + let mut r = [0u8; 20]; + r.copy_from_slice(DS_FEATURE_PAIRING); + r[1] = r[1].wrapping_add(pad); // MAC lives at bytes 1..7, LSB first + r +} + /// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact /// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense. #[rustfmt::skip] @@ -923,4 +935,16 @@ mod tests { assert!(fb.rumble.is_none()); assert!(fb.hidout.is_empty()); } + + /// The pairing reply keeps the report id and differs across pads ONLY in the MAC low octet — + /// distinct serials so SDL/Steam never dedup two virtual pads into one controller. + #[test] + fn pairing_reply_mac_is_per_pad() { + assert_eq!(ds_pairing_reply(0).as_slice(), DS_FEATURE_PAIRING); + let (a, b) = (ds_pairing_reply(1), ds_pairing_reply(2)); + assert_eq!(a[0], 0x09); // report id untouched + assert_eq!(a[1], DS_FEATURE_PAIRING[1].wrapping_add(1)); + assert_eq!(b[1], DS_FEATURE_PAIRING[1].wrapping_add(2)); + assert_eq!(a[2..], b[2..]); // everything but the low octet identical + } } diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index 622b979f..e5a63b3d 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -207,11 +207,14 @@ ujust add-user-to-input-group # NOT `usermod` on Bazzite (see the note abov sudo udevadm control --reload-rules && sudo udevadm trigger ``` -The rule contents, for reference: +The core rule contents, for reference (the full file additionally grants the `input` group access +to the vhci attach files and to the hidraw nodes of the virtual pads the host creates — Steam/SDL +need hidraw to send a DualSense's adaptive-trigger/lightbar feedback and reliable rumble): ``` KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess" KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", GROUP="input", MODE="0660", TAG+="uaccess" # + 0DF2/09CC/2009/1205/1102 ``` --- diff --git a/scripts/60-punktfunk.rules b/scripts/60-punktfunk.rules index aa5a1ee7..1c1c6357 100644 --- a/scripts/60-punktfunk.rules +++ b/scripts/60-punktfunk.rules @@ -17,3 +17,24 @@ KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", M # are root-only by default while the host runs as a user service — grant the `input` # group write when vhci_hcd appears (module autoload: modules-load.d/punktfunk.conf). ACTION=="add", SUBSYSTEM=="platform", KERNEL=="vhci_hcd.*", RUN+="/bin/sh -c 'chgrp input /sys%p/attach /sys%p/detach && chmod 0660 /sys%p/attach /sys%p/detach'" + +# hidraw access for the VIRTUAL pads this host creates. Steam/SDL drive a DualSense's rich +# feedback (adaptive triggers, lightbar, player LEDs) exclusively over hidraw — the kernel has no +# evdev API for any of it — and Steam without hidraw demotes a PlayStation pad to a generic evdev +# device, losing its rumble handling too. hidraw nodes are root-only by default; the distro's +# steam-devices rules + logind's uaccess ACL cover only the active seat session, so a game +# launched outside it (a headless/dedicated streaming session) is silently feedback-dead. +# GROUP="input" makes access follow the same group the host itself already requires. +# KERNELS matches the HID device name (works for UHID devices, which have no USB parent); +# the ATTRS pair covers the usbip/gadget Deck, which IS a (virtual) USB device. +# DualSense (054C:0CE6) / DualSense Edge (054C:0DF2) / DualShock 4 (054C:09CC) +KERNEL=="hidraw*", KERNELS=="*054C:0CE6*", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", KERNELS=="*054C:0DF2*", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", KERNELS=="*054C:09CC*", GROUP="input", MODE="0660", TAG+="uaccess" +# Switch Pro Controller (057E:2009) — SDL's hidapi driver wants hidraw for rumble/LEDs too +KERNEL=="hidraw*", KERNELS=="*057E:2009*", GROUP="input", MODE="0660", TAG+="uaccess" +# Steam Deck (28DE:1205) / classic Steam Controller (28DE:1102), UHID and usbip/gadget forms +KERNEL=="hidraw*", KERNELS=="*28DE:1205*", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", KERNELS=="*28DE:1102*", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1205", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="28de", ATTRS{idProduct}=="1102", GROUP="input", MODE="0660", TAG+="uaccess"