diff --git a/README.md b/README.md index 5e6cc4c39..e9b495d1a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big ## Requirements and install -Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. +Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. Before Steam launches, you must turn off **Restrict child processes** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process. 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 1f51f666d..c6346c41f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -5,6 +5,7 @@ import android.content.Context import android.util.Log import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.gpu.GpuInfo import com.droiddeck.launcher.runtime.LinuxRuntime import com.github.luben.zstd.ZstdInputStream import com.github.luben.zstd.ZstdOutputStream @@ -660,6 +661,107 @@ object ComponentsManager { return Catalog(items, o.optLong("fetchedAt")) } + /** + * A package Proton does not ship, required on the GPUs listed here. An empty [models] set + * means every model in [families]. A row the user already chose in Components is left alone; + * a Steam update that puts the Proton's own files back is replaced on the next session. + */ + internal data class ForcedPackage( + val families: Set, + val comp: String, + val file: String, + val release: String, + val models: Set = emptySet(), + ) { + fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models) + } + + internal val FORCED_PACKAGES = listOf( + // DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on + // Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag. + ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"), + ) + + /** + * True when this Proton should receive the forced package. No active swap means the Proton's + * own copy. A record for a different Proton build means a Steam update put that copy back, + * whichever package was recorded. A record for this build is a Components choice and stays. + */ + internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean = + activeFile.isNullOrEmpty() || !protonVersionMatches + + /** The catalog row for this file and release, or null. */ + internal fun catalogMatch(items: List, file: String, release: String): CatalogItem? = + items.firstOrNull { it.file == file && it.release == release } + + /** + * Install every [FORCED_PACKAGES] row this GPU matches into each Proton that + * [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these + * Protons need nothing. A failed fetch throws and the session logs it without stopping. + */ + fun ensureForcedPackages(context: Context): String? = ensureForcedPackages( + context, GpuInfo.detect(), + { file, release -> catalogItem(context, file, release) }, + { download(context, it) {} }, + { id, file -> swap(context, id, file) }, + ) + + internal fun ensureForcedPackages( + context: Context, + gpu: GpuInfo, + lookup: (file: String, release: String) -> CatalogItem?, + fetch: (CatalogItem) -> Unit, + apply: (protonId: String, file: String) -> String, + ): String? { + val rules = FORCED_PACKAGES.filter { it.matches(gpu) } + if (rules.isEmpty()) return null + val installed = protons(context) + val lines = mutableListOf() + for (rule in rules) { + val state = loadState(context) + val targets = installed.filter { p -> + val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp) + forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version) + } + if (targets.isEmpty()) continue + val missing = missingPackage(context, rule, lookup, fetch) + if (missing != null) { + lines.add(missing) + continue + } + lines.add(targets.joinToString("; ") { apply(it.id, rule.file) }) + } + return lines.joinToString("; ").ifEmpty { null } + } + + /** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */ + private fun missingPackage( + context: Context, + rule: ForcedPackage, + lookup: (file: String, release: String) -> CatalogItem?, + fetch: (CatalogItem) -> Unit, + ): String? { + val wcp = File(packagesDir(context), safeName(rule.file)) + if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null + wcp.delete() + val item = lookup(rule.file, rule.release) + ?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy" + fetch(item) + return null + } + + /** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */ + internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? = + resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items }) + + /** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */ + internal fun resolveCatalogItem( + file: String, + release: String, + cached: () -> List, + refresh: () -> List, + ): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release) + /** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */ fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package { require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 8c74bcf56..4c5014375 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -309,6 +309,14 @@ class SessionService : Service() { // The desktop's Steam launchers start the client there (droiddeck-steam-launch), through the // same set-up as a Steam session: it gets what the client and its games are started with. val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP + // Some GPUs need a component Proton does not ship. Apply those before the guest, so the + // next game launch copies them into the prefix. A download failure is logged and the + // session still starts; the next session tries again. + if (steamHere) { + runCatching { ComponentsManager.ensureForcedPackages(this) } + .onSuccess { if (it != null) Log.i(TAG, it) } + .onFailure { Log.w(TAG, "forced components", it) } + } addClientEnvironment(guest, steamHere) // Where the fast path's description of proot's view goes, once the binds are known. val fastPathAt = guest.size diff --git a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt new file mode 100644 index 000000000..1ce0fc144 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt @@ -0,0 +1,238 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import com.droiddeck.launcher.gpu.GpuInfo +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ForcedPackageTest { + private val dxvk2 = "dxvk-2.7.1-linux.wcp" + private lateinit var context: Context + + @Before fun setUp() { + context = RuntimeEnvironment.getApplication() + File(context.filesDir, "components").deleteRecursively() + LinuxRuntime.rootDir(context).deleteRecursively() + } + + @Test fun adreno6xxForcesDxvk2AndNothingElseDoes() { + val rule = ComponentsManager.FORCED_PACKAGES.single { GpuInfo.Family.A6XX in it.families } + assertEquals("dxvk", rule.comp) + assertEquals(dxvk2, rule.file) + assertEquals("Dxvk-Linux", rule.release) + assertTrue(rule.models.isEmpty()) + assertTrue(rule.matches(gpu(650, GpuInfo.Family.A6XX))) + assertTrue(rule.matches(gpu(610, GpuInfo.Family.A6XX))) + assertTrue(rule.matches(gpu(690, GpuInfo.Family.A6XX))) + assertFalse(rule.matches(gpu(740, GpuInfo.Family.A7XX))) + assertFalse(rule.matches(gpu(830, GpuInfo.Family.A8XX))) + assertTrue(ComponentsManager.FORCED_PACKAGES.none { GpuInfo.Family.A7XX in it.families }) + } + + @Test fun aModelListOnlyMatchesThoseChips() { + val rule = ComponentsManager.ForcedPackage( + setOf(GpuInfo.Family.A7XX_LOW), "dxvk", dxvk2, "Dxvk-Linux", setOf(710), + ) + assertTrue(rule.matches(gpu(710, GpuInfo.Family.A7XX_LOW))) + assertFalse(rule.matches(gpu(720, GpuInfo.Family.A7XX_LOW))) + assertFalse(rule.matches(gpu(710, GpuInfo.Family.A6XX))) + assertFalse(ComponentsManager.ForcedPackage(emptySet(), "dxvk", dxvk2, "Dxvk-Linux").matches(gpu(650, GpuInfo.Family.A6XX))) + } + + @Test fun aShippedComponentIsReplacedUntilTheUserChoosesOne() { + assertTrue(ComponentsManager.forcedPackageWanted(null, false)) + assertTrue(ComponentsManager.forcedPackageWanted(null, true)) + assertTrue(ComponentsManager.forcedPackageWanted("", false)) + assertFalse(ComponentsManager.forcedPackageWanted(dxvk2, true)) + assertTrue(ComponentsManager.forcedPackageWanted(dxvk2, false)) + assertFalse(ComponentsManager.forcedPackageWanted("dxvk-3.1-linux.wcp", true)) + assertTrue(ComponentsManager.forcedPackageWanted("dxvk-3.1-linux.wcp", false)) + assertTrue(ComponentsManager.forcedPackageWanted("dxvk-gplasync-2.7.1-1-linux.wcp", false)) + } + + @Test fun catalogMatchPicksTheReleaseAndIgnoresAnother() { + val wanted = item(dxvk2, "Dxvk-Linux") + val otherRelease = item(dxvk2, "Dxvk-gplasync-Linux") + val otherFile = item("dxvk-3.1-linux.wcp", "Dxvk-Linux") + assertEquals(wanted, ComponentsManager.catalogMatch(listOf(otherFile, otherRelease, wanted), dxvk2, "Dxvk-Linux")) + assertNull(ComponentsManager.catalogMatch(listOf(otherFile, otherRelease), dxvk2, "Dxvk-Linux")) + assertNull(ComponentsManager.catalogMatch(emptyList(), dxvk2, "Dxvk-Linux")) + } + + @Test fun thisMachineIsNotForced() { + assertNull(ComponentsManager.ensureForcedPackages(context)) + } + + @Test fun anotherGpuIsLeftAlone() { + proton("proton-exp") + assertNull(install(gpu(740, GpuInfo.Family.A7XX))) + } + + @Test fun noInstalledProtonNeedsNothing() { + assertNull(install(gpu(650, GpuInfo.Family.A6XX))) + } + + @Test fun aChosenPackageIsNotReplaced() { + proton("proton-exp") + writeState("proton-exp", "dxvk-3.1-linux.wcp", "experimental-11") + assertNull(install(gpu(650, GpuInfo.Family.A6XX))) + } + + @Test fun theSamePackageOnThisProtonBuildIsNotFetchedAgain() { + proton("proton-exp") + writeState("proton-exp", dxvk2, "experimental-11") + assertNull(install(gpu(650, GpuInfo.Family.A6XX))) + } + + @Test fun aMissingListingLeavesTheShippedCopy() { + proton("proton-exp") + val result = install(gpu(650, GpuInfo.Family.A6XX), lookup = { _, _ -> null }) + assertEquals("$dxvk2 is not in the Nightlies listing; DXVK stays on the Proton's copy", result) + } + + @Test fun aShippedCopyAndAProtonUpdateAreBothSwapped() { + proton("kept") + proton("proton-exp", "experimental-12") + proton("other", "experimental-12") + writeState("kept", "dxvk-3.1-linux.wcp", "experimental-11") + writeState("proton-exp", dxvk2, "experimental-11") + writeState("other", "dxvk-3.1-linux.wcp", "experimental-11") + val fetched = mutableListOf() + val applied = mutableListOf() + val result = install( + gpu(650, GpuInfo.Family.A6XX), + lookup = { file, release -> item(file, release) }, + fetch = { fetched += it.file }, + apply = { id, file -> applied += "$id $file"; "DXVK 2.7.1 is now in $id. It applies the next time a game starts." }, + ) + assertEquals(listOf(dxvk2), fetched) + assertEquals(listOf("other $dxvk2", "proton-exp $dxvk2"), applied) + assertEquals( + "DXVK 2.7.1 is now in other. It applies the next time a game starts.; " + + "DXVK 2.7.1 is now in proton-exp. It applies the next time a game starts.", + result, + ) + } + + @Test fun everyProtonStillOnItsShippedCopyIsSwapped() { + proton("one") + proton("two") + val applied = mutableListOf() + val result = install( + gpu(650, GpuInfo.Family.A6XX), + lookup = { file, release -> item(file, release) }, + fetch = {}, + apply = { id, _ -> applied += id; "DXVK 2.7.1 is now in $id. It applies the next time a game starts." }, + ) + assertEquals(listOf("one", "two"), applied) + assertEquals( + "DXVK 2.7.1 is now in one. It applies the next time a game starts.; " + + "DXVK 2.7.1 is now in two. It applies the next time a game starts.", + result, + ) + } + + @Test fun aBrokenStoredPackageIsFetchedAgain() { + proton("proton-exp") + File(context.filesDir, "components/packages/$dxvk2").apply { parentFile!!.mkdirs(); writeText("not a package") } + val fetched = mutableListOf() + install( + gpu(650, GpuInfo.Family.A6XX), + lookup = { file, release -> item(file, release) }, + fetch = { fetched += it.file }, + apply = { _, _ -> "swapped" }, + ) + assertEquals(listOf(dxvk2), fetched) + assertFalse(File(context.filesDir, "components/packages/$dxvk2").isFile) + } + + @Test fun aFailedFetchIsLeftForTheSessionToLog() { + proton("proton-exp") + val applied = mutableListOf() + val error = assertThrows(IllegalStateException::class.java) { + install( + gpu(650, GpuInfo.Family.A6XX), + lookup = { file, release -> item(file, release) }, + fetch = { throw IllegalStateException("Download failed") }, + apply = { id, _ -> applied += id; "" }, + ) + } + assertEquals("Download failed", error.message) + assertTrue(applied.isEmpty()) + } + + @Test fun theCachedListingIsUsedBeforeRefreshing() { + writeCatalog() + assertEquals(cachedUrl, ComponentsManager.catalogItem(context, dxvk2, "Dxvk-Linux")?.url) + } + + @Test fun aCacheMissReadsTheRefreshedListingOnce() { + var refreshes = 0 + val refreshed = item(dxvk2, "Dxvk-Linux") + val found = ComponentsManager.resolveCatalogItem(dxvk2, "Dxvk-Linux", { emptyList() }, { refreshes += 1; listOf(refreshed) }) + assertEquals(refreshed, found) + assertEquals(1, refreshes) + assertNull(ComponentsManager.resolveCatalogItem("missing.wcp", "Dxvk-Linux", { emptyList() }, { refreshes += 1; emptyList() })) + assertEquals(2, refreshes) + val cached = item(dxvk2, "Dxvk-Linux") + assertEquals(cached, ComponentsManager.resolveCatalogItem(dxvk2, "Dxvk-Linux", { listOf(cached) }, { error("refresh") })) + } + + private fun gpu(model: Int, family: GpuInfo.Family) = GpuInfo("Adreno $model", model, family, "", false) + + private fun install( + gpu: GpuInfo, + lookup: (String, String) -> ComponentsManager.CatalogItem? = ::refuseLookup, + fetch: (ComponentsManager.CatalogItem) -> Unit = ::refuseFetch, + apply: (String, String) -> String = ::refuseApply, + ) = ComponentsManager.ensureForcedPackages(context, gpu, lookup, fetch, apply) + + private fun refuseLookup(file: String, release: String): ComponentsManager.CatalogItem? = error("catalog $file $release") + + private fun refuseFetch(item: ComponentsManager.CatalogItem) { error("fetch ${item.file}") } + + private fun refuseApply(protonId: String, file: String): String = error("apply $protonId $file") + + private fun proton(name: String, version: String = "experimental-11") { + val dir = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/common/$name") + File(dir, "files/lib/wine").mkdirs() + File(dir, "proton").writeText("#!/bin/sh\n") + File(dir, "version").writeText("1 $version\n") + } + + private fun writeState(protonId: String, file: String, protonVersion: String) { + val state = File(context.filesDir, "components/state.json") + state.parentFile!!.mkdirs() + val active = if (state.isFile) JSONObject(state.readText()).getJSONObject("active") else JSONObject() + active.put(protonId, JSONObject().put("dxvk", JSONObject().put("file", file).put("protonVersion", protonVersion))) + state.writeText(JSONObject().put("active", active).put("queued", JSONObject()).toString()) + } + + private val cachedUrl = "https://github.com/The412Banner/Nightlies/releases/download/Dxvk-Linux/$dxvk2?cached=1" + + private fun writeCatalog() { + val cache = File(context.filesDir, "components/catalog.json") + cache.parentFile!!.mkdirs() + cache.writeText(JSONObject() + .put("fetchedAt", 1) + .put("items", JSONArray().put(JSONObject() + .put("file", dxvk2).put("comp", "dxvk").put("release", "Dxvk-Linux") + .put("url", cachedUrl).put("size", 4).put("digest", "sha256:" + "ab".repeat(32)))) + .toString()) + } + + private fun item(file: String, release: String) = ComponentsManager.CatalogItem( + file, "dxvk", release, "https://github.com/The412Banner/Nightlies/releases/download/$release/$file", 4, "sha256:" + "ab".repeat(32), + ) +} diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index 888766aaf..223915adc 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -23,6 +23,11 @@ and explicit removals take precedence over these defaults. FEX's own default remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray tracing and diagnostic logging otherwise retain the runtime's defaults. +On an Adreno 6xx, Turnip does not expose `storageBuffer8BitAccess`, which DXVK 3 +requires. A Steam or desktop session installs DXVK 2.7.1 into any Proton whose +DXVK is still the copy that Proton shipped, including after a Proton update put +that copy back. A DXVK package chosen in Components stays for that Proton build. + The variable-name picker offers 24 predefined variables and a Custom entry. Like WinNative, known variables use toggles, value dropdowns, multi-select lists, or numeric/text fields. Feature-level and shader-model dropdowns also accept custom