From d1dec8d7a4e485f5715d95d48e8ce4d530db18f0 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Tue, 29 Sep 2026 23:01:21 -0400 Subject: [PATCH] Game saves: import and export save zips per game A floppy disk beside the Steam page's cog opens a Game saves page, laid out like Components: the Proton at the top (from the client's compatibility tools, with bannerlator-proton-arm64 shown as Proton Experimental), the zip type beside it (GameHub or Winlator, LB / RB turn it), and the games the client runs with that Proton (CompatToolMapping, its default for the rest), each with Import and Export. Import picks a zip in the app's file picker, backs up the game's steamuser profile to Download/DroidDeck/Saves/backups, then unzips drive_c with any user folder but Public renamed to steamuser, so GameHub and Winlator zips both land. Export finds the save folders by name under the usual roots (Bannerlator's SaveLocator), or takes the whole profile, and writes _.zip to a folder picked the same way, under steamuser (GameHub) or xuser (Winlator). Imports wait while a session runs. Ported from Bannerlator's GameSaveBackup and SaveLocator. --- .../com/droiddeck/launcher/MainActivity.kt | 117 +++++- .../droiddeck/launcher/session/GameSaves.kt | 335 ++++++++++++++++++ .../droiddeck/launcher/ui/ComponentsPage.kt | 2 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 4 +- .../droiddeck/launcher/ui/FrontEndWidgets.kt | 16 + .../droiddeck/launcher/ui/GameSavesPage.kt | 198 +++++++++++ .../com/droiddeck/launcher/ui/SteamHome.kt | 1 + 7 files changed, 665 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index bd606a0d9..a5da8b357 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -38,6 +38,8 @@ import com.droiddeck.launcher.session.OfflineMode import com.droiddeck.launcher.session.ProtonExtras import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.ui.ComponentsPage +import com.droiddeck.launcher.ui.GameSavesPage +import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.ui.ProtonPage @@ -111,6 +113,14 @@ class MainActivity : ComponentActivity() { private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) private var showMapping by mutableStateOf(false) + // Game saves page: import / export save zips per game (the floppy beside the Steam cog). + private var showSaves by mutableStateOf(false) + private var saveTools by mutableStateOf?>(null) + private var saveGames by mutableStateOf>(emptyList()) + private var saveTool by mutableStateOf(null) + private var saveLayout by mutableStateOf(GameSaves.Layout.GAMEHUB) + private var saveBusy by mutableStateOf(null) + private var pendingSaveGame: GameSaves.Game? = null private var controllerSettings by mutableStateOf(null) private var catalog by mutableStateOf?>(null) private var catalogLoading by mutableStateOf(false) @@ -151,6 +161,26 @@ class MainActivity : ComponentActivity() { private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) } } + private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val game = pendingSaveGame ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { zip -> + saveAction("Importing into ${game.game.name}") { + val (written, backup) = GameSaves.import(game, zip) + val kind = GameSaves.layoutOf(zip)?.label ?: "zip" + "Imported $written files from the $kind into ${game.game.name}" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "") + } + } + } + private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> + val game = pendingSaveGame ?: return@registerForActivityResult + if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { dir -> + val layout = saveLayout + saveAction("Exporting ${game.game.name}") { + val (zip, count) = GameSaves.export(game, layout, dir) + "Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}" + } + } + } private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path) @@ -275,6 +305,7 @@ class MainActivity : ComponentActivity() { showPerformance -> { { PerformanceHost() } } showProtons -> { { ProtonHost() } } showComponents -> { { ComponentsHost() } } + showSaves -> { { SavesHost() } } showMapping -> { { MappingHost() } } else -> null } @@ -289,7 +320,7 @@ class MainActivity : ComponentActivity() { steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this), lsfgReady = LsfgNative.isInstalled(this), - pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, + pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showSaves) "game-saves" else if (showMapping) "controller-mapping" else null, theme = theme, isHomeApp = homeAppSelected, homeScreenEnabled = homeScreenEnabled, @@ -348,7 +379,8 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, + onGameSaves = { openSaves() }, + onPerformance = { refreshCores(); showProtons = false; showComponents = false; showSaves = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onBrowseFiles = { dir -> @@ -373,7 +405,7 @@ class MainActivity : ComponentActivity() { offline = OfflineMode.enabled(this) }, onCredits = { showCredits = true }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, + onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLauncherFullscreen = { on -> SessionPrefs.setLauncherFullscreen(this, on) @@ -452,7 +484,7 @@ class MainActivity : ComponentActivity() { onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, - onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, + onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showSaves = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -556,6 +588,14 @@ class MainActivity : ComponentActivity() { /** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { + // On the Game saves page LB / RB flip the zip type exports are written in. + if (showSaves && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + val all = GameSaves.Layout.entries + saveLayout = all[(saveLayout.ordinal + 1) % all.size] + } + return true + } if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { val all = ComponentsManager.COMPONENTS @@ -659,10 +699,74 @@ class MainActivity : ComponentActivity() { }, "catalog-desktop").start() } + private fun openSaves() { + settingsMode = null + showPerformance = false + showProtons = false + showComponents = false + showMapping = false + showSaves = true + refreshSaves() + } + + /** Protons, which game runs with which, and the saves found in each prefix: files only, off the main thread. */ + private fun refreshSaves() { + val all = steamGames + Thread({ + val tools = runCatching { GameSaves.tools(this) }.getOrDefault(emptyList()) + val games = runCatching { GameSaves.games(this, all) }.getOrDefault(emptyList()) + ui.post { + saveTools = tools + saveGames = games + // Open on the Proton the most launched games use, unless one was picked already. + if (saveTool == null || tools.none { it.name == saveTool }) saveTool = + tools.maxByOrNull { t -> games.count { it.tool == t.name && it.launched } }?.name + } + }, "game-saves").start() + } + + private fun saveAction(label: String, work: () -> String) { + if (saveBusy != null) return + saveBusy = label + Thread({ + val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" } + ui.post { + saveBusy = null + android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show() + refreshSaves() + } + }, "game-saves-action").start() + } + + @Composable + private fun SavesHost() { + GameSavesPage( + tools = saveTools, + games = saveGames, + toolName = saveTool, + layout = saveLayout, + busy = saveBusy, + sessionRunning = SessionState.running, + onTool = { saveTool = it }, + onLayout = { saveLayout = it }, + onImport = { g -> + pendingSaveGame = g + pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for ${g.game.name}", + GameSaves.savesDir().parentFile?.parentFile?.path)) + }, + onExport = { g -> + pendingSaveGame = g + GameSaves.savesDir().mkdirs() + pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save ${g.game.name}", GameSaves.savesDir().path)) + }, + onBack = { showSaves = false }, + ) + } + private fun openProtons() { settingsMode = null showPerformance = false - showComponents = false + showComponents = false; showSaves = false showMapping = false showProtons = true protons.refreshProtons() @@ -689,6 +793,7 @@ class MainActivity : ComponentActivity() { showPerformance = false showProtons = false showMapping = false + showSaves = false showComponents = true components.refreshComponents(snapshotFirst = true) } @@ -944,7 +1049,7 @@ class MainActivity : ComponentActivity() { private fun openModeSettings(mode: String) { showPerformance = false showProtons = false - showComponents = false + showComponents = false; showSaves = false showMapping = false drivers.refreshDrivers() resolutionCap = SessionPrefs.resolutionCap(this, mode) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt new file mode 100644 index 000000000..5c33d4275 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameSaves.kt @@ -0,0 +1,335 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Environment +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** + * Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's + * drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at + * "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but + * Public becomes steamuser, and on the way out the chosen layout decides the user segment. + * + * Saves belong to the game's prefix (compatdata//pfx), not to a Proton: the Proton a game is + * set to (the client's CompatToolMapping) only decides where the page lists it. Ported from + * Bannerlator's GameSaveBackup and SaveLocator. + */ +object GameSaves { + enum class Layout(val label: String, val rootPrefix: String, val user: String) { + GAMEHUB("GameHub zip", "/drive_c/", "steamuser"), + WINLATOR("Winlator zip", "drive_c/", "xuser"), + } + + /** A compatibility tool the client can run a game with, by the name it records per game. */ + data class Tool(val name: String, val label: String) + + /** A game as the page lists it: its Proton, and where its saves are. */ + data class Game(val game: Library.SteamGame, val tool: String?, val prefix: File?, val saves: List) { + val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true + } + + /** A folder of saves, relative to the steamuser profile. */ + data class SaveDir(val relPath: String, val files: Int, val bytes: Long) + + /** DroidDeck's launcher around Valve's ARM64 Proton Experimental (bannerlator-steam-compat). */ + private const val WRAPPER_TOOL = "bannerlator-proton-arm64" + private const val USER = "steamuser" + + private fun steam(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + + fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves") + private fun backupsDir(): File = File(savesDir(), "backups") + + // ------------------------------------------------------------------ Protons and games + + /** The compatibility tools installed for the client, each by its registered name and shown name. */ + fun tools(context: Context): List { + val out = LinkedHashMap() + File(steam(context), "compatibilitytools.d").listFiles()?.sortedBy { it.name }?.forEach { dir -> + val vdf = File(dir, "compatibilitytool.vdf").takeIf { it.isFile } ?: return@forEach + val tools = runCatching { Vdf.parse(vdf.readText()) }.getOrNull() + ?.child("compatibilitytools")?.child("compat_tools") ?: return@forEach + for ((name, v) in tools.children) { + val shown = (v as? Vdf.Node)?.string("display_name") ?: name + out[name] = Tool(name, if (name == WRAPPER_TOOL) "Proton Experimental (ARM64)" else shown) + } + } + return out.values.toList() + } + + /** The client's per-game compatibility tool, by app id; "0" is its default for every other game. */ + fun mapping(context: Context): Map { + val config = File(steam(context), "config/config.vdf").takeIf { it.isFile } ?: return emptyMap() + val root = runCatching { Vdf.parse(config.readText()) }.getOrNull() ?: return emptyMap() + val map = root.find("CompatToolMapping") ?: return emptyMap() + val out = HashMap() + for ((id, v) in map.children) (v as? Vdf.Node)?.string("name")?.takeIf { it.isNotEmpty() }?.let { out[id] = it } + return out + } + + /** Every game with the tool it runs with (null = none set) and the saves found in its prefix. */ + fun games(context: Context, all: List): List { + val mapping = mapping(context) + val fallback = mapping["0"] + return all.map { g -> + val unsigned = (g.appId.toLong() and 0xffffffffL).toString() + val tool = mapping[unsigned] ?: mapping[g.appId.toString()] ?: mapping[g.gameId.toString()] ?: fallback + val prefix = g.protonPrefix + Game(g, tool, prefix, prefix?.let { locate(it, g) } ?: emptyList()) + } + } + + // ------------------------------------------------------------------ finding saves + + /** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */ + private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow") + private const val KEEP_THRESHOLD = 50 + + private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER") + + /** + * Folders under the save roots, one and two deep, whose names match the game's name or its folder's. + * Nested matches keep the outer folder only. + */ + fun locate(prefix: File, game: Library.SteamGame): List { + val profile = profile(prefix) + if (!profile.isDirectory) return emptyList() + val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() } + if (ids.isEmpty()) return emptyList() + val hits = LinkedHashMap() + fun consider(dir: File) { + val d = ident(dir.name) + if (d.norm.isEmpty()) return + val s = ids.maxOf { score(it, d) } + if (s < KEEP_THRESHOLD) return + val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/') + if ((hits[rel] ?: -1) < s) hits[rel] = s + } + for (root in SAVE_ROOTS) { + File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 -> + consider(d1) + d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider) + } + } + val kept = ArrayList() + for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) { + if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel) + } + return kept.map { rel -> + var files = 0 + var bytes = 0L + File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() } + SaveDir(rel, files, bytes) + }.filter { it.files > 0 } + } + + private data class Ident(val norm: String, val tokens: Set) + + private fun ident(raw: String): Ident { + val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "") + return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet()) + } + + /** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */ + private fun score(a: Ident, b: Ident): Int { + if (a.norm.isEmpty() || b.norm.isEmpty()) return 0 + if (a.norm == b.norm) return 100 + if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70 + if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() && + a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50 + return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0 + } + + private fun levenshtein(a: String, b: String): Int { + val prev = IntArray(b.length + 1) { it } + val cur = IntArray(b.length + 1) + for (i in 1..a.length) { + cur[0] = i + for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1) + System.arraycopy(cur, 0, prev, 0, cur.size) + } + return prev[b.length] + } + + // ------------------------------------------------------------------ export + + private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date()) + private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" } + + /** + * Zips the game's saves into [dir] as "_.zip" in [layout]. The found save folders when + * there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip. + */ + fun export(game: Game, layout: Layout, dir: File): Pair { + val prefix = game.prefix ?: error("${game.game.name} has not been launched yet") + dir.mkdirs() + val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip") + val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out) + if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") } + return out to count + } + + private fun zipProfile(profile: File, roots: List?, layout: Layout, out: File): Int { + if (!profile.isDirectory) return 0 + val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile) + var count = 0 + ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos -> + for (top in scope) top.walkTopDown() + .onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } } + .filter { it.isFile } + .forEach { f -> + val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/') + zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel")) + f.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + count++ + } + } + return count + } + + // ------------------------------------------------------------------ import + + /** + * Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser + * profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher + * shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null). + */ + fun import(game: Game, zip: File): Pair { + val prefix = game.prefix ?: error("${game.game.name} has not been launched yet") + val driveC = File(prefix, "drive_c") + if (!driveC.isDirectory) error("${game.game.name} has not been launched yet") + if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip") + + backupsDir().mkdirs() + val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip") + val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup) + if (backedUp == 0) backup.delete() + + val base = driveC.canonicalFile + var written = 0 + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { + remap(e.name)?.let { rel -> + val out = File(driveC, rel).canonicalFile + if (out.path.startsWith(base.path + File.separator)) { + if (e.isDirectory) out.mkdirs() + else { + out.parentFile?.mkdirs() + Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING) + written++ + } + } + } + zis.closeEntry() + e = zis.nextEntry + } + } + return written to backup.takeIf { it.exists() } + } + + /** Which layout a zip was written in, from its first user folder; null when it has none. */ + fun layoutOf(zip: File): Layout? = runCatching { + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { + val segs = driveCSegments(e.name) + if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) + return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB + e = zis.nextEntry + } + null + } + }.getOrNull() + + private fun looksLikeSaveZip(zip: File): Boolean = runCatching { + ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis -> + var e = zis.nextEntry + while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry } + false + } + }.getOrDefault(false) + + /** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */ + private fun remap(raw: String): String? { + val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null + if (segs.any { it == ".." }) return null + // GameHub also packs its own launcher shortcuts; they are never saves. + if (segs[0].equals("proton_shortcuts", true)) return null + val last = segs.last().lowercase() + if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null + if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER + return segs.joinToString("/") + } + + private fun driveCSegments(raw: String): MutableList? { + val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." } + if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null + return segs.drop(1).toMutableList() + } + + // ------------------------------------------------------------------ Valve KeyValues + + /** The client's text KeyValues (VDF): quoted keys, quoted values or { nested blocks }. */ + internal object Vdf { + class Node(val children: LinkedHashMap = LinkedHashMap()) { + fun child(key: String): Node? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? Node + fun string(key: String): String? = children.entries.firstOrNull { it.key.equals(key, true) }?.value as? String + /** The first block named [key] anywhere below. */ + fun find(key: String): Node? { + child(key)?.let { return it } + for (v in children.values) (v as? Node)?.find(key)?.let { return it } + return null + } + } + + fun parse(text: String): Node { + var i = 0 + fun token(): String? { + while (i < text.length) { + val c = text[i] + when { + c.isWhitespace() -> i++ + c == '/' && i + 1 < text.length && text[i + 1] == '/' -> { while (i < text.length && text[i] != '\n') i++ } + c == '{' || c == '}' -> { i++; return c.toString() } + c == '"' -> { + val sb = StringBuilder(); i++ + while (i < text.length && text[i] != '"') { + if (text[i] == '\\' && i + 1 < text.length) { sb.append(text[i + 1]); i += 2 } else sb.append(text[i++]) + } + i++ + return "\"" + sb + } + else -> { val s = i; while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}') i++; return "\"" + text.substring(s, i) } + } + } + return null + } + fun block(into: Node) { + while (true) { + val k = token() ?: return + if (k == "}") return + if (k == "{") continue + val key = k.drop(1) + val v = token() ?: return + if (v == "{") { val n = Node(); block(n); into.children[key] = n } else into.children[key] = v.drop(1) + } + } + return Node().also { block(it) } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 7822ef44e..eaca8c603 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -318,7 +318,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal * fill with an outline in the text colour - unmistakable in a column of identical buttons. */ @Composable -private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { +internal fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = false, modifier: Modifier = Modifier, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index d245f0346..88d1f3b62 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -133,6 +133,8 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, + /** The Game saves page: import and export save zips per game (the floppy beside the Steam cog). */ + val onGameSaves: () -> Unit = {}, val onPerformance: () -> Unit, val onRoms: () -> Unit, val onFiles: () -> Unit, @@ -358,7 +360,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railSelection = when { s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true -> "steam" + s.pageKey?.startsWith("settings:steam") == true || s.pageKey == "game-saves" -> "steam" s.pageKey?.startsWith("settings:") == true -> "desktop" selected.startsWith("app:") -> "games" selected.startsWith("emu:") || selected.startsWith("rom:") -> "desktop" diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 7d034a138..ac4491c80 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.ui.graphics.vector.ImageVector @@ -235,3 +236,18 @@ internal fun Cog(size: androidx.compose.ui.unit.Dp = 48.dp, onClick: () -> Unit) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), ) { Icon(Icons.Filled.Settings, "Settings", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(if (size > 48.dp) 20.dp else 18.dp).rotate(rot)) } } + +/** Game saves: a floppy disk beside the cog, 70% of its size, drawn the same way. */ +@Composable +internal fun SaveDisk(size: androidx.compose.ui.unit.Dp = 38.dp, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pal = LocalPalette.current + val edge by animateColorAsState(if (hot) pal.signal else pal.line2, Motion.tw(250), label = "saveEdge") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.paneItem("saves").downToFirstTile().size(size).clip(Shape12).background(Color.White.copy(alpha = 0.03f)).border(1.dp, edge, Shape12) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick), + ) { Icon(Icons.Outlined.Save, "Game saves", tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(16.dp)) } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt new file mode 100644 index 000000000..2cbac137f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameSavesPage.kt @@ -0,0 +1,198 @@ +package com.droiddeck.launcher.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.session.GameSaves + +/** + * Game saves, opened from the floppy disk beside the Steam page's cog. A page like Components: + * the Proton at the top, the save zip type beside it (LB / RB turn it), and the games set to that + * Proton, each with Import (pick a zip in the app's file picker) and Export (pick a folder there). + * A zip of either type imports; the type only decides how an export is written. + */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +fun GameSavesPage( + tools: List?, + games: List, + toolName: String?, + layout: GameSaves.Layout, + busy: String?, + sessionRunning: Boolean, + onTool: (String) -> Unit, + onLayout: (GameSaves.Layout) -> Unit, + onImport: (GameSaves.Game) -> Unit, + onExport: (GameSaves.Game) -> Unit, + onBack: () -> Unit, + requestInitialFocus: Boolean = true, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + BackHandler(onBack = onBack) + var toolMenu by remember { mutableStateOf(false) } + + val list = tools ?: emptyList() + val tool = list.firstOrNull { it.name == toolName } ?: list.firstOrNull() + val shown = games.filter { it.tool == tool?.name } + .sortedWith(compareByDescending { it.launched }.thenBy { it.game.name.lowercase() }) + val layouts = GameSaves.Layout.entries + + Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text( + "Game saves", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + maxLines = 1, modifier = Modifier.weight(1f), + ) + if (tools != null) Text( + if (shown.size == 1) "1 game on this Proton" else "${shown.size} games on this Proton", + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, + ) + } + + // ---- which Proton, which zip type ----------------------------------------------------------- + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { + if (tool != null) Box { + ValueChip(tool.label, toolMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { toolMenu = !toolMenu } + AnchoredMenu(toolMenu, onDismiss = { toolMenu = false }, title = "Proton", + note = "Saves belong to the game, not the Proton: switch a game to another Proton and its saves go with it.") { first -> + list.forEachIndexed { i, t -> + val n = games.count { it.tool == t.name } + MenuItem( + t.label, checked = t.name == tool.name, + detail = t.name + " · " + if (n == 1) "1 game" else if (n == 0) "no games yet" else "$n games", + focusRequester = if (i == 0) first else null, + ) { onTool(t.name); toolMenu = false } + } + } + } + TabStrip(layouts.map { it.label }, layouts.indexOf(layout), { onLayout(layouts[it]) }) + } + + // ---- what the type means, or what is happening ----------------------------------------------- + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) + .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), + ) { + if (busy != null) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(14.dp)) + else Box(Modifier.size(8.dp).background(if (sessionRunning) Color(0xFFFFB86B) else pal.good, CircleShape)) + Text( + when { + busy != null -> "$busy…" + sessionRunning -> "Steam is running: close the session before importing, or the game may save over it." + layout == GameSaves.Layout.GAMEHUB -> "Exports keep saves under steamuser, as GameHub does. Imports take either type and back up the saves already there first." + else -> "Exports keep saves under xuser, as Winlator does. Imports take either type and back up the saves already there first." + }, + fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), + ) + } + + // ---- the games ------------------------------------------------------------------------------ + when { + tools == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(12.dp)) + Text("Looking for games and their saves…", fontSize = 14.sp, color = colors.onSurfaceVariant) + } + tool == null -> Text( + "No Proton is installed in the Linux runtime yet. Start the Steam client once so it sets one up.", + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), + ) + else -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { + val first = remember { FocusRequester() } + LaunchedEffect(tool.name, requestInitialFocus) { if (requestInitialFocus) runCatching { first.requestFocus() } } + SettingsGroup("Games on this Proton · ${shown.size}") { + if (shown.isEmpty()) Text( + "No game is set to ${tool.label}. Pick it for a game in Steam (Properties › Compatibility) and launch the game once.", + fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + ) + var firstSet = false + shown.forEach { g -> + val canImport = g.launched && busy == null && !sessionRunning + val canExport = g.launched && busy == null + val mod = if (!firstSet && canExport) { firstSet = true; Modifier.focusRequester(first) } else Modifier + GameSaveLine(g, canImport, canExport, mod, { onImport(g) }, { onExport(g) }) + } + } + } + } + } +} + +@Composable +private fun GameSaveLine( + g: GameSaves.Game, canImport: Boolean, canExport: Boolean, firstButton: Modifier, + onImport: () -> Unit, onExport: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val narrow = LocalNarrowPane.current + val detail = when { + !g.launched -> "Launch it once first: its save folder is made on the first launch" + g.saves.isEmpty() -> (if (g.game.library == "added") "Added game" else "Steam game") + " · no save folder found, Export takes the whole user folder" + else -> { + val files = g.saves.sumOf { it.files } + val mb = g.saves.sumOf { it.bytes } / 1048576.0 + (if (g.game.library == "added") "Added game" else "Steam game") + " · " + + (if (g.saves.size == 1) g.saves[0].relPath else "${g.saves.size} save folders") + + " · $files files · " + (if (mb < 1) "${(mb * 1024).toInt()} KB" else String.format("%.1f MB", mb)) + } + } + val text: @Composable (Modifier) -> Unit = { m -> + Column(m) { + Text(g.game.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = 12.sp, color = if (g.launched) colors.onSurfaceVariant else Color(0xFFFFB86B), maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + val buttons: @Composable () -> Unit = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SmallButton("Import", enabled = canImport, modifier = if (canImport) firstButton else Modifier, onClick = onImport) + SmallButton("Export", enabled = canExport, modifier = if (!canImport) firstButton else Modifier, onClick = onExport) + } + } + if (narrow) Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + text(Modifier.fillMaxWidth()); buttons() + } else Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + ) { text(Modifier.weight(1f)); buttons() } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 7be01984c..0cb88356e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -105,6 +105,7 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) // Enabled without a runtime: the session's loading screen installs it first. PrimaryButton("Play Steam", enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, onClick = a.onPlay) Cog(size = 54.dp, onClick = a.onSteamSettings) + SaveDisk(size = 38.dp, onClick = a.onGameSaves) } } }