A DualSense's buttons all reach the stream, and its Mute button works #380

Merged
enricobuehler merged 1 commits from worktree-dualsense-buttons-and-pad-routing into main 2026-08-23 07:39:09 +00:00
Owner

Re-implements the Kotlin half of #371. All three problems it found are real and the diagnosis is good — what this changes is where each fix lands. #371 should not be merged; each of its three fixes breaks more hardware than it repairs. Rust half: #379.

The one fact everything below turns on: 0x13d is BTN_THUMBL (L3) and 0x13e is BTN_THUMBR (R3) in the standard Linux/AOSP mapping — Gamepad.genericKeyCode says so itself. They mean touchpad click and mute only inside the straight-through report order a driverless pad enumerates in, i.e. only inside PadButtons.GENERIC_SONY. There is no scancode that means the same button on every pad; that is the entire reason padMap(dev) exists.

1. Some buttons never reached the stream

Fire OS is reported to deliver certain DualSense buttons (Triangle, touchpad, Mode/PS) with standard KEYCODE_BUTTON_* keycodes but a SOURCE_KEYBOARD tag, and MainActivity's event.isFromSource(SOURCE_GAMEPAD) gate drops them before anything can map them.

The event's source class is the platform's per-event guess; the device's source classes are the fact. New MainActivity.fromPad widens to the device — but only for keycodes that cannot be anything but a gamepad button:

private fun fromPad(event: KeyEvent): Boolean =
    event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
        (KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))

KeyEvent.isGamepadButton is exactly the KEYCODE_BUTTON_* block — no KEYCODE_DPAD_*, no KEYCODE_BACK — and both exclusions are load-bearing. buttonBit's own doc says the D-pad rows "must only be routed here when the event is from a gamepad (a keyboard's arrow keys share these keycodes)", and padButtonBit's says a remote's or keyboard's BACK "has to keep leaving the stream — for a device with no pad on it, Back IS the documented way out". #371 widened unconditionally on vendorId, which routes a Microsoft keyboard's arrow keys into the D-pad and kills the escape hatch (0x045E is Microsoft's VID for Sculpt keyboards and Arc mice too).

Three call sites, one helper — the streaming branch, the console-nav branch, and lastPadIsGamepad (a mis-tagged button also flipped the console's glyphs to remote). Routing happens before correction, so the raw keycode is what's asked; both raw and corrected are in this block for every button concerned.

Gamepad.isPad is left exactly as it is (source-class only) — four call sites read it with that meaning, and widening it would mislabel devices in the Controllers UI and the console device report too. No vendor-id or device-name matching is added to pad identification either: the 2026-08-20 field note records the Fire TV Stick 4K Max identifying both the DualSense (054c:0ce6) and the Elite Series 2 (045e:0b05) correctly. The bug was button positions, never identity.

2. Touchpad click and Mute were dropped

Both have wire bits (BTN_TOUCHPAD, BTN_MISC1) and neither has an Android keycode, so GENERIC_SONY's new 0x13d/0x13e rows borrow BUTTON_15/BUTTON_16 to carry them into buttonBit.

Inside GENERIC_SONY and nowhere else. No override in padKeyCode above padMap(dev), nothing in SONY_MODERN. #371 did both, which costs every Xbox pad, Switch Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks — and via its router change made every R3 press toggle the microphone.

On the borrowed keycodes: buttonBit is global, so a pad that genuinely presses one emits the bit. #371 chose BUTTON_1/BUTTON_2 — evdev BTN_TRIGGER/BTN_THUMB, literally a flight stick's first two buttons, which any joystick-usage HID device reports. BUTTON_15/BUTTON_16 are the last two rows of Generic.kl's joystick block, so a pad has to declare fifteen before it can collide. The mic action is gated separately and much more narrowly (§3), so the residual case is a spurious misc1 on the wire for a 16-button HOTAS.

correct()'s if (keyCode != genericKeyCode(scan)) return keyCode guard stays. It is one of the two mechanisms the block comment above PadButtons says keeps this from breaking working pads, and #371 deleted it with no stated reason while leaving that comment — and genericKeyCode's own doc claiming the guard exists — in place. With it in force GENERIC_SONY's else arm is unreachable, so it stays KEYCODE_UNKNOWN.

A hid-playstation DualSense is not assumed to deliver these on the pad node (touchpad is a separate input node, mute is KEY_MICMUTE) — that would be a separate mapping with its own evidence.

3. Mute toggles the mic — once per press, on a pad that has one

A second trigger for the existing onMicChord, not a new mechanism. Two guards, both necessary:

Edge-triggered, through the existing completesChord as the one-button chord it is. onButton deliberately still calls slotButton(down = true) on auto-repeat and suppresses only send (its repeatCount guard), so #371's unguarded || bit == Gamepad.BTN_MISC1 flaps the mic for as long as the button is held.

Gated on a new Slot.hasMuteButton. This is the part neither #371 nor the handoff's sketch caught: BTN_MISC1 is the wire's misc/QAM bit, and Sc2Device puts a Steam Controller 2's QAM button on it (Sc2Device.kt:117). Reading "any MISC1" as mute would mute the microphone on every QAM press, on first-party hardware this repo ships a capture link for. Resolved once at slot open, off the fact each path actually knows:

  • openSlot(dev)padMap(dev).buttons == GENERIC_SONY — the only route to the bit on that path, and unlike pref it survives the user pinning every pad to one type.
  • openExternal(pref)pref is PREF_DUALSENSE/PREF_DUALSENSEEDGE. (DsDevice already raised BTN_MISC1 from the DualSense report's mute bit; a DualShock 4 has no mute button.)

On systemButtons = local (the if (!systemForward && (bit == BTN_GUIDE || bit == BTN_MISC1)) return at the top of slotButton): a real mute button is exempt, and the decision is commented there. That policy's own words are "keeps them entirely with this device", and toggling this device's microphone is precisely what a mute button does with itself — returning would have left the feature present and silently dead, for a reason nobody would find. It loses its wire send instead (localOnly, which releaseHeld's close-time flush also asks, so a mute held across a disconnect can't emit a release for a press that never went out) and keeps the held tracking the edge-trigger reads. An SC2's QAM is unchanged. MISC1 is in none of the chords, so nothing leaks.

Tests

:kit:testDebugUnitTest + :app:testDebugUnitTest213 passed, 0 failed.

  • every other pad keeps L3 and R3 on those scancodes — the regression that matters most. For NATIVE, GENERIC_XBOX and SONY_MODERN, 0x13d/0x13e must still resolve to BUTTON_THUMBL/BUTTON_THUMBR and thence to BTN_LS_CLICK/BTN_RS_CLICK. Verified to fail on #371's shape by temporarily adding its SONY_MODERN rows.
  • a DualSense's touchpad and mute reach their wire buttons — replaces the old assertion of the opposite, and pins the bits as well as the keycodes.
  • a device layout outranks the table on a scancode the table would have rewritten — the guard's negative path, untested in either direction until now: every existing case fed correct() the keycode Generic.kl would have produced, so the early return was never exercised and the assertions held with it deleted.
  • the mute button toggles the mic once per press (GamepadChordTest) — the edge rule, including that R3, Guide and the face buttons never reach it.
  • an Xbox pad at the standard positions keeps X, Y and its shoulders is kept. It pins the 2026-08-21 field report, it still passes, and removing it buys nothing.

Not verified on hardware

No Fire TV Stick 4K Max, no DualSense, and no adb device attached here, so neither leg of the on-glass check is done and §1's premise (the SOURCE_KEYBOARD tagging) is still unconfirmed. I built it anyway because the fix is a no-op if the premise is false: on a platform that tags correctly, fromPad admits exactly the events the old gate admitted. Before merge, on the Controllers screen (which prints name: scan 0xNNN · RAW → FIXED per event):

  • the fix — Fire TV Stick 4K Max + DualSense over Bluetooth: every button reaches the screen; touchpad and Mute light their new chips; Mute toggles the mic once per press.
  • the regression — any Xbox controller (and a Switch Pro if one's around): L3 and R3 still work, the mic is untouched, and a USB keyboard's arrow keys and a TV remote's Back behave exactly as on main.

An SC2 QAM press should also leave the mic alone — that one is worth a look given it's the gate §3 added.

User-facing fact changed? Yes, two. docs-site/content/docs/input.md is updated in this PR. Note it was already stale in the same paragraph: it claimed the mic mute is "Linux and Windows clients only … on Apple and Android turn Stream microphone off in settings instead", but Android's Select + Y chord has worked for a while (StreamScreen.kt:490). The new sentence names both Android routes and keeps Apple settings-only, which is still correct (GamepadCapture.swift has no mic chord — its comment says Y is deliberately left free for one).

Re-implements the Kotlin half of #371. All three problems it found are real and the diagnosis is good — what this changes is *where* each fix lands. #371 should not be merged; each of its three fixes breaks more hardware than it repairs. Rust half: #379. **The one fact everything below turns on:** `0x13d` is `BTN_THUMBL` (**L3**) and `0x13e` is `BTN_THUMBR` (**R3**) in the standard Linux/AOSP mapping — `Gamepad.genericKeyCode` says so itself. They mean touchpad click and mute *only* inside the straight-through report order a driverless pad enumerates in, i.e. only inside `PadButtons.GENERIC_SONY`. There is no scancode that means the same button on every pad; that is the entire reason `padMap(dev)` exists. ## 1. Some buttons never reached the stream Fire OS is reported to deliver certain DualSense buttons (Triangle, touchpad, Mode/PS) with standard `KEYCODE_BUTTON_*` keycodes but a `SOURCE_KEYBOARD` tag, and `MainActivity`'s `event.isFromSource(SOURCE_GAMEPAD)` gate drops them before anything can map them. The event's source class is the platform's per-**event** guess; the **device's** source classes are the fact. New `MainActivity.fromPad` widens to the device — but only for keycodes that cannot be anything but a gamepad button: ```kotlin private fun fromPad(event: KeyEvent): Boolean = event.isFromSource(InputDevice.SOURCE_GAMEPAD) || (KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device)) ``` `KeyEvent.isGamepadButton` is exactly the `KEYCODE_BUTTON_*` block — **no `KEYCODE_DPAD_*`, no `KEYCODE_BACK`** — and both exclusions are load-bearing. `buttonBit`'s own doc says the D-pad rows "must only be routed here when the event is from a gamepad (a keyboard's arrow keys share these keycodes)", and `padButtonBit`'s says a remote's or keyboard's BACK "has to keep leaving the stream — for a device with no pad on it, Back IS the documented way out". #371 widened unconditionally on `vendorId`, which routes a Microsoft keyboard's arrow keys into the D-pad and kills the escape hatch (`0x045E` is Microsoft's VID for Sculpt keyboards and Arc mice too). Three call sites, one helper — the streaming branch, the console-nav branch, and `lastPadIsGamepad` (a mis-tagged button also flipped the console's glyphs to remote). Routing happens before correction, so the **raw** keycode is what's asked; both raw and corrected are in this block for every button concerned. `Gamepad.isPad` is left exactly as it is (source-class only) — four call sites read it with that meaning, and widening it would mislabel devices in the Controllers UI and the console device report too. **No vendor-id or device-name matching is added to pad identification either:** the 2026-08-20 field note records the Fire TV Stick 4K Max identifying both the DualSense (`054c:0ce6`) and the Elite Series 2 (`045e:0b05`) *correctly*. The bug was button positions, never identity. ## 2. Touchpad click and Mute were dropped Both have wire bits (`BTN_TOUCHPAD`, `BTN_MISC1`) and neither has an Android keycode, so `GENERIC_SONY`'s new `0x13d`/`0x13e` rows borrow `BUTTON_15`/`BUTTON_16` to carry them into `buttonBit`. **Inside `GENERIC_SONY` and nowhere else.** No override in `padKeyCode` above `padMap(dev)`, nothing in `SONY_MODERN`. #371 did both, which costs every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation` DualSense **both stick clicks** — and via its router change made every R3 press toggle the microphone. On the borrowed keycodes: `buttonBit` is global, so a pad that genuinely presses one emits the bit. #371 chose `BUTTON_1`/`BUTTON_2` — evdev `BTN_TRIGGER`/`BTN_THUMB`, literally a flight stick's first two buttons, which any joystick-usage HID device reports. `BUTTON_15`/`BUTTON_16` are the last two rows of `Generic.kl`'s joystick block, so a pad has to declare fifteen before it can collide. The mic action is gated separately and much more narrowly (§3), so the residual case is a spurious `misc1` on the wire for a 16-button HOTAS. `correct()`'s `if (keyCode != genericKeyCode(scan)) return keyCode` guard **stays**. It is one of the two mechanisms the block comment above `PadButtons` says keeps this from breaking working pads, and #371 deleted it with no stated reason while leaving that comment — and `genericKeyCode`'s own doc claiming the guard exists — in place. With it in force `GENERIC_SONY`'s `else` arm is unreachable, so it stays `KEYCODE_UNKNOWN`. A `hid-playstation` DualSense is **not** assumed to deliver these on the pad node (touchpad is a separate input node, mute is `KEY_MICMUTE`) — that would be a separate mapping with its own evidence. ## 3. Mute toggles the mic — once per press, on a pad that has one A second trigger for the existing `onMicChord`, not a new mechanism. Two guards, both necessary: **Edge-triggered**, through the existing `completesChord` as the one-button chord it is. `onButton` deliberately still calls `slotButton(down = true)` on auto-repeat and suppresses only `send` (its repeatCount guard), so #371's unguarded `|| bit == Gamepad.BTN_MISC1` flaps the mic for as long as the button is held. **Gated on a new `Slot.hasMuteButton`.** This is the part neither #371 nor the handoff's sketch caught: `BTN_MISC1` is the wire's misc/**QAM** bit, and `Sc2Device` puts a Steam Controller 2's QAM button on it (`Sc2Device.kt:117`). Reading "any MISC1" as mute would mute the microphone on every QAM press, on first-party hardware this repo ships a capture link for. Resolved once at slot open, off the fact each path actually knows: * `openSlot(dev)` → `padMap(dev).buttons == GENERIC_SONY` — the only route to the bit on that path, and unlike `pref` it survives the user pinning every pad to one type. * `openExternal(pref)` → `pref` is `PREF_DUALSENSE`/`PREF_DUALSENSEEDGE`. (`DsDevice` already raised `BTN_MISC1` from the DualSense report's mute bit; a DualShock 4 has no mute button.) **On `systemButtons = local`** (the `if (!systemForward && (bit == BTN_GUIDE || bit == BTN_MISC1)) return` at the top of `slotButton`): a real mute button is **exempt**, and the decision is commented there. That policy's own words are "keeps them entirely with this device", and toggling this device's microphone is precisely what a mute button does with itself — returning would have left the feature present and silently dead, for a reason nobody would find. It loses its wire send instead (`localOnly`, which `releaseHeld`'s close-time flush also asks, so a mute held across a disconnect can't emit a release for a press that never went out) and keeps the held tracking the edge-trigger reads. An SC2's QAM is unchanged. MISC1 is in none of the chords, so nothing leaks. ## Tests `:kit:testDebugUnitTest` + `:app:testDebugUnitTest` → **213 passed, 0 failed**. * **`every other pad keeps L3 and R3 on those scancodes`** — the regression that matters most. For `NATIVE`, `GENERIC_XBOX` and `SONY_MODERN`, `0x13d`/`0x13e` must still resolve to `BUTTON_THUMBL`/`BUTTON_THUMBR` and thence to `BTN_LS_CLICK`/`BTN_RS_CLICK`. **Verified to fail on #371's shape** by temporarily adding its `SONY_MODERN` rows. * `a DualSense's touchpad and mute reach their wire buttons` — replaces the old assertion of the opposite, and pins the bits as well as the keycodes. * `a device layout outranks the table on a scancode the table would have rewritten` — the guard's **negative** path, untested in either direction until now: every existing case fed `correct()` the keycode `Generic.kl` would have produced, so the early return was never exercised and the assertions held with it deleted. * `the mute button toggles the mic once per press` (GamepadChordTest) — the edge rule, including that R3, Guide and the face buttons never reach it. * `an Xbox pad at the standard positions keeps X, Y and its shoulders` is **kept**. It pins the 2026-08-21 field report, it still passes, and removing it buys nothing. ## Not verified on hardware No Fire TV Stick 4K Max, no DualSense, and no adb device attached here, so **neither leg of the on-glass check is done** and §1's premise (the `SOURCE_KEYBOARD` tagging) is still unconfirmed. I built it anyway because the fix is a no-op if the premise is false: on a platform that tags correctly, `fromPad` admits exactly the events the old gate admitted. Before merge, on the Controllers screen (which prints `name: scan 0xNNN · RAW → FIXED` per event): * **the fix** — Fire TV Stick 4K Max + DualSense over Bluetooth: every button reaches the screen; touchpad and Mute light their new chips; Mute toggles the mic once per press. * **the regression** — any Xbox controller (and a Switch Pro if one's around): **L3 and R3 still work**, the mic is untouched, and a USB keyboard's arrow keys and a TV remote's Back behave exactly as on `main`. An SC2 QAM press should also leave the mic alone — that one is worth a look given it's the gate §3 added. **User-facing fact changed?** Yes, two. `docs-site/content/docs/input.md` is updated in this PR. Note it was already stale in the same paragraph: it claimed the mic mute is "**Linux and Windows** clients only … on Apple and Android turn **Stream microphone** off in settings instead", but Android's **Select + Y** chord has worked for a while (`StreamScreen.kt:490`). The new sentence names both Android routes and keeps Apple settings-only, which is still correct (`GamepadCapture.swift` has no mic chord — its comment says Y is deliberately left free for one).
enricobuehler added 1 commit 2026-08-22 23:09:02 +00:00
fix(clients/android): a DualSense's buttons all reach the stream, and its Mute button works
ci / web (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 19s
ci / docs-drift (pull_request) Successful in 40s
ci / docs-site (pull_request) Successful in 1m17s
android / android (pull_request) Successful in 5m51s
ci / rust (pull_request) Successful in 5m57s
cb07a8f983
Three defects reported against a Bluetooth DualSense on a Fire TV Stick 4K Max,
re-implemented from #371's diagnosis. #371 itself should not be merged: all
three problems are real, but each fix lands somewhere that breaks more hardware
than it repairs.

1. Some buttons never reach the stream. Fire OS is reported to tag certain
   DualSense buttons SOURCE_KEYBOARD even though the keycodes are standard
   BUTTON_*, and MainActivity's `event.isFromSource(SOURCE_GAMEPAD)` gate then
   drops them. The event's source class is the platform's per-event guess; the
   DEVICE's is the fact. New `MainActivity.fromPad` widens to the device — but
   ONLY for `KeyEvent.isGamepadButton` keycodes. That exclusion is the whole
   safety of it: DPAD keycodes are a keyboard's arrow keys and BACK is a
   remote's way out of the stream, and both share their keycodes with a pad.
   `Gamepad.isPad` is untouched (source-class only) and no vendor-id or
   device-name matching is added anywhere — the field report records both pads
   being IDENTIFIED correctly; only their button positions were wrong.

2. Touchpad click and Mute were dropped. Both have wire bits (BTN_TOUCHPAD,
   BTN_MISC1) and no Android keycode, so GENERIC_SONY's `0x13d`/`0x13e` rows now
   borrow BUTTON_15/BUTTON_16 to carry them into `buttonBit`. Inside
   GENERIC_SONY and nowhere else: `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR —
   L3 and R3 — in the standard Linux mapping, and they mean touchpad and mute
   only inside the straight-through report order a driverless pad uses. A row in
   SONY_MODERN, or an override above `padMap(dev)`, costs every Xbox pad, Switch
   Pro, 8BitDo, Steam Deck and hid-playstation DualSense both stick clicks.
   `correct()`'s `genericKeyCode` guard stays exactly as it was.

3. Mute toggles the mic — once per press, and only on a pad that has one.
   Edge-triggered through the existing `completesChord` as the one-button chord
   it is: `onButton` still calls `slotButton(down = true)` on auto-repeat, so an
   unguarded check would flap the mic for as long as the button is held. Gated
   on a new `Slot.hasMuteButton`, because BTN_MISC1 is the wire's misc/QAM bit
   and `Sc2Device` puts a Steam Controller 2's QAM button on it — "any MISC1"
   would mute the microphone on every QAM press. Resolved at slot open from what
   each path knows: the report order for an InputDevice, the declared kind for a
   capture link. Under the "local" system-button policy a real mute button is
   exempt from the early return (that policy means the press stays with this
   device, which is exactly what the toggle does) and loses only its wire send;
   every other system button behaves as before.

Tests: `every other pad keeps L3 and R3 on those scancodes` is the regression
that matters and fails on #371's shape (verified by reproducing it). Plus the
rewritten touchpad/mute assertions, the guard's negative path — untested in
either direction until now, because every existing case fed `correct()` the
keycode `Generic.kl` would have produced — and the mute button's edge rule in
GamepadChordTest. `an Xbox pad at the standard positions keeps X, Y and its
shoulders` is kept.

Not yet verified on hardware: no Fire TV Stick 4K Max or DualSense here, and no
adb device attached. §1's premise (the SOURCE_KEYBOARD tagging) is therefore
unconfirmed — the change is a no-op if it does not hold.
enricobuehler merged commit 773eea24d9 into main 2026-08-23 07:39:08 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#380