Merge pull request 'A game can't pair the virtual pad with its speaker, because both ContainerIDs need one real usb_device' (#282) from worktree-dualsense-usbip-audio into main
ci / web (push) Successful in 2m5s
ci / rust-arm64 (push) Successful in 2m47s
ci / bun-nix (push) Successful in 39s
ci / docs-site (push) Successful in 1m35s
android / android (push) Successful in 8m22s
deb / build-publish (push) Successful in 4m15s
deb / build-publish-gamescope (push) Successful in 56s
deb / build-publish-client-arm64 (push) Successful in 1m16s
deb / build-publish-host (push) Successful in 5m4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 21s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m3s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4m2s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m23s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 8m6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11m0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16m38s
ci / rust (push) Successful in 6m51s
docker / deploy-docs (push) Successful in 41s
arch / build-publish (push) Successful in 9m20s
windows-host / package (push) Successful in 13m49s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 1m11s
docker / builders-arm64cross (push) Successful in 2m41s
deb / smoke-install (push) Successful in 11m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m20s

Reviewed-on: #282
This commit was merged in pull request #282.
This commit is contained in:
2026-08-17 11:22:18 +00:00
17 changed files with 1914 additions and 39 deletions
+56 -15
View File
@@ -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<DsTransport> {
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<DualSensePad> {
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<DsTransport> {
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)),
File diff suppressed because it is too large Load Diff
@@ -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 {
+6
View File
@@ -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"]
+4
View File
@@ -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.
@@ -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};
@@ -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<Vec<f32>>,
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<PadUsbCapturer> {
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<Vec<f32>> {
self.next_chunk_within(IDLE_TIMEOUT)
}
fn next_chunk_within(&mut self, budget: Duration) -> Result<Vec<f32>> {
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<Vec<f32>>, 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]
);
}
}
+112
View File
@@ -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 <the pad's SpeakerHaptic sink> \
--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-
+4
View File
@@ -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),
+55 -6
View File
@@ -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<Vec<f32>> {
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<Vec<f32>> {
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,
)
}) {
+13
View File
@@ -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
+194 -12
View File
@@ -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<UsbAltSetting>) -> 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<Mutex<Box<dyn UsbDeviceHandler + Send>>>,
@@ -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<Vec<Vec<u8>>> {
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<u8> {
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)
);
}
}
+12
View File
@@ -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<EndpointAttributes> {
num_traits::FromPrimitive::from_u8(self.attributes & 0x03)
}
}
+73
View File
@@ -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<u8>,
pub endpoints: Vec<UsbEndpoint>,
/// 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<Vec<u8>>,
/// 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<Vec<u8>>,
}
/// Represent a USB interface
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(Serialize))]
@@ -10,11 +42,30 @@ pub struct UsbInterface {
pub endpoints: Vec<UsbEndpoint>,
pub string_interface: u8,
pub class_specific_descriptor: Vec<u8>,
/// Alternate settings 1.. (punktfunk addition; empty for a single-setting interface, which is
/// the upstream behaviour).
pub alt_settings: Vec<UsbAltSetting>,
#[cfg_attr(feature = "serde", serde(skip))]
pub handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
}
/// 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<Vec<u8>>;
/// 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<Vec<Vec<u8>>> {
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:
+75
View File
@@ -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<T: AsyncReadExt + AsyncWriteExt + Unpin>(
mut socket: &mut T,
server: Arc<UsbIpServer>,
@@ -156,8 +208,11 @@ pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
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<T: AsyncReadExt + AsyncWriteExt + Unpin>(
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");
+144 -6
View File
@@ -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<u8>],
) -> 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<u32> = (0..3).map(|i| be(&table[i * 16..i * 16 + 4])).collect();
let acts: Vec<u32> = (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");
}
}
+1
View File
@@ -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. |