diff --git a/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt b/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt index a10b3778ae..f53e75b9d3 100644 --- a/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt +++ b/platforms/android/app/src/main/java/com/armsx2/input/ControllerMappings.kt @@ -836,6 +836,32 @@ object ControllerMappings { invalidateRuntimeCaches() } + // A latch-flagged button toggles on a TAP instead of following the physical button: press + // once to hold the PS2 button down, press again to release. The on-screen controls have had + // this ("tap to hold") since they existed; physical buttons never did, so a game that wants a + // button held while you do something else with the d-pad is unplayable for anyone who cannot + // hold two controls at once. Requested by bobo123g (#612), who cannot hold R1 and aim at the + // same time in Metal Gear Solid 2. + // + // Global rather than per-game, and stored the same way turbo is, because it describes the + // player rather than the title. + private const val LATCH_PREFIX = "pad.latch." + private fun latchKey(action: Action, player: Int) = playerPrefix(player) + LATCH_PREFIX + action.id + fun isLatchAction(action: Action, player: Int = 0): Boolean = + MainActivityRuntime.prefs.getBoolean(latchKey(action, player), false) + fun setLatchAction(action: Action, player: Int, on: Boolean) { + MainActivityRuntime.prefs.edit { putBoolean(latchKey(action, player), on) } + invalidateRuntimeCaches() + // Changing this mid-game must not stand a button up permanently: if it is latched down + // right now, the tap that would have released it no longer toggles anything. + MainActivityRuntime.releaseLatches() + } + + /** True when a physical button's PS2 target [targetKeyCode] is latch-flagged. */ + fun isLatchTarget(targetKeyCode: Int, player: Int = 0): Boolean { + return targetKeyCode in runtimeBindings().latchTargets[if (player == P2) P2 else P1] + } + /** True when a physical button's PS2 target [targetKeyCode] is turbo-flagged. */ fun isTurboTarget(targetKeyCode: Int, player: Int = 0): Boolean { return targetKeyCode in runtimeBindings().turboTargets[if (player == P2) P2 else P1] @@ -924,6 +950,7 @@ object ControllerMappings { val serial: String?, val targets: Array>, val turboTargets: Array>, + val latchTargets: Array>, val hotkeys: List, val dpadAsLeftStick: Boolean, ) @@ -949,6 +976,12 @@ object ControllerMappings { .map { it.targetKeyCode } .toSet() } + val latchTargets = Array(2) { player -> + actions.asSequence() + .filter { isLatchAction(it, player) } + .map { it.targetKeyCode } + .toSet() + } val hotkeys = SysHotkey.values().map { action -> RuntimeHotkey(action, hotkeyCode(action), hotkeyModCode(action)) } @@ -956,6 +989,7 @@ object ControllerMappings { serial, targets, turboTargets, + latchTargets, hotkeys, resolveBoolean(KEY_DPAD_AS_LSTICK, false), ) diff --git a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index f1eb875ea7..c526494f77 100644 --- a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -137,6 +137,40 @@ open class MainActivityRuntime : ComponentActivity() { companion object { var instance: MainActivityRuntime? = null lateinit var prefs: SharedPreferences + + // Tap-to-hold state (#612). In the companion rather than beside handleTurbo because the + // boot path that has to clear it -- a latch must not outlive the game it was set in -- + // runs here, while the dispatch that sets it is an instance method. Instance methods see + // companion members, so both reach it. + private val latchDown = HashSet() // physical buttons currently held, port|physical + private val latchHeld = HashSet() // PS2 targets currently latched ON, port|target + + /** A latch must not outlive the game it was set in. Called on each fresh start. */ + fun clearLatches() { + latchDown.clear() + latchHeld.clear() + } + + /** + * Release whatever is latched right now, through the normal dispatch so the analog and + * pressure bookkeeping unwinds with it. + * + * Turning tap-to-hold off for a button that is currently HELD would otherwise strand it + * pressed: the second tap that would have released it no longer toggles anything, so the + * game sees the button down forever. Called whenever the setting changes. + */ + fun releaseLatches() { + val held = latchHeld.toList() + clearLatches() + val act = instance ?: return + held.forEach { key -> + act.sendKeyAction( + KeyEventType.KeyUp, + (key and 0xffffffffL).toInt(), // target, as packed by turboMapKey + (key ushr 32).toInt(), // port + ) + } + } val setupComplete = mutableStateOf(false) // Set at launch when a restored-but-unusable setup is detected (Auto Backup // brought back prefs incl. setupComplete, but the ROMs folder permission @@ -682,6 +716,7 @@ open class MainActivityRuntime : ComponentActivity() { invoke { try { eState.value = EmuState.RUNNING + clearLatches() println("@@ANDROID_START_VM@@ kind=game path=${m_szGamefile.take(240)}") // Local co-op: re-pair controllers each session (first pad = P1, // next = P2) so player slots are deterministic per boot. @@ -1061,6 +1096,7 @@ open class MainActivityRuntime : ComponentActivity() { invoke { try { eState.value = EmuState.RUNNING + clearLatches() println("@@ANDROID_START_VM@@ kind=bios path=") com.armsx2.input.PadRouter.reset() // The BIOS is emulation too: claim the renderer rotation tier so it honours the @@ -1951,6 +1987,36 @@ open class MainActivityRuntime : ComponentActivity() { private fun turboMapKey(physicalCode: Int, port: Int) = (port.toLong() shl 32) or (physicalCode.toLong() and 0xffffffffL) + // ---- Tap to hold (latch) ----------------------------------------------- + // #612, requested by bobo123g: the on-screen buttons have had "tap to hold" since they + // existed, but physical buttons always followed the button exactly. A game that wants one + // held while you work another control -- MGS2 holding R1 to aim -- is then unplayable for + // anyone who cannot hold two controls at once. + // + // Modelled as a TRANSFORM on the event stream rather than a branch beside turbo: a tap + // becomes a synthetic KeyDown, the next tap a synthetic KeyUp, and everything in between is + // swallowed. Turbo then composes with it for free -- flag a button both and a tap toggles + // autofire on and off, which is what a shmup wants. + /** + * The event to act on for a latch-flagged button, or null when there is nothing to do. + * + * Keyed on the PHYSICAL code for "is this a fresh press" (ACTION_DOWN auto-repeats while a + * key is held, and each repeat would otherwise toggle) and on the TARGET for "is it latched", + * so two physical buttons bound to the same PS2 button cannot desync. + */ + private fun latchEdge(physicalCode: Int, type: KeyEventType, target: Int, port: Int): KeyEventType? { + val physKey = turboMapKey(physicalCode, port) + if (type != KeyEventType.KeyDown) { + // Releasing the physical button is exactly what a latch ignores. + latchDown.remove(physKey) + return null + } + if (!latchDown.add(physKey)) return null // auto-repeat, not a new press + val tgtKey = turboMapKey(target, port) + return if (latchHeld.remove(tgtKey)) KeyEventType.KeyUp + else { latchHeld.add(tgtKey); KeyEventType.KeyDown } + } + private fun handleTurbo(physicalCode: Int, type: KeyEventType, target: Int, port: Int) { val key = turboMapKey(physicalCode, port) if (type == KeyEventType.KeyDown) { @@ -3388,10 +3454,15 @@ open class MainActivityRuntime : ComponentActivity() { } val target = ControllerMappings.targetForPhysical(physicalCode, port) ?: return false + // Tap to hold rewrites the edges before anything else sees them (#612); a swallowed event + // is still consumed, or the key would fall through to the frontend. + val edge = if (ControllerMappings.isLatchTarget(target, port)) + latchEdge(physicalCode, type, target, port) ?: return true + else type if (ControllerMappings.isTurboTarget(target, port)) { - handleTurbo(physicalCode, type, target, port) + handleTurbo(physicalCode, edge, target, port) } else { - sendKeyAction(type, target, port) + sendKeyAction(edge, target, port) } return true } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt index 3f01a38681..902a45277e 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/PadTab.kt @@ -383,6 +383,38 @@ fun PadTab(@Suppress("UNUSED_PARAMETER") state: MutableState) { fontWeight = FontWeight.SemiBold, ) } + // Tap to hold (#612) — the on-screen buttons have always had this; an + // accessibility request brought it to physical ones, for games that expect a + // button held while another control is worked. Sits with Turbo because both + // change what holding the button means, and both need a binding to act on. + val latch = remember(action.id, editPlayer.intValue, refreshToken.intValue) { + mutableStateOf(ControllerMappings.isLatchAction(action, editPlayer.intValue)) + } + Row( + Modifier + .fillMaxWidth() + .clickable { + val nv = !latch.value + latch.value = nv + ControllerMappings.setLatchAction(action, editPlayer.intValue, nv) + } + .padding(start = 18.dp, end = 10.dp, top = 2.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "\u21b3 Tap to hold (press once to hold, again to release)", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp, + modifier = Modifier.weight(1f), + ) + Text( + if (latch.value) "ON" else "OFF", + color = if (latch.value) Color(0xFF4DA3FF) + else Color(0xFF808080), + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold, + ) + } } SettingsDivider() }