From dd6e9b8cc86e25bffe1b35bf9eebdee284f11a50 Mon Sep 17 00:00:00 2001 From: thisishuey Date: Thu, 8 Oct 2026 18:11:53 +0000 Subject: [PATCH] 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/ConnectionPool.kt | 11 ++- .../ControllerTypePreferencesDataStore.kt | 32 +++++++ .../connection/FactoryCalibrationReads.kt | 29 +++++++ .../connection/Joycon2Manager.kt | 19 ++++- .../connection/JoyconAdvertisement.kt | 17 +++- .../connection/JoyconConnection.kt | 35 ++++++-- .../joycon2android/connection/PacketParser.kt | 7 ++ .../connection/SpiColorParser.kt | 26 +----- .../connection/SpiReadCommand.kt | 16 ++++ .../joycon2android/connection/SpiReadReply.kt | 27 ++++++ .../connection/SpiStickCalibration.kt | 33 +++++++ .../connection/SpiTriggerZeros.kt | 17 ++++ .../connection/JoyconAdvertisementTest.kt | 17 ++++ .../connection/PacketParserTest.kt | 18 ++++ .../connection/SpiReadCommandTest.kt | 31 +++++++ .../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 +++ 71 files changed, 1156 insertions(+), 117 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/SpiReadCommand.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/SpiReadCommandTest.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 fe69e8df..95ad9e28 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. @@ -31,7 +32,7 @@ as an ordinary gamepad. ### What you need - Android 7.0 (API 24) or newer, with Bluetooth LE -- Joy-Con 2 or a Switch 2 Pro Controller +- Joy-Con 2, a Switch 2 Pro Controller or an NSO GameCube controller - [Shizuku](https://shizuku.rikka.app/), for the virtual gamepad and emulator auto setup — DSU motion works without it @@ -54,7 +55,11 @@ as an ordinary gamepad. 1. Hold **SYNC** on the controller and tap **Scan**. 2. Assign each controller to a player — from the app, or with the Switch combos: **L** on one Joy-Con - and **R** on another for a pair, **SL + SR** for a sideways Joy-Con, **L + R** on a Pro Controller. + and **R** on another for a pair, **SL + SR** for a sideways Joy-Con, **L + R** on a Pro Controller + or GameCube controller. +3. If a controller shows as the wrong type, pick the right one under **Controller type** on its card + before assigning it. The choice is remembered for that controller; **Auto** goes back to what it + reports. ### 3. Turn on the virtual gamepad @@ -92,12 +97,13 @@ the emulator afterwards — it only reads its config when it starts. | Card | Emulators | What it writes | |---|---|---| -| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks | +| Virtual Gamepad | Eden, Eden Nightly, Dolphin (GameCube) | buttons and sticks; analog L/R for a GameCube controller | | DSU Motion Server | Eden, Eden Nightly, Dolphin (Wii) | buttons, sticks and motion | The gamepad button beside **Set up** opens the mapping editor, with a card per connected player — tap one to open its bindings. A single Joy-Con is set up as a Pro Controller held sideways, so every button works in every game ([why](docs/virtual-gamepad.md#why-theyre-set-up-as-pro-controllers)). A +GameCube controller has layouts of its own, with Z on its Z button and analog L/R in Dolphin. A target can take **several sources** — tick as many as you like, and any of them fires it. - **Layouts.** Each player picks a layout to start from, which resets their changes. The card reads @@ -227,6 +233,7 @@ shoulder buttons. | "Shizuku is not running" | Open Shizuku and start it | | "Shizuku permission denied" | Shizuku → Apps → allow Joycon2Android | | Controller not found | Hold SYNC again and move closer | +| Controller shows as the wrong type (stick turned sideways, no right stick) | Set **Controller type** on its card, then assign it | | Controller stops responding | Press SYNC and reconnect; wait a moment if it stays silent | | Gamepad doesn't show up in games | Check `adb shell getevent -p` lists "Joy-Con Virtual Gamepad" | | No DSUClient device in the emulator | Check the server address, restart the emulator and open a mapping screen; `adb logcat -s DsuServer` shows whether it's connecting | diff --git a/app/src/main/java/com/joegec/joycon2android/AppContainer.kt b/app/src/main/java/com/joegec/joycon2android/AppContainer.kt index 1c22642e..864e99b3 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 @@ -47,6 +48,7 @@ import com.joegec.joycon2android.emulator.edenGamepads import com.joegec.joycon2android.session.AssignControllerUseCase import com.joegec.joycon2android.session.ObserveSessionUseCase import com.joegec.joycon2android.session.SessionCoordinator +import com.joegec.joycon2android.session.SetControllerTypeUseCase import com.joegec.joycon2android.session.UnassignControllerUseCase import com.joegec.joycon2android.dsu.DisableDsuUseCase import com.joegec.joycon2android.dsu.DsuRepository @@ -97,7 +99,7 @@ class AppContainer(context: Context) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) // --- Connection (BLE) --- - private val joycon2Manager = Joycon2Manager(appContext, scope) + private val joycon2Manager = Joycon2Manager(appContext, scope, ControllerTypePreferencesDataStore(appContext)) val controllerRepository: ControllerRepository = joycon2Manager private val connectionPriorityRepository: ConnectionPriorityRepository = joycon2Manager private val setHighConnectionPriority = SetHighConnectionPriorityUseCase(connectionPriorityRepository) @@ -230,6 +232,7 @@ class AppContainer(context: Context) { val observeSession = ObserveSessionUseCase(sessionCoordinator) val assignController = AssignControllerUseCase(sessionCoordinator) val unassignController = UnassignControllerUseCase(sessionCoordinator) + val setControllerType = SetControllerTypeUseCase(sessionCoordinator) fun disconnectAll() { disableGamepad() diff --git a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt index 98dad2f8..78a5a3e6 100644 --- a/app/src/main/java/com/joegec/joycon2android/MainActivity.kt +++ b/app/src/main/java/com/joegec/joycon2android/MainActivity.kt @@ -335,6 +335,7 @@ class MainActivity : ComponentActivity() { onAssign = viewModel::assignToPlayer, onUnassign = viewModel::unassign, onDisconnect = viewModel::disconnect, + onSetControllerType = viewModel::setControllerType, onGamepadToggle = { enabled -> gamepadViewModel.toggle(enabled, state.activePlayers) }, 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 f29af5fc..00c19202 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.service.Joycon2Service import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -91,6 +92,8 @@ class Joycon2ViewModel(application: Application) : AndroidViewModel(application) fun disconnect(address: String) = container.disconnectController(address) + fun setControllerType(address: String, side: Side?) = container.setControllerType(address, side) + fun onPermissionsDenied() { _permissionDenied.value = true } 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 aa328933..bf25294d 100644 --- a/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt +++ b/app/src/main/java/com/joegec/joycon2android/ui/JoyconScreen.kt @@ -82,6 +82,7 @@ import com.joegec.joycon2android.R import com.joegec.joycon2android.model.AppUiState import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState +import com.joegec.joycon2android.model.Side import com.joegec.joycon2android.gamepad.presentation.ShizukuSetupCard import com.joegec.joycon2android.assignment.presentation.AssignmentPanel import com.joegec.joycon2android.connection.presentation.CompactPlayerRow @@ -145,6 +146,7 @@ fun JoyconScreen( onAssign: (String, PlayerNumber) -> Unit, onUnassign: (String) -> Unit, onDisconnect: (String) -> Unit, + onSetControllerType: (String, Side?) -> Unit, onGamepadToggle: (Boolean) -> Unit, gamepadEmulators: List, selectedGamepadEmulator: String, @@ -265,6 +267,7 @@ fun JoyconScreen( gamepadEmulators, selectedGamepadEmulator, onSelectGamepadEmulator, gamepadSetupPhase, onConfigureGamepad, onOpenGamepadMapping, onScan, onDisconnectAll, onAssign, unassignController, removePlayer, onDisconnect, + onSetControllerType, onGamepadToggle, onDsuToggle, onSelectDsuEmulator, onConfigureDsu, onOpenDsuMapping, ) else -> ScanningContent(state) @@ -509,6 +512,7 @@ private fun ConnectedContent( onUnassign: (String) -> Unit, onRemovePlayer: (PlayerState) -> Unit, onDisconnect: (String) -> Unit, + onSetControllerType: (String, Side?) -> Unit, onGamepadToggle: (Boolean) -> Unit, onDsuToggle: (Boolean) -> Unit, onSelectDsuEmulator: (String) -> Unit, @@ -525,6 +529,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 25bea766..9d3ff79f 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 @@ -28,6 +28,7 @@ internal fun JoyconSide.shortLabel(): String = when (this) { JoyconSide.LEFT -> stringResource(R.string.side_left_short) JoyconSide.RIGHT -> stringResource(R.string.side_right_short) JoyconSide.DUAL -> stringResource(R.string.side_dual_short) + JoyconSide.GAMECUBE -> stringResource(R.string.side_gamecube_short) } @Composable 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 1148a9f4..6a8ee639 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 @@ -21,7 +21,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 @Composable fun targets(console: Console): List> = @@ -58,20 +58,20 @@ internal object MappingOptions { @Composable fun sources(side: JoyconSide): 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) } + 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()) } } @@ -79,6 +79,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, @@ -90,4 +96,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 88bfb064..5b92e948 100644 --- a/core/buttonmapping/presentation/src/main/res/values/strings.xml +++ b/core/buttonmapping/presentation/src/main/res/values/strings.xml @@ -36,6 +36,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 839e9f2c..5f0f16bc 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 @@ -3,6 +3,8 @@ package com.joegec.joycon2android.model data class ConnectedJoycon( val address: String, val side: Side, + val model: ControllerModel = ControllerModel.UNKNOWN, + val typeOverride: Side? = null, val deviceName: String, val connectionState: JoyconConnectionState = JoyconConnectionState(), val input: JoyconInput = JoyconInput(), 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 c163fda3..347f8318 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 @@ -15,4 +15,6 @@ data class JoyconInput( val gyroY: Int = 0, val gyroZ: Int = 0, val batteryVolts: Float = 0f, + 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 bda78856..5f46306a 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 65540c2a..83e750ee 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. @@ -201,11 +241,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. ## Android BLE gotchas diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 3ed21eac..b454e8a7 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 | ## Buttons and keycodes @@ -53,15 +53,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` = −, …). @@ -98,6 +100,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 3d4a36d6..085f4af6 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 @@ -50,6 +51,7 @@ fun AssignmentPanel( players: List, onAssign: (String, PlayerNumber) -> Unit, onDisconnect: (String) -> Unit, + onSetType: (String, Side?) -> Unit, modifier: Modifier = Modifier, ) { val shape = RoundedCornerShape(Dimens.cardCorner) @@ -70,7 +72,7 @@ fun AssignmentPanel( ) unassigned.forEach { joycon -> - JoyconAssignmentRow(joycon, players, onAssign, onDisconnect) + JoyconAssignmentRow(joycon, players, onAssign, onDisconnect, onSetType) } Text( @@ -88,12 +90,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 } @@ -146,6 +150,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 ebd74d75..973db8c4 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… Unassigned Controllers Disconnect 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 909e879b..aec8b2ce 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/ConnectionPool.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/ConnectionPool.kt index 53691df2..c787f4cf 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 @@ -3,6 +3,7 @@ package com.joegec.joycon2android.connection import android.annotation.SuppressLint import android.bluetooth.le.ScanResult import android.content.Context +import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.Side import java.util.concurrent.ConcurrentHashMap @@ -19,9 +20,15 @@ class ConnectionPool(private val context: Context) { val size: Int get() = connections.size /** Null for an address already in the pool (a duplicate scan result). */ - 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 - val connection = JoyconConnection(context, side, name) { + val connection = JoyconConnection(context, model, typeOverride, name) { connections.remove(address) onPoolChanged?.invoke() } 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..5f6aa1bb --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/FactoryCalibrationReads.kt @@ -0,0 +1,29 @@ +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( + SpiReadCommand.build(SpiStickCalibration.MAIN_ADDRESS, SpiStickCalibration.LENGTH), + SpiReadCommand.build(SpiStickCalibration.RIGHT_ADDRESS, SpiStickCalibration.LENGTH), + SpiReadCommand.build(SpiTriggerZeros.ADDRESS, SpiTriggerZeros.LENGTH), + ) + } + + /** 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 d7df5d2f..61cd78c1 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 cb75c71f..de8929a2 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.Build import android.os.Handler import android.os.Looper import android.util.Log +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 onDisconnected: (() -> Unit)? = null, ) { @@ -47,11 +49,8 @@ class JoyconConnection( 0x00, 0x00, 0xFF.toByte(), 0x00, 0x00, 0x00 ) - // 0x40 bytes of the DeviceInfo block at 0x013000: docs/protocol.md#spi-reads - private val SPI_READ_COLOR_CMD = byteArrayOf( - 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, - 0x40, 0x7E, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00 - ) + private const val DEVICE_INFO_ADDRESS = 0x013000 + private const val DEVICE_INFO_LENGTH = 0x40 // Bitmask layout: docs/protocol.md#player-leds private fun playerLedCmd(bitmask: Byte): ByteArray { @@ -71,6 +70,11 @@ class JoyconConnection( private const val INIT_GAP_MS = 500L } + @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) ) @@ -81,7 +85,8 @@ class JoyconConnection( private val mainHandler = Handler(Looper.getMainLooper()) private val opQueue = GattOpQueue() - private val stickCalibrator = StickCalibrator() + @Volatile private var calibrator = InputCalibrator(model) + private var factory = FactoryCalibration() private var gatt: BluetoothGatt? = null private var writeChar: BluetoothGattCharacteristic? = null private var notifyChar: BluetoothGattCharacteristic? = null @@ -194,7 +199,8 @@ class JoyconConnection( } enqueueInitWrite(g, INIT_CMD_1) enqueueInitWrite(g, INIT_CMD_2) - enqueueInitWrite(g, SPI_READ_COLOR_CMD) + enqueueInitWrite(g, SpiReadCommand.build(DEVICE_INFO_ADDRESS, DEVICE_INFO_LENGTH)) + FactoryCalibrationReads.commands(model).forEach { enqueueInitWrite(g, it) } opQueue.enqueue { initComplete = true @@ -236,6 +242,13 @@ 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 + calibrator = InputCalibrator(model).apply { useFactory(factory) } + } + fun setHighPriority(enabled: Boolean) { highPriority = enabled if (initComplete) gatt?.let(::requestPriority) @@ -280,7 +293,7 @@ class JoyconConnection( private fun handleCharacteristicChanged(g: BluetoothGatt, uuid: UUID, data: ByteArray) { when (uuid) { NOTIFY_CHAR -> { - PacketParser.parse(data, side)?.let { _input.value = stickCalibrator.calibrate(it) } + PacketParser.parse(data, side)?.let { _input.value = calibrator.calibrate(it) } if (!ledSentAfterFirstPacket && initComplete) { ledSentAfterFirstPacket = true mainHandler.post { opQueue.enqueue { sendLedCommand(g) } } @@ -288,6 +301,10 @@ class JoyconConnection( } CMD_RESPONSE_CHAR -> { Log.d(TAG, "[$side] Cmd response: ${data.joinToString(" ") { "%02X".format(it) }}") + FactoryCalibrationReads.update(factory, data)?.let { + factory = it + calibrator.useFactory(it) + } SpiColorParser.parseAccentColor(data)?.let { color -> Log.i(TAG, "[$side] Accent color: #${"%06X".format(color)}") _connectionState.value = _connectionState.value.copy(accentColor = color) 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 ea9fdd32..e4003470 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 @@ -9,6 +9,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( @@ -47,6 +49,8 @@ object PacketParser { gyroY = bb.getShort(0x38).toInt(), gyroZ = bb.getShort(0x3A).toInt(), batteryVolts = (bb.getShort(0x1F).toInt() and 0xFFFF) / 1000f, + triggerLeft = uint8OrZero(data, TRIGGER_LEFT), + triggerRight = uint8OrZero(data, TRIGGER_RIGHT), ) } @@ -80,6 +84,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/SpiReadCommand.kt b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadCommand.kt new file mode 100644 index 00000000..085ae59b --- /dev/null +++ b/feature/connection/data/src/main/kotlin/com/joegec/joycon2android/connection/SpiReadCommand.kt @@ -0,0 +1,16 @@ +package com.joegec.joycon2android.connection + +/** docs/protocol.md#spi-reads */ +object SpiReadCommand { + + private const val MAX_LENGTH = 0x40 + + fun build(spiAddress: Int, length: Int): ByteArray { + require(length in 1..MAX_LENGTH) { "SPI reads are 1..$MAX_LENGTH bytes, got $length" } + return byteArrayOf( + 0x02, 0x91.toByte(), 0x00, 0x04, 0x00, 0x08, 0x00, 0x00, + length.toByte(), 0x7E, 0x00, 0x00, + spiAddress.toByte(), (spiAddress shr 8).toByte(), (spiAddress shr 16).toByte(), (spiAddress shr 24).toByte(), + ) + } +} 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/SpiReadCommandTest.kt b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiReadCommandTest.kt new file mode 100644 index 00000000..5ef58e5c --- /dev/null +++ b/feature/connection/data/src/test/kotlin/com/joegec/joycon2android/connection/SpiReadCommandTest.kt @@ -0,0 +1,31 @@ +package com.joegec.joycon2android.connection + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class SpiReadCommandTest { + + @Test + fun `the device info read matches the command the controller answers`() { + assertArrayEquals( + bytes("02 91 00 04 00 08 00 00 40 7E 00 00 00 30 01 00"), + SpiReadCommand.build(0x013000, 0x40), + ) + } + + @Test + fun `length and little-endian address land in their fields`() { + assertArrayEquals( + bytes("02 91 00 04 00 08 00 00 02 7E 00 00 40 31 01 00"), + SpiReadCommand.build(0x013140, 0x02), + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `a read longer than one block is rejected`() { + SpiReadCommand.build(0x013000, 0x41) + } + + 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/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 4e26ab28..3150fce7 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 @@ -98,7 +98,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 } @@ -232,7 +232,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 03048e7a..f3fe7dc2 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 @@ -56,10 +56,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 4967c458..9e5a089e 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 @@ -1,6 +1,7 @@ package com.joegec.joycon2android.gamepad 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 @@ -71,4 +72,26 @@ class ReportMapperTest { assertEquals(0xFF.toByte(), report(JoyconButton.ZR)[12]) assertEquals(0x00.toByte(), report(JoyconButton.ZR)[11]) } + + @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(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(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