Fix Reset leaving per-game settings, and Fast Forward (Toggle) on analog triggers

Two reported bugs, unrelated to each other.

RESET (takanome9104, confirmed by lugnel): per-game settings such as affinity and
GS multithreading survived a full reset.

purgeAllSettingsFiles deleted PCSX2-Android.ini and gamesettings/ from
currentInitDataRoot — one root. A device with a configured system directory has
TWO, and on a device that has been moved between them gamesettings/ exists under
both; the surviving copy is re-read on the next launch. Verified on the test
device: gamesettings/ present under BOTH the SD root and app-private storage.
Every known root is purged now. Deleting a file that is already gone is free, so
casting wide costs nothing.

This is the same single-root assumption that hid save states from the library's
long-press menu earlier today. Worth suspecting wherever this codebase resolves
'the' data directory.

The prefs clear also moved from apply() to commit(). restartApp calls
Runtime.exit(0) on the next line, and apply() only guarantees the in-memory
update — its disk write is asynchronous and an abrupt exit can beat it. A reset
that survives the restart is the entire point of the button.

FAST FORWARD (SKrazy on an AYN pad, Shmoda12 on a Thor): Fast Forward (Toggle)
bound to L2/R2 came on for a frame and then reported OFF. It worked as Hold, it
worked on a non-trigger button like R3, and it worked once the pad was switched
to digital triggers.

Those three facts together say it: some pads report a trigger BOTH as an axis and
as a key event, so one pull reaches the hotkey dispatcher twice — once from
sendTrigger, once from the key path. For a HOLD that is harmless, since both
compute the same state from the same edge. For a TOGGLE the first flips it on and
the second immediately flips it back. Digital triggers send only key events, which
is why that setting 'fixed' it.

The axis path now claims the press and the key path skips its own edge. Scoped to
L2/R2 alone so nothing else changes, and cleared on release so the next pull
re-arms. sendTrigger already carried a comment about pads that report triggers
both ways — the hold path had been made safe against them, the hotkey path had
not.
This commit is contained in:
jpolo1224
2026-08-21 13:19:18 -04:00
parent cc9e58cf4e
commit b8f80aee02
2 changed files with 64 additions and 6 deletions
@@ -436,11 +436,39 @@ object ConfigStore {
*
* Games, BIOS, saves, memory cards, save states, covers and texture packs are untouched.
*/
fun purgeAllSettingsFiles() {
/**
* Delete the on-disk settings layers so a factory reset is not silently undone on next launch.
*
* ★ EVERY root, not just the active one.
*
* A device with a configured system directory has two — the SD/user root that
* currentInitDataRoot resolves to, and app-private storage — and gamesettings/ exists under
* BOTH on a device that has been moved between them. Purging only the active root left the
* other one intact, and its per-game INIs are re-read on the next launch: reported as
* 'reset app doesn't work as intended, some per-game settings still applied like affinity and
* GS multithreading' (takanome9104, confirmed by lugnel). Deleting a settings file that is
* already gone is free, so casting wide costs nothing and closes the hole.
*
* This is the same single-root assumption that hid save states from the library's long-press
* menu; it is worth checking for wherever this codebase resolves 'the' data directory.
*/
fun purgeAllSettingsFiles(context: android.content.Context? = null) {
runCatching { backupFile()?.delete() }
val root = MainActivityRuntime.currentInitDataRoot()?.takeIf { it.isNotBlank() } ?: return
runCatching { File(root, "PCSX2-Android.ini").delete() }
runCatching { File(root, "gamesettings").deleteRecursively() }
val roots = buildList {
MainActivityRuntime.currentInitDataRoot()?.takeIf { it.isNotBlank() }?.let(::add)
if (context != null) {
runCatching { MainActivityRuntime.assetCopyRoot(context) }.getOrNull()
?.takeIf { it.isNotBlank() }?.let(::add)
runCatching { context.getExternalFilesDir(null)?.absolutePath }.getOrNull()
?.takeIf { it.isNotBlank() }?.let(::add)
}
}.distinct()
for (root in roots) {
runCatching { File(root, "PCSX2-Android.ini").delete() }
runCatching { File(root, "gamesettings").deleteRecursively() }
}
}
/** Minimal INI reader: "[Section]" + "Key = Value" -> map keyed "Section/Key". Comments
@@ -1479,8 +1479,11 @@ open class MainActivityRuntime : ComponentActivity() {
*/
fun resetAppToDefaults(context: Context) {
// Files first — clearing prefs drops the data-root pref that locates them.
runCatching { com.armsx2.config.ConfigStore.purgeAllSettingsFiles() }
runCatching { prefs.edit { clear() } }
runCatching { com.armsx2.config.ConfigStore.purgeAllSettingsFiles(context) }
// ★ commit, not apply. restartApp calls Runtime.exit(0) immediately below, and
// apply() only guarantees the in-memory update — its disk write is asynchronous and
// an abrupt exit can beat it. A reset that survives the restart is the entire point.
runCatching { prefs.edit().clear().commit() }
restartApp(context)
}
@@ -3133,6 +3136,14 @@ open class MainActivityRuntime : ComponentActivity() {
// re-add it for the match (FAST_FORWARD needs to recognise its own
// release). heldKeys still carries the modifier either way.
val matchKeys = if (down) heldKeys else heldKeys + kc
// A trigger the axis path already acted on this press. Only L2/R2 can be claimed,
// and only by sendTrigger — see triggerHotkeyClaimed.
if ((kc == KeyEvent.KEYCODE_BUTTON_L2 || kc == KeyEvent.KEYCODE_BUTTON_R2) &&
triggerHotkeyClaimed.contains(kc))
{
if (!down) triggerHotkeyClaimed.remove(kc)
return true
}
when (ControllerMappings.matchHotkey(kc, matchKeys)) {
// Pressure modifier is a hold, handled (and consumed) earlier in
// dispatchKeyEvent; it never reaches this one-shot action switch.
@@ -4731,6 +4742,22 @@ open class MainActivityRuntime : ComponentActivity() {
// trigger-bound hotkeys, so each press fires once and re-arms on release.
private val triggerHotkeyHeld = Array(8) { HashSet<Int>() }
/**
* Trigger keycodes whose hotkey edge the AXIS path has already fired for the current press.
*
* Some pads report a trigger BOTH ways as an axis and as a key event so a single pull
* reaches the hotkey dispatcher twice, once from sendTrigger and once from the key path. For
* a hold that is harmless (both compute the same state). For a TOGGLE it is fatal: the first
* flips it on and the second immediately flips it back, which is why Fast Forward (Toggle)
* bound to L2/R2 came on for a frame and then reported OFF, worked when bound to a
* non-trigger button, and worked once the pad was switched to digital triggers reported by
* SKrazy on an AYN pad and Shmoda12 on a Thor.
*
* The axis path claims the press; the key path sees the claim and skips its own edge. Scoped
* to L2/R2 alone so nothing else changes, and cleared on release so the next pull re-arms.
*/
private val triggerHotkeyClaimed = HashSet<Int>()
private fun sendTrigger(event: MotionEvent, left: Boolean, port: Int) {
// -1 = no trigger axis on this side; its L2/R2 is a key event, key path owns it.
val raw = triggerTravel(event, left)
@@ -4746,6 +4773,9 @@ open class MainActivityRuntime : ComponentActivity() {
if (pressed) heldKeys.add(code)
if (pressed != held.contains(code)) {
if (pressed) held.add(code) else { held.remove(code); heldKeys.remove(code) }
// Claim this press so the key path does not fire the same hotkey again on a pad
// that reports the trigger both ways. See triggerHotkeyClaimed.
if (pressed) triggerHotkeyClaimed.add(code) else triggerHotkeyClaimed.remove(code)
// Triggers now reach the Hotkeys tab's capture like any other button, so they have
// to be able to fire one here. Hold-type hotkeys act on both edges (a trigger has a
// real release, unlike a stick edge); the rest fire on the press. Matching on