Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
17 changes: 17 additions & 0 deletions docs/virtual-gamepad.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -80,7 +83,24 @@ object DolphinGcpadConfig {
players: List<PlayerState>,
controllerNumbers: Map<Int, Int>,
mappingFor: (PlayerBody) -> Map<String, String>,
): 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<PlayerState>): 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<String>): Map<String, String> =
sticks.map { "$it/Calibration" }
.associateWith { IniEditor.valueOf(existing, section, it) ?: FULL_CIRCLE_CALIBRATION }

fun mergeCore(existing: String?, players: List<PlayerState>): String {
val siDevices = players
Expand All @@ -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<PlayerState>,
controllerNumbers: Map<Int, Int>,
mappingFor: (PlayerBody) -> Map<String, String>,
Expand All @@ -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,
Expand All @@ -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<String, String>): List<String> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))))
Expand All @@ -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)
Expand Down
Loading