diff --git a/README.md b/README.md index eca468c6..e699c67b 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ shoulder buttons. | Motion aiming stutters | Settings (gear, top right): keep **Ignore this device's motion in Eden** and **Faster controller updates** on | | Tilting the device moves the aim in Eden | Turn on **Ignore this device's motion in Eden** | | Pointer drifts or starts off-screen | Rest the controller for ~2 s, then press Recenter | +| Sticks feel twitchy in Dolphin (hard to walk or aim) | Tap **Set up** for Dolphin again. A Pro Controller: map it in Dolphin first, then tap **Set up** once to add the calibration | | MotionPlus tutorial replays every boot (Dolphin) | Set `MPLS.MOVIE` in `Wii/shared2/sys/SYSCONF` with Dolphin closed ([why](docs/dsu-motion.md#motionplus-tutorial-replays)) | ## Roadmap and feedback diff --git a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt index c5e711b6..39f70490 100644 --- a/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt +++ b/core/emulatorconfig/src/main/kotlin/com/joegec/joycon2android/emulatorconfig/IniEditor.kt @@ -24,6 +24,9 @@ object IniEditor { return out.toString() } + fun hasSection(existing: String?, section: String): Boolean = + existing?.lines()?.any { it.trim() == section } == true + fun valueOf(existing: String?, section: String, key: String): String? { val lines = existing?.lines() ?: return null val headerIndex = lines.indexOfFirst { it.trim() == section } diff --git a/docs/virtual-gamepad.md b/docs/virtual-gamepad.md index 072e069c..fcee9fe1 100644 --- a/docs/virtual-gamepad.md +++ b/docs/virtual-gamepad.md @@ -95,6 +95,23 @@ mapping of the console each output's card is set to, while that output runs. - **Android handles `HOME` itself** and never passes it to the app in front, so a bound Home always leaves the game. +## Dolphin stick calibration + +Dolphin setup writes `Main Stick/Calibration = 100.00` and `C-Stick/Calibration = 100.00` for each +stick it binds, keeping any `Calibration` the section already has. Setup leaves a Pro Controller +player's section to the player, so there it only adds both calibration keys, and only to a section +that already exists. + +- **Uncalibrated, Dolphin passes the stick straight through:** a full ±1.0 tilt becomes GameCube ±127. + A real pad's gate stops near ±101 (main) and ±92 (C-stick), so every push lands about a quarter + further than on hardware, and walking or fine aiming is hard. +- **Calibrated to a full circle,** Dolphin maps the rim onto its `Gate Size` octagon: 79.37 (main) + and 72.21 (C-stick) by default, averaged from genuine controllers. +- Dolphin for Android has no Calibrate button, and nothing else sets it. Sources: Dolphin's + `GCPadEmu.h` and `StickGate.cpp`, and Dolphin PR #8963. +- Confirmed on a Retroid Pocket Nova with NSO GameCube controllers, 2026-10-11: walking and scope + aiming feel like an original controller. + ## Sideways Joy-Cons ### Why they're set up as Pro Controllers 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 afde8ce5..365f3897 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 @@ -25,6 +25,9 @@ object DolphinGcpadConfig { private const val STANDARD_CONTROLLER = "6" // Dolphin SIDevice: Standard Controller + // Dolphin maps a full circle onto its GameCube gate: docs/virtual-gamepad.md#dolphin-stick-calibration + private const val FULL_CIRCLE_CALIBRATION = "100.00" + private val DOLPHIN_KEYS = mapOf( GameCubeButton.A to "Buttons/A", GameCubeButton.B to "Buttons/B", @@ -80,7 +83,24 @@ object DolphinGcpadConfig { players: List, controllerNumbers: Map, mappingFor: (PlayerBody) -> Map, - ): String = IniEditor.mergeSections(existing, sections(players, controllerNumbers, mappingFor)) + ): String { + val merged = IniEditor.mergeSections(existing, sections(existing, players, controllerNumbers, mappingFor)) + return calibrateHandMapped(merged, players) + } + + // Setup leaves a Pro Controller's section to the player, so it only adds the calibration, and only + // to a section the player has already made. + private fun calibrateHandMapped(ini: String, players: List): String = + players.filter { !it.isSetUpByApp && it.player.index in 1..4 } + .map { sectionFor(it.player.index) } + .filter { IniEditor.hasSection(ini, it) } + .fold(ini) { result, section -> + IniEditor.setKeys(result, section, calibrationFor(result, section, STICK_PREFIXES.values)) + } + + private fun calibrationFor(existing: String?, section: String, sticks: Collection): Map = + sticks.map { "$it/Calibration" } + .associateWith { IniEditor.valueOf(existing, section, it) ?: FULL_CIRCLE_CALIBRATION } fun mergeCore(existing: String?, players: List): String { val siDevices = players @@ -94,6 +114,7 @@ object DolphinGcpadConfig { // A player whose pad isn't enumerated yet is skipped: docs/virtual-gamepad.md#device-identity private fun sections( + existing: String?, players: List, controllerNumbers: Map, mappingFor: (PlayerBody) -> Map, @@ -104,10 +125,13 @@ object DolphinGcpadConfig { val index = player.player.index if (index !in 1..4) return@mapNotNull null val deviceId = controllerNumbers[index] ?: return@mapNotNull null - bodyFor(player, index, deviceId, mappingFor)?.let { "[GCPad$index]" to it } + bodyFor(existing, player, index, deviceId, mappingFor)?.let { sectionFor(index) to it } }.toMap() + private fun sectionFor(index: Int) = "[GCPad$index]" + private fun bodyFor( + existing: String?, player: PlayerState, index: Int, deviceId: Int, @@ -116,9 +140,12 @@ object DolphinGcpadConfig { if (!player.isSetUpByApp) return null val side = player.joyconSide() ?: return null val device = "Device = Android/$deviceId/Joy-Con Virtual Gamepad $index" + val bindings = lines(side, mappingFor(PlayerBody(player.player, side))) + val boundSticks = STICK_PREFIXES.values.filter { prefix -> bindings.any { it.startsWith("$prefix/") } } + val calibration = calibrationFor(existing, sectionFor(index), boundSticks) + .map { (key, value) -> "$key = $value" } 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") + return (listOf(device) + bindings + calibration + analogTriggers).joinToString("\n", postfix = "\n") } private fun lines(side: JoyconSide, mapping: Map): List { 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 1cf983c2..e8a39880 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 @@ -9,6 +9,7 @@ import com.joegec.joycon2android.model.ControllerModel import com.joegec.joycon2android.model.PlayerNumber import com.joegec.joycon2android.model.PlayerState import com.joegec.joycon2android.model.Side +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -101,6 +102,49 @@ class DolphinGcpadConfigTest { assertFalse(result.contains("C-Stick/Left")) // None leaves it unbound } + @Test + fun `each bound stick is calibrated to a full circle, so Dolphin applies the GameCube gate`() { + val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) + val lone = listOf(PlayerState(PlayerNumber.P1, right = joycon(Side.RIGHT))) + + val pairResult = merge(null, pair) + assertTrue(pairResult.contains("Main Stick/Calibration = 100.00")) + assertTrue(pairResult.contains("C-Stick/Calibration = 100.00")) + assertFalse(merge(null, lone).contains("C-Stick/")) // no C-stick to calibrate + } + + @Test + fun `a stick calibration already in the section is kept`() { + val existing = "[GCPad1]\nMain Stick/Calibration = 85.00\n" + val pair = listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.LEFT), right = joycon(Side.RIGHT))) + val result = merge(existing, pair) + + assertTrue(result.contains("Main Stick/Calibration = 85.00")) + assertFalse(result.contains("Main Stick/Calibration = 100.00")) + assertTrue(result.contains("C-Stick/Calibration = 100.00")) + } + + @Test + fun `a pro controller's hand-made section keeps its bindings and gains only the calibration`() { + val handMade = "[GCPad1]\nDevice = Android/5/Pro\nButtons/A = `Button A`\n" + val result = merge(handMade, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.PRO)))) + + assertTrue(result.contains("Device = Android/5/Pro")) + assertTrue(result.contains("Buttons/A = `Button A`")) + assertTrue(result.contains("Main Stick/Calibration = 100.00")) + assertTrue(result.contains("C-Stick/Calibration = 100.00")) + } + + @Test + fun `a pro controller's own stick calibration is kept`() { + val handMade = "[GCPad1]\nDevice = Android/5/Pro\nMain Stick/Calibration = 85.00\n" + val result = merge(handMade, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.PRO)))) + + assertTrue(result.contains("Main Stick/Calibration = 85.00")) + assertFalse(result.contains("Main Stick/Calibration = 100.00")) + assertTrue(result.contains("C-Stick/Calibration = 100.00")) + } + @Test fun `pro controllers are skipped`() { val result = merge(null, listOf(PlayerState(PlayerNumber.P1, left = joycon(Side.PRO)))) @@ -119,6 +163,17 @@ class DolphinGcpadConfigTest { assertTrue(result.contains("C-Stick/Up = `Axis 14-`")) } + @Test + fun `a gamecube controller's section is written by setup, with each stick calibrated once`() { + val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE) + val stale = "[GCPad1]\nDevice = Android/5/Old\n" + val result = merge(stale, listOf(PlayerState(PlayerNumber.P1, left = gameCube, right = gameCube))) + + assertFalse(result.contains("Device = Android/5/Old")) + assertEquals(1, Regex("Main Stick/Calibration = 100.00").findAll(result).count()) + assertEquals(1, Regex("C-Stick/Calibration = 100.00").findAll(result).count()) + } + @Test fun `a gamecube controller's analog triggers bind to the brake and gas axes`() { val gameCube = joycon(Side.PRO).copy(model = ControllerModel.GAMECUBE)