Skip to content
Open
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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ as an ordinary gamepad.

## Features

- **Joy-Con 2 and Switch 2 Pro Controller** — connect several at once, GL/GR paddles included.
- **Joy-Con 2, Switch 2 Pro Controller and NSO GameCube controller** — connect several at once, GL/GR
paddles included.
- **Up to 8 players** — each with a sideways Joy-Con, a pair, or a Pro Controller.
- **A gamepad per player** — every app sees one standard controller per player, so multiplayer just
works.
Expand All @@ -31,7 +32,7 @@ as an ordinary gamepad.
### What you need

- Android 7.0 (API 24) or newer, with Bluetooth LE
- Joy-Con 2 or a Switch 2 Pro Controller
- Joy-Con 2, a Switch 2 Pro Controller or an NSO GameCube controller
- [Shizuku](https://shizuku.rikka.app/), for the virtual gamepad and emulator auto setup — DSU motion
works without it

Expand All @@ -54,7 +55,11 @@ as an ordinary gamepad.

1. Hold **SYNC** on the controller and tap **Scan**.
2. Assign each controller to a player — from the app, or with the Switch combos: **L** on one Joy-Con
and **R** on another for a pair, **SL + SR** for a sideways Joy-Con, **L + R** on a Pro Controller.
and **R** on another for a pair, **SL + SR** for a sideways Joy-Con, **L + R** on a Pro Controller
or GameCube controller.
3. If a controller shows as the wrong type, pick the right one under **Controller type** on its card
before assigning it. The choice is remembered for that controller; **Auto** goes back to what it
reports.

### 3. Turn on the virtual gamepad

Expand Down Expand Up @@ -92,12 +97,13 @@ the emulator afterwards — it only reads its config when it starts.

| Card | Emulators | What it writes |
|---|---|---|
| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks |
| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks; analog L/R for a GameCube controller |
| DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion |

The gamepad button beside **Set up** opens the mapping editor, with a card per connected player —
tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every
button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A
GameCube controller has layouts of its own, with Z on its Z button and analog L/R in Dolphin. A
target can take **several sources** — tick as many as you like, and any of them fires it.

- **Layouts.** Each player picks a layout to start from, which resets their changes. The card reads
Expand Down Expand Up @@ -227,6 +233,7 @@ shoulder buttons.
| "Shizuku is not running" | Open Shizuku and start it |
| "Shizuku permission denied" | Shizuku → Apps → allow Joycon2Android |
| Controller not found | Hold SYNC again and move closer |
| Controller shows as the wrong type (stick turned sideways, no right stick) | Set **Controller type** on its card, then assign it |
| Controller stops responding | Press SYNC and reconnect; wait a moment if it stays silent |
| Gamepad doesn't show up in games | Check `adb shell getevent -p` lists "Joy-Con Virtual Gamepad" |
| No DSUClient device in the emulator | Check the server address, restart the emulator and open a mapping screen; `adb logcat -s DsuServer` shows whether it's connecting |
Expand Down
5 changes: 4 additions & 1 deletion app/src/main/java/com/joegec/joycon2android/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.joegec.joycon2android
import android.content.Context
import com.joegec.joycon2android.connection.ConnectionPriorityRepository
import com.joegec.joycon2android.connection.ControllerRepository
import com.joegec.joycon2android.connection.ControllerTypePreferencesDataStore
import com.joegec.joycon2android.connection.DisconnectControllerUseCase
import com.joegec.joycon2android.connection.Joycon2Manager
import com.joegec.joycon2android.connection.ObserveViewModeUseCase
Expand Down Expand Up @@ -47,6 +48,7 @@ import com.joegec.joycon2android.emulator.edenGamepads
import com.joegec.joycon2android.session.AssignControllerUseCase
import com.joegec.joycon2android.session.ObserveSessionUseCase
import com.joegec.joycon2android.session.SessionCoordinator
import com.joegec.joycon2android.session.SetControllerTypeUseCase
import com.joegec.joycon2android.session.UnassignControllerUseCase
import com.joegec.joycon2android.dsu.DisableDsuUseCase
import com.joegec.joycon2android.dsu.DsuRepository
Expand Down Expand Up @@ -97,7 +99,7 @@ class AppContainer(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

// --- Connection (BLE) ---
private val joycon2Manager = Joycon2Manager(appContext, scope)
private val joycon2Manager = Joycon2Manager(appContext, scope, ControllerTypePreferencesDataStore(appContext))
val controllerRepository: ControllerRepository = joycon2Manager
private val connectionPriorityRepository: ConnectionPriorityRepository = joycon2Manager
private val setHighConnectionPriority = SetHighConnectionPriorityUseCase(connectionPriorityRepository)
Expand Down Expand Up @@ -230,6 +232,7 @@ class AppContainer(context: Context) {
val observeSession = ObserveSessionUseCase(sessionCoordinator)
val assignController = AssignControllerUseCase(sessionCoordinator)
val unassignController = UnassignControllerUseCase(sessionCoordinator)
val setControllerType = SetControllerTypeUseCase(sessionCoordinator)

fun disconnectAll() {
disableGamepad()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ class MainActivity : ComponentActivity() {
onAssign = viewModel::assignToPlayer,
onUnassign = viewModel::unassign,
onDisconnect = viewModel::disconnect,
onSetControllerType = viewModel::setControllerType,
onGamepadToggle = { enabled ->
gamepadViewModel.toggle(enabled, state.activePlayers)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.joegec.joycon2android.ble.BlePermissionHandler
import com.joegec.joycon2android.model.AppUiState
import com.joegec.joycon2android.model.ConnectionViewMode
import com.joegec.joycon2android.model.PlayerNumber
import com.joegec.joycon2android.model.Side
import com.joegec.joycon2android.service.Joycon2Service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
Expand Down Expand Up @@ -91,6 +92,8 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application)

fun disconnect(address: String) = container.disconnectController(address)

fun setControllerType(address: String, side: Side?) = container.setControllerType(address, side)

fun onPermissionsDenied() {
_permissionDenied.value = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import com.joegec.joycon2android.R
import com.joegec.joycon2android.model.AppUiState
import com.joegec.joycon2android.model.PlayerNumber
import com.joegec.joycon2android.model.PlayerState
import com.joegec.joycon2android.model.Side
import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard
import com.joegec.joycon2android.assignment.presentation.AssignmentPanel
import com.joegec.joycon2android.connection.presentation.CompactPlayerRow
Expand Down Expand Up @@ -145,6 +146,7 @@ fun JoyconScreen(
onAssign: (String, PlayerNumber) -> Unit,
onUnassign: (String) -> Unit,
onDisconnect: (String) -> Unit,
onSetControllerType: (String, Side?) -> Unit,
onGamepadToggle: (Boolean) -> Unit,
gamepadEmulators: List<EmulatorOption>,
selectedGamepadEmulator: String,
Expand Down Expand Up @@ -265,6 +267,7 @@ fun JoyconScreen(
gamepadEmulators, selectedGamepadEmulator, onSelectGamepadEmulator,
gamepadSetupPhase, onConfigureGamepad, onOpenGamepadMapping,
onScan, onDisconnectAll, onAssign, unassignController, removePlayer, onDisconnect,
onSetControllerType,
onGamepadToggle, onDsuToggle, onSelectDsuEmulator, onConfigureDsu, onOpenDsuMapping,
)
else -> ScanningContent(state)
Expand Down Expand Up @@ -509,6 +512,7 @@ private fun ConnectedContent(
onUnassign: (String) -> Unit,
onRemovePlayer: (PlayerState) -> Unit,
onDisconnect: (String) -> Unit,
onSetControllerType: (String, Side?) -> Unit,
onGamepadToggle: (Boolean) -> Unit,
onDsuToggle: (Boolean) -> Unit,
onSelectDsuEmulator: (String) -> Unit,
Expand All @@ -525,6 +529,7 @@ private fun ConnectedContent(
players = state.players,
onAssign = onAssign,
onDisconnect = onDisconnect,
onSetType = onSetControllerType,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.joegec.joycon2android.buttonmapping

import com.joegec.joycon2android.model.AnalogTriggerMapper
import com.joegec.joycon2android.model.JoyconButton
import com.joegec.joycon2android.model.SidewaysMapper

/** What the relay reports for this button once [SidewaysMapper] has rotated a lone Joy-Con. */
/** What the relay reports for this button once [SidewaysMapper] or [AnalogTriggerMapper] has moved it. */
fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? {
val emittedId = when (side) {
JoyconSide.DUAL -> id
JoyconSide.GAMECUBE -> AnalogTriggerMapper.remapButtons(setOf(id)).first()
JoyconSide.LEFT -> SidewaysMapper.remapButtonsLeft(setOf(id)).first()
JoyconSide.RIGHT -> SidewaysMapper.remapButtonsRight(setOf(id)).first()
}
Expand All @@ -15,7 +17,7 @@ fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? {

/** A lone Joy-Con's one stick is rotated onto the left-stick axes, whichever side it came from. */
fun MappingSource.Stick.emittedStick(side: JoyconSide): StickSource =
if (side == JoyconSide.DUAL) stick else StickSource.LEFT_STICK
if (side.isLone) StickSource.LEFT_STICK else stick

/** The relay reports a lone Joy-Con's up as its rail; off a sideways remote, up is its L/R edge. */
fun MappingSource.Stick.emittedDirection(side: JoyconSide, sidewaysRemote: Boolean): StickDirection = when {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.joegec.joycon2android.buttonmapping

/** A lone Joy-Con of one side, or a full controller (pair or Pro). */
enum class JoyconSide { LEFT, RIGHT, DUAL }
/** A lone Joy-Con of one side, a full controller (pair or Pro), or the NSO GameCube controller. */
enum class JoyconSide(val isLone: Boolean = false) { LEFT(isLone = true), RIGHT(isLone = true), DUAL, GAMECUBE }
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ data class PlayerBody(val player: PlayerNumber, val side: JoyconSide)
fun PlayerState.body(): PlayerBody? = joyconSide()?.let { PlayerBody(player, it) }

fun PlayerState.joyconSide(): JoyconSide? = when {
hasGameCube -> JoyconSide.GAMECUBE
hasPro || hasFullController -> JoyconSide.DUAL
left != null -> JoyconSide.LEFT
right != null -> JoyconSide.RIGHT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.joegec.joycon2android.model.JoyconButton.SrLeft
import com.joegec.joycon2android.model.JoyconButton.SrRight
import com.joegec.joycon2android.model.JoyconButton.Up
import com.joegec.joycon2android.model.JoyconButton.X
import com.joegec.joycon2android.model.JoyconButton.ZR
import com.joegec.joycon2android.model.JoyconButton.Y

object GameCubeMapping : MappingPreset {
Expand All @@ -48,6 +49,21 @@ object GameCubeMapping : MappingPreset {
GameCubeButton.DPadLeft to Left,
GameCubeButton.DPadRight to Right,
)
// Its Z button reports as ZR.
JoyconSide.GAMECUBE -> mapOf(
GameCubeButton.A to A,
GameCubeButton.B to B,
GameCubeButton.X to X,
GameCubeButton.Y to Y,
GameCubeButton.Z to ZR,
GameCubeButton.Start to Plus,
GameCubeButton.TriggerL to L,
GameCubeButton.TriggerR to R,
GameCubeButton.DPadUp to Up,
GameCubeButton.DPadDown to Down,
GameCubeButton.DPadLeft to Left,
GameCubeButton.DPadRight to Right,
)
JoyconSide.LEFT -> mapOf(
GameCubeButton.A to Down,
GameCubeButton.B to Left,
Expand All @@ -71,7 +87,7 @@ object GameCubeMapping : MappingPreset {
}

private fun sticks(side: JoyconSide): Map<GameCubeStick, StickSource> = when (side) {
JoyconSide.DUAL -> mapOf(
JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(
GameCubeStick.MainStick to LEFT_STICK,
GameCubeStick.CStick to RIGHT_STICK,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object JoyconWiiMapping : MappingPreset {
override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries()

private fun buttons(side: JoyconSide): Map<WiimoteButton, JoyconButton> = when (side) {
JoyconSide.DUAL -> mapOf(
JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(
WiimoteButton.B to B,
WiimoteButton.One to R,
WiimoteButton.Two to ZR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ object MarioKartNunchukMapping : MappingPreset {
override val sidewaysRemote = true

override fun entries(side: JoyconSide) = when (side) {
JoyconSide.DUAL -> WiiMapping.entries(side) + pairButtons()
JoyconSide.DUAL, JoyconSide.GAMECUBE -> WiiMapping.entries(side) + pairButtons()
else -> loneButtons(side) + nunchukStick(side).stickEntries()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ object SwitchProMapping : MappingPreset {
override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries()

private fun buttons(side: JoyconSide): Map<SwitchProButton, JoyconButton> = when (side) {
JoyconSide.DUAL -> mapOf(
JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(
SwitchProButton.A to A,
SwitchProButton.B to B,
SwitchProButton.X to X,
Expand Down Expand Up @@ -84,7 +84,7 @@ object SwitchProMapping : MappingPreset {
}

private fun sticks(side: JoyconSide): Map<SwitchProStick, StickSource> = when (side) {
JoyconSide.DUAL -> mapOf(
JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(
SwitchProStick.LStick to LEFT_STICK,
SwitchProStick.RStick to RIGHT_STICK,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ object WiiMapping : MappingPreset {
buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + nunchukStick(side).stickEntries()

internal fun buttons(side: JoyconSide): Map<WiimoteButton, JoyconButton> = when (side) {
JoyconSide.DUAL -> mapOf(
JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(
WiimoteButton.A to A,
WiimoteButton.B to ZR,
WiimoteButton.One to Y,
Expand Down Expand Up @@ -77,7 +77,7 @@ object WiiMapping : MappingPreset {
// Sideways, the cluster is the face buttons, so the stick drives the d-pad.
internal fun dPadSticks(side: JoyconSide): Map<WiimoteButton, List<MappingSource>> {
val stick = when (side) {
JoyconSide.DUAL -> return emptyMap()
JoyconSide.DUAL, JoyconSide.GAMECUBE -> return emptyMap()
JoyconSide.LEFT -> LEFT_STICK
JoyconSide.RIGHT -> RIGHT_STICK
}
Expand All @@ -90,7 +90,7 @@ object WiiMapping : MappingPreset {
}

internal fun nunchukStick(side: JoyconSide): Map<WiimoteStick, StickSource> = when (side) {
JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK)
JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK)
else -> emptyMap()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.joegec.joycon2android.buttonmapping

import com.joegec.joycon2android.model.ConnectedJoycon
import com.joegec.joycon2android.model.ControllerModel
import com.joegec.joycon2android.model.JoyconButton
import com.joegec.joycon2android.model.PlayerNumber
import com.joegec.joycon2android.model.PlayerState
import com.joegec.joycon2android.model.Side
import org.junit.Assert.assertEquals
import org.junit.Test

class PlayerBodyTest {

private fun controller(model: ControllerModel) =
ConnectedJoycon(address = model.name, side = Side.PRO, model = model, deviceName = "Joy-Con 2")

@Test
fun `a gamecube controller maps as its own body, a pro controller as a pair`() {
val gameCube = controller(ControllerModel.GAMECUBE)
val pro = controller(ControllerModel.PRO_CONTROLLER)

assertEquals(JoyconSide.GAMECUBE, PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube).joyconSide())
assertEquals(JoyconSide.DUAL, PlayerState(PlayerNumber.P1, left = pro, right = pro).joyconSide())
}

@Test
fun `a gamecube controller's ZL and Z are reported on the stick clicks, a pair's are not`() {
assertEquals(JoyconButton.RS, JoyconButton.ZR.emittedFor(JoyconSide.GAMECUBE))
assertEquals(JoyconButton.LS, JoyconButton.ZL.emittedFor(JoyconSide.GAMECUBE))
assertEquals(JoyconButton.ZR, JoyconButton.ZR.emittedFor(JoyconSide.DUAL))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class WiiPresetsTest {

@Test
fun `a lone Joy-Con on the Nunchuck layout plays both halves itself`() {
JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side ->
JoyconSide.entries.filter { it.isLone }.forEach { side ->
val lone = MarioKartNunchukMapping.entries(side)
val rail = if (side == JoyconSide.LEFT) "SlLeft" else "SlRight"

Expand Down Expand Up @@ -132,7 +132,7 @@ class WiiPresetsTest {

@Test
fun `no button on a lone Joy-Con fires two targets, bar the shoulder that hops and tricks`() {
JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side ->
JoyconSide.entries.filter { it.isLone }.forEach { side ->
val entries = MappingLayouts.entriesOf(Console.WIIMOTE_NUNCHUK, side, MarioKartNunchukMapping)
val fired = mutableMapOf<String, MutableSet<String>>()
entries.forEach { (target, value) ->
Expand Down Expand Up @@ -188,7 +188,7 @@ class WiiPresetsTest {

@Test
fun `Mario Kart Wheel tricks off SR on a lone Joy-Con, the shoulder that already hops`() {
JoyconSide.entries.filterNot { it == JoyconSide.DUAL }.forEach { side ->
JoyconSide.entries.filter { it.isLone }.forEach { side ->
val lone = MarioKartWheelMapping.entries(side)

assertEquals("$side", lone.getValue(WiimoteButton.B.name), lone.getValue(WiimoteButton.Shake.name))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal fun JoyconSide.shortLabel(): String = when (this) {
JoyconSide.LEFT -> stringResource(R.string.side_left_short)
JoyconSide.RIGHT -> stringResource(R.string.side_right_short)
JoyconSide.DUAL -> stringResource(R.string.side_dual_short)
JoyconSide.GAMECUBE -> stringResource(R.string.side_gamecube_short)
}

@Composable
Expand Down
Loading