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
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:
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user