fix(host): Linux virtual-pad feedback access — hidraw udev rules, per-pad DS MAC, SET_REPORT acks
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m3s
ci / bench (push) Successful in 5m49s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / deploy-docs (push) Successful in 23s
arch / build-publish (push) Successful in 11m11s
android / android (push) Successful in 14m24s
deb / build-publish (push) Successful in 11m55s
ci / rust (push) Successful in 17m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m54s
windows-host / package (push) Successful in 15m11s
apple / swift (push) Successful in 4m43s
apple / screenshots (push) Successful in 21m4s

Root-cause fixes for "rumble + adaptive triggers never work with Linux hosts"
(the capture code itself was proven good on-hardware — see the new tests):

* 60-punktfunk.rules now grants the `input` group the VIRTUAL pads' hidraw
  nodes (DS/Edge/DS4/Switch/Deck/SC). Steam/SDL drive DualSense adaptive
  triggers, lightbar, and player LEDs exclusively over hidraw — and Steam
  without hidraw demotes a PlayStation pad to a generic evdev device, losing
  its rumble handling too. Coverage no longer depends on the distro's
  steam-devices rules + logind's active-seat uaccess ACL (which a headless/
  dedicated streaming session never gets). Verified live: nodes now come up
  root:input 0660.

* Per-pad MAC in the DualSense (0x09) and DS4 (0x12) pairing feature replies:
  hid-playstation adopts the MAC as the HID uniq and SDL/Steam dedup
  controllers by that serial — identical MACs made a second virtual pad read
  as the first one re-connecting over another transport.

* DualSense/DS4 UHID backends now ack UHID_SET_REPORT (err=0) instead of
  ignoring it, so a SET_REPORT writer no longer blocks on the kernel's 5 s
  timeout.

* New #[ignore] on-box tests play the GAME's role against a real kernel and
  pin the full feedback surface (all green on real hw): DualSense evdev-FF +
  raw hidraw output report (rumble/lightbar/LEDs/both trigger blocks verbatim,
  per-pad uniq), uinput X-Box FF upload→pump→stop-on-erase, and usbip Deck
  0xEB rumble via the controller interface (idle interfaces ACK silently,
  like real hardware).

Windows note: the UMDF driver keeps its own pairing blob copies — the shared-
MAC dedup hazard exists there too and needs a driver-side follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:47:13 +02:00
parent 5a384fe788
commit 5c7e0afa99
7 changed files with 557 additions and 12 deletions
@@ -13,9 +13,9 @@
//! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it. //! UMDF-driver backend; this module is just the `/dev/uhid` plumbing around it.
use super::dualsense_proto::{ use super::dualsense_proto::{
edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState, DS_EDGE_PRODUCT, ds_pairing_reply, edge_paddle_bits, parse_ds_output, serialize_state, DsFeedback, DsState,
DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_FEATURE_PAIRING, DS_INPUT_REPORT_LEN, DS_EDGE_PRODUCT, DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT,
DS_PRODUCT, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC, DS_TOUCH_H, DS_TOUCH_W, DS_VENDOR, DUALSENSE_EDGE_RDESC, DUALSENSE_RDESC,
}; };
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager}; use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -33,6 +33,8 @@ const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10; const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11; const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12; 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 HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03; const BUS_USB: u16 = 0x03;
@@ -106,6 +108,9 @@ impl DualSensePad {
Ok(ds) 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<()> { fn send_create2(&mut self, index: u8, id: &DsUhidIdentity) -> Result<()> {
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes()); ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
@@ -161,15 +166,25 @@ impl DualSensePad {
UHID_GET_REPORT => { UHID_GET_REPORT => {
// uhid_get_report_req: id u32 [4..8], rnum u8 [8]. // 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 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] { let data: &[u8] = match ev[8] {
0x05 => DS_FEATURE_CALIBRATION, 0x05 => DS_FEATURE_CALIBRATION,
0x09 => DS_FEATURE_PAIRING, 0x09 => &pairing,
0x20 => DS_FEATURE_FIRMWARE, 0x20 => DS_FEATURE_FIRMWARE,
_ => &[], _ => &[],
}; };
let _ = self.reply_get_report(id, data); 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 fb
@@ -189,6 +204,18 @@ impl DualSensePad {
.context("write UHID_GET_REPORT_REPLY")?; .context("write UHID_GET_REPORT_REPLY")?;
Ok(()) 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 { 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 /// 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. /// client declares for a paddle-bearing physical controller.
pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>; pub type DualSenseEdgeManager = UhidManager<DsEdgeLinuxProto>;
#[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<HidOutput>) {
let start = Instant::now();
let (mut levels, mut hidout) = (Vec::new(), Vec::<HidOutput>::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:?}"
);
}
}
@@ -33,6 +33,8 @@ const UHID_GET_REPORT: u32 = 9;
const UHID_GET_REPORT_REPLY: u32 = 10; const UHID_GET_REPORT_REPLY: u32 = 10;
const UHID_CREATE2: u32 = 11; const UHID_CREATE2: u32 = 11;
const UHID_INPUT2: u32 = 12; 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 HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2) const UHID_EVENT_SIZE: usize = 4 + 4372; // type + union (create2)
const BUS_USB: u16 = 0x03; 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) 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, 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] #[rustfmt::skip]
const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words) const DS4_FEATURE_CALIBRATION: &[u8] = &[ // report 0x02 (IMU calibration; all signed le16 words)
0x02, 0x02,
@@ -204,9 +217,9 @@ impl DualShock4Pad {
/// Service the device, non-blocking: answer the kernel's feature-report GET_REPORTs (pairing / /// 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 /// 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 /// input devices appear) and parse any HID OUTPUT reports (rumble / lightbar) into a
/// [`Ds4Feedback`]. Call frequently — especially right after [`open`] so the init handshake /// [`Ds4Feedback`] for pad `pad`. Call frequently — especially right after [`open`] so the
/// completes. /// init handshake completes.
pub fn service(&mut self) -> Ds4Feedback { pub fn service(&mut self, pad: u8) -> Ds4Feedback {
let mut fb = Ds4Feedback::default(); let mut fb = Ds4Feedback::default();
let mut ev = [0u8; UHID_EVENT_SIZE]; let mut ev = [0u8; UHID_EVENT_SIZE];
while let Ok(n) = self.fd.read(&mut ev) { while let Ok(n) = self.fd.read(&mut ev) {
@@ -223,15 +236,22 @@ impl DualShock4Pad {
UHID_GET_REPORT => { UHID_GET_REPORT => {
// uhid_get_report_req: id u32 [4..8], rnum u8 [8]. // 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 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] { let data: &[u8] = match ev[8] {
0x12 => DS4_FEATURE_PAIRING, 0x12 => &pairing,
0x02 => DS4_FEATURE_CALIBRATION, 0x02 => DS4_FEATURE_CALIBRATION,
0xA3 => DS4_FEATURE_FIRMWARE, 0xA3 => DS4_FEATURE_FIRMWARE,
_ => &[], _ => &[],
}; };
let _ = self.reply_get_report(id, data); 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 fb
@@ -251,6 +271,18 @@ impl DualShock4Pad {
.context("write UHID_GET_REPORT_REPLY")?; .context("write UHID_GET_REPORT_REPLY")?;
Ok(()) 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 { 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 /// 0xCA plane, the lightbar as a 0xCD `Led` event (a DS4 has no player LEDs / adaptive
/// triggers). /// triggers).
fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback { fn service(&self, pad: &mut DualShock4Pad, idx: u8) -> PadFeedback {
let fb = pad.service(); let fb = pad.service(idx);
PadFeedback { PadFeedback {
rumble: fb.rumble, rumble: fb.rumble,
hidout: fb hidout: fb
@@ -375,4 +407,16 @@ mod tests {
assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49); assert_eq!(DS4_FEATURE_FIRMWARE.len(), 49);
assert_eq!(DS4_FEATURE_FIRMWARE[0], 0xA3); 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
}
} }
@@ -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<String> {
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:?}"
);
}
}
@@ -730,4 +730,74 @@ mod tests {
"device not torn down on drop" "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"
);
}
} }
@@ -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, 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 /// Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino — the exact
/// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense. /// descriptor `hid-playstation` (Linux) / `hidclass` (Windows) parses to bind a DualSense.
#[rustfmt::skip] #[rustfmt::skip]
@@ -923,4 +935,16 @@ mod tests {
assert!(fb.rumble.is_none()); assert!(fb.rumble.is_none());
assert!(fb.hidout.is_empty()); 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
}
} }
+4 -1
View File
@@ -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 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=="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=="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
``` ```
--- ---
+21
View File
@@ -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` # 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). # 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'" 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"