Added support for Adreno 6xx devices (#320)

This commit is contained in:
UnbelievableFlavour authored and GitHub committed 2026-10-06 09:08:04 -04:00
1 parent b44235c495
commit a1b01a04a8
5 files changed
+354 -1

No files matched your search

+1 -1
View File
@@ -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.
@@ -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<GpuInfo.Family>,
val comp: String,
val file: String,
val release: String,
val models: Set<Int> = 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<CatalogItem>, 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<String>()
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<CatalogItem>,
refresh: () -> List<CatalogItem>,
): 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" }
@@ -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
@@ -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<String>()
val applied = mutableListOf<String>()
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<String>()
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<String>()
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<String>()
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),
)
}
+5
View File
@@ -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