Two fixes that together unblock DualSense adaptive triggers, voice-coil haptics and the speaker under GE-Proton against the usbip virtual pad. Root-caused and A/B-verified on glass on .41 (Bazzite f44) today.
1. usbip-sim: an OUT reply said actual_length = 0
The vendored simulator answered every non-isochronous OUT URB through the IN constructor with an empty buffer, i.e. actual_length = 0 — and a debug_assert pinned that as the rule. But vhci_hcd copies the field into urb->actual_length verbatim (usbip_pack_pdu(pdu, urb, USBIP_RET_SUBMIT, 0) in vhci_recv_ret_submit()) and has no other source for it on OUT. So every synchronous writer up the stack was told "0 bytes transferred" on success:
usbhid_output_report() returns actual_length as the byte count ⇒ write() on the pad's hidraw returned 0
usb_control_msg() returns the data-stage length ⇒ HIDIOCSFEATURE returned 0
winebus checks count > 0, took 0 as failure, and its failure branch prints the thread's stale errno — which is where the misleading err:hid:hidraw_device_set_output_report id 2 write failed error: 2 No such file or directory came from. GE's hidraw_enable_dualsense_usb_haptics therefore never enabled USB haptics mode, and every DS5 feature is gated behind that one enable. (An earlier test harness that "proved the pad accepts writes, 150/150 ok" was the same illusion: os.write returning 0 does not raise.)
Fix:UsbIpResponse::usbip_ret_submit_out_success(header, accepted) acknowledges the bytes taken with no payload back; the handler uses it for OUT; the assertion now pins "OUT carries no buffer", not "OUT claims 0". Two wire-byte tests pin both directions.
Measured A/B on .41 (old-binary service pad vs new-binary pad-usbip-test --pad 1 pad, side by side):
ISO audio is untouched by this change and re-verified flowing on the fixed binary. The Steam Controller 2 rides the same handler, so its OUT writes were also being acked as 0.
scripts/usbip-trace-analyse.py had the rule exactly backwards ("any nonzero OUT actual_length = desync") — it would have hidden this bug and flagged the fix. It now flags an OUT reply claiming more than it was sent, or 0 against a non-empty write.
2. udev: the pad's sound card was root-only, so PipeWire never saw it
The card is minted mid-session-bringup while no seat session is active, so logind's uaccess ACL never materialises. WirePlumber's probe fails EACCES (spa.alsa: can't open control for card hw:2: Permission denied), the card never appears in PipeWire, and no pad sink exists for winepulse to route to — which is why every mmdevapi endpoint in the GE logs was a punktfunk-speaker with a GUID_NULL container. GE's direct ALSA haptics leg can't open the PCM either.
Fix:scripts/60-punktfunk.rules grows sound-subsystem rules for DS5/DS5 Edge (GROUP="input", MODE="0660", TAG+="uaccess") — the same mechanism the file's hidraw rules already document, one subsystem over.
Verified live on .41: installing the rule + udevadm trigger --subsystem-match=sound made WirePlumber adopt the card mid-session — device, Default__Speaker__sink and Mic source all appeared, under exactly the alsa_output.usb-Sony_Interactive_Entertainment_ name prefix GE-Proton's winepulse matches.
Gates
cargo test -p usbip-sim -p pf-inject, cargo clippy --all-targets -- -D warnings, cargo fmt --check: green (mac native + punktfunk-rust-ci linux/amd64 container, non-vacuous — both crates recompiled)
full punktfunk-host debug build in the container: green
Still owed after merge
The in-game verification: .41's host service was carrying a live Deck stream session the whole time, so the service binary swap + game relaunch is deferred. Solid (game-controlled) lightbar instead of flashing is the instant on-glass tell that the enable took.
Two fixes that together unblock DualSense adaptive triggers, voice-coil haptics and the speaker under GE-Proton against the usbip virtual pad. Root-caused and A/B-verified on glass on .41 (Bazzite f44) today.
## 1. `usbip-sim`: an OUT reply said `actual_length = 0`
The vendored simulator answered every non-isochronous OUT URB through the IN constructor with an empty buffer, i.e. `actual_length = 0` — and a `debug_assert` pinned that as the rule. But `vhci_hcd` copies the field into `urb->actual_length` verbatim (`usbip_pack_pdu(pdu, urb, USBIP_RET_SUBMIT, 0)` in `vhci_recv_ret_submit()`) and has no other source for it on OUT. So every synchronous writer up the stack was told "0 bytes transferred" on success:
- `usbhid_output_report()` returns `actual_length` as the byte count ⇒ `write()` on the pad's hidraw returned **0**
- `usb_control_msg()` returns the data-stage length ⇒ `HIDIOCSFEATURE` returned **0**
winebus checks `count > 0`, took 0 as failure, and its failure branch prints the thread's **stale errno** — which is where the misleading `err:hid:hidraw_device_set_output_report id 2 write failed error: 2 No such file or directory` came from. GE's `hidraw_enable_dualsense_usb_haptics` therefore never enabled USB haptics mode, and every DS5 feature is gated behind that one enable. (An earlier test harness that "proved the pad accepts writes, 150/150 ok" was the same illusion: `os.write` returning 0 does not raise.)
**Fix:** `UsbIpResponse::usbip_ret_submit_out_success(header, accepted)` acknowledges the bytes taken with no payload back; the handler uses it for OUT; the assertion now pins "OUT carries no buffer", not "OUT claims 0". Two wire-byte tests pin both directions.
**Measured A/B on .41** (old-binary service pad vs new-binary `pad-usbip-test --pad 1` pad, side by side):
```
OLD: write(0x02,48B) -> 0 HIDIOCSFEATURE(0x08,48B) -> 0
NEW: write(0x02,48B) -> 48 HIDIOCSFEATURE(0x08,48B) -> 48
```
ISO audio is untouched by this change and re-verified flowing on the fixed binary. The Steam Controller 2 rides the same handler, so its OUT writes were also being acked as 0.
`scripts/usbip-trace-analyse.py` had the rule exactly backwards ("any nonzero OUT actual_length = desync") — it would have hidden this bug and flagged the fix. It now flags an OUT reply claiming more than it was sent, or 0 against a non-empty write.
## 2. udev: the pad's sound card was root-only, so PipeWire never saw it
The card is minted mid-session-bringup while no seat session is active, so logind's `uaccess` ACL never materialises. WirePlumber's probe fails EACCES (`spa.alsa: can't open control for card hw:2: Permission denied`), the card never appears in PipeWire, and no pad sink exists for winepulse to route to — which is why every mmdevapi endpoint in the GE logs was a `punktfunk-speaker` with a `GUID_NULL` container. GE's direct ALSA haptics leg can't open the PCM either.
**Fix:** `scripts/60-punktfunk.rules` grows sound-subsystem rules for DS5/DS5 Edge (`GROUP="input", MODE="0660", TAG+="uaccess"`) — the same mechanism the file's hidraw rules already document, one subsystem over.
**Verified live on .41:** installing the rule + `udevadm trigger --subsystem-match=sound` made WirePlumber adopt the card mid-session — device, `Default__Speaker__sink` and Mic source all appeared, under exactly the `alsa_output.usb-Sony_Interactive_Entertainment_` name prefix GE-Proton's winepulse matches.
## Gates
- `cargo test -p usbip-sim -p pf-inject`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`: green (mac native + `punktfunk-rust-ci` linux/amd64 container, non-vacuous — both crates recompiled)
- full `punktfunk-host` debug build in the container: green
## Still owed after merge
The in-game verification: .41's host service was carrying a live Deck stream session the whole time, so the service binary swap + game relaunch is deferred. Solid (game-controlled) lightbar instead of flashing is the instant on-glass tell that the enable took.
GE-Proton's `hidraw_enable_dualsense_usb_haptics` never enabled the DualSense's USB haptics
mode against our usbip pad — `err:hid:hidraw_device_set_output_report id 2 write failed
error: 2 No such file or directory`, then feature report 0x08 retried forever with
EINVAL/EAGAIN. Adaptive triggers, voice-coil haptics and the speaker are all gated behind
that one enable, so nothing downstream could ever show a result. Five theories were ruled
out by log inspection; the sixth was measured on the live pad on .41 today:
write(hidraw, output 0x02, 48 B) -> 0
ioctl(HIDIOCSFEATURE 0x08, 48 B) -> 0
ioctl(HIDIOCGFEATURE 0x05, 41 B) -> 41
The vendored simulator answered every non-isochronous OUT URB through the IN constructor
with an empty buffer, i.e. `actual_length = 0` — and a debug_assert pinned that as the
rule ("OUT nothing"). vhci_hcd copies the field into `urb->actual_length` verbatim
(`usbip_pack_pdu(pdu, urb, USBIP_RET_SUBMIT, 0)` in `vhci_recv_ret_submit()`) and has no
other source for it, so `usbhid_output_report()` returned 0 as `write()`'s byte count and
`usb_control_msg()` returned 0 for the SET_REPORT data stage. winebus checks `count > 0`,
takes 0 as failure, and prints the thread's *stale* errno — the ENOENT/EINVAL/EAGAIN in the
log were never kernel verdicts. The earlier `/tmp/hidwrite.py` "150/150 ok" was the same
illusion: `os.write` returning 0 does not raise. A real usbip stub reports the real URB's
`actual_length`, which on OUT is the bytes sent.
Fix: `UsbIpResponse::usbip_ret_submit_out_success(header, accepted)` acknowledges the bytes
taken (`data.len()`, which `read_from_socket` sized from `transfer_buffer_length`) with no
payload back; the handler uses it for OUT; the assertion now pins "OUT carries no buffer",
not "OUT claims 0". Two wire-byte tests pin both directions. The Steam Controller 2 shares
this handler, so its OUT writes were being reported as 0 bytes too.
`scripts/usbip-trace-analyse.py` flagged ANY nonzero OUT actual_length as a desync — the
wrong rule (its own framing never reads a payload back on OUT) and one that would have hid
this bug and flagged the fix. It now flags an OUT reply claiming more than it was sent, or
0 against a non-empty write.
The usbip DualSense's ALSA card is minted mid-session-bringup while no seat session is
active, so logind's uaccess ACL never materialises and /dev/snd/controlC*/pcmC* stay
root:audio 0660 with the user in neither. WirePlumber's probe fails EACCES ("spa.alsa:
can't open control for card hw:2: Permission denied"), the card never appears in PipeWire,
no pad sink exists for winepulse to route to — which is why every mmdevapi endpoint in the
GE logs was a punktfunk-speaker and the audio ContainerIds were all GUID_NULL — and
GE-Proton's direct ALSA haptics leg (find_dualsense_haptic_alsa_path) cannot open the PCM
either. Same mechanism the hidraw rules in this file already handle, one subsystem over.
Verified live on .41 (Bazzite f44): installing the rule + udevadm trigger made WirePlumber
adopt the card mid-session — device, Default__Speaker__sink and Mic source all appeared,
under exactly the alsa_output.usb-Sony_Interactive_Entertainment_ name prefix GE matches.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Two fixes that together unblock DualSense adaptive triggers, voice-coil haptics and the speaker under GE-Proton against the usbip virtual pad. Root-caused and A/B-verified on glass on .41 (Bazzite f44) today.
1.
usbip-sim: an OUT reply saidactual_length = 0The vendored simulator answered every non-isochronous OUT URB through the IN constructor with an empty buffer, i.e.
actual_length = 0— and adebug_assertpinned that as the rule. Butvhci_hcdcopies the field intourb->actual_lengthverbatim (usbip_pack_pdu(pdu, urb, USBIP_RET_SUBMIT, 0)invhci_recv_ret_submit()) and has no other source for it on OUT. So every synchronous writer up the stack was told "0 bytes transferred" on success:usbhid_output_report()returnsactual_lengthas the byte count ⇒write()on the pad's hidraw returned 0usb_control_msg()returns the data-stage length ⇒HIDIOCSFEATUREreturned 0winebus checks
count > 0, took 0 as failure, and its failure branch prints the thread's stale errno — which is where the misleadingerr:hid:hidraw_device_set_output_report id 2 write failed error: 2 No such file or directorycame from. GE'shidraw_enable_dualsense_usb_hapticstherefore never enabled USB haptics mode, and every DS5 feature is gated behind that one enable. (An earlier test harness that "proved the pad accepts writes, 150/150 ok" was the same illusion:os.writereturning 0 does not raise.)Fix:
UsbIpResponse::usbip_ret_submit_out_success(header, accepted)acknowledges the bytes taken with no payload back; the handler uses it for OUT; the assertion now pins "OUT carries no buffer", not "OUT claims 0". Two wire-byte tests pin both directions.Measured A/B on .41 (old-binary service pad vs new-binary
pad-usbip-test --pad 1pad, side by side):ISO audio is untouched by this change and re-verified flowing on the fixed binary. The Steam Controller 2 rides the same handler, so its OUT writes were also being acked as 0.
scripts/usbip-trace-analyse.pyhad the rule exactly backwards ("any nonzero OUT actual_length = desync") — it would have hidden this bug and flagged the fix. It now flags an OUT reply claiming more than it was sent, or 0 against a non-empty write.2. udev: the pad's sound card was root-only, so PipeWire never saw it
The card is minted mid-session-bringup while no seat session is active, so logind's
uaccessACL never materialises. WirePlumber's probe fails EACCES (spa.alsa: can't open control for card hw:2: Permission denied), the card never appears in PipeWire, and no pad sink exists for winepulse to route to — which is why every mmdevapi endpoint in the GE logs was apunktfunk-speakerwith aGUID_NULLcontainer. GE's direct ALSA haptics leg can't open the PCM either.Fix:
scripts/60-punktfunk.rulesgrows sound-subsystem rules for DS5/DS5 Edge (GROUP="input", MODE="0660", TAG+="uaccess") — the same mechanism the file's hidraw rules already document, one subsystem over.Verified live on .41: installing the rule +
udevadm trigger --subsystem-match=soundmade WirePlumber adopt the card mid-session — device,Default__Speaker__sinkand Mic source all appeared, under exactly thealsa_output.usb-Sony_Interactive_Entertainment_name prefix GE-Proton's winepulse matches.Gates
cargo test -p usbip-sim -p pf-inject,cargo clippy --all-targets -- -D warnings,cargo fmt --check: green (mac native +punktfunk-rust-cilinux/amd64 container, non-vacuous — both crates recompiled)punktfunk-hostdebug build in the container: greenStill owed after merge
The in-game verification: .41's host service was carrying a live Deck stream session the whole time, so the service binary swap + game relaunch is deferred. Solid (game-controlled) lightbar instead of flashing is the instant on-glass tell that the enable took.
GE-Proton's `hidraw_enable_dualsense_usb_haptics` never enabled the DualSense's USB haptics mode against our usbip pad — `err:hid:hidraw_device_set_output_report id 2 write failed error: 2 No such file or directory`, then feature report 0x08 retried forever with EINVAL/EAGAIN. Adaptive triggers, voice-coil haptics and the speaker are all gated behind that one enable, so nothing downstream could ever show a result. Five theories were ruled out by log inspection; the sixth was measured on the live pad on .41 today: write(hidraw, output 0x02, 48 B) -> 0 ioctl(HIDIOCSFEATURE 0x08, 48 B) -> 0 ioctl(HIDIOCGFEATURE 0x05, 41 B) -> 41 The vendored simulator answered every non-isochronous OUT URB through the IN constructor with an empty buffer, i.e. `actual_length = 0` — and a debug_assert pinned that as the rule ("OUT nothing"). vhci_hcd copies the field into `urb->actual_length` verbatim (`usbip_pack_pdu(pdu, urb, USBIP_RET_SUBMIT, 0)` in `vhci_recv_ret_submit()`) and has no other source for it, so `usbhid_output_report()` returned 0 as `write()`'s byte count and `usb_control_msg()` returned 0 for the SET_REPORT data stage. winebus checks `count > 0`, takes 0 as failure, and prints the thread's *stale* errno — the ENOENT/EINVAL/EAGAIN in the log were never kernel verdicts. The earlier `/tmp/hidwrite.py` "150/150 ok" was the same illusion: `os.write` returning 0 does not raise. A real usbip stub reports the real URB's `actual_length`, which on OUT is the bytes sent. Fix: `UsbIpResponse::usbip_ret_submit_out_success(header, accepted)` acknowledges the bytes taken (`data.len()`, which `read_from_socket` sized from `transfer_buffer_length`) with no payload back; the handler uses it for OUT; the assertion now pins "OUT carries no buffer", not "OUT claims 0". Two wire-byte tests pin both directions. The Steam Controller 2 shares this handler, so its OUT writes were being reported as 0 bytes too. `scripts/usbip-trace-analyse.py` flagged ANY nonzero OUT actual_length as a desync — the wrong rule (its own framing never reads a payload back on OUT) and one that would have hid this bug and flagged the fix. It now flags an OUT reply claiming more than it was sent, or 0 against a non-empty write.The usbip DualSense's ALSA card is minted mid-session-bringup while no seat session is active, so logind's uaccess ACL never materialises and /dev/snd/controlC*/pcmC* stay root:audio 0660 with the user in neither. WirePlumber's probe fails EACCES ("spa.alsa: can't open control for card hw:2: Permission denied"), the card never appears in PipeWire, no pad sink exists for winepulse to route to — which is why every mmdevapi endpoint in the GE logs was a punktfunk-speaker and the audio ContainerIds were all GUID_NULL — and GE-Proton's direct ALSA haptics leg (find_dualsense_haptic_alsa_path) cannot open the PCM either. Same mechanism the hidraw rules in this file already handle, one subsystem over. Verified live on .41 (Bazzite f44): installing the rule + udevadm trigger made WirePlumber adopt the card mid-session — device, Default__Speaker__sink and Mic source all appeared, under exactly the alsa_output.usb-Sony_Interactive_Entertainment_ name prefix GE matches.