Tap to hold for physical buttons (#612)

The on-screen controls have had tap-to-hold since they existed; physical
buttons always followed the button exactly. A game that wants one held while
another control is worked -- MGS2 holding R1 to aim -- is then unplayable for
anyone who cannot hold two controls at once, which is what the request was
about.

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 between is swallowed. Turbo composes with it for free -- flag
a button both and a tap toggles autofire on and off.

Keyed on the physical code for "is this a fresh press", because ACTION_DOWN
auto-repeats while a key is held and each repeat would otherwise toggle, and
on the PS2 target for "is it latched", so two physical buttons bound to the
same button cannot desync.

The state lives in the companion because the boot path that clears it runs
there -- a latch must not outlive the game it was set in -- while the
dispatch that sets it is an instance method. Changing the setting releases
whatever is held: turning it off for a button that is latched down would
otherwise strand it pressed, with no second tap left to release it.

Stored per action per player like turbo, and off by default.

Feature requested by bobo123g (#612)
This commit is contained in:
jpolo1224
2026-08-23 10:49:41 -04:00
parent 1a99ab237e
commit fc5c24f8a3
3 changed files with 139 additions and 2 deletions
@@ -836,6 +836,32 @@ object ControllerMappings {
invalidateRuntimeCaches() 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. */ /** True when a physical button's PS2 target [targetKeyCode] is turbo-flagged. */
fun isTurboTarget(targetKeyCode: Int, player: Int = 0): Boolean { fun isTurboTarget(targetKeyCode: Int, player: Int = 0): Boolean {
return targetKeyCode in runtimeBindings().turboTargets[if (player == P2) P2 else P1] return targetKeyCode in runtimeBindings().turboTargets[if (player == P2) P2 else P1]
@@ -924,6 +950,7 @@ object ControllerMappings {
val serial: String?, val serial: String?,
val targets: Array<Map<Int, Int>>, val targets: Array<Map<Int, Int>>,
val turboTargets: Array<Set<Int>>, val turboTargets: Array<Set<Int>>,
val latchTargets: Array<Set<Int>>,
val hotkeys: List<RuntimeHotkey>, val hotkeys: List<RuntimeHotkey>,
val dpadAsLeftStick: Boolean, val dpadAsLeftStick: Boolean,
) )
@@ -949,6 +976,12 @@ object ControllerMappings {
.map { it.targetKeyCode } .map { it.targetKeyCode }
.toSet() .toSet()
} }
val latchTargets = Array(2) { player ->
actions.asSequence()
.filter { isLatchAction(it, player) }
.map { it.targetKeyCode }
.toSet()
}
val hotkeys = SysHotkey.values().map { action -> val hotkeys = SysHotkey.values().map { action ->
RuntimeHotkey(action, hotkeyCode(action), hotkeyModCode(action)) RuntimeHotkey(action, hotkeyCode(action), hotkeyModCode(action))
} }
@@ -956,6 +989,7 @@ object ControllerMappings {
serial, serial,
targets, targets,
turboTargets, turboTargets,
latchTargets,
hotkeys, hotkeys,
resolveBoolean(KEY_DPAD_AS_LSTICK, false), resolveBoolean(KEY_DPAD_AS_LSTICK, false),
) )
@@ -137,6 +137,40 @@ open class MainActivityRuntime : ComponentActivity() {
companion object { companion object {
var instance: MainActivityRuntime? = null var instance: MainActivityRuntime? = null
lateinit var prefs: SharedPreferences 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<Long>() // physical buttons currently held, port|physical
private val latchHeld = HashSet<Long>() // 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) val setupComplete = mutableStateOf(false)
// Set at launch when a restored-but-unusable setup is detected (Auto Backup // Set at launch when a restored-but-unusable setup is detected (Auto Backup
// brought back prefs incl. setupComplete, but the ROMs folder permission // brought back prefs incl. setupComplete, but the ROMs folder permission
@@ -682,6 +716,7 @@ open class MainActivityRuntime : ComponentActivity() {
invoke { invoke {
try { try {
eState.value = EmuState.RUNNING eState.value = EmuState.RUNNING
clearLatches()
println("@@ANDROID_START_VM@@ kind=game path=${m_szGamefile.take(240)}") println("@@ANDROID_START_VM@@ kind=game path=${m_szGamefile.take(240)}")
// Local co-op: re-pair controllers each session (first pad = P1, // Local co-op: re-pair controllers each session (first pad = P1,
// next = P2) so player slots are deterministic per boot. // next = P2) so player slots are deterministic per boot.
@@ -1061,6 +1096,7 @@ open class MainActivityRuntime : ComponentActivity() {
invoke { invoke {
try { try {
eState.value = EmuState.RUNNING eState.value = EmuState.RUNNING
clearLatches()
println("@@ANDROID_START_VM@@ kind=bios path=<empty>") println("@@ANDROID_START_VM@@ kind=bios path=<empty>")
com.armsx2.input.PadRouter.reset() com.armsx2.input.PadRouter.reset()
// The BIOS is emulation too: claim the renderer rotation tier so it honours the // 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) = private fun turboMapKey(physicalCode: Int, port: Int) =
(port.toLong() shl 32) or (physicalCode.toLong() and 0xffffffffL) (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) { private fun handleTurbo(physicalCode: Int, type: KeyEventType, target: Int, port: Int) {
val key = turboMapKey(physicalCode, port) val key = turboMapKey(physicalCode, port)
if (type == KeyEventType.KeyDown) { if (type == KeyEventType.KeyDown) {
@@ -3388,10 +3454,15 @@ open class MainActivityRuntime : ComponentActivity() {
} }
val target = ControllerMappings.targetForPhysical(physicalCode, port) ?: return false 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)) { if (ControllerMappings.isTurboTarget(target, port)) {
handleTurbo(physicalCode, type, target, port) handleTurbo(physicalCode, edge, target, port)
} else { } else {
sendKeyAction(type, target, port) sendKeyAction(edge, target, port)
} }
return true return true
} }
@@ -383,6 +383,38 @@ fun PadTab(@Suppress("UNUSED_PARAMETER") state: MutableState<Settings>) {
fontWeight = FontWeight.SemiBold, 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() SettingsDivider()
} }