A game can't pair the virtual pad with its speaker, because both ContainerIDs need one real usb_device #282

Merged
enricobuehler merged 3 commits from worktree-dualsense-usbip-audio into main 2026-08-17 11:22:21 +00:00
Owner

Opt-in and default off (PUNKTFUNK_DUALSENSE_USBIP=1). ⚠ Not yet verified on hardware — see "What still needs on-glass verification".

The problem

The Linux pad-audio plane is complete and measured working end-to-end (host sink → capture → 0xD1 → client → a real DS5's voice coils, index-exact, confirmed by hand on 2026-08-17). What still fails is a real game binding to our minted sink. Marvel's Spider-Man Remastered under GE-Proton11-5 enumerates our endpoint correctly and then declines to open it.

Root cause — two halves, and both must agree

HID side (winebus.so): get_container_id_for_usb_udev_device calls udev_device_get_parent_with_subsystem_devtype(dev, "usb", "usb_device"). That parent is the only source — no bluetooth path, no synthesis, no env or registry knob. Our pad is a uhid device whose sysfs chain (/sys/devices/virtual/misc/uhid/…) has no USB ancestor, so the walk fails:

err:hid:get_container_id_for_usb_udev_device Failed to get parent device.
trace:mmdevapi:MMDevice_Register Container id: {00000000-0000-0000-0000-000000000000}

Audio side (winepulse.so, GE-added): the endpoint's ContainerID is derived from the sink's PulseAudio sysfs.path proplist key (PipeWire device.sysfs.path), then the same udev walk with byte-identical GUID packing. pulse_add_device skips the call entirely when that key is absent — and a 407 MB +pulse log contains zero get_container_id lines, proving it was never attempted. Our sinks carry no sysfs.path.

Why that matters: the GUID packs PRODUCT, BUSNUM, DEVNUM and USEC_INITIALIZED of the node it resolves to. So the two derivations must land on the same live usb_device — USB topology for the HID alone would not fix this, and two separate attachments can never match by construction.

Wine performs no GUID_NULL comparison anywhere; it just writes {00000000-…} into SPDRP_BASE_CONTAINERID. All 18 endpoints registered identically, so a trivial null-match would pair the pad with every endpoint — the rejection is the game's. The log corroborates: the game resolved both DualSense endpoints, read FriendlyName/PhysicalSpeakers, then opened neither, and GE reports No live Sony controller mono streams were registered.

Second, independent blocker: GE's haptic path enumerates real ALSA cards (snd_card_nextsnd_ctl_pcm_next_devicesnd_pcm_open demanding 48 kHz/S16/4ch). Minted PipeWire nodes are not ALSA cards; the log has zero Checking ALSA card lines, so that scan never ran either. pad_sink.rs's own module doc already names both gaps as unsatisfiable by a PipeWire graph.

The approach

One composite USB device over vhci_hcd carrying its own UAC sound card — the only shape that closes both halves with a single node, since both ContainerIDs then resolve to the same usb_device and snd-usb-audio produces a genuine ALSA card for GE's scan.

Rejected alternatives: HID-only usbip (fixes half; audio stays GUID_NULL); stamping device.sysfs.path onto minted sinks (still needs a real USB node, and leaves the card scan blind); snd-aloop (card name is fixed to "Loopback"); separate HID + audio devices (GUIDs cannot match).

Cost is low on the axes that usually kill this: vhci_hcd is already loaded and in use for the Steam Controller 2, packaging already ships the udev rule granting the punktfunk group write on the sysfs attach, and attach_device was already a device-agnostic seam. No new privilege, kernel module or packaging.

What's here

  • 52aedb30 — vendored usbip-sim learns alternate settings and isochronous transfers. Two bugs the tests caught: ISO OUT actual_length must report bytes accepted (returning the empty reply's length tells the kernel the device swallowed nothing — audio stalls while looking healthy), and ISO completion must be paced by bInterval, because for an audio endpoint the completion rate is the sample clock. Also fixes a latent panic: only bits 1..0 of bmAttributes are the transfer type, so real UAC endpoints (0x05/0x09) decoded as None and hit unimplemented!().
  • e2b37c60 — the pad's 4-interface composite layout, reproduced from an lsusb -v capture of a real DS5 and pinned by test to the hardware's published wTotalLength 0x00E3.
  • 74faee31 — capture off the ISO endpoint, sink-minting suppressed in that mode, plus a pad-usbip-test devtest.

Gate

Docker, -D warnings: clippy 0, fmt 0; pf-inject 142, usbip-sim 6, punktfunk-host 601 (+22 new). Three of four full runs green — the intermittent failures (sender_delivers_batches, local_summary_reports_a_native_session_as_streaming, a UDP EINTR in untouched code) are pre-existing: pristine origin/main failed 2 of 3 runs in the same environment.

What still needs on-glass verification

None of this has run on hardware — attaching a USB device is not read-only and the test boxes were mid-session. One command:

punktfunk-host pad-usbip-test --pad 0 --seconds 30

It reports in failure order: device enumerated → snd-usb-audio produced the real ALSA card → the usb_device node both ContainerId derivations must land on → per-pair peaks (ch0/1 speaker, ch2/3 coils). Then set PUNKTFUNK_DUALSENSE_USBIP=1 and re-run the game, expecting Created container ID in winebus, a non-null MMDevice_Register Container id, and Routing DualSense.

The one genuinely unproven assumption is that snd-usb-audio binds cleanly over vhci_hcd and its ISO OUT stream survives the loopback. If it does not, the staged fallback is the HID-only rung: attach the pad without the audio interfaces and stamp device.sysfs.path on the minted sinks pointing at an interface node under that same usb_device — not the device itself, since winepulse starts its walk from the parent and pointing at the device resolves to the root hub and mismatches. That closes the ContainerId half without ISO, leaving GE's card scan blind.

Deliberately not included

degrade_if_no_uhid still gates DualSense on /dev/uhid before the transport ladder, which is now over-broad for a pad that does not need it; and the vendored server answers URBs one at a time, so audio pacing can delay a HID poll (it lands near a physical pad's own 4 ms polling, but slower than uhid — fixing it means per-URB tasks, since USB/IP already permits out-of-order completion). No CHANGELOG entry, since the feature is unvalidated and opt-in.

Opt-in and **default off** (`PUNKTFUNK_DUALSENSE_USBIP=1`). ⚠ **Not yet verified on hardware** — see "What still needs on-glass verification". ## The problem The Linux pad-audio plane is complete and measured working end-to-end (host sink → capture → `0xD1` → client → a real DS5's voice coils, index-exact, confirmed by hand on 2026-08-17). What still fails is a **real game** binding to our minted sink. Marvel's Spider-Man Remastered under GE-Proton11-5 enumerates our endpoint correctly and then declines to open it. ## Root cause — two halves, and both must agree **HID side** (`winebus.so`): `get_container_id_for_usb_udev_device` calls `udev_device_get_parent_with_subsystem_devtype(dev, "usb", "usb_device")`. That parent is the *only* source — no bluetooth path, no synthesis, no env or registry knob. Our pad is a `uhid` device whose sysfs chain (`/sys/devices/virtual/misc/uhid/…`) has no USB ancestor, so the walk fails: ``` err:hid:get_container_id_for_usb_udev_device Failed to get parent device. trace:mmdevapi:MMDevice_Register Container id: {00000000-0000-0000-0000-000000000000} ``` **Audio side** (`winepulse.so`, GE-added): the endpoint's ContainerID is derived from the sink's PulseAudio **`sysfs.path`** proplist key (PipeWire `device.sysfs.path`), then the *same* udev walk with *byte-identical* GUID packing. `pulse_add_device` **skips the call entirely** when that key is absent — and a 407 MB `+pulse` log contains zero `get_container_id` lines, proving it was never attempted. Our sinks carry no `sysfs.path`. **Why that matters:** the GUID packs `PRODUCT`, `BUSNUM`, `DEVNUM` and `USEC_INITIALIZED` of the node it resolves to. So the two derivations must land on **the same live `usb_device`** — USB topology for the HID alone would not fix this, and two separate attachments can never match by construction. Wine performs no GUID_NULL comparison anywhere; it just writes `{00000000-…}` into `SPDRP_BASE_CONTAINERID`. All 18 endpoints registered identically, so a trivial null-match would pair the pad with *every* endpoint — the rejection is the game's. The log corroborates: the game resolved both DualSense endpoints, read `FriendlyName`/`PhysicalSpeakers`, then opened neither, and GE reports `No live Sony controller mono streams were registered`. **Second, independent blocker:** GE's haptic path enumerates real ALSA **cards** (`snd_card_next` → `snd_ctl_pcm_next_device` → `snd_pcm_open` demanding 48 kHz/S16/4ch). Minted PipeWire nodes are not ALSA cards; the log has zero `Checking ALSA card` lines, so that scan never ran either. `pad_sink.rs`'s own module doc already names both gaps as unsatisfiable by a PipeWire graph. ## The approach **One composite USB device over `vhci_hcd` carrying its own UAC sound card** — the only shape that closes both halves with a single node, since both ContainerIDs then resolve to the same `usb_device` and `snd-usb-audio` produces a genuine ALSA card for GE's scan. Rejected alternatives: HID-only usbip (fixes half; audio stays GUID_NULL); stamping `device.sysfs.path` onto minted sinks (still needs a real USB node, and leaves the card scan blind); `snd-aloop` (card name is fixed to "Loopback"); separate HID + audio devices (GUIDs cannot match). Cost is low on the axes that usually kill this: `vhci_hcd` is already loaded and in use for the Steam Controller 2, packaging already ships the udev rule granting the `punktfunk` group write on the sysfs `attach`, and `attach_device` was already a device-agnostic seam. **No new privilege, kernel module or packaging.** ## What's here - `52aedb30` — vendored `usbip-sim` learns alternate settings and isochronous transfers. Two bugs the tests caught: ISO **OUT** `actual_length` must report bytes *accepted* (returning the empty reply's length tells the kernel the device swallowed nothing — audio stalls while looking healthy), and ISO completion **must be paced by `bInterval`**, because for an audio endpoint the completion rate *is* the sample clock. Also fixes a latent panic: only bits 1..0 of `bmAttributes` are the transfer type, so real UAC endpoints (`0x05`/`0x09`) decoded as `None` and hit `unimplemented!()`. - `e2b37c60` — the pad's 4-interface composite layout, reproduced from an `lsusb -v` capture of a real DS5 and pinned by test to the hardware's published `wTotalLength 0x00E3`. - `74faee31` — capture off the ISO endpoint, sink-minting suppressed in that mode, plus a `pad-usbip-test` devtest. ## Gate Docker, `-D warnings`: clippy **0**, fmt **0**; pf-inject 142, usbip-sim 6, punktfunk-host 601 (+22 new). Three of four full runs green — the intermittent failures (`sender_delivers_batches`, `local_summary_reports_a_native_session_as_streaming`, a UDP `EINTR` in untouched code) are **pre-existing**: pristine `origin/main` failed 2 of 3 runs in the same environment. ## What still needs on-glass verification None of this has run on hardware — attaching a USB device is not read-only and the test boxes were mid-session. One command: ``` punktfunk-host pad-usbip-test --pad 0 --seconds 30 ``` It reports in failure order: device enumerated → `snd-usb-audio` produced the real ALSA card → the `usb_device` node both ContainerId derivations must land on → per-pair peaks (ch0/1 speaker, ch2/3 coils). Then set `PUNKTFUNK_DUALSENSE_USBIP=1` and re-run the game, expecting `Created container ID` in winebus, a non-null `MMDevice_Register Container id`, and `Routing DualSense`. **The one genuinely unproven assumption** is that `snd-usb-audio` binds cleanly over `vhci_hcd` and its ISO OUT stream survives the loopback. If it does not, the staged fallback is the HID-only rung: attach the pad without the audio interfaces and stamp `device.sysfs.path` on the minted sinks pointing at an *interface* node under that same `usb_device` — not the device itself, since winepulse starts its walk from the parent and pointing at the device resolves to the root hub and mismatches. That closes the ContainerId half without ISO, leaving GE's card scan blind. ## Deliberately not included `degrade_if_no_uhid` still gates DualSense on `/dev/uhid` *before* the transport ladder, which is now over-broad for a pad that does not need it; and the vendored server answers URBs one at a time, so audio pacing can delay a HID poll (it lands near a physical pad's own 4 ms polling, but slower than uhid — fixing it means per-URB tasks, since USB/IP already permits out-of-order completion). No CHANGELOG entry, since the feature is unvalidated and opt-in.
enricobuehler added 3 commits 2026-08-17 11:17:17 +00:00
A USB Audio Class device needs both and the crate had neither, so the only USB
devices it could express were HID ones.

Alternate settings: upstream emits exactly one interface descriptor per
interface with bAlternateSetting hardcoded to 0. UAC requires alt 0 to be the
zero-bandwidth setting and the streaming endpoint to live on alt 1, so the host
can release bus bandwidth while the stream is idle. Endpoint descriptors also
gained an in-descriptor tail, because a UAC isochronous endpoint is the 9-byte
form carrying bRefresh + bSynchAddress rather than the plain 7.

Isochronous transfers: the wire parser already read number_of_packets and the
iso_packet_descriptor table off the socket, and then dropped both on the floor,
always replying with an empty table — which stalls any ISO endpoint. The reply
now restates the table per packet. Two details are load-bearing:

  - actual_length means "bytes the device transferred", and that differs by
    direction. On IN it is the payload produced; on OUT there is no payload and
    it is the number of bytes *accepted*. Reporting the empty OUT reply's length
    would tell the kernel the device swallowed nothing, and an audio stream would
    make no progress while looking perfectly healthy.
  - completion is paced by bInterval x packet count, because for an audio
    endpoint the completion rate *is* the device's sample clock. snd-usb-audio
    advances its PCM pointer from URB completions and has no other time
    reference, and vhci_hcd does not throttle the server side (the same reason
    the interrupt path is already paced).

Also fixes a latent decode bug the audio endpoints would have tripped over:
only bits 1..0 of bmAttributes are the transfer type — the rest carry the
synchronisation and usage type, so a real UAC endpoint reads 0x05 or 0x09.
Matching the whole byte yielded None for both and fell through to
unimplemented!(), panicking the connection task. Every endpoint the crate
shipped with was a plain 0x03 interrupt, so nothing had noticed.
A game that drives DualSense haptics pairs "my controller" with "my
controller's speaker" by Windows ContainerId, and wine derives that by walking
the HID device through udev up to a usb_device parent. Our pad is uhid, so its
sysfs chain is /sys/devices/virtual/misc/uhid/... with no USB ancestor
anywhere: winebus logs "Failed to get parent device." and every endpoint
registers as GUID_NULL. Measured against Spider-Man Remastered under
GE-Proton11-5, the game resolves both DualSense endpoints, reads their
FriendlyName and PhysicalSpeakers, and then declines to open either — and
GE's own retarget hook reports "No live Sony controller mono streams were
registered", because the game never created one.

The same missing fact blocks GE's other route. Its haptic path finds the pad by
enumerating real ALSA *cards* (snd_card_next -> snd_ctl_pcm_next_device ->
snd_pcm_open demanding 48 kHz / S16 / 4 channels). Minted PipeWire nodes are not
ALSA cards and snd_card_next cannot see them however faithfully their proplist
impersonates one — which is why pad_sink never sets api.alsa.path. In the field
log that scan never ran at all: zero "Checking ALSA card" lines.

So present the pad as a real USB device over vhci_hcd, reproducing the
hardware's own 4-interface composite layout from an lsusb capture of a wired
054c:0ce6 — audio control, audio streaming out (isochronous, S16LE 4ch 48 kHz,
the haptics + speaker), audio streaming in (the headset mic), and HID. Because
interfaces 0-2 are a genuine UAC 1.0 device, snd-usb-audio binds them and mints
a real ALSA card named "DualSense Wireless Controller", and PipeWire's ALSA
monitor builds the HiFi__Speaker__sink / HiFi__SpeakerHaptic__sink nodes itself
from the distro's DualSense UCM. The node graph stops being impersonated.

The transport rides the ladder steam_controller already established
(usbip -> uhid, degrading on failure) on the seam steam_usbip::attach_device
already exposes, and reuses the udev grant packaging already ships for the
virtual Deck — so this needs no new privilege, module or packaging.

Opt-in behind PUNKTFUNK_DUALSENSE_USBIP=1 while it awaits on-glass
verification: it changes the pad's whole kernel presentation, including
superseding the pad-audio sinks with a real card.

The descriptor set is pinned by test against the hardware's published
wTotalLength of 0x00E3, which is the cheapest check that the terminal topology,
both streaming interfaces with their alt settings, the 9-byte isochronous
endpoints and the HID interface are all shaped like the real pad rather than
merely self-consistent.
feat(pad-audio): capture the pad's audio off its USB endpoint when the pad is a real USB device
ci / web (pull_request) Successful in 1m27s
ci / rust-arm64 (pull_request) Successful in 2m15s
ci / docs-site (pull_request) Successful in 1m41s
ci / rust (pull_request) Failing after 6m54s
ci / bun-nix (pull_request) Successful in 1m46s
android / android (pull_request) Successful in 15m19s
74faee315d
With the usbip pad the host mints nothing, so there is no sink to capture from:
PipeWire builds the pad's real sinks from its real ALSA card. Everything a game
writes — PipeWire-mixed or a raw hw:X,0 grab alike — converges on the pad's
isochronous OUT endpoint, so capture there instead. That is the same point a
physical pad's samples reach, which is what makes any route a game takes land
in one place. The 0xD1 wire path downstream is untouched; only the source moves.

The two capture modes are mutually exclusive by construction, and the choice is
read from the transport flag rather than from whether a stream happens to have
been published yet — otherwise the race between pad arrival and the streamer
thread starting would decide it, and losing that race would mint a duplicate,
competing node graph over a real card.

Adds `pad-usbip-test`, the on-glass gate for all of this with no client and no
game involved: it attaches the pad, then reports in the order the failures
happen — whether the device enumerated, whether snd-usb-audio produced the real
ALSA card GE's scan needs, which usb_device node both ContainerId derivations
must land on, and finally the per-pair sample peaks that pad-sink-test already
reports (ch0/1 speaker, ch2/3 coils), so a channel-order slip in the UAC
descriptors cannot hide behind a healthy-looking global peak.
enricobuehler merged commit a17a8917f4 into main 2026-08-17 11:22:21 +00:00
enricobuehler deleted branch worktree-dualsense-usbip-audio 2026-08-17 11:22:24 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#282