mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Android UI: fix settings clobber, restore the resume, and add opt-in controls
Three view models transformed their OWN snapshot rather than current state, so every write shipped the whole Settings object as it looked when the screen opened, reverting anything changed elsewhere since. ConfigStore then cemented it: a pinned per-game key was re-pinned from that stale object, so a wrong value became sticky and survived. Writers now transform live state, and a pinned key the caller did not touch keeps its stored value. The pause menu scheduled its resume on the composition's coroutine scope, which on Android dispatches on Choreographer FRAME callbacks. Once the menu closed there was nothing left to draw, no frame was scheduled, and the continuation after the dismiss delay was never delivered - the VM was simply never told to resume. Diagnosed from the tell that tapping the on-screen controls sped up recovery: touch input schedules a frame. Auto Progressive Scan set Triangle+Cross once, but the pad is initialised during boot and wiped it before the game ever sampled it, so it did nothing in Tekken 4 while holding by hand worked. It re-asserts now, and releases shortly after the ELF starts instead of jamming the buttons for 30s. Adds: 0% touch-control opacity (#428), optional screen pinning so a controller Home button cannot minimise the game (#425), a library cover-size slider, a DualShock 2 pressure-modifier amount, scrolling for long setting descriptions, render-pass coalescing in the Android settings and pause menu, and the four new settings registered for search.
This commit is contained in:
@@ -276,13 +276,32 @@ object ConfigStore {
|
||||
// Every field, so a pinned key can be given its CURRENT value even when that
|
||||
// value equals global's (the diff above necessarily omits it).
|
||||
val full = updated.toJson()
|
||||
val existing = loadOverrides(serial)
|
||||
val pinned = LinkedHashSet<String>()
|
||||
loadOverrides(serial)?.keys()?.forEach { pinned.add(it) }
|
||||
existing?.keys()?.forEach { pinned.add(it) }
|
||||
// What the user just changed, pinned even if it landed on global's value —
|
||||
// otherwise editing a field in Game scope could silently un-pin it.
|
||||
previous?.let { Settings.diff(it, updated).keys().forEach { k -> pinned.add(k) } }
|
||||
val changedNow = LinkedHashSet<String>()
|
||||
previous?.let { Settings.diff(it, updated).keys().forEach { k -> changedNow.add(k); pinned.add(k) } }
|
||||
pinned.forEach { key ->
|
||||
if (!overrides.has(key) && full.has(key)) overrides.put(key, full.get(key))
|
||||
if (overrides.has(key))
|
||||
return@forEach
|
||||
// ★ For a pinned key the caller did NOT touch in this save, keep the value ALREADY
|
||||
// STORED rather than re-pinning whatever `updated` happens to hold. Every screen
|
||||
// writes the whole Settings object, so `updated` can be a stale snapshot; the old
|
||||
// unconditional `full.get(key)` then wrote that stale value straight back over a
|
||||
// good override. That is how a per-game FPS cap of 30 came back as 0 and STAYED 0 —
|
||||
// the pin made the wrong value sticky, so it survived even after the writers were
|
||||
// fixed. Only trust `updated` for keys `previous` proves the caller just changed.
|
||||
//
|
||||
// When `previous` is absent the caller cannot tell us what it changed, so fall back
|
||||
// to the original behaviour rather than silently altering semantics for those paths.
|
||||
val trustUpdated = changedNow.contains(key) || previous == null
|
||||
when {
|
||||
trustUpdated && full.has(key) -> overrides.put(key, full.get(key))
|
||||
existing != null && existing.has(key) -> overrides.put(key, existing.get(key))
|
||||
full.has(key) -> overrides.put(key, full.get(key))
|
||||
}
|
||||
}
|
||||
saveOverrides(serial, overrides)
|
||||
} else {
|
||||
|
||||
@@ -253,6 +253,12 @@ data class Settings(
|
||||
* per-primitive barrier fallback. A few proprietary Adreno drivers show stale-ROAA
|
||||
* read artifacts — turn this off in the Renderer tab if so. Applies on game restart. */
|
||||
val adrenoFbFetch: Boolean = true,
|
||||
/** EmuCore/GS/CoalesceRenderPasses — group consecutive draws to the same target into a
|
||||
* single render pass. Aimed squarely at tiling GPUs (every Android GPU), where each pass
|
||||
* boundary costs a full tile load and store; rendering output is unchanged. Default off,
|
||||
* matching upstream, because it is new. bmd only wired this into the desktop UI, so
|
||||
* without this it would be unreachable on the platform it was written for. */
|
||||
val coalesceRenderPasses: Boolean = false,
|
||||
/** EmuCore/GS/ForceMaliFramebufferFetch — re-enable the Vulkan framebuffer-fetch
|
||||
* (ROAA) path on MediaTek Mali / Mali-G57, where it is force-disabled because those
|
||||
* drivers return zero/stale destination colour through ROAA (black or missing
|
||||
@@ -1139,6 +1145,7 @@ data class Settings(
|
||||
hwRov = boolAt("EmuCore/GS/HWROV") ?: this.hwRov,
|
||||
hwAa1 = boolAt("EmuCore/GS/HWAA1") ?: this.hwAa1,
|
||||
adrenoFbFetch = boolAt("EmuCore/GS/EnableAdrenoFramebufferFetch") ?: this.adrenoFbFetch,
|
||||
coalesceRenderPasses = boolAt("EmuCore/GS/CoalesceRenderPasses") ?: this.coalesceRenderPasses,
|
||||
forceMaliFbFetch = boolAt("EmuCore/GS/ForceMaliFramebufferFetch") ?: this.forceMaliFbFetch,
|
||||
useAngleOpenGL = boolAt("EmuCore/GS/AndroidUseAngleOpenGL") ?: this.useAngleOpenGL,
|
||||
overrideTextureBarriers = intAt("EmuCore/GS/OverrideTextureBarriers") ?: this.overrideTextureBarriers,
|
||||
@@ -1335,6 +1342,7 @@ data class Settings(
|
||||
put("EmuCore/GS", "HWROV", "bool", hwRov.toString())
|
||||
put("EmuCore/GS", "HWAA1", "bool", hwAa1.toString())
|
||||
put("EmuCore/GS", "EnableAdrenoFramebufferFetch", "bool", adrenoFbFetch.toString())
|
||||
put("EmuCore/GS", "CoalesceRenderPasses", "bool", coalesceRenderPasses.toString())
|
||||
put("EmuCore/GS", "ForceMaliFramebufferFetch", "bool", forceMaliFbFetch.toString())
|
||||
// Parity write (native reads the ARMSX2_ANGLE_EGL_LIBRARY env var set by
|
||||
// MainActivityRuntime.applyAngleEnv, not this key) — kept so the config file
|
||||
@@ -1586,6 +1594,7 @@ data class Settings(
|
||||
put("hwRov", hwRov)
|
||||
put("hwAa1", hwAa1)
|
||||
put("adrenoFbFetch", adrenoFbFetch)
|
||||
put("coalesceRenderPasses", coalesceRenderPasses)
|
||||
put("forceMaliFbFetch", forceMaliFbFetch)
|
||||
put("useAngleOpenGL", useAngleOpenGL)
|
||||
put("overrideTextureBarriers", overrideTextureBarriers)
|
||||
@@ -1843,6 +1852,7 @@ data class Settings(
|
||||
hwAa1 = json.optBoolean("hwAa1", def.hwAa1),
|
||||
hwAat = false,
|
||||
adrenoFbFetch = json.optBoolean("adrenoFbFetch", def.adrenoFbFetch),
|
||||
coalesceRenderPasses = json.optBoolean("coalesceRenderPasses", def.coalesceRenderPasses),
|
||||
forceMaliFbFetch = json.optBoolean("forceMaliFbFetch", def.forceMaliFbFetch),
|
||||
useAngleOpenGL = json.optBoolean("useAngleOpenGL", def.useAngleOpenGL),
|
||||
overrideTextureBarriers = json.optInt("overrideTextureBarriers", def.overrideTextureBarriers),
|
||||
@@ -2084,6 +2094,7 @@ data class Settings(
|
||||
if (current.hwRov != base.hwRov) j.put("hwRov", current.hwRov)
|
||||
if (current.hwAa1 != base.hwAa1) j.put("hwAa1", current.hwAa1)
|
||||
if (current.adrenoFbFetch != base.adrenoFbFetch) j.put("adrenoFbFetch", current.adrenoFbFetch)
|
||||
if (current.coalesceRenderPasses != base.coalesceRenderPasses) j.put("coalesceRenderPasses", current.coalesceRenderPasses)
|
||||
if (current.forceMaliFbFetch != base.forceMaliFbFetch) j.put("forceMaliFbFetch", current.forceMaliFbFetch)
|
||||
if (current.useAngleOpenGL != base.useAngleOpenGL) j.put("useAngleOpenGL", current.useAngleOpenGL)
|
||||
if (current.overrideTextureBarriers != base.overrideTextureBarriers) j.put("overrideTextureBarriers", current.overrideTextureBarriers)
|
||||
@@ -2308,6 +2319,7 @@ data class Settings(
|
||||
hwAa1 = if (overrides.has("hwAa1")) overrides.getBoolean("hwAa1") else base.hwAa1,
|
||||
hwAat = false,
|
||||
adrenoFbFetch = if (overrides.has("adrenoFbFetch")) overrides.getBoolean("adrenoFbFetch") else base.adrenoFbFetch,
|
||||
coalesceRenderPasses = if (overrides.has("coalesceRenderPasses")) overrides.getBoolean("coalesceRenderPasses") else base.coalesceRenderPasses,
|
||||
forceMaliFbFetch = if (overrides.has("forceMaliFbFetch")) overrides.getBoolean("forceMaliFbFetch") else base.forceMaliFbFetch,
|
||||
useAngleOpenGL = if (overrides.has("useAngleOpenGL")) overrides.getBoolean("useAngleOpenGL") else base.useAngleOpenGL,
|
||||
overrideTextureBarriers = if (overrides.has("overrideTextureBarriers")) overrides.getInt("overrideTextureBarriers") else base.overrideTextureBarriers,
|
||||
|
||||
@@ -1284,6 +1284,31 @@ val EN: Map<String, String> = mapOf(
|
||||
"savestate.restoreBackup.confirmTitle" to "Restore backup?",
|
||||
"savestate.slot.emptyTapToSave" to "(empty — tap to save here)",
|
||||
"savestate.title.loadManage" to "Load / Manage Saves",
|
||||
"pad.pressureAmount.label" to "Pressure modifier amount",
|
||||
"pad.pressureAmount.description" to
|
||||
"How hard the pressure modifier presses, for DualShock 2 pressure-sensitive games " +
|
||||
"(Metal Gear Solid, GTA). Applies to the on-screen PRESSURE button and to the " +
|
||||
"\"Pressure Modifier (hold)\" binding. Lower = softer press.",
|
||||
"renderer.coalesceRenderPasses.label" to "Coalesce render passes",
|
||||
"renderer.coalesceRenderPasses.description" to
|
||||
"Groups consecutive draws to the same target into one render pass. Helps on tiling GPUs " +
|
||||
"— which is every phone GPU — where each pass boundary costs a full tile load and store. " +
|
||||
"Rendering output is unchanged. New; off by default.",
|
||||
"app.library.coverSize" to "Cover size",
|
||||
"app.blockHome" to "Block Home button while playing",
|
||||
"app.blockHome.desc" to
|
||||
"Pins the screen while a game runs, so a controller's Home or Guide button can't minimise " +
|
||||
"it. Android asks you to confirm the first time. To leave, hold Back + Recents — or just " +
|
||||
"quit to the library, which unpins automatically.",
|
||||
"savestate.error.hardcore" to
|
||||
"Save states are disabled while RetroAchievements Hardcore Mode is on. Turn Hardcore off " +
|
||||
"in the RetroAchievements settings to use them (this forfeits hardcore points for the session).",
|
||||
"savestate.error.memcardBusy" to
|
||||
"The game is still writing to the memory card, so the state was not saved. " +
|
||||
"Resume the game for a second or two, then try again — the card stays busy for as " +
|
||||
"long as the game is paused.",
|
||||
"savestate.error.save" to "Couldn't save to that slot. Check the log for @@ANDROID_SAVESTATE@@.",
|
||||
"savestate.error.load" to "Couldn't load that slot.",
|
||||
"savestate.title.save" to "Save State",
|
||||
"setup.aspect.auto" to "Auto",
|
||||
"setup.aspect.stretch" to "Stretch",
|
||||
|
||||
@@ -79,7 +79,15 @@ class EmulationSurface(context: Context) :
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
lastRequestedFrameRate = Float.NaN
|
||||
NativeApp.onNativeSurfaceChanged(null, 0, 0)
|
||||
// ★ onNativeSurfaceDestroyed(), NOT onNativeSurfaceChanged(null, 0, 0). Both null s_window,
|
||||
// but the latter gates its MTGS::UpdateDisplayWindow() repost on `width > 0 && height > 0`
|
||||
// — false here — so the GS thread was NEVER told the surface died. It could then sit in
|
||||
// vkAcquireNextImageKHR with a UINT64_MAX timeout on a swapchain whose window is no longer
|
||||
// composited, and because every Java->GS route is marshalled through the CPU thread, the
|
||||
// only code that could rebuild the swapchain was queued behind the CPU thread that the GS
|
||||
// thread was blocking. Nothing times out — that is the "sometimes it never unpauses" case.
|
||||
// The correct entry point existed and was fully implemented; it just had no caller.
|
||||
NativeApp.onNativeSurfaceDestroyed()
|
||||
}
|
||||
|
||||
override fun onDisplayAdded(displayId: Int) = Unit
|
||||
|
||||
@@ -518,6 +518,8 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
private fun onReturnedToLibrary() {
|
||||
currentGame.value = null
|
||||
emulationOwnsOrientation = false
|
||||
// Never leave the device pinned once the game is gone (#425).
|
||||
com.armsx2.ui.ScreenPinning.stop()
|
||||
stopAutoProgressiveScanHold()
|
||||
instance?.runOnUiThread { instance?.applyEmulationOrientation() }
|
||||
}
|
||||
@@ -533,6 +535,13 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
/** How long to keep the combo held. Titles probe it at very different points — some well
|
||||
* after the PS2 logo — so this deliberately spans the whole boot sequence. */
|
||||
private const val AUTO_PROGRESSIVE_HOLD_MS = 30_000L
|
||||
/// How often the synthetic Triangle+Cross hold is re-pressed. Must be well under a frame
|
||||
/// budget's worth of pad polling so the game never samples a gap, and short enough that a
|
||||
/// pad re-init can't swallow the whole hold.
|
||||
private const val AUTO_PROGRESSIVE_REASSERT_MS = 200L
|
||||
/// Keep holding this long after the game's ELF starts, then let go — the 480p prompt is
|
||||
/// checked at game start, and holding into the menus would fight the player.
|
||||
private const val AUTO_PROGRESSIVE_POST_ELF_MS = 4_000L
|
||||
|
||||
/** Pad writes are dropped while no VM exists (applyPadButton bails on !HasValidVM), so
|
||||
* wait for boot rather than pressing into the void. Bounded so a failed boot can't spin. */
|
||||
@@ -553,9 +562,37 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
if (!NativeApp.hasActiveVM())
|
||||
return@launch
|
||||
held = true
|
||||
NativeApp.setPadButton(PAD_CODE_TRIANGLE, 0, true)
|
||||
NativeApp.setPadButton(PAD_CODE_CROSS, 0, true)
|
||||
delay(AUTO_PROGRESSIVE_HOLD_MS)
|
||||
// ★ RE-ASSERT, don't set once. setPadButton writes the button state a single
|
||||
// time, but the pad is (re)initialised during boot — "Pad: DS2 Config Finished"
|
||||
// lands well after the VM goes active — and that wipes the state we set before
|
||||
// it existed. So the hold silently evaporated before the game ever sampled it,
|
||||
// which is exactly the Tekken 4 report: holding Triangle+Cross by hand works,
|
||||
// the automatic hold does nothing. Re-pressing on a short interval survives any
|
||||
// number of pad resets.
|
||||
//
|
||||
// Release shortly after the game's own ELF starts rather than blocking for the
|
||||
// full timeout: the 480p prompt is checked at game start, and continuing to jam
|
||||
// Triangle+Cross into a booted game would fight the player in the menus. CRC
|
||||
// goes non-zero exactly when the ELF is running, so it is the right edge to
|
||||
// watch. AUTO_PROGRESSIVE_HOLD_MS remains the hard ceiling.
|
||||
var elapsed = 0L
|
||||
var sinceElf = -1L
|
||||
while (elapsed < AUTO_PROGRESSIVE_HOLD_MS) {
|
||||
if (!NativeApp.hasActiveVM())
|
||||
return@launch
|
||||
NativeApp.setPadButton(PAD_CODE_TRIANGLE, 0, true)
|
||||
NativeApp.setPadButton(PAD_CODE_CROSS, 0, true)
|
||||
delay(AUTO_PROGRESSIVE_REASSERT_MS)
|
||||
elapsed += AUTO_PROGRESSIVE_REASSERT_MS
|
||||
val elfRunning = runCatching { NativeApp.getGameCRC() }.getOrNull()
|
||||
?.let { it.length == 8 && it != "00000000" } ?: false
|
||||
if (elfRunning) {
|
||||
if (sinceElf < 0) sinceElf = 0
|
||||
else sinceElf += AUTO_PROGRESSIVE_REASSERT_MS
|
||||
if (sinceElf >= AUTO_PROGRESSIVE_POST_ELF_MS)
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Release on every exit path, cancellation included — a stuck Triangle+Cross
|
||||
// would make the game unplayable. These are plain JNI calls, not suspends, so
|
||||
@@ -593,6 +630,9 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
WindowImpl.overlayVisible.value = false
|
||||
WindowImpl.toolbarVisible.value = false
|
||||
emulationOwnsOrientation = true
|
||||
// Opt-in only: blocks a controller's Home button from minimising the game,
|
||||
// which the app cannot do any other way — HOME never reaches us (#425).
|
||||
instance?.let { com.armsx2.ui.ScreenPinning.start(it) }
|
||||
applyRendererPrefs()
|
||||
// Both of these are consumed by native when the VM boots, so they must be
|
||||
// pushed BEFORE runVMThread (which blocks until the VM exits). One resolve,
|
||||
@@ -829,7 +869,7 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
// Remember the game for a post-exit re-launch from the Save Manager (#374).
|
||||
if (info != null) lastLaunchedGame = info
|
||||
if (info != null) contextGame.value = info
|
||||
println(
|
||||
"@@ANDROID_LAUNCH_GAME@@ title=${info?.title ?: "<direct>"} " +
|
||||
"uri=${uri.take(240)} state=${eState.value} runLoop=$vmRunLoopActive " +
|
||||
@@ -1003,6 +1043,29 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
fun pauseForOverlay() {
|
||||
if (vmStopInProgress)
|
||||
return
|
||||
// Routed through vmControl exactly like resume(), NOT inline. The comment above the
|
||||
// executor claims pause and resume are serialised against each other; while this
|
||||
// bypassed it they were enqueued from two different Java threads, so a pause raised
|
||||
// while a resume was still in flight could be evaluated first and swallowed (native
|
||||
// pause() only acts when the VM is exactly Running, and never retries) — which leaves
|
||||
// the VM RUNNING in the background after the app is gone.
|
||||
vmControl.execute {
|
||||
if (vmStopInProgress)
|
||||
return@execute
|
||||
pauseForOverlayOnVmThread()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseForOverlayOnVmThread() {
|
||||
// ★ Keep the audio device OPEN across an overlay pause. Otherwise SPU2::SetOutputPaused
|
||||
// pauses the Oboe stream, Android reclaims an idle low-latency stream after a few
|
||||
// seconds (#333), and then the RESUME has to Close/Open/Start it again — inline on the
|
||||
// CPU thread, inside the resume task, AHEAD of Host::OnVMResumed(). That is why coming
|
||||
// back from another app can sit "stuck on pause" for seconds before the game moves.
|
||||
// Suppressed, the stream underruns to silence instead: nothing to reclaim, nothing to
|
||||
// rebuild. native-lib.cpp has always documented pauseForOverlay as the caller that sets
|
||||
// this — it simply never called it.
|
||||
runCatching { NativeApp.setOutputPauseSuppressed(true) }
|
||||
NativeApp.pause()
|
||||
}
|
||||
|
||||
@@ -1010,8 +1073,12 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
if (vmStopInProgress)
|
||||
return
|
||||
vmControl.execute {
|
||||
if (!vmStopInProgress)
|
||||
if (!vmStopInProgress) {
|
||||
NativeApp.resume()
|
||||
// Cleared only after the resume lands, so a later non-overlay pause (VM stop,
|
||||
// shutdown) still releases the device normally.
|
||||
runCatching { NativeApp.setOutputPauseSuppressed(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,10 +1248,10 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
// re-launch + load a save AFTER the game was exited. Kept SEPARATE from currentGame
|
||||
// (which stop() nulls for settings-scope) so it can't resurrect per-game scope in the
|
||||
// library. GitHub #374 — "exit, press Load → nothing boots" because currentGame was null.
|
||||
private var lastLaunchedGame: GameInfo? = null
|
||||
val contextGame = mutableStateOf<GameInfo?>(null)
|
||||
|
||||
fun launchCurrentGameFromSaveSlot(slot: Int): Boolean {
|
||||
val game = currentGame.value ?: lastLaunchedGame ?: return false
|
||||
val game = currentGame.value ?: contextGame.value ?: return false
|
||||
val launchPath = if (game.uri.scheme == "file") {
|
||||
game.uri.path ?: game.uri.toString()
|
||||
} else {
|
||||
@@ -1837,12 +1904,24 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
com.armsx2.config.ConfigStore.resolveForGame(currentGame.value?.settingsKey).orientation
|
||||
else
|
||||
com.armsx2.ui.theme.LauncherOrientationPreferences.mode.value
|
||||
requestedOrientation = when (orientation) {
|
||||
val requested = when (orientation) {
|
||||
1 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
2 -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
|
||||
3 -> ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
// ★ Only ASSIGN when it actually changes. Writing requestedOrientation makes Android
|
||||
// re-evaluate orientation even when the value is identical, and with the default
|
||||
// SCREEN_ORIENTATION_UNSPECIFIED there is no lock to hold it — so a handheld device can
|
||||
// resolve portrait for one frame and snap back to landscape, producing TWO configuration
|
||||
// changes. Each one destroys and recreates the Vulkan swapchain and re-uploads every ImGui
|
||||
// resource, which freezes the PICTURE for seconds while the EE keeps running untouched.
|
||||
// Confirmed on a Retroid Pocket 6: two "finishDrawing of orientation change" from
|
||||
// WindowManager landing exactly on two "Creating a swap chain" (1080x1920 then 1920x1080).
|
||||
// This function is called from several paths (boot, settings edits, and a LaunchedEffect
|
||||
// keyed on the resolved settings tier), so redundant calls are normal and must be free.
|
||||
if (requestedOrientation != requested)
|
||||
requestedOrientation = requested
|
||||
}
|
||||
|
||||
private fun applyEdgeToEdge() {
|
||||
@@ -1924,6 +2003,7 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
com.armsx2.ui.UiScale.load()
|
||||
com.armsx2.ui.theme.ThemePreferences.load()
|
||||
com.armsx2.ui.theme.BootLogoPreferences.load()
|
||||
com.armsx2.ui.ScreenPinning.load()
|
||||
com.armsx2.ui.theme.ToolbarPositionPreferences.load()
|
||||
com.armsx2.ui.theme.LibraryChromePreferences.load()
|
||||
com.armsx2.ui.theme.LauncherOrientationPreferences.load()
|
||||
@@ -2282,6 +2362,32 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ★ The REAL stuck-resume backstop. The block above cannot serve as one:
|
||||
// it sits in the `else` of LaunchedEffect(frontendOwnsFocus), and reaching
|
||||
// that `else` requires frontendOwnsFocus == false, i.e. eState was already
|
||||
// RUNNING — so its `if (eState == PAUSED)` can only fire in the sliver
|
||||
// between composition and coroutine start, never in the state it was
|
||||
// written for. Keyed on the actual stuck condition instead, and it RETRIES:
|
||||
// the native resume() only acts when the VM is exactly Paused and there is
|
||||
// no retry anywhere, so a resume issued a moment too early is simply lost.
|
||||
val stuckPaused = !WindowImpl.frontendCovers &&
|
||||
eState.value == EmuState.PAUSED &&
|
||||
!WindowImpl.showLibrary.value &&
|
||||
!com.armsx2.ui.touch.TouchControls.editMode.value
|
||||
androidx.compose.runtime.LaunchedEffect(stuckPaused) {
|
||||
if (!stuckPaused) return@LaunchedEffect
|
||||
// The normal close path posts its resume after a 220 ms dismiss
|
||||
// animation, so let that win first; only then start nudging.
|
||||
repeat(4) {
|
||||
kotlinx.coroutines.delay(700)
|
||||
if (eState.value != EmuState.PAUSED || WindowImpl.frontendCovers ||
|
||||
WindowImpl.showLibrary.value ||
|
||||
com.armsx2.ui.touch.TouchControls.editMode.value
|
||||
) return@LaunchedEffect
|
||||
println("@@ANDROID_RESUME_RETRY@@ attempt=$it eState=${eState.value}")
|
||||
resume()
|
||||
}
|
||||
}
|
||||
AndroidView(factory = { surface.value!! }, modifier = Modifier
|
||||
// Drop the surface from the focus system while ANY
|
||||
// Compose frontend surface (pause overlay, in-game
|
||||
|
||||
@@ -132,8 +132,15 @@ object InGameOverlay {
|
||||
|
||||
fun open() {
|
||||
if (WindowImpl.overlayVisible.value) return
|
||||
// getPauseGameSerial() formats as "SLUS-21621 (A422BB13)", but GameInfo.settingsKey — what
|
||||
// every other reader and writer keys on — is the BARE serial. Used raw, this stored
|
||||
// settings under "SLUS-21621 (A422BB13)" while boot looked up "SLUS-21621", so per-game
|
||||
// settings silently never applied on any launch without a GameInfo (Boot Disc, Swap Disc,
|
||||
// BIOS). A BIOS boot is worse still: CRC 0 yields " (00000000)", which is not blank, so it
|
||||
// forced Game scope onto a phantom key. Strip to the serial and drop what's left if empty.
|
||||
val serial = MainActivityRuntime.currentGame.value?.settingsKey
|
||||
?: runCatching { NativeApp.getPauseGameSerial() }.getOrNull()?.takeIf(String::isNotBlank)
|
||||
?: runCatching { NativeApp.getPauseGameSerial() }.getOrNull()
|
||||
?.substringBefore(" (")?.trim()?.takeIf(String::isNotBlank)
|
||||
currentSerial.value = serial
|
||||
settingsScope.value = if (serial == null) SettingsScope.Global else SettingsScope.Game
|
||||
settingsState.value = ConfigStore.resolveForGame(serial)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.armsx2.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import com.armsx2.runtime.MainActivityRuntime
|
||||
|
||||
/**
|
||||
* Opt-in screen pinning, so a stray controller Home button can't dump you out of a game (#425).
|
||||
*
|
||||
* The GameSir G8+ "GS" button — and the equivalent on several other pads — is wired to Android's
|
||||
* HOME key rather than to `KEYCODE_BUTTON_MODE`. That distinction is the whole problem: HOME is
|
||||
* consumed by the system in `PhoneWindowManager.interceptKeyBeforeDispatching` and is **never**
|
||||
* delivered to a normal app, so there is nothing for ARMSX2 to intercept, swallow, or rebind. Any
|
||||
* pad button that does reach us is already bindable — `labelForKey` falls through to
|
||||
* `KeyEvent.keyCodeToString`, so even exotic keycodes work today. HOME simply never arrives.
|
||||
*
|
||||
* Screen pinning is the one documented API that actually stops it. `Activity.startLockTask()` on
|
||||
* an app that is not a device owner enters the user-confirmed "Pin screen?" flow, and while pinned
|
||||
* the system itself blocks HOME and Recents. That is a real behavioural change to the whole device
|
||||
* UI, so it is strictly opt-in and off by default; unpin the normal way (hold Back + Recents, or
|
||||
* Back + Home) or just end the pinned session by leaving the game.
|
||||
*/
|
||||
object ScreenPinning {
|
||||
private const val KEY = "ui.blockHomeButton"
|
||||
|
||||
val enabled = mutableStateOf(false)
|
||||
|
||||
/** True while we actually hold a pinned session, so we only stop what we started. */
|
||||
private var pinned = false
|
||||
|
||||
fun load() {
|
||||
enabled.value = runCatching { MainActivityRuntime.prefs.getBoolean(KEY, false) }
|
||||
.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun set(on: Boolean) {
|
||||
enabled.value = on
|
||||
runCatching { MainActivityRuntime.prefs.edit().putBoolean(KEY, on).apply() }
|
||||
// Take effect immediately rather than at the next game launch — the user almost certainly
|
||||
// just got kicked out of a game and is turning this on to stop it happening again.
|
||||
if (!on) stop()
|
||||
}
|
||||
|
||||
/** Called when emulation starts. No-op unless the user opted in. */
|
||||
fun start(activity: Activity) {
|
||||
if (!enabled.value || pinned) return
|
||||
// Throws IllegalStateException if the activity isn't resumed, and is a no-op on devices
|
||||
// where pinning is disabled by policy — neither is worth interrupting a game launch for.
|
||||
runCatching {
|
||||
activity.startLockTask()
|
||||
pinned = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when returning to the library, so the device isn't left pinned outside a game. */
|
||||
fun stop() {
|
||||
if (!pinned) return
|
||||
pinned = false
|
||||
runCatching { MainActivityRuntime.instance?.stopLockTask() }
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,31 @@ object UiScale {
|
||||
val borderScale = mutableStateOf(1.0f)
|
||||
val fontScale = mutableStateOf(1.0f)
|
||||
|
||||
// ---- Library cover size ------------------------------------------------------------
|
||||
// The library grid is GridCells.Adaptive with a fixed ~104/118dp cell, which is sized for a
|
||||
// phone. On a tablet "adaptive" just means MORE columns, not bigger art — a 1200dp-wide screen
|
||||
// gets ten columns of phone-sized covers, which is the reported "covers are tiny on a tablet,
|
||||
// needs its own layout". Scaling the adaptive cell width fixes it without a second layout:
|
||||
// wider cells => bigger art AND fewer, better-spaced columns, and phone users can use it too.
|
||||
private const val KEY_COVER = "ui.coverScale"
|
||||
const val COVER_MIN = 0.75f
|
||||
const val COVER_MAX = 2.50f
|
||||
|
||||
/** Always 1.0 out of the box, on every screen size. An earlier version guessed a bigger default
|
||||
* for tablets from the measured width; that silently changed the library for people who never
|
||||
* asked, so the scaling is opt-in and tablet users just move the slider. */
|
||||
val coverScale = mutableStateOf(1.0f)
|
||||
|
||||
fun load() {
|
||||
borderScale.value = MainActivityRuntime.prefs.getFloat(KEY_BORDER, 1.0f).coerceIn(MIN, BORDER_MAX)
|
||||
fontScale.value = MainActivityRuntime.prefs.getFloat(KEY_FONT, 1.0f).coerceIn(MIN, MAX)
|
||||
coverScale.value = MainActivityRuntime.prefs.getFloat(KEY_COVER, 1.0f).coerceIn(COVER_MIN, COVER_MAX)
|
||||
}
|
||||
|
||||
fun setCoverScale(v: Float) {
|
||||
val c = v.coerceIn(COVER_MIN, COVER_MAX)
|
||||
coverScale.value = c
|
||||
MainActivityRuntime.prefs.edit().putFloat(KEY_COVER, c).apply()
|
||||
}
|
||||
|
||||
fun setBorderScale(v: Float) {
|
||||
|
||||
@@ -65,9 +65,14 @@ object WindowImpl {
|
||||
}
|
||||
|
||||
private fun resumeIfPaused() {
|
||||
if (MainActivityRuntime.eState.value == EmuState.PAUSED &&
|
||||
!com.armsx2.ui.touch.TouchControls.editMode.value
|
||||
) {
|
||||
// Deliberately NOT gated on eState == PAUSED any more. eState is driven by
|
||||
// Host::OnVMPaused/OnVMResumed, which fire at the very END of VMManager::SetState — after
|
||||
// the pause edge has already parked MTVU/MTGS — so it lags the real VM state. A stale
|
||||
// RUNNING here silently skipped the resume and left the game frozen with no overlay up,
|
||||
// which is the mirror of the bug in the focus-effect backstop. The native resume() is
|
||||
// already a no-op unless the VM is exactly Paused, so calling it unconditionally is safe
|
||||
// and drops the stale-state dependency entirely.
|
||||
if (!com.armsx2.ui.touch.TouchControls.editMode.value) {
|
||||
MainActivityRuntime.resume()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ import com.armsx2.ui.touch.TouchControls
|
||||
import com.armsx2.ui.theme.Danger
|
||||
import com.armsx2.ui.common.StatusChip
|
||||
import com.armsx2.ui.theme.Success
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -94,7 +95,18 @@ fun EmulationMenuScreen(viewModel: EmulationMenuViewModel = viewModel()) {
|
||||
if (!dismissing) {
|
||||
dismissing = true
|
||||
shown = false
|
||||
scope.launch {
|
||||
// ★ Dispatchers.Main, NOT the composition's own dispatcher. rememberCoroutineScope
|
||||
// inherits the composition context, which on Android is AndroidUiDispatcher — it
|
||||
// dispatches continuations on CHOREOGRAPHER FRAME CALLBACKS. We have just set
|
||||
// shown = false, so once the exit animation settles Compose has nothing left to
|
||||
// invalidate, no frame is scheduled, and the continuation after this delay is
|
||||
// never dispatched: the VM is simply never told to resume. The game sits paused
|
||||
// with the OSD reading "FPS: N/A" until something incidentally causes a frame —
|
||||
// which is exactly why tapping the on-screen controls "speeds up" the recovery
|
||||
// (touch input schedules a frame) and why waiting also eventually works.
|
||||
// Dispatchers.Main is a plain main-looper Handler dispatcher with no frame
|
||||
// dependency, so the resume fires on time whether or not anything is drawing.
|
||||
scope.launch(Dispatchers.Main) {
|
||||
delay(220)
|
||||
viewModel.dismissHandler = null
|
||||
viewModel.resumeImmediately()
|
||||
@@ -615,6 +627,16 @@ private fun GraphicsPane(state: EmulationMenuUiState, viewModel: EmulationMenuVi
|
||||
) { on ->
|
||||
viewModel.updateSettings { it.copy(gsBackThreadMode = if (on) 3 else 0) }
|
||||
}
|
||||
// Every phone GPU is a tiler, so this belongs in the in-game menu next to the other
|
||||
// renderer levers, not just in full settings — it is the kind of thing you toggle while
|
||||
// looking at the framerate.
|
||||
MenuSwitchRow(
|
||||
str("renderer.coalesceRenderPasses.label"),
|
||||
settings.coalesceRenderPasses,
|
||||
description = str("renderer.coalesceRenderPasses.description"),
|
||||
) { on ->
|
||||
viewModel.updateSettings { it.copy(coalesceRenderPasses = on) }
|
||||
}
|
||||
CompactAction(str("backend.applyRestart"), "↻", Modifier.fillMaxWidth(), MainActivityRuntime::restart)
|
||||
HorizontalOptions(
|
||||
title = str("renderer.upscale.label"),
|
||||
|
||||
+6
-1
@@ -331,7 +331,12 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
fun openAchievements() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Achievements)
|
||||
|
||||
fun updateSettings(transform: (Settings) -> Settings) {
|
||||
val updated = transform(state.value.settings)
|
||||
// ★ Transform the LIVE shared settings, not this screen's snapshot. state.value.settings is
|
||||
// only refreshed in load(), so every write here shipped the whole Settings object as it
|
||||
// looked when the menu opened — silently reverting anything changed elsewhere since. That
|
||||
// is the long-standing whole-object clobber, and it is why the FPS cap read back as 0
|
||||
// moments after being set: a later save from a stale snapshot re-pushed the old value.
|
||||
val updated = transform(InGameOverlay.settingsState.value)
|
||||
InGameOverlay.saveSettings(updated)
|
||||
state.value = state.value.copy(settings = updated)
|
||||
}
|
||||
|
||||
@@ -225,8 +225,13 @@ fun HomeScreen(
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val compact = maxWidth < 600.dp
|
||||
// Adaptive cells alone give a tablet MORE columns rather than BIGGER art, so scale the
|
||||
// cell width. Bigger cells mean bigger covers and fewer, better-spaced columns. Opt-in:
|
||||
// 1.0 everywhere until the user moves the Cover size slider.
|
||||
val coverScale = com.armsx2.ui.UiScale.coverScale.value
|
||||
val gridCellDp = (if (compact) 104f else 118f) * coverScale
|
||||
val columns = if (state.layout == LibraryLayout.Grid) {
|
||||
GridCells.Adaptive(if (compact) 104.dp else 118.dp)
|
||||
GridCells.Adaptive(gridCellDp.dp)
|
||||
} else {
|
||||
// List and Shelf are full-width rows.
|
||||
GridCells.Fixed(1)
|
||||
@@ -236,8 +241,12 @@ fun HomeScreen(
|
||||
// otherwise Up/Down move one cover at a time (feeling like Left/Right) and
|
||||
// only the very first cover can step up into the Recents row.
|
||||
val estimatedColumns = when (state.layout) {
|
||||
LibraryLayout.Grid -> (maxWidth.value / if (compact) 112f else 128f).toInt().coerceAtLeast(1)
|
||||
LibraryLayout.Shelf -> (maxWidth.value / ((if (compact) 84f else 100f) + 20f)).toInt().coerceIn(3, 8)
|
||||
// MUST track gridCellDp — this feeds HomeInputController's Up/Down step, so if the
|
||||
// estimate and the real column count diverge, controller navigation skips rows.
|
||||
LibraryLayout.Grid ->
|
||||
(maxWidth.value / ((if (compact) 112f else 128f) * coverScale)).toInt().coerceAtLeast(1)
|
||||
LibraryLayout.Shelf ->
|
||||
(maxWidth.value / (((if (compact) 84f else 100f) * coverScale) + 20f)).toInt().coerceIn(3, 8)
|
||||
LibraryLayout.List -> 1
|
||||
}
|
||||
LaunchedEffect(estimatedColumns) { HomeInputController.setColumnCount(estimatedColumns) }
|
||||
@@ -472,7 +481,7 @@ fun HomeScreen(
|
||||
GameShelf(
|
||||
games = shownRecents,
|
||||
shelfRes = R.drawable.shelf_frosted,
|
||||
coverWidth = if (compact) 84.dp else 100.dp,
|
||||
coverWidth = ((if (compact) 84f else 100f) * coverScale).dp,
|
||||
scroll = true,
|
||||
selectedIndex = recentSel,
|
||||
onLaunch = { viewModel.launch(it) },
|
||||
@@ -547,7 +556,7 @@ fun HomeScreen(
|
||||
emptyLibrary(state.query.isBlank())
|
||||
} else if (state.layout == LibraryLayout.Shelf) {
|
||||
// Fill each plank: chunk by how many covers fit the shelf width.
|
||||
val shelfCoverW = if (compact) 84.dp else 100.dp
|
||||
val shelfCoverW = ((if (compact) 84f else 100f) * coverScale).dp
|
||||
val perShelf = (maxWidth.value / (shelfCoverW.value + 20f)).toInt().coerceIn(3, 8)
|
||||
val shelfRows = state.visibleGames.chunked(perShelf)
|
||||
items(
|
||||
|
||||
@@ -60,7 +60,7 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
val serial = InGameOverlay.currentSerial.value
|
||||
?.trim()?.uppercase()
|
||||
?.takeIf { Regex("^[A-Z]{4}-\\d{5}$").matches(it) }
|
||||
val crc = runCatching { NativeApp.getGameCRC() }.getOrNull()?.takeIf { it.length == 8 }?.uppercase()
|
||||
val crc = liveCrc()
|
||||
val files = patchDirectories().flatMap { directory ->
|
||||
if (!directory.isDirectory) emptyList() else directory.walkTopDown().filter { it.isFile && it.extension.equals("pnach", true) }.toList()
|
||||
}.filter { f ->
|
||||
@@ -97,18 +97,59 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
}
|
||||
|
||||
fun update(transform: (Settings) -> Settings) {
|
||||
val updated = transform(state.value.settings)
|
||||
// Transform the CURRENT scoped settings, not this screen's snapshot — see the note in
|
||||
// EmulationMenuViewModel.updateSettings. scopedSettings() resolves the same tier this
|
||||
// save will land in, so the round-trip is consistent.
|
||||
val updated = transform(scopedSettings())
|
||||
// The shared entry point: picks the tier from the scope, live-applies, and keeps
|
||||
// settingsState in step so the pause menu and the other tabs see the same values.
|
||||
InGameOverlay.saveSettings(updated)
|
||||
state.value = state.value.copy(settings = updated)
|
||||
}
|
||||
|
||||
/** The CRC of whatever is booted, or null. `getGameCRC()` formats "%08X" unconditionally, so
|
||||
* with no VM it returns the literal "00000000" — 8 characters, which sails through a bare
|
||||
* `length == 8` check and yields a `<serial>_00000000.pnach` the core can never load. */
|
||||
private fun liveCrc(): String? =
|
||||
runCatching { NativeApp.getGameCRC() }.getOrNull()
|
||||
?.takeIf { it.length == 8 && it != "00000000" }?.uppercase()
|
||||
|
||||
/** Best known serial: the pause overlay's, then the live VM's, then the last game opened
|
||||
* (which outlives quitting to the library, unlike the other two). */
|
||||
private fun bestSerial(): String? =
|
||||
(InGameOverlay.currentSerial.value
|
||||
?: runCatching { NativeApp.getGameSerial() }.getOrNull()
|
||||
?: MainActivityRuntime.contextGame.value?.serial)
|
||||
?.trim()?.uppercase()?.takeIf { Regex("^[A-Z]{4}-\\d{5}$").matches(it) }
|
||||
|
||||
fun import(uri: Uri) {
|
||||
val context = getApplication<Application>()
|
||||
val original = DocumentFile.fromSingleUri(context, uri)?.name?.takeIf(String::isNotBlank) ?: "imported.pnach"
|
||||
val requested = if (original.endsWith(".pnach", true)) original else "$original.pnach"
|
||||
val directory = patchDirectories().first().apply { mkdirs() }
|
||||
val stem = original.substringBeforeLast('.')
|
||||
// The core only ever globs "<SERIAL>_<CRC>*.pnach" or "<CRC>*.pnach", case-SENSITIVELY
|
||||
// (FileSystem::FindFiles -> WildcardMatch defaults to case_sensitive=true). Copying the
|
||||
// file under its source name — which is what this did — produced something the Patch
|
||||
// Manager happily listed and the core could never load, so it looked installed and did
|
||||
// nothing. Rename to the canonical form, keeping the original stem after the CRC: the
|
||||
// trailing wildcard still matches it, so the user can recognise their own file.
|
||||
val serial = bestSerial()
|
||||
val crc = liveCrc()
|
||||
val alreadyCanonical = Regex("^[A-Z]{4}-\\d{5}_[0-9A-F]{8}").containsMatchIn(stem.uppercase())
|
||||
val requested = when {
|
||||
alreadyCanonical -> if (original.endsWith(".pnach", true)) original else "$original.pnach"
|
||||
serial != null && crc != null -> "${serial}_$crc $stem.pnach"
|
||||
else -> if (original.endsWith(".pnach", true)) original else "$original.pnach"
|
||||
}
|
||||
// Cheats are gated behind EnableCheats and suppressed under RA hardcore; widescreen and
|
||||
// no-interlacing patches must not be. Route by what the file actually contains rather
|
||||
// than dumping everything in cheats/ as before.
|
||||
val text = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() }
|
||||
}.getOrNull().orEmpty()
|
||||
val isPatchNotCheat = Regex("(?i)\\[(widescreen|no-?interlacing)|gsaspectratio=|gsinterlacemode=")
|
||||
.containsMatchIn(text)
|
||||
val dirs = patchDirectories()
|
||||
val directory = (if (isPatchNotCheat) dirs[1] else dirs[0]).apply { mkdirs() }
|
||||
val target = uniqueFile(directory, requested)
|
||||
val success = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { input -> target.outputStream().use(input::copyTo) }
|
||||
@@ -116,7 +157,14 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
target.length() > 0L
|
||||
}.getOrDefault(false)
|
||||
if (!success) target.delete()
|
||||
state.value = if (success) state.value.copy(message = "Imported ${target.name}.") else state.value.copy(error = "Patch import failed.")
|
||||
state.value = if (success) {
|
||||
val loadable = Regex("^[A-Z]{4}-\\d{5}_[0-9A-F]{8}").containsMatchIn(target.name.uppercase())
|
||||
state.value.copy(
|
||||
message = if (loadable) "Imported as ${target.name}."
|
||||
else "Imported ${target.name}, but the core only loads <SERIAL>_<CRC>.pnach and " +
|
||||
"no CRC is known yet — launch this game once, then re-import to have it renamed.",
|
||||
)
|
||||
} else state.value.copy(error = "Patch import failed.")
|
||||
if (success) {
|
||||
// Register the imported file's enabled (labelled) cheats in the native Enable
|
||||
// list BEFORE reloading, or the first reload skips them (see syncEnableListForFile).
|
||||
@@ -242,8 +290,10 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
// therefore matches NOTHING: the install appeared to succeed and the cheats could
|
||||
// never apply. Fall back to the running game's CRC, and refuse outright rather than
|
||||
// write a file the core can never load.
|
||||
val crcForName = snapshot.onlineCrc.takeIf { it.isNotBlank() }
|
||||
?: runCatching { NativeApp.getGameCRC() }.getOrNull()?.takeIf { it.length == 8 }
|
||||
// liveCrc() rejects the "00000000" no-VM sentinel, which the old `length == 8` check let
|
||||
// through — so this "refuse" branch never fired with nothing booted and it wrote a
|
||||
// <serial>_00000000.pnach that could never load.
|
||||
val crcForName = snapshot.onlineCrc.takeIf { it.isNotBlank() } ?: liveCrc()
|
||||
if (crcForName == null) {
|
||||
runCatching {
|
||||
NativeApp.emulog(
|
||||
|
||||
@@ -97,6 +97,8 @@ fun SaveStatePickerScreen(mode: SaveMode, onBack: () -> Unit) {
|
||||
runCatching { NativeApp.hasAutosaveState() }.getOrDefault(false)
|
||||
} else false
|
||||
}
|
||||
// i18n KEY of the last slot-tap failure, shown in place of silently closing the picker.
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
ArmsBackdrop {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
@@ -105,6 +107,14 @@ fun SaveStatePickerScreen(mode: SaveMode, onBack: () -> Unit) {
|
||||
else str("savestate.title.loadManage"),
|
||||
leading = { RoundAction("←", str("action.back"), onBack) },
|
||||
)
|
||||
failure?.let { key ->
|
||||
Text(
|
||||
str(key),
|
||||
color = Color(0xFFFFB4A2),
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
// Scrollable body: the Load screen stacks the auto-save options ABOVE the
|
||||
// slot grid, and the interval-autosave row made that block tall enough to
|
||||
// squeeze a weight(1f) grid — the two rows of tiles shrank to fit and looked
|
||||
@@ -142,11 +152,38 @@ fun SaveStatePickerScreen(mode: SaveMode, onBack: () -> Unit) {
|
||||
items((0 until SLOTS).toList(), key = { "slot_$it" }) { slot ->
|
||||
SlotTile(slot, mode) { selected ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
when (mode) {
|
||||
// The result used to be discarded and onBack() called either way, so
|
||||
// a refused save closed the picker looking exactly like a successful
|
||||
// one — no state written, no warning. That is the reported "closes
|
||||
// as if saved, takes 2-3 attempts". Stay open and say why instead.
|
||||
val ok = when (mode) {
|
||||
SaveMode.Save -> NativeApp.saveStateToSlot(selected)
|
||||
SaveMode.Load -> NativeApp.loadStateFromSlot(selected)
|
||||
}
|
||||
withContext(Dispatchers.Main) { onBack() }
|
||||
val busy = !ok && mode == SaveMode.Save &&
|
||||
runCatching { NativeApp.isMemcardBusy() }.getOrDefault(false)
|
||||
val hardcore = !ok &&
|
||||
runCatching { NativeApp.isHardcoreMode() }.getOrDefault(false)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (ok) {
|
||||
failure = null
|
||||
onBack()
|
||||
} else {
|
||||
// Store the KEY, not the resolved text — str() is
|
||||
// @Composable and this is a coroutine, and keeping the key
|
||||
// lets the banner re-translate on a language switch.
|
||||
failure = when {
|
||||
// RA hardcore forbids save states outright, and the
|
||||
// refusal happens inside VMManager — below every exit
|
||||
// this JNI logs — so it surfaced as a bare "couldn't
|
||||
// load that slot" with nothing in logcat. Name it.
|
||||
hardcore -> "savestate.error.hardcore"
|
||||
busy -> "savestate.error.memcardBusy"
|
||||
mode == SaveMode.Save -> "savestate.error.save"
|
||||
else -> "savestate.error.load"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,13 @@ fun AppTab() {
|
||||
onChange = { BootLogoPreferences.set(it) },
|
||||
)
|
||||
|
||||
ToggleRow(
|
||||
label = str("app.blockHome"),
|
||||
value = com.armsx2.ui.ScreenPinning.enabled.value,
|
||||
description = str("app.blockHome.desc"),
|
||||
onChange = { com.armsx2.ui.ScreenPinning.set(it) },
|
||||
)
|
||||
|
||||
ToggleRow(
|
||||
label = str("app.libraryMusic"),
|
||||
value = com.armsx2.LibraryMusic.enabled.value,
|
||||
@@ -488,6 +495,15 @@ fun AppTab() {
|
||||
onChange = LibraryChromePreferences::setShowRecents,
|
||||
)
|
||||
|
||||
IntSliderRow(
|
||||
label = str("app.library.coverSize"),
|
||||
value = (com.armsx2.ui.UiScale.coverScale.value * 100f).toInt().coerceIn(75, 250),
|
||||
min = 75,
|
||||
max = 250,
|
||||
valueFormatter = { "$it%" },
|
||||
onChange = { com.armsx2.ui.UiScale.setCoverScale(it / 100f) },
|
||||
)
|
||||
|
||||
IntSliderRow(
|
||||
label = str("app.library.opacity"),
|
||||
value = LibraryChromePreferences.libraryOpacity.value,
|
||||
|
||||
@@ -268,6 +268,20 @@ fun PadTab(@Suppress("UNUSED_PARAMETER") state: MutableState<Settings>) {
|
||||
onChange = { ControllerMappings.setHapticIntensity(it); refreshToken.intValue++ },
|
||||
)
|
||||
SettingsDivider()
|
||||
// How hard the DS2 pressure modifier presses. There was a PRESSURE button (on-screen
|
||||
// and bindable as "Pressure Modifier (hold)") but no way to choose the amount, so it
|
||||
// was permanently stuck at the hardcoded 50%. Range is deliberately 5..95: 0 collides
|
||||
// with the "full press" sentinel and 100 is just a normal press.
|
||||
IntSliderRow(
|
||||
label = str("pad.pressureAmount.label"),
|
||||
value = com.armsx2.ui.touch.TouchControls.pressurePercent.intValue,
|
||||
min = 5,
|
||||
max = 95,
|
||||
description = str("pad.pressureAmount.description"),
|
||||
valueFormatter = { "${it}%" },
|
||||
onChange = { com.armsx2.ui.touch.TouchControls.setPressurePercent(it) },
|
||||
)
|
||||
SettingsDivider()
|
||||
// PS2 Multitap: route up to 8 controllers (both ports become 4-slot taps).
|
||||
// The pref drives PadRouter's slot count + the boot-time native arming; when a
|
||||
// game is already running we also arm it live. setMultitap parks the VM, so it
|
||||
|
||||
@@ -432,6 +432,16 @@ fun RendererTab(state: MutableState<Settings>) {
|
||||
apply(s.copy(hwRov = it))
|
||||
}
|
||||
SettingsDivider()
|
||||
// Every Android GPU is a tiler, so this is aimed at us even though it landed with
|
||||
// only a desktop UI. Default OFF because it is brand new, not because it is risky.
|
||||
ToggleRow(
|
||||
str("renderer.coalesceRenderPasses.label"),
|
||||
s.coalesceRenderPasses,
|
||||
description = str("renderer.coalesceRenderPasses.description"),
|
||||
) {
|
||||
apply(s.copy(coalesceRenderPasses = it))
|
||||
}
|
||||
SettingsDivider()
|
||||
ToggleRow(
|
||||
str("renderer.accurateBlendingFastPath.label"),
|
||||
s.adrenoFbFetch,
|
||||
@@ -714,6 +724,11 @@ private fun GsDumpCaptureRow() {
|
||||
private fun activeTextureSerial(): String? {
|
||||
return MainActivityRuntime.currentGame.value?.serial?.takeIf { it.isNotBlank() }
|
||||
?: runCatching { NativeApp.getGameSerial() }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
// Last resort: the game the user most recently had open. Both sources above go blank the
|
||||
// moment you quit to the library (currentGame is nulled so per-game settings scope can't
|
||||
// leak, and the VM's serial dies with the VM), which stranded texture-pack import behind
|
||||
// "Boot a game first" even though the user had just played — and quit — that game.
|
||||
?: MainActivityRuntime.contextGame.value?.serial?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun importTexturePack(context: Context, uri: Uri, serial: String): Int {
|
||||
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -1140,7 +1142,18 @@ private fun InfoHint(title: String, message: String) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { open = false },
|
||||
title = { Text(title) },
|
||||
text = { Text(message) },
|
||||
// AlertDialog does NOT scroll its text slot: a description longer than the slot was
|
||||
// simply CLIPPED mid-sentence with no way to reach the rest, which is most of the
|
||||
// longer setting explanations (reported against Low Latency Mode, which cuts off at
|
||||
// "...turning back off if the frame pacing"). Cap the height and scroll inside it.
|
||||
text = {
|
||||
Text(
|
||||
message,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 340.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { open = false }) { Text(str("action.close")) }
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
|
||||
SettingsSearchEntry("app.bootLogo", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.library.search", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.library.recents", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.library.coverSize", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.blockHome", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.theme", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.toolbarPosition", true, SettingsCategory.General),
|
||||
SettingsSearchEntry("app.launcherRotation", true, SettingsCategory.General),
|
||||
@@ -84,6 +86,8 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
|
||||
SettingsSearchEntry("renderer.dumpReplaceableTextures.label", true, SettingsCategory.Graphics),
|
||||
SettingsSearchEntry("renderer.texturePackOsd.label", true, SettingsCategory.Graphics),
|
||||
SettingsSearchEntry("renderer.rov.label", true, SettingsCategory.Graphics),
|
||||
SettingsSearchEntry("renderer.coalesceRenderPasses.label", true, SettingsCategory.Graphics),
|
||||
SettingsSearchEntry("pad.pressureAmount.label", true, SettingsCategory.Controls),
|
||||
SettingsSearchEntry("renderer.accurateBlendingFastPath.label", true, SettingsCategory.Graphics),
|
||||
SettingsSearchEntry("renderer.forceMaliFbFetch.label", true, SettingsCategory.Graphics),
|
||||
SettingsSearchEntry("renderer.angleOpenGL.label", true, SettingsCategory.Graphics),
|
||||
|
||||
+6
-1
@@ -63,10 +63,15 @@ class TextureManagerViewModel(application: Application) : AndroidViewModel(appli
|
||||
if (!live.isNullOrBlank()) return live
|
||||
}
|
||||
return MainActivityRuntime.currentGame.value?.serial
|
||||
// Survives quitting to the library, where the two sources above are both blank.
|
||||
?: MainActivityRuntime.contextGame.value?.serial?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
fun update(transform: (Settings) -> Settings) {
|
||||
val updated = transform(state.value.settings)
|
||||
// Transform the CURRENT global, not this screen's snapshot — see the note in
|
||||
// EmulationMenuViewModel.updateSettings. This one saves to global explicitly, so read
|
||||
// global explicitly rather than whatever was loaded when the screen opened.
|
||||
val updated = transform(ConfigStore.loadGlobal())
|
||||
ConfigStore.saveGlobal(updated)
|
||||
if (MainActivityRuntime.nativeReady.value) runCatching { updated.applyTo() }
|
||||
state.value = state.value.copy(settings = updated)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user