diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 3997f66..ab6790b 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -21,7 +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.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 @@ -105,7 +106,8 @@ class AppContainer(context: Context) { appContext.packageManager, privilegedAccess::acquire, scope = scope, - gamepadPorts = { virtualGamepadPorts(appContext) }, + gamepadPorts = { edenGamepadPorts(appContext) }, + gamepadControllerNumbers = { dolphinGamepadIds(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/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 9ffca97..0000000 --- a/app/src/main/java/com/joegec/joycon2android/emulator/VirtualGamepadPorts.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.joegec.joycon2android.emulator - -import android.content.Context -import android.hardware.input.InputManager - -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 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 } - } - return ports -} 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-`")) }