From 8ca60abafb5fda96416bce9af4cf27cdbf201287 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Thu, 8 Oct 2026 18:11:53 +0000 Subject: [PATCH 01/17] Support the NSO GameCube controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NSO GameCube controller advertises product ID 0x2073, which the scanner didn't know. It connected as an unknown side, and a lone unknown controller is treated as a sideways Joy-Con. So its stick and D-pad were turned 90 degrees, its C-stick was never decoded, L + R didn't assign it, and it played as whichever Joy-Con the first button pressed suggested. The scanner now names a ControllerModel from the product ID. The GameCube controller reports in the Pro Controller's layout, so it's parsed and assigned as one: one body, both sticks. It shares nothing else with the Pro. For button mapping it's a body of its own, beside a pair and a lone Joy-Con, with its own layouts and saved bindings. Its default puts Z on its Z button (reported as ZR) and the triggers on L/R. The mapping editor offers only the buttons it has, with ZR shown as Z. Dolphin setup configures a GameCube player and still skips Pro players, as before. Joy-Con pairs and Pro Controllers keep their layouts exactly as they were. Each controller card now has a Controller type menu: Auto, Pro Controller, Left or Right Joy-Con. This covers any controller whose advertisement names no type or the wrong one. The choice is stored per BLE address, which captures show is a fixed public address, so it applies again on reconnect. On a connected controller it takes effect at once: the parser reads the new side's sticks, and stick calibration starts over. A controller assigned to a player is unassigned first, since its slot was checked against the old type. Analog L/R now reach Dolphin. The controller reports trigger travel at 0x3C/0x3D. TriggerCalibrator maps it from the factory zero (SPI 0x013140) to the first stop, where analog travel ends, and drives the virtual gamepad's trigger bytes with it. Dolphin setup binds L-Analog and R-Analog to those axes and keeps the click at the second stop on L/R. Every other controller keeps ZL/ZR on the trigger bytes. On this controller ZL and Z also move off BUTTON_L2/R2 and onto the stick clicks, which it doesn't have. On a Retroid Pocket Nova those keys followed the trigger axes, so pulling R pressed Z and Z itself did nothing. With the move, both work. At connect it also reads its factory stick calibration (0x0130A8 for the main stick, 0x0130E8 for the C-stick, decoded as the Linux and SDL Switch 2 drivers do), so its sticks start centred with their full range. Learning still applies on top, and takes over when the flash is unset or implausible. Only the GameCube controller's values have been checked against its sticks, so Joy-Con 2 and Pro Controllers aren't read and keep learning theirs. Tested on a Retroid Pocket Nova (Android 13), with three GameCube controllers connected at once as players 1–3 in Dolphin. Recognition, L + R assign, both sticks, the D-pad, every button, analog L/R with a separate click, and the type menu all behave. Packet captures from the three controllers back the offsets and calibration values in protocol.md. Four controllers and Android 14+ are untested. Rumble is left out: the app has no rumble path yet. Closes #27 Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +++- .../com/joegec/joycon2android/AppContainer.kt | 5 +- .../com/joegec/joycon2android/MainActivity.kt | 1 + .../joycon2android/ui/Joycon2ViewModel.kt | 3 + .../joegec/joycon2android/ui/JoyconScreen.kt | 5 ++ .../buttonmapping/EmittedInput.kt | 6 +- .../buttonmapping/JoyconSide.kt | 4 +- .../buttonmapping/PlayerBody.kt | 1 + .../buttonmapping/preset/GameCubeMapping.kt | 18 +++- .../buttonmapping/preset/JoyconWiiMapping.kt | 2 +- .../preset/MarioKartNunchukMapping.kt | 2 +- .../buttonmapping/preset/SwitchProMapping.kt | 4 +- .../buttonmapping/preset/WiiMapping.kt | 6 +- .../buttonmapping/PlayerBodyTest.kt | 32 +++++++ .../buttonmapping/preset/WiiPresetsTest.kt | 6 +- .../presentation/MappingLabels.kt | 1 + .../presentation/MappingOptions.kt | 18 +++- .../src/main/res/values/strings.xml | 1 + .../model/AnalogTriggerMapper.kt | 14 +++ .../joycon2android/model/ConnectedJoycon.kt | 2 + .../joycon2android/model/ControllerModel.kt | 10 +++ .../joycon2android/model/GamepadState.kt | 20 ++++- .../joycon2android/model/JoyconInput.kt | 2 + .../joycon2android/model/PlayerState.kt | 2 + core/session/build.gradle.kts | 1 + .../session/SessionCoordinator.kt | 7 ++ .../session/SetControllerTypeUseCase.kt | 7 ++ .../session/SessionCoordinatorTypeTest.kt | 85 +++++++++++++++++++ docs/architecture.md | 3 +- docs/protocol.md | 63 ++++++++++++-- docs/virtual-gamepad.md | 29 +++++-- .../presentation/AssignmentPanel.kt | 13 ++- .../presentation/ControllerTypeDropdown.kt | 32 +++++++ .../presentation/ControllerTypeLabels.kt | 23 +++++ .../src/main/res/values/strings.xml | 8 ++ .../joycon2android/connection/BleScanner.kt | 38 +++------ .../connection/CommonSession.kt | 26 +++--- .../connection/ConnectionPool.kt | 12 ++- .../ControllerTypePreferencesDataStore.kt | 32 +++++++ .../connection/FactoryCalibrationReads.kt | 32 +++++++ .../connection/Joycon2Manager.kt | 19 ++++- .../connection/JoyconAdvertisement.kt | 17 +++- .../connection/JoyconConnection.kt | 32 ++++++- .../joycon2android/connection/PacketParser.kt | 7 ++ .../connection/SpiColorParser.kt | 26 +----- .../joycon2android/connection/SpiReadReply.kt | 27 ++++++ .../connection/SpiStickCalibration.kt | 33 +++++++ .../connection/SpiTriggerZeros.kt | 17 ++++ .../connection/JoyconAdvertisementTest.kt | 17 ++++ .../connection/PacketParserTest.kt | 18 ++++ .../connection/SpiStickCalibrationTest.kt | 64 ++++++++++++++ .../connection/SpiTriggerZerosTest.kt | 36 ++++++++ .../connection/ControllerRepository.kt | 4 + .../connection/ControllerTypePreferences.kt | 9 ++ .../connection/FactoryCalibration.kt | 9 ++ .../connection/InputCalibrator.kt | 29 +++++++ .../connection/StickCalibration.kt | 11 +++ .../connection/StickCalibrator.kt | 24 +++++- .../connection/TriggerCalibrator.kt | 62 ++++++++++++++ .../connection/InputCalibratorTest.kt | 36 ++++++++ .../connection/StickCalibratorTest.kt | 10 +++ .../connection/TriggerCalibratorTest.kt | 54 ++++++++++++ .../dsu/emulator/DolphinWiimoteConfig.kt | 4 +- .../dsu/emulator/EdenDsuConfig.kt | 1 + .../joycon2android/gamepad/ReportMapper.kt | 6 +- .../gamepad/ReportMapperTest.kt | 23 +++++ .../gamepad/emulator/DolphinGcpadConfig.kt | 12 ++- .../gamepad/emulator/EdenGamepadConfig.kt | 1 + .../emulator/DolphinGcpadConfigTest.kt | 42 +++++++++ .../gamepad/emulator/EdenGamepadConfigTest.kt | 12 +++ 70 files changed, 1130 insertions(+), 123 deletions(-) create mode 100644 core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt create mode 100644 core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt create mode 100644 core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt create mode 100644 feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt create mode 100644 feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt create mode 100644 feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt create mode 100644 feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt create mode 100644 feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt create mode 100644 feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt create mode 100644 feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt create mode 100644 feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt create mode 100644 feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt diff --git a/README.md b/README.md index 0e785df1..41155919 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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, 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 @@ -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 @@ -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) | Set **Controller type** on its card, 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 | diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 229986da..945753f6 100644 --- a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt +++ b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt @@ -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 @@ -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 @@ -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) @@ -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() diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index 3c23db75..42de0ab3 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -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) }, diff --git a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt index 91903f06..766b862f 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt @@ -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.power.BatteryOptimizationExemption import com.joegec.joycon2android.service.Joycon2Service import kotlinx.coroutines.flow.MutableStateFlow @@ -100,6 +101,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 } diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index 7f753f79..d478417f 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -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.Side import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard import com.joegec.joycon2android.assignment.presentation.AssignmentPanel import com.joegec.joycon2android.model.ConnectionViewMode @@ -134,6 +135,7 @@ fun JoyconScreen( onAssign: (String, PlayerNumber) -> Unit, onUnassign: (String) -> Unit, onDisconnect: (String) -> Unit, + onSetControllerType: (String, Side?) -> Unit, onGamepadToggle: (Boolean) -> Unit, gamepadEmulators: List, selectedGamepadEmulator: String, @@ -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) @@ -463,6 +466,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, @@ -479,6 +483,7 @@ private fun ConnectedContent( players = state.players, onAssign = onAssign, onDisconnect = onDisconnect, + onSetType = onSetControllerType, ) } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index acbe3dfc..60496754 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -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() } @@ -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 { diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt index 84eda56f..7a667fde 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/JoyconSide.kt @@ -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 } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt index b2e5c10e..25006ef1 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBody.kt @@ -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 diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt index efdbf018..94a3ccc5 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/GameCubeMapping.kt @@ -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 { @@ -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, @@ -71,7 +87,7 @@ object GameCubeMapping : MappingPreset { } private fun sticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( GameCubeStick.MainStick to LEFT_STICK, GameCubeStick.CStick to RIGHT_STICK, ) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index 37e6a86d..0dbb3857 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -24,7 +24,7 @@ object JoyconWiiMapping : MappingPreset { override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() private fun buttons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( WiimoteButton.B to B, WiimoteButton.One to R, WiimoteButton.Two to ZR, diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt index 64174ad2..241308d0 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/MarioKartNunchukMapping.kt @@ -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() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt index 4a6c07f2..db072ed8 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -38,7 +38,7 @@ object SwitchProMapping : MappingPreset { override fun entries(side: JoyconSide) = buttons(side).buttonEntries() + sticks(side).stickEntries() private fun buttons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( SwitchProButton.A to A, SwitchProButton.B to B, SwitchProButton.X to X, @@ -84,7 +84,7 @@ object SwitchProMapping : MappingPreset { } private fun sticks(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( SwitchProStick.LStick to LEFT_STICK, SwitchProStick.RStick to RIGHT_STICK, ) diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt index b3adf1e2..b93da115 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiMapping.kt @@ -36,7 +36,7 @@ object WiiMapping : MappingPreset { buttons(side).buttonEntries() + dPadSticks(side).sourceEntries() + nunchukStick(side).stickEntries() internal fun buttons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf( + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( WiimoteButton.A to A, WiimoteButton.B to ZR, WiimoteButton.One to Y, @@ -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> { val stick = when (side) { - JoyconSide.DUAL -> return emptyMap() + JoyconSide.DUAL, JoyconSide.GAMECUBE -> return emptyMap() JoyconSide.LEFT -> LEFT_STICK JoyconSide.RIGHT -> RIGHT_STICK } @@ -90,7 +90,7 @@ object WiiMapping : MappingPreset { } internal fun nunchukStick(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) + JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf(WiimoteStick.NunchukStick to LEFT_STICK) else -> emptyMap() } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt new file mode 100644 index 00000000..e337aa21 --- /dev/null +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt @@ -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)) + } +} diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 0580dc07..0bd572fd 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -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" @@ -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>() entries.forEach { (target, value) -> @@ -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)) diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt index 6e2c490a..1486e362 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingLabels.kt @@ -29,6 +29,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 diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index 7608485e..cb4a755c 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -23,7 +23,7 @@ internal object MappingOptions { const val NONE_ID = "" fun offersSidewaysRemote(console: Console, side: JoyconSide) = - console == Console.WIIMOTE_NUNCHUK && side != JoyconSide.DUAL + console == Console.WIIMOTE_NUNCHUK && side.isLone fun offersRemoteMotion(console: Console) = console == Console.WIIMOTE_NUNCHUK @@ -67,20 +67,20 @@ internal object MappingOptions { @Composable fun sources(side: JoyconSide, section: MappingSection): List> = listOf(NONE_ID to stringResource(R.string.source_none)) + - physicalButtons(side).map { it.name to it.id } + + physicalButtons(side).map { it.name to it.markingOn(side) } + if (section == MappingSection.ANDROID) emptyList() else stickDirections(side) // A lone Joy-Con has one stick, so its directions need no "Left"/"Right" to tell them apart. @Composable private fun stickDirections(side: JoyconSide): List> { val sticks = when (side) { - JoyconSide.DUAL -> StickSource.entries + JoyconSide.DUAL, JoyconSide.GAMECUBE -> StickSource.entries JoyconSide.LEFT -> listOf(StickSource.LEFT_STICK) JoyconSide.RIGHT -> listOf(StickSource.RIGHT_STICK) } return sticks.flatMap(MappingSource::directionsOf).map { source -> val stick = - if (side == JoyconSide.DUAL) source.stick.label() else stringResource(R.string.stick_lone) + if (side.isLone) stringResource(R.string.stick_lone) else source.stick.label() source.id to stringResource(R.string.source_direction, stick, source.direction.label()) } } @@ -88,6 +88,12 @@ internal object MappingOptions { // Only what that side physically has, so every choice can fire. private fun physicalButtons(side: JoyconSide): List = when (side) { JoyconSide.DUAL -> JoyconButton.entries + JoyconSide.GAMECUBE -> listOf( + JoyconButton.A, JoyconButton.B, JoyconButton.X, JoyconButton.Y, + JoyconButton.L, JoyconButton.R, JoyconButton.ZL, JoyconButton.ZR, JoyconButton.Plus, + JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, + JoyconButton.Home, JoyconButton.Capture, JoyconButton.Chat, + ) JoyconSide.LEFT -> listOf( JoyconButton.L, JoyconButton.ZL, JoyconButton.Minus, JoyconButton.LS, JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, @@ -99,4 +105,8 @@ internal object MappingOptions { JoyconButton.Home, JoyconButton.Chat, JoyconButton.SrRight, JoyconButton.SlRight, ) } + + // The GameCube controller's Z reports as ZR. + private fun JoyconButton.markingOn(side: JoyconSide): String = + if (side == JoyconSide.GAMECUBE && this == JoyconButton.ZR) "Z" else id } diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 2b56f6d2..83436093 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ L R L/R + GC P%1$d %2$s Up Down diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt new file mode 100644 index 00000000..2f311468 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt @@ -0,0 +1,14 @@ +package com.joegec.joycon2android.model + +/** Why ZL and ZR move off L2/R2: docs/virtual-gamepad.md#analog-triggers */ +object AnalogTriggerMapper { + + fun remapButtons(pressed: Set): Set = + pressed.mapTo(mutableSetOf()) { REMAP[it] ?: it } + + // The stick clicks are free: a controller with analog triggers has none. + private val REMAP = mapOf( + JoyconButton.ZL.id to JoyconButton.LS.id, + JoyconButton.ZR.id to JoyconButton.RS.id, + ) +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt index a9d6d957..63185bae 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt @@ -4,6 +4,8 @@ data class ConnectedJoycon( val address: String, val side: Side, val deviceName: String, + val model: ControllerModel = ControllerModel.UNKNOWN, + val typeOverride: Side? = null, val connectionState: JoyconConnectionState = JoyconConnectionState(), val input: JoyconInput = JoyconInput(), val assignedPlayer: PlayerNumber? = null, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt new file mode 100644 index 00000000..129080f8 --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ControllerModel.kt @@ -0,0 +1,10 @@ +package com.joegec.joycon2android.model + +/** From the advertised product ID: docs/protocol.md#advertising */ +enum class ControllerModel(val defaultSide: Side, val hasAnalogTriggers: Boolean = false) { + JOYCON_LEFT(Side.LEFT), + JOYCON_RIGHT(Side.RIGHT), + PRO_CONTROLLER(Side.PRO), + GAMECUBE(Side.PRO, hasAnalogTriggers = true), + UNKNOWN(Side.UNKNOWN), +} diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt index 57adf1bf..06f1a6de 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GamepadState.kt @@ -6,11 +6,29 @@ data class GamepadState( val leftStickY: Int, val rightStickX: Int, val rightStickY: Int, + val leftTrigger: Int = 0, + val rightTrigger: Int = 0, ) { companion object { private const val CENTER = 2048 + private const val TRIGGER_PULLED = 255 - fun from(state: PlayerState): GamepadState = when { + fun from(state: PlayerState): GamepadState = buttonsAndSticks(state).withTriggers(state) + + private fun GamepadState.withTriggers(state: PlayerState): GamepadState = + if (state.hasAnalogTriggers) { + copy( + pressed = AnalogTriggerMapper.remapButtons(pressed), + leftTrigger = state.leftInput.triggerLeft, + rightTrigger = state.leftInput.triggerRight, + ) + } else { + copy(leftTrigger = pulledBy(JoyconButton.ZL), rightTrigger = pulledBy(JoyconButton.ZR)) + } + + private fun GamepadState.pulledBy(button: JoyconButton): Int = if (button.id in pressed) TRIGGER_PULLED else 0 + + private fun buttonsAndSticks(state: PlayerState): GamepadState = when { state.hasFullController -> GamepadState( pressed = state.pressed, leftStickX = state.leftStickX, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt index 4d20036e..7845600c 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/JoyconInput.kt @@ -16,4 +16,6 @@ data class JoyconInput( val gyroZ: Int = 0, val battery: BatteryCharge? = null, val motionSupport: MotionSupport = MotionSupport.Full, + val triggerLeft: Int = 0, + val triggerRight: Int = 0, ) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt index 223a3222..f83ed217 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/PlayerState.kt @@ -9,6 +9,8 @@ data class PlayerState( val hasController: Boolean get() = left != null || right != null val hasFullController: Boolean get() = left != null && right != null val isSideways: Boolean get() = hasController && !hasFullController && !hasPro + val hasAnalogTriggers: Boolean get() = hasPro && left!!.model.hasAnalogTriggers + val hasGameCube: Boolean get() = hasPro && left!!.model == ControllerModel.GAMECUBE // As the hardware reports it, before any sideways rotation ([gamepad]). val pressed: Set diff --git a/core/session/build.gradle.kts b/core/session/build.gradle.kts index fe0f16a6..6c8a5e5e 100644 --- a/core/session/build.gradle.kts +++ b/core/session/build.gradle.kts @@ -8,4 +8,5 @@ dependencies { api(project(":feature:connection:domain")) api(project(":feature:assignment:domain")) api(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) } diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt index afd65fdc..512fe5fb 100644 --- a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.assignment.PlayerStateResolver import com.joegec.joycon2android.model.AppUiState import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.Side import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -60,6 +61,12 @@ class SessionCoordinator( player?.let(onPlayerUnassigned) } + // Unassigned first: the assignment's slot checks were made for the old type. + fun setControllerType(address: String, side: Side?) { + if (assignments.getPlayer(address) != null) unassign(address) + controllers.setControllerType(address, side) + } + private fun publish(state: AppUiState) { _uiState.value = state onState(state) diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt new file mode 100644 index 00000000..9631d12b --- /dev/null +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt @@ -0,0 +1,7 @@ +package com.joegec.joycon2android.session + +import com.joegec.joycon2android.model.Side + +class SetControllerTypeUseCase(private val coordinator: SessionCoordinator) { + operator fun invoke(address: String, side: Side?) = coordinator.setControllerType(address, side) +} diff --git a/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt b/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt new file mode 100644 index 00000000..0c1eb5a2 --- /dev/null +++ b/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt @@ -0,0 +1,85 @@ +package com.joegec.joycon2android.session + +import com.joegec.joycon2android.assignment.AssignmentRepository +import com.joegec.joycon2android.assignment.ComboAssignmentDetector +import com.joegec.joycon2android.assignment.PlayerStateResolver +import com.joegec.joycon2android.connection.ControllerRepository +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.Side +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SessionCoordinatorTypeTest { + + private val controllers = FakeControllers() + private val assignments = FakeAssignments() + private val unassignedPlayers = mutableListOf() + private val coordinator = SessionCoordinator( + scope = CoroutineScope(Dispatchers.Unconfined), + controllers = controllers, + assignments = assignments, + resolver = PlayerStateResolver(evictConflicting = {}), + comboDetector = ComboAssignmentDetector(), + onState = {}, + onPlayerAssigned = {}, + onPlayerUnassigned = { unassignedPlayers += it }, + ) + + @Test + fun `an assigned controller leaves its player before its type changes`() { + assignments.players[ADDRESS] = PlayerNumber.P2 + + coordinator.setControllerType(ADDRESS, Side.PRO) + + assertNull(assignments.players[ADDRESS]) + assertEquals(listOf(PlayerNumber.P2), unassignedPlayers) + assertEquals(ADDRESS to Side.PRO, controllers.typeChanges.single()) + } + + @Test + fun `an unassigned controller just changes type`() { + coordinator.setControllerType(ADDRESS, null) + + assertEquals(emptyList(), unassignedPlayers) + assertEquals(ADDRESS to null, controllers.typeChanges.single()) + } + + private class FakeControllers : ControllerRepository { + val typeChanges = mutableListOf>() + override val controllers: StateFlow> = MutableStateFlow(emptyList()) + override val scanning: StateFlow = MutableStateFlow(false) + override val error: StateFlow = MutableStateFlow(null) + override fun startScan() = Unit + override fun stopScan() = Unit + override fun disconnect(address: String) = Unit + override fun disconnectAll() = Unit + override fun setPlayerLed(address: String, player: PlayerNumber?) = Unit + override fun setControllerType(address: String, side: Side?) { + typeChanges += address to side + } + override fun emitError(message: String) = Unit + } + + private class FakeAssignments : AssignmentRepository { + val players = mutableMapOf() + override val assignments: StateFlow> = MutableStateFlow(emptyMap()) + override fun assign(address: String, side: Side, player: PlayerNumber): Boolean = players.put(address, player) == null + override fun unassign(address: String) { + players.remove(address) + } + override fun unassignAll() = players.clear() + override fun getPlayer(address: String): PlayerNumber? = players[address] + override fun nextFreePlayer(): PlayerNumber? = null + override fun addressesForPlayer(player: PlayerNumber): List = players.filterValues { it == player }.keys.toList() + } + + private companion object { + const val ADDRESS = "3C:A9:AB:5E:6A:A6" + } +} diff --git a/docs/architecture.md b/docs/architecture.md index a02ccd55..29ff90b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,8 @@ shipped layouts in `preset/`), persistence (`data`) and the editor (`presentatio ## Button mapping -- **Keyed by `PlayerBody`** — a player plus the body they hold — so each player maps independently. +- **Keyed by `PlayerBody`** — a player plus the body they hold (a pair or Pro, a lone Joy-Con, or a + GameCube controller) — so each player maps independently. - **A target holds every source bound to it.** Dolphin ORs them into one expression; Eden binds one input per key, so it keeps the first the body can emit. - **A layout is a name for a set of bindings, never a stored reference.** Applying one copies out diff --git a/docs/protocol.md b/docs/protocol.md index ab060ca1..edb1c92a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -24,9 +24,10 @@ enables a subscription that never delivers data. Manufacturer data for ID `0x0553` carries: - **Bytes `[5..6]`** — little-endian product ID: `0x2067` left Joy-Con 2, `0x2066` right Joy-Con 2, - `0x2069` Switch 2 Pro Controller. The advertisement has no local name, so this is the only type - signal before input starts. Left and right are confirmed on hardware; the Pro value is community - reverse-engineering. + `0x2069` Switch 2 Pro Controller, `0x2073` NSO GameCube controller. The advertisement has no local + name, so this is the only type signal before input starts. Left, right and GameCube are confirmed + on hardware; the Pro value is community reverse-engineering. `JoyconAdvertisement.model` maps it to + a `ControllerModel`; the GameCube controller is held and decoded as a Pro Controller. - **Bytes `[10..15]`** — the bonded host's MAC. Holding SYNC zeroes it; a button press on a synced controller wakes it into a short reconnect advertisement carrying the address. The scanner only accepts a zeroed field, so stray presses on nearby synced Joy-Cons don't flash into the list. @@ -114,8 +115,8 @@ stream. | Temperature | 0x2E | int16 | °C = 25 + raw / 127 | | Accel X/Y/Z | 0x30–0x35 | int16 ×3 | 4096 = 1 g | | Gyro X/Y/Z | 0x36–0x3B | int16 ×3 | 48000 = 360 °/s | -| Trigger L | 0x3C | uint8 | analog | -| Trigger R | 0x3D | uint8 | analog | +| Trigger L | 0x3C | uint8 | analog, GameCube controller only ([below](#analog-triggers)) | +| Trigger R | 0x3D | uint8 | analog, GameCube controller only | A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick bytes are too. @@ -147,10 +148,49 @@ A left Joy-Con's right-stick bytes are garbage, and a right Joy-Con's left-stick The mask's low nibble lights P1–P4 solid (`0x01`, `0x02`, `0x04`, `0x08`), its high nibble flashes them (`0x10` … `0x80`). `0xF0`, all flashing, is the controller's default cycling animation. +### Analog triggers + +The NSO GameCube controller's L and R travel to a **first stop**, where analog travel ends, then +click through to a **second stop**, which sets the L/R button bit. Measured on three units with a +Retroid Pocket Nova, 2026-10-07: + +| | Raw | +|---|---| +| Factory zero (SPI `0x013140`, byte 0 left, byte 1 right; `0xFF` unset) | 31–35 | +| Rest | factory zero −2..+4 | +| First stop | 170–196, differing by side and unit | +| Second stop, bit set | 209–242 | + +- **The click is the bit, never an analog threshold.** In quick presses the bit can show with the + analog value at 155, and values up to 220 pass without it. +- `TriggerCalibrator` maps factory zero plus a 5-count dead zone to 0 and the first stop to 255. + Full starts at 170 and widens to a value **held** still for 5 packets without the click, up to 205, + so quick presses through to the click don't move it. Without a factory zero, the lowest value seen + stands in. +- Other drivers (Linux, SDL, BlueRetro) scale to ~225–232, the click point, which caps an unclicked + trigger at ~80%. + ## SPI reads -The controller keeps its factory data in SPI flash. The app wants one field: the **shell accent -colour**, 3 bytes RGB at `0x01301F` — the per-side colour (coral right, blue left) the UI paints each +The controller keeps its factory data in SPI flash. `FactoryCalibrationReads` picks what a model +needs at connect: + +| Field | Address | Length | Read for | +|---|---|---|---| +| Main stick calibration | `0x0130A8` | 9 | the GameCube controller (its main stick) | +| Right stick calibration | `0x0130E8` | 9 | the GameCube controller (its C-stick) | +| [Trigger zeros](#analog-triggers) | `0x013140` | 2 | the GameCube controller | +| Shell accent colour | `0x01301F` | 3 | every model, inside the DeviceInfo read below | + +**Stick calibration** is three X/Y pairs, each packed in 3 bytes as two 12-bit values (X = `b0 | +(b1 & 0x0F) << 8`, Y = `b1 >> 4 | b2 << 4`): the centre, the span above it, the span below it. All +`FF` means unset. Layout and addresses are from the Linux `hid-nintendo` Switch 2 driver (v16) and +SDL's `SDL_hidapi_switch2.c`, both USB; the same flash serves BLE. A user calibration made on a +Switch 2 lives at `0x1FC040` (magic `B2 A1`, then the same 9 bytes); the app doesn't read it, and +sources disagree on the right stick's address. Only the GameCube controller's values have been +checked against its sticks, so Joy-Con 2 and Pro Controllers aren't read and learn theirs instead. + +The **shell accent colour** is the per-side colour (coral right, blue left) the UI paints each controller with. Not the body colour at `0x013019`: that is the near-black shell, the same on both Joy-Cons. @@ -202,11 +242,18 @@ Treating 2048 as centre and half-span leaves full deflection at ~60% with a 4– `StickCalibrator` runs where packets are parsed, so the live display, gamepad and DSU all see corrected values: +- **A GameCube controller's factory calibration wins** once its [flash read](#spi-reads) answers, + about a second after connecting: it sets the centre and each direction's span. Everything below is + how every other controller calibrates, and the GameCube controller's fallback for unset flash and + the first packets before the reply. - **Centre** is learned from the first still window (30 samples), then frozen: a stick held at full deflection is perfectly still too. - **Each direction scales by its own span**, the centre/below/above triple the factory calibration stores. Spans are seeded just under the smallest travel measured (~1180 LSB), so full tilt works - from the first packet, and only ever widen. + from the first packet, and only ever widen, factory spans included. The NSO GameCube controller's + C-stick travels less, 1022–1204 from centre on three units (2026-10-07), so its spans are seeded + at 1000. +- Changing a controller's type resets the calibrator; the factory values are applied again. ## Console-protocol controllers diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index c99ab409..0cacf97e 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -30,8 +30,8 @@ Emulators address it by enumeration rank instead: with P1, P2 and P4, P4 is the | 0–1 | 15 button bits, then one padding bit | | 2 | hat switch (low nibble: 0 = N … 7 = NW, `0xF` = centre), padding | | 3–10 | left X, left Y, right X, right Y — int16, −32767..32767, Y inverted (up is negative) | -| 11 | left trigger / brake — digital, 0 or 255 | -| 12 | right trigger / accelerator — digital, 0 or 255 | +| 11 | left trigger / brake — ZL as 0 or 255, or analog L travel ([below](#analog-triggers)) | +| 12 | right trigger / accelerator — ZR as 0 or 255, or analog R travel | | 13 | overflow buttons, in a trailing vendor-defined collection: bit 0 GR, bit 1 C | | 14 | Android keys, in a trailing Consumer Control collection: bit 0 Home, bit 1 Back ([below](#android-keys)) | @@ -54,15 +54,17 @@ shift: | R | 7 | 103 `BUTTON_R1` | | | | - **Capture and GL** take `BUTTON_C` / `BUTTON_Z`, the two slots with no Switch equivalent. +- **ZL and ZR take the stick clicks** on a controller with analog triggers ([below](#analog-triggers)). - **GR and C overflow.** One gamepad collection carries 15 buttons — a 16th lands on `0x13F`, which no key layout names — and the Switch 2 controllers have 17. For a Button usage outside a pointer/joystick/gamepad collection Linux falls back to `BTN_MISC + n - 1`, which key layouts name `BUTTON_1..16`: GR is **188**, C is **189**. The collection is vendor-defined so nothing interprets it. Firmware that re-publishes pads (see [Device identity](#device-identity)) forwards only keys it knows, so it may drop these two. -- **Triggers are Brake (left) and Accelerator (right)**, never reversed. Android aliases - `AXIS_LTRIGGER` to `AXIS_BRAKE` and `AXIS_RTRIGGER` to `AXIS_GAS`, and re-publishing firmware - synthesises `L2`/`R2` from those axes — reversed, a ZR pull arrives as L2. +- **Triggers are Brake (left) and Accelerator (right)**, never reversed. On Android 14+ these arrive + as `AXIS_LTRIGGER`/`AXIS_RTRIGGER` with compat copies as `AXIS_BRAKE` (23) and `AXIS_GAS` (22); + Android 7–13 report only 23 and 22. Re-publishing firmware synthesises `L2`/`R2` from those axes — + reversed, a ZR pull arrives as L2. - **D-pad** is the hat (`AXIS_HAT_X` 15, `AXIS_HAT_Y` 16). Sticks are axes 0/1 (left) and 11/14 (right). Eden's config uses numeric keycodes; Dolphin's uses names (`Button L2` = ZL, `Select` = −, …). @@ -124,6 +126,23 @@ Left Joy-Con turned 90° counter-clockwise, right 90° clockwise, as on a Switch Motion is turned too, but for DSU only — see [dsu-motion.md](dsu-motion.md#sideways-joy-cons). +### Analog triggers + +A controller with analog triggers (`ControllerModel.hasAnalogTriggers`, the NSO GameCube controller) +drives bytes 11/12 with its calibrated L/R travel instead of ZL/ZR +([calibration](protocol.md#analog-triggers)). The click at the bottom stays on L/R +(`BUTTON_L1`/`BUTTON_R1`). + +ZL and Z (the ZR bit) move to the stick-click bits, `BUTTON_THUMBL`/`BUTTON_THUMBR`, which this +controller has no use for (`AnalogTriggerMapper`). They can't stay on `BUTTON_L2`/`BUTTON_R2`, which +can follow the trigger axes instead. Retroid Pocket Nova, Android 13, 2026-10-08: pulling R fired +Dolphin's `Button R2`, and pressing Z fired nothing. The Nova's L2/R2 setting wasn't the cause. + +For such a player, Dolphin setup adds ``Triggers/L-Analog = `Axis 23+` `` and +``Triggers/R-Analog = `Axis 22+` ``. Dolphin names Android axes by number, and a trigger has only a `+` +half. Those two ids exist on every Android version the app supports. `Triggers/L`/`R` stay on the +click: while it is held, Dolphin reports the analog value as full. + ## Emulator config - **Dolphin's names** for each Android keycode and hat direction (`DolphinGcpadConfig`) are diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 8eaa8412..4f4b9322 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.joegec.joycon2android.assignment.presentation.R import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -51,6 +52,7 @@ fun AssignmentPanel( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, + onSetType: (String, Side?) -> Unit, modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(Dimens.cardCorner) @@ -71,7 +73,7 @@ fun AssignmentPanel( ) unassigned.forEach { joycon -> - JoyconAssignmentRow(joycon, players, onAssign, onDisconnect) + JoyconAssignmentRow(joycon, players, onAssign, onDisconnect, onSetType) } Text( @@ -89,12 +91,14 @@ private fun JoyconAssignmentRow( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, + onSetType: (String, Side?) -> Unit, ) { val sideColor = joyconBorderColor(joycon.accentColor, JoyconDefaultColor) val sideLabel = when (joycon.side) { Side.LEFT -> stringResource(R.string.side_left) Side.RIGHT -> stringResource(R.string.side_right) - Side.PRO -> stringResource(R.string.side_pro) + Side.PRO -> if (joycon.model == ControllerModel.GAMECUBE) stringResource(R.string.side_gamecube) + else stringResource(R.string.side_pro) Side.UNKNOWN -> joycon.deviceName } @@ -158,6 +162,11 @@ private fun JoyconAssignmentRow( ) } } + ControllerTypeDropdown( + joycon, + onSetType = { side -> onSetType(joycon.address, side) }, + modifier = Modifier.padding(horizontal = Dimens.cardPadding), + ) PlayerSelection(joycon, players, onAssign) } } diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt new file mode 100644 index 00000000..0d2f0635 --- /dev/null +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.assignment.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.OptionDropdown + +private const val AUTO_ID = "AUTO" +private val choosableSides = listOf(Side.PRO, Side.LEFT, Side.RIGHT) + +@Composable +internal fun ControllerTypeDropdown( + joycon: ConnectedJoycon, + onSetType: (Side?) -> Unit, + modifier: Modifier = Modifier, +) { + val detected = joycon.model.label() + val auto = stringResource(R.string.type_auto, detected) + val options = listOf(DropdownOption(AUTO_ID, auto)) + + choosableSides.map { DropdownOption(it.name, it.typeLabel()) } + OptionDropdown( + options = options, + selectedId = joycon.typeOverride?.name ?: AUTO_ID, + label = joycon.typeOverride?.typeLabel() ?: auto, + subLabel = stringResource(R.string.type_hint), + onSelect = { id -> onSetType(if (id == AUTO_ID) null else Side.valueOf(id)) }, + modifier = modifier, + ) +} diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt new file mode 100644 index 00000000..2b115fb2 --- /dev/null +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt @@ -0,0 +1,23 @@ +package com.joegec.joycon2android.assignment.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.Side + +@Composable +internal fun ControllerModel.label(): String = when (this) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.type_joycon_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.type_joycon_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.type_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.type_gamecube) + ControllerModel.UNKNOWN -> stringResource(R.string.type_unknown) +} + +@Composable +internal fun Side.typeLabel(): String = when (this) { + Side.LEFT -> stringResource(R.string.type_joycon_left) + Side.RIGHT -> stringResource(R.string.type_joycon_right) + Side.PRO -> stringResource(R.string.type_pro) + Side.UNKNOWN -> stringResource(R.string.type_unknown) +} diff --git a/feature/assignment/presentation/src/main/res/values/strings.xml b/feature/assignment/presentation/src/main/res/values/strings.xml index 8bb8164e..b1fc5795 100644 --- a/feature/assignment/presentation/src/main/res/values/strings.xml +++ b/feature/assignment/presentation/src/main/res/values/strings.xml @@ -3,6 +3,14 @@ Left Right Pro + GameCube + Auto: %1$s + Controller type + Left Joy-Con + Right Joy-Con + Pro Controller + GameCube controller + not recognised CONNECTING… CONNECTION LOST · PRESS SYNC Unassigned Controllers diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt index 18230547..7ba4dbfc 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/BleScanner.kt @@ -10,7 +10,7 @@ import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel @SuppressLint("MissingPermission") class BleScanner(context: Context) { @@ -18,11 +18,10 @@ class BleScanner(context: Context) { companion object { private const val TAG = "Joycon2" private const val NINTENDO_MANUFACTURER_ID = 0x0553 - private const val SIDE_TYPE_INDEX = 5 private const val SCAN_TIMEOUT_MS = 15_000L } - var onDeviceFound: ((ScanResult, Side, String) -> Unit)? = null + var onDeviceFound: ((ScanResult, ControllerModel, String) -> Unit)? = null var onScanFailed: ((Int) -> Unit)? = null var onTimeout: (() -> Unit)? = null @@ -69,8 +68,10 @@ class BleScanner(context: Context) { val name = result.device.name ?: result.scanRecord?.deviceName ?: "Joy-Con 2" - val side = detectSide(result, name) - onDeviceFound?.invoke(result, side, name) + val model = modelFromName(name) + ?: JoyconAdvertisement.model(manufacturerData) + ?: ControllerModel.UNKNOWN + onDeviceFound?.invoke(result, model, name) } override fun onScanFailed(errorCode: Int) { @@ -106,29 +107,10 @@ class BleScanner(context: Context) { .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() - private fun detectSide(result: ScanResult, name: String): Side { - sideFromName(name)?.let { return it } - sideFromManufacturerData(result)?.let { return it } - return Side.UNKNOWN - } - - private fun sideFromName(name: String): Side? = when { - name.contains("(L)") || name.contains("Left") -> Side.LEFT - name.contains("(R)") || name.contains("Right") -> Side.RIGHT - name.contains("Pro") -> Side.PRO + private fun modelFromName(name: String): ControllerModel? = when { + name.contains("(L)") || name.contains("Left") -> ControllerModel.JOYCON_LEFT + name.contains("(R)") || name.contains("Right") -> ControllerModel.JOYCON_RIGHT + name.contains("Pro") -> ControllerModel.PRO_CONTROLLER else -> null } - - /** The product ID's low byte: docs/protocol.md#advertising */ - private fun sideFromManufacturerData(result: ScanResult): Side? { - val mfgData = result.scanRecord - ?.getManufacturerSpecificData(NINTENDO_MANUFACTURER_ID) ?: return null - if (mfgData.size <= SIDE_TYPE_INDEX) return null - return when (mfgData[SIDE_TYPE_INDEX].toInt() and 0xFF) { - 0x67 -> Side.LEFT - 0x66 -> Side.RIGHT - 0x69 -> Side.PRO - else -> null - } - } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt index 5ebf03e1..890eac9b 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt @@ -16,11 +16,13 @@ import java.util.UUID internal class CommonSession( private val gatt: BluetoothGatt, private val channel: CommonChannel, - private val side: Side, + private val side: () -> Side, private val ops: GattOpQueue, + private val calibrationReads: List, initialLedBitmask: Byte, private val onInput: (JoyconInput) -> Unit, private val onAccentColor: (Int) -> Unit, + private val onCommandReply: (ByteArray) -> Unit, private val onReady: () -> Unit, ) : ControllerProtocol { companion object { @@ -51,9 +53,10 @@ internal class CommonSession( ops.enqueue(INIT_GAP_MS) { write(FEATURES_INIT) } ops.enqueue(INIT_GAP_MS) { write(FEATURES_ENABLE) } ops.enqueue(INIT_GAP_MS) { write(SPI_READ_COLOR) } + calibrationReads.forEach { read -> ops.enqueue(INIT_GAP_MS) { write(read) } } ops.enqueue { ready = true - Log.i(TAG, "[$side] Init sequence complete") + Log.i(TAG, "[${side()}] Init sequence complete") onReady() false // no GATT op — advance immediately } @@ -86,11 +89,11 @@ internal class CommonSession( } else { BluetoothGatt.CONNECTION_PRIORITY_BALANCED } - Log.i(TAG, "[$side] Connection priority high=$enabled accepted=${gatt.requestConnectionPriority(priority)}") + Log.i(TAG, "[${side()}] Connection priority high=$enabled accepted=${gatt.requestConnectionPriority(priority)}") } private fun onReport(report: ByteArray) { - PacketParser.parse(report, side)?.let(onInput) + PacketParser.parse(report, side())?.let(onInput) if (!ledSentAfterFirstPacket && ready) { ledSentAfterFirstPacket = true sendPlayerLed() @@ -98,9 +101,10 @@ internal class CommonSession( } private fun onCommandResponse(value: ByteArray) { - Log.d(TAG, "[$side] Cmd response: ${value.joinToString(" ") { "%02X".format(it) }}") + Log.d(TAG, "[${side()}] Cmd response: ${value.joinToString(" ") { "%02X".format(it) }}") + onCommandReply(value) SpiColorParser.parseAccentColor(value)?.let { color -> - Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") + Log.i(TAG, "[${side()}] Accent color: #${"%06X".format(color)}") onAccentColor(color) } } @@ -108,7 +112,7 @@ internal class CommonSession( private fun sendPlayerLed() { ops.enqueue { val cmd = ControllerCommands.playerLed(ledBitmask) - Log.i(TAG, "[$side] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") + Log.i(TAG, "[${side()}] Sending LED cmd: ${cmd.joinToString(" ") { "%02X".format(it) }}") write(cmd) } } @@ -118,11 +122,11 @@ internal class CommonSession( gatt.setCharacteristicNotification(characteristic, true) val cccd = characteristic.getDescriptor(CommonChannel.CCCD) if (cccd == null) { - Log.w(TAG, "[$side] CMD_RESPONSE char has no CCCD descriptor") + Log.w(TAG, "[${side()}] CMD_RESPONSE char has no CCCD descriptor") return } ops.enqueue { - Log.d(TAG, "[$side] Writing CMD_RESPONSE CCCD") + Log.d(TAG, "[${side()}] Writing CMD_RESPONSE CCCD") writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } } @@ -131,11 +135,11 @@ internal class CommonSession( gatt.setCharacteristicNotification(channel.input, true) val cccd = channel.input.getDescriptor(CommonChannel.CCCD) if (cccd == null) { - Log.e(TAG, "[$side] NOTIFY char has no CCCD descriptor — notifications won't work!") + Log.e(TAG, "[${side()}] NOTIFY char has no CCCD descriptor — notifications won't work!") return } ops.enqueue { - Log.d(TAG, "[$side] Writing NOTIFY CCCD") + Log.d(TAG, "[${side()}] Writing NOTIFY CCCD") writeDescriptor(cccd, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index d843f8a3..688fdefb 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.bluetooth.le.ScanResult import android.content.Context import com.joegec.joycon2android.connection.console.SecurityRequestReceiver +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap @@ -24,7 +25,13 @@ class ConnectionPool(private val context: Context) { val size: Int get() = connections.size /** Null for an address already in the pool; one awaiting its controller's return reconnects instead. */ - fun connect(result: ScanResult, side: Side, name: String, highPriority: Boolean): JoyconConnection? { + fun connect( + result: ScanResult, + model: ControllerModel, + typeOverride: Side?, + name: String, + highPriority: Boolean, + ): JoyconConnection? { val address = result.device.address connections[address]?.let { it.reconnect(result.device) @@ -32,7 +39,8 @@ class ConnectionPool(private val context: Context) { } val connection = JoyconConnection( context, - side, + model, + typeOverride, name, onReady = { reassertOtherPriorities(address) }, ) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt new file mode 100644 index 00000000..afbf1db5 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.connection + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.joegec.joycon2android.model.Side +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.controllerTypeDataStore: DataStore by preferencesDataStore(name = "controller_types") + +class ControllerTypePreferencesDataStore(context: Context) : ControllerTypePreferences { + + private val dataStore = context.applicationContext.controllerTypeDataStore + + override val overrides: Flow> = dataStore.data.map { prefs -> + prefs.asMap().mapNotNull { (key, value) -> + val side = runCatching { Side.valueOf(value as String) }.getOrNull() ?: return@mapNotNull null + key.name to side + }.toMap() + } + + override suspend fun set(address: String, side: Side?) { + dataStore.edit { prefs -> + val key = stringPreferencesKey(address) + if (side == null) prefs.remove(key) else prefs[key] = side.name + } + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt new file mode 100644 index 00000000..516d6b60 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt @@ -0,0 +1,32 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel + +/** docs/protocol.md#spi-reads */ +object FactoryCalibrationReads { + + // Only the GameCube controller's flash has been checked against its sticks and triggers. + fun commands(model: ControllerModel): List = + if (model != ControllerModel.GAMECUBE) { + emptyList() + } else { + listOf( + spiRead(SpiStickCalibration.MAIN_ADDRESS, SpiStickCalibration.LENGTH), + spiRead(SpiStickCalibration.RIGHT_ADDRESS, SpiStickCalibration.LENGTH), + spiRead(SpiTriggerZeros.ADDRESS, SpiTriggerZeros.LENGTH), + ) + } + + // Transport 0x00, as the colour read sends it. + private fun spiRead(address: Int, length: Int) = ControllerCommands.spiRead(length, address, ControllerCommands.USB) + + /** Null when [reply] adds nothing: another read, or flash left unset. */ + fun update(factory: FactoryCalibration, reply: ByteArray): FactoryCalibration? { + SpiStickCalibration.parse(reply, SpiStickCalibration.MAIN_ADDRESS)?.let { return factory.copy(mainStick = it) } + SpiStickCalibration.parse(reply, SpiStickCalibration.RIGHT_ADDRESS)?.let { return factory.copy(rightStick = it) } + SpiTriggerZeros.parse(reply)?.let { (left, right) -> + return factory.copy(triggerZeroLeft = left, triggerZeroRight = right) + } + return null + } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index 1b6e4c20..c55e76f8 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -5,18 +5,22 @@ import android.bluetooth.le.ScanResult import android.content.Context import com.joegec.joycon2android.connection.data.R import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.Side import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class Joycon2Manager( private val context: Context, private val scope: CoroutineScope, + private val typePreferences: ControllerTypePreferences, ) : ControllerRepository, ConnectionPriorityRepository { companion object { @@ -26,6 +30,7 @@ class Joycon2Manager( private val scanner = BleScanner(context) private val pool = ConnectionPool(context) private val connectionJobs = mutableMapOf() + private val typeOverrides = typePreferences.overrides.stateIn(scope, SharingStarted.Eagerly, emptyMap()) @Volatile private var highPriority = false @@ -85,6 +90,13 @@ class Joycon2Manager( if (player != null) connection.setPlayerLed(player) else connection.clearPlayerLed() } + override fun setControllerType(address: String, side: Side?) { + scope.launch { typePreferences.set(address, side) } + val connection = pool.get(address) ?: return + connection.overrideType(side) + rebuildControllers() + } + override fun setHighPriority(enabled: Boolean) { highPriority = enabled pool.setHighPriority(enabled) @@ -115,6 +127,8 @@ class Joycon2Manager( ConnectedJoycon( address = address, side = connection.side, + model = connection.model, + typeOverride = connection.typeOverride, deviceName = connection.deviceName, connectionState = connection.connectionState.value, input = connection.input.value, @@ -123,14 +137,15 @@ class Joycon2Manager( } } - private fun onDeviceFound(result: ScanResult, side: Side, name: String) { + private fun onDeviceFound(result: ScanResult, model: ControllerModel, name: String) { if (pool.size >= MAX_CONNECTIONS) { scanner.stop() _scanning.value = false return } - pool.connect(result, side, name, highPriority) ?: return + val typeOverride = typeOverrides.value[result.device.address] + pool.connect(result, model, typeOverride, name, highPriority) ?: return onPoolChanged() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt index d19a3461..f49af6de 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisement.kt @@ -1,11 +1,26 @@ package com.joegec.joycon2android.connection -/** Bonded-host MAC at bytes [10..15], zeroed while pairing: docs/protocol.md#advertising */ +import com.joegec.joycon2android.model.ControllerModel + +/** Manufacturer data for Nintendo's ID: docs/protocol.md#advertising */ object JoyconAdvertisement { + private const val PRODUCT_ID_LOW = 5 private const val HOST_MAC_OFFSET = 10 private const val HOST_MAC_LENGTH = 6 + fun model(manufacturerData: ByteArray): ControllerModel? { + if (manufacturerData.size <= PRODUCT_ID_LOW) return null + return when (manufacturerData[PRODUCT_ID_LOW].toInt() and 0xFF) { + 0x67 -> ControllerModel.JOYCON_LEFT + 0x66 -> ControllerModel.JOYCON_RIGHT + 0x69 -> ControllerModel.PRO_CONTROLLER + 0x73 -> ControllerModel.GAMECUBE + else -> null + } + } + + // Bonded-host MAC, zeroed while pairing. fun isPairing(manufacturerData: ByteArray): Boolean { if (manufacturerData.size < HOST_MAC_OFFSET + HOST_MAC_LENGTH) return true return (HOST_MAC_OFFSET until HOST_MAC_OFFSET + HOST_MAC_LENGTH) diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index fba51189..dff7ce77 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -13,6 +13,7 @@ import android.os.Looper import android.util.Log import com.joegec.joycon2android.connection.console.ConsoleChannel import com.joegec.joycon2android.connection.console.ConsoleSession +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.JoyconConnectionState import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -25,7 +26,8 @@ import java.util.UUID @SuppressLint("MissingPermission") class JoyconConnection( private val context: Context, - val side: Side, + val model: ControllerModel, + typeOverride: Side?, val deviceName: String, private val onReady: () -> Unit, private val onDisconnected: (() -> Unit)? = null, @@ -41,6 +43,11 @@ class JoyconConnection( private const val RETURN_WINDOW_MS = 120_000L } + @Volatile var typeOverride: Side? = typeOverride + private set + @Volatile var side: Side = typeOverride ?: model.defaultSide + private set + private val _connectionState = MutableStateFlow( JoyconConnectionState(connecting = true, deviceName = deviceName) ) @@ -52,7 +59,8 @@ class JoyconConnection( private val mainHandler = Handler(Looper.getMainLooper()) private val ops = GattOpQueue() private val returnWindow = ReturnWindow(RETURN_WINDOW_MS, MainThreadScheduler(), ::giveUpOnReturn) - private val stickCalibrator = StickCalibrator() + @Volatile private var calibrator = InputCalibrator(model) + private var factory = FactoryCalibration() private var gatt: BluetoothGatt? = null private var device: BluetoothDevice? = null private var common: CommonSession? = null @@ -101,6 +109,13 @@ class JoyconConnection( if (initComplete) protocol?.setHighPriority(enabled) } + // A new side reads the sticks from other offsets, so their learned centres no longer apply. + fun overrideType(side: Side?) { + typeOverride = side + this.side = side ?: model.defaultSide + calibrator = InputCalibrator(model).apply { useFactory(factory) } + } + fun setPlayerLed(player: PlayerNumber) { assignedPlayer = player protocol?.setPlayerLed(player.ledBitmask) @@ -188,11 +203,13 @@ class JoyconConnection( common = CommonSession( gatt = g, channel = channel, - side = side, + side = { side }, ops = ops, + calibrationReads = FactoryCalibrationReads.commands(model), initialLedBitmask = assignedPlayer?.ledBitmask ?: ControllerCommands.LED_ALL_ON, onInput = ::publishInput, onAccentColor = ::publishAccentColor, + onCommandReply = ::applyFactoryCalibration, onReady = { onProtocolReady() mainHandler.postDelayed({ fallBackToConsoleIfSilent(g) }, CONSOLE_FALLBACK_MS) @@ -336,7 +353,14 @@ class JoyconConnection( } private fun publishInput(input: JoyconInput) { - _input.value = stickCalibrator.calibrate(input) + _input.value = calibrator.calibrate(input) + } + + private fun applyFactoryCalibration(reply: ByteArray) { + FactoryCalibrationReads.update(factory, reply)?.let { + factory = it + calibrator.useFactory(it) + } } private fun publishAccentColor(color: Int) { diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt index 2d5bf264..14adbc3e 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/PacketParser.kt @@ -10,6 +10,8 @@ import java.nio.ByteOrder object PacketParser { private const val MIN_PACKET_SIZE = 0x3B + private const val TRIGGER_LEFT = 0x3C + private const val TRIGGER_RIGHT = 0x3D // docs/protocol.md#packet-layout. The back-paddle byte (0x07) is folded into bits 32..39. private val buttonMasks: List> = listOf( @@ -48,6 +50,8 @@ object PacketParser { gyroY = bb.getShort(0x38).toInt(), gyroZ = bb.getShort(0x3A).toInt(), battery = BatteryCharge.fromVolts((bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f), + triggerLeft = uint8OrZero(data, TRIGGER_LEFT), + triggerRight = uint8OrZero(data, TRIGGER_RIGHT), ) } @@ -81,6 +85,9 @@ object PacketParser { return (v and 0xFFF) to ((v shr 12) and 0xFFF) } + private fun uint8OrZero(data: ByteArray, offset: Int): Int = + if (offset < data.size) data[offset].toInt() and 0xFF else 0 + private fun decodeUint24(data: ByteArray, offset: Int): Int = (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) or diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt index e53170c6..67130ed0 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiColorParser.kt @@ -3,33 +3,11 @@ package com.joegec.joycon2android.connection /** Pulls the shell accent colour out of an SPI-flash read reply: docs/protocol.md#spi-reads. */ object SpiColorParser { - private const val REPORT_TYPE_SPI = 0x02 - private const val COMMAND_SPI_READ = 0x04 - private const val ADDRESS_OFFSET = 0x0C - private const val DATA_OFFSET = 0x10 - const val ACCENT_COLOR_ADDRESS = 0x01301F /** Packed 0xRRGGBB, or null if this is not an SPI read or does not span the accent address. */ fun parseAccentColor(reply: ByteArray): Int? { - if (reply.size < DATA_OFFSET) return null - if (reply[0].toInt() and 0xFF != REPORT_TYPE_SPI) return null - if (reply[3].toInt() and 0xFF != COMMAND_SPI_READ) return null - - val baseAddress = readLeUInt32(reply, ADDRESS_OFFSET) - val colorOffset = DATA_OFFSET + (ACCENT_COLOR_ADDRESS - baseAddress) - if (colorOffset < DATA_OFFSET || colorOffset + 3 > reply.size) return null - - val r = reply[colorOffset].toInt() and 0xFF - val g = reply[colorOffset + 1].toInt() and 0xFF - val b = reply[colorOffset + 2].toInt() and 0xFF - return (r shl 16) or (g shl 8) or b - } - - private fun readLeUInt32(data: ByteArray, offset: Int): Int { - return (data[offset].toInt() and 0xFF) or - ((data[offset + 1].toInt() and 0xFF) shl 8) or - ((data[offset + 2].toInt() and 0xFF) shl 16) or - ((data[offset + 3].toInt() and 0xFF) shl 24) + val rgb = SpiReadReply.bytesAt(reply, ACCENT_COLOR_ADDRESS, 3) ?: return null + return ((rgb[0].toInt() and 0xFF) shl 16) or ((rgb[1].toInt() and 0xFF) shl 8) or (rgb[2].toInt() and 0xFF) } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt new file mode 100644 index 00000000..82863164 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadReply.kt @@ -0,0 +1,27 @@ +package com.joegec.joycon2android.connection + +/** docs/protocol.md#spi-reads */ +object SpiReadReply { + + private const val REPORT_TYPE_SPI = 0x02 + private const val COMMAND_SPI_READ = 0x04 + private const val ADDRESS_OFFSET = 0x0C + private const val DATA_OFFSET = 0x10 + + /** The [length] bytes at [address], or null if [reply] is not an SPI read that spans them. */ + fun bytesAt(reply: ByteArray, address: Int, length: Int): ByteArray? { + if (reply.size < DATA_OFFSET) return null + if (reply[0].toInt() and 0xFF != REPORT_TYPE_SPI) return null + if (reply[3].toInt() and 0xFF != COMMAND_SPI_READ) return null + + val start = DATA_OFFSET + (address - readLeUInt32(reply, ADDRESS_OFFSET)) + if (start < DATA_OFFSET || start + length > reply.size) return null + return reply.copyOfRange(start, start + length) + } + + private fun readLeUInt32(data: ByteArray, offset: Int): Int = + (data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8) or + ((data[offset + 2].toInt() and 0xFF) shl 16) or + ((data[offset + 3].toInt() and 0xFF) shl 24) +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt new file mode 100644 index 00000000..f55a95bc --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiStickCalibration.kt @@ -0,0 +1,33 @@ +package com.joegec.joycon2android.connection + +/** 9 bytes: centre, span above, span below, each an X/Y pair packed in 12 bits. docs/protocol.md#spi-reads */ +object SpiStickCalibration { + + const val MAIN_ADDRESS = 0x0130A8 + const val RIGHT_ADDRESS = 0x0130E8 + const val LENGTH = 9 + + // Unset flash reads 0xFFF; anything outside these is not a stick this app knows. + private val CENTRE_RANGE = 1024..3072 + private val SPAN_RANGE = 500..2000 + + /** Null when [reply] isn't a read of [address], or the flash there is unset or implausible. */ + fun parse(reply: ByteArray, address: Int): StickCalibration? { + val bytes = SpiReadReply.bytesAt(reply, address, LENGTH) ?: return null + val (centreX, centreY) = pair(bytes, 0) + val (aboveX, aboveY) = pair(bytes, 3) + val (belowX, belowY) = pair(bytes, 6) + return StickCalibration(centreX, centreY, aboveX, aboveY, belowX, belowY).takeIf(::plausible) + } + + private fun pair(bytes: ByteArray, offset: Int): Pair { + val b0 = bytes[offset].toInt() and 0xFF + val b1 = bytes[offset + 1].toInt() and 0xFF + val b2 = bytes[offset + 2].toInt() and 0xFF + return (b0 or ((b1 and 0x0F) shl 8)) to ((b1 shr 4) or (b2 shl 4)) + } + + private fun plausible(c: StickCalibration): Boolean = + c.centreX in CENTRE_RANGE && c.centreY in CENTRE_RANGE && + listOf(c.aboveX, c.aboveY, c.belowX, c.belowY).all { it in SPAN_RANGE } +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt new file mode 100644 index 00000000..d2e0c806 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiTriggerZeros.kt @@ -0,0 +1,17 @@ +package com.joegec.joycon2android.connection + +/** Factory trigger zero points, left then right: docs/protocol.md#analog-triggers */ +object SpiTriggerZeros { + + const val ADDRESS = 0x013140 + const val LENGTH = 2 + private const val UNSET = 0xFF + + /** Null when [reply] isn't this read; a null zero when flash leaves it unset. */ + fun parse(reply: ByteArray): Pair? { + val zeros = SpiReadReply.bytesAt(reply, ADDRESS, LENGTH) ?: return null + return zero(zeros[0]) to zero(zeros[1]) + } + + private fun zero(byte: Byte): Int? = (byte.toInt() and 0xFF).takeIf { it != UNSET } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt index 53dd68de..46d31e20 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/JoyconAdvertisementTest.kt @@ -1,6 +1,9 @@ package com.joegec.joycon2android.connection +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -8,6 +11,8 @@ class JoyconAdvertisementTest { // Captured from a right Joy-Con 2: SYNC held (pairing) vs A pressed (wake) private val pairing = bytes("01 00 03 7E 05 66 20 00 01 00 00 00 00 00 00 00 0F 00 00 00 00 00 00 00") + // NSO GameCube controller, SYNC held (Retroid Pocket Nova capture, 2026-10-07) + private val gameCube = bytes("01 00 03 7E 05 73 20 00 01 00 00 00 00 00 00 00 0F 00 00 00 00 00 00 00") private val wake = bytes("01 00 03 7E 05 66 20 00 01 00 09 A7 9A 55 E2 98 0F 00 00 00 00 00 00 00") @Test @@ -25,6 +30,18 @@ class JoyconAdvertisementTest { assertTrue(JoyconAdvertisement.isPairing(ByteArray(4))) } + @Test + fun `the product ID names the model`() { + assertEquals(ControllerModel.JOYCON_RIGHT, JoyconAdvertisement.model(pairing)) + assertEquals(ControllerModel.GAMECUBE, JoyconAdvertisement.model(gameCube)) + } + + @Test + fun `an unknown product ID or short data names no model`() { + assertNull(JoyconAdvertisement.model(bytes("01 00 03 7E 05 99 20"))) + assertNull(JoyconAdvertisement.model(ByteArray(4))) + } + private fun bytes(hex: String): ByteArray = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() } diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt index af47f7a4..4115a6c1 100644 --- a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/PacketParserTest.kt @@ -64,4 +64,22 @@ class PacketParserTest { assertTrue(JoyconButton.GR.id !in input.pressed) assertTrue(JoyconButton.GL.id !in input.pressed) } + + @Test + fun `analog triggers decode raw from bytes 0x3C and 0x3D`() { + val data = ByteArray(63).apply { + this[0x3C] = 0xEA.toByte() + this[0x3D] = 0x22 + } + val input = PacketParser.parse(data, Side.PRO)!! + assertEquals(0xEA, input.triggerLeft) + assertEquals(0x22, input.triggerRight) + } + + @Test + fun `triggers read zero from a packet too short to carry them`() { + val input = PacketParser.parse(ByteArray(0x3C), Side.PRO)!! + assertEquals(0, input.triggerLeft) + assertEquals(0, input.triggerRight) + } } diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt new file mode 100644 index 00000000..e3b13e11 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiStickCalibrationTest.kt @@ -0,0 +1,64 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SpiStickCalibrationTest { + + // A GameCube controller's main stick as captured: rest 2073,2060, travel 835..3297 x 811..3258. + private val mainStick = StickCalibration( + centreX = 2073, centreY = 2060, aboveX = 1224, aboveY = 1198, belowX = 1238, belowY = 1249, + ) + + @Test + fun `the nine bytes decode as centre, span above and span below`() { + assertEquals(mainStick, SpiStickCalibration.parse(reply(SpiStickCalibration.MAIN_ADDRESS, mainStick), SpiStickCalibration.MAIN_ADDRESS)) + } + + @Test + fun `unset flash reads as no calibration`() { + val unset = reply(SpiStickCalibration.MAIN_ADDRESS, ByteArray(9) { 0xFF.toByte() }) + assertNull(SpiStickCalibration.parse(unset, SpiStickCalibration.MAIN_ADDRESS)) + } + + @Test + fun `a reply for the other stick is not this read`() { + assertNull(SpiStickCalibration.parse(reply(SpiStickCalibration.RIGHT_ADDRESS, mainStick), SpiStickCalibration.MAIN_ADDRESS)) + } + + @Test + fun `only a gamecube controller reads its flash, both sticks and the trigger zeros`() { + assertEquals(0, FactoryCalibrationReads.commands(ControllerModel.JOYCON_RIGHT).size) + assertEquals(0, FactoryCalibrationReads.commands(ControllerModel.PRO_CONTROLLER).size) + assertEquals(3, FactoryCalibrationReads.commands(ControllerModel.GAMECUBE).size) + } + + @Test + fun `each reply fills its own part of the factory calibration`() { + val withMain = FactoryCalibrationReads.update(FactoryCalibration(), reply(SpiStickCalibration.MAIN_ADDRESS, mainStick))!! + val withRight = FactoryCalibrationReads.update(withMain, reply(SpiStickCalibration.RIGHT_ADDRESS, mainStick))!! + assertEquals(mainStick, withRight.mainStick) + assertEquals(mainStick, withRight.rightStick) + assertNull(withRight.triggerZeroLeft) + } + + @Test + fun `a reply carrying nothing it reads adds nothing`() { + assertNull(FactoryCalibrationReads.update(FactoryCalibration(), reply(0x013000, ByteArray(9)))) + } + + private fun reply(address: Int, calibration: StickCalibration): ByteArray = reply( + address, + pack(calibration.centreX, calibration.centreY) + pack(calibration.aboveX, calibration.aboveY) + + pack(calibration.belowX, calibration.belowY), + ) + + private fun reply(address: Int, data: ByteArray): ByteArray = + byteArrayOf(0x02, 0x01, 0x00, 0x04, 0x10, 0x78, 0x00, 0x00, data.size.toByte(), 0x00, 0x00, 0x00) + + byteArrayOf(address.toByte(), (address shr 8).toByte(), (address shr 16).toByte(), 0x00) + data + + private fun pack(x: Int, y: Int): ByteArray = + byteArrayOf(x.toByte(), ((x shr 8) and 0x0F or ((y and 0x0F) shl 4)).toByte(), (y shr 4).toByte()) +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt new file mode 100644 index 00000000..39ce14d3 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiTriggerZerosTest.kt @@ -0,0 +1,36 @@ +package com.joegec.joycon2android.connection + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SpiTriggerZerosTest { + + // Reply from an NSO GameCube controller to a 0x40-byte read at 0x013140 (Retroid Pocket Nova, 2026-10-07) + private val reply = bytes("02 01 00 04 10 78 00 00 40 00 00 00 40 31 01 00 21 20" + " FF".repeat(62)) + + @Test + fun `the first two bytes are the left and right zero points`() { + assertEquals(33 to 32, SpiTriggerZeros.parse(reply)) + } + + @Test + fun `an unset zero reads as null`() { + val unset = reply.copyOf().apply { this[16] = 0xFF.toByte() } + assertEquals(null to 32, SpiTriggerZeros.parse(unset)) + } + + @Test + fun `a reply for another address is not this read`() { + val colour = reply.copyOf().apply { this[13] = 0x30 } + assertNull(SpiTriggerZeros.parse(colour)) + } + + @Test + fun `the colour parser still finds the accent in a device info block`() { + val info = bytes("02 01 00 04 10 78 00 00 40 00 00 00 00 30 01 00" + " 00".repeat(0x1F) + " 12 34 56" + " 00".repeat(0x1D)) + assertEquals(0x123456, SpiColorParser.parseAccentColor(info)) + } + + private fun bytes(hex: String): ByteArray = hex.trim().split(" ").map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt index 223ebb84..3238f0be 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber +import com.joegec.joycon2android.model.Side import kotlinx.coroutines.flow.StateFlow /** [controllers] re-emits on every input or state change. */ @@ -15,5 +16,8 @@ interface ControllerRepository { fun disconnect(address: String) fun disconnectAll() fun setPlayerLed(address: String, player: PlayerNumber?) + + /** Null returns the controller to the type its advertisement named. */ + fun setControllerType(address: String, side: Side?) fun emitError(message: String) } diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt new file mode 100644 index 00000000..1f789f2e --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.Side +import kotlinx.coroutines.flow.Flow + +interface ControllerTypePreferences { + val overrides: Flow> + suspend fun set(address: String, side: Side?) +} diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt new file mode 100644 index 00000000..b618e45f --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibration.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.connection + +/** What the controller's SPI flash holds; null where it is unread or unset. docs/protocol.md#spi-reads */ +data class FactoryCalibration( + val mainStick: StickCalibration? = null, + val rightStick: StickCalibration? = null, + val triggerZeroLeft: Int? = null, + val triggerZeroRight: Int? = null, +) diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt new file mode 100644 index 00000000..e7650345 --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/InputCalibrator.kt @@ -0,0 +1,29 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.JoyconInput + +class InputCalibrator(private val model: ControllerModel) { + + private val sticks = + if (model == ControllerModel.GAMECUBE) StickCalibrator(rightSeedHalfSpan = C_STICK_SEED_HALF_SPAN) + else StickCalibrator() + private val triggers = TriggerCalibrator() + + fun useFactory(factory: FactoryCalibration) { + sticks.useFactory(factory.mainStick, factory.rightStick) + triggers.setFactoryZeros(factory.triggerZeroLeft, factory.triggerZeroRight) + } + + // Bytes 0x3C/0x3D carry nothing meaningful on a controller without analog triggers. + fun calibrate(input: JoyconInput): JoyconInput { + val calibrated = sticks.calibrate(input) + return if (model.hasAnalogTriggers) triggers.calibrate(calibrated) + else calibrated.copy(triggerLeft = 0, triggerRight = 0) + } + + private companion object { + // Just under the shortest C-stick travel measured, 1022 (three units, 2026-10-07). + const val C_STICK_SEED_HALF_SPAN = 1000 + } +} diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt new file mode 100644 index 00000000..1ff3210b --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibration.kt @@ -0,0 +1,11 @@ +package com.joegec.joycon2android.connection + +/** Raw 12-bit units: the resting centre, then how far the stick travels above and below it per axis. */ +data class StickCalibration( + val centreX: Int, + val centreY: Int, + val aboveX: Int, + val aboveY: Int, + val belowX: Int, + val belowY: Int, +) diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt index 5182f972..dac70438 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/StickCalibrator.kt @@ -7,12 +7,24 @@ class StickCalibrator( restWindowSize: Int = DEFAULT_REST_WINDOW, maxRestSpreadLsb: Int = DEFAULT_MAX_REST_SPREAD, seedHalfSpan: Int = DEFAULT_SEED_HALF_SPAN, + rightSeedHalfSpan: Int = seedHalfSpan, ) { private val leftX = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) private val leftY = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) - private val rightX = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) - private val rightY = Axis(restWindowSize, maxRestSpreadLsb, seedHalfSpan) + private val rightX = Axis(restWindowSize, maxRestSpreadLsb, rightSeedHalfSpan) + private val rightY = Axis(restWindowSize, maxRestSpreadLsb, rightSeedHalfSpan) + + fun useFactory(main: StickCalibration?, right: StickCalibration?) { + main?.let { + leftX.useFactory(it.centreX, it.aboveX, it.belowX) + leftY.useFactory(it.centreY, it.aboveY, it.belowY) + } + right?.let { + rightX.useFactory(it.centreX, it.aboveX, it.belowX) + rightY.useFactory(it.centreY, it.aboveY, it.belowY) + } + } fun calibrate(input: JoyconInput): JoyconInput = input.copy( stickX = leftX.rescale(input.stickX), @@ -36,6 +48,14 @@ class StickCalibrator( private var min = 0 private var max = 0 + // Spans still widen past what the factory measured. + fun useFactory(centre: Int, above: Int, below: Int) { + this.centre = centre + centreLearned = true + this.above = above + this.below = below + } + fun rescale(raw: Int): Int { learnCentre(raw) val delta = raw - centre diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt new file mode 100644 index 00000000..b91014d2 --- /dev/null +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/TriggerCalibrator.kt @@ -0,0 +1,62 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconInput +import kotlin.math.abs + +/** Raw trigger bytes onto 0..255, full at the first stop: docs/protocol.md#analog-triggers */ +class TriggerCalibrator( + seedFull: Int = DEFAULT_SEED_FULL, + deadZone: Int = DEFAULT_DEAD_ZONE, +) { + + private val left = Axis(seedFull, deadZone) + private val right = Axis(seedFull, deadZone) + + fun setFactoryZeros(left: Int?, right: Int?) { + this.left.factoryZero = left + this.right.factoryZero = right + } + + fun calibrate(input: JoyconInput): JoyconInput = input.copy( + triggerLeft = left.rescale(input.triggerLeft, clicked = JoyconButton.L.id in input.pressed), + triggerRight = right.rescale(input.triggerRight, clicked = JoyconButton.R.id in input.pressed), + ) + + private class Axis(private var full: Int, private val deadZone: Int) { + var factoryZero: Int? = null + private var lowestSeen = Int.MAX_VALUE + private var heldValue = -1 + private var heldPackets = 0 + + fun rescale(raw: Int, clicked: Boolean): Int { + lowestSeen = minOf(lowestSeen, raw) + if (!clicked) learnFull(raw) + val start = (factoryZero ?: lowestSeen) + deadZone + if (raw <= start) return 0 + return ((raw - start) * MAX / (full - start).coerceAtLeast(1)).coerceAtMost(MAX) + } + + // Only a held value counts: a quick press passes far beyond the first stop on its way to the click. + private fun learnFull(raw: Int) { + if (abs(raw - heldValue) <= HOLD_JITTER) heldPackets++ else { + heldValue = raw + heldPackets = 1 + } + if (heldPackets >= HOLD_PACKETS && raw in (full + 1)..FULL_CEILING) full = raw + } + } + + companion object { + private const val MAX = 255 + + // Measured on three units (2026-10-07): first stops 170-196, rest up to 4 above the factory zero. + private const val DEFAULT_SEED_FULL = 170 + private const val DEFAULT_DEAD_ZONE = 5 + private const val FULL_CEILING = 205 + + // ~150 ms at the 30 ms balanced interval. + private const val HOLD_PACKETS = 5 + private const val HOLD_JITTER = 2 + } +} diff --git a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt new file mode 100644 index 00000000..b8649bba --- /dev/null +++ b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/InputCalibratorTest.kt @@ -0,0 +1,36 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.model.JoyconInput +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputCalibratorTest { + + @Test + fun `a controller without analog triggers reports none`() { + val calibrated = InputCalibrator(ControllerModel.PRO_CONTROLLER).calibrate(JoyconInput(triggerLeft = 120, triggerRight = 90)) + assertEquals(0, calibrated.triggerLeft) + assertEquals(0, calibrated.triggerRight) + } + + @Test + fun `a gamecube c-stick reaches full tilt at its shorter travel`() { + val calibrator = InputCalibrator(ControllerModel.GAMECUBE) + assertEquals(4095, calibrator.calibrate(JoyconInput(rightStickY = 2048 + 1022)).rightStickY) + } + + @Test + fun `a pro controller's right stick keeps the joy-con seed`() { + val calibrator = InputCalibrator(ControllerModel.PRO_CONTROLLER) + assertTrue(calibrator.calibrate(JoyconInput(rightStickY = 2048 + 1022)).rightStickY < 4095) + } + + @Test + fun `a gamecube controller's triggers are calibrated`() { + val calibrator = InputCalibrator(ControllerModel.GAMECUBE) + .apply { useFactory(FactoryCalibration(triggerZeroLeft = 33, triggerZeroRight = 32)) } + assertEquals(255, calibrator.calibrate(JoyconInput(triggerLeft = 180)).triggerLeft) + } +} diff --git a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt index 47239698..134666bb 100644 --- a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt +++ b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/StickCalibratorTest.kt @@ -103,4 +103,14 @@ class StickCalibratorTest { assertEquals(2048, rescale(restX)) assertEquals(2048, other.calibrate(JoyconInput(stickX = 2014)).stickX) } + + @Test + fun `factory calibration applies at once, without waiting to learn the centre`() { + val factory = StickCalibration(centreX = 2073, centreY = 2060, aboveX = 1224, aboveY = 1198, belowX = 1238, belowY = 1249) + val calibrated = StickCalibrator().apply { useFactory(factory, null) } + + assertEquals(2048, calibrated.calibrate(JoyconInput(stickX = 2073)).stickX) + assertEquals(4095, calibrated.calibrate(JoyconInput(stickX = 2073 + 1224)).stickX) + assertEquals(0, calibrated.calibrate(JoyconInput(stickY = 2060 - 1249)).stickY) + } } diff --git a/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt new file mode 100644 index 00000000..f4e086ed --- /dev/null +++ b/feature/connection/domain/src/test/kotlin/com/joegec/joycon2android/connection/TriggerCalibratorTest.kt @@ -0,0 +1,54 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.JoyconButton +import com.joegec.joycon2android.model.JoyconInput +import org.junit.Assert.assertEquals +import org.junit.Test + +class TriggerCalibratorTest { + + private val calibrator = TriggerCalibrator().apply { setFactoryZeros(33, 32) } + + private fun left(raw: Int, clicked: Boolean = false) = calibrator.calibrate( + JoyconInput(triggerLeft = raw, pressed = if (clicked) setOf(JoyconButton.L.id) else emptySet()), + ).triggerLeft + + @Test + fun `rest a few counts above the factory zero reads as released`() { + assertEquals(0, left(37)) + } + + @Test + fun `travel scales up to the seeded first stop`() { + assertEquals(255, left(170)) + assertEquals(127, left(104)) // halfway between 38 and 170 + } + + @Test + fun `a held first stop beyond the seed becomes full`() { + repeat(5) { left(194) } + assertEquals(255, left(194)) + assertEquals(183, left(150)) + } + + @Test + fun `a quick press through to the click does not move full`() { + listOf(120, 190, 220).forEach { left(it) } + assertEquals(255, left(170)) + } + + @Test + fun `a value held while clicked does not move full`() { + repeat(5) { left(200, clicked = true) } + assertEquals(255, left(170)) + } + + @Test + fun `without a factory zero the lowest value seen stands in`() { + val fresh = TriggerCalibrator() + val input = { raw: Int -> fresh.calibrate(JoyconInput(triggerLeft = raw)).triggerLeft } + input(40) + assertEquals(0, input(44)) + assertEquals(255, input(170)) + } +} diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt index f93e6fae..f886662a 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/DolphinWiimoteConfig.kt @@ -102,7 +102,7 @@ object DolphinWiimoteConfig { // A left Joy-Con's L/ZL edge is already a sideways remote's nose, so only a right one differs. private fun bodyInputs(side: JoyconSide, sidewaysRemote: Boolean): Map = when (side) { - JoyconSide.DUAL -> emptyMap() + JoyconSide.DUAL, JoyconSide.GAMECUBE -> emptyMap() JoyconSide.LEFT -> SIDEWAYS_REMOTE_INPUTS JoyconSide.RIGHT -> if (sidewaysRemote) SIDEWAYS_REMOTE_INPUTS else RIGHT_BODY_INPUTS } @@ -256,7 +256,7 @@ object DolphinWiimoteConfig { } private fun lines(side: JoyconSide, sidewaysRemote: Boolean, mapping: Map): List { - val sideways = sidewaysRemote && side != JoyconSide.DUAL + val sideways = sidewaysRemote && side.isLone val buttonLines = (mapping.toSourceMap() - WiimoteButton.Shake) .mapNotNull { (target, sources) -> expressionFor(side, sidewaysRemote, sources) diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index c2ff1d62..ecf0aea3 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -157,6 +157,7 @@ object EdenDsuConfig { } private fun sideFor(player: PlayerState): JoyconSide? = when { + player.hasGameCube -> JoyconSide.GAMECUBE player.hasPro || player.hasFullController -> JoyconSide.DUAL player.left != null -> JoyconSide.LEFT player.right != null -> JoyconSide.RIGHT diff --git a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt index da1f4066..391961c2 100644 --- a/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt +++ b/feature/gamepad/data/src/main/kotlin/com/joegec/joycon2android/gamepad/ReportMapper.kt @@ -62,10 +62,8 @@ object ReportMapper { // Bytes 9-10: right stick Y (inverted) putInt16LE(report, 9, mapStick(4096 - gamepad.rightStickY)) - // Byte 11: left trigger / brake (digital: 0 or 255) - report[11] = if (JoyconButton.ZL.id in pressed) 0xFF.toByte() else 0x00 - // Byte 12: right trigger / accelerator (digital: 0 or 255) - report[12] = if (JoyconButton.ZR.id in pressed) 0xFF.toByte() else 0x00 + report[11] = gamepad.leftTrigger.toByte() + report[12] = gamepad.rightTrigger.toByte() report[13] = bits(pressed, OVERFLOW_MAP).toByte() diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index d475fa13..6d15e533 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -3,6 +3,7 @@ package com.joegec.joycon2android.gamepad import com.joegec.joycon2android.model.AndroidKey import com.joegec.joycon2android.model.BatteryCharge import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.JoyconInput import com.joegec.joycon2android.model.PlayerNumber @@ -95,4 +96,26 @@ class ReportMapperTest { assertEquals(0.toByte(), report[14]) assertEquals(0b10000.toByte(), report[1]) // Home, bit 12 } + + @Test + fun `a gamecube controller's analog travel drives the trigger bytes, not ZL or ZR`() { + val input = JoyconInput(pressed = setOf(JoyconButton.ZL.id), triggerLeft = 128, triggerRight = 40) + val gameCube = ConnectedJoycon( + address = "gc", side = Side.PRO, model = ControllerModel.GAMECUBE, deviceName = "Joy-Con 2", input = input, + ) + val report = ReportMapper.buildReport(GamepadFrame(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + assertEquals(128.toByte(), report[11]) + assertEquals(40.toByte(), report[12]) + } + + @Test + fun `a gamecube controller's ZL and Z travel on the stick-click bits, clear of L2 and R2`() { + val input = JoyconInput(pressed = setOf(JoyconButton.ZL.id, JoyconButton.ZR.id)) + val gameCube = ConnectedJoycon( + address = "gc", side = Side.PRO, model = ControllerModel.GAMECUBE, deviceName = "Joy-Con 2", input = input, + ) + val report = ReportMapper.buildReport(GamepadFrame(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + val word = (report[0].toInt() and 0xFF) or ((report[1].toInt() and 0xFF) shl 8) + assertEquals((1 shl 13) or (1 shl 14), word) // BUTTON_THUMBL, BUTTON_THUMBR + } } 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 2b22a53a..7e171dfd 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 @@ -68,6 +68,12 @@ object DolphinGcpadConfig { private val STICK_PREFIXES = mapOf(GameCubeStick.MainStick to "Main Stick", GameCubeStick.CStick to "C-Stick") + // AXIS_BRAKE (23) and AXIS_GAS (22): docs/virtual-gamepad.md#analog-triggers + private val ANALOG_TRIGGER_LINES = listOf( + "Triggers/L-Analog = `Axis 23+`", + "Triggers/R-Analog = `Axis 22+`", + ) + fun merge( existing: String?, players: List, @@ -77,7 +83,7 @@ object DolphinGcpadConfig { fun mergeCore(existing: String?, players: List): String { val siDevices = players - .filter { it.hasController && !it.hasPro && it.player.index in 1..4 } + .filter { it.hasController && (!it.hasPro || it.hasGameCube) && it.player.index in 1..4 } .associate { "SIDevice${it.player.index - 1}" to STANDARD_CONTROLLER } return IniEditor.setKeys(existing, "[Core]", siDevices) } @@ -104,6 +110,7 @@ object DolphinGcpadConfig { mappingFor: (PlayerBody) -> Map, ): String? { val side = when { + player.hasGameCube -> JoyconSide.GAMECUBE player.hasPro -> return null player.hasFullController -> JoyconSide.DUAL player.right != null -> JoyconSide.RIGHT @@ -111,7 +118,8 @@ object DolphinGcpadConfig { else -> return null } val device = "Device = Android/$deviceId/Joy-Con Virtual Gamepad $index" - return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side)))) + val analogTriggers = if (player.hasAnalogTriggers) ANALOG_TRIGGER_LINES else emptyList() + return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side))) + analogTriggers) .joinToString("\n", postfix = "\n") } diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index 5f7a7502..979ee8e7 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -108,6 +108,7 @@ object EdenGamepadConfig { } private fun sideFor(player: PlayerState): JoyconSide? = when { + player.hasGameCube -> JoyconSide.GAMECUBE player.hasPro || player.hasFullController -> JoyconSide.DUAL player.left != null && player.right == null -> JoyconSide.LEFT player.right != null && player.left == null -> JoyconSide.RIGHT 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 d78a689a..2faec5aa 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 @@ -5,6 +5,7 @@ import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -107,6 +108,47 @@ class DolphinGcpadConfigTest { assertFalse(result.contains("[GCPad1]")) } + @Test + fun `a gamecube controller is configured with both sticks and Z on its Z button`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertTrue(result.contains("[GCPad1]")) + assertTrue(result.contains("Buttons/Z = `Button R3`")) // the stick click Z travels on + assertTrue(result.contains("Triggers/R = `Button R1`")) + assertTrue(result.contains("C-Stick/Up = `Axis 14-`")) + } + + @Test + fun `a gamecube controller's analog triggers bind to the brake and gas axes`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertTrue(result.contains("Triggers/L-Analog = `Axis 23+`")) + assertTrue(result.contains("Triggers/R-Analog = `Axis 22+`")) + assertTrue(result.contains("Triggers/L = `Button L1`")) // the click at the second stop + } + + @Test + fun `controllers with digital triggers bind no analog axis`() { + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT)))) + + assertFalse(result.contains("L-Analog")) + } + + @Test + fun `core config includes a gamecube controller's port but not a pro controller's`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val players = listOf( + PlayerState(PlayerNumber.P1, left = joycon(Side.PRO)), + PlayerState(PlayerNumber.P2, left = gameCube, right = gameCube), + ) + val result = DolphinGcpadConfig.mergeCore(null, players) + + assertFalse(result.contains("SIDevice0")) + assertTrue(result.contains("SIDevice1 = 6")) + } + @Test fun `core config sets each configured port to a standard controller, preserving other keys`() { val existing = "[Core]\nGFXBackend = Vulkan\nSIDevice0 = 0\n[Interface]\nFoo = Bar\n" diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index 1a0e6e7c..56a8a398 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -5,6 +5,7 @@ import com.joegec.joycon2android.buttonmapping.JoyconSide import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -187,6 +188,17 @@ class EdenGamepadConfigTest { assertTrue(result.contains("foo=bar")) } + @Test + fun `a gamecube controller's ZL and ZR bind to the stick clicks they travel on`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val players = listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube)) + + val result = merge(null, players, mapOf(1 to 0)) + + assertTrue(result.contains("player_0_button_zl=\"$DEVICE,button:106,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBL + assertTrue(result.contains("player_0_button_zr=\"$DEVICE,button:107,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBR + } + private companion object { // The ids UhidRelay creates our virtual gamepads with. const val VENDOR_ID = 0x1234 From 7d79662b9d3d79a4c18b4436c8db7468d6a1d450 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 02:01:54 +0000 Subject: [PATCH 02/17] Keep a GameCube controller's ZL and Z off its stick-click bindings A GameCube controller's ZL and Z travel on the stick-click bits, which it has no buttons for. Its layouts still bound the stick clicks there, so each press fired twice: in Eden, ZL also pressed L3 and Z pressed R3, on both the virtual gamepad and DSU. In the Joy-Con Wii layout, Z pressed Two and Recenter at once. emittedFor now resolves LS and RS to nothing for a GameCube controller, so no config writer can bind them, whatever the mapping says. Its Switch Pro layout drops the stick-click targets, and its Joy-Con Wii layout leaves Recenter unbound: there is no stick click to put it on, and the Wii layout's default, R, is already One there. The Joy-Con layout's no-double-fire test now checks the buttons each body emits rather than the ones it names. That catches this case and caught the R clash. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../buttonmapping/EmittedInput.kt | 2 +- .../buttonmapping/preset/JoyconWiiMapping.kt | 6 ++- .../buttonmapping/preset/SwitchProMapping.kt | 43 ++++++++++--------- .../buttonmapping/PlayerBodyTest.kt | 7 +++ .../buttonmapping/preset/WiiPresetsTest.kt | 7 ++- .../model/AnalogTriggerMapper.kt | 3 ++ docs/virtual-gamepad.md | 4 ++ .../dsu/emulator/EdenDsuConfigTest.kt | 13 ++++++ .../gamepad/emulator/EdenGamepadConfigTest.kt | 4 +- 10 files changed, 66 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 41155919..c5470a8b 100644 --- a/README.md +++ b/README.md @@ -137,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 (unbound on a GameCube controller, which has none) | | 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 | diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt index 60496754..5c012924 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/EmittedInput.kt @@ -8,7 +8,7 @@ import com.joegec.joycon2android.model.SidewaysMapper fun JoyconButton.emittedFor(side: JoyconSide): JoyconButton? { val emittedId = when (side) { JoyconSide.DUAL -> id - JoyconSide.GAMECUBE -> AnalogTriggerMapper.remapButtons(setOf(id)).first() + JoyconSide.GAMECUBE -> AnalogTriggerMapper.emittedId(id) ?: return null JoyconSide.LEFT -> SidewaysMapper.remapButtonsLeft(setOf(id)).first() JoyconSide.RIGHT -> SidewaysMapper.remapButtonsRight(setOf(id)).first() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index 0dbb3857..ed996695 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -21,7 +21,7 @@ object JoyconWiiMapping : MappingPreset { override val id = "JOYCON" override val console = Console.WIIMOTE_NUNCHUK - override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() + override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() + unbound(side) private fun buttons(side: JoyconSide): Map = when (side) { JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( @@ -46,4 +46,8 @@ object JoyconWiiMapping : MappingPreset { WiimoteButton.Recenter to LS, ) } + + // No stick click to recenter from, and the Wii layout's R is One here. + private fun unbound(side: JoyconSide): Map = + if (side == JoyconSide.GAMECUBE) mapOf(WiimoteButton.Recenter.name to "") else emptyMap() } diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt index db072ed8..6b5573e6 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/SwitchProMapping.kt @@ -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 = when (side) { - JoyconSide.DUAL, JoyconSide.GAMECUBE -> 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, diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt index e337aa21..e272ac15 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt @@ -29,4 +29,11 @@ class PlayerBodyTest { assertEquals(JoyconButton.LS, JoyconButton.ZL.emittedFor(JoyconSide.GAMECUBE)) assertEquals(JoyconButton.ZR, JoyconButton.ZR.emittedFor(JoyconSide.DUAL)) } + + @Test + fun `a gamecube controller's stick clicks emit nothing, so they can't fire with ZL or Z`() { + assertEquals(null, JoyconButton.LS.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(null, JoyconButton.RS.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.LS, JoyconButton.LS.emittedFor(JoyconSide.DUAL)) + } } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index 0bd572fd..b797ee9e 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.MappingLayouts import com.joegec.joycon2android.buttonmapping.MappingSource import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.sourceIdsOf +import com.joegec.joycon2android.buttonmapping.toMappingSources import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import org.junit.Assert.assertEquals @@ -59,7 +60,7 @@ class WiiPresetsTest { JoyconSide.entries.forEach { side -> val fired = mutableMapOf>() JoyconWiiMapping.entries(side).forEach { (target, value) -> - sourceIdsOf(value).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } + emittedIds(value, side).forEach { fired.getOrPut(it) { mutableSetOf() }.add(target) } } assertEquals("$side", emptyMap>(), fired.filterValues { it.size > 1 }) @@ -125,6 +126,10 @@ class WiiPresetsTest { } } + private fun emittedIds(value: String, side: JoyconSide) = value.toMappingSources().mapNotNull { source -> + if (source is MappingSource.Button) source.button.emittedFor(side)?.name else source.id + } + private fun emittedFace(entries: Map, target: WiimoteButton, side: JoyconSide) = (MappingSource.fromId(sourceIdsOf(entries.getValue(target.name)).first()) as? MappingSource.Button) ?.button diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt index 2f311468..dbc4abd5 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt @@ -6,6 +6,9 @@ object AnalogTriggerMapper { fun remapButtons(pressed: Set): Set = pressed.mapTo(mutableSetOf()) { REMAP[it] ?: it } + /** Null for a stick click, whose bits carry ZL or ZR instead. */ + fun emittedId(id: String): String? = REMAP[id] ?: id.takeUnless { it in REMAP.values } + // The stick clicks are free: a controller with analog triggers has none. private val REMAP = mapOf( JoyconButton.ZL.id to JoyconButton.LS.id, diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 0cacf97e..30acf21f 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -138,6 +138,10 @@ controller has no use for (`AnalogTriggerMapper`). They can't stay on `BUTTON_L2 can follow the trigger axes instead. Retroid Pocket Nova, Android 13, 2026-10-08: pulling R fired Dolphin's `Button R2`, and pressing Z fired nothing. The Nova's L2/R2 setting wasn't the cause. +So no binding of this controller's can use the stick clicks: on the wire they are ZL and Z. +`emittedFor` resolves LS/RS to nothing for it, and its layouts leave the stick-click targets and +the Joy-Con Wii layout's Recenter unbound. + For such a player, Dolphin setup adds ``Triggers/L-Analog = `Axis 23+` `` and ``Triggers/R-Analog = `Axis 22+` ``. Dolphin names Android axes by number, and a trigger has only a `+` half. Those two ids exist on every Android version the app supports. `Triggers/L`/`R` stay on the diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt index 2f380d8c..8c4c8a62 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.preset.MappingPresets import com.joegec.joycon2android.dsu.DsuConfig import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side @@ -194,4 +195,16 @@ class EdenDsuConfigTest { assertTrue(result.contains("[Core]")) assertEquals("true", valueOf(result, "use_multi_core")) } + + @Test + fun `a gamecube controller's ZL and ZR bind to the stick-click bits they travel on, and nothing else does`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + + val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertEquals("\"${device(0)},button:2\"", valueOf(result, "player_0_button_zl")) // LS + assertEquals("\"${device(0)},button:4\"", valueOf(result, "player_0_button_zr")) // RS + assertFalse(result.contains("player_0_button_lstick")) + assertFalse(result.contains("player_0_button_rstick")) + } } diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index 56a8a398..cdb5187a 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -189,7 +189,7 @@ class EdenGamepadConfigTest { } @Test - fun `a gamecube controller's ZL and ZR bind to the stick clicks they travel on`() { + fun `a gamecube controller's ZL and ZR bind to the stick clicks they travel on, and nothing else does`() { val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) val players = listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube)) @@ -197,6 +197,8 @@ class EdenGamepadConfigTest { assertTrue(result.contains("player_0_button_zl=\"$DEVICE,button:106,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBL assertTrue(result.contains("player_0_button_zr=\"$DEVICE,button:107,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBR + assertFalse(result.contains("player_0_button_lstick")) + assertFalse(result.contains("player_0_button_rstick")) } private companion object { From 925545cc521c71c1e618e8fec578108aec765f25 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 02:55:25 +0000 Subject: [PATCH 03/17] Put a GameCube controller's ZL and Z on the bumpers The analog triggers already drive the trigger axes, so the bumper keys are where Android expects ZL and Z: Z is the GameCube's bumper. They move from the stick clicks to BUTTON_L1/BUTTON_R1, and the trigger clicks at the bottom of L/R take the free stick clicks instead. Nothing goes on BUTTON_L2/R2, which can follow the trigger axes. Dolphin and Eden bind whatever each button sends, so they play as before. This changes what apps the app doesn't configure see by default: a full trigger pull no longer presses a bumper, and Z no longer presses the right stick in. Tested on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller. Co-Authored-By: Claude Opus 5.5 --- .../buttonmapping/PlayerBodyTest.kt | 10 ++++--- .../model/AnalogTriggerMapper.kt | 12 +++++---- docs/virtual-gamepad.md | 26 ++++++++++++------- .../dsu/emulator/EdenDsuConfigTest.kt | 8 +++--- .../gamepad/ReportMapperTest.kt | 16 +++++++++--- .../emulator/DolphinGcpadConfigTest.kt | 6 ++--- .../gamepad/emulator/EdenGamepadConfigTest.kt | 8 +++--- 7 files changed, 54 insertions(+), 32 deletions(-) diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt index e272ac15..93dbc4d5 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/PlayerBodyTest.kt @@ -24,14 +24,16 @@ class PlayerBodyTest { } @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)) + fun `a gamecube controller's ZL and Z are reported as the bumpers and its trigger clicks as the stick clicks`() { + assertEquals(JoyconButton.R, JoyconButton.ZR.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.L, JoyconButton.ZL.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.RS, JoyconButton.R.emittedFor(JoyconSide.GAMECUBE)) + assertEquals(JoyconButton.LS, JoyconButton.L.emittedFor(JoyconSide.GAMECUBE)) assertEquals(JoyconButton.ZR, JoyconButton.ZR.emittedFor(JoyconSide.DUAL)) } @Test - fun `a gamecube controller's stick clicks emit nothing, so they can't fire with ZL or Z`() { + fun `a gamecube controller's stick clicks emit nothing, so they can't fire with a trigger click`() { assertEquals(null, JoyconButton.LS.emittedFor(JoyconSide.GAMECUBE)) assertEquals(null, JoyconButton.RS.emittedFor(JoyconSide.GAMECUBE)) assertEquals(JoyconButton.LS, JoyconButton.LS.emittedFor(JoyconSide.DUAL)) diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt index dbc4abd5..787c72d4 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/AnalogTriggerMapper.kt @@ -1,17 +1,19 @@ package com.joegec.joycon2android.model -/** Why ZL and ZR move off L2/R2: docs/virtual-gamepad.md#analog-triggers */ +/** Where each shoulder goes, and why: docs/virtual-gamepad.md#analog-triggers */ object AnalogTriggerMapper { fun remapButtons(pressed: Set): Set = pressed.mapTo(mutableSetOf()) { REMAP[it] ?: it } - /** Null for a stick click, whose bits carry ZL or ZR instead. */ + /** Null for a stick click, whose bits carry the trigger clicks instead. */ fun emittedId(id: String): String? = REMAP[id] ?: id.takeUnless { it in REMAP.values } - // The stick clicks are free: a controller with analog triggers has none. + // A controller with analog triggers has no stick clicks to lose. private val REMAP = mapOf( - JoyconButton.ZL.id to JoyconButton.LS.id, - JoyconButton.ZR.id to JoyconButton.RS.id, + JoyconButton.ZL.id to JoyconButton.L.id, + JoyconButton.ZR.id to JoyconButton.R.id, + JoyconButton.L.id to JoyconButton.LS.id, + JoyconButton.R.id to JoyconButton.RS.id, ) } diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 30acf21f..7d32e98d 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -54,7 +54,8 @@ shift: | R | 7 | 103 `BUTTON_R1` | | | | - **Capture and GL** take `BUTTON_C` / `BUTTON_Z`, the two slots with no Switch equivalent. -- **ZL and ZR take the stick clicks** on a controller with analog triggers ([below](#analog-triggers)). +- **A controller with analog triggers moves its shoulders:** ZL/ZR onto L1/R1, the trigger clicks onto + the stick clicks ([below](#analog-triggers)). - **GR and C overflow.** One gamepad collection carries 15 buttons — a 16th lands on `0x13F`, which no key layout names — and the Switch 2 controllers have 17. For a Button usage outside a pointer/joystick/gamepad collection Linux falls back to `BTN_MISC + n - 1`, which key layouts name @@ -130,17 +131,22 @@ Motion is turned too, but for DSU only — see [dsu-motion.md](dsu-motion.md#sid A controller with analog triggers (`ControllerModel.hasAnalogTriggers`, the NSO GameCube controller) drives bytes 11/12 with its calibrated L/R travel instead of ZL/ZR -([calibration](protocol.md#analog-triggers)). The click at the bottom stays on L/R -(`BUTTON_L1`/`BUTTON_R1`). +([calibration](protocol.md#analog-triggers)). `AnalogTriggerMapper` moves its other shoulder buttons +so each lands where Android expects that control: -ZL and Z (the ZR bit) move to the stick-click bits, `BUTTON_THUMBL`/`BUTTON_THUMBR`, which this -controller has no use for (`AnalogTriggerMapper`). They can't stay on `BUTTON_L2`/`BUTTON_R2`, which -can follow the trigger axes instead. Retroid Pocket Nova, Android 13, 2026-10-08: pulling R fired -Dolphin's `Button R2`, and pressing Z fired nothing. The Nova's L2/R2 setting wasn't the cause. +| Button | Bits | Keycodes | +|---|---|---| +| ZL / Z (the ZR bit) | 6 / 7 | `BUTTON_L1` / `BUTTON_R1` | +| L / R click at the bottom | 13 / 14 | `BUTTON_THUMBL` / `BUTTON_THUMBR` | + +Confirmed on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller, 2026-10-13. -So no binding of this controller's can use the stick clicks: on the wire they are ZL and Z. -`emittedFor` resolves LS/RS to nothing for it, and its layouts leave the stick-click targets and -the Joy-Con Wii layout's Recenter unbound. +- **Nothing goes on `BUTTON_L2`/`BUTTON_R2`:** they can follow the trigger axes. Retroid Pocket Nova, + Android 13, 2026-10-08: with Z on R2, pulling R fired Dolphin's `Button R2` and Z fired nothing. + The Nova's L2/R2 setting wasn't the cause. +- **The stick-click bits are free to reuse:** the controller has no stick clicks. So no binding of + its can use LS/RS: `emittedFor` resolves them to nothing for it, and its layouts leave the + stick-click targets and the Joy-Con Wii layout's Recenter unbound. For such a player, Dolphin setup adds ``Triggers/L-Analog = `Axis 23+` `` and ``Triggers/R-Analog = `Axis 22+` ``. Dolphin names Android axes by number, and a trigger has only a `+` diff --git a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt index 8c4c8a62..fc4c3b25 100644 --- a/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt +++ b/feature/dsu/domain/src/test/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfigTest.kt @@ -197,13 +197,15 @@ class EdenDsuConfigTest { } @Test - fun `a gamecube controller's ZL and ZR bind to the stick-click bits they travel on, and nothing else does`() { + fun `a gamecube controller's ZL and Z bind to the bumper bits and its trigger clicks to the stick-click bits`() { val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) - assertEquals("\"${device(0)},button:2\"", valueOf(result, "player_0_button_zl")) // LS - assertEquals("\"${device(0)},button:4\"", valueOf(result, "player_0_button_zr")) // RS + assertEquals("\"${device(0)},button:1024\"", valueOf(result, "player_0_button_zl")) // L + assertEquals("\"${device(0)},button:2048\"", valueOf(result, "player_0_button_zr")) // R + assertEquals("\"${device(0)},button:2\"", valueOf(result, "player_0_button_l")) // LS + assertEquals("\"${device(0)},button:4\"", valueOf(result, "player_0_button_r")) // RS assertFalse(result.contains("player_0_button_lstick")) assertFalse(result.contains("player_0_button_rstick")) } diff --git a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt index 6d15e533..8b5b5fc7 100644 --- a/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt +++ b/feature/gamepad/data/src/test/kotlin/com/joegec/joycon2android/gamepad/ReportMapperTest.kt @@ -109,13 +109,21 @@ class ReportMapperTest { } @Test - fun `a gamecube controller's ZL and Z travel on the stick-click bits, clear of L2 and R2`() { - val input = JoyconInput(pressed = setOf(JoyconButton.ZL.id, JoyconButton.ZR.id)) + fun `a gamecube controller's ZL and Z travel on the bumper bits, clear of L2 and R2`() { + assertEquals((1 shl 6) or (1 shl 7), gameCubeButtonWord(JoyconButton.ZL, JoyconButton.ZR)) // BUTTON_L1, BUTTON_R1 + } + + @Test + fun `a gamecube controller's trigger clicks travel on the stick-click bits`() { + assertEquals((1 shl 13) or (1 shl 14), gameCubeButtonWord(JoyconButton.L, JoyconButton.R)) // BUTTON_THUMBL, BUTTON_THUMBR + } + + private fun gameCubeButtonWord(vararg pressed: JoyconButton): Int { + val input = JoyconInput(pressed = pressed.map { it.id }.toSet()) val gameCube = ConnectedJoycon( address = "gc", side = Side.PRO, model = ControllerModel.GAMECUBE, deviceName = "Joy-Con 2", input = input, ) val report = ReportMapper.buildReport(GamepadFrame(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) - val word = (report[0].toInt() and 0xFF) or ((report[1].toInt() and 0xFF) shl 8) - assertEquals((1 shl 13) or (1 shl 14), word) // BUTTON_THUMBL, BUTTON_THUMBR + return (report[0].toInt() and 0xFF) or ((report[1].toInt() and 0xFF) shl 8) } } 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 2faec5aa..1cf983c2 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 @@ -114,8 +114,8 @@ class DolphinGcpadConfigTest { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) assertTrue(result.contains("[GCPad1]")) - assertTrue(result.contains("Buttons/Z = `Button R3`")) // the stick click Z travels on - assertTrue(result.contains("Triggers/R = `Button R1`")) + assertTrue(result.contains("Buttons/Z = `Button R1`")) // the bumper Z travels on + assertTrue(result.contains("Triggers/R = `Button R3`")) assertTrue(result.contains("C-Stick/Up = `Axis 14-`")) } @@ -126,7 +126,7 @@ class DolphinGcpadConfigTest { assertTrue(result.contains("Triggers/L-Analog = `Axis 23+`")) assertTrue(result.contains("Triggers/R-Analog = `Axis 22+`")) - assertTrue(result.contains("Triggers/L = `Button L1`")) // the click at the second stop + assertTrue(result.contains("Triggers/L = `Button L3`")) // the click at the second stop } @Test diff --git a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt index cdb5187a..1a1b36c9 100644 --- a/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt +++ b/feature/gamepad/domain/src/test/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfigTest.kt @@ -189,14 +189,16 @@ class EdenGamepadConfigTest { } @Test - fun `a gamecube controller's ZL and ZR bind to the stick clicks they travel on, and nothing else does`() { + fun `a gamecube controller's ZL and Z bind to the bumpers and its trigger clicks to the stick clicks`() { val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) val players = listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube)) val result = merge(null, players, mapOf(1 to 0)) - assertTrue(result.contains("player_0_button_zl=\"$DEVICE,button:106,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBL - assertTrue(result.contains("player_0_button_zr=\"$DEVICE,button:107,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBR + assertTrue(result.contains("player_0_button_zl=\"$DEVICE,button:102,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_L1 + assertTrue(result.contains("player_0_button_zr=\"$DEVICE,button:103,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_R1 + assertTrue(result.contains("player_0_button_l=\"$DEVICE,button:106,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBL + assertTrue(result.contains("player_0_button_r=\"$DEVICE,button:107,display:Joy-Con Virtual Gamepad 1 0\"")) // BUTTON_THUMBR assertFalse(result.contains("player_0_button_lstick")) assertFalse(result.contains("player_0_button_rstick")) } From e0104afae0a7682f9ebc195166a84cd9746cce77 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:20:34 +0000 Subject: [PATCH 04/17] =?UTF-8?q?Give=20a=20GameCube=20controller=20?= =?UTF-8?q?=E2=88=92=20and=20Recenter=20in=20the=20Joy-Con=20Wii=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Joy-Con layout puts − on the controller's own − and Recenter on a stick click. A GameCube controller has neither, so − pressed nothing and Recenter was left unbound. − now takes X, as it already does on a lone right Joy-Con, and Recenter takes Y, which was free. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../buttonmapping/preset/JoyconWiiMapping.kt | 18 ++++++++++++------ .../buttonmapping/preset/WiiPresetsTest.kt | 7 +++++++ docs/virtual-gamepad.md | 4 ++-- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c5470a8b..5c670d48 100644 --- a/README.md +++ b/README.md @@ -137,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 (unbound on a GameCube controller, which has none) | +| 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 | diff --git a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt index ed996695..173262cd 100644 --- a/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt +++ b/core/buttonmapping/domain/src/main/kotlin/com/joegec/joycon2android/buttonmapping/preset/JoyconWiiMapping.kt @@ -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 @@ -21,16 +23,24 @@ object JoyconWiiMapping : MappingPreset { override val id = "JOYCON" override val console = Console.WIIMOTE_NUNCHUK - override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() + unbound(side) + override fun entries(side: JoyconSide) = WiiMapping.entries(side) + buttons(side).buttonEntries() private fun buttons(side: JoyconSide): Map = when (side) { - JoyconSide.DUAL, JoyconSide.GAMECUBE -> mapOf( + JoyconSide.DUAL -> mapOf( WiimoteButton.B to B, WiimoteButton.One to R, WiimoteButton.Two to ZR, 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, @@ -46,8 +56,4 @@ object JoyconWiiMapping : MappingPreset { WiimoteButton.Recenter to LS, ) } - - // No stick click to recenter from, and the Wii layout's R is One here. - private fun unbound(side: JoyconSide): Map = - if (side == JoyconSide.GAMECUBE) mapOf(WiimoteButton.Recenter.name to "") else emptyMap() } diff --git a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt index b797ee9e..18f47771 100644 --- a/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt +++ b/core/buttonmapping/domain/src/test/kotlin/com/joegec/joycon2android/buttonmapping/preset/WiiPresetsTest.kt @@ -55,6 +55,13 @@ class WiiPresetsTest { assertEquals("LS", left.getValue(WiimoteButton.Recenter.name)) } + @Test + fun `the Joy-Con layout puts a gamecube controller's missing minus and stick click on X and Y`() { + val gameCube = JoyconWiiMapping.entries(JoyconSide.GAMECUBE) + assertEquals("X", gameCube.getValue(WiimoteButton.Minus.name)) + assertEquals("Y", gameCube.getValue(WiimoteButton.Recenter.name)) + } + @Test fun `no button fires two of the Joy-Con layout's targets`() { JoyconSide.entries.forEach { side -> diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 7d32e98d..d1b3471d 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -145,8 +145,8 @@ Confirmed on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller, Android 13, 2026-10-08: with Z on R2, pulling R fired Dolphin's `Button R2` and Z fired nothing. The Nova's L2/R2 setting wasn't the cause. - **The stick-click bits are free to reuse:** the controller has no stick clicks. So no binding of - its can use LS/RS: `emittedFor` resolves them to nothing for it, and its layouts leave the - stick-click targets and the Joy-Con Wii layout's Recenter unbound. + its can use LS/RS: `emittedFor` resolves them to nothing for it, and its Switch Pro layout leaves + the stick-click targets unbound. For such a player, Dolphin setup adds ``Triggers/L-Analog = `Axis 23+` `` and ``Triggers/R-Analog = `Axis 22+` ``. Dolphin names Android axes by number, and a trigger has only a `+` From b20496946053affed9f779184fea8adc7af385d3 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:30:08 +0000 Subject: [PATCH 05/17] Let the controller type menu pick a GameCube controller The override was stored as a Side, so a GameCube controller the scanner didn't recognise could only be forced to Pro Controller. That gave it a Pro's layouts, with no analog triggers and no C-stick calibration. The override is now a ControllerModel: Left or Right Joy-Con, Pro Controller or GameCube controller. The model in effect is the override or, without one, what the advertisement named, and the side follows from it. ConnectedJoycon carries both, so the menu's Auto entry still names what was detected. Switching a connected controller to GameCube also reads its factory calibration, as connecting as one does. Overrides saved as a side no longer parse and are dropped. Co-Authored-By: Claude Opus 5.5 --- .../joegec/joycon2android/ui/Joycon2ViewModel.kt | 4 ++-- .../com/joegec/joycon2android/ui/JoyconScreen.kt | 6 +++--- .../joycon2android/model/ConnectedJoycon.kt | 3 ++- .../joycon2android/session/SessionCoordinator.kt | 6 +++--- .../session/SetControllerTypeUseCase.kt | 4 ++-- .../session/SessionCoordinatorTypeTest.kt | 11 ++++++----- .../assignment/presentation/AssignmentPanel.kt | 6 +++--- .../presentation/ControllerTypeDropdown.kt | 15 +++++++-------- .../presentation/ControllerTypeLabels.kt | 9 --------- .../joycon2android/connection/CommonSession.kt | 6 +++++- .../joycon2android/connection/ConnectionPool.kt | 7 +++---- .../ControllerTypePreferencesDataStore.kt | 12 ++++++------ .../joycon2android/connection/Joycon2Manager.kt | 8 ++++---- .../connection/JoyconConnection.kt | 16 +++++++++------- .../connection/ControllerRepository.kt | 4 ++-- .../connection/ControllerTypePreferences.kt | 6 +++--- 16 files changed, 60 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt index 766b862f..f75a0a3a 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/Joycon2ViewModel.kt @@ -14,7 +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.model.ControllerModel import com.joegec.joycon2android.power.BatteryOptimizationExemption import com.joegec.joycon2android.service.Joycon2Service import kotlinx.coroutines.flow.MutableStateFlow @@ -101,7 +101,7 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) fun disconnect(address: String) = container.disconnectController(address) - fun setControllerType(address: String, side: Side?) = container.setControllerType(address, side) + fun setControllerType(address: String, model: ControllerModel?) = container.setControllerType(address, model) fun onPermissionsDenied() { _permissionDenied.value = true diff --git a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt index d478417f..659de430 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -73,7 +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.Side +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 @@ -135,7 +135,7 @@ fun JoyconScreen( onAssign: (String, PlayerNumber) -> Unit, onUnassign: (String) -> Unit, onDisconnect: (String) -> Unit, - onSetControllerType: (String, Side?) -> Unit, + onSetControllerType: (String, ControllerModel?) -> Unit, onGamepadToggle: (Boolean) -> Unit, gamepadEmulators: List, selectedGamepadEmulator: String, @@ -466,7 +466,7 @@ private fun ConnectedContent( onUnassign: (String) -> Unit, onRemovePlayer: (PlayerState) -> Unit, onDisconnect: (String) -> Unit, - onSetControllerType: (String, Side?) -> Unit, + onSetControllerType: (String, ControllerModel?) -> Unit, onGamepadToggle: (Boolean) -> Unit, onDsuToggle: (Boolean) -> Unit, onSelectDsuEmulator: (String) -> Unit, diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt index 63185bae..7cfa42f5 100644 --- a/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/ConnectedJoycon.kt @@ -5,7 +5,8 @@ data class ConnectedJoycon( val side: Side, val deviceName: String, val model: ControllerModel = ControllerModel.UNKNOWN, - val typeOverride: Side? = null, + val detectedModel: ControllerModel = model, + val typeOverride: ControllerModel? = null, val connectionState: JoyconConnectionState = JoyconConnectionState(), val input: JoyconInput = JoyconInput(), val assignedPlayer: PlayerNumber? = null, diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt index 512fe5fb..dc9a7f33 100644 --- a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SessionCoordinator.kt @@ -7,7 +7,7 @@ import com.joegec.joycon2android.assignment.PlayerStateResolver import com.joegec.joycon2android.model.AppUiState import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -62,9 +62,9 @@ class SessionCoordinator( } // Unassigned first: the assignment's slot checks were made for the old type. - fun setControllerType(address: String, side: Side?) { + fun setControllerType(address: String, model: ControllerModel?) { if (assignments.getPlayer(address) != null) unassign(address) - controllers.setControllerType(address, side) + controllers.setControllerType(address, model) } private fun publish(state: AppUiState) { diff --git a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt index 9631d12b..b291621e 100644 --- a/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt +++ b/core/session/src/main/kotlin/com/joegec/joycon2android/session/SetControllerTypeUseCase.kt @@ -1,7 +1,7 @@ package com.joegec.joycon2android.session -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel class SetControllerTypeUseCase(private val coordinator: SessionCoordinator) { - operator fun invoke(address: String, side: Side?) = coordinator.setControllerType(address, side) + operator fun invoke(address: String, model: ControllerModel?) = coordinator.setControllerType(address, model) } diff --git a/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt b/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt index 0c1eb5a2..04f92c27 100644 --- a/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt +++ b/core/session/src/test/kotlin/com/joegec/joycon2android/session/SessionCoordinatorTypeTest.kt @@ -5,6 +5,7 @@ import com.joegec.joycon2android.assignment.ComboAssignmentDetector import com.joegec.joycon2android.assignment.PlayerStateResolver import com.joegec.joycon2android.connection.ControllerRepository import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.Side import kotlinx.coroutines.CoroutineScope @@ -35,11 +36,11 @@ class SessionCoordinatorTypeTest { fun `an assigned controller leaves its player before its type changes`() { assignments.players[ADDRESS] = PlayerNumber.P2 - coordinator.setControllerType(ADDRESS, Side.PRO) + coordinator.setControllerType(ADDRESS, ControllerModel.GAMECUBE) assertNull(assignments.players[ADDRESS]) assertEquals(listOf(PlayerNumber.P2), unassignedPlayers) - assertEquals(ADDRESS to Side.PRO, controllers.typeChanges.single()) + assertEquals(ADDRESS to ControllerModel.GAMECUBE, controllers.typeChanges.single()) } @Test @@ -51,7 +52,7 @@ class SessionCoordinatorTypeTest { } private class FakeControllers : ControllerRepository { - val typeChanges = mutableListOf>() + val typeChanges = mutableListOf>() override val controllers: StateFlow> = MutableStateFlow(emptyList()) override val scanning: StateFlow = MutableStateFlow(false) override val error: StateFlow = MutableStateFlow(null) @@ -60,8 +61,8 @@ class SessionCoordinatorTypeTest { override fun disconnect(address: String) = Unit override fun disconnectAll() = Unit override fun setPlayerLed(address: String, player: PlayerNumber?) = Unit - override fun setControllerType(address: String, side: Side?) { - typeChanges += address to side + override fun setControllerType(address: String, model: ControllerModel?) { + typeChanges += address to model } override fun emitError(message: String) = Unit } diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 4f4b9322..93ab82d9 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -52,7 +52,7 @@ fun AssignmentPanel( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, - onSetType: (String, Side?) -> Unit, + onSetType: (String, ControllerModel?) -> Unit, modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(Dimens.cardCorner) @@ -91,7 +91,7 @@ private fun JoyconAssignmentRow( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, - onSetType: (String, Side?) -> Unit, + onSetType: (String, ControllerModel?) -> Unit, ) { val sideColor = joyconBorderColor(joycon.accentColor, JoyconDefaultColor) val sideLabel = when (joycon.side) { @@ -164,7 +164,7 @@ private fun JoyconAssignmentRow( } ControllerTypeDropdown( joycon, - onSetType = { side -> onSetType(joycon.address, side) }, + onSetType = { model -> onSetType(joycon.address, model) }, modifier = Modifier.padding(horizontal = Dimens.cardPadding), ) PlayerSelection(joycon, players, onAssign) diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt index 0d2f0635..3fa4c56f 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt @@ -4,29 +4,28 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.model.ConnectedJoycon -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.ui.components.DropdownOption import com.joegec.joycon2android.ui.components.OptionDropdown private const val AUTO_ID = "AUTO" -private val choosableSides = listOf(Side.PRO, Side.LEFT, Side.RIGHT) +private val choosableModels = ControllerModel.entries - ControllerModel.UNKNOWN @Composable internal fun ControllerTypeDropdown( joycon: ConnectedJoycon, - onSetType: (Side?) -> Unit, + onSetType: (ControllerModel?) -> Unit, modifier: Modifier = Modifier, ) { - val detected = joycon.model.label() - val auto = stringResource(R.string.type_auto, detected) + val auto = stringResource(R.string.type_auto, joycon.detectedModel.label()) val options = listOf(DropdownOption(AUTO_ID, auto)) + - choosableSides.map { DropdownOption(it.name, it.typeLabel()) } + choosableModels.map { DropdownOption(it.name, it.label()) } OptionDropdown( options = options, selectedId = joycon.typeOverride?.name ?: AUTO_ID, - label = joycon.typeOverride?.typeLabel() ?: auto, + label = joycon.typeOverride?.label() ?: auto, subLabel = stringResource(R.string.type_hint), - onSelect = { id -> onSetType(if (id == AUTO_ID) null else Side.valueOf(id)) }, + onSelect = { id -> onSetType(if (id == AUTO_ID) null else ControllerModel.valueOf(id)) }, modifier = modifier, ) } diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt index 2b115fb2..6515e5be 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt @@ -3,7 +3,6 @@ package com.joegec.joycon2android.assignment.presentation import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.joegec.joycon2android.model.ControllerModel -import com.joegec.joycon2android.model.Side @Composable internal fun ControllerModel.label(): String = when (this) { @@ -13,11 +12,3 @@ internal fun ControllerModel.label(): String = when (this) { ControllerModel.GAMECUBE -> stringResource(R.string.type_gamecube) ControllerModel.UNKNOWN -> stringResource(R.string.type_unknown) } - -@Composable -internal fun Side.typeLabel(): String = when (this) { - Side.LEFT -> stringResource(R.string.type_joycon_left) - Side.RIGHT -> stringResource(R.string.type_joycon_right) - Side.PRO -> stringResource(R.string.type_pro) - Side.UNKNOWN -> stringResource(R.string.type_unknown) -} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt index 890eac9b..0f6c6e8b 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/CommonSession.kt @@ -53,7 +53,7 @@ internal class CommonSession( ops.enqueue(INIT_GAP_MS) { write(FEATURES_INIT) } ops.enqueue(INIT_GAP_MS) { write(FEATURES_ENABLE) } ops.enqueue(INIT_GAP_MS) { write(SPI_READ_COLOR) } - calibrationReads.forEach { read -> ops.enqueue(INIT_GAP_MS) { write(read) } } + read(calibrationReads) ops.enqueue { ready = true Log.i(TAG, "[${side()}] Init sequence complete") @@ -66,6 +66,10 @@ internal class CommonSession( ready = false } + fun read(commands: List) { + commands.forEach { command -> ops.enqueue(INIT_GAP_MS) { write(command) } } + } + /** The console detour ended, so this channel is driving the controller again. */ fun resume() { ready = true diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index 688fdefb..3f5ffc57 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt @@ -5,7 +5,6 @@ import android.bluetooth.le.ScanResult import android.content.Context import com.joegec.joycon2android.connection.console.SecurityRequestReceiver import com.joegec.joycon2android.model.ControllerModel -import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap /** Thread-safe: BLE callbacks arrive on binder threads. */ @@ -27,8 +26,8 @@ class ConnectionPool(private val context: Context) { /** Null for an address already in the pool; one awaiting its controller's return reconnects instead. */ fun connect( result: ScanResult, - model: ControllerModel, - typeOverride: Side?, + detectedModel: ControllerModel, + typeOverride: ControllerModel?, name: String, highPriority: Boolean, ): JoyconConnection? { @@ -39,7 +38,7 @@ class ConnectionPool(private val context: Context) { } val connection = JoyconConnection( context, - model, + detectedModel, typeOverride, name, onReady = { reassertOtherPriorities(address) }, diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt index afbf1db5..849fd2c0 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt @@ -6,7 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -16,17 +16,17 @@ class ControllerTypePreferencesDataStore(context: Context) : ControllerTypePrefe private val dataStore = context.applicationContext.controllerTypeDataStore - override val overrides: Flow> = dataStore.data.map { prefs -> + override val overrides: Flow> = dataStore.data.map { prefs -> prefs.asMap().mapNotNull { (key, value) -> - val side = runCatching { Side.valueOf(value as String) }.getOrNull() ?: return@mapNotNull null - key.name to side + val model = runCatching { ControllerModel.valueOf(value as String) }.getOrNull() ?: return@mapNotNull null + key.name to model }.toMap() } - override suspend fun set(address: String, side: Side?) { + override suspend fun set(address: String, model: ControllerModel?) { dataStore.edit { prefs -> val key = stringPreferencesKey(address) - if (side == null) prefs.remove(key) else prefs[key] = side.name + if (model == null) prefs.remove(key) else prefs[key] = model.name } } } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index c55e76f8..8d777a45 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -7,7 +7,6 @@ import com.joegec.joycon2android.connection.data.R import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber -import com.joegec.joycon2android.model.Side import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -90,10 +89,10 @@ class Joycon2Manager( if (player != null) connection.setPlayerLed(player) else connection.clearPlayerLed() } - override fun setControllerType(address: String, side: Side?) { - scope.launch { typePreferences.set(address, side) } + override fun setControllerType(address: String, model: ControllerModel?) { + scope.launch { typePreferences.set(address, model) } val connection = pool.get(address) ?: return - connection.overrideType(side) + connection.overrideType(model) rebuildControllers() } @@ -128,6 +127,7 @@ class Joycon2Manager( address = address, side = connection.side, model = connection.model, + detectedModel = connection.detectedModel, typeOverride = connection.typeOverride, deviceName = connection.deviceName, connectionState = connection.connectionState.value, diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index dff7ce77..93bf1940 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -26,8 +26,8 @@ import java.util.UUID @SuppressLint("MissingPermission") class JoyconConnection( private val context: Context, - val model: ControllerModel, - typeOverride: Side?, + val detectedModel: ControllerModel, + typeOverride: ControllerModel?, val deviceName: String, private val onReady: () -> Unit, private val onDisconnected: (() -> Unit)? = null, @@ -43,9 +43,10 @@ class JoyconConnection( private const val RETURN_WINDOW_MS = 120_000L } - @Volatile var typeOverride: Side? = typeOverride + @Volatile var typeOverride: ControllerModel? = typeOverride private set - @Volatile var side: Side = typeOverride ?: model.defaultSide + val model: ControllerModel get() = typeOverride ?: detectedModel + @Volatile var side: Side = model.defaultSide private set private val _connectionState = MutableStateFlow( @@ -110,10 +111,11 @@ class JoyconConnection( } // A new side reads the sticks from other offsets, so their learned centres no longer apply. - fun overrideType(side: Side?) { - typeOverride = side - this.side = side ?: model.defaultSide + fun overrideType(override: ControllerModel?) { + typeOverride = override + side = model.defaultSide calibrator = InputCalibrator(model).apply { useFactory(factory) } + if (initComplete) common?.read(FactoryCalibrationReads.commands(model)) } fun setPlayerLed(player: PlayerNumber) { diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt index 3238f0be..6d10374e 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerRepository.kt @@ -2,7 +2,7 @@ package com.joegec.joycon2android.connection import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.PlayerNumber -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel import kotlinx.coroutines.flow.StateFlow /** [controllers] re-emits on every input or state change. */ @@ -18,6 +18,6 @@ interface ControllerRepository { fun setPlayerLed(address: String, player: PlayerNumber?) /** Null returns the controller to the type its advertisement named. */ - fun setControllerType(address: String, side: Side?) + fun setControllerType(address: String, model: ControllerModel?) fun emitError(message: String) } diff --git a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt index 1f789f2e..af84df77 100644 --- a/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt +++ b/feature/connection/domain/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferences.kt @@ -1,9 +1,9 @@ package com.joegec.joycon2android.connection -import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.model.ControllerModel import kotlinx.coroutines.flow.Flow interface ControllerTypePreferences { - val overrides: Flow> - suspend fun set(address: String, side: Side?) + val overrides: Flow> + suspend fun set(address: String, model: ControllerModel?) } From f7bc9d4368f557f682a2e16ed9d1126c703fb6cc Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:37:49 +0000 Subject: [PATCH 06/17] Move the controller type menu into the card's type label The type picker was a full-width dropdown on every unassigned card, recognised or not. The type label at the top of the card now opens it instead, so a recognised controller costs no extra row. The menu lists Detect automatically first, sub-labelled with what was detected, then the four types. A manual choice shows SET MANUALLY beside the label. Only an unrecognised controller gets a warning, and tapping it opens the same menu. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/DESIGN.md | 4 + .../presentation/AssignmentPanel.kt | 35 +++++++-- .../presentation/ControllerTypeDropdown.kt | 31 -------- .../presentation/ControllerTypeMenu.kt | 75 +++++++++++++++++++ .../src/main/res/values/strings.xml | 8 +- 6 files changed, 113 insertions(+), 44 deletions(-) delete mode 100644 feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt create mode 100644 feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt diff --git a/README.md b/README.md index 5c670d48..3be04c12 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ as an ordinary gamepad. 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 or GameCube controller. -3. If a controller shows as the wrong type, pick the right one under **Controller type** on its card +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. @@ -248,7 +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) | Set **Controller type** on its card, then assign it | +| 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 | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a1eb060d..6a9a4992 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -102,6 +102,10 @@ In `JoyconScreen.kt`: inset as top clearance. - **Settings behind a gear.** The app bar's only action opens the settings panel, an `EndDrawer` from the right so it sits under the icon that opened it. The Ko-fi banner sits under the panel title. +- **The type label is the type menu.** An unassigned controller's type at the top of its card opens + a `PanelDropdownMenu`: Detect automatically first, sub-labelled with what was detected, then the + four types. A manual choice shows `SET MANUALLY` beside the label. Only an unrecognised controller + gets a `WarningBox`, which opens the same menu; a recognised one costs no extra row. ### Landscape diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 93ab82d9..9de0f485 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -26,6 +26,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -37,6 +41,7 @@ import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side +import com.joegec.joycon2android.ui.components.WarningBox import com.joegec.joycon2android.ui.theme.Accent import com.joegec.joycon2android.ui.theme.CardBg import com.joegec.joycon2android.ui.theme.Dimens @@ -102,6 +107,8 @@ private fun JoyconAssignmentRow( Side.UNKNOWN -> joycon.deviceName } + var typeMenuOpen by remember { mutableStateOf(false) } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.cardPadding), @@ -112,11 +119,21 @@ private fun JoyconAssignmentRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - sideLabel, + ControllerTypeMenu( + joycon, + label = sideLabel, color = sideColor, - style = MaterialTheme.typography.titleSmall, + expanded = typeMenuOpen, + onExpandedChange = { typeMenuOpen = it }, + onSetType = { model -> onSetType(joycon.address, model) }, ) + if (joycon.typeOverride != null) { + Text( + stringResource(R.string.type_manual), + color = TextDim, + style = MaterialTheme.typography.bodySmall, + ) + } AnimatedVisibility( visible = joycon.connectionLost, enter = fadeIn() + expandHorizontally(), @@ -162,11 +179,13 @@ private fun JoyconAssignmentRow( ) } } - ControllerTypeDropdown( - joycon, - onSetType = { model -> onSetType(joycon.address, model) }, - modifier = Modifier.padding(horizontal = Dimens.cardPadding), - ) + if (joycon.model == ControllerModel.UNKNOWN) { + WarningBox( + stringResource(R.string.type_unrecognised), + modifier = Modifier.padding(horizontal = Dimens.cardPadding), + onClick = { typeMenuOpen = true }, + ) + } PlayerSelection(joycon, players, onAssign) } } diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt deleted file mode 100644 index 3fa4c56f..00000000 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeDropdown.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.joegec.joycon2android.assignment.presentation - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.joegec.joycon2android.model.ConnectedJoycon -import com.joegec.joycon2android.model.ControllerModel -import com.joegec.joycon2android.ui.components.DropdownOption -import com.joegec.joycon2android.ui.components.OptionDropdown - -private const val AUTO_ID = "AUTO" -private val choosableModels = ControllerModel.entries - ControllerModel.UNKNOWN - -@Composable -internal fun ControllerTypeDropdown( - joycon: ConnectedJoycon, - onSetType: (ControllerModel?) -> Unit, - modifier: Modifier = Modifier, -) { - val auto = stringResource(R.string.type_auto, joycon.detectedModel.label()) - val options = listOf(DropdownOption(AUTO_ID, auto)) + - choosableModels.map { DropdownOption(it.name, it.label()) } - OptionDropdown( - options = options, - selectedId = joycon.typeOverride?.name ?: AUTO_ID, - label = joycon.typeOverride?.label() ?: auto, - subLabel = stringResource(R.string.type_hint), - onSelect = { id -> onSetType(if (id == AUTO_ID) null else ControllerModel.valueOf(id)) }, - modifier = modifier, - ) -} diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt new file mode 100644 index 00000000..69f0fc22 --- /dev/null +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeMenu.kt @@ -0,0 +1,75 @@ +package com.joegec.joycon2android.assignment.presentation + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel +import com.joegec.joycon2android.ui.components.DropdownOption +import com.joegec.joycon2android.ui.components.PanelDropdownMenu +import com.joegec.joycon2android.ui.theme.Dimens + +private const val DETECT_ID = "DETECT" +private val choosableModels = ControllerModel.entries - ControllerModel.UNKNOWN + +@Composable +internal fun ControllerTypeMenu( + joycon: ConnectedJoycon, + label: String, + color: Color, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSetType: (ControllerModel?) -> Unit, +) { + Box { + Row( + Modifier + .clip(RoundedCornerShape(Dimens.buttonCorner)) + .clickable(onClickLabel = stringResource(R.string.type_change)) { onExpandedChange(true) } + .heightIn(min = Dimens.minTouchTarget), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, color = color, style = MaterialTheme.typography.titleSmall) + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = color, + modifier = Modifier.size(Dimens.iconSizeMedium), + ) + } + PanelDropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }, + options = typeOptions(joycon), + selectedId = joycon.typeOverride?.name ?: DETECT_ID, + onSelect = { option -> + onExpandedChange(false) + onSetType(if (option.id == DETECT_ID) null else ControllerModel.valueOf(option.id)) + }, + ) + } +} + +@Composable +private fun typeOptions(joycon: ConnectedJoycon): List { + val detect = DropdownOption( + DETECT_ID, + stringResource(R.string.type_detect), + subLabel = joycon.detectedModel.label(), + ) + return listOf(detect) + choosableModels.map { DropdownOption(it.name, it.label()) } +} diff --git a/feature/assignment/presentation/src/main/res/values/strings.xml b/feature/assignment/presentation/src/main/res/values/strings.xml index b1fc5795..9b9027bb 100644 --- a/feature/assignment/presentation/src/main/res/values/strings.xml +++ b/feature/assignment/presentation/src/main/res/values/strings.xml @@ -4,13 +4,15 @@ Right Pro GameCube - Auto: %1$s - Controller type + Change controller type + Detect automatically + SET MANUALLY + Couldn\'t tell what this controller is. Tap its name to choose. Left Joy-Con Right Joy-Con Pro Controller GameCube controller - not recognised + Not recognised CONNECTING… CONNECTION LOST · PRESS SYNC Unassigned Controllers From b4701a9ffc6084464eb794655815150258afd3d0 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:55:58 +0000 Subject: [PATCH 07/17] Load saved controller types before a scan connects anything The saved type overrides started as an empty map until DataStore answered, so a controller found in that window connected as its advertised type and kept it. A scan now starts once they have loaded: at once in the usual case, when they already have. Co-Authored-By: Claude Opus 5.5 --- .../joycon2android/connection/Joycon2Manager.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt index 8d777a45..266cbd94 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/Joycon2Manager.kt @@ -8,11 +8,14 @@ import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -29,7 +32,8 @@ class Joycon2Manager( private val scanner = BleScanner(context) private val pool = ConnectionPool(context) private val connectionJobs = mutableMapOf() - private val typeOverrides = typePreferences.overrides.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + // Null until the stored overrides load, so no controller connects as the wrong type. + private val typeOverrides = typePreferences.overrides.stateIn(scope, SharingStarted.Eagerly, null) @Volatile private var highPriority = false @@ -51,7 +55,7 @@ class Joycon2Manager( } override fun startScan() { - if (scanner.isScanning) return + if (scanner.isScanning || _scanning.value) return if (pool.size >= MAX_CONNECTIONS) return if (!scanner.isAvailable) { @@ -60,8 +64,11 @@ class Joycon2Manager( } _error.value = null - scanner.start(pool::isLive) _scanning.value = true + scope.launch(Dispatchers.Main.immediate) { + typeOverrides.filterNotNull().first() + if (_scanning.value) scanner.start(pool::isLive) + } } override fun stopScan() { @@ -144,7 +151,7 @@ class Joycon2Manager( return } - val typeOverride = typeOverrides.value[result.device.address] + val typeOverride = typeOverrides.value?.get(result.device.address) pool.connect(result, model, typeOverride, name, highPriority) ?: return onPoolChanged() } From 581d2125b6baba1ccd6472fb879e7040debe00fa Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:55:58 +0000 Subject: [PATCH 08/17] Mark a connection's factory calibration volatile It is written on the binder thread a reply arrives on and read on the main thread when the type changes. Co-Authored-By: Claude Opus 5.5 --- .../com/joegec/joycon2android/connection/JoyconConnection.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index 93bf1940..7cbc643a 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -61,7 +61,7 @@ class JoyconConnection( private val ops = GattOpQueue() private val returnWindow = ReturnWindow(RETURN_WINDOW_MS, MainThreadScheduler(), ::giveUpOnReturn) @Volatile private var calibrator = InputCalibrator(model) - private var factory = FactoryCalibration() + @Volatile private var factory = FactoryCalibration() private var gatt: BluetoothGatt? = null private var device: BluetoothDevice? = null private var common: CommonSession? = null From 65a1106140ffee513b71900a8360da946277f35b Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:55:58 +0000 Subject: [PATCH 09/17] Take each player's body from joyconSide() in every config writer EdenGamepadConfig, EdenDsuConfig and DolphinGcpadConfig each kept their own copy of PlayerState.joyconSide(), so the GameCube case had to be added three times. They now call it. Dolphin's GameCube setup still leaves a Pro Controller to the player, now under one name in both places it checks. Co-Authored-By: Claude Opus 5.5 --- .../joycon2android/dsu/emulator/EdenDsuConfig.kt | 11 ++--------- .../gamepad/emulator/DolphinGcpadConfig.kt | 16 +++++++--------- .../gamepad/emulator/EdenGamepadConfig.kt | 11 ++--------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt index ecf0aea3..6b302c2a 100644 --- a/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt +++ b/feature/dsu/domain/src/main/kotlin/com/joegec/joycon2android/dsu/emulator/EdenDsuConfig.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.emittedStick +import com.joegec.joycon2android.buttonmapping.joyconSide import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.toSourceMap @@ -90,7 +91,7 @@ object EdenDsuConfig { val slot = player.player.index - 1 if (slot !in 0 until DsuSlots.COUNT) return@forEach val type = EdenControls.npadType(player) ?: return@forEach - val side = sideFor(player) ?: return@forEach + val side = player.joyconSide() ?: return@forEach val mapping = mappingFor(PlayerBody(player.player, side)) val device = device(slot) @@ -155,12 +156,4 @@ object EdenDsuConfig { } return "axis:$axis,threshold:0.5,invert:$invert" } - - private fun sideFor(player: PlayerState): JoyconSide? = when { - player.hasGameCube -> JoyconSide.GAMECUBE - player.hasPro || player.hasFullController -> JoyconSide.DUAL - player.left != null -> JoyconSide.LEFT - player.right != null -> JoyconSide.RIGHT - else -> null - } } 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 7e171dfd..afde8ce5 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 @@ -6,6 +6,7 @@ import com.joegec.joycon2android.buttonmapping.PlayerBody import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor +import com.joegec.joycon2android.buttonmapping.joyconSide import com.joegec.joycon2android.buttonmapping.emittedStick import com.joegec.joycon2android.buttonmapping.target.GameCubeButton import com.joegec.joycon2android.buttonmapping.target.GameCubeStick @@ -83,11 +84,14 @@ object DolphinGcpadConfig { fun mergeCore(existing: String?, players: List): String { val siDevices = players - .filter { it.hasController && (!it.hasPro || it.hasGameCube) && it.player.index in 1..4 } + .filter { it.hasController && it.isSetUpByApp && it.player.index in 1..4 } .associate { "SIDevice${it.player.index - 1}" to STANDARD_CONTROLLER } return IniEditor.setKeys(existing, "[Core]", siDevices) } + // A Pro Controller's section is left to the player. + private val PlayerState.isSetUpByApp: Boolean get() = !hasPro || hasGameCube + // A player whose pad isn't enumerated yet is skipped: docs/virtual-gamepad.md#device-identity private fun sections( players: List, @@ -109,14 +113,8 @@ object DolphinGcpadConfig { deviceId: Int, mappingFor: (PlayerBody) -> Map, ): String? { - val side = when { - player.hasGameCube -> JoyconSide.GAMECUBE - player.hasPro -> return null - player.hasFullController -> JoyconSide.DUAL - player.right != null -> JoyconSide.RIGHT - player.left != null -> JoyconSide.LEFT - else -> return null - } + if (!player.isSetUpByApp) return null + val side = player.joyconSide() ?: return null val device = "Device = Android/$deviceId/Joy-Con Virtual Gamepad $index" val analogTriggers = if (player.hasAnalogTriggers) ANALOG_TRIGGER_LINES else emptyList() return (listOf(device) + lines(side, mappingFor(PlayerBody(player.player, side))) + analogTriggers) diff --git a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt index 979ee8e7..8f6f8432 100644 --- a/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt +++ b/feature/gamepad/domain/src/main/kotlin/com/joegec/joycon2android/gamepad/emulator/EdenGamepadConfig.kt @@ -7,6 +7,7 @@ import com.joegec.joycon2android.buttonmapping.StickDirection import com.joegec.joycon2android.buttonmapping.StickSource import com.joegec.joycon2android.buttonmapping.emittedFor import com.joegec.joycon2android.buttonmapping.emittedStick +import com.joegec.joycon2android.buttonmapping.joyconSide import com.joegec.joycon2android.buttonmapping.target.SwitchProButton import com.joegec.joycon2android.buttonmapping.target.SwitchProStick import com.joegec.joycon2android.buttonmapping.toSourceMap @@ -82,7 +83,7 @@ object EdenGamepadConfig { if (index !in 1..4) return@forEach val gamepad = gamepads[index] ?: return@forEach val type = EdenControls.npadType(player) ?: return@forEach - val side = sideFor(player) ?: return@forEach + val side = player.joyconSide() ?: return@forEach val layout = layoutFor(side, mappingFor(PlayerBody(player.player, side))) val p = index - 1 val device = "engine:android,port:${gamepad.port},guid:${gamepad.guid},pad:0" @@ -107,14 +108,6 @@ object EdenGamepadConfig { return keys } - private fun sideFor(player: PlayerState): JoyconSide? = when { - player.hasGameCube -> JoyconSide.GAMECUBE - player.hasPro || player.hasFullController -> JoyconSide.DUAL - player.left != null && player.right == null -> JoyconSide.LEFT - player.right != null && player.left == null -> JoyconSide.RIGHT - else -> null - } - private fun layoutFor(side: JoyconSide, mapping: Map): Layout { val buttons = mapping.toSourceMap().mapNotNull { (target, sources) -> inputFor(side, sources)?.let { EdenControls.BUTTON_KEYS.getValue(target) to it } From b83bc3f876629b873e3cdb4775c420b1495db4f5 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 03:55:59 +0000 Subject: [PATCH 10/17] Say that DSU carries no analog trigger travel Co-Authored-By: Claude Opus 5.5 --- docs/virtual-gamepad.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index d1b3471d..4d0bffe0 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -147,6 +147,9 @@ Confirmed on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller, - **The stick-click bits are free to reuse:** the controller has no stick clicks. So no binding of its can use LS/RS: `emittedFor` resolves them to nothing for it, and its Switch Pro layout leaves the stick-click targets unbound. +- **DSU carries no trigger travel.** Its packet has analog L2/R2 bytes, but `DsuPacketEncoder` fills + each analog byte from its button, 0 or 255, and this controller's ZL/ZR bits are moved, so L2/R2 + stay 0. Its buttons reach DSU as they reach the pad: ZL/Z as L1/R1, the clicks as L3/R3. For such a player, Dolphin setup adds ``Triggers/L-Analog = `Axis 23+` `` and ``Triggers/R-Analog = `Axis 22+` ``. Dolphin names Android axes by number, and a trigger has only a `+` From bb3237b1ddf8dd82aa74e3507591d01889d19263 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 04:07:45 +0000 Subject: [PATCH 11/17] Keep controllers with analog triggers off the console protocol The console fallback takes over after 1.5 s of silence on the common channel, or on an SMP security request. The console report has no trigger bytes and its parser knows only the Joy-Con's buttons, so a GameCube controller moved there would lose its triggers. It answers on the common channel, so this hasn't been seen, but nothing stopped it. Now a controller with analog triggers never starts a console session, and on a security request it goes on to the common channel. Co-Authored-By: Claude Opus 5.5 --- docs/protocol.md | 3 ++- .../joegec/joycon2android/connection/JoyconConnection.kt | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index edb1c92a..10dc558f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -351,7 +351,8 @@ Joy-Con 2 never does. Android pairs one device at a time, so a second clone conn first one's pairing is pending sends none, and silence on the common channel 1.5 s after init switches it over instead. Genuine Joy-Con 2s answer the console channel too, so a controller moved over by that timeout returns to the common path on its first report on `...fd2`, which only a -controller speaking that protocol sends. +controller speaking that protocol sends. A controller with analog triggers (the GameCube controller) +never switches: the console report has no trigger bytes, and it answers on the common channel. **Holding the link.** The pairing never succeeds (Confirm Value Failed, or a 30 s timeout): diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index 7cbc643a..76508e63 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -83,6 +83,9 @@ class JoyconConnection( val awaitingReturn: Boolean get() = returnWindow.isOpen + // The console report has no trigger bytes: docs/protocol.md#android-workarounds + private val mayUseConsole: Boolean get() = !model.hasAnalogTriggers + fun connect(device: BluetoothDevice) { this.device = device gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) @@ -189,7 +192,7 @@ class JoyconConnection( } servicesDiscovered = true - if (securityRequested) { + if (securityRequested && mayUseConsole) { startConsoleSession(g) return } @@ -261,14 +264,14 @@ class JoyconConnection( private fun commonChannelAnswered() = commonReportSeen || commandReplySeen private fun fallBackToConsoleIfSilent(g: BluetoothGatt) { - if (commonChannelAnswered()) return + if (commonChannelAnswered() || !mayUseConsole) return Log.i(TAG, "[$side] No reply on the common channel; switching to the console protocol") startConsoleSession(g) } @Synchronized private fun startConsoleSession(g: BluetoothGatt) { - if (console != null || commonReportSeen || gatt !== g) return + if (!mayUseConsole || console != null || commonReportSeen || gatt !== g) return val channel = ConsoleChannel.find(g) if (channel == null) { fail("Not a compatible Joy-Con 2") From 3d808d83473a88868c7febfd6d9594c3478436a6 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 04:15:05 +0000 Subject: [PATCH 12/17] Test the console fallback guard and how saved controller types read back Both rules lived inside Android classes no unit test can build. They now sit in ConsoleFallback and StoredControllerModel, with tests: a GameCube controller never falls back to the console protocol, and a side saved by an earlier build reads as no override. Co-Authored-By: Claude Opus 5.5 --- .../connection/ConsoleFallback.kt | 8 ++++++ .../ControllerTypePreferencesDataStore.kt | 2 +- .../connection/JoyconConnection.kt | 3 +- .../connection/StoredControllerModel.kt | 9 ++++++ .../connection/ConsoleFallbackTest.kt | 21 ++++++++++++++ .../connection/StoredControllerModelTest.kt | 28 +++++++++++++++++++ 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt create mode 100644 feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt create mode 100644 feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt new file mode 100644 index 00000000..b504ec1d --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConsoleFallback.kt @@ -0,0 +1,8 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel + +// The console report has no trigger bytes: docs/protocol.md#android-workarounds +internal object ConsoleFallback { + fun allows(model: ControllerModel) = !model.hasAnalogTriggers +} diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt index 849fd2c0..267bfdf7 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ControllerTypePreferencesDataStore.kt @@ -18,7 +18,7 @@ class ControllerTypePreferencesDataStore(context: Context) : ControllerTypePrefe override val overrides: Flow> = dataStore.data.map { prefs -> prefs.asMap().mapNotNull { (key, value) -> - val model = runCatching { ControllerModel.valueOf(value as String) }.getOrNull() ?: return@mapNotNull null + val model = StoredControllerModel.parse(value) ?: return@mapNotNull null key.name to model }.toMap() } diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt index 76508e63..6e9e2fc0 100644 --- a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/JoyconConnection.kt @@ -83,8 +83,7 @@ class JoyconConnection( val awaitingReturn: Boolean get() = returnWindow.isOpen - // The console report has no trigger bytes: docs/protocol.md#android-workarounds - private val mayUseConsole: Boolean get() = !model.hasAnalogTriggers + private val mayUseConsole: Boolean get() = ConsoleFallback.allows(model) fun connect(device: BluetoothDevice) { this.device = device diff --git a/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt new file mode 100644 index 00000000..69077489 --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/StoredControllerModel.kt @@ -0,0 +1,9 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel + +// A side saved by an earlier build, or a model the menu can't choose, reads as no override. +internal object StoredControllerModel { + fun parse(value: Any?): ControllerModel? = + ControllerModel.entries.firstOrNull { it.name == value && it != ControllerModel.UNKNOWN } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt new file mode 100644 index 00000000..00452092 --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/ConsoleFallbackTest.kt @@ -0,0 +1,21 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConsoleFallbackTest { + + @Test + fun `a controller with analog triggers never falls back to the console protocol`() { + assertFalse(ConsoleFallback.allows(ControllerModel.GAMECUBE)) + } + + @Test + fun `a Joy-Con or an unrecognised controller still can`() { + assertTrue(ConsoleFallback.allows(ControllerModel.JOYCON_LEFT)) + assertTrue(ConsoleFallback.allows(ControllerModel.JOYCON_RIGHT)) + assertTrue(ConsoleFallback.allows(ControllerModel.UNKNOWN)) + } +} diff --git a/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt new file mode 100644 index 00000000..5325e65b --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/StoredControllerModelTest.kt @@ -0,0 +1,28 @@ +package com.joegec.joycon2android.connection + +import com.joegec.joycon2android.model.ControllerModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StoredControllerModelTest { + + @Test + fun `every model the menu offers reads back as itself`() { + (ControllerModel.entries - ControllerModel.UNKNOWN).forEach { model -> + assertEquals(model, StoredControllerModel.parse(model.name)) + } + } + + @Test + fun `a side saved by an earlier build reads as no override`() { + assertNull(StoredControllerModel.parse("PRO")) + assertNull(StoredControllerModel.parse("LEFT")) + } + + @Test + fun `unknown, or anything that isn't a model name, reads as no override`() { + assertNull(StoredControllerModel.parse("UNKNOWN")) + assertNull(StoredControllerModel.parse(42)) + } +} From 3a999f16cb5de367c2b44b3afd47975664537678 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 04:15:05 +0000 Subject: [PATCH 13/17] List the GameCube controller among the controller types a bug report names Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00a51cdc..14d55f57 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) From 543a4dcb82c6c567a64c834c6d49e9258f01378b Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 04:21:48 +0000 Subject: [PATCH 14/17] Say the bumper layout was confirmed in Dolphin only Co-Authored-By: Claude Opus 5.5 --- docs/virtual-gamepad.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 4d0bffe0..072e069c 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -139,7 +139,8 @@ so each lands where Android expects that control: | ZL / Z (the ZR bit) | 6 / 7 | `BUTTON_L1` / `BUTTON_R1` | | L / R click at the bottom | 13 / 14 | `BUTTON_THUMBL` / `BUTTON_THUMBR` | -Confirmed on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller, 2026-10-13. +Confirmed in Dolphin on a Retroid Pocket Nova (Android 13) with an NSO GameCube controller, +2026-10-13. Eden is untested on hardware. - **Nothing goes on `BUTTON_L2`/`BUTTON_R2`:** they can follow the trigger axes. Retroid Pocket Nova, Android 13, 2026-10-08: with Z on R2, pulling R fired Dolphin's `Button R2` and Z fired nothing. From 8220eeca63bf5a2907d07ab2cb79b698d40a152a Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 22:43:45 +0000 Subject: [PATCH 15/17] Name a GameCube controller as one wherever a player's controllers are listed The assignment card said GameCube, but once assigned, the compact player row and the mapping card said Pro for any Pro-shaped body. All three now name a controller by its model through the same exhaustive when, so a new model fails to build until each has a word for it. Co-Authored-By: Claude Opus 5.5 --- .../presentation/ControllerModelLabel.kt | 16 ++++++++++++++++ .../presentation/PlayerMappingCard.kt | 10 +++++----- .../presentation/src/main/res/values/strings.xml | 1 + .../assignment/presentation/AssignmentPanel.kt | 8 +------- .../presentation/ControllerTypeLabels.kt | 10 ++++++++++ .../connection/presentation/CompactPlayerRow.kt | 14 +++++++------- .../presentation/ControllerModelLabel.kt | 15 +++++++++++++++ .../presentation/src/main/res/values/strings.xml | 1 + 8 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt create mode 100644 feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt new file mode 100644 index 00000000..14dcdf2f --- /dev/null +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/ControllerModelLabel.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.buttonmapping.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.core.buttonmapping.presentation.R +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel + +@Composable +internal fun ConnectedJoycon.modelLabel(): String = when (model) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.controller_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.controller_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.controller_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.controller_gamecube) + ControllerModel.UNKNOWN -> deviceName +} diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt index 5945c835..9d034343 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/PlayerMappingCard.kt @@ -149,18 +149,18 @@ private fun CardHeader(player: PlayerState, layoutName: String?, expanded: Boole private fun ControllerChips(player: PlayerState) { Row(horizontalArrangement = Arrangement.spacedBy(Dimens.compactControllerGap)) { if (player.hasPro) { - ControllerChip(R.string.controller_pro, player.left!!) + ControllerChip(player.left!!.modelLabel(), player.left!!) } else { - player.left?.let { ControllerChip(R.string.controller_left, it) } - player.right?.let { ControllerChip(R.string.controller_right, it) } + player.left?.let { ControllerChip(stringResource(R.string.controller_left), it) } + player.right?.let { ControllerChip(stringResource(R.string.controller_right), it) } } } } @Composable -private fun ControllerChip(textRes: Int, joycon: ConnectedJoycon) { +private fun ControllerChip(text: String, joycon: ConnectedJoycon) { Text( - stringResource(textRes), + text, color = joyconBorderColor(joycon.accentColor, JoyconDefaultColor), style = MaterialTheme.typography.titleMedium, ) diff --git a/core/buttonmapping/presentation/src/main/res/values/strings.xml b/core/buttonmapping/presentation/src/main/res/values/strings.xml index 83436093..a049a7d2 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Left Right Pro + GameCube Standard Wii Like a Wiimote, ZR \u2192 B diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt index 9de0f485..59666078 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/AssignmentPanel.kt @@ -99,13 +99,7 @@ private fun JoyconAssignmentRow( onSetType: (String, ControllerModel?) -> Unit, ) { val sideColor = joyconBorderColor(joycon.accentColor, JoyconDefaultColor) - val sideLabel = when (joycon.side) { - Side.LEFT -> stringResource(R.string.side_left) - Side.RIGHT -> stringResource(R.string.side_right) - Side.PRO -> if (joycon.model == ControllerModel.GAMECUBE) stringResource(R.string.side_gamecube) - else stringResource(R.string.side_pro) - Side.UNKNOWN -> joycon.deviceName - } + val sideLabel = joycon.modelLabel() var typeMenuOpen by remember { mutableStateOf(false) } diff --git a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt index 6515e5be..b64bf236 100644 --- a/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt +++ b/feature/assignment/presentation/src/main/kotlin/com/joegec/joycon2android/assignment/presentation/ControllerTypeLabels.kt @@ -2,6 +2,7 @@ package com.joegec.joycon2android.assignment.presentation import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon import com.joegec.joycon2android.model.ControllerModel @Composable @@ -12,3 +13,12 @@ internal fun ControllerModel.label(): String = when (this) { ControllerModel.GAMECUBE -> stringResource(R.string.type_gamecube) ControllerModel.UNKNOWN -> stringResource(R.string.type_unknown) } + +@Composable +internal fun ConnectedJoycon.modelLabel(): String = when (model) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.side_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.side_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.side_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.side_gamecube) + ControllerModel.UNKNOWN -> deviceName +} diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt index e27bbe76..0057a00f 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/CompactPlayerRow.kt @@ -60,14 +60,14 @@ fun CompactPlayerRow( Row(horizontalArrangement = Arrangement.spacedBy(Dimens.compactControllerGap)) { when { playerState.hasPro -> - ControllerChip(R.string.controller_pro, playerState.left!!, onUnassign) + ControllerChip(playerState.left!!.modelLabel(), playerState.left!!, onUnassign) playerState.hasFullController -> { - ControllerChip(R.string.controller_left, playerState.left!!, onUnassign, batteryFirst = true) - ControllerChip(R.string.controller_right, playerState.right!!, onUnassign) + ControllerChip(stringResource(R.string.controller_left), playerState.left!!, onUnassign, batteryFirst = true) + ControllerChip(stringResource(R.string.controller_right), playerState.right!!, onUnassign) } else -> { - playerState.left?.let { ControllerChip(R.string.controller_left, it, onUnassign) } - playerState.right?.let { ControllerChip(R.string.controller_right, it, onUnassign) } + playerState.left?.let { ControllerChip(stringResource(R.string.controller_left), it, onUnassign) } + playerState.right?.let { ControllerChip(stringResource(R.string.controller_right), it, onUnassign) } } } } @@ -82,7 +82,7 @@ fun CompactPlayerRow( @Composable private fun ControllerChip( - textRes: Int, + text: String, joycon: ConnectedJoycon, onUnassign: (String) -> Unit, batteryFirst: Boolean = false, @@ -114,7 +114,7 @@ private fun ControllerChip( if (batteryFirst) battery() Text( - stringResource(textRes), + text, color = joyconBorderColor(joycon.accentColor, JoyconDefaultColor), style = MaterialTheme.typography.titleMedium, ) diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt new file mode 100644 index 00000000..d85627b0 --- /dev/null +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ControllerModelLabel.kt @@ -0,0 +1,15 @@ +package com.joegec.joycon2android.connection.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.joegec.joycon2android.model.ConnectedJoycon +import com.joegec.joycon2android.model.ControllerModel + +@Composable +internal fun ConnectedJoycon.modelLabel(): String = when (model) { + ControllerModel.JOYCON_LEFT -> stringResource(R.string.controller_left) + ControllerModel.JOYCON_RIGHT -> stringResource(R.string.controller_right) + ControllerModel.PRO_CONTROLLER -> stringResource(R.string.controller_pro) + ControllerModel.GAMECUBE -> stringResource(R.string.controller_gamecube) + ControllerModel.UNKNOWN -> deviceName +} diff --git a/feature/connection/presentation/src/main/res/values/strings.xml b/feature/connection/presentation/src/main/res/values/strings.xml index aa508897..935a0244 100644 --- a/feature/connection/presentation/src/main/res/values/strings.xml +++ b/feature/connection/presentation/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Left Right Pro + GameCube Remove player Connection lost Connection lost. Press SYNC on the controller to bring it back. From a09f182a0d7ec6cab8da782d95fd308e57488bb1 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 22:43:45 +0000 Subject: [PATCH 16/17] Draw only a GameCube controller's own buttons in its detailed view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It used the Pro Controller's layout as is: GL, GR, − and the stick clicks, with Z labelled ZR. GameCubeButtons in core:model now holds what it has and its Z marking; the detailed view leaves out the rest and marks Z, and the mapping editor offers from the same list. Co-Authored-By: Claude Opus 5.5 --- .../presentation/MappingOptions.kt | 11 ++--- .../joycon2android/model/GameCubeButtons.kt | 14 ++++++ .../model/GameCubeButtonsTest.kt | 22 ++++++++++ docs/DESIGN.md | 3 ++ .../presentation/ProControllerLayout.kt | 43 +++++++++++-------- 5 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt create mode 100644 core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt diff --git a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt index cb4a755c..670fcb23 100644 --- a/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt +++ b/core/buttonmapping/presentation/src/main/kotlin/com/joegec/joycon2android/buttonmapping/presentation/MappingOptions.kt @@ -17,6 +17,7 @@ import com.joegec.joycon2android.buttonmapping.target.WiimoteButton import com.joegec.joycon2android.buttonmapping.target.WiimoteStick import com.joegec.joycon2android.core.buttonmapping.presentation.R import com.joegec.joycon2android.model.AndroidKey +import com.joegec.joycon2android.model.GameCubeButtons import com.joegec.joycon2android.model.JoyconButton internal object MappingOptions { @@ -88,12 +89,7 @@ internal object MappingOptions { // Only what that side physically has, so every choice can fire. private fun physicalButtons(side: JoyconSide): List = when (side) { JoyconSide.DUAL -> JoyconButton.entries - JoyconSide.GAMECUBE -> listOf( - JoyconButton.A, JoyconButton.B, JoyconButton.X, JoyconButton.Y, - JoyconButton.L, JoyconButton.R, JoyconButton.ZL, JoyconButton.ZR, JoyconButton.Plus, - JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, - JoyconButton.Home, JoyconButton.Capture, JoyconButton.Chat, - ) + JoyconSide.GAMECUBE -> GameCubeButtons.present.toList() JoyconSide.LEFT -> listOf( JoyconButton.L, JoyconButton.ZL, JoyconButton.Minus, JoyconButton.LS, JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, @@ -106,7 +102,6 @@ internal object MappingOptions { ) } - // The GameCube controller's Z reports as ZR. private fun JoyconButton.markingOn(side: JoyconSide): String = - if (side == JoyconSide.GAMECUBE && this == JoyconButton.ZR) "Z" else id + (if (side == JoyconSide.GAMECUBE) GameCubeButtons.marking(this) else null) ?: id } diff --git a/core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt new file mode 100644 index 00000000..6e98440d --- /dev/null +++ b/core/model/src/main/kotlin/com/joegec/joycon2android/model/GameCubeButtons.kt @@ -0,0 +1,14 @@ +package com.joegec.joycon2android.model + +// The NSO GameCube controller has no −, stick clicks or back paddles. +object GameCubeButtons { + val present: Set = setOf( + JoyconButton.A, JoyconButton.B, JoyconButton.X, JoyconButton.Y, + JoyconButton.L, JoyconButton.R, JoyconButton.ZL, JoyconButton.ZR, JoyconButton.Plus, + JoyconButton.Up, JoyconButton.Down, JoyconButton.Left, JoyconButton.Right, + JoyconButton.Home, JoyconButton.Capture, JoyconButton.Chat, + ) + + /** Null where the controller marks the button as the Switch 2 controllers do. Its Z reports as ZR. */ + fun marking(button: JoyconButton): String? = if (button == JoyconButton.ZR) "Z" else null +} diff --git a/core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt b/core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt new file mode 100644 index 00000000..f06607c8 --- /dev/null +++ b/core/model/src/test/kotlin/com/joegec/joycon2android/model/GameCubeButtonsTest.kt @@ -0,0 +1,22 @@ +package com.joegec.joycon2android.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +class GameCubeButtonsTest { + + @Test + fun `a gamecube controller has no minus, stick clicks or back paddles`() { + listOf(JoyconButton.Minus, JoyconButton.LS, JoyconButton.RS, JoyconButton.GL, JoyconButton.GR).forEach { + assertFalse("$it", it in GameCubeButtons.present) + } + } + + @Test + fun `its ZR is marked Z and every other button keeps its marking`() { + assertEquals("Z", GameCubeButtons.marking(JoyconButton.ZR)) + assertNull(GameCubeButtons.marking(JoyconButton.ZL)) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6a9a4992..f8e41dd0 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -106,6 +106,9 @@ In `JoyconScreen.kt`: a `PanelDropdownMenu`: Detect automatically first, sub-labelled with what was detected, then the four types. A manual choice shows `SET MANUALLY` beside the label. Only an unrecognised controller gets a `WarningBox`, which opens the same menu; a recognised one costs no extra row. +- **A GameCube controller is named and drawn as itself.** Its card, compact row and mapping card + say GameCube, and its detailed view is the Pro layout without the buttons it lacks, with ZR marked + Z. `GameCubeButtons` is the one list of what it has, which the mapping editor offers too. ### Landscape diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt index 76de6df7..eae46acd 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.joegec.joycon2android.model.GameCubeButtons import com.joegec.joycon2android.model.JoyconButton import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.ui.theme.Dimens @@ -21,21 +22,25 @@ internal fun ProControllerLayout( modifier: Modifier = Modifier, ) { val pressed = state.pressed + val buttons = if (state.hasGameCube) GameCubeButtons.present else JoyconButton.entries.toSet() + val marking = { button: JoyconButton -> + (if (state.hasGameCube) GameCubeButtons.marking(button) else null) ?: button.label + } JoyconCard( controller = state.left, onClick = { onUnassign(state.left!!.address) }, modifier = modifier.fillMaxWidth(), ) { - ShoulderButtons(pressed) - ButtonRow(state, pressed) - SticksRow(state, pressed) + ShoulderButtons(pressed, buttons, marking) + ButtonRow(state, pressed, buttons) + SticksRow(state, pressed, buttons) SpecialButtons(pressed) SidewaysImuDisplay(state.leftInput) } } @Composable -private fun ShoulderButtons(pressed: Set) { +private fun ShoulderButtons(pressed: Set, buttons: Set, marking: (JoyconButton) -> String) { Row( Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), @@ -44,43 +49,47 @@ private fun ShoulderButtons(pressed: Set) { Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap), ) { - ShoulderButton(JoyconButton.ZL.label, JoyconButton.ZL.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.L.label, JoyconButton.L.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.GL.label, JoyconButton.GL.id in pressed, Modifier.fillMaxWidth()) + listOf(JoyconButton.ZL, JoyconButton.L, JoyconButton.GL).filter { it in buttons }.forEach { + ShoulderButton(marking(it), it.id in pressed, Modifier.fillMaxWidth()) + } } Column( Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap), ) { - ShoulderButton(JoyconButton.ZR.label, JoyconButton.ZR.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.R.label, JoyconButton.R.id in pressed, Modifier.fillMaxWidth()) - ShoulderButton(JoyconButton.GR.label, JoyconButton.GR.id in pressed, Modifier.fillMaxWidth()) + listOf(JoyconButton.ZR, JoyconButton.R, JoyconButton.GR).filter { it in buttons }.forEach { + ShoulderButton(marking(it), it.id in pressed, Modifier.fillMaxWidth()) + } } } } +// The ends are weighted so the battery stays centred when − is missing. @Composable -private fun ButtonRow(state: PlayerState, pressed: Set) { +private fun ButtonRow(state: PlayerState, pressed: Set, buttons: Set) { Row( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (JoyconButton.Minus in buttons) SmallButton(JoyconButton.Minus.label, JoyconButton.Minus.id in pressed) + } state.leftInput.battery?.let { BatteryPill(it) } - SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + SmallButton(JoyconButton.Plus.label, JoyconButton.Plus.id in pressed) + } } } @Composable -private fun SticksRow(state: PlayerState, pressed: Set) { +private fun SticksRow(state: PlayerState, pressed: Set, buttons: Set) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - StickCard(state.leftStickX, state.leftStickY, JoyconButton.LS.id in pressed, + StickCard(state.leftStickX, state.leftStickY, JoyconButton.LS in buttons && JoyconButton.LS.id in pressed, canvasSize = Dimens.sidewaysStickSize) } Column( @@ -91,7 +100,7 @@ private fun SticksRow(state: PlayerState, pressed: Set) { FaceButtons(pressed, buttonSize = Dimens.sidewaysFaceSize) } Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - StickCard(state.rightStickX, state.rightStickY, JoyconButton.RS.id in pressed, + StickCard(state.rightStickX, state.rightStickY, JoyconButton.RS in buttons && JoyconButton.RS.id in pressed, canvasSize = Dimens.sidewaysStickSize) } } From ee2ea8073973fd689c735e99d1b9ad3e6c4de6a1 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Tue, 13 Oct 2026 23:42:03 +0000 Subject: [PATCH 17/17] Put a GameCube controller's L and R above ZL and Z in its detailed view L and R are its triggers, so they head the shoulder columns, the place ZL and ZR take on a Pro Controller. The two columns now share one ShoulderColumn. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 2 +- .../presentation/ProControllerLayout.kt | 43 +++++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f8e41dd0..5889e16f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -108,7 +108,7 @@ In `JoyconScreen.kt`: gets a `WarningBox`, which opens the same menu; a recognised one costs no extra row. - **A GameCube controller is named and drawn as itself.** Its card, compact row and mapping card say GameCube, and its detailed view is the Pro layout without the buttons it lacks, with ZR marked - Z. `GameCubeButtons` is the one list of what it has, which the mapping editor offers too. + Z and its triggers, L and R, above ZL and Z. `GameCubeButtons` is the one list of what it has, which the mapping editor offers too. ### Landscape diff --git a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt index eae46acd..55aae0a4 100644 --- a/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt +++ b/feature/connection/presentation/src/main/kotlin/com/joegec/joycon2android/connection/presentation/ProControllerLayout.kt @@ -31,7 +31,7 @@ internal fun ProControllerLayout( onClick = { onUnassign(state.left!!.address) }, modifier = modifier.fillMaxWidth(), ) { - ShoulderButtons(pressed, buttons, marking) + ShoulderButtons(pressed, shoulders(state.hasGameCube), marking) ButtonRow(state, pressed, buttons) SticksRow(state, pressed, buttons) SpecialButtons(pressed) @@ -39,28 +39,35 @@ internal fun ProControllerLayout( } } +// A GameCube controller's L and R are its triggers, so they sit above ZL and Z. +private fun shoulders(gameCube: Boolean): Pair, List> = + if (gameCube) listOf(JoyconButton.L, JoyconButton.ZL) to listOf(JoyconButton.R, JoyconButton.ZR) + else listOf(JoyconButton.ZL, JoyconButton.L, JoyconButton.GL) to listOf(JoyconButton.ZR, JoyconButton.R, JoyconButton.GR) + @Composable -private fun ShoulderButtons(pressed: Set, buttons: Set, marking: (JoyconButton) -> String) { +private fun ShoulderButtons( + pressed: Set, + shoulders: Pair, List>, + marking: (JoyconButton) -> String, +) { Row( Modifier.fillMaxWidth().height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(Dimens.elementSpacing), ) { - Column( - Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap), - ) { - listOf(JoyconButton.ZL, JoyconButton.L, JoyconButton.GL).filter { it in buttons }.forEach { - ShoulderButton(marking(it), it.id in pressed, Modifier.fillMaxWidth()) - } - } - Column( - Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap), - ) { - listOf(JoyconButton.ZR, JoyconButton.R, JoyconButton.GR).filter { it in buttons }.forEach { - ShoulderButton(marking(it), it.id in pressed, Modifier.fillMaxWidth()) - } - } + ShoulderColumn(shoulders.first, pressed, marking, Modifier.weight(1f)) + ShoulderColumn(shoulders.second, pressed, marking, Modifier.weight(1f)) + } +} + +@Composable +private fun ShoulderColumn( + buttons: List, + pressed: Set, + marking: (JoyconButton) -> String, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(Dimens.sidewaysShoulderGap)) { + buttons.forEach { ShoulderButton(marking(it), it.id in pressed, Modifier.fillMaxWidth()) } } }