A game that renders DS5 haptics writes a POSITIONED FL/FR/RL/RR quad, because that is the only public 4-channel surface a physically connected pad publishes. We minted a single AUX0..AUX3 node wearing the mono sink's Speaker__sink name, so that write was position-remixed on arrival and the coil pair folded away — measured on .181 against the old build:
positioned quad in → peak_speaker=0.2441 peak_coils=0.0000
The haptics were discarded silently: nothing errored, nothing logged, the sink looked healthy. This is the "games don't write haptics into our virtual sink" report.
The specimen
Measured a real DS5 (054c:0ce6, USB) on a SteamOS 3.7 Deck running alsa-ucm-conf 1.2.14-2.4. A real pad is one card plus three playback nodes, not one sink:
strstr(name, "Speaker__sink") matches only the mono sink — SpeakerHaptic__sink does not contain it, since the Haptic infix breaks the substring. That is how GE-Proton keeps the two legs apart.
The public pair names the hidden parent in api.alsa.split.name (GE's pipewire:NODE= haptic leg) and the parent names itself, exactly as the specimen does.
Everything written to any of the three is summed onto one hardware quad. A real pad gets that free from ALSA SplitPCM; we have to do it by hand, because GE drives the haptic leg and the controller-effect leg at once by design — emitting each node's buffers straight into the chunk channel would interleave them and gap both halves ~50%. Alignment is by contribution round, costing one quantum (~5 ms) of buffering.
Two things the UCM settles that this file had wrong
The four hardware channels.Headphones takes Channel0 0/Channel1 1, Speaker takes Channel0 1, both haptic devices take Channel2 2/Channel3 3. So ch1 is the built-in mono speaker, and a mono write landing on ch0 (what a bare AUX node does) would have played controller effects into the headphone left channel with the speaker silent.
The USB iProduct string is DualSense Wireless Controller, model word included. The DualSense_ infix was dropped here to keep Sony_Interactive_Entertainment_Wireless_Controller contiguous — a property no real pad has either, since its ALSA card is usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00. The name now carries the infix and drops the invented MAC (a real pad has no USB iSerialNumber; the trailing ALSA card index disambiguates).
device.vendor.id/device.product.id also gain the 0x prefix the specimen publishes. strtol(s,_,16) and strtoul(s,_,0) both yield 0x054c for "0x054c", while the bare "054c" we published is parse-dependent — base 0 reads it as octal 054, stops at the c, and yields 44, matching nothing.
On glass (.181), index-exact in every leg
Amplitudes encode the source channel, so a fold or a swap would show:
drive
speaker (ch0/1)
coils (ch2/3)
positioned coils-only → SpeakerHaptic__sink
0.0000
0.4883 (was 0.0000 — the bug)
AUX coils-only → parent
0.0000
0.4883 (unchanged, no regression)
positioned front-only → SpeakerHaptic__sink
0.3052
0.0000
mono → Speaker__sink
0.2747
0.0000
both legs concurrently
0.2747
0.4883
Deliberate deviations from the specimen
Each documented at the head of the module:
node.description keeps "Wireless Controller" — wine hands it to PKEY_Device_FriendlyName and FF14/FF7R do a case-sensitive wcsstr(name, L"Wireless Controller"). The specimen's own "DualSense wireless controller (PS5)" would fail that; GE papers over it on real pads with PROTON_SONY_WINDOWS_DEVICE_NAMES.
priority.session stays low — our nodes come and go with pad arrival and must never win WirePlumber's default-sink election.
api.alsa.split.position is not set — that key is WirePlumber's own split_nodes_om trigger.
Gate
clippy --all-targets -D warnings clean, 9/9 pad_sink tests, fmt clean, all in punktfunk-rust-ci linux/amd64.
Not in this PR
The client leg is a separate, still-unfixed defect: pf-client-core/src/pad_audio.rs picks a sink by name with no channel check and streams positions[..4] = [3,4,12,13] (positioned). The cure exists on worktree-linux-client-pad-audio but is not on main, so the host fix alone does not close the end-to-end.
A game that renders DS5 haptics writes a **POSITIONED `FL/FR/RL/RR` quad**, because that is the only public 4-channel surface a physically connected pad publishes. We minted a single `AUX0..AUX3` node wearing the mono sink's `Speaker__sink` name, so that write was position-remixed on arrival and the coil pair folded away — measured on .181 against the old build:
```
positioned quad in → peak_speaker=0.2441 peak_coils=0.0000
```
The haptics were discarded silently: nothing errored, nothing logged, the sink looked healthy. This is the "games don't write haptics into our virtual sink" report.
## The specimen
Measured a real DS5 (`054c:0ce6`, USB) on a SteamOS 3.7 Deck running `alsa-ucm-conf` 1.2.14-2.4. A real pad is **one card plus three playback nodes**, not one sink:
| node | `media.class` | format |
|---|---|---|
| `alsa_output.hw_Controller_0` | `Audio/Sink/Internal` (hidden parent, `api.alsa.split.parent`) | S16LE **4ch AUX0..AUX3** |
| `…-00.HiFi__SpeakerHaptic__sink` | `Audio/Sink` | F32LE **4ch POSITIONED FL FR RL RR** |
| `…-00.HiFi__Speaker__sink` | `Audio/Sink` | F32LE **1ch MONO** |
`strstr(name, "Speaker__sink")` matches only the mono sink — `SpeakerHaptic__sink` does not contain it, since the `Haptic` infix breaks the substring. That is how GE-Proton keeps the two legs apart.
## What this mints now
```
alsa_output.hw_punktfunkpad<N>_0 Audio/Sink/Internal 4ch AUX0..AUX3 (capture point)
…-<NN>.HiFi__SpeakerHaptic__sink Audio/Sink 4ch FL FR RL RR
…-<NN>.HiFi__Speaker__sink Audio/Sink 1ch MONO
```
The public pair names the hidden parent in `api.alsa.split.name` (GE's `pipewire:NODE=` haptic leg) and the parent names itself, exactly as the specimen does.
Everything written to any of the three is summed onto one hardware quad. A real pad gets that free from ALSA SplitPCM; we have to do it by hand, because **GE drives the haptic leg and the controller-effect leg at once by design** — emitting each node's buffers straight into the chunk channel would interleave them and gap both halves ~50%. Alignment is by contribution round, costing one quantum (~5 ms) of buffering.
## Two things the UCM settles that this file had wrong
**The four hardware channels.** `Headphones` takes `Channel0 0`/`Channel1 1`, `Speaker` takes `Channel0 1`, both haptic devices take `Channel2 2`/`Channel3 3`. So ch1 is the built-in mono speaker, and a mono write landing on ch0 (what a bare AUX node does) would have played controller effects into the headphone **left** channel with the speaker silent.
**The USB `iProduct` string** is `DualSense Wireless Controller`, model word included. The `DualSense_` infix was dropped here to keep `Sony_Interactive_Entertainment_Wireless_Controller` contiguous — a property no real pad has either, since its ALSA card is `usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00`. The name now carries the infix and drops the invented MAC (a real pad has no USB `iSerialNumber`; the trailing ALSA card index disambiguates).
`device.vendor.id`/`device.product.id` also gain the `0x` prefix the specimen publishes. `strtol(s,_,16)` and `strtoul(s,_,0)` both yield `0x054c` for `"0x054c"`, while the bare `"054c"` we published is parse-dependent — base 0 reads it as octal `054`, stops at the `c`, and yields 44, matching nothing.
## On glass (.181), index-exact in every leg
Amplitudes encode the source channel, so a fold or a swap would show:
| drive | speaker (ch0/1) | coils (ch2/3) |
|---|---|---|
| positioned coils-only → `SpeakerHaptic__sink` | 0.0000 | **0.4883** (was **0.0000** — the bug) |
| AUX coils-only → parent | 0.0000 | 0.4883 (unchanged, no regression) |
| positioned front-only → `SpeakerHaptic__sink` | 0.3052 | 0.0000 |
| mono → `Speaker__sink` | 0.2747 | 0.0000 |
| **both legs concurrently** | **0.2747** | **0.4883** |
## Deliberate deviations from the specimen
Each documented at the head of the module:
- `node.description` keeps `"Wireless Controller"` — wine hands it to `PKEY_Device_FriendlyName` and FF14/FF7R do a case-sensitive `wcsstr(name, L"Wireless Controller")`. The specimen's own "DualSense wireless controller (PS5)" would fail that; GE papers over it on real pads with `PROTON_SONY_WINDOWS_DEVICE_NAMES`.
- `priority.session` stays low — our nodes come and go with pad arrival and must never win WirePlumber's default-sink election.
- `api.alsa.split.position` is not set — that key is WirePlumber's own `split_nodes_om` trigger.
## Gate
`clippy --all-targets -D warnings` clean, 9/9 `pad_sink` tests, `fmt` clean, all in `punktfunk-rust-ci` linux/amd64.
## Not in this PR
The **client** leg is a separate, still-unfixed defect: `pf-client-core/src/pad_audio.rs` picks a sink by name with no channel check and streams `positions[..4] = [3,4,12,13]` (positioned). The cure exists on `worktree-linux-client-pad-audio` but is not on `main`, so the host fix alone does not close the end-to-end.
A game that renders DS5 haptics writes a POSITIONED FL/FR/RL/RR quad, because that
is the only public 4-channel surface a physically connected pad publishes. We minted
a single AUX0..AUX3 node wearing the mono sink's `Speaker__sink` name, so that write
was position-remixed on arrival and the coil pair folded away — measured on .181,
`peak_speaker=0.2441` with `peak_coils=0.0000`. The haptics were discarded silently:
nothing errored, nothing logged, the sink looked healthy.
Measured a real DS5 (054c:0ce6, USB) on a SteamOS 3.7 Deck running alsa-ucm-conf
1.2.14-2.4 and minted what it actually presents — a card's worth of nodes, not one:
alsa_output.hw_punktfunkpad<N>_0 Audio/Sink/Internal 4ch AUX0..AUX3
…-<NN>.HiFi__SpeakerHaptic__sink Audio/Sink 4ch FL FR RL RR
…-<NN>.HiFi__Speaker__sink Audio/Sink 1ch MONO
with the public pair naming the hidden parent in `api.alsa.split.name` (GE-Proton's
`pipewire:NODE=` haptic leg) and the parent naming itself, exactly as the specimen
does. Everything written to any of the three is summed onto one hardware quad, which
a real pad gets free from ALSA SplitPCM and we have to do by hand — GE drives the
haptic leg and the controller-effect leg AT ONCE by design, so emitting each node's
buffers straight into the chunk channel would interleave them and gap both halves.
The UCM also settles two things this file had wrong:
- The four hardware channels: `Headphones` takes Channel0 0/Channel1 1, `Speaker`
takes Channel0 1, both haptic devices take Channel2 2/Channel3 3. So ch1 is the
built-in mono speaker, and a mono write landing on ch0 (what a bare AUX node does)
would have played controller effects into the headphone LEFT channel with the
speaker silent.
- A plain DualSense's USB iProduct is "DualSense Wireless Controller", model word
included. The `DualSense_` infix was dropped here to keep
`Sony_Interactive_Entertainment_Wireless_Controller` contiguous — a property no
real pad has either, so the name now carries the infix and drops the invented MAC
(a real pad has no USB iSerialNumber; the trailing ALSA card index disambiguates).
`device.vendor.id`/`device.product.id` also gain the `0x` prefix the specimen
publishes. `strtol(s,_,16)` and `strtoul(s,_,0)` both yield 0x054c for "0x054c",
while the bare "054c" we published is parse-dependent — base 0 reads it as octal
054, stops at the `c`, and yields 44, matching nothing.
On glass on .181, index-exact in every leg (amplitudes encode the source channel):
positioned coils-only → SpeakerHaptic__sink speaker 0.0000 coils 0.4883 (was 0.0000)
AUX coils-only → parent speaker 0.0000 coils 0.4883 (unchanged)
positioned front-only → SpeakerHaptic__sink speaker 0.3052 coils 0.0000
mono → Speaker__sink speaker 0.2747 coils 0.0000
both legs concurrently speaker 0.2747 coils 0.4883
Three deliberate deviations from the specimen stay, each documented at the head of
the module: `node.description` keeps "Wireless Controller" (FF14/FF7R case-sensitive
`wcsstr`), `priority.session` stays low (our nodes come and go with pad arrival and
must never win a default-sink election), and `api.alsa.split.position` is not set
(it is WirePlumber's own management trigger).
Gate: clippy --all-targets -D warnings clean, 9/9 pad_sink tests, fmt clean, all in
punktfunk-rust-ci linux/amd64.
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.
A game that renders DS5 haptics writes a POSITIONED
FL/FR/RL/RRquad, because that is the only public 4-channel surface a physically connected pad publishes. We minted a singleAUX0..AUX3node wearing the mono sink'sSpeaker__sinkname, so that write was position-remixed on arrival and the coil pair folded away — measured on .181 against the old build:The haptics were discarded silently: nothing errored, nothing logged, the sink looked healthy. This is the "games don't write haptics into our virtual sink" report.
The specimen
Measured a real DS5 (
054c:0ce6, USB) on a SteamOS 3.7 Deck runningalsa-ucm-conf1.2.14-2.4. A real pad is one card plus three playback nodes, not one sink:media.classalsa_output.hw_Controller_0Audio/Sink/Internal(hidden parent,api.alsa.split.parent)…-00.HiFi__SpeakerHaptic__sinkAudio/Sink…-00.HiFi__Speaker__sinkAudio/Sinkstrstr(name, "Speaker__sink")matches only the mono sink —SpeakerHaptic__sinkdoes not contain it, since theHapticinfix breaks the substring. That is how GE-Proton keeps the two legs apart.What this mints now
The public pair names the hidden parent in
api.alsa.split.name(GE'spipewire:NODE=haptic leg) and the parent names itself, exactly as the specimen does.Everything written to any of the three is summed onto one hardware quad. A real pad gets that free from ALSA SplitPCM; we have to do it by hand, because GE drives the haptic leg and the controller-effect leg at once by design — emitting each node's buffers straight into the chunk channel would interleave them and gap both halves ~50%. Alignment is by contribution round, costing one quantum (~5 ms) of buffering.
Two things the UCM settles that this file had wrong
The four hardware channels.
HeadphonestakesChannel0 0/Channel1 1,SpeakertakesChannel0 1, both haptic devices takeChannel2 2/Channel3 3. So ch1 is the built-in mono speaker, and a mono write landing on ch0 (what a bare AUX node does) would have played controller effects into the headphone left channel with the speaker silent.The USB
iProductstring isDualSense Wireless Controller, model word included. TheDualSense_infix was dropped here to keepSony_Interactive_Entertainment_Wireless_Controllercontiguous — a property no real pad has either, since its ALSA card isusb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller-00. The name now carries the infix and drops the invented MAC (a real pad has no USBiSerialNumber; the trailing ALSA card index disambiguates).device.vendor.id/device.product.idalso gain the0xprefix the specimen publishes.strtol(s,_,16)andstrtoul(s,_,0)both yield0x054cfor"0x054c", while the bare"054c"we published is parse-dependent — base 0 reads it as octal054, stops at thec, and yields 44, matching nothing.On glass (.181), index-exact in every leg
Amplitudes encode the source channel, so a fold or a swap would show:
SpeakerHaptic__sinkSpeakerHaptic__sinkSpeaker__sinkDeliberate deviations from the specimen
Each documented at the head of the module:
node.descriptionkeeps"Wireless Controller"— wine hands it toPKEY_Device_FriendlyNameand FF14/FF7R do a case-sensitivewcsstr(name, L"Wireless Controller"). The specimen's own "DualSense wireless controller (PS5)" would fail that; GE papers over it on real pads withPROTON_SONY_WINDOWS_DEVICE_NAMES.priority.sessionstays low — our nodes come and go with pad arrival and must never win WirePlumber's default-sink election.api.alsa.split.positionis not set — that key is WirePlumber's ownsplit_nodes_omtrigger.Gate
clippy --all-targets -D warningsclean, 9/9pad_sinktests,fmtclean, all inpunktfunk-rust-cilinux/amd64.Not in this PR
The client leg is a separate, still-unfixed defect:
pf-client-core/src/pad_audio.rspicks a sink by name with no channel check and streamspositions[..4] = [3,4,12,13](positioned). The cure exists onworktree-linux-client-pad-audiobut is not onmain, so the host fix alone does not close the end-to-end.