Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8ca60ab
Support the NSO GameCube controller
thisishuey Oct 8, 2026
7d79662
Keep a GameCube controller's ZL and Z off its stick-click bindings
thisishuey Oct 13, 2026
925545c
Put a GameCube controller's ZL and Z on the bumpers
thisishuey Oct 13, 2026
e0104af
Give a GameCube controller − and Recenter in the Joy-Con Wii layout
thisishuey Oct 13, 2026
b204969
Let the controller type menu pick a GameCube controller
thisishuey Oct 13, 2026
f7bc9d4
Move the controller type menu into the card's type label
thisishuey Oct 13, 2026
b4701a9
Load saved controller types before a scan connects anything
thisishuey Oct 13, 2026
581d212
Mark a connection's factory calibration volatile
thisishuey Oct 13, 2026
65a1106
Take each player's body from joyconSide() in every config writer
thisishuey Oct 13, 2026
b83bc3f
Say that DSU carries no analog trigger travel
thisishuey Oct 13, 2026
bb3237b
Keep controllers with analog triggers off the console protocol
thisishuey Oct 13, 2026
3d808d8
Test the console fallback guard and how saved controller types read back
thisishuey Oct 13, 2026
3a999f1
List the GameCube controller among the controller types a bug report …
thisishuey Oct 13, 2026
543a4dc
Say the bumper layout was confirmed in Dolphin only
thisishuey Oct 13, 2026
8220eec
Name a GameCube controller as one wherever a player's controllers are…
thisishuey Oct 13, 2026
a09f182
Draw only a GameCube controller's own buttons in its detailed view
thisishuey Oct 13, 2026
ee2ea80
Put a GameCube controller's L and R above ZL and Z in its detailed view
thisishuey Oct 13, 2026
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Add tests for new domain and data logic, and update any doc your change makes st
BLE behaviour is very device-specific, so a good bug report includes:

- Device model and Android version
- Controller type (Joy-Con 2 left / right / pair / Pro) and how it was connected
- Controller type (Joy-Con 2 left / right / pair, Pro or NSO GameCube) and how it was connected
- What you expected vs. what happened
- A relevant `adb logcat` snippet (the app logs under the `Joycon2` and `DsuServer` tags)

Expand Down
17 changes: 12 additions & 5 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 @@ -35,7 +36,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 @@ -58,7 +59,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, tap its type at the top of its card and pick the right one
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 @@ -96,12 +101,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 @@ -131,7 +137,7 @@ The layouts the app ships:
| Layout | For |
|---|---|
| Wii | The Wii Remote's own arrangement: the trigger under your finger is B, 1 and 2 under the thumb |
| Joy-Con | The same, with the Joy-Con's own B as B, 1 and 2 on the shoulders so your thumb stays on the stick, and Recenter on the stick click |
| Joy-Con | The same, with the Joy-Con's own B as B, 1 and 2 on the shoulders so your thumb stays on the stick, and Recenter on the stick click. A GameCube controller has no stick click or −, so Recenter is on Y and − on X |
| Mario Kart Wheel | A lone Joy-Con held sideways as a wheel, laid out the way Mario Kart 8 uses one — 2 accelerates, 1 brakes, SR hops and tricks, and SL throws an item alongside the stick. Steers by **tilt**, so it plays as a sideways Wii Remote: the d-pad turns with it and a right Joy-Con aims from its tail |
| Mario Kart Nunchuck | The remote-and-nunchuk scheme, which steers by **stick** instead. A pair splits the halves across the hands, each index finger on the shoulder its controller keeps a trigger on; a lone Joy-Con plays both halves itself, its own stick standing in for the Nunchuk's and its rails carrying C and Z |

Expand Down Expand Up @@ -242,6 +248,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) | Tap its type at the top of its card, pick the right one, then assign it |
| Controller stops responding | Press SYNC and reconnect; wait a moment if it stays silent |
| Controller shows "Connection lost" | Press SYNC on it and it reconnects to the same player by itself, or tap Scan. After two minutes it's removed |
| Controllers drop when a game starts | Some phones clear background apps when a game launches. Tap the yellow banner at the top to let the app run in the background (or set its battery use to unrestricted), and exclude it from the game launcher's cleanup |
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 @@ -64,6 +65,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 @@ -120,7 +122,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 @@ -294,6 +296,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 @@ -348,6 +348,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.ControllerModel
import com.joegec.joycon2android.power.BatteryOptimizationExemption
import com.joegec.joycon2android.service.Joycon2Service
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -100,6 +101,8 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application)

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

fun setControllerType(address: String, model: ControllerModel?) = container.setControllerType(address, model)

fun onPermissionsDenied() {
_permissionDenied.value = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,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.ControllerModel
import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard
import com.joegec.joycon2android.assignment.presentation.AssignmentPanel
import com.joegec.joycon2android.model.ConnectionViewMode
Expand Down Expand Up @@ -134,6 +135,7 @@ fun JoyconScreen(
onAssign: (String, PlayerNumber) -> Unit,
onUnassign: (String) -> Unit,
onDisconnect: (String) -> Unit,
onSetControllerType: (String, ControllerModel?) -> Unit,
onGamepadToggle: (Boolean) -> Unit,
gamepadEmulators: List<EmulatorOption>,
selectedGamepadEmulator: String,
Expand Down Expand Up @@ -255,6 +257,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 @@ -463,6 +466,7 @@ private fun ConnectedContent(
onUnassign: (String) -> Unit,
onRemovePlayer: (PlayerState) -> Unit,
onDisconnect: (String) -> Unit,
onSetControllerType: (String, ControllerModel?) -> Unit,
onGamepadToggle: (Boolean) -> Unit,
onDsuToggle: (Boolean) -> Unit,
onSelectDsuEmulator: (String) -> Unit,
Expand All @@ -479,6 +483,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.emittedId(id) ?: return null
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 @@ -13,6 +13,8 @@ import com.joegec.joycon2android.model.JoyconButton.R
import com.joegec.joycon2android.model.JoyconButton.RS
import com.joegec.joycon2android.model.JoyconButton.Right
import com.joegec.joycon2android.model.JoyconButton.Up
import com.joegec.joycon2android.model.JoyconButton.X
import com.joegec.joycon2android.model.JoyconButton.Y
import com.joegec.joycon2android.model.JoyconButton.ZL
import com.joegec.joycon2android.model.JoyconButton.ZR

Expand All @@ -31,6 +33,14 @@ object JoyconWiiMapping : MappingPreset {
WiimoteButton.Minus to Minus,
WiimoteButton.Recenter to RS,
)
// No − and no stick click: − takes X as on a lone right Joy-Con, and Recenter the free Y.
JoyconSide.GAMECUBE -> mapOf(
WiimoteButton.B to B,
WiimoteButton.One to R,
WiimoteButton.Two to ZR,
WiimoteButton.Minus to X,
WiimoteButton.Recenter to Y,
)
JoyconSide.RIGHT -> mapOf(
WiimoteButton.B to B,
WiimoteButton.One to R,
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 @@ -37,27 +37,30 @@ object SwitchProMapping : MappingPreset {

override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries()

private val PAIR_BUTTONS = mapOf(
SwitchProButton.A to A,
SwitchProButton.B to B,
SwitchProButton.X to X,
SwitchProButton.Y to Y,
SwitchProButton.L to L,
SwitchProButton.R to R,
SwitchProButton.ZL to ZL,
SwitchProButton.ZR to ZR,
SwitchProButton.Plus to Plus,
SwitchProButton.Minus to Minus,
SwitchProButton.Home to Home,
SwitchProButton.Capture to Capture,
SwitchProButton.LStickClick to LS,
SwitchProButton.RStickClick to RS,
SwitchProButton.DPadUp to Up,
SwitchProButton.DPadDown to Down,
SwitchProButton.DPadLeft to Left,
SwitchProButton.DPadRight to Right,
)

private fun buttons(side: JoyconSide): Map<SwitchProButton, JoyconButton> = when (side) {
JoyconSide.DUAL -> mapOf(
SwitchProButton.A to A,
SwitchProButton.B to B,
SwitchProButton.X to X,
SwitchProButton.Y to Y,
SwitchProButton.L to L,
SwitchProButton.R to R,
SwitchProButton.ZL to ZL,
SwitchProButton.ZR to ZR,
SwitchProButton.Plus to Plus,
SwitchProButton.Minus to Minus,
SwitchProButton.Home to Home,
SwitchProButton.Capture to Capture,
SwitchProButton.LStickClick to LS,
SwitchProButton.RStickClick to RS,
SwitchProButton.DPadUp to Up,
SwitchProButton.DPadDown to Down,
SwitchProButton.DPadLeft to Left,
SwitchProButton.DPadRight to Right,
)
JoyconSide.DUAL -> PAIR_BUTTONS
JoyconSide.GAMECUBE -> PAIR_BUTTONS - SwitchProButton.LStickClick - SwitchProButton.RStickClick
// Rails as shoulders, ZL/ZR unbound: docs/virtual-gamepad.md#sidewaysmapper
JoyconSide.LEFT -> mapOf(
SwitchProButton.A to Down,
Expand All @@ -84,7 +87,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
Loading
Loading