Merge pull request #29 from The412Banner/feat/armsx2-polish

ARMSX2 like RPCS3, controllers and on-screen controls everywhere, Back, game art
This commit is contained in:
The412Banner authored and GitHub committed 2026-09-24 05:44:41 -04:00
commit 08a6769bc0
8 files changed
+440 -25

No files matched your search

@@ -47,6 +47,7 @@ import com.droiddeck.launcher.ui.CreditsDialog
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
@@ -885,6 +886,12 @@ class MainActivity : ComponentActivity() {
} else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { steamGames = games; emulatorList = emus }
// Box art for the games that have none, fetched after the list is up; the list is
// rebuilt once if any was found.
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
val refreshed = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { emulatorList = refreshed }
}
}, "library").start()
}
@@ -192,6 +192,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
SessionDrawer(drawerOpen, DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
@@ -885,7 +887,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.OSC_ALWAYS -> true
SessionPrefs.OSC_STEAM_QAM -> true
SessionPrefs.OSC_NEVER -> false
else -> !PadBridge.anyControllerConnected()
// Auto: the touch pad when there is no controller - except on the desktop, where the
// screen is a touchpad for the pointer and a pad over it would be in the way. A game
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
@@ -0,0 +1,260 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* Box art for the emulators' games, found and kept on the device: the front end shows a disc image
* with nothing but its emulator's logo otherwise.
*
* In order: the name of the file against RetroArch's thumbnail collection for the system
* (libretro-thumbnails, named after the standard disc-dump names - "Need for Speed - Underground 2
* (USA)"), first exactly and then loosely (tags dropped, the same region preferred) against the
* collection's list of names; then the disc's own ID, read from the image - PS2 serials against
* xlenore/ps2-covers (what PCSX2 and ARMSX2 download from), GameCube/Wii IDs against GameTDB (what
* Dolphin uses). A PS3 disc image gives its own PS3_GAME/ICON0.PNG first: the libretro PS3
* collection is small. A cover is downloaded once into files/covers; a game none of them has is not
* asked about again for a few days.
*/
object CoverArt {
private const val TAG = "CoverArt"
private const val MISS_RETRY_MS = 3L * 24 * 3600 * 1000
private const val INDEX_TTL_MS = 14L * 24 * 3600 * 1000
/** Downloads in one pass, so a first visit to a big library does not hold the network for long. */
private const val MAX_PER_PASS = 40
/** The emulator's systems in libretro-thumbnails, in the order they are tried. */
private val collections = mapOf(
"rpcs3" to listOf("Sony_-_PlayStation_3"),
"armsx2" to listOf("Sony_-_PlayStation_2"),
"duckstation" to listOf("Sony_-_PlayStation"),
"ppsspp" to listOf("Sony_-_PlayStation_Portable"),
"dolphin" to listOf("Nintendo_-_GameCube", "Nintendo_-_Wii"),
"melonds" to listOf("Nintendo_-_Nintendo_DS"),
"cemu" to listOf("Nintendo_-_Wii_U"),
)
private fun root(context: Context) = File(context.filesDir, "covers")
/** The file name, as the cover is kept under: a game keeps its art when its folder moves. */
private fun keyOf(rom: Library.Rom): String =
rom.hostPath.name.replace(Regex("[^A-Za-z0-9._() -]"), "_").take(150)
private fun coverFile(context: Context, rom: Library.Rom) = File(root(context), "${rom.emulatorId}/${keyOf(rom)}.img")
private fun missFile(context: Context, rom: Library.Rom) = File(root(context), "${rom.emulatorId}/${keyOf(rom)}.miss")
/** A cover already on the device for this game, or null. */
fun cached(context: Context, rom: Library.Rom): File? = coverFile(context, rom).takeIf { it.isFile && it.length() > 0 }
private fun wanted(context: Context, rom: Library.Rom): Boolean {
if (rom.art != null || cached(context, rom) != null || rom.emulatorId !in collections) return false
val miss = missFile(context, rom)
return !(miss.isFile && System.currentTimeMillis() - miss.lastModified() < MISS_RETRY_MS)
}
/**
* Looks for covers for the games that have none, on the calling (background) thread. True when
* at least one was found, so the caller rebuilds the list to show it.
*/
fun fetchMissing(context: Context, roms: List<Library.Rom>): Boolean {
var found = false
var tries = 0
for (rom in roms) {
if (!wanted(context, rom)) continue
if (++tries > MAX_PER_PASS) break
val target = coverFile(context, rom)
// A PS3 disc carries its own cover (PS3_GAME/ICON0.PNG, not encrypted): no download.
if (rom.emulatorId == "rpcs3" && try { copyPs3Icon(rom.hostPath, target) } catch (e: Exception) { false }) {
Log.i(TAG, "${rom.name}: ICON0.PNG from the disc")
missFile(context, rom).delete()
found = true
continue
}
val url = try { findUrl(context, rom) } catch (e: Exception) { Log.w(TAG, "${rom.name}: $e"); null }
if (url != null && download(url, target)) {
Log.i(TAG, "${rom.name}: $url")
missFile(context, rom).delete()
found = true
} else {
missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") }
}
}
return found
}
private fun findUrl(context: Context, rom: Library.Rom): String? {
val name = rom.hostPath.nameWithoutExtension.removeSuffix(".dec")
for (collection in collections.getValue(rom.emulatorId)) {
// Exactly the dump name first: no listing needed.
val exact = thumbnailUrl(collection, name)
if (exists(exact)) return exact
// Then loosely, against the collection's names.
nearest(name, names(context, collection))?.let { return thumbnailUrl(collection, it) }
}
// The disc's own ID.
return when (rom.emulatorId) {
"armsx2" -> ps2Serial(rom.hostPath)?.let { "https://raw.githubusercontent.com/xlenore/ps2-covers/main/covers/default/$it.jpg" }
"dolphin" -> nintendoDiscId(rom.hostPath)?.let { id ->
val region = when (id.getOrNull(3)) { 'P' -> "EN"; 'J' -> "JA"; 'K' -> "KO"; else -> "US" }
"https://art.gametdb.com/wii/cover/$region/$id.png"
}
else -> null
}?.takeIf { exists(it) }
}
/** libretro-thumbnails replaces these in a title's file name. */
private fun thumbName(title: String) = title.replace(Regex("[&*/:`<>?\\\\|\"]"), "_")
private fun thumbnailUrl(collection: String, title: String) =
"https://raw.githubusercontent.com/libretro-thumbnails/$collection/master/Named_Boxarts/" +
URLEncoder.encode("${thumbName(title)}.png", "UTF-8").replace("+", "%20")
// ---- loose matching ------------------------------------------------------------------------
private fun tags(name: String): List<String> = Regex("[(\\[]([^)\\]]*)[)\\]]").findAll(name).map { it.groupValues[1].lowercase() }.toList()
/** A title without its tags, articles and punctuation: "Legend of Zelda, The (USA)" = "legend of zelda". */
private fun base(name: String): String = name
.replace(Regex("\\s*[(\\[][^)\\]]*[)\\]]"), " ")
.lowercase()
.replace(Regex("[^a-z0-9]+"), " ")
.split(' ').filter { it.isNotEmpty() && it != "the" && it != "a" }
.joinToString(" ")
private fun nearest(name: String, candidates: List<String>): String? {
if (candidates.isEmpty()) return null
val want = base(name)
if (want.isEmpty()) return null
val same = candidates.filter { base(it) == want }
if (same.isEmpty()) return null
val region = tags(name).firstOrNull { it in setOf("usa", "europe", "japan", "world", "korea") } ?: "usa"
return same.firstOrNull { region in tags(it) } ?: same.firstOrNull { "usa" in tags(it) } ?: same.first()
}
/** The titles in a collection's Named_Boxarts (no extension), kept for a fortnight. */
private fun names(context: Context, collection: String): List<String> {
val cache = File(root(context), "index-$collection.txt")
if (cache.isFile && System.currentTimeMillis() - cache.lastModified() < INDEX_TTL_MS) return cache.readLines()
val list = try {
val top = JSONArray(get("https://api.github.com/repos/libretro-thumbnails/$collection/contents/") ?: return cachedOr(cache))
val sha = (0 until top.length()).map { top.getJSONObject(it) }.firstOrNull { it.optString("name") == "Named_Boxarts" }
?.optString("sha") ?: return cachedOr(cache)
val tree = JSONObject(get("https://api.github.com/repos/libretro-thumbnails/$collection/git/trees/$sha") ?: return cachedOr(cache))
.getJSONArray("tree")
(0 until tree.length()).map { tree.getJSONObject(it).optString("path") }
.filter { it.endsWith(".png") }.map { it.removeSuffix(".png") }
} catch (e: Exception) {
Log.w(TAG, "index $collection: $e"); return cachedOr(cache)
}
cache.parentFile?.mkdirs()
cache.writeText(list.joinToString("\n"))
return list
}
private fun cachedOr(cache: File): List<String> = if (cache.isFile) cache.readLines() else emptyList()
// ---- disc IDs ------------------------------------------------------------------------------
/**
* A file's bytes from an ISO 9660 image by path ("PS3_GAME/ICON0.PNG"), or null. Primary volume
* names only (upper case, ";1" suffix), which is what PS2 and PS3 discs use.
*/
private fun isoFile(file: File, path: String, maxSize: Int): ByteArray? {
if (!file.name.endsWith(".iso", ignoreCase = true)) return null
RandomAccessFile(file, "r").use { f ->
fun read(lba: Long, length: Int): ByteArray = ByteArray(length).also { f.seek(lba * 2048); f.readFully(it) }
fun le32(b: ByteArray, at: Int) = (b[at].toLong() and 0xff) or ((b[at + 1].toLong() and 0xff) shl 8) or
((b[at + 2].toLong() and 0xff) shl 16) or ((b[at + 3].toLong() and 0xff) shl 24)
val pvd = read(16, 2048)
if (String(pvd, 1, 5, Charsets.US_ASCII) != "CD001") return null
var lba = le32(pvd, 156 + 2)
var size = le32(pvd, 156 + 10)
val parts = path.split('/')
for ((i, part) in parts.withIndex()) {
val dir = read(lba, size.toInt().coerceAtMost(256 * 1024))
var at = 0
var hit = false
while (at < dir.size) {
val len = dir[at].toInt() and 0xff
if (len == 0) { at = (at / 2048 + 1) * 2048; continue }
val name = String(dir, at + 33, dir[at + 32].toInt() and 0xff, Charsets.US_ASCII).substringBefore(';')
if (name.equals(part, ignoreCase = true)) {
lba = le32(dir, at + 2); size = le32(dir, at + 10); hit = true; break
}
at += len
}
if (!hit) return null
if (i == parts.lastIndex) return if (size in 1..maxSize) read(lba, size.toInt()) else null
}
}
return null
}
private fun copyPs3Icon(file: File, target: File): Boolean {
val png = isoFile(file, "PS3_GAME/ICON0.PNG", 4 * 1024 * 1024) ?: return false
target.parentFile?.mkdirs()
val tmp = File(target.path + ".part")
tmp.writeBytes(png)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(tmp.path, bounds)
return if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false }
}
/** "SLUS-21065" from a PS2 .iso's SYSTEM.CNF (BOOT2 = cdrom0:\SLUS_210.65;1), or null. */
private fun ps2Serial(file: File): String? {
val cnf = String(isoFile(file, "SYSTEM.CNF", 4096) ?: return null, Charsets.US_ASCII)
val m = Regex("BOOT2\\s*=\\s*cdrom0:\\\\?([A-Z]{4})_(\\d{3})\\.(\\d{2})").find(cnf) ?: return null
return "${m.groupValues[1]}-${m.groupValues[2]}${m.groupValues[3]}"
}
/** The six-character ID of a GameCube/Wii disc image (.iso/.gcm, or .wbfs), or null. */
private fun nintendoDiscId(file: File): String? {
val offset = when (file.extension.lowercase()) { "iso", "gcm" -> 0L; "wbfs" -> 0x200L; else -> return null }
RandomAccessFile(file, "r").use { f ->
val b = ByteArray(6); f.seek(offset); f.readFully(b)
val id = String(b, Charsets.US_ASCII)
return id.takeIf { it.all { c -> c.isLetterOrDigit() } }
}
}
// ---- network -------------------------------------------------------------------------------
private fun open(url: String, method: String = "GET"): HttpURLConnection =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 10_000
readTimeout = 20_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "DroidDeck")
}
private fun exists(url: String): Boolean = try {
val c = open(url, "HEAD"); val ok = c.responseCode == 200; c.disconnect(); ok
} catch (e: Exception) { false }
private fun get(url: String): String? = try {
val c = open(url)
if (c.responseCode != 200) { c.disconnect(); null } else c.inputStream.bufferedReader().use { it.readText() }
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
/** Saves the image at [url] to [target] if it really is an image. */
private fun download(url: String, target: File): Boolean = try {
val c = open(url)
if (c.responseCode != 200) { c.disconnect(); false } else {
target.parentFile?.mkdirs()
val tmp = File(target.path + ".part")
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(tmp.path, bounds)
if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false }
}
} catch (e: Exception) { Log.w(TAG, "$url: $e"); false }
}
@@ -71,11 +71,24 @@ object Library {
Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")),
Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()),
)
/**
* A dump's file name as a title: its tags - (USA), (En,Fr,Es,Pt), [!], (v1.01) - identify the
* file and are dropped for reading ("Tomb Raider (USA) (En,Fr,Es,Pt)" -> "Tomb Raider"). The
* file keeps them, and the cover lookup still uses them.
*/
private fun displayTitle(name: String): String =
name.replace(Regex("\\s*[(\\[][^)\\]]*[)\\]]"), "").trim().ifEmpty { name }
/** Every system folder name the specs claim, so a loose-file scan of the root skips them. */
private val systemFolders: Set<String> by lazy { specs.flatMap { it.folders }.toSet() }
private val installedIds = mapOf(
"rpcs3" to "rpcs3", "armsx2" to "armsx2", "dolphin" to "dolphin", "duckstation" to "duckstation",
"melonds" to "melonds", "cemu" to "cemu", "ppsspp" to "emulators", "retroarch" to "emulators",
)
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
/** Desktop catalog package that supplies this emulator. */
fun packageId(emulatorId: String): String? = installedIds[emulatorId]
@@ -85,20 +98,23 @@ object Library {
return specs.map { spec ->
val games = ArrayList<Rom>()
if (romsRoot != null && spec.exts.isNotEmpty()) {
// The system's folder(s), matched without regard to case, then the root itself for
// a file left loose there - and one folder deeper, since a dump usually comes as a
// folder named for the game with the image inside it.
// The system's folder(s), matched without regard to case, and up to three folders
// inside them - a dump usually comes as a folder named for the game with the image
// inside it, and people sort those into folders of their own (ps2/games/<game>/).
// The root itself only one deep, for a file left loose there.
val systemDirs = romsRoot.listFiles { f -> f.isDirectory && f.name.lowercase() in spec.folders }.orEmpty().toList()
val dirs = LinkedHashSet<File>()
for (top in systemDirs + romsRoot) {
dirs.add(top)
top.listFiles { f -> f.isDirectory }?.forEach { dirs.add(it) }
}
for (top in systemDirs) top.walkTopDown().maxDepth(3).filter { it.isDirectory }.forEach { dirs.add(it) }
dirs.add(romsRoot)
// ...and not into another system's folder: a PS3 .iso in ps3/ is not a PS2 game.
romsRoot.listFiles { f -> f.isDirectory && f.name.lowercase() !in systemFolders }?.forEach { dirs.add(it) }
for (dir in dirs) {
// A PS3 disc dump is a folder with PS3_GAME in it; RPCS3 boots the folder.
if (spec.id == "rpcs3" && File(dir, "PS3_GAME").isDirectory) {
val rel = dir.relativeTo(romsRoot).path
games.add(Rom(dir.name, dir, "/root/ROMs/$rel", spec.id))
games.add(Rom(displayTitle(dir.name), dir, "/root/ROMs/$rel", spec.id,
// The dump carries its own art, as an installed package does.
art = File(dir, "PS3_GAME/ICON0.PNG").takeIf { it.isFile }))
continue
}
dir.listFiles()?.sortedBy { it.name.lowercase() }?.forEach { f ->
@@ -106,13 +122,15 @@ object Library {
// A .bin beside a .cue is a track, not a game.
if (f.isFile && ext in spec.exts && !(ext == "bin" && File(dir, f.nameWithoutExtension + ".cue").isFile)) {
val rel = f.relativeTo(romsRoot).path
games.add(Rom(f.nameWithoutExtension.removeSuffix(".dec"), f, "/root/ROMs/$rel", spec.id))
games.add(Rom(displayTitle(f.nameWithoutExtension.removeSuffix(".dec")), f, "/root/ROMs/$rel", spec.id))
}
}
}
}
if (spec.id == "rpcs3") games.addAll(rpcs3Installed(context))
Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds.getValue(spec.id)), games)
// A cover found for the game before (CoverArt) where it has no art of its own.
val withArt = games.map { g -> if (g.art != null) g else CoverArt.cached(context, g)?.let { Rom(g.name, g.hostPath, g.guestPath, g.emulatorId, it) } ?: g }
Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds.getValue(spec.id)), withArt)
}
}
@@ -170,6 +188,9 @@ object Library {
/** How the emulator is told which game to boot, on its command line. */
fun launchArgs(emulatorId: String, guestPath: String): List<String> = when (emulatorId) {
"rpcs3" -> listOf("--no-gui", guestPath)
// Straight into the game in its controller-driven full-screen UI (first-time setup there
// too), and gone when the game is quit from its pause menu (guide button): -batch.
"armsx2" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath)
"dolphin" -> listOf("-e", guestPath)
"cemu" -> listOf("-g", guestPath)
else -> listOf(guestPath)
@@ -31,12 +31,18 @@ object DesktopCatalog {
return try {
val json = JSONObject(body)
val arr = json.getJSONArray("packages")
// A package built per memory page size (ARMSX2: 4K and 16K kernels) names the others
// under "pages"; the device's own size picks one, and the top-level file is the 4K one.
val pageSize = try {
android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE).toString()
} catch (e: Exception) { "4096" }
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
val file = o.optJSONObject("pages")?.optJSONObject(pageSize) ?: o
Entry(
o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.optString("version", ""),
o.optString("kind", "tar"), o.getString("url"), o.optString("sha256", ""),
o.optLong("size", 0L), o.optString("notes", ""),
o.optString("kind", "tar"), file.getString("url"), file.optString("sha256", ""),
file.optLong("size", 0L), o.optString("notes", ""),
o.optString("icon", "applications-games"), o.optString("category", "Game"),
)
}
@@ -233,6 +233,18 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
val colors = MaterialTheme.colorScheme
val ctx = LocalContext.current
BackHandler(enabled = s.pageKey != null && page != null) { a.onPageBack() }
// Back (and B) from a game or an emulator steps out one level, as its "‹" link does, instead
// of leaving the app: a game -> its emulator (or Steam), an emulator -> Desktop.
BackHandler(
enabled = (s.pageKey == null || page == null) &&
(selected.startsWith("app:") || selected.startsWith("emu:") || selected.startsWith("rom:")),
) {
selected = when {
selected.startsWith("app:") -> "steam"
selected.startsWith("emu:") -> "desktop"
else -> "emu:" + selected.removePrefix("rom:").substringBefore(':')
}
}
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true }
@@ -849,7 +861,7 @@ private fun Poster(art: File?, name: String, modifier: Modifier) {
.graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false }
.clip(Shape12).background(artBrush(hueOf(name))),
) {
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
if (art != null) CoverImage(art, Modifier.fillMaxSize())
else Text(name, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
@@ -921,13 +933,41 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
}
}
/**
* Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header -
* is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop.
*/
@Composable
private fun CoverImage(art: File, modifier: Modifier) {
var wideArt by remember(art) { mutableStateOf(false) }
Box(modifier) {
if (wideArt) {
AsyncImage(
model = art, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp),
)
Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)))
}
AsyncImage(
model = art, contentDescription = null,
contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop,
onSuccess = { state ->
val size = state.painter.intrinsicSize
if (size.width > size.height * 1.1f) wideArt = true
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
val colors = MaterialTheme.colorScheme
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
when {
art != null -> AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
else CoverImage(art, Modifier.fillMaxSize())
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
else -> {
@@ -142,6 +142,8 @@ fun SessionPausedOverlay(onResume: () -> Unit) {
/** Everything the drawer shows and does. */
class DrawerActions(
val steam: Boolean,
/** The drawer's heading: the emulator for a program from the rail, else Steam or Desktop. */
val title: String? = null,
val isHomeApp: Boolean,
val androidApps: List<HomeApp.LaunchableApp>,
val hudOn: Boolean,
@@ -215,7 +217,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Brush.linearGradient(listOf(colors.primary, pal.primary2))))
Spacer(Modifier.width(10.dp))
Text(if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
}
if (a.onSteamMenu != null && a.onQam != null) {
val qamInteraction = remember { MutableInteractionSource() }
@@ -318,9 +320,11 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"), a.touchMode,
onPick = a.onTouch,
)
if (a.steam) ChoiceRow(
ChoiceRow(
host, "osc", "On-screen controls", null,
listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never"), a.oscMode,
if (a.steam) listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never")
else listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"),
a.oscMode,
onPick = a.onOsc,
)
SettingsRow("Keyboard", null) {
@@ -116,7 +116,7 @@ def ini_section(lines, name):
return (start, len(lines)) if start is not None else None
def patch_ini_section(path, section, body, what, needle="SDL-", require=None):
def patch_ini_section(path, section, body, what, needle="SDL-", require=None, done="Player 1 on the controller"):
"""Add body's lines to [section] of an ini the emulator wrote itself, unless the section
already binds a controller (needle). Missing file: nothing - the emulator writes its own
defaults on first run and this runs again."""
@@ -144,7 +144,7 @@ def patch_ini_section(path, section, body, what, needle="SDL-", require=None):
with open(path + ".tmp", "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
os.replace(path + ".tmp", path)
print(f"bannerlator-pad-defaults: {what}: Player 1 on the controller ({path})")
print(f"bannerlator-pad-defaults: {what}: {done} ({path})")
# DuckStation: SDL-<n>/<name>, axes signed (src/util/sdl_input_source.cpp); Pad1 keys from
@@ -221,10 +221,7 @@ SmallMotor = SDL-0/SmallMotor
def pcsx2():
patch_ini_section(os.path.join(CONFIG, "PCSX2/inis/PCSX2.ini"), "Pad1", PCSX2_PAD1, "PCSX2",
require="SettingsVersion")
# ARMSX2 (PCSX2 with ARM64 recompilers): the same ini and binding names under its own folder
# (pcsx2/Pcsx2Config.cpp: $XDG_CONFIG_HOME/ARMSX2/inis/PCSX2.ini).
patch_ini_section(os.path.join(CONFIG, "ARMSX2/inis/PCSX2.ini"), "Pad1", PCSX2_PAD1, "ARMSX2",
require="SettingsVersion")
# Dolphin: SDL/<id>/<SDL gamepad name>; control names from InputCommon/.../SDL/SDLGamepad.h (Left Y+
@@ -287,6 +284,81 @@ Nunchuk/Stick/Right = `Left X+`
"""
# ARMSX2 - the PS2 emulator (PCSX2 with ARM64 recompilers; upstream PCSX2 interprets on ARM64). Its
# files are PCSX2's under $XDG_CONFIG_HOME/ARMSX2 (pcsx2/Pcsx2Config.cpp). The user supplies the BIOS
# and the games; they live in the ROMs folder (/root/ROMs). The guide button opens the pause menu
# (Hotkeys OpenPauseMenu) - the way out of a game started from the front end, which runs ARMSX2
# without its window (-batch -bigpicture) - so it is not also the analog toggle here.
ARMSX2_PAD1 = "\n".join(l for l in PCSX2_PAD1.splitlines() if not l.startswith("Analog ")) + "\n"
ROMS = "/root/ROMs"
def ps2_folder():
for name in ("ps2", "PS2"):
d = os.path.join(ROMS, name)
if os.path.isdir(d):
return d
return os.path.join(ROMS, "ps2")
def ps2_bios_folder():
base = ps2_folder()
for name in ("bios", "BIOS", "firmware"):
d = os.path.join(base, name)
if os.path.isdir(d):
return d
d = os.path.join(base, "bios")
if os.path.isdir(ROMS):
try:
os.makedirs(d, exist_ok=True) # where the user is told to put their BIOS
except OSError:
pass
return d
def armsx2():
root = os.path.join(CONFIG, "ARMSX2")
ini = os.path.join(root, "inis/PCSX2.ini")
old = os.path.join(CONFIG, "PCSX2")
if not os.path.isfile(ini) and os.path.isfile(os.path.join(old, "inis/PCSX2.ini")):
# Someone who played on PCSX2 here: the same settings, BIOS choice and memory cards (the
# saves), copied once. PCSX2's own folder is left as it was.
import shutil
for sub in ("inis", "memcards", "bios", "gamesettings", "cheats", "patches", "covers",
"inputprofiles", "textures"):
src = os.path.join(old, sub)
if os.path.isdir(src):
shutil.copytree(src, os.path.join(root, sub), dirs_exist_ok=True)
print(f"bannerlator-pad-defaults: ARMSX2: settings and memory cards copied from PCSX2 ({root})")
elif not os.path.isfile(ini):
# A new player: ARMSX2's own first-time setup still runs (SetupWizardIncomplete) to pick the
# BIOS, but looking in the ROMs folder, with the ps2 folder as its game list and the
# controller already on Player 1.
bios, games = ps2_bios_folder(), ps2_folder()
seed(ini, "[UI]\nSettingsVersion = 1\nSetupWizardIncomplete = true\n\n"
f"[Folders]\nBios = {bios}\n\n"
f"[GameList]\nRecursivePaths = {games}\n\n"
# Vulkan outright rather than Auto's pick: the renderer every other path here uses.
"[EmuCore/GS]\nRenderer = 14\n\n"
"[Hotkeys]\nOpenPauseMenu = Keyboard/Escape\nOpenPauseMenu = SDL-0/Guide\n\n"
"[Pad1]\n" + ARMSX2_PAD1, "ARMSX2", "first-time setup, BIOS from " + bios)
return
patch_ini_section(ini, "Pad1", ARMSX2_PAD1, "ARMSX2", require="SettingsVersion")
# The guide button is the pause menu here; PCSX2's profile (ours, copied over) had it as the
# analog toggle as well.
with open(ini, encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
kept = [l for l in lines if l.replace(" ", "") != "Analog=SDL-0/Guide"]
if len(kept) != len(lines):
with open(ini + ".tmp", "w", encoding="utf-8") as f:
f.write("\n".join(kept) + "\n")
os.replace(ini + ".tmp", ini)
patch_ini_section(ini, "Hotkeys", "OpenPauseMenu = SDL-0/Guide\n", "ARMSX2", needle="SDL-0/Guide",
require="SettingsVersion", done="the guide button opens the pause menu")
patch_ini_section(ini, "GameList", f"RecursivePaths = {ps2_folder()}\n", "ARMSX2", needle=ROMS,
require="SettingsVersion", done="games listed from the ROMs folder")
def dolphin():
d = os.path.join(CONFIG, "dolphin-emu")
seed(os.path.join(d, "GCPadNew.ini"), DOLPHIN_GC, "Dolphin (GameCube)")
@@ -454,7 +526,7 @@ def retroarch():
print(f"bannerlator-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})")
for step in (rpcs3, duckstation, pcsx2, dolphin, cemu, melonds, ppsspp, retroarch):
for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch):
try:
step()
except Exception as e: