From 36d19ad6a54c9f8e010c3e64e406a0387a9b1603 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Wed, 2 Sep 2026 19:11:04 +0100 Subject: [PATCH 1/2] Fix Dolphin GameCube device id and dual-Joy-Con stick order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two faults in the GameCube auto-setup, both found on an AYN Thor: Dolphin's Android device qualifier is Source/ID/Name, and the ID comes from InputDevice.getControllerNumber() — Android's own gamepad enumeration counter (ControllerInterface::AddDevice prefers GetPreferredId(), which the Android backend fills from getControllerNumber, falling back to a duplicate-name index only for non-gamepads). We wrote the pad's rank among our own virtual gamepads instead, which only matches when nothing else is connected: on a handheld with a built-in controller that number is already taken, so the section bound to the wrong device or to none. On the Thor the built-in pad holds number 1 and ours reports 3. It is now read from the live input-device list, alongside the port lookup Eden already used, and a player whose pad isn't enumerated is skipped rather than given a guessed id. The dual-Joy-Con stick default also had the GameCube sticks crossed — main stick driven by the right Joy-Con and C-stick by the left. The Switch Pro and Nunchuk defaults already route left to left. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/joegec/joycon2android/AppContainer.kt | 2 + .../joycon2android/emulator/EmulatorSetup.kt | 2 + .../emulator/VirtualGamepadPorts.kt | 38 ++++++++++++++----- .../DefaultControllerMappings.kt | 4 +- .../gamepad/emulator/DolphinGcpadConfig.kt | 32 ++++++++++------ .../emulator/DolphinGcpadConfigTest.kt | 37 ++++++++++++------ 6 files changed, 82 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 3997f66..6ffeddb 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -21,6 +21,7 @@ import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerAssignmentManager import com.joegec.joycon2android.assignment.PlayerStateResolver import com.joegec.joycon2android.emulator.EmulatorSetup +import com.joegec.joycon2android.emulator.virtualGamepadControllerNumbers import com.joegec.joycon2android.emulator.virtualGamepadPorts import com.joegec.joycon2android.session.AssignControllerUseCase import com.joegec.joycon2android.session.ObserveSessionUseCase @@ -106,6 +107,7 @@ class AppContainer(context: Context) { privilegedAccess::acquire, scope = scope, gamepadPorts = { virtualGamepadPorts(appContext) }, + gamepadControllerNumbers = { virtualGamepadControllerNumbers(appContext) }, getControllerMapping = getControllerMapping, ) diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt index 03b1e7e..ec72701 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/EmulatorSetup.kt @@ -33,6 +33,7 @@ class EmulatorSetup( private val acquireShell: (onResult: (PrivilegedShell?) -> Unit) -> Unit, private val scope: CoroutineScope, private val gamepadPorts: () -> Map, + private val gamepadControllerNumbers: () -> Map, private val getControllerMapping: GetEffectiveControllerMappingUseCase, ) { @@ -101,6 +102,7 @@ class EmulatorSetup( val mappings = DolphinGcpadConfig.merge( shell.readText(DolphinGcpadConfig.path), players, + gamepadControllerNumbers(), mappingLookup(Console.GAMECUBE), ) val mappingsOk = shell.writeText(DolphinGcpadConfig.path, mappings) diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt index 9ffca97..5fd6398 100644 --- a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt +++ b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.emulator import android.content.Context import android.hardware.input.InputManager +import android.view.InputDevice private const val PREFIX = "Joy-Con Virtual Gamepad " @@ -12,16 +13,35 @@ private const val PREFIX = "Joy-Con Virtual Gamepad " * list, find our pads by name, and rank them by device id to reproduce that order. */ fun virtualGamepadPorts(context: Context): Map { - val manager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager ?: return emptyMap() - val deviceIds = manager.inputDeviceIds ?: return emptyMap() - val pads = deviceIds.toList() - .mapNotNull { id -> manager.getInputDevice(id) } - .filter { device -> device.name.startsWith(PREFIX) } - .sortedBy { device -> device.id } - val ports = HashMap() - pads.forEachIndexed { rank, device -> - device.name.removePrefix(PREFIX).trim().toIntOrNull()?.let { player -> ports[player] = rank } + virtualPads(context).forEachIndexed { rank, device -> + playerOf(device)?.let { player -> ports[player] = rank } } return ports } + +/** + * Maps each assigned player number to the id Dolphin uses in its `Android//` device + * qualifier. Dolphin takes that id from `InputDevice.getControllerNumber()` — Android's own gamepad + * enumeration counter — falling back to a duplicate-name index only for non-gamepads, so it cannot + * be derived from the player number: any built-in controller (Odin, Thor) already holds number 1. + */ +fun virtualGamepadControllerNumbers(context: Context): Map { + val numbers = HashMap() + virtualPads(context).forEach { device -> + playerOf(device)?.let { player -> numbers[player] = device.controllerNumber } + } + return numbers +} + +private fun virtualPads(context: Context): List { + val manager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager ?: return emptyList() + val deviceIds = manager.inputDeviceIds ?: return emptyList() + return deviceIds.toList() + .mapNotNull { id -> manager.getInputDevice(id) } + .filter { device -> device.name.startsWith(PREFIX) } + .sortedBy { device -> device.id } +} + +private fun playerOf(device: InputDevice): Int? = + device.name.removePrefix(PREFIX).trim().toIntOrNull() diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt index adeeb87..1398761 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/DefaultControllerMappings.kt @@ -78,8 +78,8 @@ object DefaultControllerMappings { fun gameCubeSticks(side: JoyconSide): Map = when (side) { JoyconSide.DUAL -> mapOf( - GameCubeStick.MainStick to RIGHT_STICK, - GameCubeStick.CStick to LEFT_STICK, + GameCubeStick.MainStick to LEFT_STICK, + GameCubeStick.CStick to RIGHT_STICK, ) else -> emptyMap() } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt index 1284451..938acf1 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfig.kt @@ -16,8 +16,8 @@ import com.joegec.joycon2android.model.SidewaysMapper * Generates Dolphin's GCPadNew.ini mappings for the Virtual Gamepad, one `[GCPadN]` section per * assigned player, driven by the user's customizable Joy-Con -> GameCube mapping. Each player's * UHID pad shows up to Dolphin as a distinct Android input device - * (`Android//Joy-Con Virtual Gamepad `); the relay remaps buttons/sticks by orientation - * (see [SidewaysMapper]), so which physical button reaches a given Android control differs + * (`Android//Joy-Con Virtual Gamepad `); the relay remaps buttons/sticks + * by orientation (see [SidewaysMapper]), so which physical button reaches a given Android control differs * between a sideways single Joy-Con and a pair. [ANDROID_NAMES]/[HAT_NAMES] are the fixed, * body-independent Dolphin names for each Android keycode/hat direction our virtual pad emits * (captured from a real mapping); [specFor] applies the same physical -> virtual remap @@ -78,8 +78,12 @@ object DolphinGcpadConfig { "Main Stick/Right = `Axis 0+`", ) - fun merge(existing: String?, players: List, mappingFor: (JoyconSide) -> Map): String = - IniEditor.mergeSections(existing, sections(players, mappingFor)) + fun merge( + existing: String?, + players: List, + controllerNumbers: Map, + mappingFor: (JoyconSide) -> Map, + ): String = IniEditor.mergeSections(existing, sections(players, controllerNumbers, mappingFor)) /** Sets each configured player's GameCube port to a Standard Controller in Dolphin.ini. */ fun mergeCore(existing: String?, players: List): String { @@ -89,16 +93,22 @@ object DolphinGcpadConfig { return IniEditor.setKeys(existing, "[Core]", siDevices) } - // Dolphin's device id is the pad's enumeration rank among active virtual gamepads (1-based), - // which is NOT the player number when a lower slot is empty (e.g. P4 with no P3 → Android/3/…). - // The device *name* still carries the player number. Sections stay on the player's own GC port. - private fun sections(players: List, mappingFor: (JoyconSide) -> Map): Map = + // Dolphin's device id comes from Android's own gamepad enumeration counter + // (InputDevice.getControllerNumber()), so it has to be read from the live device list rather + // than derived — any built-in controller already holds number 1. A player whose pad isn't + // enumerated yet is skipped: a guessed id binds the section to the wrong device, or to none. + private fun sections( + players: List, + controllerNumbers: Map, + mappingFor: (JoyconSide) -> Map, + ): Map = players.filter { it.hasController } .sortedBy { it.player.index } - .mapIndexedNotNull { rank, player -> + .mapNotNull { player -> val index = player.player.index - if (index !in 1..4) return@mapIndexedNotNull null - bodyFor(player, index, deviceId = rank + 1, mappingFor)?.let { "[GCPad$index]" to it } + if (index !in 1..4) return@mapNotNull null + val deviceId = controllerNumbers[index] ?: return@mapNotNull null + bodyFor(player, index, deviceId, mappingFor)?.let { "[GCPad$index]" to it } }.toMap() private fun bodyFor(player: PlayerState, index: Int, deviceId: Int, mappingFor: (JoyconSide) -> Map): String? { diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt index 248aef4..4b67225 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/DolphinGcpadConfigTest.kt @@ -29,30 +29,45 @@ class DolphinGcpadConfigTest { private fun joycon(side: Side) = ConnectedJoycon(address = side.name, side = side, deviceName = "Joy-Con") - private fun merge(existing: String?, players: List) = - DolphinGcpadConfig.merge(existing, players) { side -> defaultMapping(Console.GAMECUBE, side) } + private fun merge( + existing: String?, + players: List, + controllerNumbers: Map = players.associate { it.player.index to it.player.index }, + ) = DolphinGcpadConfig.merge(existing, players, controllerNumbers) { side -> + defaultMapping(Console.GAMECUBE, side) + } @Test fun `device path uses the per-player virtual gamepad name`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P2, right = joycon(Side.RIGHT)))) assertTrue(result.contains("[GCPad2]")) - assertTrue(result.contains("Device = Android/1/Joy-Con Virtual Gamepad 2")) + assertTrue(result.contains("Device = Android/2/Joy-Con Virtual Gamepad 2")) } @Test - fun `device id is the enumeration rank, not the player number, when a slot is skipped`() { + fun `device id is the reported controller number, not the player number`() { + val player = PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)) + + // A device with a built-in controller already holds controller number 1, so our first + // pad enumerates higher — the port and name still stay on the player number. + val result = merge(null, listOf(player), controllerNumbers = mapOf(1 to 3)) + + assertTrue(result.contains("[GCPad1]")) + assertTrue(result.contains("Device = Android/3/Joy-Con Virtual Gamepad 1")) + } + + @Test + fun `a player whose pad is not enumerated is skipped rather than guessed`() { val players = listOf( PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT)), PlayerState(PlayerNumber.P2, right = joycon(Side.RIGHT)), - PlayerState(PlayerNumber.P4, right = joycon(Side.RIGHT)), ) - val result = merge(null, players) + val result = merge(null, players, controllerNumbers = mapOf(2 to 5)) - // P4 is the 3rd active pad → Android/3, but its port and name stay 4 - assertTrue(result.contains("[GCPad4]")) - assertTrue(result.contains("Device = Android/3/Joy-Con Virtual Gamepad 4")) + assertFalse(result.contains("[GCPad1]")) + assertTrue(result.contains("Device = Android/5/Joy-Con Virtual Gamepad 2")) } @Test @@ -62,8 +77,8 @@ class DolphinGcpadConfigTest { val result = merge(null, listOf(both)) assertTrue(result.contains("Buttons/A = `Button A`")) - assertTrue(result.contains("Main Stick/Up = `Axis 14-`")) // right stick - assertTrue(result.contains("C-Stick/Up = `Axis 1-`")) // left stick + assertTrue(result.contains("Main Stick/Up = `Axis 1-`")) // left stick + assertTrue(result.contains("C-Stick/Up = `Axis 14-`")) // right stick assertTrue(result.contains("D-Pad/Up = `Axis 16-`")) } From 7de68663df166074f42d2baa91fc67f8f4a02c39 Mon Sep 17 00:00:00 2001 From: Joe Barker Date: Wed, 2 Sep 2026 19:20:55 +0100 Subject: [PATCH 2/2] Read Eden's port from the device list too, not just Dolphin's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eden numbers its `port` by walking InputDevice.getDeviceIds() and counting every physical game controller it passes (InputHandler.getDevices), so a built-in pad shifts ours along — a controller number already registered is skipped but still consumes a port. We were ranking our own virtual pads among themselves, which only agrees when nothing else is connected. On the AYN Thor the built-in Odin Controller (device id 10) takes port 0 and our pad (id 15) is port 1, but we wrote port 0 — the same fault just fixed for Dolphin, and invisible on any device where our pad happens to enumerate first. Both rules now live in VirtualGamepadIdentity next to each other, each read from the emulator's own source: Dolphin wants getControllerNumber(), Eden wants that walk order, and neither is the player number. Dolphin's Wii Remote config addresses our own DSU slot, so it has nothing to look up. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/joegec/joycon2android/AppContainer.kt | 8 +- .../emulator/VirtualGamepadIdentity.kt | 109 ++++++++++++++++++ .../emulator/VirtualGamepadPorts.kt | 47 -------- 3 files changed, 113 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt delete mode 100644 app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 6ffeddb..ab6790b 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -21,8 +21,8 @@ import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerAssignmentManager import com.joegec.joycon2android.assignment.PlayerStateResolver import com.joegec.joycon2android.emulator.EmulatorSetup -import com.joegec.joycon2android.emulator.virtualGamepadControllerNumbers -import com.joegec.joycon2android.emulator.virtualGamepadPorts +import com.joegec.joycon2android.emulator.dolphinGamepadIds +import com.joegec.joycon2android.emulator.edenGamepadPorts import com.joegec.joycon2android.session.AssignControllerUseCase import com.joegec.joycon2android.session.ObserveSessionUseCase import com.joegec.joycon2android.session.SessionCoordinator @@ -106,8 +106,8 @@ class AppContainer(context: Context) { appContext.packageManager, privilegedAccess::acquire, scope = scope, - gamepadPorts = { virtualGamepadPorts(appContext) }, - gamepadControllerNumbers = { virtualGamepadControllerNumbers(appContext) }, + gamepadPorts = { edenGamepadPorts(appContext) }, + gamepadControllerNumbers = { dolphinGamepadIds(appContext) }, getControllerMapping = getControllerMapping, ) diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt new file mode 100644 index 0000000..678d464 --- /dev/null +++ b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadIdentity.kt @@ -0,0 +1,109 @@ +package com.joegec.joycon2android.emulator + +import android.content.Context +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +/* + * Resolves how each emulator identifies our virtual gamepads, by reading the live input-device + * list rather than deriving a number from the player index. + * + * Every emulator picks its own quantity, and none of them is the player number, so each rule is + * read from the emulator's own source and mirrored here: + * + * - **Dolphin** takes the id in its `Android//` qualifier from + * `InputDevice.getControllerNumber()` — Android's gamepad enumeration counter. + * `ControllerInterface::AddDevice` prefers `GetPreferredId()`, which the Android backend fills + * from `getControllerNumber()`, falling back to a duplicate-name index only for non-gamepads. + * - **Eden** (yuzu lineage) numbers `port` by walking `InputDevice.getDeviceIds()` and counting + * *every* physical game controller it passes, so any built-in pad shifts ours along. See + * `InputHandler.getDevices()`: a controller number already registered is skipped but still + * consumes a port, which edenGamepadPorts reproduces. + * + * A guessed number binds a config to the wrong device or to none, and any handheld with a built-in + * controller already occupies the low numbers — hence read, never derive. + */ + +private const val PREFIX = "Joy-Con Virtual Gamepad " + +/** Each assigned player number to the `port` Eden expects for its virtual gamepad. */ +fun edenGamepadPorts(context: Context): Map { + val ports = HashMap() + val registered = HashSet() + var port = 0 + inputDevices(context).forEach { device -> + if (!isPhysicalGameController(device)) return@forEach + if (registered.add(device.controllerNumber)) { + playerOf(device)?.let { player -> ports[player] = port } + } + port++ + } + return ports +} + +/** Each assigned player number to the id Dolphin expects in its `Android//` qualifier. */ +fun dolphinGamepadIds(context: Context): Map { + val ids = HashMap() + inputDevices(context).forEach { device -> + playerOf(device)?.let { player -> ids[player] = device.controllerNumber } + } + return ids +} + +// Eden walks the ids in the order the platform returns them, so this must not be re-sorted. +private fun inputDevices(context: Context): List { + val manager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager ?: return emptyList() + val deviceIds = manager.inputDeviceIds ?: return emptyList() + return deviceIds.toList().mapNotNull { id -> manager.getInputDevice(id) } +} + +// Mirrors Eden's InputHandler.isPhysicalGameController. +private fun isPhysicalGameController(device: InputDevice): Boolean { + if (device.isVirtual) return false + + val sources = device.sources + val hasControllerSource = sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + if (!hasControllerSource) return false + + val hasControllerButtons = device.hasKeys(*CONTROLLER_BUTTONS).any { it } + val hasControllerAxes = device.motionRanges.any { range -> range.axis in CONTROLLER_AXES } + return hasControllerButtons || hasControllerAxes +} + +private fun playerOf(device: InputDevice): Int? = + if (device.name.startsWith(PREFIX)) device.name.removePrefix(PREFIX).trim().toIntOrNull() else null + +private val CONTROLLER_BUTTONS = intArrayOf( + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1, + KeyEvent.KEYCODE_BUTTON_L2, + KeyEvent.KEYCODE_BUTTON_R2, + KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, +) + +private val CONTROLLER_AXES = intArrayOf( + MotionEvent.AXIS_X, + MotionEvent.AXIS_Y, + MotionEvent.AXIS_Z, + MotionEvent.AXIS_RX, + MotionEvent.AXIS_RY, + MotionEvent.AXIS_RZ, + MotionEvent.AXIS_HAT_X, + MotionEvent.AXIS_HAT_Y, + MotionEvent.AXIS_LTRIGGER, + MotionEvent.AXIS_RTRIGGER, +) diff --git a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt b/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt deleted file mode 100644 index 5fd6398..0000000 --- a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.joegec.joycon2android.emulator - -import android.content.Context -import android.hardware.input.InputManager -import android.view.InputDevice - -private const val PREFIX = "Joy-Con Virtual Gamepad " - -/** - * Maps each assigned player number to the device "port" an emulator sees for its virtual gamepad. - * Our pads share a guid, so emulators tell them apart by enumeration rank among same-guid devices — - * which isn't the player number (the pads enumerate in creation order). We read the live input-device - * list, find our pads by name, and rank them by device id to reproduce that order. - */ -fun virtualGamepadPorts(context: Context): Map { - val ports = HashMap() - virtualPads(context).forEachIndexed { rank, device -> - playerOf(device)?.let { player -> ports[player] = rank } - } - return ports -} - -/** - * Maps each assigned player number to the id Dolphin uses in its `Android//` device - * qualifier. Dolphin takes that id from `InputDevice.getControllerNumber()` — Android's own gamepad - * enumeration counter — falling back to a duplicate-name index only for non-gamepads, so it cannot - * be derived from the player number: any built-in controller (Odin, Thor) already holds number 1. - */ -fun virtualGamepadControllerNumbers(context: Context): Map { - val numbers = HashMap() - virtualPads(context).forEach { device -> - playerOf(device)?.let { player -> numbers[player] = device.controllerNumber } - } - return numbers -} - -private fun virtualPads(context: Context): List { - val manager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager ?: return emptyList() - val deviceIds = manager.inputDeviceIds ?: return emptyList() - return deviceIds.toList() - .mapNotNull { id -> manager.getInputDevice(id) } - .filter { device -> device.name.startsWith(PREFIX) } - .sortedBy { device -> device.id } -} - -private fun playerOf(device: InputDevice): Int? = - device.name.removePrefix(PREFIX).trim().toIntOrNull()