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
7 changed files with 254 additions and 25 deletions
@@ -932,6 +932,10 @@ private val TEST_BUTTONS = listOf(
"Select" to KeyEvent.KEYCODE_BUTTON_SELECT,
"Start" to KeyEvent.KEYCODE_BUTTON_START,
"Guide" to KeyEvent.KEYCODE_BUTTON_MODE,
// The two buttons Android has no keycode for, on the keycodes [Gamepad.buttonBit] borrows for
// them. Only a driverless Sony pad reaches these; every other controller leaves them dark.
"Touch" to KeyEvent.KEYCODE_BUTTON_15,
"Mute" to KeyEvent.KEYCODE_BUTTON_16,
"" to KeyEvent.KEYCODE_DPAD_UP,
"" to KeyEvent.KEYCODE_DPAD_DOWN,
"" to KeyEvent.KEYCODE_DPAD_LEFT,
@@ -615,7 +615,7 @@ class MainActivity : ComponentActivity() {
// keyboard arrows and belong to the VK path below — and BACK, which is how a pad with
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
@@ -697,7 +697,7 @@ class MainActivity : ComponentActivity() {
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are) — and, for a real
// pad, WHICH pad family, so the glyphs wear its lettering/shapes.
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
lastPadIsGamepad = fromPad(event)
if (lastPadIsGamepad) {
lastPadStyle = Gamepad.styleFor(event.device)
lastPadDeviceId = event.deviceId
@@ -705,7 +705,7 @@ class MainActivity : ComponentActivity() {
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
if (fromPad(event)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched. Read
@@ -728,6 +728,32 @@ class MainActivity : ComponentActivity() {
return super.dispatchKeyEvent(event)
}
/**
* Did this key event come from a controller — the question every pad branch here actually
* means when it asks `isFromSource(SOURCE_GAMEPAD)`.
*
* The event's source class is the platform's per-EVENT guess, and some boxes get it wrong:
* Fire OS is reported to deliver a Bluetooth DualSense's Triangle, touchpad and Mode/PS with
* standard `KEYCODE_BUTTON_*` keycodes but a SOURCE_KEYBOARD tag, and the plain gate then
* drops them before anything can map them. The DEVICE's source classes are the fact, so widen
* to the device — but only for keycodes that cannot be anything BUT a gamepad button.
*
* That restriction is the whole safety of this. [KeyEvent.isGamepadButton] is exactly the
* `KEYCODE_BUTTON_*` block — no `KEYCODE_DPAD_*`, no `KEYCODE_BACK` — and both exclusions are
* load-bearing: a keyboard's arrow keys share the D-pad keycodes and belong to the VK path
* ([Gamepad.buttonBit]), and a remote's or keyboard's BACK shares `KEYCODE_BACK` and has to
* keep leaving the stream, which for a device with no pad on it is the documented way out
* ([Gamepad.padButtonBit]). Widening on the device alone — or on its vendor id, which for
* `0x045E`/`0x054C` covers those vendors' keyboards and mice too — routes both into the pad
* branch and breaks them.
*
* The RAW keycode is what is asked: routing happens before [Gamepad.padKeyCode]'s correction,
* and both the raw and the corrected keycode are in this block for every button concerned.
*/
private fun fromPad(event: KeyEvent): Boolean =
event.isFromSource(InputDevice.SOURCE_GAMEPAD) ||
(KeyEvent.isGamepadButton(event.keyCode) && Gamepad.isPad(event.device))
/**
* `true` (back) / `false` (forward) when this key event is a MOUSE side button, null when it is
* anything else — including a remote's or keyboard's BACK, which must keep exiting the stream.
@@ -282,6 +282,17 @@ object Gamepad {
* `KEYCODE_DPAD_*` are included but must only be routed here when the event is from a gamepad
* (a keyboard's arrow keys share these keycodes and belong to the VK path) — see MainActivity.
* L2/R2 are forwarded as the analog trigger axes, never as buttons.
*
* [BTN_TOUCHPAD] and [BTN_MISC1] have no Android keycode at all, so
* [PadButtons.GENERIC_SONY] BORROWS the last two rows of `Generic.kl`'s joystick block for
* them ([KEYCODE_BUTTON_15][KeyEvent.KEYCODE_BUTTON_15] / `_16`, evdev `BTN_BASE5`/`BTN_BASE6`)
* — see there. This table is global, so a device that genuinely presses one of those two
* emits the bit as well. That is the cost of the borrow, and it is why the borrow is at the
* TOP of the block rather than at `BUTTON_1`/`BUTTON_2`: those are a flight stick's trigger
* and thumb button, which any joystick-usage HID device reports, whereas reaching `BUTTON_15`
* takes a pad that declares fifteen. The residual case — a fifteen-button HOTAS whose button
* 16 also toggles the client's mic — is the one this leaves on the table; narrowing it
* further needs per-device knowledge the router does not have (see `GamepadRouter`).
*/
fun buttonBit(keyCode: Int): Int = when (keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> BTN_A
@@ -295,6 +306,8 @@ object Gamepad {
KeyEvent.KEYCODE_BUTTON_START -> BTN_START
KeyEvent.KEYCODE_BUTTON_SELECT -> BTN_BACK
KeyEvent.KEYCODE_BUTTON_MODE -> BTN_GUIDE
KeyEvent.KEYCODE_BUTTON_15 -> BTN_TOUCHPAD // borrowed — see the KDoc
KeyEvent.KEYCODE_BUTTON_16 -> BTN_MISC1 // borrowed — see the KDoc
KeyEvent.KEYCODE_DPAD_UP -> BTN_DPAD_UP
KeyEvent.KEYCODE_DPAD_DOWN -> BTN_DPAD_DOWN
KeyEvent.KEYCODE_DPAD_LEFT -> BTN_DPAD_LEFT
@@ -404,9 +417,12 @@ object Gamepad {
/**
* A Sony pad numbering straight through with no kernel driver behind it: □ ✕ ○ △ L1 R1
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
* [buttonBit] deliberately drops — the wire carries the axis, never both.
* L2 R2 Create Options L3 R3 PS touchpad mute, i.e. `0x130`..`0x13e` in that order. The
* analog trigger value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold
* to keycodes [buttonBit] deliberately drops — the wire carries the axis, never both.
*
* This order — and ONLY this order — is where `0x13d`/`0x13e` mean the touchpad click and
* the mute button. Everywhere else they are L3/R3.
*/
GENERIC_SONY,
@@ -453,7 +469,23 @@ object Gamepad {
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
// Touchpad click and mute. The wire has bits for both ([BTN_TOUCHPAD] /
// [BTN_MISC1]) and Android has no keycode for either, so these two borrow
// BUTTON_15/BUTTON_16 to reach [buttonBit] — see its KDoc for the cost.
//
// ONLY here. `0x13d`/`0x13e` are BTN_THUMBL/BTN_THUMBR (L3/R3) in the standard
// Linux mapping — [genericKeyCode] says so itself — and they mean touchpad and
// mute purely because a driverless DualSense enumerates its buttons straight
// through in its own report order, which is what GENERIC_SONY IS. Hoisting
// this above `padMap(dev)` would put L3 on the touchpad and R3 on the mic for
// every Xbox pad, Switch Pro, 8BitDo, Steam Deck and `hid-playstation`
// DualSense on the couch. There is no scancode that means the same button on
// all pads; that is the entire reason this enum exists.
0x13d -> KeyEvent.KEYCODE_BUTTON_15 // touchpad click → BTN_TOUCHPAD
0x13e -> KeyEvent.KEYCODE_BUTTON_16 // mute → BTN_MISC1
// Unreachable with the guard above in force (it only lets `0x130`..`0x13e`
// through, and every one of those is now named), and KEYCODE_UNKNOWN is the
// safe answer if that ever changes.
else -> KeyEvent.KEYCODE_UNKNOWN
}
GENERIC_XBOX -> when (scan) {
@@ -101,6 +101,18 @@ class GamepadRouter(
* the whole session. The capture-link pads carry the same flag on [ExternalPad].
*/
val motionReaches: Boolean = true,
/**
* Whether [Gamepad.BTN_MISC1] means a MUTE button on this particular pad — the one bit
* whose physical meaning differs per controller, and the gate on the mic toggle in
* [slotButton].
*
* A DualSense has one; a Steam Controller 2 puts its QAM button on the same wire bit
* (`Sc2Device`), and QAM must not mute anyone's microphone. Asked once at open, off the
* fact each path actually knows: the report order for an [InputDevice] (only
* [Gamepad.PadButtons.GENERIC_SONY] mints this bit there), the declared pad kind for a
* capture link.
*/
val hasMuteButton: Boolean = false,
) {
/** Forwarded button bits currently held (Gamepad.BTN_*) — for release-on-close + chord detection. */
var held = 0
@@ -160,7 +172,8 @@ class GamepadRouter(
/**
* Invoked (main thread) each time the mic-mute chord ([MIC_CHORD], Select + Y) is COMPLETED on
* a pad — the couch equivalent of the stream's on-screen mute button, which a gamepad user
* a pad, or a pad's own mute button ([Gamepad.BTN_MISC1] — a DualSense's) is pressed — the
* couch equivalent of the stream's on-screen mute button, which a gamepad user
* cannot reach. `StreamScreen` wires it to the mute toggle. Unlike the exit chord this fires
* immediately: muting is the kind of thing you want to have already happened, and the on-screen
* indicator makes an accidental toggle self-evident. The buttons still go to the host — the
@@ -234,15 +247,40 @@ class GamepadRouter(
}
}
/**
* Is this bit's WIRE SEND kept with this device, though the bit is otherwise tracked normally?
*
* Exactly one is: a real mute button ([Slot.hasMuteButton]) under the "local" [systemForward]
* policy. It is tracked — the mic toggle in [slotButton] is edge-triggered off held state —
* but not forwarded, so every send site has to ask, including [releaseHeld]'s close-time
* flush, or a mute held across a disconnect would put a release on the wire for a press that
* never went out. Every other system button under that policy leaves [slotButton] at the top
* and never reaches a send at all.
*/
private fun localOnly(slot: Slot, bit: Int): Boolean =
!systemForward && bit == Gamepad.BTN_MISC1 && slot.hasMuteButton
/**
* One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s
* transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire
* the instant chords ([MIC_CHORD], [STATS_CHORD]).
* the instant chords ([MIC_CHORD], [STATS_CHORD], and the mute button's own mic toggle).
*/
private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) {
// Raw system buttons stay local under the "local" policy — no wire send and no held
// tracking, symmetric on both edges so nothing leaks into the chords either.
if (!systemForward && (bit == Gamepad.BTN_GUIDE || bit == Gamepad.BTN_MISC1)) return
// tracking, symmetric on both edges so nothing leaks into the chords either. A Steam
// Controller 2's QAM button is BTN_MISC1 and keeps exactly that behaviour.
//
// A real MUTE button ([Slot.hasMuteButton]) is deliberately exempt: 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 here would have left the
// button present and silently dead under `local`, for a reason nobody would ever find. It
// loses its wire send instead (see [localOnly]) and keeps the held tracking the toggle's
// edge-trigger reads. It cannot leak into a chord — MISC1 is in none of them.
if (!systemForward &&
(bit == Gamepad.BTN_GUIDE || (bit == Gamepad.BTN_MISC1 && !slot.hasMuteButton))
) {
return
}
if (down) {
if (guideGesture && send) {
// A Select pressed ALONE is held back until it resolves: a tap (delivered
@@ -258,7 +296,7 @@ class GamepadRouter(
}
flushPendingSelect(slot)
}
if (send && forwarding) {
if (send && forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, true, slot.index)
}
val wasHeld = slot.held
@@ -268,11 +306,26 @@ class GamepadRouter(
// Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES
// its chord (see [completesChord]) — the two meanings this client gives Select plus a
// face button. Both leave the press on the wire: the game still gets its buttons.
if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke()
//
// A pad's own mute button is a second trigger for the SAME toggle, not a new
// mechanism — so it gets the same edge-trigger, expressed as the one-button chord it
// is. That is load-bearing rather than tidy: [onButton] deliberately still calls this
// with `down = true` on auto-repeat and suppresses only `send` (its repeatCount
// guard), so an unguarded `bit == BTN_MISC1` would flap the mic for as long as the
// button is held down.
//
// [Slot.hasMuteButton] is the other half, and it is not belt-and-braces: BTN_MISC1 is
// the wire's misc/QAM bit, and `Sc2Device` puts a Steam Controller 2's QAM button on
// it. Reading "any MISC1" as mute would mute the microphone on every QAM press.
if (completesChord(wasHeld, bit, MIC_CHORD) ||
(slot.hasMuteButton && completesChord(wasHeld, bit, Gamepad.BTN_MISC1))
) {
onMicChord?.invoke()
}
if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke()
} else {
val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot)
if (!owned && send && forwarding) {
if (!owned && send && forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
slot.held = slot.held and bit.inv()
@@ -543,7 +596,15 @@ class GamepadRouter(
// time. Cheap enough to ask unconditionally; the answer holds for the pad's lifetime.
val motionReaches = NativeBridge.nativePadMotionReaches(handle, pref)
if (forwarding && hasGyro && !motionReaches) onMotionUnreachable?.invoke()
slots[syntheticId] = Slot(index, Gamepad.AxisMapper(handle, index))
// `DsDevice` raises BTN_MISC1 from the DualSense report's mute bit; `Sc2Device` raises the
// same bit from the Steam Controller 2's QAM button, which must not touch the microphone.
// The declared kind separates them (a DualShock 4 has no mute button either).
val hasMute = pref == Gamepad.PREF_DUALSENSE || pref == Gamepad.PREF_DUALSENSEEDGE
slots[syntheticId] = Slot(
index,
Gamepad.AxisMapper(handle, index),
hasMuteButton = hasMute,
)
return ExternalPad(syntheticId, index, motionReaches)
}
@@ -603,10 +664,15 @@ class GamepadRouter(
// Asked here, off the kind this pad just DECLARED — not off the session's resolved backend,
// which under Automatic answers for whichever pad happened to be active at dial time. Held
// for the slot's life; the sensor path reads it on every sample.
val map = Gamepad.padMap(dev)
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
Gamepad.AxisMapper(handle, index, map),
NativeBridge.nativePadMotionReaches(handle, pref),
// The only route to BTN_MISC1 on this path is GENERIC_SONY's `0x13e` row, so the
// report order IS the answer — and unlike `pref` it survives the user pinning every
// pad to one type, which would otherwise cost a DualSense its mute button.
hasMuteButton = map.buttons == Gamepad.PadButtons.GENERIC_SONY,
)
slots[dev.id] = slot
// After the table holds the slot, so a listener that sends on this device the moment it is
@@ -652,7 +718,9 @@ class GamepadRouter(
var bits = slot.held
while (bits != 0) {
val bit = bits and -bits // lowest set bit
if (forwarding) NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
if (forwarding && !localOnly(slot, bit)) {
NativeBridge.nativeSendGamepadButton(handle, bit, false, slot.index)
}
bits = bits and bit.inv()
}
slot.held = 0
@@ -1,6 +1,7 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -155,6 +156,44 @@ class GamepadChordTest {
assertEquals(instantChords, pad.press(Gamepad.BTN_BACK))
}
/**
* A pad's own mute button (a DualSense's) is a second trigger for the mic toggle, and
* `slotButton` reads it through the SAME edge rule expressed as a one-button chord.
*
* That is not decoration. `onButton` deliberately still calls `slotButton(down = true)` on
* auto-repeat and suppresses only the wire send (its repeatCount guard), so a plain
* `bit == BTN_MISC1` would toggle the mic on every repeat — hold the button and the mic
* flaps. `completesChord` against a single-bit mask is exactly "a fresh press of it".
*
* The other half is which buttons must NOT reach it. `0x13e` is R3 on every pad but a
* driverless Sony one, so a mapping that leaked touchpad/mute meanings outside
* [Gamepad.PadButtons.GENERIC_SONY] would put the mic toggle on every R3 press in the house.
*
* `slotButton` ANDs this rule with `Slot.hasMuteButton`, because BTN_MISC1 is the wire's
* misc/QAM bit and a Steam Controller 2's QAM button rides it too. That term needs a live
* `Slot`, which needs an InputManager and a main Looper, so it is out of reach from here —
* the edge rule below is the half a unit test can hold.
*/
@Test
fun `the mute button toggles the mic once per press`() {
fun fires(wasHeld: Int, bit: Int) =
GamepadRouter.completesChord(wasHeld, bit, Gamepad.BTN_MISC1)
assertTrue("a fresh press must toggle", fires(0, Gamepad.BTN_MISC1))
assertFalse("auto-repeat re-fired the toggle", fires(Gamepad.BTN_MISC1, Gamepad.BTN_MISC1))
assertTrue(
"a press while other buttons are held is still a fresh press",
fires(Gamepad.BTN_A or Gamepad.BTN_BACK, Gamepad.BTN_MISC1),
)
for (other in listOf(
Gamepad.BTN_A, Gamepad.BTN_X, Gamepad.BTN_Y, Gamepad.BTN_BACK,
Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, Gamepad.BTN_GUIDE, Gamepad.BTN_TOUCHPAD,
)) {
assertFalse("$other toggled the mic", fires(0, other))
assertFalse("$other toggled the mic under a held mute", fires(Gamepad.BTN_MISC1, other))
}
}
/** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */
@Test
fun `chord masks are wire button bits`() {
@@ -64,12 +64,44 @@ class PadButtonsTest {
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
}
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
/**
* The touchpad click and the mute button reach the wire, on the two bits that exist for them.
* Android has no keycode for either, so [Gamepad.PadButtons.GENERIC_SONY] borrows BUTTON_15
* and BUTTON_16 to carry them into [Gamepad.buttonBit] — the keycode is an implementation
* detail of that hop, the BIT is the contract, so both halves are pinned here.
*/
@Test
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
fun `a DualSense's touchpad and mute reach their wire buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_15, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_BUTTON_16, sony(0x13e))
assertEquals(Gamepad.BTN_TOUCHPAD, Gamepad.buttonBit(sony(0x13d)))
assertEquals(Gamepad.BTN_MISC1, Gamepad.buttonBit(sony(0x13e)))
}
/**
* The regression the touchpad/mute mapping is one hoist away from causing, and the reason it
* lives inside GENERIC_SONY rather than anywhere above `padMap(dev)`.
*
* `0x13d`/`0x13e` are `BTN_THUMBL`/`BTN_THUMBR` — L3 and R3 — in the standard Linux/AOSP
* mapping, which is what [Gamepad.genericKeyCode] says they are. They mean touchpad click and
* mute ONLY inside the straight-through enumeration a driverless Sony pad uses. Read as
* touchpad and mute anywhere else, every Xbox pad, Switch Pro, 8BitDo, Steam Deck and
* `hid-playstation` DualSense loses both stick clicks — and R3 starts toggling the microphone.
*/
@Test
fun `every other pad keeps L3 and R3 on those scancodes`() {
for (p in listOf(
Gamepad.PadButtons.NATIVE,
Gamepad.PadButtons.GENERIC_XBOX,
Gamepad.PadButtons.SONY_MODERN,
)) {
val l3 = p.correct(0x13d, Gamepad.genericKeyCode(0x13d))
val r3 = p.correct(0x13e, Gamepad.genericKeyCode(0x13e))
assertEquals("$p L3", KeyEvent.KEYCODE_BUTTON_THUMBL, l3)
assertEquals("$p R3", KeyEvent.KEYCODE_BUTTON_THUMBR, r3)
assertEquals("$p L3 bit", Gamepad.BTN_LS_CLICK, Gamepad.buttonBit(l3))
assertEquals("$p R3 bit", Gamepad.BTN_RS_CLICK, Gamepad.buttonBit(r3))
}
}
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
@@ -116,6 +148,30 @@ class PadButtonsTest {
)
}
/**
* The guard's NEGATIVE path — the half that decides anything.
*
* The cases above all deliver the keycode `Generic.kl` would have produced, so the guard is
* transparent in every one of them and the assertions would hold with it deleted. These are
* the ones that fail without it: a device-specific key layout answering something the table
* disagrees with, on a scancode the table has an opinion about. The layout wins — it knows
* this controller, and the table is only ever a guess about a pad nothing knew.
*/
@Test
fun `a device layout outranks the table on a scancode the table would have rewritten`() {
// `Generic.kl` calls 0x134 BUTTON_Y, and GENERIC_SONY/GENERIC_XBOX both rewrite that
// scancode to BUTTON_L1. A layout that says BUTTON_X must survive both.
for (p in listOf(Gamepad.PadButtons.GENERIC_SONY, Gamepad.PadButtons.GENERIC_XBOX)) {
assertEquals("$p", KeyEvent.KEYCODE_BUTTON_X, p.correct(0x134, KeyEvent.KEYCODE_BUTTON_X))
}
// And the two rows added for the touchpad and mute are no different: a pad whose layout
// resolved 0x13d itself keeps that answer rather than the borrowed BUTTON_15.
assertEquals(
KeyEvent.KEYCODE_BUTTON_1,
Gamepad.PadButtons.GENERIC_SONY.correct(0x13d, KeyEvent.KEYCODE_BUTTON_1),
)
}
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
@Test
fun `correction is idempotent`() {
+7 -3
View File
@@ -44,9 +44,13 @@ from the [stats overlay](/docs/stats), so it shows even with stats off.
The mute lasts for that stream only — the next session starts unmuted; nothing is written to your
settings. With **Stream microphone** off in [client settings](/docs/client-settings#audio) the
shortcut does nothing and no badge appears. **Linux and Windows** clients only (a Steam Deck stream
is the Linux client, so an attached keyboard gets the chord); on Apple and Android turn **Stream
microphone** off in settings instead.
shortcut does nothing and no badge appears.
The **keyboard** chord is **Linux and Windows** only (a Steam Deck stream is the Linux client, so an
attached keyboard gets it). On **Android** a controller can reach the same toggle: **Select + Y**,
and on a DualSense the pad's own **Mute** button does it too — one toggle per press, and the badge
is the same. On **Apple** clients there is no shortcut; turn **Stream microphone** off in settings
instead.
Alt-Tabbing away releases input on its own and takes it back when you return. A release you asked
for with the chord stays released until you opt back in. Either way, keys and buttons you were