A DualSense's buttons all reach the stream, and its Mute button works #380
@@ -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`() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user