Works through the 2026-08-18 field handoff (punktfunk-planning/design/dualsense-remaining-work-handoff.md): §1, §2 and §4.2 ship here. §3 stays uncharacterized but loses a suspect, and §4.1/§4.3 still need the box.
1. The mono sink, and the crash that only exists when it does
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback sink: the 1-channel …Default__Speaker__sink. GE-Proton mints its synthetic "Sony controller speaker" endpoint from it, and Marvel's Spider-Man Remastered overruns that endpoint reliably, ~74 s in.
Not a format mismatch — that was ruled out in the field. GetMixFormat and the game's Initialize both agree on mono float32 nBlockAlign 4, and pulse sized maxlength: 20136 = 5034 × 4 correctly. At the fault rsi=rbp=0x13aa (5034, the frame count) while rcx/rdx are 5206/5207: the copy loop had already run past the count. It is a game/GE bug on a code path that only exists when the mono sink does.
So the fix deletes the mono sink rather than chasing the overrun. With a SpeakerHaptic device at PlaybackPriority 200 against Speaker's 100, the card takes HiFi (Mic, SpeakerHaptic), the sink is the 4-channel one, and the mono sink — with the crash path — never exists. The voice coils reach their own channels as a bonus.
Two corrections to the handoff
This is not an upstream regression.alsa-ucm-conf has never carried SpeakerHaptic. The DualSense profile arrived upstream in 1.2.15 (36a111a, 2025-05-21) already without it. The Deck's is a Valve downstream patch, which they still carry on their own 1.2.16.1 build. Their DualSense-PS5.conf is byte-identical to upstream's, so the whole delta is two added SectionDevice blocks — and the upstream half of the plan is a new contribution, not a revert.
There is a fifth shipping option, and it dominates all four in the handoff's table.USB-Audio/USB-Audio.conf ends with an unconditional, optional include of USB-Audio/conf.d/{vid}-{pid}.conf, placed after its device table has chosen ${var:ProfileName} and before it includes the profile that name resolves to. A two-line drop-in keyed by 054c:0ce6 / 054c:0df2 therefore swaps the profile without replacing a single file alsa-ucm-conf owns — no diversion, no Conflicts, no %config fight, and it works for rpm, deb, Arch and the sysext alike.
Verified against alsa-lib rather than assumed: ucm_cond.c makes Condition optional for a syntax-v8 If carrying Append, and uc_mgr_evaluate_include evaluates each included subtree in place before moving to the next include — so our Define lands before the profile include substitutes the variable. The hook and the DualSense profile shipped in the same release (1.2.15), so every tree that has the bug has the hook.
Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the RPMs — deb, Arch). The client already has a working fallback in ensure_pro_audio, and a shared file in two co-installable packages is a file conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to drop into and needs a package override instead.
Proven off-hardware, and guarded
scripts/ci/check-dualsense-ucm.sh drives the whole chain with no DualSense, through UCM's card-less conf.virt.d path with only the four card built-ins stubbed. Against a pristine Fedora 44 alsa-ucm-conf 1.2.16.1 tree:
devices
distro tree alone
Headphones / Headset / Mic / Speaker
the defect
+ the four drop-in files
SpeakerHaptic / HeadphonesHaptic as well
PlaybackPriority 200 vs 100, PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3
It exists because this fix hooks another project's dispatcher: an upstream rename would neuter it silently, and what comes back is the crash, not a quieter pad. Negative-tested both ways (typo'd ProfileName, hook deleted). Runs in the rpm workflow's Fedora container; skips itself anywhere without alsa-ucm/alsaucm.
⚠ A wrong ProfileName is not a no-op — it makes the pad's UCM fail to load outright. Loud rather than silent, but it is why the check exists.
2. −23.88 dB, twice
WirePlumber starts every new card's sink at device.routes.default-sink-volume. That is 0.4, and 0.4 is a cubed number: what a mixer shows as 40 % is 0.4³ = 0.064 linear, −23.88 dB. The setting is global — it cannot be scoped to one device from configuration — so there was no config file to ship, and it fires again on every fresh card, which for a usbip pad is every attach.
Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice: nobody chose it and nobody would look for it, and both ends of a session mint one, so they multiply — 0.064² = −47.8 dB, matching the −48 dB measured in the field. That is the difference between "the haptics are subtle" and "I'm not sure the haptics are connected".
Both ends now set channelVolumes to unity (1.0 — unity in the linear units the pod speaks and in the cubed scale a mixer displays, the one unambiguous value).
Client (pin_sink_volume, from correlate_pad_sink): at every pick, so a card that re-minted its nodes after a profile change or replug is re-pinned without anything having to notice. Skipped for the split_parent pick — that is a name lifted off another node's proplist, and pinning the sink that named it would pin the wrong node.
Host (audio/linux/pad_card_volume.rs, when PadUsbCapturer::open succeeds): the host half matters because pad_usb captures at the pad's isochronous OUT endpoint, downstream of this sink — so what we encode and send is already attenuated and no client-side fix can recover it. Retries for 15 s rather than firing once: the USB device is live well before its ALSA card is, and pinning before WirePlumber has applied the default we are undoing would simply be overwritten. Only sinks belonging to a DualSense card are touched; device.id keeps it off a host's own minted pad sink, which publishes the full DualSense identity on purpose.
Neither end restores on the way out, deliberately: the profile swap next door overrides a choice the user made, this overrides a default nobody made, and putting −24 dB back would be restoring the bug. PUNKTFUNK_PAD_SINK_VOLUME=0 disables both.
3. A silent speaker that looked like broken hardware
An evening went into measuring the host for a pad speaker switched off at the other end. Two hints, at the two places somebody actually looks:
punktfunk-session --pad-audio-test now says, before the tone, whether pad_speaker / pad_haptics would stop a real session rendering the very thing the tone is about to prove works. It has to be said up front: the capability is never advertised when the toggle is off, so no later log line can catch it.
The Android settings row states its default in the subtitle. Android is the only client that defaults this off, and that is a deliberate form-factor choice, so the default stands and the row stops being silent about it. pf_client_core's default_pad_speaker is "pad" and always has been — the desktop clients were never the ones hiding it.
Worth recording: the host already had the signal.native/pad_audio.rs logs pad audio streaming … haptics=<bool> speaker=<bool> at stream start, and a client with the toggle off shows up there as speaker=false. It was being printed on the host that was being measured all evening.
What this also settles for §3 (the "speaker still sounds broken" report)
Not characterized — that needs the box — but suspect 1 is eliminated from ALSA's source, and the recipe that would have chased it was wrong. The split is type dshare, and dshare's share_areas moves samples with snd_pcm_area_copy — a copy, never an accumulate. Two client channels bound to one hardware channel therefore cannot sum: no +6 dB, no clipping, no phase cancellation. Two corrections fall out:
The last binding wins, not the first. The copy loop runs chn = 0..n and each copy overwrites the previous, so with Channel0 1, Channel1 1 it is sink ch1 that reaches the speaker. ⚠ A tone injected on ch0 alone measures silence at the speaker on a perfectly healthy chain.
The declared wiring is honored, but only just: dshare has an interleaved fast path that ignores bindings entirely, skipped the moment any bindings[chn] != chn (pcm_direct.c). [1,1,2,3] fails at chn 0, so the binding-honoring path runs.
Still open
§3 characterization — needs measurement on the box.
§4.1 touchpad click — no guess-fix. The wire path is ruled out at every layer: SDL gamepad.rs:797, Android DsDevice.kt:310, host dualsense_proto.rs:353, serialized at r[10] by buttons2_with_click() (which ORs the rich-plane click, so neither source strands the other). Next step is one command on the box: hid-playstation reports the click on the separate ps-touchpad evdev node as BTN_LEFT, so evtest there while pressing says in one step whether the bit reaches the kernel.
§4.3vhci_hcd: urb … already given back — untouched.
NixOS UCM coverage.
Testing
scripts/ci/check-dualsense-ucm.sh passes on a real Fedora 44 alsa-ucm-conf 1.2.16.1 tree, and fails correctly when the ProfileName is typo'd or the upstream hook is removed.
cargo check --all-targets clean on Linux (in punktfunk-rust-ci) for pf-client-core, punktfunk-host and punktfunk-client-session — confirmed to actually cover the new files by a deliberate-error run.
cargo test: 2 passed (host pad_card_volume), 19 passed (client pad_audio, including the new unity-pod test and the picker tests touching the changed struct).
⚠ The Kotlin subtitle change was not compiled — no Android toolchain on the machine this was written on. It is a string in the same idiom as the row above it.
No on-glass run: the UCM fix is proven at the UCM layer off-hardware, and the field session already proved the same two device definitions stop the crash when bind-mounted. What has not been exercised on glass is this packaging path. First on-box step: install the rpm/sysext, replug the pad, then LC_ALL=C pactl list short sinks | grep SpeakerHaptic.
Works through the 2026-08-18 field handoff (`punktfunk-planning/design/dualsense-remaining-work-handoff.md`): §1, §2 and §4.2 ship here. §3 stays uncharacterized but loses a suspect, and §4.1/§4.3 still need the box.
## 1. The mono sink, and the crash that only exists when it does
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic "Sony controller speaker" endpoint from it, and *Marvel's Spider-Man Remastered* overruns that endpoint reliably, ~74 s in.
Not a format mismatch — that was ruled out in the field. `GetMixFormat` and the game's `Initialize` both agree on mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 × 4 correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while `rcx`/`rdx` are 5206/5207: the copy loop had already run past the count. It is a game/GE bug **on a code path that only exists when the mono sink does**.
So the fix deletes the mono sink rather than chasing the overrun. With a `SpeakerHaptic` device at `PlaybackPriority 200` against `Speaker`'s 100, the card takes `HiFi (Mic, SpeakerHaptic)`, the sink is the 4-channel one, and the mono sink — with the crash path — never exists. The voice coils reach their own channels as a bonus.
### Two corrections to the handoff
- **This is not an upstream regression.** `alsa-ucm-conf` has *never* carried `SpeakerHaptic`. The DualSense profile arrived upstream in **1.2.15** (`36a111a`, 2025-05-21) already without it. The Deck's is a **Valve downstream patch**, which they still carry on their own 1.2.16.1 build. Their `DualSense-PS5.conf` is byte-identical to upstream's, so the whole delta is two added `SectionDevice` blocks — and the upstream half of the plan is a *new contribution*, not a revert.
- **There is a fifth shipping option, and it dominates all four in the handoff's table.** `USB-Audio/USB-Audio.conf` ends with an unconditional, optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed *after* its device table has chosen `${var:ProfileName}` and *before* it includes the profile that name resolves to. A two-line drop-in keyed by `054c:0ce6` / `054c:0df2` therefore swaps the profile **without replacing a single file `alsa-ucm-conf` owns** — no diversion, no `Conflicts`, no `%config` fight, and it works for rpm, deb, Arch and the sysext alike.
Verified against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates each included subtree in place *before* moving to the next include — so our `Define` lands before the profile include substitutes the variable. The hook and the DualSense profile shipped in the **same release (1.2.15)**, so every tree that has the bug has the hook.
Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the RPMs — deb, Arch). The client already has a working fallback in `ensure_pro_audio`, and a shared file in two co-installable packages is a file conflict for a nicety. **NixOS is not covered**: it has no `/usr/share/alsa/ucm2` to drop into and needs a package override instead.
### Proven off-hardware, and guarded
`scripts/ci/check-dualsense-ucm.sh` drives the whole chain with no DualSense, through UCM's card-less `conf.virt.d` path with only the four card built-ins stubbed. Against a pristine Fedora 44 `alsa-ucm-conf` 1.2.16.1 tree:
| | devices | |
|---|---|---|
| distro tree alone | `Headphones / Headset / Mic / Speaker` | the defect |
| + the four drop-in files | `SpeakerHaptic / HeadphonesHaptic` as well | `PlaybackPriority` 200 vs 100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3` |
It exists because this fix hooks another project's dispatcher: an upstream rename would neuter it **silently**, and what comes back is the crash, not a quieter pad. Negative-tested both ways (typo'd `ProfileName`, hook deleted). Runs in the rpm workflow's Fedora container; skips itself anywhere without `alsa-ucm`/`alsaucm`.
⚠ A wrong `ProfileName` is not a no-op — it makes the pad's UCM fail to load outright. Loud rather than silent, but it is why the check exists.
## 2. −23.88 dB, twice
WirePlumber starts every new card's sink at `device.routes.default-sink-volume`. That is 0.4, and 0.4 is a **cubed** number: what a mixer shows as 40 % is 0.4³ = 0.064 linear, −23.88 dB. The setting is global — it cannot be scoped to one device from configuration — so there was no config file to ship, and it fires again on every fresh card, which for a usbip pad is every attach.
Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice: nobody chose it and nobody would look for it, and **both ends of a session mint one**, so they multiply — 0.064² = **−47.8 dB**, matching the −48 dB measured in the field. That is the difference between "the haptics are subtle" and "I'm not sure the haptics are connected".
Both ends now set `channelVolumes` to unity (1.0 — unity in the linear units the pod speaks *and* in the cubed scale a mixer displays, the one unambiguous value).
- **Client** (`pin_sink_volume`, from `correlate_pad_sink`): at every pick, so a card that re-minted its nodes after a profile change or replug is re-pinned without anything having to notice. Skipped for the `split_parent` pick — that is a name lifted off another node's proplist, and pinning the sink that named it would pin the wrong node.
- **Host** (`audio/linux/pad_card_volume.rs`, when `PadUsbCapturer::open` succeeds): the host half matters because `pad_usb` captures at the pad's isochronous OUT endpoint, **downstream** of this sink — so what we encode and send is already attenuated and no client-side fix can recover it. Retries for 15 s rather than firing once: the USB device is live well before its ALSA card is, and pinning before WirePlumber has applied the default we are undoing would simply be overwritten. Only sinks belonging to a DualSense **card** are touched; `device.id` keeps it off a host's own minted pad sink, which publishes the full DualSense identity on purpose.
Neither end restores on the way out, deliberately: the profile swap next door overrides a choice the user made, this overrides a default nobody made, and putting −24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0` disables both.
## 3. A silent speaker that looked like broken hardware
An evening went into measuring the **host** for a pad speaker switched off at the other end. Two hints, at the two places somebody actually looks:
- `punktfunk-session --pad-audio-test` now says, *before* the tone, whether `pad_speaker` / `pad_haptics` would stop a real session rendering the very thing the tone is about to prove works. It has to be said up front: the capability is never advertised when the toggle is off, so no later log line can catch it.
- The Android settings row states its default in the subtitle. Android is the only client that defaults this off, and that is a deliberate form-factor choice, so the default stands and the row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is `"pad"` and always has been — the desktop clients were never the ones hiding it.
Worth recording: **the host already had the signal.** `native/pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at stream start, and a client with the toggle off shows up there as `speaker=false`. It was being printed on the host that was being measured all evening.
## What this also settles for §3 (the "speaker still sounds broken" report)
Not characterized — that needs the box — but **suspect 1 is eliminated from ALSA's source**, and the recipe that would have chased it was wrong. The split is `type dshare`, and dshare's `share_areas` moves samples with `snd_pcm_area_copy` — a **copy, never an accumulate**. Two client channels bound to one hardware channel therefore *cannot* sum: no +6 dB, no clipping, no phase cancellation. Two corrections fall out:
- **The last binding wins, not the first.** The copy loop runs `chn = 0..n` and each copy overwrites the previous, so with `Channel0 1, Channel1 1` it is **sink ch1** that reaches the speaker. ⚠ A tone injected on ch0 alone measures **silence at the speaker on a perfectly healthy chain**.
- The declared wiring is honored, but only just: dshare has an interleaved fast path that ignores bindings entirely, skipped the moment any `bindings[chn] != chn` (`pcm_direct.c`). `[1,1,2,3]` fails at chn 0, so the binding-honoring path runs.
## Still open
- **§3** characterization — needs measurement on the box.
- **§4.1 touchpad click** — no guess-fix. The wire path is ruled out at every layer: SDL `gamepad.rs:797`, Android `DsDevice.kt:310`, host `dualsense_proto.rs:353`, serialized at `r[10]` by `buttons2_with_click()` (which ORs the rich-plane click, so neither source strands the other). Next step is one command on the box: `hid-playstation` reports the click on the **separate `ps-touchpad` evdev node as `BTN_LEFT`**, so `evtest` there while pressing says in one step whether the bit reaches the kernel.
- **§4.3** `vhci_hcd: urb … already given back` — untouched.
- **NixOS** UCM coverage.
## Testing
- `scripts/ci/check-dualsense-ucm.sh` passes on a real Fedora 44 `alsa-ucm-conf` 1.2.16.1 tree, and fails correctly when the `ProfileName` is typo'd or the upstream hook is removed.
- `cargo check --all-targets` clean on Linux (in `punktfunk-rust-ci`) for `pf-client-core`, `punktfunk-host` and `punktfunk-client-session` — confirmed to actually cover the new files by a deliberate-error run.
- `cargo test`: 2 passed (host `pad_card_volume`), 19 passed (client `pad_audio`, including the new unity-pod test and the picker tests touching the changed struct).
- ⚠ The Kotlin subtitle change was **not compiled** — no Android toolchain on the machine this was written on. It is a string in the same idiom as the row above it.
- No on-glass run: the UCM fix is proven at the UCM layer off-hardware, and the field session already proved the same two device definitions stop the crash when bind-mounted. What has **not** been exercised on glass is this *packaging* path. First on-box step: install the rpm/sysext, replug the pad, then `LC_ALL=C pactl list short sinks | grep SpeakerHaptic`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback
sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic
"Sony controller speaker" endpoint from that lone mono sink, and Marvel's
Spider-Man Remastered overruns it — reliably, ~74 s in:
73.846 render_GetBuffer (…)->(5034, …) <- GE's mono endpoint
73.846 EXCEPTION_ACCESS_VIOLATION info[0]=1 (WRITE) info[1]=5CB9A000
Not a format mismatch: `GetMixFormat` and the game's `Initialize` both agree on
mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 x 4
correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while
`rcx`/`rdx` are 5206/5207 — the copy loop had already run past the count. It is
a game/GE bug on a code path that ONLY EXISTS WHEN THE MONO SINK DOES.
So delete the mono sink rather than chase the overrun. `alsa-ucm-conf` describes
the pad as Speaker / Headphones / Mic / Headset and has never carried a
`SpeakerHaptic` device — the DualSense profile arrived upstream in 1.2.15
(36a111a) already without it, and the Deck's is a Valve downstream patch they
still carry on their own 1.2.16.1. With `SpeakerHaptic` at `PlaybackPriority
200` against `Speaker`'s 100 the card takes `HiFi (Mic, SpeakerHaptic)`, the
sink is the 4-channel one, and the mono sink — with the crash path — never
exists. The voice coils reach their own channels as a bonus.
Shipped WITHOUT replacing a file `alsa-ucm-conf` owns, which is what made this
awkward to package. `USB-Audio/USB-Audio.conf` ends with an unconditional,
optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed after its device
table has chosen `${var:ProfileName}` and before it includes the profile that
name resolves to — so a two-line drop-in keyed by 054c:0ce6 / 054c:0df2 swaps
the profile with no diversion, no `Conflicts`, and no `%config` fight. Verified
against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional
for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates
each included subtree in place before moving to the next include, so the
`Define` lands before the profile include substitutes the variable. The hook and
the DualSense profile shipped in the SAME release (1.2.15), so every tree that
has the bug has the hook.
Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the
RPMs — deb, Arch). The client already has a working fallback in
`ensure_pro_audio`, and a shared file in two co-installable packages is a file
conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to
drop into and needs a package override instead.
`scripts/ci/check-dualsense-ucm.sh` runs the whole chain on a real distro tree
with no hardware, via UCM's card-less `conf.virt.d` path with only the four card
built-ins stubbed. Against pristine Fedora 44 alsa-ucm-conf 1.2.16.1: baseline
`Headphones/Headset/Mic/Speaker`; with the drop-in, `SpeakerHaptic` and
`HeadphonesHaptic` too, `PlaybackPriority/SpeakerHaptic=200` over `Speaker`'s
100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3`. It exists
because this fix hooks another project's dispatcher: an upstream rename would
neuter it silently, and what comes back is the crash, not a quieter pad.
Negative-tested both ways (typo'd ProfileName, hook deleted).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WirePlumber starts every new card's sink at `device.routes.default-sink-volume`.
That is 0.4, and 0.4 is a CUBED number: what a mixer shows as 40 % is 0.4^3 =
0.064 of linear amplitude, -23.88 dB. The setting is global — it cannot be
scoped to one device from configuration — so there was no config file to ship,
and it fires again on every fresh card, which for a usbip pad is every attach.
Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice:
nobody chose it and nobody would think to look for it (a pad's sink is not a
listening volume anyone reaches for, so it reads as weak hardware), and BOTH
ends of a session mint one. The game's samples cross this sink on the host and
the pad's own sink on the client, so the two multiply: 0.064^2 = -47.8 dB by the
time haptics reach a voice coil, which matches the -48 dB measured in the field.
That is the difference between "the haptics are subtle" and "I am not sure the
haptics are connected".
Both ends now set `channelVolumes` to unity — 1.0, which is unity in the linear
units the pod speaks AND in the cubed scale a mixer displays, the one value that
is unambiguous.
* Client (`pin_sink_volume`, called from `correlate_pad_sink`): at every pick,
so a card that re-minted its nodes after a profile change or a replug is
re-pinned without anything having to notice. Skipped for the `split_parent`
pick — that is a name lifted off another node's proplist, with no object of
our own behind it, and pinning the sink that named it would pin the wrong
node.
* Host (`audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open`
succeeds): the host half matters because `pad_usb` captures at the pad's
isochronous OUT endpoint, DOWNSTREAM of this sink — PipeWire applies the
volume when it mixes into the ALSA device, so what we encode and send is
already attenuated and no client-side fix can recover it. It retries for 15 s
rather than firing once: the USB device is live well before its ALSA card is,
and pinning before WirePlumber has applied the default we are undoing would
simply be overwritten. Only sinks belonging to a DualSense CARD are touched —
`device.id` is what keeps it off a host's own minted pad sink, which
publishes the full DualSense identity on purpose.
Neither end restores on the way out, deliberately: the profile swap next door
overrides a choice the user made, this overrides a default nobody made, and
putting -24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0`
disables both, for bisecting a box where something else attenuates.
Both pods are unit-tested for the shape that actually matters — one unity float
per channel. PipeWire ignores a `channelVolumes` whose length does not match the
port count, and an ignored pod looks exactly like the pin silently not working,
which is the -23.88 dB back again and just as invisible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A field session spent an evening measuring the HOST — sinks, tone injection,
per-channel peaks — for a pad speaker that was switched off at the other end.
A disabled toggle and dead hardware are indistinguishable from the pad, and
nothing said which one it was.
Two hints, at the two places somebody actually looks:
* `punktfunk-session --pad-audio-test` now says, before the tone, whether
`pad_speaker` / `pad_haptics` would stop a real session rendering the very
thing the tone is about to prove works. The devtest drives the pad DIRECTLY
and is otherwise blind to the settings, which is exactly what makes "the tone
plays here but the game is silent" so confusing. It has to be said up front:
the capability is never advertised when the toggle is off, so no later log
line can catch it.
* The Android settings row now states its default in the subtitle. Android is
the one client that defaults this off, and that is a deliberate form-factor
choice (a small, easily-startling loudspeaker in the user's hands, unlike
haptics duplicating audio they already hear) — so the default stands and the
row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is
`"pad"` and always has been, so the desktop clients were never the ones
hiding it.
Worth recording for the next time: the host ALREADY had the signal. `native/
pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at
stream start, and a client with the toggle off shows up there as
`speaker=false`. It was being printed on the host that was being measured all
evening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three packaging conflicts, all the same shape: #307 added a
`60-punktfunk-dualsense.conf` install at the exact line this branch added the
ALSA UCM install to. Both sides kept — they act on different layers and neither
subsumes the other:
* the WirePlumber rules govern how the pad's nodes BEHAVE once they exist
(`node.always-process` so GE-Proton's raw open cannot race itself,
`priority.driver = 0` so a pad never clocks somebody else's graph);
* the UCM drop-in governs WHICH nodes exist at all (a `SpeakerHaptic` device
at priority 200, so the 1-channel sink games overrun is never minted).
Checked rather than assumed: the drop-in's node-name matchers
(`~alsa_output.usb-Sony_Interactive_Entertainment_DualSense.*`) still match the
sink the UCM change introduces — `…DualSense_Wireless_Controller-00.HiFi__
SpeakerHaptic__sink` — so the policy follows the pad onto the new profile. And
neither touches volume, so the 0 dB pin on this branch is untouched by both.
The Android side of #301 deleted the Compose gamepad mirror, not
`SettingsScreen.kt`, so the "Controller speaker" subtitle survives; the Skia
console that replaced it carries no speaker row of its own (it opens Android's
connected-controllers view instead), so there is no second place to say it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Works through the 2026-08-18 field handoff (
punktfunk-planning/design/dualsense-remaining-work-handoff.md): §1, §2 and §4.2 ship here. §3 stays uncharacterized but loses a suspect, and §4.1/§4.3 still need the box.1. The mono sink, and the crash that only exists when it does
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback sink: the 1-channel
…Default__Speaker__sink. GE-Proton mints its synthetic "Sony controller speaker" endpoint from it, and Marvel's Spider-Man Remastered overruns that endpoint reliably, ~74 s in.Not a format mismatch — that was ruled out in the field.
GetMixFormatand the game'sInitializeboth agree on mono float32nBlockAlign 4, and pulse sizedmaxlength: 20136= 5034 × 4 correctly. At the faultrsi=rbp=0x13aa(5034, the frame count) whilercx/rdxare 5206/5207: the copy loop had already run past the count. It is a game/GE bug on a code path that only exists when the mono sink does.So the fix deletes the mono sink rather than chasing the overrun. With a
SpeakerHapticdevice atPlaybackPriority 200againstSpeaker's 100, the card takesHiFi (Mic, SpeakerHaptic), the sink is the 4-channel one, and the mono sink — with the crash path — never exists. The voice coils reach their own channels as a bonus.Two corrections to the handoff
alsa-ucm-confhas never carriedSpeakerHaptic. The DualSense profile arrived upstream in 1.2.15 (36a111a, 2025-05-21) already without it. The Deck's is a Valve downstream patch, which they still carry on their own 1.2.16.1 build. TheirDualSense-PS5.confis byte-identical to upstream's, so the whole delta is two addedSectionDeviceblocks — and the upstream half of the plan is a new contribution, not a revert.USB-Audio/USB-Audio.confends with an unconditional, optional include ofUSB-Audio/conf.d/{vid}-{pid}.conf, placed after its device table has chosen${var:ProfileName}and before it includes the profile that name resolves to. A two-line drop-in keyed by054c:0ce6/054c:0df2therefore swaps the profile without replacing a single filealsa-ucm-confowns — no diversion, noConflicts, no%configfight, and it works for rpm, deb, Arch and the sysext alike.Verified against alsa-lib rather than assumed:
ucm_cond.cmakesConditionoptional for a syntax-v8IfcarryingAppend, anduc_mgr_evaluate_includeevaluates each included subtree in place before moving to the next include — so ourDefinelands before the profile include substitutes the variable. The hook and the DualSense profile shipped in the same release (1.2.15), so every tree that has the bug has the hook.Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the RPMs — deb, Arch). The client already has a working fallback in
ensure_pro_audio, and a shared file in two co-installable packages is a file conflict for a nicety. NixOS is not covered: it has no/usr/share/alsa/ucm2to drop into and needs a package override instead.Proven off-hardware, and guarded
scripts/ci/check-dualsense-ucm.shdrives the whole chain with no DualSense, through UCM's card-lessconf.virt.dpath with only the four card built-ins stubbed. Against a pristine Fedora 44alsa-ucm-conf1.2.16.1 tree:Headphones / Headset / Mic / SpeakerSpeakerHaptic / HeadphonesHapticas wellPlaybackPriority200 vs 100,PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3It exists because this fix hooks another project's dispatcher: an upstream rename would neuter it silently, and what comes back is the crash, not a quieter pad. Negative-tested both ways (typo'd
ProfileName, hook deleted). Runs in the rpm workflow's Fedora container; skips itself anywhere withoutalsa-ucm/alsaucm.⚠ A wrong
ProfileNameis not a no-op — it makes the pad's UCM fail to load outright. Loud rather than silent, but it is why the check exists.2. −23.88 dB, twice
WirePlumber starts every new card's sink at
device.routes.default-sink-volume. That is 0.4, and 0.4 is a cubed number: what a mixer shows as 40 % is 0.4³ = 0.064 linear, −23.88 dB. The setting is global — it cannot be scoped to one device from configuration — so there was no config file to ship, and it fires again on every fresh card, which for a usbip pad is every attach.Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice: nobody chose it and nobody would look for it, and both ends of a session mint one, so they multiply — 0.064² = −47.8 dB, matching the −48 dB measured in the field. That is the difference between "the haptics are subtle" and "I'm not sure the haptics are connected".
Both ends now set
channelVolumesto unity (1.0 — unity in the linear units the pod speaks and in the cubed scale a mixer displays, the one unambiguous value).pin_sink_volume, fromcorrelate_pad_sink): at every pick, so a card that re-minted its nodes after a profile change or replug is re-pinned without anything having to notice. Skipped for thesplit_parentpick — that is a name lifted off another node's proplist, and pinning the sink that named it would pin the wrong node.audio/linux/pad_card_volume.rs, whenPadUsbCapturer::opensucceeds): the host half matters becausepad_usbcaptures at the pad's isochronous OUT endpoint, downstream of this sink — so what we encode and send is already attenuated and no client-side fix can recover it. Retries for 15 s rather than firing once: the USB device is live well before its ALSA card is, and pinning before WirePlumber has applied the default we are undoing would simply be overwritten. Only sinks belonging to a DualSense card are touched;device.idkeeps it off a host's own minted pad sink, which publishes the full DualSense identity on purpose.Neither end restores on the way out, deliberately: the profile swap next door overrides a choice the user made, this overrides a default nobody made, and putting −24 dB back would be restoring the bug.
PUNKTFUNK_PAD_SINK_VOLUME=0disables both.3. A silent speaker that looked like broken hardware
An evening went into measuring the host for a pad speaker switched off at the other end. Two hints, at the two places somebody actually looks:
punktfunk-session --pad-audio-testnow says, before the tone, whetherpad_speaker/pad_hapticswould stop a real session rendering the very thing the tone is about to prove works. It has to be said up front: the capability is never advertised when the toggle is off, so no later log line can catch it.pf_client_core'sdefault_pad_speakeris"pad"and always has been — the desktop clients were never the ones hiding it.Worth recording: the host already had the signal.
native/pad_audio.rslogspad audio streaming … haptics=<bool> speaker=<bool>at stream start, and a client with the toggle off shows up there asspeaker=false. It was being printed on the host that was being measured all evening.What this also settles for §3 (the "speaker still sounds broken" report)
Not characterized — that needs the box — but suspect 1 is eliminated from ALSA's source, and the recipe that would have chased it was wrong. The split is
type dshare, and dshare'sshare_areasmoves samples withsnd_pcm_area_copy— a copy, never an accumulate. Two client channels bound to one hardware channel therefore cannot sum: no +6 dB, no clipping, no phase cancellation. Two corrections fall out:chn = 0..nand each copy overwrites the previous, so withChannel0 1, Channel1 1it is sink ch1 that reaches the speaker. ⚠ A tone injected on ch0 alone measures silence at the speaker on a perfectly healthy chain.bindings[chn] != chn(pcm_direct.c).[1,1,2,3]fails at chn 0, so the binding-honoring path runs.Still open
gamepad.rs:797, AndroidDsDevice.kt:310, hostdualsense_proto.rs:353, serialized atr[10]bybuttons2_with_click()(which ORs the rich-plane click, so neither source strands the other). Next step is one command on the box:hid-playstationreports the click on the separateps-touchpadevdev node asBTN_LEFT, soevtestthere while pressing says in one step whether the bit reaches the kernel.vhci_hcd: urb … already given back— untouched.Testing
scripts/ci/check-dualsense-ucm.shpasses on a real Fedora 44alsa-ucm-conf1.2.16.1 tree, and fails correctly when theProfileNameis typo'd or the upstream hook is removed.cargo check --all-targetsclean on Linux (inpunktfunk-rust-ci) forpf-client-core,punktfunk-hostandpunktfunk-client-session— confirmed to actually cover the new files by a deliberate-error run.cargo test: 2 passed (hostpad_card_volume), 19 passed (clientpad_audio, including the new unity-pod test and the picker tests touching the changed struct).LC_ALL=C pactl list short sinks | grep SpeakerHaptic.🤖 Generated with Claude Code
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic "Sony controller speaker" endpoint from that lone mono sink, and Marvel's Spider-Man Remastered overruns it — reliably, ~74 s in: 73.846 render_GetBuffer (…)->(5034, …) <- GE's mono endpoint 73.846 EXCEPTION_ACCESS_VIOLATION info[0]=1 (WRITE) info[1]=5CB9A000 Not a format mismatch: `GetMixFormat` and the game's `Initialize` both agree on mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 x 4 correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while `rcx`/`rdx` are 5206/5207 — the copy loop had already run past the count. It is a game/GE bug on a code path that ONLY EXISTS WHEN THE MONO SINK DOES. So delete the mono sink rather than chase the overrun. `alsa-ucm-conf` describes the pad as Speaker / Headphones / Mic / Headset and has never carried a `SpeakerHaptic` device — the DualSense profile arrived upstream in 1.2.15 (36a111a) already without it, and the Deck's is a Valve downstream patch they still carry on their own 1.2.16.1. With `SpeakerHaptic` at `PlaybackPriority 200` against `Speaker`'s 100 the card takes `HiFi (Mic, SpeakerHaptic)`, the sink is the 4-channel one, and the mono sink — with the crash path — never exists. The voice coils reach their own channels as a bonus. Shipped WITHOUT replacing a file `alsa-ucm-conf` owns, which is what made this awkward to package. `USB-Audio/USB-Audio.conf` ends with an unconditional, optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed after its device table has chosen `${var:ProfileName}` and before it includes the profile that name resolves to — so a two-line drop-in keyed by 054c:0ce6 / 054c:0df2 swaps the profile with no diversion, no `Conflicts`, and no `%config` fight. Verified against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates each included subtree in place before moving to the next include, so the `Define` lands before the profile include substitutes the variable. The hook and the DualSense profile shipped in the SAME release (1.2.15), so every tree that has the bug has the hook. Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the RPMs — deb, Arch). The client already has a working fallback in `ensure_pro_audio`, and a shared file in two co-installable packages is a file conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to drop into and needs a package override instead. `scripts/ci/check-dualsense-ucm.sh` runs the whole chain on a real distro tree with no hardware, via UCM's card-less `conf.virt.d` path with only the four card built-ins stubbed. Against pristine Fedora 44 alsa-ucm-conf 1.2.16.1: baseline `Headphones/Headset/Mic/Speaker`; with the drop-in, `SpeakerHaptic` and `HeadphonesHaptic` too, `PlaybackPriority/SpeakerHaptic=200` over `Speaker`'s 100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3`. It exists because this fix hooks another project's dispatcher: an upstream rename would neuter it silently, and what comes back is the crash, not a quieter pad. Negative-tested both ways (typo'd ProfileName, hook deleted). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>WirePlumber starts every new card's sink at `device.routes.default-sink-volume`. That is 0.4, and 0.4 is a CUBED number: what a mixer shows as 40 % is 0.4^3 = 0.064 of linear amplitude, -23.88 dB. The setting is global — it cannot be scoped to one device from configuration — so there was no config file to ship, and it fires again on every fresh card, which for a usbip pad is every attach. Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice: nobody chose it and nobody would think to look for it (a pad's sink is not a listening volume anyone reaches for, so it reads as weak hardware), and BOTH ends of a session mint one. The game's samples cross this sink on the host and the pad's own sink on the client, so the two multiply: 0.064^2 = -47.8 dB by the time haptics reach a voice coil, which matches the -48 dB measured in the field. That is the difference between "the haptics are subtle" and "I am not sure the haptics are connected". Both ends now set `channelVolumes` to unity — 1.0, which is unity in the linear units the pod speaks AND in the cubed scale a mixer displays, the one value that is unambiguous. * Client (`pin_sink_volume`, called from `correlate_pad_sink`): at every pick, so a card that re-minted its nodes after a profile change or a replug is re-pinned without anything having to notice. Skipped for the `split_parent` pick — that is a name lifted off another node's proplist, with no object of our own behind it, and pinning the sink that named it would pin the wrong node. * Host (`audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open` succeeds): the host half matters because `pad_usb` captures at the pad's isochronous OUT endpoint, DOWNSTREAM of this sink — PipeWire applies the volume when it mixes into the ALSA device, so what we encode and send is already attenuated and no client-side fix can recover it. It retries for 15 s rather than firing once: the USB device is live well before its ALSA card is, and pinning before WirePlumber has applied the default we are undoing would simply be overwritten. Only sinks belonging to a DualSense CARD are touched — `device.id` is what keeps it off a host's own minted pad sink, which publishes the full DualSense identity on purpose. Neither end restores on the way out, deliberately: the profile swap next door overrides a choice the user made, this overrides a default nobody made, and putting -24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0` disables both, for bisecting a box where something else attenuates. Both pods are unit-tested for the shape that actually matters — one unity float per channel. PipeWire ignores a `channelVolumes` whose length does not match the port count, and an ignored pod looks exactly like the pin silently not working, which is the -23.88 dB back again and just as invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>A field session spent an evening measuring the HOST — sinks, tone injection, per-channel peaks — for a pad speaker that was switched off at the other end. A disabled toggle and dead hardware are indistinguishable from the pad, and nothing said which one it was. Two hints, at the two places somebody actually looks: * `punktfunk-session --pad-audio-test` now says, before the tone, whether `pad_speaker` / `pad_haptics` would stop a real session rendering the very thing the tone is about to prove works. The devtest drives the pad DIRECTLY and is otherwise blind to the settings, which is exactly what makes "the tone plays here but the game is silent" so confusing. It has to be said up front: the capability is never advertised when the toggle is off, so no later log line can catch it. * The Android settings row now states its default in the subtitle. Android is the one client that defaults this off, and that is a deliberate form-factor choice (a small, easily-startling loudspeaker in the user's hands, unlike haptics duplicating audio they already hear) — so the default stands and the row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is `"pad"` and always has been, so the desktop clients were never the ones hiding it. Worth recording for the next time: the host ALREADY had the signal. `native/ pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at stream start, and a client with the toggle off shows up there as `speaker=false`. It was being printed on the host that was being measured all evening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>