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
2 Commits
Author SHA1 Message Date
enricobuehler a519491928 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
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.
2026-08-18 10:46:24 +02:00
enricobuehler faa00ed142 fix(usbip): an OUT reply said 0 bytes accepted, so every hidraw write on the pad "failed"
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.
2026-08-18 10:32:40 +02:00