The usbip pad acknowledged every write with 0 bytes, so GE-Proton's haptics enable "failed" #291

Merged
enricobuehler merged 2 commits from worktree-usbip-out-actual-length into main 2026-08-18 08:55:18 +00:00
Owner

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.

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.
enricobuehler added 2 commits 2026-08-18 08:48:52 +00:00
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.
fix(pad): the pad's sound card was root-only, so PipeWire never even saw it
ci / rust-arm64 (pull_request) Successful in 3m14s
ci / web (pull_request) Successful in 1m48s
android / android (pull_request) Successful in 6m7s
ci / bun-nix (pull_request) Successful in 33s
ci / docs-site (pull_request) Successful in 2m1s
ci / rust (pull_request) Successful in 44m45s
a519491928
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.
enricobuehler merged commit 838892ce1c into main 2026-08-18 08:55:18 +00:00
enricobuehler deleted branch worktree-usbip-out-actual-length 2026-08-18 08:55:20 +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#291