Field report: DualSense audio haptics and speaker do nothing on the Linux client, against a host the Android client drives fine. Root-caused, fixed, and measured on glass with a wired DS5 on a Steam Deck.
What was wrong
The voice coils are channels 3 and 4 of the pad's USB audio function. The Linux renderer matched a PipeWire sink by name signature alone — no channel check — and streamed a positioned FL/FR/RL/RR quad at it.
A DualSense almost never presents four channels by default. On this Deck the card publishes three nodes, and the old matcher took whichever of the two public ones the registry happened to replay first:
node
media.class
ch
audio.position
alsa_output.hw_Controller_0
Audio/Sink/Internal
4
AUX0..AUX3
…HiFi__SpeakerHaptic__sink
Audio/Sink
4
FL,FR,RL,RR
…HiFi__Speaker__sink
Audio/Sink
1
MONO
One of those is a mono node that cannot carry the coils at all — a coin flip against the registry. Where PipeWire's ACP picks a stereo profile instead, there is no four-channel node at all. Either way the quad is position-remixed on arrival and the coil pair is folded away: audible-looking, felt as nothing.
The Windows half of the same module has required a 4-channel endpoint since it was written. Only Linux never did, and it had only ever been compile-verified — every on-glass run was Android → Linux host, which renders over raw USB and never touches an audio graph.
The fix
Correlation now walks nodes and devices and picks a four-channel node belonging to a DualSense card:
Identity from the USB ids (base 16, either 0x spelling) with the name signature as fallback, on the node or its card — a split card's public sinks publish neither.
device.id is required. That is what keeps a Punktfunk host's own minted pad sink out of the match — it carries the full DualSense identity on purpose, and rendering into it would loop the plane back at the host.
Order: public quad (unpositioned, then positioned) → the hidden split parent → api.alsa.split.name by name → move the card's profile.
With no four-channel node anywhere, the card goes to Pro Audio for the session and is restored at the end (never saved; PUNKTFUNK_PAD_AUDIO_PROFILE=0 opts out; a sandboxed client that is refused the write is told to do it by hand).
The stream now sets stream.dont-remix and AUX0..AUX3 instead of FL,FR,RL,RR, so channel k reaches channel k whatever the node advertises — the same shape the host sink mints and GE-Proton forces on its own haptic streams.
Measured, not reasoned
Capturing the target sink's own monitor index-exact while the real client binary renders:
run
ch0
ch1
ch2
ch3
--coils
0.0000
0.0000
0.5000
0.5000
--speaker
0.5000
0.5000
0.0000
0.0000
Bit-exact, zero cross-talk both directions, card profile untouched. Haptics confirmed physically felt on the pad.
Two defects that only on-glass could catch
Registry global events carry only a subset of an object's proplist.media.class, node.name and device.id are in it — audio.channels and audio.position are not. Reading them there returned 0 channels for every node on the real box, so the matcher could not see the four-channel sink sitting right there and went off changing the user's card profile to fix a problem that did not exist. pw-dump/pactl show those fields because they bind every object, which is what made the registry-only version look plausible. The walk now binds each sink and awaits its info.
Preferring the "unpositioned" node was wrong. It picks the hidden parent, whose AUX0 is a dead hardware channel, so index-exact into it throws away half the speaker signal. A card's public four-channel sink now wins; the parent is the fallback.
Also: a failed profile swap now restores immediately and is not retried (it had left a real Deck sitting on Pro Audio).
The speaker needed more than routing
Haptics were felt; the speaker was not — with the routing already proven correct above. Channel 1 of the pad's audio function is the headphone jack's right channel and the built-in speaker, and the pad powers up pointing at the jack, so with nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are unaffected by that select — exactly why haptics worked instantly and the speaker did not. #259 reads the same thing out of the UCM from the host side.
We only wrote those bytes when a host forwarded a game's AudioCtl, so a title that sets no audio of its own got silence. A tier-A slot with the speaker capability now sends a default speaker-enable packet.
⚠ The one thing still owed. That path byte is empirical — SDL's vendored source pins the struct but never writes these fields. Measured with the pad's own microphone as detector: 0x20 loudest (~5× noise floor), 0x30 also sounds, 0x10 silent. Thin evidence for a constant, so both it and the volume are field levers (PUNKTFUNK_PAD_SPEAKER_PATH / PUNKTFUNK_PAD_SPEAKER_VOLUME). A human confirming which value they actually hear is still outstanding. Everything else in this PR is measured.
Also here
punktfunk-session --pad-audio-test — prints every DualSense object in the graph, the node it chose, and drives a tone into the coils. Separates "the plane never arrived" from "it arrived and the graph folded it away" with no host, no game and no pairing. This diagnostic not existing is why this became a field report.
Controller haptics / Controller speaker rows in the GTK settings — reachable only from Android and the settings file before. Not profileable (which pad is in your hands is a fact about this device), and a stored "mix" survives the round trip.
The tier-A activation packet no longer swallows its error, where SDL does not own the pad's HID link.
Docs: the client half of controller-audio, the settings rows, and the new levers. The "speaker is opt-in" line was only ever true of Android.
No file overlap (that one is host-side pad_sink.rs + devtest.rs), merged clean, re-gated on top. The two agree on the hardware map from opposite directions — my reading of api.alsa.split.position on a real pad and #259's reading of the UCM both give ch0 = headphone L, ch1 = headphone R + speaker, ch2/ch3 = coils.
⚠ Worth knowing: now that the host mirrors a real pad exactly — same names, classes, split keys, channel counts and positions — device.id is the only discriminator left between "the pad in my hands" and "the sink my own host minted". Verified on the Deck with a decoy null-sink wearing the host's identity: listed (virtual), skipped, real pad still picked. Please don't weaken that filter.
Gates
clippy --all-targets -D warnings over pf-client-core, pf-presenter, punktfunk-client-session, punktfunk-client-linux; plain build; 207 tests green (204 in pf-client-core, 7 new); cargo fmt --check — all in punktfunk-rust-ci linux/amd64, on top of current main.
⚠ Note for anyone reproducing on a Deck: the CI image is Ubuntu 26.04 / glibc 2.43 and its binaries cannot start on SteamOS 3.7 (glibc 2.41). A debian:trixie-slim builder matches exactly; it needs libfreetype-dev + libfontconfig-dev on top of the obvious deps (skia, via pf-console-ui).
Field report: DualSense audio haptics and speaker do nothing on the Linux client, against a host the Android client drives fine. Root-caused, fixed, and measured on glass with a wired DS5 on a Steam Deck.
## What was wrong
The voice coils **are** channels 3 and 4 of the pad's USB audio function. The Linux renderer matched a PipeWire sink by **name signature alone — no channel check** — and streamed a *positioned* FL/FR/RL/RR quad at it.
A DualSense almost never presents four channels by default. On this Deck the card publishes three nodes, and the old matcher took whichever of the two public ones the registry happened to replay first:
| node | media.class | ch | audio.position |
|---|---|---|---|
| `alsa_output.hw_Controller_0` | `Audio/Sink/Internal` | 4 | `AUX0..AUX3` |
| `…HiFi__SpeakerHaptic__sink` | `Audio/Sink` | 4 | `FL,FR,RL,RR` |
| `…HiFi__Speaker__sink` | `Audio/Sink` | **1** | `MONO` |
One of those is a **mono** node that cannot carry the coils at all — a coin flip against the registry. Where PipeWire's ACP picks a stereo profile instead, there is no four-channel node at all. Either way the quad is position-remixed on arrival and the coil pair is folded away: audible-looking, felt as nothing.
The Windows half of the same module has required a 4-channel endpoint since it was written. Only Linux never did, and it had only ever been compile-verified — every on-glass run was Android → Linux *host*, which renders over raw USB and never touches an audio graph.
## The fix
Correlation now walks nodes **and** devices and picks a four-channel node belonging to a DualSense **card**:
- Identity from the USB ids (base 16, either `0x` spelling) with the name signature as fallback, on the node **or** its card — a split card's public sinks publish neither.
- **`device.id` is required.** That is what keeps a Punktfunk *host's* own minted pad sink out of the match — it carries the full DualSense identity on purpose, and rendering into it would loop the plane back at the host.
- Order: public quad (unpositioned, then positioned) → the hidden split parent → `api.alsa.split.name` by name → move the card's profile.
- With no four-channel node anywhere, the card goes to **Pro Audio** for the session and is restored at the end (never saved; `PUNKTFUNK_PAD_AUDIO_PROFILE=0` opts out; a sandboxed client that is refused the write is told to do it by hand).
The stream now sets `stream.dont-remix` and `AUX0..AUX3` instead of `FL,FR,RL,RR`, so channel *k* reaches channel *k* whatever the node advertises — the same shape the host sink mints and GE-Proton forces on its own haptic streams.
## Measured, not reasoned
Capturing the target sink's own monitor index-exact while the **real client binary** renders:
| run | ch0 | ch1 | ch2 | ch3 |
|---|---|---|---|---|
| `--coils` | 0.0000 | 0.0000 | **0.5000** | **0.5000** |
| `--speaker` | **0.5000** | **0.5000** | 0.0000 | 0.0000 |
Bit-exact, zero cross-talk both directions, card profile untouched. **Haptics confirmed physically felt on the pad.**
## Two defects that only on-glass could catch
1. **Registry `global` events carry only a subset of an object's proplist.** `media.class`, `node.name` and `device.id` are in it — `audio.channels` and `audio.position` are not. Reading them there returned **0 channels for every node** on the real box, so the matcher could not see the four-channel sink sitting right there and went off changing the user's card profile to fix a problem that did not exist. `pw-dump`/`pactl` show those fields because they bind every object, which is what made the registry-only version look plausible. The walk now binds each sink and awaits its `info`.
2. **Preferring the "unpositioned" node was wrong.** It picks the hidden parent, whose **AUX0 is a dead hardware channel**, so index-exact into it throws away half the speaker signal. A card's *public* four-channel sink now wins; the parent is the fallback.
Also: a failed profile swap now restores immediately and is not retried (it had left a real Deck sitting on Pro Audio).
## The speaker needed more than routing
Haptics were felt; the speaker was not — with the routing already proven correct above. Channel 1 of the pad's audio function is the headphone jack's right channel **and** the built-in speaker, and the pad powers up pointing at the jack, so with nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are unaffected by that select — exactly why haptics worked instantly and the speaker did not. #259 reads the same thing out of the UCM from the host side.
We only wrote those bytes when a host forwarded a game's `AudioCtl`, so a title that sets no audio of its own got silence. A tier-A slot with the speaker capability now sends a default speaker-enable packet.
> ⚠ **The one thing still owed.** That path byte is empirical — SDL's vendored source pins the struct but never writes these fields. Measured with the pad's own microphone as detector: `0x20` loudest (~5× noise floor), `0x30` also sounds, `0x10` silent. Thin evidence for a constant, so both it and the volume are field levers (`PUNKTFUNK_PAD_SPEAKER_PATH` / `PUNKTFUNK_PAD_SPEAKER_VOLUME`). **A human confirming which value they actually hear is still outstanding.** Everything else in this PR is measured.
## Also here
- **`punktfunk-session --pad-audio-test`** — prints every DualSense object in the graph, the node it chose, and drives a tone into the coils. Separates "the plane never arrived" from "it arrived and the graph folded it away" with no host, no game and no pairing. This diagnostic not existing is why this became a field report.
- **Controller haptics / Controller speaker rows** in the GTK settings — reachable only from Android and the settings file before. Not profileable (which pad is in your hands is a fact about this device), and a stored `"mix"` survives the round trip.
- The tier-A activation packet no longer swallows its error, where SDL does not own the pad's HID link.
- Docs: the client half of controller-audio, the settings rows, and the new levers. The "speaker is opt-in" line was only ever true of Android.
## Relationship to #259
No file overlap (that one is host-side `pad_sink.rs` + `devtest.rs`), merged clean, re-gated on top. The two agree on the hardware map from opposite directions — my reading of `api.alsa.split.position` on a real pad and #259's reading of the UCM both give **ch0 = headphone L, ch1 = headphone R + speaker, ch2/ch3 = coils**.
⚠ Worth knowing: now that the host mirrors a real pad *exactly* — same names, classes, split keys, channel counts and positions — **`device.id` is the only discriminator left** between "the pad in my hands" and "the sink my own host minted". Verified on the Deck with a decoy null-sink wearing the host's identity: listed `(virtual)`, skipped, real pad still picked. Please don't weaken that filter.
## Gates
`clippy --all-targets -D warnings` over `pf-client-core`, `pf-presenter`, `punktfunk-client-session`, `punktfunk-client-linux`; plain build; 207 tests green (204 in `pf-client-core`, 7 new); `cargo fmt --check` — all in `punktfunk-rust-ci` linux/amd64, on top of current main.
⚠ Note for anyone reproducing on a Deck: the CI image is Ubuntu 26.04 / glibc 2.43 and its binaries **cannot start** on SteamOS 3.7 (glibc 2.41). A `debian:trixie-slim` builder matches exactly; it needs `libfreetype-dev` + `libfontconfig-dev` on top of the obvious deps (skia, via `pf-console-ui`).
The Linux client's pad-audio renderer matched a PipeWire sink by name signature
alone and streamed a positioned FL/FR/RL/RR quad at it. The voice coils ARE
channels 3 and 4 of the pad's USB sound card, and a DualSense almost never
presents four channels by default: PipeWire's ACP picks a stereo profile, and a
modern alsa-ucm-conf splits the card into a mono Speaker and a stereo Headphones
sink instead. Every one of those opens perfectly and then position-remixes our
quad into the speaker pair, so the coils are never excited — nothing is felt, and
nothing looks wrong. The Windows half of the same module has required a 4-channel
endpoint since it was written; only Linux never did, and it was compile-verified
only (the on-glass leg was Android -> Linux host, which renders over raw USB and
never touches a graph).
Correlation now walks nodes AND devices, and picks a four-channel node that
belongs to a DualSense CARD:
- Identity comes from the USB ids (base 16, either 0x spelling) with the old name
signature as the fallback, and it may sit on either the node or its card — a
split card's public sinks publish neither.
- `device.id` is required, which is also what keeps a Punktfunk HOST's own minted
pad sink out: it carries the full DualSense identity on purpose, and rendering
into it would loop the plane back at the host.
- Unpositioned (AUX) quads are preferred, then positioned ones, then the hidden
four-channel parent a split sink names in `api.alsa.split.name` — GE-Proton's
own preferred haptic leg.
- With no four-channel node anywhere, the card's profile is moved to Pro Audio for
the session and restored when it ends (never saved; `PUNKTFUNK_PAD_AUDIO_PROFILE=0`
opts out; a sandboxed client that is refused the write gets told to do it by hand).
The stream itself now sets `stream.dont-remix` and AUX0..AUX3 rather than
FL/FR/RL/RR, so channel k reaches channel k whatever the node advertises — the
same shape the host-side sink mints and GE forces on its own haptic streams.
Also here:
- `punktfunk-session --pad-audio-test` prints every DualSense object in the graph,
the node it chose, and drives a tone into the coils. It separates "the plane
never arrived" from "it arrived and the graph folded it away" with no host, no
game and no pairing — the diagnostic whose absence made this a field report.
- The GTK client grows Controller haptics / Controller speaker rows; they were
reachable from Android and the settings file only. Not profileable (which pad is
in your hands is a fact about this device), and a stored "mix" survives the
round trip.
- The tier-A activation packet no longer swallows its error: where SDL does not own
the pad's HID link (Linux's own hid-playstation has it), that is worth saying,
because that driver asserts the same audio-haptics disable bit on every rumble.
- Docs: the client half of controller-audio, the two settings rows, and
PUNKTFUNK_PAD_AUDIO_PROFILE. The "speaker is opt-in" line was only true of
Android; desktop has shipped it on.
Gates (Ubuntu 26.04 rust-ci container, linux/amd64): clippy --all-targets -D
warnings over pf-client-core, pf-presenter, punktfunk-client-session and
punktfunk-client-linux; plain build; 204 tests green including 5 new ones for the
matcher, the identity parse and the profile chooser; cargo fmt --check. The graph
walk was smoke-tested against a live PipeWire daemon on home-bazzite-1 (2 sinks,
2 cards enumerated, matching --list-audio; correct "no DualSense" verdict with no
pad attached). NOT yet exercised against a real DualSense — that is the on-glass
step this leaves open.
On-glass on a Steam Deck (SteamOS 3.7, alsa-ucm-conf with DualSense-PS5.conf) with a
wired DualSense. The card publishes three usable-looking nodes, and the previous
commit's "prefer the unpositioned quad" rule picked the wrong one:
alsa_output.hw_Controller_0 Audio/Sink/Internal 4ch AUX0,AUX1,AUX2,AUX3
....HiFi__SpeakerHaptic__sink Audio/Sink 4ch FL,FR,RL,RR
....HiFi__Speaker__sink Audio/Sink 1ch MONO
The hardware map is in the splits' own `api.alsa.split.position`: the mono Speaker
device is `[AUX1]` and SpeakerHaptic is `[AUX1,AUX1,AUX2,AUX3]`, so AUX1 is the
internal speaker, AUX2/AUX3 are the two voice coils, and **AUX0 is nothing**. Our
stream is speaker on 0/1 and haptics on 2/3, so index-exact into the PARENT puts
speaker-left into the dead channel and only speaker-right into the speaker — half
the speaker thrown away. The public split sink folds BOTH our speaker channels onto
AUX1, which is what its UCM author intended, and passes the coil pair through
untouched. Haptics are identical either way; the speaker is not.
So the order is now public-quad (unpositioned, then positioned) before the internal
parent, with `SinkNode::internal` carrying `media.class == Audio/Sink/Internal` or
`api.alsa.split.parent`. Pro Audio's `pro-output-0` is a PUBLIC AUX quad, so it is
still caught by the first rule and nothing about the no-UCM path changes.
Measured, not reasoned: playing a 200 Hz tone present ONLY in channels 3/4, in the
shape this client now uses (AUX0..AUX3 + `stream.dont-remix`), into SpeakerHaptic
and reading that sink's own monitor back index-exact gives
ch0 0.0000 ch1 0.0000 ch2 0.5000 ch3 0.5000
— bit-exact on the coil pair, nothing leaking into the speaker pair. The parent
node has no monitor to capture (0 frames), which is why its map is read from the
split properties instead.
The new test transcribes all three real nodes and asserts the pick from every
enumeration order, which also pins the original defect: the old name-only matcher
took whichever public sink the registry replayed first, and one of them is a MONO
node that cannot carry the coils at all.
Gates: clippy -D warnings over the four client packages, build, 205 tests green
(202 in pf-client-core), cargo fmt --check. Also confirmed on the same Deck that
the pad still presents to the input layer as 054c:0ce6 alongside Steam Input's
28de:11ff virtual pad, so tier-A detection has the real ids to match on.
Caught by running --pad-audio-test on a real Steam Deck with a wired DualSense. The
graph walk read `audio.channels` and `audio.position` out of the registry's `global`
event, and a global announce carries only a SUBSET of an object's proplist. The
subset happens to include `media.class`, `node.name` and `device.id` — which is
exactly why this looked like it worked — but not the audio shape. So every sink came
back as 0 channels:
parent device.id=140 channels=0 position=- alsa_output.hw_Controller_0
sink device.id=140 channels=0 position=- ....HiFi__SpeakerHaptic__sink
sink device.id=140 channels=0 position=- ....HiFi__Speaker__sink
pick: card 140 has no four-channel node — moving it to a four-channel profile
i.e. the matcher could never see the four-channel sink that was sitting right there,
and then went and changed the user's card profile to fix a problem that did not
exist. `pw-dump` and `pactl` show these fields because they BIND every object and
read its info props; reading their output is what made the registry-only version look
plausible.
The walk is now two rounds: the registry replay binds every `Audio/Sink…` node, and a
second sync collects the `info` events that provoked, whose props are the whole
proplist. Cards need no second round — their identity keys are in the announce, and
nothing else about them is weighed. Node parsing moved into `sink_from_props` so the
two sources cannot drift, and it now strips the `[ ... ]` brackets PipeWire puts
around `audio.position`.
Second defect from the same run, and the reason the Deck was left sitting on Pro
Audio afterwards: the devtest's early `?` returned before `restore_profile()`. The
restore now wraps the whole body. And a profile swap that fails to produce a
four-channel node restores the card immediately and records the card in
PROFILE_TRIED, so the renderer's backoff cannot flip a device in the user's sound
settings back and forth for the length of a session.
Gates: clippy -D warnings over the four client packages, build, 205 tests, fmt.
Field result from the Deck: haptics FELT, speaker inaudible — with the routing
already proven correct. Capturing the sink's own monitor while the client renders
shows the speaker pair carrying full-scale signal:
--coils ch0 0.0000 ch1 0.0000 ch2 0.5000 ch3 0.5000
--speaker ch0 0.5000 ch1 0.5000 ch2 0.0000 ch3 0.0000
so nothing was lost on the way to the pad. The loss is inside it. Channel 1 of the
DualSense's audio function is the headphone jack's RIGHT channel *and* the built-in
mono speaker — #259 reads the same thing out of the UCM from the host side ("ch1 is
the built-in mono speaker") — and which of the two physically sounds is chosen by
`ucAudioEnableBits`, report byte 8. A pad powers up pointing at the jack, so with
nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are
NOT affected by that select, which is exactly why haptics worked the instant the
samples were routed right and the speaker did not.
We only ever wrote those bytes when a host forwarded a game's `AudioCtl`, so a title
that manages no audio settings of its own — and every standalone test — got silence.
A tier-A slot with the speaker capability now sends a default speaker-enable packet
beside the audio-haptics packet it already sends. A later `AudioCtl` still overrides
it verbatim, so a game driving its own volume still wins.
⚠ The path byte is EMPIRICAL, not documented: SDL's vendored SDL_hidapi_ps5.c pins
the struct layout but never writes these fields. Measured on 054c:0ce6 using the
pad's own microphone as the detector (Goertzel at the test tone): 0x20 loudest at
~5x the noise floor, 0x30 also sounds, 0x10 silent. That is thin evidence for a
constant, so both it and the volume are field levers —
PUNKTFUNK_PAD_SPEAKER_PATH / PUNKTFUNK_PAD_SPEAKER_VOLUME, hex or decimal — and an
on-glass confirmation of which value a human actually hears is still owed.
The test pins what must not regress: the two validity bits are set, volume and path
land at the same offsets the AudioCtl fold uses, every other byte stays zero, and
`ucEnableBits1` bits 0/1 stay CLEAR — asserting either would enable rumble emulation
and disable audio haptics, muting the coils to make the speaker audible.
Gates: clippy -D warnings over the four client packages, build, 207 tests (204 in
pf-client-core), fmt — on top of current main.
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.
Field report: DualSense audio haptics and speaker do nothing on the Linux client, against a host the Android client drives fine. Root-caused, fixed, and measured on glass with a wired DS5 on a Steam Deck.
What was wrong
The voice coils are channels 3 and 4 of the pad's USB audio function. The Linux renderer matched a PipeWire sink by name signature alone — no channel check — and streamed a positioned FL/FR/RL/RR quad at it.
A DualSense almost never presents four channels by default. On this Deck the card publishes three nodes, and the old matcher took whichever of the two public ones the registry happened to replay first:
alsa_output.hw_Controller_0Audio/Sink/InternalAUX0..AUX3…HiFi__SpeakerHaptic__sinkAudio/SinkFL,FR,RL,RR…HiFi__Speaker__sinkAudio/SinkMONOOne of those is a mono node that cannot carry the coils at all — a coin flip against the registry. Where PipeWire's ACP picks a stereo profile instead, there is no four-channel node at all. Either way the quad is position-remixed on arrival and the coil pair is folded away: audible-looking, felt as nothing.
The Windows half of the same module has required a 4-channel endpoint since it was written. Only Linux never did, and it had only ever been compile-verified — every on-glass run was Android → Linux host, which renders over raw USB and never touches an audio graph.
The fix
Correlation now walks nodes and devices and picks a four-channel node belonging to a DualSense card:
0xspelling) with the name signature as fallback, on the node or its card — a split card's public sinks publish neither.device.idis required. That is what keeps a Punktfunk host's own minted pad sink out of the match — it carries the full DualSense identity on purpose, and rendering into it would loop the plane back at the host.api.alsa.split.nameby name → move the card's profile.PUNKTFUNK_PAD_AUDIO_PROFILE=0opts out; a sandboxed client that is refused the write is told to do it by hand).The stream now sets
stream.dont-remixandAUX0..AUX3instead ofFL,FR,RL,RR, so channel k reaches channel k whatever the node advertises — the same shape the host sink mints and GE-Proton forces on its own haptic streams.Measured, not reasoned
Capturing the target sink's own monitor index-exact while the real client binary renders:
--coils--speakerBit-exact, zero cross-talk both directions, card profile untouched. Haptics confirmed physically felt on the pad.
Two defects that only on-glass could catch
globalevents carry only a subset of an object's proplist.media.class,node.nameanddevice.idare in it —audio.channelsandaudio.positionare not. Reading them there returned 0 channels for every node on the real box, so the matcher could not see the four-channel sink sitting right there and went off changing the user's card profile to fix a problem that did not exist.pw-dump/pactlshow those fields because they bind every object, which is what made the registry-only version look plausible. The walk now binds each sink and awaits itsinfo.Also: a failed profile swap now restores immediately and is not retried (it had left a real Deck sitting on Pro Audio).
The speaker needed more than routing
Haptics were felt; the speaker was not — with the routing already proven correct above. Channel 1 of the pad's audio function is the headphone jack's right channel and the built-in speaker, and the pad powers up pointing at the jack, so with nothing plugged in the speaker pair goes nowhere. The coils are channels 2/3 and are unaffected by that select — exactly why haptics worked instantly and the speaker did not. #259 reads the same thing out of the UCM from the host side.
We only wrote those bytes when a host forwarded a game's
AudioCtl, so a title that sets no audio of its own got silence. A tier-A slot with the speaker capability now sends a default speaker-enable packet.Also here
punktfunk-session --pad-audio-test— prints every DualSense object in the graph, the node it chose, and drives a tone into the coils. Separates "the plane never arrived" from "it arrived and the graph folded it away" with no host, no game and no pairing. This diagnostic not existing is why this became a field report."mix"survives the round trip.Relationship to #259
No file overlap (that one is host-side
pad_sink.rs+devtest.rs), merged clean, re-gated on top. The two agree on the hardware map from opposite directions — my reading ofapi.alsa.split.positionon a real pad and #259's reading of the UCM both give ch0 = headphone L, ch1 = headphone R + speaker, ch2/ch3 = coils.⚠ Worth knowing: now that the host mirrors a real pad exactly — same names, classes, split keys, channel counts and positions —
device.idis the only discriminator left between "the pad in my hands" and "the sink my own host minted". Verified on the Deck with a decoy null-sink wearing the host's identity: listed(virtual), skipped, real pad still picked. Please don't weaken that filter.Gates
clippy --all-targets -D warningsoverpf-client-core,pf-presenter,punktfunk-client-session,punktfunk-client-linux; plain build; 207 tests green (204 inpf-client-core, 7 new);cargo fmt --check— all inpunktfunk-rust-cilinux/amd64, on top of current main.⚠ Note for anyone reproducing on a Deck: the CI image is Ubuntu 26.04 / glibc 2.43 and its binaries cannot start on SteamOS 3.7 (glibc 2.41). A
debian:trixie-slimbuilder matches exactly; it needslibfreetype-dev+libfontconfig-devon top of the obvious deps (skia, viapf-console-ui).