From 4817394db1e30deb8bef5198e47f91ecf08ce0d2 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Wed, 22 Jul 2026 11:36:37 -0400 Subject: [PATCH] feat: classify USB-direct controllers by interface descriptor, not a VID:PID allowlist Direct mode recognized a controller only if its exact VID:PID was in the native table, so an XInput pad under an unlisted PID -- e.g. an 8BitDo Ultimate 2.4g dongle in X-input mode -- fell through to the generic-HID guess and failed to claim, even though the OS xpad driver handles it fine. The Kotlin side also claimed the first HID/vendor interface with an interrupt-IN, which on a composite Xbox 360 controller can be the audio interface. Classify by the USB interface descriptor instead: - New pure usbparsers::classifyDevice(vid, pid, class, subclass, protocol): a known-table entry still wins (preserving every listed device's parser/init/quirks and the Sony/Switch vendor report formats); otherwise 0xFF/0x5D/0x01 -> XInput, 0xFF/0x47/0xD0 -> Xbox One GIP (power-on), class 0x03 -> HID. Host-tested. - Plumb the interface triple through attachUsbDevice (Kotlin -> JNI -> usbhost::attachDevice). - Rank interface selection so the gamepad interface is chosen over the Xbox 360 audio/expansion (0x5D/0x02, 0x5D/0x03) and security (0xFD) interfaces, which are never claimed. Any standards-compliant wired XInput / GIP / HID controller now works in Direct mode without a table entry; the 8BitDo dongle is a consequence, not a special case, and its rumble + analog triggers come with the XInput path. Auto-claim is unchanged -- descriptor-classified devices are reached only through an explicit Direct pick, never auto-claimed. Sourced from the Linux xpad driver and [MS-GIPUSB]. Scoped out (needs the hardware to verify, not guessed): the Microsoft Xbox 360 wireless-receiver (0x5D/0x81) presence/unwrap state machine, and deeper HID multi-report muxing. Existing table entries for those keep working unchanged. --- app/src/main/cpp/satellite_jni.cpp | 5 +- app/src/main/cpp/usb_host.cpp | 20 ++--- app/src/main/cpp/usb_host.h | 4 +- app/src/main/cpp/usb_parsers.cpp | 21 +++++ app/src/main/cpp/usb_parsers.h | 9 ++ .../dish/core/jni/PhysicalInputNative.kt | 6 ++ .../dish/core/jni/SatelliteNative.kt | 4 + .../dish/source/usb/UsbGamepadManager.kt | 78 +++++++++++------ app/src/test/cpp/usb_parsers_test.cpp | 37 ++++++++ .../dish/source/usb/UsbGamepadManagerTest.kt | 87 ++++++++++++++++++- 10 files changed, 227 insertions(+), 44 deletions(-) diff --git a/app/src/main/cpp/satellite_jni.cpp b/app/src/main/cpp/satellite_jni.cpp index ecca500f..c404a7d3 100644 --- a/app/src/main/cpp/satellite_jni.cpp +++ b/app/src/main/cpp/satellite_jni.cpp @@ -1076,7 +1076,7 @@ Java_com_tinkernorth_dish_hotpath_input_RumbleBridge_nativeInstall(JNIEnv* env, JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_attachUsbDevice( JNIEnv*, jobject, jint fd, jint vid, jint pid, jint interfaceNumber, jint epIn, - jint epInMaxPacket, jint epOut) { + jint epInMaxPacket, jint epOut, jint ifClass, jint ifSubclass, jint ifProtocol) { int dupFd = dup(fd); if (dupFd < 0) { LOGE("attachUsbDevice: dup(%d) failed: %s", fd, strerror(errno)); @@ -1084,7 +1084,8 @@ JNIEXPORT jint JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_attach } usbhost::AttachResult r = usbhost::attachDevice( dupFd, (uint16_t)(vid & 0xFFFF), (uint16_t)(pid & 0xFFFF), interfaceNumber, - (uint8_t)(epIn & 0xFF), (uint16_t)(epInMaxPacket & 0xFFFF), (uint8_t)(epOut & 0xFF)); + (uint8_t)(epIn & 0xFF), (uint16_t)(epInMaxPacket & 0xFFFF), (uint8_t)(epOut & 0xFF), + (uint8_t)(ifClass & 0xFF), (uint8_t)(ifSubclass & 0xFF), (uint8_t)(ifProtocol & 0xFF)); return r.ok ? (jint)r.syntheticDeviceId : 0; } diff --git a/app/src/main/cpp/usb_host.cpp b/app/src/main/cpp/usb_host.cpp index 516e87ae..950e8b9e 100644 --- a/app/src/main/cpp/usb_host.cpp +++ b/app/src/main/cpp/usb_host.cpp @@ -339,21 +339,15 @@ void fetchPsCalibration(int fd, int interfaceNumber, uint8_t reportId, } // namespace AttachResult attachDevice(int fd, uint16_t vid, uint16_t pid, int interfaceNumber, uint8_t epIn, - uint16_t epInMaxPacket, uint8_t epOut) { + uint16_t epInMaxPacket, uint8_t epOut, uint8_t ifClass, + uint8_t ifSubclass, uint8_t ifProtocol) { AttachResult out; - const usbparsers::KnownDevice* known = usbparsers::lookupKnown(vid, pid); - std::string modelName; - usbparsers::Parser parser = usbparsers::Parser::NONE; - usbparsers::InitKind init = usbparsers::InitKind::NONE; - if (known) { - modelName = known->name; - parser = known->parser; - init = known->init; - } else { - modelName = "USB controller"; - parser = usbparsers::Parser::GENERIC_HID_GAMEPAD; - } + usbparsers::Classification classification = + usbparsers::classifyDevice(vid, pid, ifClass, ifSubclass, ifProtocol); + std::string modelName = classification.name != nullptr ? classification.name : "USB controller"; + usbparsers::Parser parser = classification.parser; + usbparsers::InitKind init = classification.init; // We expect Kotlin to have already called UsbDeviceConnection.claimInterface(force=true); // CLAIMINTERFACE here is idempotent (returns EBUSY if already held by our process, which is diff --git a/app/src/main/cpp/usb_host.h b/app/src/main/cpp/usb_host.h index 48f58003..ef43ad0d 100644 --- a/app/src/main/cpp/usb_host.h +++ b/app/src/main/cpp/usb_host.h @@ -13,7 +13,9 @@ struct AttachResult { }; AttachResult attachDevice(int fd, uint16_t vid, uint16_t pid, int interfaceNumber, - uint8_t endpointIn, uint16_t endpointInMaxPacket, uint8_t endpointOut); + uint8_t endpointIn, uint16_t endpointInMaxPacket, uint8_t endpointOut, + uint8_t interfaceClass, uint8_t interfaceSubclass, + uint8_t interfaceProtocol); void detachDevice(int32_t syntheticDeviceId); diff --git a/app/src/main/cpp/usb_parsers.cpp b/app/src/main/cpp/usb_parsers.cpp index 07c833e2..71ae74f2 100644 --- a/app/src/main/cpp/usb_parsers.cpp +++ b/app/src/main/cpp/usb_parsers.cpp @@ -396,6 +396,27 @@ bool isVerifiedFastLane(uint16_t vid, uint16_t pid) { return k != nullptr && k->parser != Parser::NONE; } +constexpr uint8_t kIfClassVendor = 0xFF; +constexpr uint8_t kXInputSubclass = 0x5D; +constexpr uint8_t kXInputProtocol = 0x01; +constexpr uint8_t kGipSubclass = 0x47; +constexpr uint8_t kGipProtocol = 0xD0; + +Classification classifyDevice(uint16_t vid, uint16_t pid, uint8_t ifClass, uint8_t ifSubclass, + uint8_t ifProtocol) { + const KnownDevice* known = lookupKnown(vid, pid); + if (known != nullptr) { return {known->parser, known->init, known->name}; } + // Wired XInput streams unsolicited; GIP needs the power-on packet first. + if (ifClass == kIfClassVendor && ifSubclass == kXInputSubclass && + ifProtocol == kXInputProtocol) { + return {Parser::XINPUT_360, InitKind::NONE, nullptr}; + } + if (ifClass == kIfClassVendor && ifSubclass == kGipSubclass && ifProtocol == kGipProtocol) { + return {Parser::XBOX_ONE_GIP, InitKind::XBOX_ONE_POWERON, nullptr}; + } + return {Parser::GENERIC_HID_GAMEPAD, InitKind::NONE, nullptr}; +} + const char* parserName(Parser p) { switch (p) { case Parser::XINPUT_360: diff --git a/app/src/main/cpp/usb_parsers.h b/app/src/main/cpp/usb_parsers.h index afe64e64..51b161eb 100644 --- a/app/src/main/cpp/usb_parsers.h +++ b/app/src/main/cpp/usb_parsers.h @@ -40,6 +40,12 @@ struct KnownDevice { InitKind init; }; +struct Classification { + Parser parser = Parser::NONE; + InitKind init = InitKind::NONE; + const char* name = nullptr; +}; + // Per-device, expand-only auto-range for sticks that report raw ADC values. We don't read the // controller's factory calibration from SPI flash, and the usable deflection varies per unit and // per direction, so each axis tracks the largest deflection seen on each side of center @@ -80,6 +86,9 @@ struct ParserState { const KnownDevice* lookupKnown(uint16_t vid, uint16_t pid); +Classification classifyDevice(uint16_t vid, uint16_t pid, uint8_t ifClass, uint8_t ifSubclass, + uint8_t ifProtocol); + bool isVerifiedFastLane(uint16_t vid, uint16_t pid); const char* parserName(Parser p); diff --git a/app/src/main/java/com/tinkernorth/dish/core/jni/PhysicalInputNative.kt b/app/src/main/java/com/tinkernorth/dish/core/jni/PhysicalInputNative.kt index 57f3c7f5..69df9e86 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/jni/PhysicalInputNative.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/jni/PhysicalInputNative.kt @@ -65,6 +65,9 @@ class PhysicalInputNative endpointIn: Int, endpointInMaxPacket: Int, endpointOut: Int, + interfaceClass: Int, + interfaceSubclass: Int, + interfaceProtocol: Int, ): Int = SatelliteNative.attachUsbDevice( fd = fd, @@ -74,6 +77,9 @@ class PhysicalInputNative endpointIn = endpointIn, endpointInMaxPacket = endpointInMaxPacket, endpointOut = endpointOut, + interfaceClass = interfaceClass, + interfaceSubclass = interfaceSubclass, + interfaceProtocol = interfaceProtocol, ) fun detachUsbDevice(syntheticDeviceId: Int) { diff --git a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt index 69e38569..201b96b7 100644 --- a/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt +++ b/app/src/main/java/com/tinkernorth/dish/core/jni/SatelliteNative.kt @@ -149,6 +149,7 @@ object SatelliteNative { keyCode: Int, ): Boolean + @Suppress("LongParameterList") external fun attachUsbDevice( fd: Int, vendorId: Int, @@ -157,6 +158,9 @@ object SatelliteNative { endpointIn: Int, endpointInMaxPacket: Int, endpointOut: Int, + interfaceClass: Int, + interfaceSubclass: Int, + interfaceProtocol: Int, ): Int external fun detachUsbDevice(syntheticDeviceId: Int) diff --git a/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt b/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt index 0a800e42..43f9f1e9 100644 --- a/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt +++ b/app/src/main/java/com/tinkernorth/dish/source/usb/UsbGamepadManager.kt @@ -339,6 +339,9 @@ class UsbGamepadManager endpointIn = epIn.address, endpointInMaxPacket = epIn.maxPacketSize, endpointOut = epOut?.address ?: 0, + interfaceClass = intf.interfaceClass, + interfaceSubclass = intf.interfaceSubclass, + interfaceProtocol = intf.interfaceProtocol, ) if (synthetic == 0) { runCatching { @@ -488,39 +491,48 @@ class UsbGamepadManager return product?.takeIf { it.isNotBlank() } ?: device.deviceName } - private fun isGamepadShaped(device: UsbDevice): Boolean { - for (i in 0 until device.interfaceCount) { - val intf = device.getInterface(i) - val isHid = intf.interfaceClass == UsbConstants.USB_CLASS_HID - val isVendor = intf.interfaceClass == UsbConstants.USB_CLASS_VENDOR_SPEC - if (!isHid && !isVendor) continue - for (e in 0 until intf.endpointCount) { - val ep = intf.getEndpoint(e) - if (ep.type == UsbConstants.USB_ENDPOINT_XFER_INT && ep.direction == UsbConstants.USB_DIR_IN) { - return true - } - } + private fun isGamepadShaped(device: UsbDevice): Boolean = findInterruptInPair(device) != null + + private fun gameInterfaceRank(intf: UsbInterface): Int { + val cls = intf.interfaceClass + if (cls == UsbConstants.USB_CLASS_HID) return RANK_HID + if (cls != UsbConstants.USB_CLASS_VENDOR_SPEC) return RANK_NONE + val sub = intf.interfaceSubclass + val proto = intf.interfaceProtocol + return when { + sub == XINPUT_SUBCLASS && proto == XINPUT_PROTOCOL -> RANK_XINPUT + sub == GIP_SUBCLASS && proto == GIP_PROTOCOL -> RANK_GIP + sub == XINPUT_SUBCLASS && (proto == XINPUT_AUX_PROTOCOL || proto == XINPUT_AUDIO_PROTOCOL) -> RANK_NONE + sub == XINPUT_SECURITY_SUBCLASS -> RANK_NONE + else -> RANK_VENDOR_FALLBACK } - return false } + private fun interruptInOutOf(intf: UsbInterface): Pair? { + var epIn: UsbEndpoint? = null + var epOut: UsbEndpoint? = null + for (e in 0 until intf.endpointCount) { + val ep = intf.getEndpoint(e) + if (ep.type != UsbConstants.USB_ENDPOINT_XFER_INT) continue + if (ep.direction == UsbConstants.USB_DIR_IN && epIn == null) epIn = ep + if (ep.direction == UsbConstants.USB_DIR_OUT && epOut == null) epOut = ep + } + return epIn?.let { it to epOut } + } + + // Ranked, not first-match: a composite 360 pad's audio/security interfaces also carry an interrupt-IN. private fun findInterruptInPair(device: UsbDevice): Triple? { + var best: Triple? = null + var bestRank = RANK_NONE for (i in 0 until device.interfaceCount) { val intf = device.getInterface(i) - val isHid = intf.interfaceClass == UsbConstants.USB_CLASS_HID - val isVendor = intf.interfaceClass == UsbConstants.USB_CLASS_VENDOR_SPEC - if (!isHid && !isVendor) continue - var epIn: UsbEndpoint? = null - var epOut: UsbEndpoint? = null - for (e in 0 until intf.endpointCount) { - val ep = intf.getEndpoint(e) - if (ep.type != UsbConstants.USB_ENDPOINT_XFER_INT) continue - if (ep.direction == UsbConstants.USB_DIR_IN && epIn == null) epIn = ep - if (ep.direction == UsbConstants.USB_DIR_OUT && epOut == null) epOut = ep - } - if (epIn != null) return Triple(intf, epIn, epOut) + val rank = gameInterfaceRank(intf) + if (rank <= bestRank) continue + val pair = interruptInOutOf(intf) ?: continue + best = Triple(intf, pair.first, pair.second) + bestRank = rank } - return null + return best } private fun deviceFromIntent(intent: Intent): UsbDevice? = @@ -537,6 +549,20 @@ class UsbGamepadManager const val TAG = "UsbGamepadManager" const val ACTION_USB_PERMISSION = "com.tinkernorth.dish.USB_PERMISSION" const val TRANSITION_TIMEOUT_MS = 4000L + + const val XINPUT_SUBCLASS = 0x5D + const val XINPUT_PROTOCOL = 0x01 + const val XINPUT_AUX_PROTOCOL = 0x02 + const val XINPUT_AUDIO_PROTOCOL = 0x03 + const val XINPUT_SECURITY_SUBCLASS = 0xFD + const val GIP_SUBCLASS = 0x47 + const val GIP_PROTOCOL = 0xD0 + + const val RANK_NONE = 0 + const val RANK_VENDOR_FALLBACK = 1 + const val RANK_HID = 2 + const val RANK_GIP = 3 + const val RANK_XINPUT = 4 } } diff --git a/app/src/test/cpp/usb_parsers_test.cpp b/app/src/test/cpp/usb_parsers_test.cpp index b8c51ca4..431c688f 100644 --- a/app/src/test/cpp/usb_parsers_test.cpp +++ b/app/src/test/cpp/usb_parsers_test.cpp @@ -655,3 +655,40 @@ TEST(TouchpadCapability, PlayStationParsersHaveTouchpads) { EXPECT_FALSE(usbparsers::parserHasTouchpad(Parser::SWITCH_PRO_USB)); EXPECT_FALSE(usbparsers::parserHasTouchpad(Parser::GENERIC_HID_GAMEPAD)); } + +TEST(ClassifyDevice, KnownDeviceWinsOverDescriptorTriple) { + auto c = usbparsers::classifyDevice(0x045E, 0x028E, 0x00, 0x00, 0x00); + EXPECT_EQ(c.parser, Parser::XINPUT_360); + EXPECT_NE(c.name, nullptr); +} + +TEST(ClassifyDevice, WiredXInputInterfaceClassifiesWithoutTableEntry) { + auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0xFF, 0x5D, 0x01); + EXPECT_EQ(c.parser, Parser::XINPUT_360); + EXPECT_EQ(c.init, InitKind::NONE); + EXPECT_EQ(c.name, nullptr); +} + +TEST(ClassifyDevice, EightBitDoDongleTripleClassifiesAsXInput) { + // 0xFFFF stands in for the unlisted 2.4g dongle PID: only the descriptor can classify it. + auto c = usbparsers::classifyDevice(0x2DC8, 0xFFFF, 0xFF, 0x5D, 0x01); + EXPECT_EQ(c.parser, Parser::XINPUT_360); + EXPECT_EQ(c.init, InitKind::NONE); +} + +TEST(ClassifyDevice, GipInterfaceClassifiesAsXboxOneWithPowerOn) { + auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0xFF, 0x47, 0xD0); + EXPECT_EQ(c.parser, Parser::XBOX_ONE_GIP); + EXPECT_EQ(c.init, InitKind::XBOX_ONE_POWERON); +} + +TEST(ClassifyDevice, HidInterfaceClassifiesAsGenericHid) { + auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0x03, 0x00, 0x00); + EXPECT_EQ(c.parser, Parser::GENERIC_HID_GAMEPAD); + EXPECT_EQ(c.init, InitKind::NONE); +} + +TEST(ClassifyDevice, UnknownVendorInterfaceFallsBackToGeneric) { + auto c = usbparsers::classifyDevice(0x1234, 0x5678, 0xFF, 0x99, 0x99); + EXPECT_EQ(c.parser, Parser::GENERIC_HID_GAMEPAD); +} diff --git a/app/src/test/java/com/tinkernorth/dish/source/usb/UsbGamepadManagerTest.kt b/app/src/test/java/com/tinkernorth/dish/source/usb/UsbGamepadManagerTest.kt index 06f50640..8cd7320e 100644 --- a/app/src/test/java/com/tinkernorth/dish/source/usb/UsbGamepadManagerTest.kt +++ b/app/src/test/java/com/tinkernorth/dish/source/usb/UsbGamepadManagerTest.kt @@ -74,6 +74,8 @@ class UsbGamepadManagerTest { val intf = mockk { every { interfaceClass } returns UsbConstants.USB_CLASS_HID + every { interfaceSubclass } returns 0 + every { interfaceProtocol } returns 0 every { id } returns 0 every { endpointCount } returns 1 every { getEndpoint(0) } returns epIn @@ -144,7 +146,9 @@ class UsbGamepadManagerTest { val conn = mockConn() every { usbManager.openDevice(device) } returns conn every { conn.claimInterface(any(), true) } returns true - every { native.attachUsbDevice(any(), any(), any(), any(), any(), any(), any()) } returns 0 + every { + native.attachUsbDevice(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } returns 0 val m = buildManager() m.tryDirectMode(vid, pid) // The interface was stolen, so we wait for re-enumeration rather than declaring Standard usable. @@ -157,7 +161,9 @@ class UsbGamepadManagerTest { val conn = mockConn() every { usbManager.openDevice(device) } returns conn every { conn.claimInterface(any(), true) } returns true - every { native.attachUsbDevice(any(), any(), any(), any(), any(), any(), any()) } returns -1000 + every { + native.attachUsbDevice(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } returns -1000 val m = buildManager() m.tryDirectMode(vid, pid) assertEquals(UsbPhase.Direct, m.controllers.value[key]?.phase) @@ -166,6 +172,83 @@ class UsbGamepadManagerTest { verify { registry.addUsbSynthetic(-1000, "Pad", any(), any(), vid, pid) } } + private fun vendorInterface( + ifaceId: Int, + subclass: Int, + protocol: Int, + epInAddress: Int, + ): UsbInterface { + val epIn = + mockk { + every { type } returns UsbConstants.USB_ENDPOINT_XFER_INT + every { direction } returns UsbConstants.USB_DIR_IN + every { address } returns epInAddress + every { maxPacketSize } returns 32 + every { interval } returns 4 + } + return mockk { + every { interfaceClass } returns UsbConstants.USB_CLASS_VENDOR_SPEC + every { interfaceSubclass } returns subclass + every { interfaceProtocol } returns protocol + every { id } returns ifaceId + every { endpointCount } returns 1 + every { getEndpoint(0) } returns epIn + } + } + + // Audio interface first (id 0) so a first-match bug would claim it over the gamepad (id 1). + private fun compositeXbox360(): UsbDevice { + val audio = vendorInterface(ifaceId = 0, subclass = 0x5D, protocol = 0x03, epInAddress = 0x81) + val game = vendorInterface(ifaceId = 1, subclass = 0x5D, protocol = 0x01, epInAddress = 0x82) + return mockk { + every { vendorId } returns vid + every { productId } returns pid + every { deviceName } returns "xbox360" + every { interfaceCount } returns 2 + every { getInterface(0) } returns audio + every { getInterface(1) } returns game + } + } + + private fun buildManagerForDevice(dev: UsbDevice): UsbGamepadManager { + val ctx = mockk(relaxed = true) + every { ctx.getSystemService(Context.USB_SERVICE) } returns usbManager + every { usbManager.deviceList } returns hashMapOf("d" to dev) + every { usbManager.hasPermission(dev) } returns true + every { registry.devices } returns MutableStateFlow(emptyMap()) + every { native.lookupKnownModelName(vid, pid) } returns "Pad" + every { pathPrefs.choiceFor(vid, pid) } returns null + val scope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher()) + return UsbGamepadManager(ctx, registry, Provider { hub }, notifications, scope, native, pathPrefs) + } + + @Test + fun `claims the gamepad interface of a composite controller, not the audio one`() { + val dev = compositeXbox360() + val conn = mockConn() + every { usbManager.openDevice(dev) } returns conn + every { conn.claimInterface(any(), true) } returns true + every { + native.attachUsbDevice(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } returns -1000 + val m = buildManagerForDevice(dev) + m.tryDirectMode(vid, pid) + verify { + native.attachUsbDevice( + fd = any(), + vendorId = vid, + productId = pid, + interfaceNumber = 1, + endpointIn = 0x82, + endpointInMaxPacket = any(), + endpointOut = any(), + interfaceClass = UsbConstants.USB_CLASS_VENDOR_SPEC, + interfaceSubclass = 0x5D, + interfaceProtocol = 0x01, + ) + } + } + // A real registry so directFailureFor genuinely reflects what markDirectFailed recorded, instead of // relying on stubbing the read back (the guard is integration, not a single mocked return). private fun realRegistry(): PhysicalGamepadRegistry {