Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/main/java/com/joegec/joycon2android/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class EmulatorSetup(
private val acquireShell: (onResult: (PrivilegedShell?) -> Unit) -> Unit,
private val scope: CoroutineScope,
private val gamepadPorts: () -> Map<Int, Int>,
private val gamepadControllerNumbers: () -> Map<Int, Int>,
private val getControllerMapping: GetEffectiveControllerMappingUseCase,
) {

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<id>/<name>` 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<Int, Int> {
val ports = HashMap<Int, Int>()
val registered = HashSet<Int>()
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/<id>/<name>` qualifier. */
fun dolphinGamepadIds(context: Context): Map<Int, Int> {
val ids = HashMap<Int, Int>()
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<InputDevice> {
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,
)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ object DefaultControllerMappings {

fun gameCubeSticks(side: JoyconSide): Map<GameCubeStick, StickSource> = 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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>/Joy-Con Virtual Gamepad <n>`); the relay remaps buttons/sticks by orientation
* (see [SidewaysMapper]), so which physical button reaches a given Android control differs
* (`Android/<controllerNumber>/Joy-Con Virtual Gamepad <player>`); 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
Expand Down Expand Up @@ -78,8 +78,12 @@ object DolphinGcpadConfig {
"Main Stick/Right = `Axis 0+`",
)

fun merge(existing: String?, players: List<PlayerState>, mappingFor: (JoyconSide) -> Map<String, String>): String =
IniEditor.mergeSections(existing, sections(players, mappingFor))
fun merge(
existing: String?,
players: List<PlayerState>,
controllerNumbers: Map<Int, Int>,
mappingFor: (JoyconSide) -> Map<String, String>,
): 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<PlayerState>): String {
Expand All @@ -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<PlayerState>, mappingFor: (JoyconSide) -> Map<String, String>): Map<String, String> =
// 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<PlayerState>,
controllerNumbers: Map<Int, Int>,
mappingFor: (JoyconSide) -> Map<String, String>,
): Map<String, String> =
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, String>): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlayerState>) =
DolphinGcpadConfig.merge(existing, players) { side -> defaultMapping(Console.GAMECUBE, side) }
private fun merge(
existing: String?,
players: List<PlayerState>,
controllerNumbers: Map<Int, Int> = 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
Expand All @@ -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-`"))
}

Expand Down
Loading