mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
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:
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:
|
||||
|
||||
Reference in new issue
Block a user