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
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:
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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-
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user