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

View File

@@ -31,13 +31,25 @@ class CoverArtManager(private val context: Context) {
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 {
add(gameTitle)
val expanded = expandRegions(gameTitle)
expanded.filter { it != gameTitle }.forEach { add(it) }
val cleaned = cleanTitle(gameTitle)
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) {
@@ -45,10 +57,12 @@ class CoverArtManager(private val context: Context) {
.replace("+", "%20")
val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png"
if (downloadFile(url, dest)) {
Log.d(TAG, "Cover art hit for '$gameTitle' via '$name'")
return dest.absolutePath
}
}
Log.d(TAG, "No cover art match for '$gameTitle' after ${namesToTry.size} attempts")
return null
}
@@ -89,13 +103,14 @@ class CoverArtManager(private val context: Context) {
private const val TAG = "CoverArtManager"
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(
"(U)" to listOf("(USA, Europe)", "(USA)"),
"(E)" to listOf("(Europe)", "(Europe, Australia)"),
"(J)" to listOf("(Japan)", "(Japan, USA)"),
"(UE)" to listOf("(USA, Europe)"),
"(JU)" to listOf("(Japan, USA)"),
"(U)" to listOf("(USA)", "(USA, Europe)", "(World)"),
"(E)" to listOf("(Europe)", "(Europe, Australia)", "(World)"),
"(J)" to listOf("(Japan)", "(Japan, USA)", "(World)"),
"(UE)" to listOf("(USA, Europe)", "(USA)", "(World)"),
"(JU)" to listOf("(Japan, USA)", "(USA)", "(Japan)"),
"(W)" to listOf("(World)"),
"(F)" to listOf("(France)"),
"(G)" to listOf("(Germany)"),
@@ -104,6 +119,19 @@ class CoverArtManager(private val context: Context) {
"(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> {
val stripped = title
.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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.size.Scale
import com.lazy.emulate.data.model.Game
import java.io.File
@@ -55,8 +58,16 @@ fun GameCard(
contentAlignment = Alignment.Center
) {
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(
model = File(game.coverArtPath),
model = ImageRequest.Builder(LocalContext.current)
.data(File(game.coverArtPath))
.size(384)
.scale(Scale.FILL)
.build(),
contentDescription = "${game.title} cover art",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()