Fix white-screen on USB detach and OOM from cover art bitmaps

Three independent stability fixes that surfaced while testing the Joy-Con
controller path:

1. Manifest: add keyboard|keyboardHidden|navigation|uiMode to MainActivity's
   configChanges. Without these, unplugging the OTG cable mid-game triggered
   a navigation/keyboard configuration change that destroyed and recreated
   the activity. The new activity instance never re-established the emulator
   surface cleanly, leaving a white screen, and Samsung Freecess then froze
   the seemingly-idle process.

2. GameCard: cap Coil's image decode at 384px. LibRetro thumbnail PNGs are
   often 1500x2000+, which burns ~12 MB of bitmap per card. With 30 visible
   tiles that's hundreds of MB and was OOM-ing the app during in-game input
   delivery (NewObjectV in nativePollOnce → JNI abort).

3. CoverArtManager: try (World) as a fallback region tag and add a cleaned-
   title-plus-common-region cascade. LibRetro stores Super Mario Bros. as
   '(World).png' but most US dumps tag the ROM '(U)', so the previous
   expansion table only tried (USA)/(USA, Europe) and missed it.
This commit is contained in:
2026-04-11 17:33:29 -04:00
parent da5f3a912b
commit 31f7bd8904
3 changed files with 48 additions and 9 deletions

View File

@@ -31,7 +31,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation|uiMode"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -31,13 +31,25 @@ class CoverArtManager(private val context: Context) {
val dest = cacheFile(gameTitle, consoleType) val dest = cacheFile(gameTitle, consoleType)
// Try the title as-is, with expanded regions, then fully cleaned // Build the list of candidate filenames to try, in priority order:
// 1. Title as-is
// 2. Title with GoodTools-style region tags expanded into LibRetro forms
// (e.g. "(U)" -> "(USA)", "(USA, Europe)", "(World)")
// 3. Fully cleaned title (no parens at all) — rare hit
// 4. Cleaned title with each common LibRetro region tag appended — this catches
// cases like "Super Mario Bros. (U)" where LibRetro stores "(World)" but our
// region table didn't know that this specific game used the World tag.
val namesToTry = buildList { val namesToTry = buildList {
add(gameTitle) add(gameTitle)
val expanded = expandRegions(gameTitle) val expanded = expandRegions(gameTitle)
expanded.filter { it != gameTitle }.forEach { add(it) } expanded.filter { it != gameTitle }.forEach { add(it) }
val cleaned = cleanTitle(gameTitle) val cleaned = cleanTitle(gameTitle)
if (cleaned != gameTitle && cleaned !in expanded) add(cleaned) if (cleaned != gameTitle && cleaned !in expanded) add(cleaned)
// Append fallback region tags to the bare cleaned title
for (region in COMMON_LIBRETRO_REGIONS) {
val candidate = "$cleaned $region"
if (candidate !in this) add(candidate)
}
} }
for (name in namesToTry) { for (name in namesToTry) {
@@ -45,10 +57,12 @@ class CoverArtManager(private val context: Context) {
.replace("+", "%20") .replace("+", "%20")
val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png" val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png"
if (downloadFile(url, dest)) { if (downloadFile(url, dest)) {
Log.d(TAG, "Cover art hit for '$gameTitle' via '$name'")
return dest.absolutePath return dest.absolutePath
} }
} }
Log.d(TAG, "No cover art match for '$gameTitle' after ${namesToTry.size} attempts")
return null return null
} }
@@ -89,13 +103,14 @@ class CoverArtManager(private val context: Context) {
private const val TAG = "CoverArtManager" private const val TAG = "CoverArtManager"
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails" private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
// GoodNES (U) can mean (USA) or (USA, Europe) — try both // GoodTools-style short region tags can map to several LibRetro long forms.
// Order matters — earlier entries are tried first.
private val regionExpansions = mapOf( private val regionExpansions = mapOf(
"(U)" to listOf("(USA, Europe)", "(USA)"), "(U)" to listOf("(USA)", "(USA, Europe)", "(World)"),
"(E)" to listOf("(Europe)", "(Europe, Australia)"), "(E)" to listOf("(Europe)", "(Europe, Australia)", "(World)"),
"(J)" to listOf("(Japan)", "(Japan, USA)"), "(J)" to listOf("(Japan)", "(Japan, USA)", "(World)"),
"(UE)" to listOf("(USA, Europe)"), "(UE)" to listOf("(USA, Europe)", "(USA)", "(World)"),
"(JU)" to listOf("(Japan, USA)"), "(JU)" to listOf("(Japan, USA)", "(USA)", "(Japan)"),
"(W)" to listOf("(World)"), "(W)" to listOf("(World)"),
"(F)" to listOf("(France)"), "(F)" to listOf("(France)"),
"(G)" to listOf("(Germany)"), "(G)" to listOf("(Germany)"),
@@ -104,6 +119,19 @@ class CoverArtManager(private val context: Context) {
"(Unl)" to listOf("(USA) (Unl)", "(Unl)"), "(Unl)" to listOf("(USA) (Unl)", "(Unl)"),
) )
// Region tags to append to a cleaned (paren-stripped) title as a fallback for ROMs
// whose original filename had no region tag, an unrecognized region tag, or a tag
// that doesn't match what LibRetro stores for that specific game (e.g. SMB1 is
// "(World)" but most US dumps tag it "(U)").
private val COMMON_LIBRETRO_REGIONS = listOf(
"(USA)",
"(World)",
"(USA, Europe)",
"(Europe)",
"(Japan)",
"(Japan, USA)",
)
fun expandRegions(title: String): List<String> { fun expandRegions(title: String): List<String> {
val stripped = title val stripped = title
.replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1] .replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1]

View File

@@ -24,9 +24,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.size.Scale
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import java.io.File import java.io.File
@@ -55,8 +58,16 @@ fun GameCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (game.coverArtPath != null) { if (game.coverArtPath != null) {
// Cap decode size — LibRetro thumbnail PNGs can be 1500x2000+, which
// burns ~12 MB of bitmap memory each. With 30 cards visible that's
// hundreds of MB and was OOM-ing the app during in-game input delivery.
// 384px is more than enough for a tile in a grid.
AsyncImage( AsyncImage(
model = File(game.coverArtPath), model = ImageRequest.Builder(LocalContext.current)
.data(File(game.coverArtPath))
.size(384)
.scale(Scale.FILL)
.build(),
contentDescription = "${game.title} cover art", contentDescription = "${game.title} cover art",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()