diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index 118051424..7004f2d10 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -80,7 +80,11 @@ object ComponentsManager { data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long) data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long) data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String) - data class ProtonView(val proton: Proton, val components: Map, val originals: List, val inUseByGame: Boolean) + data class ProtonView( + val proton: Proton, val components: Map, val originals: List, val inUseByGame: Boolean, + /** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */ + val reappliedAt: Long = 0, + ) data class Snapshot(val protons: List, val packages: List) data class Catalog(val items: List, val fetchedAt: Long) @@ -96,6 +100,12 @@ object ComponentsManager { private fun originalsDir(context: Context) = File(dataDir(context), "originals").apply { mkdirs() } private fun stateFile(context: Context) = File(dataDir(context), "state.json") private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json") + /** + * Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's + * bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use. + */ + private const val LAUNCH_DIR = "root/.local/share/bannerlator-components" + private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR) fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" } @@ -112,6 +122,8 @@ object ComponentsManager { val tmp = File(f.parentFile, f.name + ".tmp") tmp.writeText(state.toString(2)) tmp.renameTo(f) + // Every change of choice reaches the launch wrappers at once. + runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) } } private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) } @@ -364,6 +376,7 @@ object ComponentsManager { for (p in protons(context)) for (comp in COMPONENTS) { runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) } } + runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) } } // ------------------------------------------------------------------ view @@ -371,6 +384,13 @@ object ComponentsManager { fun snapshot(context: Context): Snapshot = synchronized(lock) { migratePlugin(context) val state = loadState(context) + val reapplied = HashMap() + runCatching { + File(launchDir(context), "reapplied.log").forEachLine { line -> + val parts = line.split('\t') + if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) } + } + } val views = protons(context).map { p -> val comps = COMPONENTS.associateWith { comp -> val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version")) @@ -393,11 +413,73 @@ object ComponentsManager { q?.optString("label"), ) } - ProtonView(p, comps, originals(context, p.id), inUse(p)) + ProtonView(p, comps, originals(context, p.id), inUse(p), reapplied[p.dir.name] ?: 0) } Snapshot(views, packages(context)) } + // ------------------------------------------------------------------ launch-time enforcement + + /** + * Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per + * file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of + * every package or older-build original in use to copy from. Rebuilt whole after every change, so + * a restored or deleted choice simply stops being enforced. The Proton's own current-build + * originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is + * what overwrote files in the first place. + */ + private fun syncLaunchState(context: Context, state: JSONObject) { + val dir = launchDir(context) + val store = File(dir, "store").apply { mkdirs() } + val all = protons(context) + val lines = StringBuilder() + val keep = HashSet() + val active = state.sub("active") + for (pid in active.keys()) { + val p = all.firstOrNull { it.id == pid } ?: continue + val comps = active.optJSONObject(pid) ?: continue + for (comp in comps.keys()) { + val a = comps.optJSONObject(comp) ?: continue + if (a.optString("protonVersion") != p.version) continue + val file = a.optString("file") + val (key, wcp) = if (file.startsWith("original ")) { + val v = file.removePrefix("original ") + "original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp") + } else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file) + if (!wcp.isFile) continue + val unpacked = File(store, key) + if (!File(unpacked, ".complete").isFile) { + unpacked.deleteRecursively() + readWcp(wcp) { tar -> + while (true) { + val e = tar.nextTarEntry ?: break + val rel = normalize(e.name) + if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue + val out = File(unpacked, rel) + out.parentFile?.mkdirs() + FileOutputStream(out).use { tar.copyTo(it) } + } + } + File(unpacked, ".complete").writeText("1\n") + } + keep += key + val files = a.optJSONObject("files") ?: continue + for (rel in files.keys()) { + val src = File(unpacked, rel) + if (!src.isFile) continue + val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/') + lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t') + .append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n') + } + } + } + store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() } + val list = File(dir, "desired.tsv") + val tmp = File(dir, "desired.tsv.tmp") + tmp.writeText(lines.toString()) + tmp.renameTo(list) + } + // ------------------------------------------------------------------ actions /** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */ 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 6640c7c96..820c9f9eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -147,8 +147,9 @@ fun ComponentsPage( Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape)) Spacer(Modifier.width(6.dp)) + val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else "" Text( - if (st.queued != null) "Next: ${st.queued} · after the game" else "In use: ${st.inUse}", + (if (st.queued != null) "Next: ${st.queued} · after the game" else "In use: ${st.inUse}") + fixedAt, fontSize = 12.5.sp, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) if (st.queued != null) { Spacer(Modifier.width(8.dp)); FocusText("Cancel", pal.signal, onClick = onCancelQueued) } @@ -244,6 +245,7 @@ fun ComponentsPage( Text( "FEX, DXVK and VKD3D-Proton for each Proton the Steam client runs games with.\n\n" + "Tap an installed row to swap it in; it applies the next time a game starts. If a game is running on that Proton, the swap waits until it closes.\n\n" + + "Your choices are also checked right before every game launch: if anything changed a Proton's files (Steam's start-up tests, for one), they are put back first, and the page says \"re-applied at launch\".\n\n" + "Each Proton build's own files are kept as its ORIGINAL, so a Steam update never loses them. Packages come from the Nightlies \"-Linux\" releases (refresh) or Import .wcp.", fontSize = 13.sp, ) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 8f918fc88..7a4169e70 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -733,7 +733,8 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin val p = view.proton SettingsGroup("Components") { ChoiceRow( - host, "cmp-proton", "Proton", p.version + if (view.inUseByGame) " · in use by the running game" else "", + host, "cmp-proton", "Proton", p.version + (if (view.inUseByGame) " · in use by the running game" else "") + + (if (view.reappliedAt > 0) " · re-applied at launch " + java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date(view.reappliedAt * 1000)) else ""), snap.protons.map { it.proton.id to it.proton.name }, p.id, chipModifier = track("cmp-proton"), onPick = { pick = it }, ) diff --git a/tools/linuxfs/overlay/usr/local/bin/__pycache__/bannerlator-steam-compatcpython-313.pyc b/tools/linuxfs/overlay/usr/local/bin/__pycache__/bannerlator-steam-compatcpython-313.pyc new file mode 100644 index 000000000..da69fcc74 Binary files /dev/null and b/tools/linuxfs/overlay/usr/local/bin/__pycache__/bannerlator-steam-compatcpython-313.pyc differ diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 4156846a5..82644dd02 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -70,6 +70,62 @@ TOOL_MANIFEST_VDF = """"manifest" } """ % LAUNCHER + +# Steam runs its own graphics probe through EVERY registered tool at once, ~40 s after logging in, +# all with STEAM_COMPAT_DATA_PATH=.../compatdata/0. A prefix's system32 holds links into the Proton +# that last set it up, and the Wines of the other Protons, still running outside Proton's pfx.lock, +# wrote their own builtin DLLs through those links - into that Proton's tree (seen on device: GE's +# FEX DLLs replaced by Proton Experimental's at session start). So each tool's tool-0 run gets a +# prefix of its own; games, which each have their own compatdata/, are not touched. +BL_PROBE_PREFIX_SETUP = """ +bl_probe_prefix() { + case "${STEAM_COMPAT_DATA_PATH:-}" in + */compatdata/0|*/compatdata/0/) + STEAM_COMPAT_DATA_PATH="${STEAM_COMPAT_DATA_PATH%/}-$1" + mkdir -p "$STEAM_COMPAT_DATA_PATH" + export STEAM_COMPAT_DATA_PATH + echo "bannerlator-proton: Steam's tool-0 run gets its own prefix: $STEAM_COMPAT_DATA_PATH" >&2 ;; + esac +} +""" + +# The Components page's choices, enforced in the one place every game launch passes: the app keeps +# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of +# each package in use. A file that no longer matches - whatever changed it - is put back before +# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build +# differs and the Proton's own new files are left alone until the user chooses again. +BL_COMPONENTS_SETUP = """ +bl_components() { + base=$1 + cm_dir="${HOME:-/root}/.local/share/bannerlator-components" + cm_list="$cm_dir/desired.tsv" + [ -f "$cm_list" ] || return 0 + case "${STEAM_COMPAT_DATA_PATH:-}" in */compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac + cm_tool=${base##*/} + cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null) + cm_fixed=0 + cm_checked=0 + while IFS=$'\\t' read -r t want comp rel sum src; do + [ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue + cm_checked=$((cm_checked + 1)) + f="$base/$rel" + if [ -f "$f" ] && [ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ]; then continue; fi + if [ ! -f "$src" ]; then + echo "bannerlator-proton: components: $comp source missing ($src), left as it is" >&2 + continue + fi + mkdir -p "$(dirname "$f")" + cp -f "$src" "$f.bl-new" && mv -f "$f.bl-new" "$f" || continue + case "$rel" in *.so) chmod 755 "$f" ;; *) chmod 644 "$f" ;; esac + cm_fixed=$((cm_fixed + 1)) + done < "$cm_list" + if [ "$cm_fixed" -gt 0 ]; then + echo "bannerlator-proton: components: re-applied $cm_fixed of $cm_checked file(s) in $cm_tool before this launch" >&2 + printf '%s\\t%s\\t%s\\n' "$(date +%s)" "$cm_tool" "$cm_fixed" >> "$cm_dir/reapplied.log" + fi +} +""" + # Resolves the depot at launch time, so the tool can be registered before the depot exists and # survives the client updating or replacing it. Proton takes its base directory from # dirname(sys.argv[0]) without resolving it, so it is run from the depot itself. @@ -172,9 +228,11 @@ if [ -f "$fake" ]; then *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; esac fi +bl_probe_prefix "bannerlator-proton-arm64" for name in %s; do depot="$steam_root/steamapps/common/$name" if [ -d "$depot/files/bin-arm64" ] && [ -x "$depot/proton" ]; then + bl_components "$depot" bl_directaudio "$depot" bl_game_cpus bl_idtech3 "$@" @@ -188,7 +246,8 @@ exit 1 # and a % format would eat that into a single %, leaving a shell expansion that silently does # something else. LAUNCHER_SH = LAUNCHER_SH.replace( - "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP, 1) + "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP + + BL_PROBE_PREFIX_SETUP + BL_COMPONENTS_SETUP, 1) # A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of # that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot @@ -220,6 +279,8 @@ if [ -f "$fake" ]; then *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; esac fi +bl_probe_prefix "${here##*/}" +bl_components "$here" bl_directaudio "$here" bl_game_cpus bl_idtech3 "$@" @@ -384,7 +445,8 @@ def adopt_extras(tools): # ${major%%.*} of its own, and putting it through the format would eat both. body = (EXTRA_WRAPPER_SH % entry_point).replace( 'here=$(dirname "$0")', - 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP, 1) + 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP + + BL_PROBE_PREFIX_SETUP + BL_COMPONENTS_SETUP, 1) with open(wrapper, "w", encoding="utf-8") as f: f.write(body) os.chmod(wrapper, 0o755)