From 52aedb30c38b75b3b90f648188d9f2a7842b7543 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 17 Aug 2026 13:01:32 +0200 Subject: [PATCH 1/3] feat(usbip): the vendored simulator learns alternate settings and isochronous transfers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A USB Audio Class device needs both and the crate had neither, so the only USB devices it could express were HID ones. Alternate settings: upstream emits exactly one interface descriptor per interface with bAlternateSetting hardcoded to 0. UAC requires alt 0 to be the zero-bandwidth setting and the streaming endpoint to live on alt 1, so the host can release bus bandwidth while the stream is idle. Endpoint descriptors also gained an in-descriptor tail, because a UAC isochronous endpoint is the 9-byte form carrying bRefresh + bSynchAddress rather than the plain 7. Isochronous transfers: the wire parser already read number_of_packets and the iso_packet_descriptor table off the socket, and then dropped both on the floor, always replying with an empty table — which stalls any ISO endpoint. The reply now restates the table per packet. Two details are load-bearing: - actual_length means "bytes the device transferred", and that differs by direction. On IN it is the payload produced; on OUT there is no payload and it is the number of bytes *accepted*. Reporting the empty OUT reply's length would tell the kernel the device swallowed nothing, and an audio stream would make no progress while looking perfectly healthy. - completion is paced by bInterval x packet count, because for an audio endpoint the completion rate *is* the device's sample clock. snd-usb-audio advances its PCM pointer from URB completions and has no other time reference, and vhci_hcd does not throttle the server side (the same reason the interrupt path is already paced). Also fixes a latent decode bug the audio endpoints would have tripped over: only bits 1..0 of bmAttributes are the transfer type — the rest carry the synchronisation and usage type, so a real UAC endpoint reads 0x05 or 0x09. Matching the whole byte yielded None for both and fell through to unimplemented!(), panicking the connection task. Every endpoint the crate shipped with was a plain 0x03 interrupt, so nothing had noticed. --- .../src/inject/linux/triton_usbip.rs | 1 + crates/punktfunk-host/vendor/usbip-sim/NOTICE | 13 ++ .../vendor/usbip-sim/src/device.rs | 206 +++++++++++++++++- .../vendor/usbip-sim/src/endpoint.rs | 12 + .../vendor/usbip-sim/src/interface.rs | 73 +++++++ .../vendor/usbip-sim/src/lib.rs | 75 +++++++ .../vendor/usbip-sim/src/usbip_protocol.rs | 150 ++++++++++++- 7 files changed, 512 insertions(+), 18 deletions(-) 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/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"); + } +} -- 2.54.0 From e2b37c6050eb36bbdeb71f4de305bb23c93b5539 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 17 Aug 2026 13:01:54 +0200 Subject: [PATCH 2/3] feat(pad): the virtual DualSense can arrive as a real USB device with its own sound card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A game that drives DualSense haptics pairs "my controller" with "my controller's speaker" by Windows ContainerId, and wine derives that by walking the HID device through udev up to a usb_device parent. Our pad is uhid, so its sysfs chain is /sys/devices/virtual/misc/uhid/... with no USB ancestor anywhere: winebus logs "Failed to get parent device." and every endpoint registers as GUID_NULL. Measured against Spider-Man Remastered under GE-Proton11-5, the game resolves both DualSense endpoints, reads their FriendlyName and PhysicalSpeakers, and then declines to open either — and GE's own retarget hook reports "No live Sony controller mono streams were registered", because the game never created one. The same missing fact blocks GE's other route. Its haptic path finds the pad by enumerating real ALSA *cards* (snd_card_next -> 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 pad_sink never sets api.alsa.path. In the field log that scan never ran at all: zero "Checking ALSA card" lines. So present the pad as a real USB device over vhci_hcd, reproducing the hardware's own 4-interface composite layout from an lsusb capture of a wired 054c:0ce6 — audio control, audio streaming out (isochronous, S16LE 4ch 48 kHz, the haptics + speaker), audio streaming in (the headset mic), and HID. 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", and PipeWire's ALSA monitor builds the HiFi__Speaker__sink / HiFi__SpeakerHaptic__sink nodes itself from the distro's DualSense UCM. The node graph stops being impersonated. The transport rides the ladder steam_controller already established (usbip -> uhid, degrading on failure) on the seam steam_usbip::attach_device already exposes, and reuses the udev grant packaging already ships for the virtual Deck — so this needs no new privilege, module or packaging. Opt-in behind PUNKTFUNK_DUALSENSE_USBIP=1 while it awaits on-glass verification: it changes the pad's whole kernel presentation, including superseding the pad-audio sinks with a real card. The descriptor set is pinned by test against the hardware's published wTotalLength of 0x00E3, which is the cheapest check that the terminal topology, both streaming interfaces with their alt settings, the 9-byte isochronous endpoints and the HID interface are all shaped like the real pad rather than merely self-consistent. --- .../pf-inject/src/inject/linux/dualsense.rs | 71 +- .../src/inject/linux/dualsense_usbip.rs | 1039 +++++++++++++++++ crates/pf-inject/src/lib.rs | 6 + 3 files changed, 1101 insertions(+), 15 deletions(-) create mode 100644 crates/pf-inject/src/inject/linux/dualsense_usbip.rs 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/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"] -- 2.54.0 From 74faee315d2daf56bc784ef6ef336f05c9696821 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 17 Aug 2026 13:02:08 +0200 Subject: [PATCH 3/3] feat(pad-audio): capture the pad's audio off its USB endpoint when the pad is a real USB device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the usbip pad the host mints nothing, so there is no sink to capture from: PipeWire builds the pad's real sinks from its real ALSA card. Everything a game writes — PipeWire-mixed or a raw hw:X,0 grab alike — converges on the pad's isochronous OUT endpoint, so capture there instead. That is the same point a physical pad's samples reach, which is what makes any route a game takes land in one place. The 0xD1 wire path downstream is untouched; only the source moves. The two capture modes are mutually exclusive by construction, and the choice is read from the transport flag rather than from whether a stream happens to have been published yet — otherwise the race between pad arrival and the streamer thread starting would decide it, and losing that race would mint a duplicate, competing node graph over a real card. Adds `pad-usbip-test`, the on-glass gate for all of this with no client and no game involved: it attaches the pad, then reports in the order the failures happen — whether the device enumerated, whether snd-usb-audio produced the real ALSA card GE's scan needs, which usb_device node both ContainerId derivations must land on, and finally the per-pair sample peaks that pad-sink-test already reports (ch0/1 speaker, ch2/3 coils), so a channel-order slip in the UAC descriptors cannot hide behind a healthy-looking global peak. --- crates/punktfunk-host/src/audio.rs | 4 + crates/punktfunk-host/src/audio/linux/mod.rs | 1 + .../punktfunk-host/src/audio/linux/pad_usb.rs | 124 ++++++++++++++++++ crates/punktfunk-host/src/devtest.rs | 112 ++++++++++++++++ crates/punktfunk-host/src/main.rs | 4 + crates/punktfunk-host/src/native/pad_audio.rs | 61 ++++++++- docs-site/content/docs/configuration.md | 1 + 7 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 crates/punktfunk-host/src/audio/linux/pad_usb.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/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. | -- 2.54.0