diff --git a/crates/pf-inject/src/inject/linux/dualsense.rs b/crates/pf-inject/src/inject/linux/dualsense.rs index 39af7025..36a8b90a 100644 --- a/crates/pf-inject/src/inject/linux/dualsense.rs +++ b/crates/pf-inject/src/inject/linux/dualsense.rs @@ -216,9 +216,45 @@ impl Drop for DualSensePad { } } -/// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): UHID transport -/// open, the [`DsState`] mappers, and the kernel-handshake service pass. Everything lifecycle- -/// shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in [`UhidManager`]. +/// How a virtual DualSense is presented to the kernel. +/// +/// [`Usbip`](DsTransport::Usbip) is a *real* USB device (see [`crate::dualsense_usbip`]) and is the +/// only rung that can satisfy wine's ContainerId derivation or GE-Proton's raw-ALSA leg, because +/// both walk sysfs for a `usb_device` parent that a UHID pad simply does not have. +/// [`Uhid`](DsTransport::Uhid) is the long-validated universal fallback: the pad works, games see +/// it, but its speaker cannot be paired to it by a libScePad-style title. +pub enum DsTransport { + Usbip(crate::dualsense_usbip::DualSenseUsbip), + Uhid(DualSensePad), +} + +/// Open the best DualSense transport available: **usbip/`vhci_hcd` → UHID**, degrading on failure +/// so a host without `vhci_hcd` (or without the `punktfunk` group's write on its sysfs `attach`) +/// still gets a working pad. +/// +/// The usbip rung is opt-in while it awaits on-glass verification — see +/// [`crate::dualsense_usbip::usbip_preferred`]. +fn open_transport(idx: u8) -> Result { + if crate::dualsense_usbip::usbip_preferred() { + match crate::dualsense_usbip::DualSenseUsbip::open(idx) { + Ok(u) => return Ok(DsTransport::Usbip(u)), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "usbip DualSense unavailable — falling back to UHID") + } + } + } + let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?; + tracing::info!( + index = idx, + "virtual DualSense created (UHID hid-playstation)" + ); + Ok(DsTransport::Uhid(p)) +} + +/// The DualSense-specific half of the shared stateful manager (see [`PadProto`]): transport +/// open ([`open_transport`]), the [`DsState`] mappers, and the kernel-handshake service pass. +/// Everything lifecycle-shaped (slot table, unplug sweep, heartbeat, feedback dedup) lives in +/// [`UhidManager`]. pub struct DsLinuxProto { /// Fallback policy for the Steam back grips a client may send (the DualSense has no back-button /// HID slot). `PUNKTFUNK_STEAM_REMAP=paddles=…`; default drop. @@ -234,19 +270,14 @@ impl Default for DsLinuxProto { } impl PadProto for DsLinuxProto { - type Pad = DualSensePad; + type Pad = DsTransport; type State = DsState; const LABEL: &'static str = "DualSense"; const DEVICE: &'static str = "DualSense"; const CREATE_HINT: &'static str = ""; - fn open(&mut self, idx: u8) -> Result { - let p = DualSensePad::open(idx, &DsUhidIdentity::dualsense())?; - tracing::info!( - index = idx, - "virtual DualSense created (UHID hid-playstation)" - ); - Ok(p) + fn open(&mut self, idx: u8) -> Result { + open_transport(idx) } fn neutral(&self) -> DsState { @@ -289,15 +320,25 @@ impl PadProto for DsLinuxProto { st.clear_rich(); } - fn write_state(&self, pad: &mut DualSensePad, st: &DsState) { - let _ = pad.write_state(st); + fn write_state(&self, pad: &mut DsTransport, st: &DsState) { + match pad { + DsTransport::Usbip(u) => u.write_state(st), + DsTransport::Uhid(p) => { + let _ = p.write_state(st); + } + } } /// Answer the kernel's init handshake (it blocks `hid-playstation` init until its GET_REPORTs /// are answered — call frequently) and parse a game's feedback: motor rumble on the universal /// 0xCA plane, the rich lightbar/player-LED/trigger events on the 0xCD plane. - fn service(&self, pad: &mut DualSensePad, idx: u8) -> PadFeedback { - let fb = pad.service(idx); + fn service(&self, pad: &mut DsTransport, idx: u8) -> PadFeedback { + let fb = match pad { + // The usbip pad answers EP0 on the server thread, so `service` only drains what the + // handlers already collected — `idx` is baked into the handler at build time. + DsTransport::Usbip(u) => u.service(), + DsTransport::Uhid(p) => p.service(idx), + }; PadFeedback { // No trigger motors on this protocol — see `PadFeedback::rumble`. rumble: fb.rumble.map(|(low, high)| (low, high, 0, 0)), diff --git a/crates/pf-inject/src/inject/linux/dualsense_usbip.rs b/crates/pf-inject/src/inject/linux/dualsense_usbip.rs new file mode 100644 index 00000000..cd6a1632 --- /dev/null +++ b/crates/pf-inject/src/inject/linux/dualsense_usbip.rs @@ -0,0 +1,1039 @@ +//! Virtual Sony DualSense over **USB/IP** (`vhci_hcd`) — a *composite* pad that carries its own +//! USB Audio Class sound card, so the host sees the pad the way a physically-plugged DS5 presents. +//! +//! # Why this exists (the uhid pad is not enough) +//! +//! [`super::dualsense`] creates the pad with `/dev/uhid`. That is enough for the kernel — the HID +//! device claims `bus = BUS_USB` and `hid-playstation` binds it — but the *sysfs topology* is a lie: +//! +//! ```text +//! /sys/devices/virtual/misc/uhid/0003:054C:0CE6.000C [hid] +//! -> /sys/devices/virtual/misc/uhid [misc] +//! -> /sys/devices/virtual +//! ``` +//! +//! There is no `usb_device` anywhere on that chain, and two things a DualSense game depends on are +//! derived by *walking* it: +//! +//! 1. **Wine's ContainerId.** `winebus`'s `get_container_id_for_usb_udev_device` walks a HID device +//! up to its `usb_device` parent to synthesise the Windows ContainerId. With no USB ancestor it +//! logs `Failed to get parent device.` and every endpoint registers as `GUID_NULL`. A +//! libScePad-style title pairs "my controller" with "my controller's speaker" by ContainerId, so +//! the pad reads as having no speaker and the game never opens the haptic stream at all. +//! 2. **GE-Proton's raw-ALSA leg.** GE finds the pad's haptics by enumerating real ALSA **cards** +//! (`snd_card_next` → `snd_card_get_name`/`get_longname` → `snd_ctl_pcm_next_device` → +//! `snd_pcm_open` demanding 48 kHz / S16 / **4 channels**). Minted PipeWire nodes are not ALSA +//! cards and `snd_card_next` cannot see them, however faithfully their proplist impersonates one +//! — which is why [`crate::pad_sink`](../../../punktfunk-host/src/audio/linux/pad_sink.rs) never +//! sets `api.alsa.path`. +//! +//! Both fall out of the same missing fact, and both are fixed by the same change: make the pad a +//! **real USB device**. `vhci_hcd` gives us one with no out-of-tree module and no Secure Boot +//! trouble — the transport [`super::steam_usbip`] already ships and validates on Bazzite/SteamOS. +//! +//! # What this presents +//! +//! The real DualSense's own 4-interface composite layout, reproduced from a `lsusb -v` capture of a +//! wired `054c:0ce6` (see [`tests::config_descriptor_matches_hardware`], which pins the assembled +//! `wTotalLength` to the hardware's `0x00E3`): +//! +//! | interface | class | contents | +//! |---|---|---| +//! | 0 | Audio **Control** | the topology: USB-streaming IN terminal (4ch) → feature unit → Speaker OUT terminal, plus the headset capture chain | +//! | 1 | Audio **Streaming** (out) | alt 0 = zero-bandwidth, alt 1 = isochronous OUT `0x01`, **S16LE 4ch 48 kHz** — the haptics + speaker | +//! | 2 | Audio **Streaming** (in) | alt 0 = zero-bandwidth, alt 1 = isochronous IN `0x82`, S16LE 2ch 48 kHz — the headset mic | +//! | 3 | **HID** | interrupt IN `0x84` / OUT `0x03`, the same report `0x01`/`0x02` codec the uhid pad uses | +//! +//! Because interfaces 0–2 are a genuine UAC 1.0 device, `snd-usb-audio` binds them and mints a real +//! ALSA card named `DualSense Wireless Controller` — the card GE scans for. PipeWire's ALSA monitor +//! then creates the `…HiFi__Speaker__sink` / `…HiFi__SpeakerHaptic__sink` nodes *itself*, from the +//! distro's DualSense UCM, so the node graph is not impersonated any more: it is the real thing. +//! +//! # Where the audio comes out +//! +//! Whatever the game writes lands on us as isochronous OUT packets on endpoint `0x01`, which is the +//! single point every route converges on — PipeWire-mixed or a raw `hw:X,0` grab alike. The handler +//! converts those S16LE quad frames to `f32` and publishes them on a per-pad channel that +//! `punktfunk-host`'s pad-audio streamer drains ([`take_audio_rx`]); the 0xD1 wire path downstream +//! is unchanged. +//! +//! # Known limitation: URBs are answered one at a time +//! +//! The vendored server handles one URB per connection at a time — read a `USBIP_CMD_SUBMIT`, answer +//! it, read the next — and both the interrupt-IN and the isochronous paths *sleep* for their +//! service interval before answering (they must: `vhci_hcd` does not throttle the server side, and +//! for an audio endpoint the completion rate is the sample clock). So while audio is streaming, a +//! HID input poll can queue behind an isochronous URB and wait up to that URB's duration. +//! +//! In practice that lands the pad's input polling in the same band as a **physical** DualSense, +//! which declares `bInterval 6` = 4 ms; it is nevertheless slower than the uhid pad, which has no +//! polling model at all. Fixing it properly means answering URBs concurrently — USB/IP already +//! permits out-of-order completion, since every `USBIP_RET_SUBMIT` carries its `seqnum` — by +//! splitting the socket and spawning per-URB tasks behind a shared writer. That is a change to the +//! vendored server's concurrency model and is deliberately **not** bundled with the first landing +//! of this transport. +//! +//! # Report descriptor fidelity +//! +//! The hardware declares a 289-byte HID report descriptor; we serve +//! [`DUALSENSE_RDESC`](super::dualsense_proto::DUALSENSE_RDESC) (273 bytes), the descriptor the uhid +//! pad has always served and that `hid-playstation` is proven to bind. `wDescriptorLength` follows +//! whatever we actually serve, so the two stay consistent. + +use super::dualsense_proto::{ + ds_pairing_reply, parse_ds_output, serialize_state, DsFeedback, DsState, + DS_FEATURE_CALIBRATION, DS_FEATURE_FIRMWARE, DS_INPUT_REPORT_LEN, DS_PRODUCT, DS_VENDOR, + DUALSENSE_RDESC, +}; +use super::steam_usbip::{attach_device, boxed, UsbipAttachment}; +use crate::sensor_clock::SensorClock; +use anyhow::Result; +use std::any::Any; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use usbip_sim::{ + Direction, IsoPacket, SetupPacket, UsbAltSetting, UsbDevice, UsbEndpoint, UsbInterface, + UsbInterfaceHandler, Version, +}; + +/// The pad's audio quad: ch0/1 = headphone L/R (ch1 doubles as the internal mono speaker), +/// ch2/ch3 = the two haptic voice coils. Same layout `pad_sink`/`split_quad` already assume. +pub const PAD_AUDIO_CHANNELS: usize = 4; + +/// Isochronous OUT endpoint (haptics + speaker), from the hardware capture. +const EP_AUDIO_OUT: u8 = 0x01; +/// Isochronous IN endpoint (headset mic). +const EP_AUDIO_IN: u8 = 0x82; +/// Interrupt IN endpoint (HID input report `0x01`). +const EP_HID_IN: u8 = 0x84; +/// Interrupt OUT endpoint (HID output report `0x02`). +const EP_HID_OUT: u8 = 0x03; + +/// `bInterval` for the HID endpoints. **Deliberately 4 (1 ms at high speed), not the hardware's 6 +/// (4 ms).** Nothing fingerprints the polling interval, and inheriting the hardware's 250 Hz cap +/// would add up to 4 ms of input latency the uhid pad does not have — a real regression on the one +/// axis this product is judged by. +const HID_INTERVAL: u8 = 4; +/// `bInterval` for the isochronous audio endpoints — 4 ⇒ one packet per millisecond at high speed, +/// exactly as the hardware declares. +const AUDIO_INTERVAL: u8 = 4; + +/// `wMaxPacketSize` of the audio OUT endpoint: 49 frames × 4 ch × 2 bytes. One millisecond of +/// 48 kHz is 48 frames; the spare frame is the slack an *adaptive* sink needs to absorb the host's +/// clock drift, and the hardware declares the same 392. +const AUDIO_OUT_MPS: u16 = 392; +/// `wMaxPacketSize` of the audio IN endpoint: 49 frames × 2 ch × 2 bytes. +const AUDIO_IN_MPS: u16 = 196; + +/// How many decoded audio chunks may queue for the streamer before we drop. The consumer wakes +/// every 5 ms and each chunk is ~1 ms, so this is generous; dropping beats blocking the URB reply, +/// which would stall the kernel's ISO ring and xrun the game's stream. +const AUDIO_QUEUE_DEPTH: usize = 256; + +// ---- per-pad audio hand-off to the host's pad-audio streamer ---- + +/// Receivers published by live usbip pads, indexed by wire pad index. +/// +/// The pad is created on the session's input thread while the pad-audio streamer runs on its own +/// thread in another crate, so the two are joined by this registry rather than by a call graph. A +/// `Receiver` is single-consumer by construction, so [`take_audio_rx`] hands it over exactly once; +/// the streamer's existing open-with-backoff loop covers the case where it looks before the pad +/// exists. +static AUDIO_RX: Mutex>>>> = Mutex::new(Vec::new()); + +/// Take the audio receiver for wire pad `pad`, if a usbip DualSense has published one and nobody +/// has claimed it yet. Returns interleaved `f32` chunks of [`PAD_AUDIO_CHANNELS`] channels at +/// 48 kHz — the pad's raw hardware quad. +pub fn take_audio_rx(pad: u8) -> Option>> { + let mut g = AUDIO_RX.lock().ok()?; + g.get_mut(pad as usize).and_then(Option::take) +} + +fn publish_audio_rx(pad: u8, rx: Receiver>) { + if let Ok(mut g) = AUDIO_RX.lock() { + if g.len() <= pad as usize { + g.resize_with(pad as usize + 1, || None); + } + g[pad as usize] = Some(rx); + } +} + +fn clear_audio_rx(pad: u8) { + if let Ok(mut g) = AUDIO_RX.lock() { + if let Some(slot) = g.get_mut(pad as usize) { + *slot = None; + } + } +} + +// ---- descriptor helpers ---- + +fn ep(address: u8, attributes: u8, max_packet_size: u16, interval: u8) -> UsbEndpoint { + UsbEndpoint { + address, + attributes, + max_packet_size, + interval, + } +} + +/// The 9-byte HID class descriptor for interface 3. `bcdHID 1.11`, country 0 — the hardware's +/// values (the Deck helper in [`super::steam_usbip`] bakes its own 1.10/33, hence a local copy). +fn hid_class_descriptor(report_len: usize) -> Vec { + let l = report_len as u16; + #[rustfmt::skip] + let d = vec![ + 0x09, 0x21, // bLength, bDescriptorType (HID) + 0x11, 0x01, // bcdHID 1.11 + 0x00, // bCountryCode + 0x01, // bNumDescriptors + 0x22, // bDescriptorType (Report) + (l & 0xff) as u8, (l >> 8) as u8, // wDescriptorLength + ]; + d +} + +/// The class-specific **Audio Control** descriptor block for interface 0, verbatim from the +/// hardware capture: the 4-channel USB-streaming input terminal feeding a feature unit and the +/// Speaker output terminal, plus the Headset input terminal feeding the USB-streaming output +/// terminal. `wTotalLength` is `0x0049` (73) — the length of everything below. +#[rustfmt::skip] +fn audio_control_descriptor() -> Vec { + vec![ + // HEADER: bcdADC 1.00, wTotalLength 73, 2 streaming interfaces (1, 2) + 0x0A, 0x24, 0x01, 0x00, 0x01, 0x49, 0x00, 0x02, 0x01, 0x02, + // INPUT_TERMINAL 1: USB Streaming (0x0101), assoc 6, 4 ch, FL|FR|RL|RR (0x0033) + 0x0C, 0x24, 0x02, 0x01, 0x01, 0x01, 0x06, 0x04, 0x33, 0x00, 0x00, 0x00, + // FEATURE_UNIT 2: source 1, 1-byte controls, master mute+volume then 4 silent channels + 0x0C, 0x24, 0x06, 0x02, 0x01, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + // OUTPUT_TERMINAL 3: Speaker (0x0301), assoc 4, source 2 + 0x09, 0x24, 0x03, 0x03, 0x01, 0x03, 0x04, 0x02, 0x00, + // INPUT_TERMINAL 4: Headset (0x0402), assoc 3, 2 ch, L|R (0x0003) + 0x0C, 0x24, 0x02, 0x04, 0x02, 0x04, 0x03, 0x02, 0x03, 0x00, 0x00, 0x00, + // FEATURE_UNIT 5: source 4, master mute+volume then 1 silent channel + 0x09, 0x24, 0x06, 0x05, 0x04, 0x01, 0x03, 0x00, 0x00, + // OUTPUT_TERMINAL 6: USB Streaming (0x0101), assoc 1, source 5 + 0x09, 0x24, 0x03, 0x06, 0x01, 0x01, 0x01, 0x05, 0x00, + ] +} + +/// The class-specific **Audio Streaming** block for one direction: `AS_GENERAL` naming the terminal +/// it links to, then a Type-I `FORMAT_TYPE` fixing PCM S16 at 48 kHz over `channels` channels. +#[rustfmt::skip] +fn audio_streaming_descriptor(terminal_link: u8, channels: u8) -> Vec { + vec![ + // AS_GENERAL: bTerminalLink, bDelay 1 frame, wFormatTag PCM (0x0001) + 0x07, 0x24, 0x01, terminal_link, 0x01, 0x01, 0x00, + // FORMAT_TYPE_I: channels, 2-byte subframes, 16-bit, 1 discrete rate, 48000 (24-bit LE) + 0x0B, 0x24, 0x02, 0x01, channels, 0x02, 0x10, 0x01, 0x80, 0xBB, 0x00, + ] +} + +/// The 2 bytes a UAC 1.0 isochronous endpoint descriptor carries past the standard 7 +/// (`bRefresh`, `bSynchAddress`), and the `AS_ENDPOINT` descriptor that follows it. +fn audio_endpoint_extras() -> (Vec, Vec) { + let in_descriptor = vec![0x00, 0x00]; // bRefresh, bSynchAddress + #[rustfmt::skip] + let trailer = vec![ + // AS_ENDPOINT (CS_ENDPOINT / EP_GENERAL): no controls, no lock delay + 0x07, 0x25, 0x01, 0x00, 0x00, 0x00, 0x00, + ]; + (in_descriptor, trailer) +} + +// ---- interface handlers ---- + +/// Answers the standard per-interface requests every interface must survive: `SET_INTERFACE` (which +/// `snd-usb-audio` uses to arm and disarm a streaming altsetting), `GET_INTERFACE`, and +/// `GET_STATUS`. Returns `None` if the request was not one of those. +fn standard_interface_reply(setup: SetupPacket, current_alt: &mut u8) -> Option> { + match (setup.request_type, setup.request) { + // SET_INTERFACE — wValue is the alternate setting. ACK with no data. + (0x01, 0x0B) => { + *current_alt = setup.value as u8; + Some(Vec::new()) + } + // GET_INTERFACE — report whichever setting is armed. + (0x81, 0x0A) => Some(vec![*current_alt]), + // GET_STATUS — interfaces are always "0". + (0x81, 0x00) => Some(vec![0x00, 0x00]), + _ => None, + } +} + +/// Interface 0 — Audio Control. Serves the topology descriptor and answers the feature-unit +/// mute/volume queries `snd-usb-audio` makes while building its mixer. A refused query only costs +/// the mixer control, but answering keeps `amixer`/`wpctl` showing the pad the way hardware does. +#[derive(Debug, Default)] +struct AudioControlHandler { + current_alt: u8, +} + +impl UsbInterfaceHandler for AudioControlHandler { + fn get_class_specific_descriptor(&self) -> Vec { + audio_control_descriptor() + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + _ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + _req: &[u8], + ) -> std::io::Result> { + if let Some(r) = standard_interface_reply(setup, &mut self.current_alt) { + return Ok(r); + } + // Class requests carry the control selector in wValue's high byte: 0x01 = MUTE (1 byte), + // 0x02 = VOLUME (2 bytes, signed 1/256 dB). + let selector = (setup.value >> 8) as u8; + Ok(match (setup.request_type, setup.request, selector) { + // GET_CUR / GET_MIN / GET_MAX / GET_RES on MUTE. + (0xA1, 0x81..=0x84, 0x01) => vec![0x00], // never muted + // GET_CUR on VOLUME — 0 dB. + (0xA1, 0x81, 0x02) => 0i16.to_le_bytes().to_vec(), + // GET_MIN — −60 dB. + (0xA1, 0x82, 0x02) => (-60i16 * 256).to_le_bytes().to_vec(), + // GET_MAX — 0 dB. + (0xA1, 0x83, 0x02) => 0i16.to_le_bytes().to_vec(), + // GET_RES — 3/16 dB, the step the hardware advertises. + (0xA1, 0x84, 0x02) => 0x0030i16.to_le_bytes().to_vec(), + // SET_CUR and anything else: ACK silently, exactly as the hardware tolerates. + _ => Vec::new(), + }) + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +/// Interface 1 — Audio Streaming OUT. **This is the capture point**: every isochronous packet the +/// kernel pushes here is one service interval of the game's haptic/speaker quad. +#[derive(Debug)] +struct SpeakerStreamHandler { + tx: SyncSender>, + current_alt: u8, + /// Packets dropped because the streamer fell behind — logged once per burst rather than per + /// packet so a stalled consumer cannot itself become the log flood. + dropped: u64, +} + +impl UsbInterfaceHandler for SpeakerStreamHandler { + fn get_class_specific_descriptor(&self) -> Vec { + // Alt 0 is the zero-bandwidth setting and carries no class-specific descriptor; the real + // ones live on alt 1 (see `build_device`). + Vec::new() + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + _ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + _req: &[u8], + ) -> std::io::Result> { + Ok(standard_interface_reply(setup, &mut self.current_alt).unwrap_or_default()) + } + + fn handle_iso_urb( + &mut self, + _interface: &UsbInterface, + _ep: UsbEndpoint, + packets: &[IsoPacket<'_>], + ) -> std::io::Result>> { + let total: usize = packets.iter().map(|p| p.data.len()).sum(); + if total >= 2 { + let mut pcm = Vec::with_capacity(total / 2); + for p in packets { + // Trailing odd byte can only mean a truncated frame; `chunks_exact` drops it. + for s in p.data.chunks_exact(2) { + pcm.push(i16::from_le_bytes([s[0], s[1]]) as f32 / 32768.0); + } + } + if !pcm.is_empty() && self.tx.try_send(pcm).is_err() { + self.dropped += 1; + if self.dropped.is_power_of_two() { + tracing::debug!( + dropped = self.dropped, + "pad usb audio queue full — dropping (streamer behind?)" + ); + } + } + } + // OUT packets are acknowledged with no payload; the caller fills in actual_length. + Ok(vec![Vec::new(); packets.len()]) + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +/// Interface 2 — Audio Streaming IN (the headset mic). Declared for topology fidelity so the ALSA +/// card looks like the hardware's; it streams silence until pad-mic capture exists. +#[derive(Debug, Default)] +struct MicStreamHandler { + current_alt: u8, +} + +impl UsbInterfaceHandler for MicStreamHandler { + fn get_class_specific_descriptor(&self) -> Vec { + Vec::new() + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + _ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + _req: &[u8], + ) -> std::io::Result> { + Ok(standard_interface_reply(setup, &mut self.current_alt).unwrap_or_default()) + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +/// Interface 3 — the controller itself. Mirrors [`super::dualsense::DualSensePad`]'s codec: report +/// `0x01` out of the interrupt-IN endpoint, report `0x02` in on the interrupt-OUT endpoint, and the +/// `0x05`/`0x09`/`0x20` feature reports `hid-playstation` demands during init (without them no +/// input devices ever appear). +struct HidHandler { + report: Arc>, + feedback: Arc>, + pad: u8, + current_alt: u8, +} + +// Hand-written because `DsFeedback` is not `Debug` (it carries `HidOutput`s), and the trait bound +// on `UsbInterfaceHandler` only ever wants a name for tracing. +impl std::fmt::Debug for HidHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HidHandler") + .field("pad", &self.pad) + .field("current_alt", &self.current_alt) + .finish() + } +} + +impl UsbInterfaceHandler for HidHandler { + fn get_class_specific_descriptor(&self) -> Vec { + hid_class_descriptor(DUALSENSE_RDESC.len()) + } + + fn handle_urb( + &mut self, + _interface: &UsbInterface, + ep: UsbEndpoint, + _len: u32, + setup: SetupPacket, + req: &[u8], + ) -> std::io::Result> { + if ep.is_ep0() { + if let Some(r) = standard_interface_reply(setup, &mut self.current_alt) { + return Ok(r); + } + return Ok(match (setup.request_type, setup.request) { + // GET_DESCRIPTOR(Report) — standard, interface recipient. + (0x81, 0x06) if (setup.value >> 8) == 0x22 => DUALSENSE_RDESC.to_vec(), + // HID GET_REPORT(Feature) — wValue low byte is the report id. + (0xA1, 0x01) => { + let pairing = ds_pairing_reply(self.pad); + match setup.value as u8 { + 0x05 => DS_FEATURE_CALIBRATION.to_vec(), + 0x09 => pairing.to_vec(), + 0x20 => DS_FEATURE_FIRMWARE.to_vec(), + _ => Vec::new(), + } + } + // HID SET_REPORT — every known DualSense writer sends feedback as an OUTPUT report + // on the interrupt endpoint, but parse it anyway so a SET_REPORT writer is not + // silently ignored. + (0x21, 0x09) => { + self.absorb_output(req); + Vec::new() + } + // SET_IDLE / SET_PROTOCOL. + (0x21, 0x0A) | (0x21, 0x0B) => Vec::new(), + _ => Vec::new(), + }); + } + match ep.direction() { + // Interrupt IN: hand over the current state report. The sim paces this by bInterval. + Direction::In => Ok(self + .report + .lock() + .map(|g| g.to_vec()) + .unwrap_or_else(|_| vec![0u8; DS_INPUT_REPORT_LEN])), + // Interrupt OUT: report 0x02 — rumble / lightbar / player LEDs / adaptive triggers. + Direction::Out => { + self.absorb_output(req); + Ok(Vec::new()) + } + } + } + + fn as_any(&mut self) -> &mut dyn Any { + self + } +} + +impl HidHandler { + /// Fold one HID output report into the pending feedback. Merged rather than replaced: a writer + /// may split rumble and LED updates across reports, and `service` drains at its own cadence. + fn absorb_output(&mut self, data: &[u8]) { + let mut fb = DsFeedback::default(); + parse_ds_output(self.pad, data, &mut fb); + if let Ok(mut g) = self.feedback.lock() { + if fb.rumble.is_some() { + g.rumble = fb.rumble; + } + g.hidout.extend(fb.hidout); + } + } +} + +// ---- device assembly ---- + +/// Assemble the 4-interface composite DualSense. `index` is the wire pad index; it varies nothing +/// in the descriptors (the hardware has no `iSerialNumber`, and `hid-playstation` takes the per-pad +/// identity from the `0x09` pairing feature report instead). +fn build_device( + index: u8, + report: &Arc>, + feedback: &Arc>, + audio_tx: &SyncSender>, +) -> UsbDevice { + let (ep_extra, ep_trailer) = audio_endpoint_extras(); + + let mut dev = UsbDevice::new(0); // one device per server, so the default bus_id "0-0-0" stands. + dev.vendor_id = DS_VENDOR as u16; + dev.product_id = DS_PRODUCT as u16; + dev.usb_version = Version::from(0x0200u16); // bcdUSB 2.00 + dev.device_bcd = Version::from(0x0100u16); // bcdDevice 1.00 + dev.configuration_attributes = 0xC0; // self-powered, as the hardware reports + dev.configuration_max_power = 250; // 500 mA in 2 mA units + dev.set_manufacturer_name("Sony Interactive Entertainment"); + dev.set_product_name("DualSense Wireless Controller"); + + dev + // Interface 0 — Audio Control (no endpoints). + .with_interface( + 0x01, + 0x01, + 0x00, + None, + vec![], + boxed(AudioControlHandler::default()), + ) + // Interface 1 — Audio Streaming OUT: alt 0 idle, alt 1 carries the isochronous endpoint. + .with_interface( + 0x01, + 0x02, + 0x00, + None, + vec![], + boxed(SpeakerStreamHandler { + tx: audio_tx.clone(), + current_alt: 0, + dropped: 0, + }), + ) + .with_alt_settings(vec![UsbAltSetting { + alternate_setting: 1, + interface_class: 0x01, + interface_subclass: 0x02, + interface_protocol: 0x00, + class_specific_descriptor: audio_streaming_descriptor(1, 4), + // 0x09 = isochronous, adaptive, data — the sink must ride the host's clock. + endpoints: vec![ep(EP_AUDIO_OUT, 0x09, AUDIO_OUT_MPS, AUDIO_INTERVAL)], + endpoint_extra: vec![ep_extra.clone()], + endpoint_trailers: vec![ep_trailer.clone()], + }]) + // Interface 2 — Audio Streaming IN (headset mic). + .with_interface( + 0x01, + 0x02, + 0x00, + None, + vec![], + boxed(MicStreamHandler::default()), + ) + .with_alt_settings(vec![UsbAltSetting { + alternate_setting: 1, + interface_class: 0x01, + interface_subclass: 0x02, + interface_protocol: 0x00, + class_specific_descriptor: audio_streaming_descriptor(6, 2), + // 0x05 = isochronous, asynchronous, data — a source runs on its own clock. + endpoints: vec![ep(EP_AUDIO_IN, 0x05, AUDIO_IN_MPS, AUDIO_INTERVAL)], + endpoint_extra: vec![ep_extra], + endpoint_trailers: vec![ep_trailer], + }]) + // Interface 3 — HID. + .with_interface( + 0x03, + 0x00, + 0x00, + None, + vec![ + ep(EP_HID_IN, 0x03, 64, HID_INTERVAL), + ep(EP_HID_OUT, 0x03, 64, HID_INTERVAL), + ], + boxed(HidHandler { + report: report.clone(), + feedback: feedback.clone(), + pad: index, + current_alt: 0, + }), + ) +} + +/// A virtual DualSense presented over USB/IP, carrying its own USB Audio Class sound card. +/// +/// Dropping it detaches the `vhci_hcd` port — the pad and its ALSA card disappear together, exactly +/// as unplugging the hardware would — and withdraws the audio receiver. +pub struct DualSenseUsbip { + report: Arc>, + feedback: Arc>, + clock: SensorClock, + pad: u8, + seq: u8, + _attach: UsbipAttachment, +} + +impl DualSenseUsbip { + /// Bind a virtual DualSense for wire pad `index` and attach it locally via `vhci_hcd`. + /// + /// Fails (so the caller can degrade to uhid) when `vhci_hcd` is absent or its sysfs `attach` is + /// not writable — see [`super::steam_usbip::attach_device`]. + pub fn open(index: u8) -> Result { + let report = Arc::new(Mutex::new([0u8; DS_INPUT_REPORT_LEN])); + let feedback = Arc::new(Mutex::new(DsFeedback::default())); + let (tx, rx) = sync_channel::>(AUDIO_QUEUE_DEPTH); + + let attach = attach_device( + || build_device(index, &report, &feedback, &tx), + &format!("virtual DualSense {index}"), + )?; + + // Publish only once the device is actually attached, so a failed attach leaves no stale + // receiver for the streamer to drain forever. + publish_audio_rx(index, rx); + tracing::info!( + index, + "virtual DualSense created (usbip — real USB topology, so wine derives a ContainerId \ + and GE finds a real ALSA card)" + ); + Ok(DualSenseUsbip { + report, + feedback, + clock: SensorClock::dualsense(), + pad: index, + seq: 0, + _attach: attach, + }) + } + + /// Serialize `st` into report `0x01`, ready for the next interrupt-IN poll. + pub fn write_state(&mut self, st: &DsState) { + self.seq = self.seq.wrapping_add(1); + let ts = self.clock.ds_ticks(Instant::now()); + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + serialize_state(&mut r, st, self.seq, ts); + if let Ok(mut g) = self.report.lock() { + *g = r; + } + } + + /// Drain the feedback the kernel/game has written to the pad since the last call. + pub fn service(&mut self) -> DsFeedback { + self.feedback + .lock() + .map(|mut f| std::mem::take(&mut *f)) + .unwrap_or_default() + } +} + +impl Drop for DualSenseUsbip { + fn drop(&mut self) { + clear_audio_rx(self.pad); + } +} + +/// The sysfs path of an attached virtual DualSense's `usb_device` node, plus the udev properties +/// wine turns into a Windows ContainerId. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsbTopology { + /// e.g. `/sys/devices/platform/vhci_hcd.0/usb11/11-2` — the `usb_device` both container-id + /// derivations must land on. + pub sysfs_path: std::path::PathBuf, + /// `PRODUCT` (`vendor/product/bcd`), `BUSNUM` and `DEVNUM`, the fields wine packs into the GUID. + pub busnum: String, + pub devnum: String, +} + +/// Locate the attached virtual DualSense's USB device in sysfs. +/// +/// Used to *prove* the fix rather than to implement it: with a real USB device the audio sinks get +/// their `device.sysfs.path` from PipeWire's own ALSA/udev path, and wine walks the HID device's own +/// tree — neither needs us to tell it anything. This just lets the devtest print the node both +/// derivations will land on, so a mismatch is visible without launching a game. +/// +/// **Assumes a single virtual DualSense.** It matches on vendor/product under a `vhci_hcd` path, +/// which cannot tell two virtual pads apart (the hardware has no `iSerialNumber`, so neither can +/// anything else); with several attached it returns the first. Good enough for the devtest, which +/// attaches exactly one. +pub fn find_usb_topology() -> Option { + let attr = |dir: &std::path::Path, name: &str| { + std::fs::read_to_string(dir.join(name)) + .ok() + .map(|s| s.trim().to_string()) + }; + for entry in std::fs::read_dir("/sys/bus/usb/devices").ok()?.flatten() { + let dir = entry.path(); + // Interfaces (`11-2:1.0`) have no idVendor; only usb_device nodes do. + if attr(&dir, "idVendor").as_deref() != Some("054c") + || attr(&dir, "idProduct").as_deref() != Some("0ce6") + { + continue; + } + let real = std::fs::canonicalize(&dir).unwrap_or(dir); + if !real.to_string_lossy().contains("vhci_hcd") { + continue; // a physically plugged pad, not ours + } + return Some(UsbTopology { + busnum: attr(&real, "busnum").unwrap_or_default(), + devnum: attr(&real, "devnum").unwrap_or_default(), + sysfs_path: real, + }); + } + None +} + +/// Whether a usbip DualSense should be attempted before the uhid pad. +/// +/// **Opt-in for now** (`PUNKTFUNK_DUALSENSE_USBIP=1`). The uhid pad is the long-validated default +/// and this changes the pad's whole kernel presentation — including minting a real ALSA card that +/// supersedes the pad-audio sinks — so it stays behind a flag until it has been through on-glass +/// verification. Flip the default here once it has. +pub fn usbip_preferred() -> bool { + matches!( + std::env::var("PUNKTFUNK_DUALSENSE_USBIP").ok().as_deref(), + Some("1") | Some("true") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Sum the descriptor bytes the device would emit for its configuration, the way + /// `UsbDevice::handle_urb` assembles them. Kept in lockstep with that assembly by the + /// `wTotalLength` assertion below, which is the number the hardware publishes. + fn assembled_config_len() -> usize { + let (ep_extra, ep_trailer) = audio_endpoint_extras(); + let iso_ep_len = 7 + ep_extra.len() + ep_trailer.len(); + 9 // configuration descriptor + + 9 + audio_control_descriptor().len() // interface 0 + its AC block + + 9 // interface 1 alt 0 + + 9 + audio_streaming_descriptor(1, 4).len() + iso_ep_len // interface 1 alt 1 + + 9 // interface 2 alt 0 + + 9 + audio_streaming_descriptor(6, 2).len() + iso_ep_len // interface 2 alt 1 + + 9 + hid_class_descriptor(DUALSENSE_RDESC.len()).len() + 7 + 7 // interface 3 + } + + /// The assembled configuration descriptor must be exactly as long as the hardware's, which + /// `lsusb -v` on a wired `054c:0ce6` reports as `wTotalLength 0x00e3`. This is the cheapest + /// possible check that the whole descriptor set — terminal topology, both streaming interfaces + /// with their alt settings, the 9-byte isochronous endpoints, the HID interface — is shaped + /// like the real pad rather than merely self-consistent. + #[test] + fn config_descriptor_matches_hardware() { + assert_eq!(assembled_config_len(), 0x00E3); + } + + /// The Audio Control block's own `wTotalLength` (bytes 5..7 of the HEADER descriptor) must + /// equal the block's real length, or `snd-usb-audio` walks off the end of the topology and + /// creates no PCM at all. + #[test] + fn audio_control_total_length_is_self_consistent() { + let d = audio_control_descriptor(); + let stated = u16::from_le_bytes([d[5], d[6]]) as usize; + assert_eq!(stated, d.len(), "AC header wTotalLength vs actual block"); + assert_eq!(stated, 0x49, "hardware publishes 73"); + } + + /// Every descriptor in the Audio Control block must declare its own `bLength` correctly — + /// the kernel walks the block by hopping `bLength` bytes at a time, so one wrong byte + /// desynchronises everything after it. + #[test] + fn audio_control_sub_descriptors_walk_cleanly() { + let d = audio_control_descriptor(); + let mut off = 0; + let mut seen = 0; + while off < d.len() { + let len = d[off] as usize; + assert!(len >= 3, "descriptor at {off} has absurd bLength {len}"); + assert_eq!(d[off + 1], 0x24, "CS_INTERFACE type at {off}"); + off += len; + seen += 1; + } + assert_eq!(off, d.len(), "descriptor walk overran the block"); + assert_eq!(seen, 7, "header + 2 input + 2 feature + 2 output terminals"); + } + + /// The streaming block fixes the format the whole design depends on: 48 kHz, 16-bit, and + /// **4 channels** on the OUT side. GE-Proton rejects any haptic PCM that is not exactly that, + /// so a slip here reproduces the original bug with a real ALSA card in place. + #[test] + fn streaming_format_is_48k_s16_quad() { + let d = audio_streaming_descriptor(1, 4); + let fmt = &d[7..]; // skip AS_GENERAL + assert_eq!(fmt[0] as usize, fmt.len(), "FORMAT_TYPE bLength"); + assert_eq!(fmt[3], 0x01, "FORMAT_TYPE_I"); + assert_eq!(fmt[4], 4, "bNrChannels"); + assert_eq!(fmt[5], 2, "bSubframeSize"); + assert_eq!(fmt[6], 16, "bBitResolution"); + let rate = u32::from_le_bytes([fmt[8], fmt[9], fmt[10], 0]); + assert_eq!(rate, 48_000); + } + + /// The OUT endpoint must hold a whole millisecond of the quad with a frame to spare, or an + /// adaptive sink underruns the moment the host's clock drifts. + #[test] + fn audio_out_packet_holds_a_millisecond_of_quad() { + let bytes_per_frame = PAD_AUDIO_CHANNELS * 2; + assert_eq!(AUDIO_OUT_MPS as usize, 49 * bytes_per_frame); + assert!(AUDIO_OUT_MPS as usize >= 48 * bytes_per_frame); + } + + /// Isochronous OUT packets must turn into interleaved `f32` quad frames, and the URB must be + /// acknowledged packet-for-packet whatever the consumer does. + #[test] + fn iso_out_decodes_s16_quad_to_f32() { + let (tx, rx) = sync_channel::>(4); + let mut h = SpeakerStreamHandler { + tx, + current_alt: 1, + dropped: 0, + }; + // Two frames: full-scale positive on ch0, full-scale negative on ch3. + let mut raw = Vec::new(); + for s in [i16::MAX, 0, 0, i16::MIN, 0, 0, 0, 0] { + raw.extend_from_slice(&s.to_le_bytes()); + } + let packets = [IsoPacket { + data: &raw, + requested_len: raw.len(), + }]; + let intf = probe_interface(); + let replies = h + .handle_iso_urb(&intf, ep(EP_AUDIO_OUT, 0x09, AUDIO_OUT_MPS, 4), &packets) + .expect("iso urb"); + assert_eq!(replies.len(), 1, "one reply per packet"); + assert!(replies[0].is_empty(), "OUT packets carry no reply payload"); + + let pcm = rx.try_recv().expect("decoded chunk"); + assert_eq!(pcm.len(), 8, "2 frames x 4 channels"); + assert!( + (pcm[0] - 0.999_97).abs() < 1e-4, + "ch0 full scale: {}", + pcm[0] + ); + assert_eq!(pcm[3], -1.0, "ch3 full scale negative"); + assert_eq!(pcm[4], 0.0); + } + + /// A wedged consumer must not wedge the URB path: packets are dropped, the reply still comes. + #[test] + fn iso_out_drops_rather_than_blocking_when_the_streamer_stalls() { + let (tx, _rx) = sync_channel::>(1); + let mut h = SpeakerStreamHandler { + tx, + current_alt: 1, + dropped: 0, + }; + let raw = vec![0u8; 8 * PAD_AUDIO_CHANNELS * 2]; + let packets = [IsoPacket { + data: &raw, + requested_len: raw.len(), + }]; + let intf = probe_interface(); + for _ in 0..8 { + let replies = h + .handle_iso_urb(&intf, ep(EP_AUDIO_OUT, 0x09, AUDIO_OUT_MPS, 4), &packets) + .expect("iso urb"); + assert_eq!(replies.len(), 1); + } + assert!(h.dropped > 0, "a full queue must register drops"); + } + + /// `SET_INTERFACE` is how `snd-usb-audio` arms alt 1 to start streaming and returns to alt 0 to + /// stop. Losing it would leave the endpoint permanently idle. + #[test] + fn set_interface_is_tracked_and_reported() { + let mut alt = 0u8; + let set = SetupPacket { + request_type: 0x01, + request: 0x0B, + value: 1, + index: 1, + length: 0, + }; + assert_eq!(standard_interface_reply(set, &mut alt), Some(Vec::new())); + assert_eq!(alt, 1); + let get = SetupPacket { + request_type: 0x81, + request: 0x0A, + value: 0, + index: 1, + length: 1, + }; + assert_eq!(standard_interface_reply(get, &mut alt), Some(vec![1])); + } + + /// `hid-playstation` will not publish any input device until the `0x05`/`0x09`/`0x20` feature + /// reports answer, and the `0x09` MAC must differ per pad or SDL/Steam merge the two pads. + #[test] + fn hid_feature_reports_answer_and_the_mac_is_per_pad() { + let feature = |h: &mut HidHandler, id: u8| { + let setup = SetupPacket { + request_type: 0xA1, + request: 0x01, + value: 0x0300 | id as u16, + index: 3, + length: 64, + }; + h.handle_urb( + &probe_interface(), + UsbEndpoint { + address: 0x80, + attributes: 0x00, + max_packet_size: 64, + interval: 0, + }, + 64, + setup, + &[], + ) + .expect("feature") + }; + let mk = |pad| HidHandler { + report: Arc::new(Mutex::new([0u8; DS_INPUT_REPORT_LEN])), + feedback: Arc::new(Mutex::new(DsFeedback::default())), + pad, + current_alt: 0, + }; + let (mut a, mut b) = (mk(0), mk(1)); + assert_eq!(feature(&mut a, 0x05), DS_FEATURE_CALIBRATION.to_vec()); + assert_eq!(feature(&mut a, 0x20), DS_FEATURE_FIRMWARE.to_vec()); + let (m0, m1) = (feature(&mut a, 0x09), feature(&mut b, 0x09)); + assert_eq!(m0[0], 0x09, "reply keeps its report id"); + assert_ne!(m0[1..7], m1[1..7], "per-pad MAC must differ"); + } + + /// A rumble output report on the interrupt-OUT endpoint must reach `service`, which is the + /// entire feedback path back to the client's pad. + #[test] + fn interrupt_out_report_becomes_feedback() { + let feedback = Arc::new(Mutex::new(DsFeedback::default())); + let mut h = HidHandler { + report: Arc::new(Mutex::new([0u8; DS_INPUT_REPORT_LEN])), + feedback: feedback.clone(), + pad: 0, + current_alt: 0, + }; + // Report 0x02 with the compatible-vibration flag and both motors set. + let mut out = vec![0u8; 48]; + out[0] = 0x02; + out[1] = 0x01; // valid_flag0: compatible vibration + out[3] = 0x80; // right (high-frequency) motor + out[4] = 0x40; // left (low-frequency) motor + h.handle_urb( + &probe_interface(), + ep(EP_HID_OUT, 0x03, 64, HID_INTERVAL), + 0, + SetupPacket { + request_type: 0, + request: 0, + value: 0, + index: 0, + length: 0, + }, + &out, + ) + .expect("interrupt out"); + let got = feedback.lock().unwrap().rumble; + assert_eq!(got, Some((0x4000, 0x8000))); + } + + /// The interrupt-IN endpoint must hand back exactly the report `write_state` last serialized, + /// report id included. + #[test] + fn interrupt_in_serves_the_current_state_report() { + let report = Arc::new(Mutex::new([0u8; DS_INPUT_REPORT_LEN])); + let mut h = HidHandler { + report: report.clone(), + feedback: Arc::new(Mutex::new(DsFeedback::default())), + pad: 0, + current_alt: 0, + }; + let mut r = [0u8; DS_INPUT_REPORT_LEN]; + serialize_state(&mut r, &DsState::neutral(), 7, 0); + *report.lock().unwrap() = r; + let got = h + .handle_urb( + &probe_interface(), + ep(EP_HID_IN, 0x03, 64, HID_INTERVAL), + 64, + SetupPacket { + request_type: 0, + request: 0, + value: 0, + index: 0, + length: 0, + }, + &[], + ) + .expect("interrupt in"); + assert_eq!(got.len(), DS_INPUT_REPORT_LEN); + assert_eq!(got[0], 0x01, "report id"); + assert_eq!(got[7], 7, "sequence number"); + } + + /// The audio hand-off is take-once and per-pad, so two pads never cross streams and a second + /// consumer cannot silently steal the first one's samples. + #[test] + fn audio_receiver_handoff_is_take_once_per_pad() { + let (tx, rx) = sync_channel::>(1); + publish_audio_rx(9, rx); + tx.send(vec![0.25; 4]).expect("send"); + let taken = take_audio_rx(9).expect("first take wins"); + assert!(take_audio_rx(9).is_none(), "second take must find nothing"); + assert_eq!(taken.try_recv().expect("chunk"), vec![0.25; 4]); + assert!(take_audio_rx(8).is_none(), "other pads unaffected"); + clear_audio_rx(9); + } + + /// `UsbInterfaceHandler::handle_iso_urb` takes an interface it never reads; build a throwaway. + fn probe_interface() -> UsbInterface { + UsbInterface { + interface_class: 0, + interface_subclass: 0, + interface_protocol: 0, + endpoints: vec![], + string_interface: 0, + class_specific_descriptor: vec![], + alt_settings: vec![], + handler: boxed(MicStreamHandler::default()), + } + } +} diff --git a/crates/pf-inject/src/inject/linux/triton_usbip.rs b/crates/pf-inject/src/inject/linux/triton_usbip.rs index 0259d0fb..45cc0fa2 100644 --- a/crates/pf-inject/src/inject/linux/triton_usbip.rs +++ b/crates/pf-inject/src/inject/linux/triton_usbip.rs @@ -960,6 +960,7 @@ mod tests { endpoints: vec![], string_interface: 0, class_specific_descriptor: vec![], + alt_settings: vec![], handler: boxed(IdleDummy), }; let ep_out = UsbEndpoint { diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 9d304ea7..7c7eda7b 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -486,6 +486,12 @@ pub mod dualsense_edge_windows; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/proto/dualsense_proto.rs"] pub mod dualsense_proto; +/// Linux: virtual DualSense over **USB/IP** (`vhci_hcd`) carrying its own USB Audio Class sound +/// card — the pad with *real* USB topology, so wine can derive a ContainerId for it and GE-Proton +/// finds a real ALSA card. The uhid pad ([`dualsense`]) can satisfy neither. +#[cfg(target_os = "linux")] +#[path = "inject/linux/dualsense_usbip.rs"] +pub mod dualsense_usbip; /// Windows: virtual DualSense via the UMDF minidriver + a shared-memory host channel. #[cfg(target_os = "windows")] #[path = "inject/windows/dualsense_windows.rs"] diff --git a/crates/punktfunk-host/src/audio.rs b/crates/punktfunk-host/src/audio.rs index 8d9e5cf5..49fb3057 100644 --- a/crates/punktfunk-host/src/audio.rs +++ b/crates/punktfunk-host/src/audio.rs @@ -357,6 +357,10 @@ mod linux; // layer mints per-pad sinks and the CLI exposes the `pad-sink-test` devtest. #[cfg(target_os = "linux")] pub(crate) use linux::pad_sink; +// The same pad audio taken one layer lower, when the pad is a real USB device and therefore has a +// real sound card: capture straight off its isochronous endpoint instead of minting a node graph. +#[cfg(target_os = "linux")] +pub(crate) use linux::pad_usb; // DualSense pad-audio endpoint provisioning + loopback capture (design: pad haptics/audio). // pub(crate): the session layer queries endpoints by pad index and the CLI exposes the // `pad-endpoint` devtest. diff --git a/crates/punktfunk-host/src/audio/linux/mod.rs b/crates/punktfunk-host/src/audio/linux/mod.rs index 803b8912..5f921c64 100644 --- a/crates/punktfunk-host/src/audio/linux/mod.rs +++ b/crates/punktfunk-host/src/audio/linux/mod.rs @@ -29,6 +29,7 @@ mod monitor_rate; pub(crate) mod pad_sink; +pub(crate) mod pad_usb; mod stream_sink; use super::{AudioCapturer, MicBackendStats, VirtualMic, SAMPLE_RATE}; diff --git a/crates/punktfunk-host/src/audio/linux/pad_usb.rs b/crates/punktfunk-host/src/audio/linux/pad_usb.rs new file mode 100644 index 00000000..13d4c0a5 --- /dev/null +++ b/crates/punktfunk-host/src/audio/linux/pad_usb.rs @@ -0,0 +1,124 @@ +//! Pad audio captured at the **USB layer** — the counterpart to [`super::pad_sink`] for a pad that +//! is a real USB device ([`pf_inject::dualsense_usbip`]) rather than a minted PipeWire graph. +//! +//! When the pad arrives over `vhci_hcd` it carries its own USB Audio Class sound card, so the host +//! mints nothing: `snd-usb-audio` creates a real ALSA card and PipeWire's own ALSA monitor builds +//! the `…HiFi__Speaker__sink` / `…HiFi__SpeakerHaptic__sink` nodes from the distro's DualSense UCM. +//! Everything a game writes — whether PipeWire mixed it or a raw `hw:X,0` grab produced it — +//! converges on the pad's isochronous OUT endpoint, and *that* is what we capture. +//! +//! Capturing one layer lower is what makes the USB pad worth the complexity: +//! +//! - it is the same point a physical pad's samples reach, so any route a game takes lands here; +//! - there is no impersonated node graph left to keep faithful; and +//! - the sinks that do exist are real, which is precisely what lets wine derive a matching +//! ContainerId for the pad and its speaker (see [`pf_inject::dualsense_usbip`] for why that is +//! the whole point). +//! +//! The decode itself lives in the USB handler; this type is just the [`AudioCapturer`] face of the +//! channel it publishes. + +use crate::audio::AudioCapturer; +use anyhow::{anyhow, Result}; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::Duration; + +/// How long [`next_chunk`](AudioCapturer::next_chunk) waits before reporting "nothing right now". +/// Matches [`super::pad_sink::PadSinkCapturer`]'s idle timeout so a quiet pad behaves identically +/// on both transports. +const IDLE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Interleaved `f32` quad frames lifted straight off the pad's isochronous OUT endpoint. +pub(crate) struct PadUsbCapturer { + rx: Receiver>, + pad: u8, +} + +impl PadUsbCapturer { + /// Claim wire pad `pad`'s USB audio stream. + /// + /// Fails while no usbip pad has published one — which is the normal state for the moment + /// between the pad-audio thread starting and the pad attaching. The streamer's + /// open-with-backoff loop retries, so this costs a late start rather than a silent pad. + pub(crate) fn open(pad: u8) -> Result { + let rx = pf_inject::dualsense_usbip::take_audio_rx(pad) + .ok_or_else(|| anyhow!("no usbip pad audio published for pad {pad} (not attached?)"))?; + tracing::info!(pad, "pad audio capturing from the USB isochronous endpoint"); + Ok(PadUsbCapturer { rx, pad }) + } +} + +impl AudioCapturer for PadUsbCapturer { + fn next_chunk(&mut self) -> Result> { + self.next_chunk_within(IDLE_TIMEOUT) + } + + fn next_chunk_within(&mut self, budget: Duration) -> Result> { + match self.rx.recv_timeout(budget.min(IDLE_TIMEOUT)) { + Ok(chunk) => Ok(chunk), + // Nothing arrived in the budget. The game isn't writing (or the stream is stopped) — + // a quiet pad, not a dead one, exactly as the sink capturer reports it. + Err(RecvTimeoutError::Timeout) => Ok(Vec::new()), + // The sender lives inside the attached USB device, so this can only mean the pad went + // away. Err tells the streamer to reopen, which is what re-arrival should do. + Err(RecvTimeoutError::Disconnected) => Err(anyhow!( + "usbip pad {} detached — audio endpoint gone", + self.pad + )), + } + } + + fn channels(&self) -> u32 { + pf_inject::dualsense_usbip::PAD_AUDIO_CHANNELS as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc::sync_channel; + + fn capturer() -> (std::sync::mpsc::SyncSender>, PadUsbCapturer) { + let (tx, rx) = sync_channel(4); + (tx, PadUsbCapturer { rx, pad: 0 }) + } + + /// The capture is the pad's hardware quad — the channel count the 0xD1 framer splits into + /// speaker (ch0/1) and coils (ch2/3). + #[test] + fn reports_the_hardware_quad() { + assert_eq!(capturer().1.channels(), 4); + } + + /// A quiet pad must read as an empty chunk, never an error: the streamer keeps a capturer + /// through silence and only reopens on a genuine death. + #[test] + fn silence_is_an_empty_chunk_not_an_error() { + let (_tx, mut c) = capturer(); + let got = c + .next_chunk_within(Duration::from_millis(10)) + .expect("silence must not error"); + assert!(got.is_empty()); + } + + /// A detached pad must surface as `Err` so the streamer reopens rather than spinning on a dead + /// channel forever. + #[test] + fn detach_surfaces_as_an_error() { + let (tx, mut c) = capturer(); + drop(tx); + assert!(c.next_chunk_within(Duration::from_millis(10)).is_err()); + } + + /// Samples pass through untouched — the handler already produced interleaved `f32`. + #[test] + fn delivers_the_published_chunk_verbatim() { + let (tx, mut c) = capturer(); + tx.send(vec![0.5, -0.5, 0.25, -0.25]).expect("send"); + assert_eq!( + c.next_chunk_within(Duration::from_millis(50)) + .expect("chunk"), + vec![0.5, -0.5, 0.25, -0.25] + ); + } +} diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index bea9d68a..28179595 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -310,6 +310,118 @@ pub fn pad_sink_test(args: &[String]) -> Result<()> { Ok(()) } +/// Attach the **usbip** virtual DualSense — a real USB device carrying its own USB Audio Class +/// sound card — and capture the pad's audio off its isochronous endpoint. The on-glass gate for +/// everything a UHID pad cannot do, with no client and no game involved. +/// +/// What it proves, in the order the failures happen: +/// +/// 1. **The device enumerates.** `vhci_hcd` accepted the emulated descriptors and `hid-playstation` +/// bound the HID interface. +/// 2. **`snd-usb-audio` bound the audio function**, producing a *real* ALSA card — the thing +/// GE-Proton's `snd_card_next` scan looks for and a minted PipeWire node can never be. +/// 3. **The USB topology exists**, so wine can walk a HID device up to a `usb_device` parent and +/// derive a non-null Windows ContainerId instead of `GUID_NULL`. +/// 4. **Samples flow**, split per pair exactly as `pad-sink-test` reports them. +/// +/// This ignores `PUNKTFUNK_DUALSENSE_USBIP` — asking for the devtest *is* the opt-in. +/// `--pad N` (default 0), `--seconds N` (default 30). +#[cfg(target_os = "linux")] +pub fn pad_usbip_test(args: &[String]) -> Result<()> { + use crate::audio::AudioCapturer as _; + use std::time::{Duration, Instant}; + let arg = |name: &str, default: u64| -> u64 { + args.iter() + .skip_while(|a| *a != name) + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(default) + }; + let secs = arg("--seconds", 30); + let pad = arg("--pad", 0) as u8; + + let _pad = pf_inject::dualsense_usbip::DualSenseUsbip::open(pad).context( + "attach the usbip DualSense (is vhci_hcd loaded, and is \ + /sys/devices/platform/vhci_hcd.0/attach writable by the `punktfunk` group?)", + )?; + // Enumeration, driver bind and the ALSA/PipeWire cascade are all asynchronous; give the kernel + // and PipeWire a moment before reporting what appeared, or the report reads as a failure. + std::thread::sleep(Duration::from_millis(1500)); + + match pf_inject::dualsense_usbip::find_usb_topology() { + Some(t) => println!( + "usb device attached:\n \ + sysfs = {}\n \ + busnum/devnum = {}/{} (with the vendor/product pair, these are exactly the fields \ + wine packs into the ContainerId — the pad's HID device and its audio sink must both \ + resolve to THIS node)\n \ + check the sink agrees:\n \ + pactl list sinks | grep -E 'Name:|sysfs'\n \ + (`sysfs.path` on the sink is what winepulse prefixes with /sys and walks up; with a \ + real card PipeWire fills it in itself)", + t.sysfs_path.display(), + t.busnum, + t.devnum, + ), + None => println!( + "⚠ no 054c:0ce6 usb device found under vhci_hcd — the attach reported success but the \ + kernel did not enumerate it. Check `dmesg | tail -40`." + ), + } + match std::fs::read_to_string("/proc/asound/cards") { + Ok(cards) if cards.contains("DualSense") => println!( + "alsa card present (GE-Proton's snd_card_next scan can see this):\n{}", + cards.trim_end() + ), + Ok(cards) => println!( + "⚠ no DualSense ALSA card — snd-usb-audio did NOT bind the audio function, so \ + GE-Proton's raw-ALSA haptic leg stays blind. /proc/asound/cards:\n{}\n \ + check `dmesg | grep -i 'usb\\|snd' | tail -30`", + cards.trim_end() + ), + Err(e) => println!("⚠ could not read /proc/asound/cards: {e}"), + } + println!( + "drive it (either route converges on the pad's isochronous endpoint):\n \ + via PipeWire: pw-play --target \ + --channel-map 'front-left,front-right,rear-left,rear-right' <48k-file>\n \ + raw ALSA: aplay -D plughw:CARD=Controller -f S16_LE -r 48000 -c 4 <48k-file>\n\ + Capturing for {secs}s…" + ); + + let mut cap = crate::audio::pad_usb::PadUsbCapturer::open(pad) + .context("claim the usbip pad's audio stream")?; + let deadline = Instant::now() + Duration::from_secs(secs); + let (mut chunks, mut samples) = (0u64, 0u64); + // Per-pair peaks, the same split_quad contract pad-sink-test checks: ch0/1 = speaker/headphone, + // ch2/3 = the voice coils. Proving the pairs separately is the point — a channel-order slip in + // the UAC descriptors would smear or zero one pair while a global peak still looked healthy. + let (mut peak_spk, mut peak_coil) = (0f32, 0f32); + let mut last_report = Instant::now(); + while Instant::now() < deadline { + let c = cap.next_chunk().context("usb pad capture")?; + if !c.is_empty() { + chunks += 1; + samples += c.len() as u64; + for f in c.chunks_exact(4) { + peak_spk = peak_spk.max(f[0].abs()).max(f[1].abs()); + peak_coil = peak_coil.max(f[2].abs()).max(f[3].abs()); + } + } + if last_report.elapsed() >= Duration::from_secs(1) { + last_report = Instant::now(); + println!( + " chunks={chunks} samples={samples} (~{:.1}ms of 4ch audio) \ + peak_speaker={peak_spk:.4} peak_coils={peak_coil:.4}", + samples as f64 / (4.0 * 48.0) + ); + (chunks, samples, peak_spk, peak_coil) = (0, 0, 0.0, 0.0); + } + } + println!("pad-usbip-test: done"); + Ok(()) +} + /// Create a virtual Switch Pro Controller via UHID and exercise it (validation, no /// streaming session): answers the full hid-nintendo probe conversation, then cycles the /// A/B buttons (positionally swapped) + sweeps the left stick, printing rumble / player- diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index c9dcf750..a6116c16 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -633,6 +633,10 @@ fn real_main() -> Result<()> { // Mint one pad-audio PipeWire sink and capture from it — the Linux 0xD1 source gate. #[cfg(target_os = "linux")] Some("pad-sink-test") => devtest::pad_sink_test(&args), + // Attach the usbip DualSense (real USB device + its own UAC sound card) and capture off its + // isochronous endpoint — the gate for the ContainerId / real-ALSA-card path. + #[cfg(target_os = "linux")] + Some("pad-usbip-test") => devtest::pad_usbip_test(&args), // Create a virtual Switch Pro Controller via UHID and exercise it (validation, no session). #[cfg(target_os = "linux")] Some("switchpro-test") => devtest::switchpro_test(&args), diff --git a/crates/punktfunk-host/src/native/pad_audio.rs b/crates/punktfunk-host/src/native/pad_audio.rs index 14759eec..881e1c41 100644 --- a/crates/punktfunk-host/src/native/pad_audio.rs +++ b/crates/punktfunk-host/src/native/pad_audio.rs @@ -353,11 +353,52 @@ pub(super) fn spawn( } } -/// Linux: mint the pad's PipeWire sink lazily inside the streamer thread (the same -/// open-with-backoff loop the Windows capture rides — a PipeWire hiccup at arrival time starts -/// pad audio late, not never). `edge` picks the DualSense Edge identity for the sink. `None` -/// only for empty kinds, a slot past `PUNKTFUNK_PAD_AUDIO_SLOTS`, or a failed thread spawn; -/// the pad itself keeps working either way, just without audio. +/// Where a Linux pad's audio is captured from. The two are mutually exclusive by construction: +/// a usbip pad brings a **real** ALSA card, so PipeWire builds the pad's sinks itself and minting +/// impersonated ones alongside would produce a duplicate, competing node graph. +#[cfg(target_os = "linux")] +enum LinuxPadCapture { + /// The pad is a real USB device — take the samples off its isochronous endpoint. + Usb(crate::audio::pad_usb::PadUsbCapturer), + /// The pad is UHID — mint the sinks it would have had and capture what lands in them. + Sink(crate::audio::pad_sink::PadSinkCapturer), +} + +#[cfg(target_os = "linux")] +impl crate::audio::AudioCapturer for LinuxPadCapture { + fn next_chunk(&mut self) -> anyhow::Result> { + match self { + LinuxPadCapture::Usb(c) => c.next_chunk(), + LinuxPadCapture::Sink(c) => c.next_chunk(), + } + } + + fn next_chunk_within(&mut self, budget: std::time::Duration) -> anyhow::Result> { + match self { + LinuxPadCapture::Usb(c) => c.next_chunk_within(budget), + LinuxPadCapture::Sink(c) => c.next_chunk_within(budget), + } + } + + fn channels(&self) -> u32 { + match self { + LinuxPadCapture::Usb(c) => c.channels(), + LinuxPadCapture::Sink(c) => c.channels(), + } + } +} + +/// Linux: open the pad's capture lazily inside the streamer thread (the same open-with-backoff +/// loop the Windows capture rides — a hiccup at arrival time starts pad audio late, not never). +/// `edge` picks the DualSense Edge identity for the minted sink. `None` only for empty kinds, a +/// slot past `PUNKTFUNK_PAD_AUDIO_SLOTS`, or a failed thread spawn; the pad itself keeps working +/// either way, just without audio. +/// +/// The transport decides the capture: with the usbip pad selected we wait for *its* endpoint and +/// never mint sinks, because that pad already owns a real sound card. The choice is read from the +/// same flag the pad transport uses rather than from whether a stream happens to have been +/// published yet — otherwise the race between pad arrival and this thread starting would decide +/// it, and losing the race would mint a duplicate node graph over a real card. #[cfg(target_os = "linux")] pub(super) fn spawn( conn: quinn::Connection, @@ -377,6 +418,7 @@ pub(super) fn spawn( return None; } let stop_t = stop.clone(); + let usb = pf_inject::dualsense_usbip::usbip_preferred(); match std::thread::Builder::new() .name(format!("punktfunk1-pad{pad}")) .spawn(move || { @@ -384,7 +426,14 @@ pub(super) fn spawn( conn, pad, kinds, - move || crate::audio::pad_sink::PadSinkCapturer::open(pad, edge), + move || { + if usb { + crate::audio::pad_usb::PadUsbCapturer::open(pad).map(LinuxPadCapture::Usb) + } else { + crate::audio::pad_sink::PadSinkCapturer::open(pad, edge) + .map(LinuxPadCapture::Sink) + } + }, stop_t, ) }) { diff --git a/crates/punktfunk-host/vendor/usbip-sim/NOTICE b/crates/punktfunk-host/vendor/usbip-sim/NOTICE index 6a26fc07..8684af55 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/NOTICE +++ b/crates/punktfunk-host/vendor/usbip-sim/NOTICE @@ -17,6 +17,19 @@ Modifications by the punktfunk project: `handle_urb` (so a simulated interrupt-IN mimics a real device's NAK-until-bInterval behaviour rather than free-running over the loopback link); added the tokio `time` feature for it. + - Added **alternate settings** (`UsbAltSetting`, `UsbDevice::with_alt_settings`). + Upstream emits one interface descriptor per interface with + `bAlternateSetting` hardcoded to 0, which cannot express a USB Audio Class + streaming interface (alt 0 zero-bandwidth, alt 1 carrying the endpoint). + Endpoint descriptors gained an in-descriptor `extra` tail so a UAC + isochronous endpoint can be the 9-byte form (`bRefresh`/`bSynchAddress`). + - Added **isochronous transfer** support (`UsbInterfaceHandler::handle_iso_urb`, + `IsoPacket`, `UsbIpResponse::usbip_ret_submit_iso`, the ISO branch in + `handler`). Upstream parsed `number_of_packets`/`iso_packet_descriptor` off + the wire and then discarded them, always replying with an empty packet + table, which stalls any ISO endpoint. ISO completion is paced by + bInterval × packet count, because for an audio endpoint the completion rate + *is* the device's sample clock. Only the USB/IP server *simulation* path is retained: the device model, the USB/IP wire protocol, and the `UsbInterfaceHandler` trait. The original MIT diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/device.rs b/crates/punktfunk-host/vendor/usbip-sim/src/device.rs index bd7f5171..4e307e4e 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/src/device.rs +++ b/crates/punktfunk-host/vendor/usbip-sim/src/device.rs @@ -187,11 +187,31 @@ impl UsbDevice { endpoints, string_interface, class_specific_descriptor, + alt_settings: Vec::new(), handler, }); self } + /// Attach alternate settings to the interface added most recently by [`with_interface`] + /// (punktfunk addition — see [`UsbAltSetting`]). Chained directly after the `with_interface` + /// that created the alt-0 setting. + /// + /// # Panics + /// If no interface has been added yet, or if any setting uses `alternate_setting == 0` (that + /// number belongs to the interface's own descriptor). + pub fn with_alt_settings(mut self, alts: Vec) -> Self { + assert!( + alts.iter().all(|a| a.alternate_setting != 0), + "alternate_setting 0 is the interface's own descriptor" + ); + self.interfaces + .last_mut() + .expect("with_alt_settings called before with_interface") + .alt_settings = alts; + self + } + pub fn with_device_handler( mut self, handler: Arc>>, @@ -217,7 +237,11 @@ impl UsbDevice { Some((self.ep0_out, None)) } else { for intf in &self.interfaces { - for endpoint in &intf.endpoints { + // Alt-setting endpoints route to the same handler as alt 0 (punktfunk addition): + // one handler implements the whole interface across its settings, and the kernel + // only ever drives the endpoints of the setting it selected. + let alt_eps = intf.alt_settings.iter().flat_map(|a| a.endpoints.iter()); + for endpoint in intf.endpoints.iter().chain(alt_eps) { if endpoint.address == ep { return Some((*endpoint, Some(intf))); } @@ -271,6 +295,47 @@ impl UsbDevice { result } + /// The service interval of `ep` — one packet's worth of time. High/Super speed express + /// `bInterval` as `2^(n-1)` 125 µs microframes; full/low speed as whole milliseconds. + fn service_interval(&self, ep: UsbEndpoint) -> std::time::Duration { + if self.speed == UsbSpeed::High as u32 + || self.speed == UsbSpeed::Super as u32 + || self.speed == UsbSpeed::SuperPlus as u32 + { + let n = ep.interval.clamp(1, 16) as u32; + std::time::Duration::from_micros((1u64 << (n - 1)) * 125) + } else { + std::time::Duration::from_millis(ep.interval.max(1) as u64) + } + } + + /// Dispatch an **isochronous** URB to the owning interface's handler (punktfunk addition). + /// + /// Returns one payload per packet: empty vectors for an OUT endpoint (the host wants only the + /// per-packet `actual_length` back), the sampled data for an IN endpoint. + /// + /// **Paced by `bInterval` × the packet count, and that pacing is the device's audio clock.** + /// Isochronous endpoints move exactly one packet per service interval on real hardware, and + /// `snd-usb-audio` advances its PCM pointer from URB *completions* — it has no other time + /// reference. `vhci_hcd` does not throttle the server side (the same reason the interrupt path + /// above is paced), so completing instantly would both spin the loopback link and tell the + /// kernel the device consumed a whole URB's worth of samples in no time, running the stream's + /// clock away and xrunning it continuously. + pub(crate) async fn handle_iso_urb( + &self, + ep: UsbEndpoint, + intf: Option<&UsbInterface>, + packets: &[IsoPacket<'_>], + ) -> Result>> { + let Some(intf) = intf else { + // ISO on ep0 is not a thing; treat it as an unsupported transfer rather than panicking. + return Err(std::io::Error::other("isochronous transfer to ep0")); + }; + tokio::time::sleep(self.service_interval(ep) * packets.len() as u32).await; + let mut handler = intf.handler.lock().unwrap(); + handler.handle_iso_urb(intf, ep, packets) + } + pub(crate) async fn handle_urb( &self, ep: UsbEndpoint, @@ -284,7 +349,8 @@ impl UsbDevice { use EndpointAttributes::*; use StandardRequest::*; - match (FromPrimitive::from_u8(ep.attributes), ep.direction()) { + // Only bits 1..0 of bmAttributes are the transfer type — see `UsbEndpoint::transfer_type`. + match (ep.transfer_type(), ep.direction()) { (Some(Control), In) => { // control in debug!("Control IN setup={setup_packet:x?}"); @@ -374,18 +440,39 @@ impl UsbDevice { intf_desc.append(&mut specific); // endpoint descriptors for endpoint in &intf.endpoints { - let mut ep_desc = vec![ - 0x07, // bLength - Endpoint as u8, // bDescriptorType: Endpoint - endpoint.address, // bEndpointAddress - endpoint.attributes, // bmAttributes - endpoint.max_packet_size as u8, - (endpoint.max_packet_size >> 8) as u8, // wMaxPacketSize - endpoint.interval, // bInterval - ]; - intf_desc.append(&mut ep_desc); + intf_desc.append(&mut endpoint_descriptor(endpoint, &[])); } desc.append(&mut intf_desc); + + // Alternate settings 1.. (punktfunk addition): another full + // interface descriptor per setting, same bInterfaceNumber. + for alt in &intf.alt_settings { + let mut alt_desc = vec![ + 0x09, // bLength + Interface as u8, // bDescriptorType + i as u8, // bInterfaceNumber + alt.alternate_setting, // bAlternateSetting + alt.endpoints.len() as u8, // bNumEndpoints + alt.interface_class, + alt.interface_subclass, + alt.interface_protocol, + intf.string_interface, // iInterface + ]; + alt_desc.extend_from_slice(&alt.class_specific_descriptor); + for (n, endpoint) in alt.endpoints.iter().enumerate() { + let extra = alt + .endpoint_extra + .get(n) + .map(Vec::as_slice) + .unwrap_or(&[]); + alt_desc + .append(&mut endpoint_descriptor(endpoint, extra)); + if let Some(t) = alt.endpoint_trailers.get(n) { + alt_desc.extend_from_slice(t); + } + } + desc.append(&mut alt_desc); + } } // length let len = desc.len() as u16; @@ -549,6 +636,23 @@ impl UsbDevice { } } +/// Serialize one standard endpoint descriptor. `extra` is appended inside the descriptor and grows +/// `bLength` past 7 — UAC 1.0 isochronous endpoints carry `bRefresh` + `bSynchAddress` that way +/// (punktfunk addition; upstream only ever emitted the 7-byte form). +fn endpoint_descriptor(endpoint: &UsbEndpoint, extra: &[u8]) -> Vec { + let mut d = vec![ + (7 + extra.len()) as u8, // bLength + DescriptorType::Endpoint as u8, // bDescriptorType + endpoint.address, // bEndpointAddress + endpoint.attributes, // bmAttributes + endpoint.max_packet_size as u8, // wMaxPacketSize (lo) + (endpoint.max_packet_size >> 8) as u8, // wMaxPacketSize (hi) + endpoint.interval, // bInterval + ]; + d.extend_from_slice(extra); + d +} + /// A handler for URB targeting the device pub trait UsbDeviceHandler: std::fmt::Debug { /// Handle a URB(USB Request Block) targeting at this device @@ -574,3 +678,81 @@ pub trait UsbDeviceHandler: std::fmt::Debug { } // (In-crate test module removed in the vendored copy — see NOTICE.) + +#[cfg(test)] +mod pacing_tests { + use super::*; + + fn dev(speed: UsbSpeed) -> UsbDevice { + let mut d = UsbDevice::new(0); + d.speed = speed as u32; + d + } + + fn iso_ep(interval: u8) -> UsbEndpoint { + UsbEndpoint { + address: 0x01, + attributes: EndpointAttributes::Isochronous as u8, + max_packet_size: 392, + interval, + } + } + + /// At high speed `bInterval` counts 125 µs microframes as `2^(n-1)`, so the audio endpoints' + /// `bInterval 4` must come out as exactly one millisecond — the rate that makes a 48 kHz + /// stream advance 48 frames per packet. Getting this wrong retunes the device's sample clock. + #[test] + fn high_speed_interval_4_is_one_millisecond() { + assert_eq!( + dev(UsbSpeed::High).service_interval(iso_ep(4)), + std::time::Duration::from_millis(1) + ); + assert_eq!( + dev(UsbSpeed::High).service_interval(iso_ep(1)), + std::time::Duration::from_micros(125) + ); + assert_eq!( + dev(UsbSpeed::High).service_interval(iso_ep(6)), + std::time::Duration::from_millis(4) + ); + } + + /// `bmAttributes` carries the synchronisation and usage type above the transfer type, so a real + /// UAC endpoint is `0x05`/`0x09` rather than a bare `0x01`. Decoding the whole byte returns + /// `None` for those and used to reach `unimplemented!()`; only bits 1..0 may be decoded. + #[test] + fn transfer_type_ignores_the_sync_and_usage_bits() { + let iso = |attrs| { + UsbEndpoint { + address: 0x01, + attributes: attrs, + max_packet_size: 392, + interval: 4, + } + .transfer_type() + }; + // 0x09 = isochronous + adaptive data (the DualSense's speaker/haptic endpoint), + // 0x05 = isochronous + asynchronous data (its microphone endpoint). + assert!(matches!(iso(0x09), Some(EndpointAttributes::Isochronous))); + assert!(matches!(iso(0x05), Some(EndpointAttributes::Isochronous))); + assert!(matches!(iso(0x01), Some(EndpointAttributes::Isochronous))); + // The plain forms every pre-existing device used must keep decoding as before. + assert!(matches!(iso(0x03), Some(EndpointAttributes::Interrupt))); + assert!(matches!(iso(0x02), Some(EndpointAttributes::Bulk))); + assert!(matches!(iso(0x00), Some(EndpointAttributes::Control))); + } + + /// Full speed states `bInterval` in whole milliseconds instead, and 0 must not mean "no wait" + /// (that would free-run the link). + #[test] + fn full_speed_interval_is_milliseconds_and_never_zero() { + assert_eq!( + dev(UsbSpeed::Full).service_interval(iso_ep(1)), + std::time::Duration::from_millis(1) + ); + assert_eq!( + dev(UsbSpeed::Full).service_interval(iso_ep(0)), + std::time::Duration::from_millis(1) + ); + } +} diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs b/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs index 19b5c295..fa0def0c 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs +++ b/crates/punktfunk-host/vendor/usbip-sim/src/endpoint.rs @@ -28,4 +28,16 @@ impl UsbEndpoint { pub fn is_ep0(&self) -> bool { self.address & 0x7F == 0 } + + /// The endpoint's **transfer type**, which is only bits 1..0 of `bmAttributes`. + /// + /// punktfunk fix: the rest of the byte is meaningful — for an isochronous endpoint bits 3..2 are + /// the synchronisation type and bits 5..4 the usage type (USB 2.0 §9.6.6). A real UAC endpoint + /// therefore reads `0x05` (async data) or `0x09` (adaptive data), and matching the *whole* byte + /// against [`EndpointAttributes`] yields `None` for both — which used to fall through to + /// `unimplemented!()` and panic the connection task. Every endpoint the crate shipped with was + /// a plain `0x03` interrupt, so nothing noticed until an audio endpoint arrived. + pub fn transfer_type(&self) -> Option { + num_traits::FromPrimitive::from_u8(self.attributes & 0x03) + } } diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs b/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs index 1a9733ae..676c89bd 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs +++ b/crates/punktfunk-host/vendor/usbip-sim/src/interface.rs @@ -1,5 +1,37 @@ use super::*; +/// One **alternate setting** of a [`UsbInterface`] beyond the implicit `bAlternateSetting 0` the +/// interface's own fields describe. +/// +/// punktfunk addition. The upstream crate emits exactly one interface descriptor per interface with +/// `bAlternateSetting` hardcoded to 0, which cannot express a USB Audio Class streaming interface: +/// UAC requires alt 0 to be the zero-bandwidth setting (no endpoints) and the *streaming* endpoint +/// to live on alt 1, so the host can release the bus bandwidth when the stream is idle. A virtual +/// DualSense has to present that shape verbatim or `snd-usb-audio` will not create a PCM for it. +/// +/// Endpoints declared here are routed to the owning interface's handler exactly like alt-0 +/// endpoints ([`UsbDevice::find_ep`](crate::UsbDevice) searches both), because a single handler +/// implements the whole interface across its settings. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct UsbAltSetting { + /// `bAlternateSetting` — must be non-zero (0 is the interface's own descriptor). + pub alternate_setting: u8, + pub interface_class: u8, + pub interface_subclass: u8, + pub interface_protocol: u8, + /// Class-specific descriptors emitted between this setting's interface and endpoint descriptors. + pub class_specific_descriptor: Vec, + pub endpoints: Vec, + /// Bytes appended **inside** each endpoint descriptor, growing its `bLength` past the standard + /// 7 — a UAC 1.0 isochronous endpoint carries `bRefresh` + `bSynchAddress` there, making it 9 + /// bytes. Indexed in lockstep with `endpoints`; a missing entry means the plain 7-byte form. + pub endpoint_extra: Vec>, + /// Whole class-specific descriptors emitted **after** each endpoint descriptor — the UAC 1.0 + /// `AS_ENDPOINT` (`CS_ENDPOINT`/`EP_GENERAL`) descriptor. Indexed like `endpoint_extra`. + pub endpoint_trailers: Vec>, +} + /// Represent a USB interface #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(Serialize))] @@ -10,11 +42,30 @@ pub struct UsbInterface { pub endpoints: Vec, pub string_interface: u8, pub class_specific_descriptor: Vec, + /// Alternate settings 1.. (punktfunk addition; empty for a single-setting interface, which is + /// the upstream behaviour). + pub alt_settings: Vec, #[cfg_attr(feature = "serde", serde(skip))] pub handler: Arc>>, } +/// One packet of an isochronous URB, as described by the USB/IP `iso_packet_descriptor` array +/// (punktfunk addition — see [`UsbInterfaceHandler::handle_iso_urb`]). +/// +/// The kernel submits an ISO URB covering several service intervals at once (a 1 ms-per-packet +/// audio endpoint typically arrives 8 packets at a time), so a handler must see the packet +/// boundaries rather than one flat buffer: each packet is an independent frame whose length may +/// differ, and the reply reports an `actual_length` per packet. +#[derive(Debug, Clone, Copy)] +pub struct IsoPacket<'a> { + /// This packet's payload, sliced out of the URB transfer buffer. Empty for an IN endpoint. + pub data: &'a [u8], + /// The `length` field from the descriptor — how many bytes the host offered (OUT) or expects + /// at most (IN). + pub requested_len: usize, +} + /// A handler of a custom usb interface pub trait UsbInterfaceHandler: std::fmt::Debug { /// Return the class specific descriptor which is inserted between interface descriptor and endpoint descriptor @@ -33,6 +84,28 @@ pub trait UsbInterfaceHandler: std::fmt::Debug { req: &[u8], ) -> Result>; + /// Handle an **isochronous** URB — one transfer carrying `packets.len()` service-interval + /// packets (punktfunk addition; ISO is how USB audio moves samples, and the upstream crate + /// dropped `number_of_packets`/`iso_packet_descriptor` on the floor, which stalls any ISO + /// endpoint). + /// + /// For an OUT endpoint each [`IsoPacket::data`] is one service interval's payload; the reply's + /// `transfer_buffer` must be empty. For an IN endpoint `data` is empty and the handler returns + /// one payload per packet. The default implementation **accepts and discards** OUT packets and + /// returns silence for IN, which keeps a declared-but-unused ISO endpoint streaming rather than + /// erroring the URB. + fn handle_iso_urb( + &mut self, + _interface: &UsbInterface, + ep: UsbEndpoint, + packets: &[IsoPacket<'_>], + ) -> Result>> { + Ok(match ep.direction() { + Direction::In => packets.iter().map(|p| vec![0u8; p.requested_len]).collect(), + Direction::Out => vec![Vec::new(); packets.len()], + }) + } + /// Helper to downcast to actual struct /// /// Please implement it as: diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs b/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs index dd11ca4c..a1b1f53d 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs +++ b/crates/punktfunk-host/vendor/usbip-sim/src/lib.rs @@ -82,6 +82,58 @@ impl UsbIpServer { } } +/// Answer one isochronous `USBIP_CMD_SUBMIT` (punktfunk addition). +/// +/// The wire's `iso_packet_descriptor` is `number_of_packets` × 16 bytes of big-endian +/// `offset / length / actual_length / status`. On an OUT transfer the payload for each packet lives +/// at `offset` in the URB's transfer buffer — packets are **not** necessarily contiguous, which is +/// why the offsets must be honoured rather than assuming a flat stride. +async fn handle_iso_submit( + device: &UsbDevice, + header: &usbip_protocol::UsbIpHeaderBasic, + real_ep: u8, + start_frame: u32, + number_of_packets: u32, + data: &[u8], + iso_packet_descriptor: &[u8], +) -> UsbIpResponse { + let n = number_of_packets as usize; + let be = |b: &[u8]| u32::from_be_bytes([b[0], b[1], b[2], b[3]]); + + let mut requested = Vec::with_capacity(n); + let mut packets = Vec::with_capacity(n); + for i in 0..n { + let d = &iso_packet_descriptor[i * 16..i * 16 + 16]; + let offset = be(&d[0..4]) as usize; + let length = be(&d[4..8]); + requested.push(length); + // Slice the packet out of the transfer buffer, tolerating a short/absent buffer (an IN + // transfer carries none) rather than panicking on a malformed table. + let end = offset.saturating_add(length as usize).min(data.len()); + let payload = data.get(offset..end).unwrap_or(&[]); + packets.push(IsoPacket { + data: payload, + requested_len: length as usize, + }); + } + + match device.find_ep(real_ep) { + None => { + warn!("Endpoint {real_ep:02x?} not found (iso)"); + UsbIpResponse::usbip_ret_submit_fail(header) + } + Some((ep, intf)) => match device.handle_iso_urb(ep, intf, &packets).await { + Ok(replies) => { + UsbIpResponse::usbip_ret_submit_iso(header, start_frame, &requested, &replies) + } + Err(err) => { + warn!("Error handling iso URB: {err}"); + UsbIpResponse::usbip_ret_submit_fail(header) + } + }, + } +} + pub async fn handler( mut socket: &mut T, server: Arc, @@ -156,8 +208,11 @@ pub async fn handler( UsbIpCommand::UsbIpCmdSubmit { mut header, transfer_buffer_length, + start_frame, + number_of_packets, setup, data, + iso_packet_descriptor, .. } => { trace!("Got USBIP_CMD_SUBMIT"); @@ -168,6 +223,26 @@ pub async fn handler( header.command = USBIP_RET_SUBMIT.into(); + // Isochronous URBs carry a packet table and must be answered packet-by-packet + // (punktfunk addition — upstream dropped the table, which stalls USB audio). + // `0xFFFFFFFF` is the kernel's documented "not ISO" sentinel; the real + // implementation sends 0, and `read_from_socket` treats both as no table. + if !iso_packet_descriptor.is_empty() { + let res = handle_iso_submit( + device, + &header, + real_ep as u8, + start_frame, + number_of_packets, + &data, + &iso_packet_descriptor, + ) + .await; + res.write_to_socket(socket).await?; + trace!("Sent USBIP_RET_SUBMIT (iso)"); + continue; + } + let res = match device.find_ep(real_ep as u8) { None => { warn!("Endpoint {real_ep:02x?} not found"); diff --git a/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs b/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs index a139e2df..99978361 100644 --- a/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs +++ b/crates/punktfunk-host/vendor/usbip-sim/src/usbip_protocol.rs @@ -385,11 +385,15 @@ impl UsbIpResponse { Vec::with_capacity(48 + transfer_buffer.len() + iso_packet_descriptor.len()); debug_assert!(header.command == USBIP_RET_SUBMIT.into()); - debug_assert!(if header.direction == Direction::In as u32 { - actual_length == transfer_buffer.len() as u32 - } else { - actual_length == 0 - }); + // For an isochronous URB `actual_length` totals the per-packet actual lengths in + // both directions, so the OUT-is-zero rule only applies to non-ISO transfers. + debug_assert!( + if number_of_packets != 0 || header.direction == Direction::In as u32 { + actual_length == transfer_buffer.len() as u32 + } else { + actual_length == 0 + } + ); result.extend_from_slice(&header.to_bytes()); result.extend_from_slice(&status.to_be_bytes()); @@ -464,6 +468,59 @@ impl UsbIpResponse { } } + /// Constructs a successful `USBIP_RET_SUBMIT` for an **isochronous** URB (punktfunk addition). + /// + /// `replies` holds one payload per ISO packet (empty for an OUT endpoint) and `requested` the + /// per-packet `length` the host asked for. The reply's `iso_packet_descriptor` restates the + /// host's `length` and fills in `offset`, `actual_length` and a per-packet `status` of 0. + /// + /// **`actual_length` means "bytes the device transferred", which differs by direction.** On an + /// IN endpoint that is the payload we produced, concatenated into the transfer buffer at the + /// offsets stated. On an OUT endpoint there is no payload to return and the value is the number + /// of bytes we *accepted* — the whole packet. Reporting the empty reply's length there would + /// tell the kernel the device swallowed nothing, and an audio stream would make no progress + /// while looking healthy. + /// + /// Note this deliberately bypasses [`usbip_ret_submit_success`]'s + /// `direction == Out ⇒ actual_length == 0` assertion: for ISO the field carries the total of + /// the per-packet actual lengths in **both** directions. + pub fn usbip_ret_submit_iso( + header: &UsbIpHeaderBasic, + start_frame: u32, + requested: &[u32], + replies: &[Vec], + ) -> Self { + let inbound = header.direction == Direction::In as u32; + let mut transfer_buffer = Vec::new(); + let mut iso_packet_descriptor = Vec::with_capacity(16 * requested.len()); + let mut offset = 0u32; + for (i, &req_len) in requested.iter().enumerate() { + let actual = if inbound { + let payload = replies.get(i).map(Vec::as_slice).unwrap_or(&[]); + let n = payload.len().min(req_len as usize); + transfer_buffer.extend_from_slice(&payload[..n]); + n as u32 + } else { + req_len + }; + iso_packet_descriptor.extend_from_slice(&offset.to_be_bytes()); + iso_packet_descriptor.extend_from_slice(&req_len.to_be_bytes()); + iso_packet_descriptor.extend_from_slice(&actual.to_be_bytes()); + iso_packet_descriptor.extend_from_slice(&0u32.to_be_bytes()); // status: success + offset += actual; + } + Self::UsbIpRetSubmit { + header: header.clone(), + status: 0, + actual_length: transfer_buffer.len() as u32, + start_frame, + number_of_packets: requested.len() as u32, + error_count: 0, + transfer_buffer, + iso_packet_descriptor, + } + } + /// Constructs a failed OP_REP_IMPORT response pub fn usbip_ret_submit_fail(header: &UsbIpHeaderBasic) -> Self { Self::UsbIpRetSubmit { @@ -495,4 +552,85 @@ impl UsbIpResponse { } } -// (In-crate test module removed in the vendored copy — see NOTICE.) +// (Upstream's in-crate test module removed in the vendored copy — see NOTICE. What follows covers +// only the punktfunk isochronous addition.) +#[cfg(test)] +mod iso_tests { + use super::*; + + fn header(direction: u32) -> UsbIpHeaderBasic { + UsbIpHeaderBasic { + command: USBIP_RET_SUBMIT.into(), + seqnum: 42, + devid: 0, + direction, + ep: 1, + } + } + + fn be(b: &[u8]) -> u32 { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) + } + + /// An isochronous OUT reply must restate one 16-byte descriptor per packet with the host's + /// `length` echoed and `actual_length` reporting that we consumed it all. vhci_hcd reads this + /// table to complete the URB; a short or mis-sized table desynchronises the kernel's ISO ring + /// and the audio stream xruns. + #[test] + fn iso_out_reply_acknowledges_every_packet() { + let requested = [192u32, 192, 192]; + let replies = vec![Vec::new(), Vec::new(), Vec::new()]; + let bytes = + UsbIpResponse::usbip_ret_submit_iso(&header(0), 7, &requested, &replies).to_bytes(); + + // 48-byte fixed header: ... status@20, actual_length@24, start_frame@28, + // number_of_packets@32, error_count@36. + assert_eq!(be(&bytes[20..24]), 0, "status"); + assert_eq!(be(&bytes[24..28]), 0, "OUT carries no payload back"); + assert_eq!(be(&bytes[28..32]), 7, "start_frame echoed"); + assert_eq!(be(&bytes[32..36]), 3, "number_of_packets"); + assert_eq!(be(&bytes[36..40]), 0, "error_count"); + + let table = &bytes[48..]; + assert_eq!(table.len(), 3 * 16, "one 16-byte descriptor per packet"); + for i in 0..3 { + let d = &table[i * 16..i * 16 + 16]; + assert_eq!(be(&d[4..8]), 192, "packet {i} length echoed"); + assert_eq!(be(&d[8..12]), 192, "packet {i} fully consumed"); + assert_eq!(be(&d[12..16]), 0, "packet {i} status"); + } + } + + /// An isochronous IN reply packs the per-packet payloads contiguously and states the offset + /// each one starts at, so a short packet cannot make the host misread the packets after it. + #[test] + fn iso_in_reply_packs_payloads_at_the_offsets_it_states() { + let requested = [4u32, 4, 4]; + let replies = vec![vec![1u8, 2, 3, 4], vec![9u8, 9], vec![5u8, 6, 7, 8]]; + let bytes = + UsbIpResponse::usbip_ret_submit_iso(&header(1), 0, &requested, &replies).to_bytes(); + + assert_eq!(be(&bytes[24..28]), 10, "4 + 2 + 4 bytes actually returned"); + let (table_at, payload) = (48 + 10, &bytes[48..58]); + assert_eq!(payload, &[1, 2, 3, 4, 9, 9, 5, 6, 7, 8]); + + let table = &bytes[table_at..]; + let offs: Vec = (0..3).map(|i| be(&table[i * 16..i * 16 + 4])).collect(); + let acts: Vec = (0..3) + .map(|i| be(&table[i * 16 + 8..i * 16 + 12])) + .collect(); + assert_eq!(offs, vec![0, 4, 6], "offsets follow the short packet"); + assert_eq!(acts, vec![4, 2, 4], "actual lengths, short packet included"); + } + + /// A handler that returns more than the host asked for must be truncated, not allowed to + /// overrun the host's buffer. + #[test] + fn iso_in_reply_truncates_an_overlong_payload() { + let bytes = UsbIpResponse::usbip_ret_submit_iso(&header(1), 0, &[2], &[vec![1u8, 2, 3, 4]]) + .to_bytes(); + assert_eq!(be(&bytes[24..28]), 2); + assert_eq!(&bytes[48..50], &[1, 2]); + assert_eq!(be(&bytes[50 + 8..50 + 12]), 2, "actual_length clamped"); + } +} diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index fb9a41d3..ca58ad64 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -146,6 +146,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t |---|---|---| | `PUNKTFUNK_GAMEPAD` | `xbox360` · `xboxone` · `dualsense` · `dualsenseedge` · `dualshock4` · `steamdeck` · `switchpro` · `steamcontroller` · `steamcontroller2` (aliases: `ps5`, `edge`, `ps4`, `deck`, `switch`, `sc2`, `ibex`, …) | The virtual pad the host creates. Usually **auto-resolved from the client's physical controller** — set this only to force a type. `xbox360` (XInput) is the universal fallback. `dualsenseedge` gives the client's back paddles native buttons; `switchpro` gives Nintendo-family pads correct glyphs/layout + gyro. `steamcontroller2` (the 2026 Steam Controller) is passed through **as-is** — the host presents a real SC2 (`28DE:1302`) that Steam Input drives directly, mirroring the physical pad's raw reports (Linux only). DualSense (Edge)/DualShock 4 work on Linux (UHID) and Windows (UMDF); the Steam Deck pad too (Windows via the promoted UMDF identity); Switch Pro and the classic Steam Controller need Linux UHID. Unsupported choices fold to Xbox 360. | | `PUNKTFUNK_STEAM_GADGET` | `1` · `0` | Force the raw USB-gadget virtual Steam Deck on/off. **On by default on SteamOS**, off elsewhere. Lets Steam promote the virtual Deck to full Steam Input. | +| `PUNKTFUNK_DUALSENSE_USBIP` | `1` · `0` *(default off)* | **(Linux, experimental)** Present the virtual DualSense as a **real USB device** over `vhci_hcd`, carrying its own USB Audio Class sound card, instead of as a UHID device. This is what lets a libScePad-style title pair the pad with its own speaker: wine derives a Windows ContainerId by walking sysfs to a `usb_device` parent, which a UHID pad does not have, so on the default path the pad and its speaker both register as `GUID_NULL` and the game never opens the haptic stream. It also gives GE-Proton the real ALSA card its raw-`snd_pcm_open` haptic path scans for. With this on, the pad's audio is captured from its isochronous endpoint and **no PipeWire sinks are minted** — PipeWire builds the real ones from the card. Needs `vhci_hcd` loaded and the `punktfunk` group's write on its sysfs `attach` (both shipped by packaging); degrades to UHID otherwise. | | `PUNKTFUNK_PAD_AUDIO` | `1` · `0` *(default on)* | Controller audio: what a game plays through the DualSense's built-in speaker and voice-coil haptics is streamed to the client's physical pad as its own low-latency plane. On by default and free while idle — silence is never encoded or sent; `0` turns it off host-wide. On Windows the pad's audio device is a pre-provisioned virtual endpoint; on Linux it is a per-pad PipeWire sink minted with the DualSense identity games match on — see [Controller speaker and haptics](/docs/controller-audio). | | `PUNKTFUNK_PAD_AUDIO_SLOTS` | `1`–`4` *(default: Windows `1`, Linux `4`)* | How many controllers can have their own audio at once. On Windows each slot is a pre-provisioned virtual endpoint, so the default stays at one; a Linux sink is minted lazily and costs nothing idle, so every slot is on. | | `PUNKTFUNK_PAD_SINK_NAME` / `PUNKTFUNK_PAD_SINK_DESC` | templates | **(Linux, field debugging)** Override the minted pad sink's `node.name` / `node.description`. `{pad}` and `{mac}` expand per pad. Only for chasing a title whose device matcher wants different strings — the defaults carry every known match surface. |