Compare commits

...
17 changed files with 5085 additions and 32 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -483,20 +483,7 @@ object ComponentsManager {
} 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")
}
unpack(wcp, unpacked)
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
@@ -515,6 +502,34 @@ object ComponentsManager {
tmp.renameTo(list)
}
/**
* A package's files/ unpacked into [dir], with `.complete` written last; a directory that already
* has `.complete` is left as it is. The launch wrappers and droiddeck-recipe read only complete ones.
*/
internal fun unpack(wcp: File, dir: File) {
if (File(dir, ".complete").isFile) return
dir.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(dir, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
}
File(dir, "files").mkdirs()
File(dir, ".complete").writeText("1\n")
}
/** The stored package file for a catalog file name (present or not). */
internal fun packageFile(context: Context, file: String): File = File(packagesDir(context), safeName(file))
/** A stored package's details, or null when it is missing or unreadable. */
internal fun storedPackage(context: Context, file: String): Package? =
packageFile(context, file).takeIf { it.isFile }?.let { runCatching { packageInfo(context, it) }.getOrNull() }
// ------------------------------------------------------------------ actions
/** Applies a package now, or queues it when a game is running on that Proton. Returns what happened. */
@@ -737,7 +752,7 @@ object ComponentsManager {
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
}
if (targets.isEmpty()) continue
val missing = missingPackage(context, rule, lookup, fetch)
val missing = missingPackage(context, rule.comp, rule.file, rule.release, lookup, fetch)
if (missing != null) {
lines.add(missing)
continue
@@ -747,18 +762,20 @@ object ComponentsManager {
return lines.joinToString("; ").ifEmpty { null }
}
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
private fun missingPackage(
/** Null when [file]'s package is stored and readable. Otherwise the log line, after a fetch. */
internal fun missingPackage(
context: Context,
rule: ForcedPackage,
comp: String,
file: String,
release: String,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
): String? {
val wcp = File(packagesDir(context), safeName(rule.file))
val wcp = packageFile(context, file)
if (wcp.isFile && runCatching { packageInfo(context, 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"
val item = lookup(file, release)
?: return "$file is not in the Nightlies listing; ${LABEL.getValue(comp)} stays on the Proton's copy"
fetch(item)
return null
}
@@ -0,0 +1,183 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
/**
* The recipes behind "DroidDeck Proton (Auto)": per game, a DXVK, VKD3D-Proton and/or FEX package
* from the Nightlies "-Linux" releases and a few environment variables.
*
* The app keeps the recipes ([OVERRIDE] in its files, else the bundled [ASSET], the community library
* tools/recipes/convert.py builds), picks each game's variants for this device's GPU, makes sure every
* package they name is stored (the same download Components uses), unpacks each into the runtime's
* recipe store, and publishes [GUEST_FILE] with those paths. droiddeck-recipe reads that file when
* the Auto tool launches a game and starts Proton from a tree assembled for it; nothing is swapped
* into any Proton, so every other tool stays as it shipped.
*
* A recipe may only set tuning variables: the same rule droiddeck-recipe applies, kept here so a
* bad file is refused before it is published.
*/
object DdProtonRecipes {
private const val TAG = "DdProtonRecipes"
const val ASSET = "recipes/default.json"
const val OVERRIDE = "recipes.json"
private const val GUEST_FILE = "root/.config/droiddeck/recipes.json"
private const val STORE = "root/.local/share/droiddeck-recipes/store"
private val ENV_PREFIXES = listOf("DXVK_", "VKD3D_", "FEX_", "MESA_", "PROTON_")
private val ENV_EXACT = setOf("mesa_glthread", "WINEDLLOVERRIDES", "OPENSSL_ia32cap")
private val IA32CAP = Regex("~?0x[0-9a-fA-F]{1,16}(?::~?0x[0-9a-fA-F]{1,16})?")
private val ENV_DENIED_PARTS = listOf("PATH", "_DIR", "_FILE", "LIBRARY", "ROOTFS", "THUNK", "PRELOAD")
private val DLL_OVERRIDE = Regex("[A-Za-z0-9_.*-]+(?:,[A-Za-z0-9_.*-]+)*=(?:n|b|n,b|b,n|d)?")
data class PackageRef(val comp: String, val file: String, val release: String)
data class Recipe(val id: String, val packages: List<PackageRef>, val env: Map<String, String>, val note: String)
fun envAllowed(name: String, value: String): Boolean {
if (!GameEnvironment.validName(name) || name.startsWith("LD_")) return false
if (ENV_DENIED_PARTS.any { it in name.uppercase() }) return false
if (name !in ENV_EXACT && ENV_PREFIXES.none { name.startsWith(it) }) return false
if (!GameEnvironment.validValue(value)) return false
if (name == "WINEDLLOVERRIDES") return value.split(';').filter { it.isNotEmpty() }.all { DLL_OVERRIDE.matches(it) }
if (name == "OPENSSL_ia32cap") return IA32CAP.matches(value)
return true
}
/**
* The recipes in [text] for a device whose GPU is [family] (a GpuInfo.Family name, or null for
* none); throws IllegalArgumentException naming the first thing wrong.
*
* Version 1 gives each game one recipe. Version 2 (tools/recipes/convert.py) gives each game a
* list of variants, each optionally limited to GPU families: the matching ones are merged in
* order, the first to name a component providing it and later variables winning.
*/
fun parse(text: String, family: String? = null): List<Recipe> {
val json = JSONObject(text)
val version = json.optInt("version")
require(version == 1 || version == 2) { "recipes: version must be 1 or 2" }
val games = json.optJSONObject("games") ?: return emptyList()
return games.keys().asSequence().sorted().mapNotNull { id ->
require(id.isNotEmpty() && GameEnvironment.validScope(id)) { "recipes: $id is not a Steam app or shortcut id" }
val game = games.getJSONObject(id)
val variants = if (version == 1) listOf(game) else {
val list = game.getJSONArray("variants")
(0 until list.length()).map { list.getJSONObject(it) }.filter { variant ->
val gpu = variant.optJSONArray("gpu") ?: return@filter true
(0 until gpu.length()).any { gpu.getString(it) == family }
}
}
val packages = LinkedHashMap<String, PackageRef>()
val env = LinkedHashMap<String, String>()
val notes = LinkedHashSet<String>()
for (variant in variants) {
for (comp in ComponentsManager.COMPONENTS) {
val ref = variant.optJSONObject(comp) ?: continue
val file = ref.optString("file")
val release = ref.optString("release")
require(file.endsWith(".wcp") && ComponentsManager.safeName(file) == file) { "recipes: $id: $comp file '$file'" }
require(release.isNotEmpty() && ComponentsManager.safeName(release) == release) { "recipes: $id: $comp release '$release'" }
packages.putIfAbsent(comp, PackageRef(comp, file, release))
}
variant.optJSONObject("env")?.let { vars ->
vars.keys().forEach { name ->
val value = vars.get(name)
require(value is String && envAllowed(name, value)) { "recipes: $id: $name is not a variable a recipe may set" }
env[name] = value
}
}
variant.optString("note").takeIf { it.isNotEmpty() }?.let { notes += it }
}
if (packages.isEmpty() && env.isEmpty()) null
else Recipe(id, packages.values.toList(), env, notes.joinToString("; ").take(200))
}.toList()
}
/** The recipe store directory a package unpacks into. */
internal fun storeKey(file: String) = ComponentsManager.safeName(file.removeSuffix(".wcp"))
/**
* The guest file: each game's components as unpacked store paths. A component whose package is
* not in [available] (file name -> guest path) is left out, so that game runs on the depot's copy.
*/
internal fun publishJson(recipes: List<Recipe>, available: Map<String, String>): JSONObject {
val games = JSONObject()
for (recipe in recipes) {
val game = JSONObject()
for (ref in recipe.packages) available[ref.file]?.let { game.put(ref.comp, it) }
if (recipe.env.isNotEmpty()) game.put("env", JSONObject(recipe.env))
if (recipe.note.isNotEmpty()) game.put("note", recipe.note)
games.put(recipe.id, game)
}
return JSONObject().put("version", 1).put("games", games)
}
private fun source(context: Context): String? {
val override = File(context.filesDir, OVERRIDE)
if (override.isFile) return override.readText()
return runCatching { context.assets.open(ASSET).use { it.readBytes().decodeToString() } }.getOrNull()
}
/**
* Fetches and unpacks what the recipes need, then publishes them; returns a line for the session
* log. A package that cannot be fetched is left out of the published file and named in the line.
*/
fun ensure(context: Context): String = ensure(
context,
GpuInfo.detect().family.name,
{ file, release -> ComponentsManager.catalogItem(context, file, release) },
{ ComponentsManager.download(context, it) {} },
)
internal fun ensure(
context: Context,
family: String?,
lookup: (file: String, release: String) -> ComponentsManager.CatalogItem?,
fetch: (ComponentsManager.CatalogItem) -> Unit,
kind: (file: String) -> String? = { ComponentsManager.storedPackage(context, it)?.comp },
unpack: (file: String, dir: File) -> Unit = { file, dir -> ComponentsManager.unpack(ComponentsManager.packageFile(context, file), dir) },
): String {
val text = source(context)
val recipes = if (text == null) emptyList() else parse(text, family)
val root = LinuxRuntime.rootDir(context)
val store = File(root, STORE).apply { mkdirs() }
val available = LinkedHashMap<String, String>()
val problems = ArrayList<String>()
for (ref in recipes.flatMap { it.packages }.distinctBy { it.file }) {
val fetched = runCatching { ComponentsManager.missingPackage(context, ref.comp, ref.file, ref.release, lookup, fetch) }
val missing = fetched.getOrElse { "${ref.file}: ${it.message}" }
if (missing != null) { problems += missing; continue }
when (kind(ref.file)) {
ref.comp -> Unit
null -> { problems += "${ref.file} could not be read"; continue }
else -> { problems += "${ref.file} is not a ${ref.comp} package"; continue }
}
val dir = File(store, storeKey(ref.file))
runCatching { unpack(ref.file, dir) }
.onFailure { dir.deleteRecursively(); problems += "${ref.file}: ${it.message}" }
.onSuccess { available[ref.file] = "/" + dir.absolutePath.removePrefix(root.absolutePath.trimEnd('/')).trimStart('/') }
}
val keep = available.keys.map { storeKey(it) }.toSet()
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
write(File(root, GUEST_FILE), publishJson(recipes, available).toString())
val line = "$TAG: ${recipes.size} recipe(s), ${available.size} package(s) ready"
return if (problems.isEmpty()) line else "$line; " + problems.joinToString("; ")
}
private fun write(file: File, text: String) {
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
val atomic = AtomicFile(file)
val output = atomic.startWrite()
try {
output.write(text.toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
throw error
}
}
}
@@ -52,6 +52,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
"usr/local/bin/droiddeck-recipe" to "usr/local/bin/droiddeck-recipe",
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
"usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex",
"libblsession.so" to "usr/local/lib/libblsession.so",
@@ -350,6 +350,11 @@ class SessionService : Service() {
runCatching { ComponentsManager.ensureForcedPackages(this) }
.onSuccess { if (it != null) Log.i(TAG, it) }
.onFailure { Log.w(TAG, "forced components", it) }
// DroidDeck Proton (Auto)'s recipes: their packages fetched and unpacked, the file
// droiddeck-recipe reads published. Only that tool reads it; a failure leaves it stock.
runCatching { DdProtonRecipes.ensure(this) }
.onSuccess { Log.i(TAG, it) }
.onFailure { Log.w(TAG, "DroidDeck Proton (Auto) recipes", it) }
}
addClientEnvironment(guest, steamHere)
// Where the fast path's description of proot's view goes, once the binds are known.
@@ -0,0 +1,180 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
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 DdProtonRecipesTest {
private val dxvk = "dxvk-2.4-linux.wcp"
private val vkd3d = "vkd3d-proton-2.14-linux.wcp"
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
File(context.filesDir, "components").deleteRecursively()
File(context.filesDir, DdProtonRecipes.OVERRIDE).delete()
LinuxRuntime.rootDir(context).deleteRecursively()
}
@Test fun recipesParseIntoPackagesEnvAndNote() {
val recipes = DdProtonRecipes.parse(
"""{"version":1,"games":{"42":{"dxvk":{"file":"$dxvk","release":"Dxvk-Linux"},
"env":{"DXVK_HUD":"version","WINEDLLOVERRIDES":"xinput1_3=n,b;libsentry=d"},"note":"test"},
"4294967295":{"fex":{"file":"fex-2607-linux.wcp","release":"FexCore-Linux"}}}}""",
)
assertEquals(listOf("42", "4294967295"), recipes.map { it.id })
assertEquals(listOf(DdProtonRecipes.PackageRef("dxvk", dxvk, "Dxvk-Linux")), recipes[0].packages)
assertEquals(mapOf("DXVK_HUD" to "version", "WINEDLLOVERRIDES" to "xinput1_3=n,b;libsentry=d"), recipes[0].env)
assertEquals("test", recipes[0].note)
assertEquals("fex", recipes[1].packages.single().comp)
}
@Test fun version2PicksTheVariantsForThisGpu() {
val text = """{"version":2,"games":{
"42":{"name":"Game","variants":[
{"env":{"FEX_X87REDUCEDPRECISION":"1"},"note":"community"},
{"gpu":["A6XX"],"dxvk":{"file":"dxvk-gplasync-2.7.1-1-linux.wcp","release":"Dxvk-gplasync-Linux"},"env":{"DXVK_ASYNC":"1"},"note":"community"},
{"gpu":["A7XX","A8XX"],"vkd3d":{"file":"$vkd3d","release":"Vkd3d-proton-Linux"},"env":{"FEX_X87REDUCEDPRECISION":"0"}}]},
"43":{"name":"Only 6xx","variants":[{"gpu":["A6XX"],"env":{"DXVK_ASYNC":"1"}}]}}}"""
val a7 = DdProtonRecipes.parse(text, "A7XX")
assertEquals(listOf("42"), a7.map { it.id })
assertEquals(listOf("vkd3d"), a7[0].packages.map { it.comp })
assertEquals(mapOf("FEX_X87REDUCEDPRECISION" to "0"), a7[0].env)
assertEquals("community", a7[0].note)
val a6 = DdProtonRecipes.parse(text, "A6XX")
assertEquals(listOf("42", "43"), a6.map { it.id })
assertEquals(listOf("dxvk"), a6[0].packages.map { it.comp })
assertEquals(mapOf("FEX_X87REDUCEDPRECISION" to "1", "DXVK_ASYNC" to "1"), a6[0].env)
val none = DdProtonRecipes.parse(text, null)
assertEquals(listOf("42"), none.map { it.id })
assertTrue(none[0].packages.isEmpty())
}
@Test fun theBundledLibraryParsesForEveryFamily() {
val text = File("src/main/assets/${DdProtonRecipes.ASSET}").readText()
for (family in listOf("A6XX", "A7XX_LOW", "A7XX", "A8XX", "ADRENO_UNKNOWN", "NOT_ADRENO")) {
DdProtonRecipes.parse(text, family)
}
}
@Test fun anythingOutsideTheFormatIsRefused() {
val bad = listOf(
"""{"version":3,"games":{}}""",
"""{"version":1,"games":{"0":{}}}""",
"""{"version":1,"games":{"steam":{}}}""",
"""{"version":1,"games":{"42":{"dxvk":{"file":"../x.wcp","release":"Dxvk-Linux"}}}}""",
"""{"version":1,"games":{"42":{"dxvk":{"file":"dxvk.tar","release":"Dxvk-Linux"}}}}""",
"""{"version":1,"games":{"42":{"dxvk":{"file":"$dxvk","release":""}}}}""",
"""{"version":1,"games":{"42":{"env":{"LD_PRELOAD":"/x.so"}}}}""",
"""{"version":1,"games":{"42":{"env":{"DXVK_HUD":1}}}}""",
)
for (text in bad) {
assertThrows(text, Exception::class.java) { DdProtonRecipes.parse(text) }
}
}
@Test fun onlyTuningVariablesAreAllowed() {
for (name in listOf("DXVK_HUD", "VKD3D_CONFIG", "FEX_TSOENABLED", "MESA_SHADER_CACHE_MAX_SIZE", "PROTON_USE_WINED3D", "mesa_glthread")) {
assertTrue(name, DdProtonRecipes.envAllowed(name, "1"))
}
for (name in listOf("LD_PRELOAD", "LD_LIBRARY_PATH", "WINEDLLPATH", "PATH", "HOME", "PROTON_LOG_DIR", "DXVK_CONFIG_FILE",
"VKD3D_SHADER_CACHE_PATH", "MESA_SHADER_CACHE_DIR", "FEX_ROOTFS", "FEX_THUNKHOSTLIBS", "CUSTOM", "BAD-NAME")) {
assertFalse(name, DdProtonRecipes.envAllowed(name, "1"))
}
assertTrue(DdProtonRecipes.envAllowed("WINEDLLOVERRIDES", "a=n;b=b,n;c=d;d="))
for (value in listOf("../evil.dll=n", "a=native", "a", "a=n;b=x")) {
assertFalse(value, DdProtonRecipes.envAllowed("WINEDLLOVERRIDES", value))
}
assertFalse(DdProtonRecipes.envAllowed("DXVK_HUD", "a\u0000b"))
assertTrue(DdProtonRecipes.envAllowed("OPENSSL_ia32cap", "~0x20000000"))
assertFalse(DdProtonRecipes.envAllowed("OPENSSL_ia32cap", "/tmp/x"))
}
@Test fun thePublishedFileNamesStorePathsAndLeavesOutWhatIsMissing() {
val recipes = DdProtonRecipes.parse(
"""{"version":1,"games":{"42":{"dxvk":{"file":"$dxvk","release":"Dxvk-Linux"},
"vkd3d":{"file":"$vkd3d","release":"Vkd3d-proton-Linux"},"env":{"DXVK_HUD":"version"},"note":"n"}}}""",
)
val json = DdProtonRecipes.publishJson(recipes, mapOf(dxvk to "/root/.local/share/droiddeck-recipes/store/dxvk-2.4-linux"))
assertEquals(1, json.getInt("version"))
val game = json.getJSONObject("games").getJSONObject("42")
assertEquals("/root/.local/share/droiddeck-recipes/store/dxvk-2.4-linux", game.getString("dxvk"))
assertFalse(game.has("vkd3d"))
assertEquals("version", game.getJSONObject("env").getString("DXVK_HUD"))
assertEquals("n", game.getString("note"))
}
@Test fun ensureFetchesUnpacksAndPublishes() {
File(context.filesDir, DdProtonRecipes.OVERRIDE).writeText(
"""{"version":1,"games":{"42":{"dxvk":{"file":"$dxvk","release":"Dxvk-Linux"},
"vkd3d":{"file":"$vkd3d","release":"Vkd3d-proton-Linux"}}}}""",
)
val fetched = mutableListOf<String>()
val line = DdProtonRecipes.ensure(
context,
"A7XX",
{ file, release -> if (file == dxvk) item(file, release) else null },
{ fetched += it.file },
kind = { file -> if (file == dxvk) "dxvk" else null },
unpack = ::fakeUnpack,
)
assertEquals(listOf(dxvk), fetched)
assertTrue(line, line.startsWith("DdProtonRecipes: 1 recipe(s), 1 package(s) ready; $vkd3d is not in the Nightlies listing"))
val root = LinuxRuntime.rootDir(context)
val store = File(root, "root/.local/share/droiddeck-recipes/store/dxvk-2.4-linux")
assertTrue(File(store, ".complete").isFile)
assertEquals("d3d11", File(store, "files/lib/wine/dxvk/aarch64-windows/d3d11.dll").readText())
val published = JSONObject(File(root, "root/.config/droiddeck/recipes.json").readText())
val game = published.getJSONObject("games").getJSONObject("42")
assertEquals("/root/.local/share/droiddeck-recipes/store/dxvk-2.4-linux", game.getString("dxvk"))
assertFalse(game.has("vkd3d"))
// A recipe that stops naming a package drops its unpacked copy; a package of the wrong kind is refused.
File(context.filesDir, DdProtonRecipes.OVERRIDE).writeText(
"""{"version":1,"games":{"42":{"vkd3d":{"file":"$dxvk","release":"Dxvk-Linux"}}}}""",
)
val second = DdProtonRecipes.ensure(context, "A7XX", { f, r -> item(f, r) }, {}, kind = { "dxvk" }, unpack = ::fakeUnpack)
assertTrue(second, second.endsWith("$dxvk is not a vkd3d package"))
assertFalse(store.exists())
assertFalse(JSONObject(File(root, "root/.config/droiddeck/recipes.json").readText())
.getJSONObject("games").getJSONObject("42").has("vkd3d"))
}
@Test fun anEmptyOverridePublishesAnEmptyFile() {
File(context.filesDir, DdProtonRecipes.OVERRIDE).writeText("""{"version":1,"games":{}}""")
val line = DdProtonRecipes.ensure(context, "A7XX", { _, _ -> null }, {}, kind = { null }, unpack = ::fakeUnpack)
assertEquals("DdProtonRecipes: 0 recipe(s), 0 package(s) ready", line)
val published = JSONObject(File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/recipes.json").readText())
assertEquals(0, published.getJSONObject("games").length())
}
@Test fun withoutAnOverrideTheBundledLibraryIsPublished() {
val line = DdProtonRecipes.ensure(context, "A7XX", { _, _ -> null }, {}, kind = { null }, unpack = ::fakeUnpack)
val count = Regex("(\\d+) recipe").find(line)!!.groupValues[1].toInt()
assertTrue(line, count > 100)
val games = JSONObject(File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/recipes.json").readText()).getJSONObject("games")
assertEquals(count, games.length())
// Packages could not be fetched here, so every published recipe carries only its variables.
games.keys().forEach { id -> assertFalse(id, games.getJSONObject(id).has("dxvk")) }
}
private fun item(file: String, release: String) =
ComponentsManager.CatalogItem(file, "dxvk", release, "https://example.invalid/$file", 1, "sha256:00")
/** Stands in for ComponentsManager.unpack: zstd-jni has no host library for JVM tests. */
private fun fakeUnpack(file: String, dir: File) {
File(dir, "files/lib/wine/dxvk/aarch64-windows").mkdirs()
File(dir, "files/lib/wine/dxvk/aarch64-windows/d3d11.dll").writeText("d3d11")
File(dir, ".complete").writeText("1\n")
}
}
+231
View File
@@ -0,0 +1,231 @@
# DroidDeck Proton (Auto) community library
Generated by `tools/recipes/convert.py`; do not edit by hand. See
[game-environment.md](game-environment.md#droiddeck-proton-auto) for the rules.
- Uploads read: 15848 in 2738 folders
- Resolved to Steam apps: 1545
- Games with a recipe: 218
Builds treated as app defaults (ignored): A6XX dxvk: dxvk-2.3.1-async; A6XX vkd3d: vkd3d-2.12; A7XX dxvk: dxvk-2.3.1-async; A7XX vkd3d: vkd3d-2.12, vkd3d-proton-3.0.1, vkd3d-proton-3.0b; A7XX_LOW dxvk: dxvk-2.3.1-async; A7XX_LOW vkd3d: vkd3d-2.12; A8XX dxvk: dxvk-v2.6-1-async, dxvk-v2.7.1-1-async; A8XX vkd3d: vkd3d-2.12, vkd3d-proton-3.0.1, vkd3d-proton-3.0b
| App | Game | Uploads | Kept |
| --- | --- | --- | --- |
| 550 | Left 4 Dead 2 | 70 | FEX_HALFBARRIERTSOENABLED=0 (38 of 43 sources)<br>FEX_X87REDUCEDPRECISION=1 (40 of 43 sources) |
| 620 | Portal 2 | 15 | FEX_X87REDUCEDPRECISION=1 (10 of 11 sources) |
| 8140 | Tomb Raider: Underworld | 7 | FEX_HALFBARRIERTSOENABLED=0 (4 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 5 sources) |
| 8190 | Just Cause 2 | 27 | FEX_X87REDUCEDPRECISION=1 (7 of 9 sources) |
| 8870 | BioShock Infinite | 9 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>FEX_MULTIBLOCK=0 (3 of 4 sources) |
| 10180 | Call of Duty®: Modern Warfare® 2 (2009) | 42 | FEX_TSOENABLED=0 (9 of 11 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (9 of 11 sources)<br>FEX_X87REDUCEDPRECISION=1 (11 of 11 sources)<br>A6XX: DXVK Dxvk-1.7.2 from dxvk-1.7.2 (3 sources) |
| 15100 | Assassin's Creed™: Director's Cut Edition | 13 | FEX_TSOENABLED=0 (6 of 8 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (6 of 8 sources)<br>FEX_X87REDUCEDPRECISION=1 (7 of 8 sources) |
| 17470 | Dead Space (2008) | 22 | FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 19000 | Silent Hill Homecoming | 6 | FEX_TSOENABLED=0 (4 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 19680 | Alice: Madness Returns | 10 | FEX_X87REDUCEDPRECISION=1 (5 of 5 sources)<br>FEX_MULTIBLOCK=0 (5 of 5 sources) |
| 19980 | Prince of Persia® | 10 | FEX_TSOENABLED=0 (4 of 5 sources)<br>FEX_SMALLTSCSCALE=0 (4 of 5 sources)<br>FEX_VOLATILEMETADATA=0 (4 of 5 sources)<br>FEX_MONOHACKS=0 (4 of 5 sources) |
| 21690 | Resident Evil 5 | 109 | FEX_X87REDUCEDPRECISION=1 (25 of 27 sources)<br>A7XX_LOW: DXVK dxvk-gplasync-2.6.2-1 from dxvk-v2.6.2-1-async (6 sources) |
| 22300 | Fallout 3 | 17 | FEX_HALFBARRIERTSOENABLED=0 (9 of 10 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 22380 | Fallout: New Vegas | 14 | FEX_HALFBARRIERTSOENABLED=0 (6 of 8 sources)<br>FEX_X87REDUCEDPRECISION=1 (7 of 8 sources) |
| 33230 | Assassin's Creed 2 | 10 | FEX_X87REDUCEDPRECISION=1 (6 of 7 sources) |
| 39150 | FINAL FANTASY VIII | 4 | A6XX: VKD3D vkd3d-proton-3.0.1 from vkd3d-proton-3.0.1 (3 sources) |
| 39210 | FINAL FANTASY XIV Online | 20 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>A8XX: DXVK dxvk-gplasync-2.4.1-1 from 2.4.1-1-gplasync (3 sources) |
| 42680 | Call of Duty®: Modern Warfare® 3 (2011) | 39 | FEX_TSOENABLED=0 (10 of 12 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (11 of 12 sources)<br>FEX_X87REDUCEDPRECISION=1 (12 of 12 sources) |
| 44500 | Dirt 3 | 6 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 45740 | Dead Rising® 2 | 6 | A6XX: DXVK dxvk-1.10.3 from dxvk-1.10.3 (3 sources) |
| 45760 | Ultra Street Fighter® IV | 17 | A8XX: DXVK dxvk-gplasync-2.3.1-1 from dxvk-2.3.1-async (3 sources) |
| 47780 | Dead Space™ 2 | 16 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 50130 | Mafia II (Classic) | 36 | FEX_TSOENABLED=0 (14 of 15 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (15 of 15 sources)<br>FEX_X87REDUCEDPRECISION=1 (15 of 15 sources) |
| 55230 | Saints Row: The Third | 10 | FEX_X87REDUCEDPRECISION=1 (5 of 5 sources)<br>A6XX: VKD3D vkd3d-proton-3.0.1 from vkd3d-proton-3.0.1 (3 sources) |
| 108710 | Alan Wake | 13 | FEX_TSOENABLED=0 (5 of 5 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 108800 | Crysis 2 - Maximum Edition | 9 | FEX_TSOENABLED=0 (4 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 115320 | Prototype 2 | 73 | FEX_X87REDUCEDPRECISION=1 (36 of 37 sources) |
| 201870 | Assassin's Creed® Revelations | 20 | FEX_TSOENABLED=0 (14 of 15 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (14 of 15 sources)<br>FEX_X87REDUCEDPRECISION=1 (15 of 15 sources) |
| 202170 | Sleeping Dogs | 70 | FEX_HALFBARRIERTSOENABLED=0 (33 of 37 sources)<br>FEX_X87REDUCEDPRECISION=1 (35 of 37 sources) |
| 203140 | Hitman: Absolution™ | 35 | FEX_X87REDUCEDPRECISION=1 (6 of 7 sources) |
| 204030 | Fable - The Lost Chapters | 4 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 204100 | Max Payne 3 | 14 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 208650 | Batman™: Arkham Knight | 17 | FEX_HALFBARRIERTSOENABLED=0 (13 of 14 sources)<br>FEX_X87REDUCEDPRECISION=1 (14 of 14 sources) |
| 209160 | Call of Duty®: Ghosts | 22 | FEX_X87REDUCEDPRECISION=1 (14 of 15 sources) |
| 219990 | Grim Dawn | 4 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>FEX_MULTIBLOCK=0 (3 of 4 sources) |
| 220240 | Far Cry 3 | 105 | FEX_TSOENABLED=0 (42 of 54 sources)<br>FEX_X87REDUCEDPRECISION=1 (46 of 54 sources)<br>FEX_SMCCHECKS=none (42 of 54 sources) |
| 220440 | DmC: Devil May Cry | 28 | FEX_HALFBARRIERTSOENABLED=0 (12 of 14 sources)<br>FEX_X87REDUCEDPRECISION=1 (14 of 14 sources) |
| 222480 | Resident Evil Revelations | 15 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 225540 | Just Cause™ 3 | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 234140 | Mad Max | 12 | FEX_HALFBARRIERTSOENABLED=0 (6 of 8 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 8 sources) |
| 235460 | METAL GEAR RISING: REVENGEANCE | 30 | FEX_X87REDUCEDPRECISION=1 (17 of 17 sources) |
| 235600 | Tom Clancy’s Splinter Cell Blacklist | 6 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 236430 | DARK SOULS™ II | 17 | FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 238320 | Outlast | 12 | FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 239140 | Dying Light | 25 | FEX_X87REDUCEDPRECISION=1 (13 of 14 sources) |
| 239250 | Castlevania: Lords of Shadow 2 | 16 | FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 241930 | Middle-earth™: Shadow of Mordor™ | 10 | FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 242050 | Assassin’s Creed® IV Black Flag™ | 83 | FEX_HALFBARRIERTSOENABLED=0 (33 of 37 sources) |
| 243470 | Watch_Dogs™ | 35 | FEX_X87REDUCEDPRECISION=1 (15 of 16 sources) |
| 250760 | Shovel Knight: Treasure Trove | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 251150 | The Legend of Heroes: Trails in the Sky | 6 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 254700 | Resident Evil 4 (2005) | 34 | FEX_HALFBARRIERTSOENABLED=0 (7 of 9 sources)<br>FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 268050 | The Evil Within | 6 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>FEX_MULTIBLOCK=0 (3 of 4 sources) |
| 268910 | Cuphead | 38 | A6XX: DXVK Dxvk-2.6-2-gplasync from dxvk-2.6-arm64ec-async (4 sources) |
| 286690 | Metro 2033 Redux | 30 | FEX_X87REDUCEDPRECISION=1 (15 of 16 sources) |
| 287290 | Resident Evil Revelations 2 | 31 | FEX_HALFBARRIERTSOENABLED=0 (12 of 13 sources)<br>FEX_X87REDUCEDPRECISION=1 (11 of 13 sources) |
| 288470 | Fable Anniversary | 6 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 289650 | Assassin's Creed® Unity | 23 | FEX_HALFBARRIERTSOENABLED=0 (9 of 10 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 298110 | Far Cry® 4 | 18 | FEX_TSOENABLED=0 (9 of 11 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (10 of 11 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 11 sources) |
| 304430 | INSIDE | 17 | FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 307690 | Sleeping Dogs: Definitive Edition | 10 | FEX_TSOENABLED=0 (5 of 6 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 307780 | Mortal Kombat X | 39 | FEX_TSOENABLED=0 (12 of 14 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (13 of 14 sources) |
| 310950 | Street Fighter V | 6 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 311730 | DEAD OR ALIVE 5 Last Round: Core Fighters | 10 | FEX_X87REDUCEDPRECISION=1 (6 of 6 sources)<br>FEX_MULTIBLOCK=0 (6 of 6 sources) |
| 329050 | Devil May Cry 4 Special Edition | 44 | FEX_X87REDUCEDPRECISION=1 (20 of 22 sources) |
| 331600 | One Piece Pirate Warriors 3 | 12 | FEX_TSOENABLED=0 (11 of 11 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (11 of 11 sources)<br>FEX_X87REDUCEDPRECISION=1 (11 of 11 sources)<br>A6XX: DXVK dxvk-gplasync-2.3.1-1 from DXVK-2.3.1-1-gplasync-arm64ec (3 sources) |
| 348470 | SAMURAI WARRIORS 4-II | 13 | FEX_TSOENABLED=0 (12 of 12 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (12 of 12 sources)<br>FEX_X87REDUCEDPRECISION=1 (12 of 12 sources)<br>A6XX: DXVK dxvk-gplasync-2.3.1-1 from DXVK-2.3.1-1-gplasync-arm64ec (10 sources) |
| 349040 | NARUTO SHIPPUDEN: Ultimate Ninja STORM 4 | 67 | FEX_TSOENABLED=0 (27 of 30 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (28 of 30 sources)<br>FEX_X87REDUCEDPRECISION=1 (30 of 30 sources) |
| 355130 | MotoGP™15 | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>FEX_SMCCHECKS=none (3 of 3 sources)<br>FEX_HIDEHYPERVISORBIT=1 (3 of 3 sources)<br>FEX_SMALLTSCSCALE=0 (3 of 3 sources)<br>FEX_MONOHACKS=0 (3 of 3 sources) |
| 357190 | ULTIMATE MARVEL VS. CAPCOM 3 | 26 | A8XX: DXVK dxvk-gplasync-2.3.1-1 from dxvk-2.3.1-async (7 sources) |
| 373420 | Divinity: Original Sin - Enhanced Edition | 4 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 374320 | DARK SOULS™ III | 24 | FEX_HALFBARRIERTSOENABLED=0 (11 of 14 sources)<br>FEX_X87REDUCEDPRECISION=1 (13 of 14 sources) |
| 377160 | Fallout 4 | 17 | FEX_X87REDUCEDPRECISION=1 (9 of 10 sources) |
| 379720 | DOOM | 5 | FEX_TSOENABLED=0 (4 of 5 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 383150 | Dead Island Definitive Edition | 13 | FEX_HALFBARRIERTSOENABLED=0 (8 of 10 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 388410 | Darksiders II Deathinitive Edition | 12 | FEX_TSOENABLED=0 (6 of 8 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (6 of 8 sources)<br>FEX_X87REDUCEDPRECISION=1 (7 of 8 sources) |
| 389730 | TEKKEN 7 | 35 | FEX_TSOENABLED=0 (13 of 16 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (15 of 16 sources)<br>FEX_X87REDUCEDPRECISION=1 (16 of 16 sources) |
| 391220 | Rise of the Tomb Raider™ | 195 | FEX_X87REDUCEDPRECISION=1 (81 of 86 sources) |
| 393080 | Call of Duty®: Modern Warfare® Remastered (2017) | 8 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 418370 | Resident Evil 7 Biohazard | 35 | FEX_X87REDUCEDPRECISION=1 (16 of 16 sources) |
| 447040 | Watch_Dogs® 2 | 20 | FEX_X87REDUCEDPRECISION=1 (9 of 10 sources) |
| 489830 | The Elder Scrolls V: Skyrim Special Edition | 66 | FEX_HALFBARRIERTSOENABLED=0 (26 of 26 sources)<br>FEX_X87REDUCEDPRECISION=1 (26 of 26 sources) |
| 495420 | State of Decay 2: Juggernaut Edition | 7 | FEX_HALFBARRIERTSOENABLED=0 (4 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 516750 | My Summer Car | 11 | FEX_HALFBARRIERTSOENABLED=0 (5 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 526870 | Satisfactory | 5 | FEX_TSOENABLED=0 (4 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_SMCCHECKS=none (4 of 4 sources)<br>FEX_HIDEHYPERVISORBIT=1 (4 of 4 sources) |
| 544750 | SOULCALIBUR VI | 5 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 552520 | Far Cry® 5 | 20 | FEX_X87REDUCEDPRECISION=1 (8 of 8 sources) |
| 557340 | My Friend Pedro - Blood Bullets Bananas | 6 | FEX_HALFBARRIERTSOENABLED=0 (4 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 5 sources) |
| 561610 | MotoGP™17 | 13 | FEX_TSOENABLED=0 (7 of 8 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (7 of 8 sources)<br>FEX_X87REDUCEDPRECISION=1 (8 of 8 sources) |
| 570940 | DARK SOULS™: REMASTERED | 43 | A6XX: DXVK Dxvk-2.6-2-gplasync from dxvk-v2.6-1-async (3 sources) |
| 601050 | Attack on Titan 2 - A.O.T.2 | 10 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 601150 | Devil May Cry 5 | 38 | FEX_X87REDUCEDPRECISION=1 (19 of 22 sources) |
| 606280 | Darksiders III | 7 | FEX_TSOENABLED=0 (3 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 612880 | Wolfenstein II: The New Colossus | 11 | FEX_TSOENABLED=0 (6 of 6 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (6 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 627270 | Injustice™ 2 | 9 | FEX_TSOENABLED=0 (6 of 6 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (6 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 632360 | Risk of Rain 2 | 6 | FEX_TSOENABLED=0 (5 of 6 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 646910 | The Crew™ 2 | 16 | FEX_HALFBARRIERTSOENABLED=0 (10 of 10 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 647830 | LEGO® Marvel Super Heroes 2 | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 648800 | Raft | 7 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 675010 | MudRunner | 4 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 678950 | DRAGON BALL FighterZ | 43 | FEX_HALFBARRIERTSOENABLED=0 (21 of 26 sources)<br>FEX_X87REDUCEDPRECISION=1 (22 of 26 sources) |
| 686060 | Mewgenics | 6 | FEX_TSOENABLED=0 (4 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 699130 | World War Z | 4 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 774361 | Blasphemous | 10 | FEX_X87REDUCEDPRECISION=1 (5 of 6 sources)<br>FEX_MULTIBLOCK=0 (6 of 6 sources) |
| 782330 | DOOM Eternal | 4 | FEX_TSOENABLED=0 (3 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 805550 | Assetto Corsa Competizione | 5 | FEX_TSOENABLED=0 (4 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>A8XX: VKD3D vkd3d-proton-3.0.1 from vkd3d-proton-3.0.1.tar (3 sources) |
| 814380 | Sekiro™: Shadows Die Twice - GOTY Edition | 59 | PROTON_NO_ESYNC=1 (WINEESYNC=0, 30 of 30 sources) |
| 831560 | WARRIORS OROCHI 4 | 4 | FEX_TSOENABLED=0 (3 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 851850 | DRAGON BALL Z: KAKAROT | 13 | FEX_X87REDUCEDPRECISION=1 (11 of 11 sources)<br>A8XX: VKD3D vkd3d-proton-2.14.1 from vkd3d-proton-2.14.1 (3 sources) |
| 860510 | Little Nightmares II | 17 | FEX_X87REDUCEDPRECISION=1 (8 of 9 sources) |
| 882100 | XCOM®: Chimera Squad | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>A7XX: DXVK dxvk-gplasync-2.7.1-1 from dxvk-v2.7.1-1-async (3 sources) |
| 892970 | Valheim | 4 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 921570 | OCTOPATH TRAVELER™ | 6 | FEX_TSOENABLED=0 (5 of 5 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 934700 | Dead Island 2 | 4 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 953490 | CARRION | 5 | FEX_TSOENABLED=0 (5 of 5 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources)<br>A7XX: VKD3D vkd3d-proton-3.0.1 from vkd3d-3.0.1-arm64ec (4 sources) |
| 976310 | Mortal Kombat 11 | 15 | FEX_TSOENABLED=0 (8 of 10 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (9 of 10 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 990080 | Hogwarts Legacy | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1020790 | NARUTO X BORUTO Ultimate Ninja STORM CONNECTIONS | 54 | FEX_TSOENABLED=0 (24 of 30 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (27 of 30 sources)<br>FEX_X87REDUCEDPRECISION=1 (29 of 30 sources) |
| 1030300 | Hollow Knight: Silksong | 49 | A6XX: DXVK Dxvk-2.6-2-gplasync from dxvk-2.6-arm64ec-async (8 sources)<br>A7XX: DXVK Dxvk-2.6-2-gplasync from dxvk-2.6-arm64ec-async (6 sources) |
| 1057090 | Ori and the Will of the Wisps | 8 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1091500 | Cyberpunk 2077 | 20 | FEX_X87REDUCEDPRECISION=1 (8 of 8 sources) |
| 1104450 | Bombergrounds: Reborn | 4 | FEX_VECTORTSOENABLED=1 (3 of 3 sources)<br>FEX_MEMCPYSETTSOENABLED=1 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>FEX_SMCCHECKS=full (3 of 3 sources)<br>FEX_HIDEHYPERVISORBIT=1 (3 of 3 sources) |
| 1113000 | Persona 4 Golden | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1114150 | CarX Street | 7 | FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources)<br>FEX_SMCCHECKS=none (4 of 5 sources) |
| 1144200 | Ready or Not | 4 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 1145360 | Hades | 16 | FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 1151640 | Horizon Zero Dawn™ Complete Edition | 4 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 1196590 | Resident Evil Village | 34 | A7XX: DXVK dxvk-gplasync-2.7.1-1 from dxvk-v2.7.1-1-async (4 sources) |
| 1222680 | Need for Speed™ Heat | 34 | FEX_HALFBARRIERTSOENABLED=0 (20 of 20 sources)<br>FEX_X87REDUCEDPRECISION=1 (20 of 20 sources)<br>FEX_SMCCHECKS=none (17 of 20 sources) |
| 1222690 | Dragon Age™ Inquisition | 6 | FEX_TSOENABLED=0 (4 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 1228710 | MY HERO ONE'S JUSTICE 2 - Season Pass | 8 | FEX_TSOENABLED=0 (7 of 7 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (7 of 7 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 7 sources)<br>OPENSSL_ia32cap=~0x20000000 (OPENSSL_ia32cap=~0x20000000, 4 of 4 sources) |
| 1237970 | Titanfall® 2 | 8 | FEX_X87REDUCEDPRECISION=1 (7 of 7 sources) |
| 1238060 | Dead Space™ 3 | 10 | FEX_HALFBARRIERTSOENABLED=0 (6 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 6 sources) |
| 1238810 | Battlefield™ V | 11 | FEX_X87REDUCEDPRECISION=1 (6 of 7 sources) |
| 1238860 | Battlefield 4™ | 10 | FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 1245620 | ELDEN RING | 13 | FEX_X87REDUCEDPRECISION=1 (12 of 12 sources) |
| 1259420 | Days Gone | 19 | FEX_TSOENABLED=0 (8 of 10 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (10 of 10 sources)<br>FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 1262560 | Need for Speed™ Most Wanted | 48 | FEX_HALFBARRIERTSOENABLED=0 (18 of 24 sources)<br>FEX_X87REDUCEDPRECISION=1 (22 of 24 sources) |
| 1262580 | Need for Speed™ Payback | 17 | FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 1262600 | Need for Speed™ Rivals | 21 | FEX_HALFBARRIERTSOENABLED=0 (11 of 14 sources)<br>FEX_X87REDUCEDPRECISION=1 (13 of 14 sources) |
| 1285190 | Borderlands 4 | 3 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1295510 | DRAGON QUEST® XI S: Echoes of an Elusive Age™ - Definitive Edition | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1306630 | Lost Ruins | 3 | FEX_MULTIBLOCK=0 (3 of 3 sources)<br>A6XX: DXVK dxvk-gplasync-2.5.3-1 from dxvk-v2.5.2-1-async (3 sources) |
| 1326470 | Sons Of The Forest | 6 | FEX_HALFBARRIERTSOENABLED=0 (6 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 1328660 | Need for Speed™ Hot Pursuit Remastered | 9 | FEX_HALFBARRIERTSOENABLED=0 (5 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 1332010 | Stray | 16 | FEX_HALFBARRIERTSOENABLED=0 (11 of 11 sources)<br>FEX_X87REDUCEDPRECISION=1 (11 of 11 sources) |
| 1361510 | Teenage Mutant Ninja Turtles: Shredder's Revenge | 5 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>FEX_SMCCHECKS=none (3 of 3 sources) |
| 1378990 | Crash Bandicoot™ 4: It’s About Time | 9 | FEX_HALFBARRIERTSOENABLED=0 (8 of 9 sources)<br>FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 1426210 | It Takes Two | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1446780 | MONSTER HUNTER RISE | 17 | FEX_X87REDUCEDPRECISION=1 (15 of 15 sources) |
| 1451190 | Undisputed | 6 | FEX_TSOENABLED=0 (4 of 5 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources)<br>A8XX: VKD3D vkd3d-proton-3.0.1 from vkd3d-3.0.1-arm64ec (3 sources) |
| 1462040 | FINAL FANTASY VII REMAKE INTERGRADE | 8 | FEX_HALFBARRIERTSOENABLED=0 (4 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 5 sources) |
| 1506830 | FIFA 22 | 99 | FEX_X87REDUCEDPRECISION=1 (35 of 35 sources) |
| 1544020 | The Callisto Protocol™ | 3 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1547000 | Grand Theft Auto: San Andreas – The Definitive Edition | 32 | A6XX: VKD3D vkd3d-proton-3.0.1 from vkd3d-proton-3.0.1 (4 sources) |
| 1593500 | God of War | 156 | FEX_HALFBARRIERTSOENABLED=0 (73 of 79 sources)<br>FEX_X87REDUCEDPRECISION=1 (73 of 79 sources) |
| 1622710 | Oceanhorn 2: Knights of the Lost Realm | 7 | FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 1623730 | Palworld | 5 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>A6XX: DXVK Dxvk-2.6-2-gplasync from dxvk-v2.6-1-async (3 sources) |
| 1627720 | Lies of P | 3 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1634860 | Minishoot' Adventures | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1659420 | UNCHARTED™: Legacy of Thieves Collection | 8 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>A8XX: VKD3D vkd3d-proton-2.14.1 from vkd3d-proton-2.14.1 (4 sources) |
| 1663850 | REPLACED | 26 | FEX_HALFBARRIERTSOENABLED=0 (13 of 16 sources)<br>FEX_X87REDUCEDPRECISION=1 (16 of 16 sources) |
| 1672500 | GUNDAM BREAKER 4 | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1672810 | MIO: Memories in Orbit | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1677740 | Stumble Guys | 5 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>FEX_SMCCHECKS=none (3 of 3 sources) |
| 1687950 | Persona 5 Royal | 4 | FEX_HALFBARRIERTSOENABLED=0 (3 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 1693980 | Dead Space | 16 | FEX_X87REDUCEDPRECISION=1 (5 of 6 sources) |
| 1755070 | DRAGON BALL Z: KAKAROT Demo | 4 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1766740 | Choo-Choo Charles | 5 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1778820 | TEKKEN 8 | 5 | FEX_HALFBARRIERTSOENABLED=0 (4 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 1790600 | DRAGON BALL: Sparking! ZERO | 158 | FEX_HALFBARRIERTSOENABLED=0 (74 of 80 sources)<br>FEX_X87REDUCEDPRECISION=1 (77 of 80 sources) |
| 1817070 | Marvel’s Spider-Man Remastered | 20 | FEX_X87REDUCEDPRECISION=1 (10 of 10 sources) |
| 1890183 | Forza Horizon 5 2017 #25 Ferrari 488 | 4 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>FEX_SMCCHECKS=none (3 of 3 sources) |
| 1903340 | Clair Obscur: Expedition 33 | 5 | FEX_TSOENABLED=0 (3 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 4 sources) |
| 1954200 | Kena: Bridge of Spirits | 6 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 1971650 | OCTOPATH TRAVELER II | 7 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 1978590 | Anger Foot | 4 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 2050650 | Resident Evil 4 | 71 | FEX_HALFBARRIERTSOENABLED=0 (36 of 42 sources)<br>FEX_X87REDUCEDPRECISION=1 (40 of 42 sources) |
| 2124490 | SILENT HILL 2 | 6 | FEX_TSOENABLED=0 (4 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 2129530 | REANIMAL | 9 | FEX_HALFBARRIERTSOENABLED=0 (7 of 7 sources)<br>FEX_X87REDUCEDPRECISION=1 (7 of 7 sources) |
| 2138710 | Sifu | 20 | FEX_X87REDUCEDPRECISION=1 (8 of 10 sources) |
| 2161700 | Persona 3 Reload | 5 | FEX_X87REDUCEDPRECISION=1 (3 of 4 sources) |
| 2163330 | Yet Another Zombie Survivors | 5 | FEX_MULTIBLOCK=0 (3 of 3 sources) |
| 2184260 | Ricochet Blur | 16 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 2254890 | The Killing Antidote | 22 | FEX_HALFBARRIERTSOENABLED=0 (7 of 9 sources)<br>FEX_X87REDUCEDPRECISION=1 (9 of 9 sources)<br>A8XX: DXVK dxvk-gplasync-2.3.1-1 from dxvk-2.3.1-async (3 sources)<br>A8XX: VKD3D vkd3d-proton-2.14.1 from vkd3d-proton-2.14.1 (3 sources) |
| 2277560 | WUCHANG: Fallen Feathers | 6 | FEX_VECTORTSOENABLED=1 (5 of 5 sources)<br>FEX_MEMCPYSETTSOENABLED=1 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources)<br>FEX_SMCCHECKS=none (5 of 5 sources)<br>FEX_HIDEHYPERVISORBIT=1 (5 of 5 sources)<br>FEX_MONOHACKS=0 (5 of 5 sources)<br>A6XX: DXVK dxvk-gplasync-2.7.1-1 from dxvk-v2.7.1-1-async (4 sources) |
| 2358720 | Black Myth: Wukong | 12 | FEX_X87REDUCEDPRECISION=1 (7 of 7 sources) |
| 2416450 | MOUSE: P.I. For Hire | 26 | FEX_X87REDUCEDPRECISION=1 (12 of 12 sources) |
| 2420110 | Horizon Forbidden West™ Complete Edition | 3 | A8XX: VKD3D vkd3d-proton-2.14.1 from vkd3d-proton-2.14.1 (3 sources) |
| 2424420 | Avatar Legends: The Fighting Game | 6 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2427410 | S.T.A.L.K.E.R.: Shadow of Chornobyl - Enhanced Edition | 6 | FEX_TSOENABLED=0 (5 of 5 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (5 of 5 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 5 sources) |
| 2464280 | Tormented Souls 2 | 6 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>A8XX: DXVK dxvk-gplasync-3.0.2-1 from dxvk-3.0.2-async (3 sources) |
| 2473350 | HUNTDOWN: OVERTIME | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2523720 | Gears of War: Reloaded | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources)<br>FEX_SMCCHECKS=none (3 of 3 sources) |
| 2527500 | MiSide | 22 | A7XX: DXVK dxvk-gplasync-2.7.1-1 from dxvk-v2.7.1-1-async (3 sources) |
| 2536520 | Diablo II: Resurrected – Infernal Edition | 7 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2592160 | Dispatch | 7 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2638370 | Five Nights at Freddy's: Into the Pit | 6 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>FEX_MULTIBLOCK=0 (3 of 4 sources) |
| 2651280 | Marvel's Spider-Man 2 | 22 | FEX_HALFBARRIERTSOENABLED=0 (5 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (5 of 6 sources) |
| 2679460 | Metaphor: ReFantazio | 6 | FEX_TSOENABLED=0 (3 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources)<br>A7XX: DXVK dxvk-gplasync-2.7.1-1 from dxvk-v2.7.1-1-async (3 sources) |
| 2725260 | ENDER MAGNOLIA: Bloom in the Mist | 3 | FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2806050 | Halo: Campaign Evolved | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2947440 | SILENT HILL f | 3 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 2988580 | Yakuza 0 Director's Cut | 9 | FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 3010850 | Gears of War: E-Day | 6 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 3059520 | F1® 25 | 21 | FEX_TSOENABLED=0 (10 of 12 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (10 of 12 sources)<br>FEX_X87REDUCEDPRECISION=1 (12 of 12 sources) |
| 3077390 | MotoGP™25 | 4 | FEX_HALFBARRIERTSOENABLED=0 (4 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 3169520 | Warhammer 40,000: Space Marine - Master Crafted Edition | 4 | FEX_TSOENABLED=0 (3 of 3 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 3 sources)<br>FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 3183760 | SpeedRunners 2: King of Speed | 4 | FEX_TSOENABLED=0 (3 of 4 sources)<br>FEX_HALFBARRIERTSOENABLED=0 (3 of 4 sources)<br>FEX_X87REDUCEDPRECISION=1 (4 of 4 sources) |
| 3250520 | Night Project - Chapter 5 | 33 | FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 3357650 | PRAGMATA | 13 | FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 3405340 | Megabonk | 11 | FEX_X87REDUCEDPRECISION=1 (9 of 9 sources) |
| 3659280 | Off The Grid | 5 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 3875050 | MotoGP™26 | 8 | FEX_HALFBARRIERTSOENABLED=0 (6 of 6 sources)<br>FEX_X87REDUCEDPRECISION=1 (6 of 6 sources) |
| 4100940 | Poppy Playtime - Chapter 5 | 7 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 4144680 | DEAD OR ALIVE 6 Last Round | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 4371450 | Arctic Shift: Fuel Station | 3 | FEX_X87REDUCEDPRECISION=1 (3 of 3 sources) |
| 5006530 | The Witcher 3: Wild Hunt — Songs of the Past | 37 | FEX_HALFBARRIERTSOENABLED=0 (15 of 20 sources)<br>FEX_X87REDUCEDPRECISION=1 (17 of 20 sources) |
+48
View File
@@ -0,0 +1,48 @@
# DroidDeck Proton (Auto) on the Thor
Results of running games through "DroidDeck Proton (Auto)" on the AYN Thor (Snapdragon 8 Gen 2,
Adreno 740: `A7XX`). The base is Valve's Proton Experimental ARM64 `1790245028
experimental-11.0-20260924-arm64`. The FEX preset was the Stability-style full-TSO one. See
[game-environment.md](game-environment.md#droiddeck-proton-auto) for how recipes work and
[dd-proton-library.md](dd-proton-library.md) for the library.
Each game was mapped to the Auto tool in Steam's Compatibility setting. FPS readings come from
`DXVK_HUD`, set through the game's own environment profile so it applies with and without a
recipe.
## Plumbing (hand-written recipes, 2026-10-08)
| Check | Result |
| --- | --- |
| Tool listed in Steam's per-game Compatibility; choice survives restarts | Pass |
| Bug Fables (1082710), recipe DXVK 2.6.2 + `DXVK_HUD` | Pass: HUD shows `DXVK v2.6.2`, main menu at 60 FPS, prefix `d3d11.dll` matches the package |
| Same game, recipe without packages | Pass: Valve's `DXVK v3.1.1` back in the prefix (sha256 match), 60 FPS |
| Valve's depot after both | Pass: DXVK files byte-identical to before |
| 8-Bit Bayonetta (567090), Auto tool, no recipe | Pass: stock launch, no tree built, esync on the depot |
| esync on top of a recipe tree | Pass after the fix below: exact pack match, game runs |
| Proton update | Covered by unit tests (version line changes the key); not exercised on the device |
The first device run found that a guest `opendir()` of a directory symlink fails under proot's
fast path (`ENOTDIR`). esync's tree on top of the recipe tree then had no Wine unix libraries, and
`wineboot` died (`ws2_32.dll failed to initialize`). Recipe trees now keep esync's directories
real (`ESYNC_DIRS`). The fast path bug itself is tracked separately.
## Community library (bundled `default.json`, 2026-10-08)
Steam's idle screensaver takes focus from a running game, so captures need a key press first.
`droiddeckctl input tap` reaches Skyrim's launcher; Enter does not.
With no override file, the app published the bundled library for `A7XX`: 209 recipes, 3
packages fetched. Three installed games have a library entry. Each received exactly its FEX
variables, and the Auto launch path ran.
| Game | Recipe | Result |
| --- | --- | --- |
| The Elder Scrolls V: Skyrim Special Edition (489830) | `FEX_HALFBARRIERTSOENABLED=0`, `FEX_X87REDUCEDPRECISION=1` (65 sources) | Main menu at 60 FPS (DXVK 3.1.1), but the whole frame showed fine vertical stripe artifacts. A stock launch rendered cleanly (59.5 FPS while loading). Likely candidate for `overrides.json`; repeat the comparison on a fully loaded menu and in gameplay before blocking. |
| Portal 2 (620) | `FEX_X87REDUCEDPRECISION=1` (15 sources) | Recipe applied; `portal2.exe` created its Vulkan device. Not yet checked on screen. |
| OCTOPATH TRAVELER II (1971650) | `FEX_HALFBARRIERTSOENABLED=0`, `FEX_X87REDUCEDPRECISION=1` (7 sources) | Recipe applied; the game stopped before creating a Vulkan device. Not yet compared with a stock launch, so the cause is open. |
Still to do on the device: a stock run of each of these for comparison (same tool with an
empty override), and FPS and stability in gameplay. Record the outcome in
`tools/recipes/overrides.json`: keep the entry, replace it with a tested one, or block it. Then
regenerate the library.
+93 -2
View File
@@ -56,6 +56,95 @@ requiring a different/custom component; custom entries remain allowed. The old
`DXVK_FRAME_RATE` environment variable was removed from current DXVK, so it is
not offered as a suggestion. Prefer the session frame limiter for ordinary play.
## DroidDeck Proton (Auto)
A proof of concept for per-game Proton assembly. `steam-compatibility` registers
`droiddeck-proton-auto` ("DroidDeck Proton (Auto)") next to `droiddeck-proton-arm64`. It runs
the same Valve ARM64 depot, and its launcher also exports `DROIDDECK_RECIPES=1`. Choose it per
game in Steam's Properties → Compatibility. Every other tool behaves as before, including
Components swaps and the forced DXVK on Adreno 6xx.
For a game launched through it, `droiddeck-game-env` asks `droiddeck-recipe` for the game's
recipe. A recipe can name a DXVK, VKD3D-Proton and/or FEX package, plus environment variables.
Proton is then started from `~/.local/share/droiddeck-recipes/dist/<key>/`:
- real directories down to each directory a package replaces;
- every other entry a symlink to the depot;
- the component's own DLLs (the same set Components swaps) linked from the package.
The depot is never written. The key covers the depot, its version line and the packages, so a
Steam update gives a fresh tree. Trees unused for 14 days are removed. esync builds its own tree
on top, as it does for a depot. A recipe never stops a launch: a missing package or an error
leaves that component, or the whole launch, on the stock Proton. The session log and
`droiddeck-recipes/launches.log` record what was applied.
Recipe variables sit in the automatic-fixes layer: above shared entries, below the game's own
entries. They are limited to tuning names: the `DXVK_`, `VKD3D_`, `FEX_`, `MESA_` and `PROTON_`
families, `mesa_glthread` and `WINEDLLOVERRIDES`. Names that refer to a path, directory, file,
library or preload are refused. The app (`DdProtonRecipes`) and the guest both enforce this.
Recipes come from the app's `files/recipes.json` when present (for development), otherwise
from the bundled community library, `assets/recipes/default.json`. At session start the app
picks each game's variants for the device's GPU family (`GpuInfo.Family`). It then fetches each
named package from the Nightlies "-Linux" releases, the same way Components does, unpacks them
into `droiddeck-recipes/store/` and publishes `~/.config/droiddeck/recipes.json` with those
paths. A hand-written file can use version 1, which has one recipe per game:
```json
{"version": 1, "games": {"<app or shortcut id>": {
"dxvk": {"file": "dxvk-2.4-linux.wcp", "release": "Dxvk-Linux"},
"env": {"DXVK_HUD": "version"},
"note": "why this game needs it"}}}
```
The library uses version 2. Each game has a list of variants, and a variant can be limited to GPU
families. Matching variants are merged in order: the first to name a component provides it, and
later variables win.
```json
{"version": 2, "games": {"489830": {"name": "The Elder Scrolls V: Skyrim Special Edition",
"variants": [
{"env": {"FEX_X87REDUCEDPRECISION": "1"}, "note": "community: 66 uploads, 65 sources"},
{"gpu": ["A6XX"], "dxvk": {"file": "dxvk-gplasync-2.7.1-1-linux.wcp",
"release": "Dxvk-gplasync-Linux"}, "env": {"DXVK_ASYNC": "1"}}]}}}
```
### The community library
`tools/recipes/convert.py` builds the library from The412Banner's
[bannerhub-game-configs](https://github.com/The412Banner/bannerhub-game-configs) (GameHub and
BannerHub uploads) and [bannerlator-game-configs](https://github.com/The412Banner/bannerlator-game-configs)
(which upload folder is which Steam app). Those configs describe Android Wine containers, so
most of what they hold does not apply to Valve's ARM64 Proton under DroidDeck. The converter
keeps only what carries over, and only where independent uploads agree:
- **FEX:** taken from arm64x containers on any GPU; arm64x is the same ARM64 Wine with FEX that
Valve's ARM64 Proton uses. Only uploads where the player chose a FEX profile or changed a
setting count; GameHub's own per-game recommendation does not. A value is kept when it differs
from FEX's default.
- **DXVK and VKD3D-Proton:** chosen per Adreno family. A build carried by at least 10% of that
family's uploads is the app default of its day, not a choice, and is ignored. The build must
exist as a Nightlies Linux package. Async forks map to gplasync with `DXVK_ASYNC=1`.
- **Other variables:** only names the recipe allowlist accepts. Turnip's `TU_DEBUG` and file
paths are dropped.
"Agree" means at least 3 independent sources, counted as distinct upload tokens (or devices for
older uploads). At least 75% of the sources that chose something must pick the same value. For
FEX they must also cover at least half of the game's sources. Folders without a Steam app ID
(repacks, tools) are left out. Games tested by hand are recorded in `tools/recipes/overrides.json`:
kept, replaced or blocked. The converter applies that file last.
The library and [dd-proton-library.md](dd-proton-library.md), which lists every game with its
evidence, are generated together. Refresh them with:
```bash
git clone --depth 1 https://github.com/The412Banner/bannerhub-game-configs /tmp/bh
git clone --depth 1 https://github.com/The412Banner/bannerlator-game-configs /tmp/bl
python3 tools/recipes/convert.py --bannerhub /tmp/bh --bannerlator /tmp/bl
```
Results from games tested on the Thor are in [dd-proton-testing.md](dd-proton-testing.md).
## Launch path and references
WinNative's Linux session filters Android-only options, merges user variables
@@ -87,7 +176,9 @@ Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-
[DXVK variables](https://github.com/doitsujin/dxvk#environment-variables),
[DXVK frame-limiter changes](https://github.com/doitsujin/dxvk/releases).
Validation: `./gradlew testDebugUnitTest` and
`python3 -m unittest discover -s tools/tests -p test_game_environment.py`.
Validation: `./gradlew testDebugUnitTest`,
`python3 -m unittest discover -s tools/tests -p test_game_environment.py` and
`python3 -m unittest discover -s tools/tests -p test_recipe.py` and
`python3 -m unittest discover -s tools/tests -p test_recipe_convert.py`.
Actual game compatibility depends on the installed Proton, VKD3D and Vulkan driver;
these checks do not establish that every device supports feature level 12_2 or SM 6_9.
@@ -174,6 +174,39 @@ def sync_config(config, app_id):
}
def load_recipes():
try:
return runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-recipe"))
except Exception as error:
print("droiddeck-game-env: recipes not loaded (%s); stock Proton" % error, file=sys.stderr)
return None
def game_recipe(env, app_id, recipes=None):
"""The game's recipe when DroidDeck Proton (Auto) launched it (see droiddeck-recipe), or None."""
if env.get("DROIDDECK_RECIPES") != "1":
return None, None
recipes = recipes if recipes is not None else load_recipes()
if recipes is None:
return None, None
try:
return recipes, recipes["lookup"](env, app_id)
except Exception as error:
print("droiddeck-game-env: recipe not read (%s); stock Proton" % error, file=sys.stderr)
return None, None
def recipe_fixes(fixes, recipe_env):
"""A recipe's variables over the engine fixes: both are automatic, the recipe is the one made for
this game. WINEDLLOVERRIDES entries are joined rather than replaced, the recipe's after."""
result = dict(fixes)
for name, value in recipe_env.items():
if name == "WINEDLLOVERRIDES" and result.get(name):
value = result[name] + ";" + value
result[name] = value
return result
def load_sync():
try:
return runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-esync"))
@@ -265,6 +298,7 @@ def main():
env = os.environ.copy()
mark_install_scripts(command, env)
app_id = game_id(env)
recipes = recipe = None
if app_id:
waiting = len(command) > 1 and command[1] == "waitforexitandrun"
fixes = {}
@@ -273,6 +307,9 @@ def main():
for note in notes:
print("droiddeck-game-env: " + note, file=sys.stderr)
command += extra
recipes, recipe = game_recipe(env, app_id)
if recipe:
fixes = recipe_fixes(fixes, recipe["env"])
components = win_components(env.get("STEAM_COMPAT_DATA_PATH", ""), app_id)
if components:
own = fixes.get("WINEDLLOVERRIDES")
@@ -290,6 +327,9 @@ def main():
env = apply_config(env, {"version": 1}, app_id, fixes)
except (OSError, ValueError, TypeError, AttributeError):
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
env.pop("DROIDDECK_RECIPES", None)
if recipe:
command = recipes["assemble"](command, recipe, app_id)
env = force_ssbs(command, env)
sync = load_sync()
launch, launch_env = select_sync(command, env, sync)
@@ -0,0 +1,342 @@
#!/usr/bin/python3
# droiddeck-recipe: the per-game Proton behind "DroidDeck Proton (Auto)".
#
# That tool runs Valve's ARM64 Proton like droiddeck-proton-arm64 does, and exports
# DROIDDECK_RECIPES=1. droiddeck-game-env then asks this module for the game's recipe, a DXVK,
# VKD3D-Proton and/or FEX package and a few environment variables the app published in
# ~/.config/droiddeck/recipes.json, and starts Proton from a tree assembled for that recipe:
#
# ~/.local/share/droiddeck-recipes/dist/<key>/
# real directories down to each directory a package replaces, every other entry a symlink to
# the depot, and in a replaced directory the component's own files linked from the package.
#
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it (the same thing
# steam-compatibility and droiddeck-esync rely on), so it reads the package's DLLs from that tree
# and copies them into the game's prefix as if they were its own. The depot is never written: other
# tools, and this one for other games, keep Valve's files. A Steam update changes the depot's
# version line, which changes the key, so the next launch gets a fresh tree.
#
# A recipe never stops a launch. Anything wrong - a missing package, a bad file, an error here -
# is logged and the game starts on the stock Proton.
import contextlib
import fcntl
import hashlib
import json
import os
import re
import shutil
import sys
import time
HOME = os.environ.get("HOME") or os.path.expanduser("~")
RECIPES = os.path.join(HOME, ".config", "droiddeck", "recipes.json")
ROOT = os.path.join(HOME, ".local", "share", "droiddeck-recipes")
FLAG = "DROIDDECK_RECIPES"
RECORD = ".droiddeck-recipe.json"
LOG_LINES = 200
DIST_AGE = 14 * 24 * 3600
TEMP_AGE = 3600
WINE = "files/lib/wine"
PE_ARCHES = ("aarch64-windows", "i386-windows")
COMPONENTS = ("dxvk", "vkd3d", "fex")
LABEL = {"dxvk": "DXVK", "vkd3d": "VKD3D", "fex": "FEX"}
# The files that ARE each component, as ComponentsManager swaps them: in a replaced directory these
# are dropped from the depot's side before the package's files are linked in, so no file of the
# depot's version is left mixed in. Anything else there (GE's openvr_api_dxvk.dll) stays.
COMP_DIRS = {"dxvk": WINE + "/dxvk", "vkd3d": WINE + "/vkd3d-proton"}
COMP_DLLS = {
"dxvk": frozenset(("d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll")),
"vkd3d": frozenset(("d3d12.dll", "d3d12core.dll")),
}
# The directories droiddeck-esync rebuilds as real ones when it puts its own tree on top of this
# one (its REAL_DIRS). They are real here too, so it never has to list a directory through a
# symlink: a guest opendir() of a symlink, answered by proot's fast path, comes back ENOTDIR and
# esync would take that directory for empty.
ESYNC_DIRS = (".", "files", "files/bin-arm64", "files/lib", "files/lib/wine", "files/lib/wine/aarch64-unix")
FEX_FILES = {
WINE + "/aarch64-windows": frozenset(("libarm64ecfex.dll", "libwow64fex.dll")),
WINE + "/aarch64-unix": frozenset(("libarm64ecfex.so", "libwow64fex.so")),
}
# Environment a recipe may set. Names only from the graphics, translation and Proton families, and
# never one that names a file, a directory or a library to load: a recipe can tune a game, not run
# code in it.
ENV_PREFIXES = ("DXVK_", "VKD3D_", "FEX_", "MESA_", "PROTON_")
# OPENSSL_ia32cap masks CPU features OpenSSL would otherwise pick from what FEX reports.
ENV_EXACT = frozenset(("mesa_glthread", "WINEDLLOVERRIDES", "OPENSSL_ia32cap"))
IA32CAP = re.compile(r"~?0x[0-9a-fA-F]{1,16}(?::~?0x[0-9a-fA-F]{1,16})?")
ENV_DENIED_PARTS = ("PATH", "_DIR", "_FILE", "LIBRARY", "ROOTFS", "THUNK", "PRELOAD")
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
DLL_OVERRIDE = re.compile(r"[A-Za-z0-9_.*-]+(?:,[A-Za-z0-9_.*-]+)*=(?:n|b|n,b|b,n|d)?")
def say(message):
with contextlib.suppress(Exception):
print("droiddeck-recipe: " + message, file=sys.stderr, flush=True)
def env_allowed(name, value):
if not isinstance(name, str) or not ENV_NAME.fullmatch(name) or name.startswith("LD_"):
return False
if any(part in name.upper() for part in ENV_DENIED_PARTS):
return False
if name not in ENV_EXACT and not name.startswith(ENV_PREFIXES):
return False
if not isinstance(value, str) or "\0" in value or len(value) > 8192:
return False
if name == "WINEDLLOVERRIDES":
return all(DLL_OVERRIDE.fullmatch(entry) for entry in value.split(";") if entry)
if name == "OPENSSL_ia32cap":
return bool(IA32CAP.fullmatch(value))
return True
def store_root():
return os.path.join(ROOT, "store")
def package_ok(path):
"""A package directory the app unpacked completely, inside the recipe store."""
if not isinstance(path, str) or not os.path.isabs(path):
return False
store = os.path.realpath(store_root())
real = os.path.realpath(path)
return (os.path.dirname(real) == store and os.path.isfile(os.path.join(real, ".complete"))
and os.path.isdir(os.path.join(real, "files")))
def lookup(env, app_id, path=None):
"""The game's recipe when this launch came through the Auto tool, else None.
Returns {"components": {comp: package dir}, "skipped": [comp], "env": {...}, "note": str}.
Raises nothing: a bad file is logged and treated as no recipe."""
if env.get(FLAG) != "1" or not app_id:
return None
try:
with open(path or RECIPES, encoding="utf-8") as source:
data = json.load(source)
except FileNotFoundError:
return None
except (OSError, ValueError) as error:
say("recipes not read (%s); stock Proton" % error)
return None
if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("games"), dict):
say("recipes file not understood; stock Proton")
return None
entry = data["games"].get(app_id)
if not isinstance(entry, dict):
return None
components, skipped, environment = {}, [], {}
for comp in COMPONENTS:
if comp not in entry:
continue
if package_ok(entry[comp]):
components[comp] = os.path.realpath(entry[comp])
else:
skipped.append(comp)
raw = entry.get("env", {})
if isinstance(raw, dict):
for name, value in raw.items():
if env_allowed(name, value):
environment[name] = value
else:
say("app %s: %s is not a variable a recipe may set; left out" % (app_id, name))
note = entry.get("note") if isinstance(entry.get("note"), str) else ""
return {"components": components, "skipped": skipped, "env": environment, "note": note[:200]}
# ------------------------------------------------------------------ the tree
def version_line(tool):
try:
with open(os.path.join(tool, "version"), encoding="utf-8", errors="replace") as source:
return source.readline().strip()
except OSError:
return ""
def overrides(components):
"""{rel dir: (names dropped from the depot's side, {name: source file})} for these packages."""
result = {}
def entry(rel):
return result.setdefault(rel, (set(), {}))
for comp, package in sorted(components.items()):
files = os.path.join(package, "files")
for folder, dirs, names in os.walk(files):
dirs[:] = [d for d in dirs if not os.path.islink(os.path.join(folder, d))]
rel = os.path.relpath(folder, package).replace(os.sep, "/")
for name in names:
source = os.path.join(folder, name)
if os.path.isfile(source) and not os.path.islink(source):
entry(rel)[1][name] = source
if comp in COMP_DIRS:
for arch in PE_ARCHES:
rel = COMP_DIRS[comp] + "/" + arch
if rel in result:
result[rel][0].update(COMP_DLLS[comp])
else:
for rel, names in FEX_FILES.items():
entry(rel)[0].update(names)
return result
def ancestors(rel):
parts = rel.split("/")
return {"/".join(parts[:i]) for i in range(1, len(parts) + 1)} | {"."}
def fill(base, out, rel, real, replaced):
folder = os.path.join(out, rel) if rel != "." else out
os.makedirs(folder, exist_ok=True)
source = os.path.join(base, rel) if rel != "." else base
dropped, files = replaced.get(rel, (set(), {}))
try:
names = sorted(os.listdir(source))
except OSError:
names = []
for name in names:
child = name if rel == "." else rel + "/" + name
if rel == "." and name == RECORD:
continue
if child in real and os.path.isdir(os.path.join(source, name)):
fill(base, out, child, real, replaced)
elif name.lower() not in dropped and name not in files:
os.symlink(os.path.join(source, name), os.path.join(folder, name))
for child in sorted(r for r in real if r != "." and os.path.dirname(r) == ("" if rel == "." else rel)):
if not os.path.lexists(os.path.join(out, child)):
fill(base, out, child, real, replaced)
for name, target in sorted(files.items()):
os.symlink(target, os.path.join(folder, name))
def build(base, components, out):
replaced = overrides(components)
real = {rel for rel in ESYNC_DIRS if os.path.isdir(os.path.join(base, rel))}
for rel in replaced:
real |= ancestors(rel)
fill(base, out, ".", real, replaced)
def key_of(base, components):
stamp = {comp: [path, os.stat(os.path.join(path, ".complete")).st_mtime_ns] for comp, path in sorted(components.items())}
text = json.dumps({"base": os.path.realpath(base), "version": version_line(base), "components": stamp}, sort_keys=True)
return hashlib.sha256(text.encode("utf-8", "surrogateescape")).hexdigest()[:16]
@contextlib.contextmanager
def locked(path):
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def remove(path):
if os.path.islink(path) or (os.path.lexists(path) and not os.path.isdir(path)):
with contextlib.suppress(FileNotFoundError):
os.unlink(path)
elif os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
def ensure(base, components, root=None):
"""The assembled tree for `base` with these packages, built once and reused."""
folder = os.path.join(root or ROOT, "dist")
os.makedirs(folder, exist_ok=True)
key = key_of(base, components)
dist = os.path.join(folder, key)
with locked(dist + ".lock"):
if not os.path.isfile(os.path.join(dist, RECORD)):
remove(dist)
temp = os.path.join(folder, ".%s.tmp-%d" % (key, os.getpid()))
remove(temp)
try:
build(base, components, temp)
with open(os.path.join(temp, RECORD), "w", encoding="utf-8") as target:
json.dump({"base": base, "version": version_line(base), "components": components}, target, sort_keys=True)
os.rename(temp, dist)
except BaseException:
remove(temp)
raise
os.utime(dist)
collect(folder, keep=key)
return dist
def collect(folder, keep=None, now=None):
"""Drops trees not used for DIST_AGE and temp builds older than TEMP_AGE."""
now = now if now is not None else time.time()
try:
names = os.listdir(folder)
except OSError:
return
for name in names:
path = os.path.join(folder, name)
try:
age = now - os.lstat(path).st_mtime
except OSError:
continue
if name.startswith("."):
if ".tmp-" in name and age > TEMP_AGE:
remove(path)
elif name.endswith(".lock"):
if age > DIST_AGE and not os.path.isdir(path[:-5]):
remove(path)
elif name != keep and age > DIST_AGE:
with locked(path + ".lock"):
remove(path)
def log_line(app_id, text, root=None):
path = os.path.join(root or ROOT, "launches.log")
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with locked(path + ".lock"):
try:
with open(path, encoding="utf-8", errors="replace") as source:
lines = [line for line in source.read().split("\n") if line]
except FileNotFoundError:
lines = []
lines.append("%d\t%s\t%s" % (int(time.time()), app_id, re.sub(r"[\t\r\n]", " ", text)))
temp = "%s.tmp-%d" % (path, os.getpid())
with open(temp, "w", encoding="utf-8") as target:
target.write("\n".join(lines[-LOG_LINES:]) + "\n")
os.replace(temp, path)
except Exception as error:
say("launch not logged (%s)" % error)
def summary(recipe):
parts = []
for comp in COMPONENTS:
if comp in recipe["components"]:
parts.append("%s %s" % (LABEL[comp], os.path.basename(recipe["components"][comp])))
elif comp in recipe["skipped"]:
parts.append("%s stock (package missing)" % LABEL[comp])
else:
parts.append("%s stock" % LABEL[comp])
parts.append("env %d" % len(recipe["env"]))
text = ", ".join(parts)
return text + (" (%s)" % recipe["note"] if recipe["note"] else "")
def assemble(command, recipe, app_id, root=None):
"""`command` started from the recipe's tree, or unchanged when it has no packages or fails."""
text = summary(recipe)
say("app %s: %s" % (app_id, text))
log_line(app_id, text, root)
if not recipe["components"] or not command:
return list(command)
try:
base = os.path.dirname(command[0]) or "."
base = base if os.path.isabs(base) else os.path.join(os.getcwd(), base)
dist = ensure(base, recipe["components"], root)
return [os.path.join(dist, os.path.basename(command[0]))] + list(command[1:])
except Exception as error:
say("app %s: tree not assembled (%s); stock Proton" % (app_id, error))
return list(command)
@@ -43,7 +43,12 @@ LIBRARY = "/mnt/droiddeck" # the card library is LIBRARY + "-sd"; internal stor
# unmistakable at a glance, or a user picks a neighbour and gets a launch that fails silently.
# Newest first: whichever the client has downloaded is used.
SOURCES = ("Proton Experimental (ARM64)", "Proton 11.0 (ARM64)")
OWN_TOOLS = ((TOOL, SOURCES), (TOOL_11, ("Proton 11.0 (ARM64)",)))
# DroidDeck Proton (Auto): the same Valve depot, started from a tree assembled per game out of the
# recipe the app published for it (see droiddeck-recipe). Listed last: the app's default request
# names a depot directory, and the first tool on that directory is the one it means.
RECIPE_TOOL = "droiddeck-proton-auto"
RECIPE_DISPLAY = "DroidDeck Proton (Auto)"
OWN_TOOLS = ((TOOL, SOURCES), (TOOL_11, ("Proton 11.0 (ARM64)",)), (RECIPE_TOOL, SOURCES))
FEX_TOOL = "droiddeck-fex"
FEX_LAUNCHER = "droiddeck-fex-tool"
FEX_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", FEX_LAUNCHER)
@@ -404,15 +409,22 @@ exit 1
LAUNCHER_TEMPLATE = LAUNCHER_SH
def launcher_sh(sources=SOURCES):
def launcher_sh(sources=SOURCES, recipes=False):
# Spliced in after the formatting, never through it: the function body contains ${major%%.*}
# and a % format would eat that into a single %, leaving a shell expansion that silently does
# something else.
flag = "export DROIDDECK_RECIPES=1\n" if recipes else ""
return (LAUNCHER_TEMPLATE % " ".join('"%s"' % n for n in sources)).replace(
"verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
"verb=$1; shift", "verb=$1; shift\n" + flag + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1)
def tool_display(name, depot):
if name == RECIPE_TOOL:
return display_name(RECIPE_DISPLAY)
return display_name(os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64")
LAUNCHER_SH = launcher_sh()
# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of
@@ -1009,8 +1021,7 @@ def tool_catalog(steam_root, extras=None):
depot = find_source(steam_root, sources)
if depot is None and name != TOOL:
continue
catalog.append(tool_entry(name, display_name(
os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64"), depot, True))
catalog.append(tool_entry(name, tool_display(name, depot), depot, True))
if extras is None:
extras = {}
try:
@@ -1201,9 +1212,8 @@ def build_tool(tool_dir, depot, sources=SOURCES, name=TOOL):
shutil.rmtree(tool_dir)
os.makedirs(tool_dir, exist_ok=True)
write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF)
write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % (name, display_name(
os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64")))
write_changed(os.path.join(tool_dir, LAUNCHER), launcher_sh(sources), 0o755)
write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % (name, tool_display(name, depot)))
write_changed(os.path.join(tool_dir, LAUNCHER), launcher_sh(sources, recipes=name == RECIPE_TOOL), 0o755)
def build_fex_tool(tool_dir):
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Builds DroidDeck Proton (Auto)'s recipe library from community game configs.
tools/recipes/convert.py --bannerhub <bannerhub-game-configs> --bannerlator <bannerlator-game-configs> \
[--catalog nightlies.json] [--out app/src/main/assets/recipes/default.json] [--report docs/...md]
Input: The412Banner/bannerhub-game-configs (one JSON per uploaded GameHub/BannerHub config) and
The412Banner/bannerlator-game-configs (which config folder is which Steam app). Those configs come
from Android Wine containers, so most of what they hold does not apply here. Only what carries over
to Valve's ARM64 Proton under DroidDeck is kept, and only where independent uploads agree:
FEX From arm64x containers (the same ARM64 Wine with FEX as Valve's ARM64 Proton), on any GPU:
FEX's job is the CPU. Only configs where the player chose a FEX profile or changed a
setting count, and a value is kept when it differs from FEX's own default.
DXVK Per Adreno family. A build most uploads carry on that family is the app's default of
VKD3D its day, not a choice, and is ignored. A build must exist as a Nightlies "-Linux" package
(async forks map to gplasync with DXVK_ASYNC=1).
env Variables a recipe may set (droiddeck-recipe's allowlist), on agreement; Wine's own esync
and fsync switches become Proton's (WINEESYNC=0 -> PROTON_NO_ESYNC=1).
"Agree" means: at least MIN_SOURCES independent uploads (distinct upload tokens, or devices for
uploads without one) choose the same value, AGREEMENT of those that chose anything for it, and for
FEX at least FEX_SHARE of the game's sources. tools/recipes/overrides.json is applied last: games
tested by hand, kept, changed or blocked.
Output: the app's recipe file (version 2: per game, a list of variants, each optionally limited to
GPU families) and a Markdown report of what was kept and why.
"""
import argparse
import collections
import json
import os
import re
import runpy
import sys
import time
import urllib.request
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
RECIPE = runpy.run_path(str(REPO / "tools/linuxfs/overlay/usr/local/bin/droiddeck-recipe"))
MIN_SOURCES = 3
AGREEMENT = 0.75
FEX_SHARE = 0.5
DEFAULT_SHARE = 0.10
NIGHTLIES = "https://api.github.com/repos/The412Banner/Nightlies/releases/tags/%s"
RELEASES = ("Dxvk-Linux", "Dxvk-gplasync-Linux", "Vkd3d-proton-Linux")
# GameHub's FEX settings, FEX's own default for each (DroidDeck's Default preset), and the variable.
FEX_FIELDS = {
"TSOEnabled": (True, "FEX_TSOENABLED"),
"VectorTSOEnabled": (False, "FEX_VECTORTSOENABLED"),
"MemcpySetTSOEnabled": (False, "FEX_MEMCPYSETTSOENABLED"),
"HalfBarrierTSOEnabled": (True, "FEX_HALFBARRIERTSOENABLED"),
"X87ReducedPrecision": (False, "FEX_X87REDUCEDPRECISION"),
"Multiblock": (True, "FEX_MULTIBLOCK"),
"SMCChecks": ("mtrack", "FEX_SMCCHECKS"),
"HideHypervisorBit": (False, "FEX_HIDEHYPERVISORBIT"),
"SmallTSCScale": (True, "FEX_SMALLTSCSCALE"),
"VolatileMetadata": (True, "FEX_VOLATILEMETADATA"),
"MonoHacks": (True, "FEX_MONOHACKS"),
}
SMC_VALUES = ("none", "mtrack", "full")
# Variables GameHub's Wine reads under another name than Proton does: (name, value) -> (name, value).
ENV_TRANSLATE = {("WINEESYNC", "0"): ("PROTON_NO_ESYNC", "1"), ("WINEFSYNC", "0"): ("PROTON_NO_FSYNC", "1")}
# GameHub's per-game recommendation: not the player's choice.
RECOMMENDED_PROFILE = "game_recommend_id"
FAMILIES = {"6": "A6XX", "8": "A8XX"}
A7XX_LOW = {"710", "720", "722"}
STAMP = re.compile(r"-(\d{10})\.json$")
# ------------------------------------------------------------------ reading
def json_field(value):
if isinstance(value, dict):
return value
if isinstance(value, str) and value.startswith("{"):
try:
return json.loads(value)
except ValueError:
return None
return None
def family(soc):
"""DroidDeck's GPU family (GpuInfo.Family) for an uploaded SoC string, or None."""
match = re.search(r"Adreno[^0-9]*(\d)(\d\d)", soc or "")
if not match:
return None
model = match.group(1) + match.group(2)
if match.group(1) == "7":
return "A7XX_LOW" if model in A7XX_LOW else "A7XX"
return FAMILIES.get(match.group(1))
class Upload:
"""One uploaded config, reduced to what the converter reads."""
def __init__(self, name, data):
meta = data.get("meta") or {}
settings = data.get("settings") or {}
stamp = STAMP.search(name)
self.time = int(stamp.group(1)) if stamp else 0
# Uploads from before meta existed are named <game>-<maker>-<model>-<time>.json.
self.device = meta.get("device") or (name.rsplit("-", 2)[-2] if name.count("-") >= 2 else name)
device = self.device
self.source = meta.get("upload_token") or "device:" + str(device)
self.family = family(meta.get("soc"))
container = json_field(settings.get("pc_ls_CONTAINER_LIST")) or {}
self.arm64x = "arm64x" in (container.get("name") or "")
self.dxvk = (json_field(settings.get("pc_ls_DXVK")) or {}).get("name")
self.vkd3d = (json_field(settings.get("pc_ls_VK3k")) or {}).get("name")
self.fex = None
for key, value in settings.items():
if "TRANSLATOR_CONFIG_APPLYING_FEX" in key:
profile = json_field(value)
if profile is not None:
self.fex = profile
self.env = {}
for part in re.split(r"\s+", (settings.get("pc_ls_environment_variable") or "").strip()):
if "=" in part:
name_, _, val = part.partition("=")
self.env[name_] = val.strip('"')
def fex_chosen(self):
"""The FEX values the player chose, or {} when the profile is GameHub's recommendation."""
profile = self.fex
if not profile or not self.arm64x:
return {}
changed = {k.rsplit(".", 1)[-1].replace("Setting", "") for k in (profile.get("userChangeSettingMap") or {})}
if profile.get("id") == RECOMMENDED_PROFILE and not changed:
return {}
return {field: profile[field] for field in FEX_FIELDS if field in profile}
def read_uploads(bannerhub):
"""{folder: [Upload]} from a bannerhub-game-configs checkout."""
result = {}
configs = Path(bannerhub) / "configs"
for folder in sorted(os.listdir(configs)):
if not (configs / folder).is_dir():
continue
uploads = []
for name in sorted(os.listdir(configs / folder)):
if not name.endswith(".json"):
continue
try:
with open(configs / folder / name, encoding="utf-8") as source:
uploads.append(Upload(name, json.load(source)))
except (OSError, ValueError, AttributeError):
continue
if uploads:
result[folder] = uploads
return result
def app_ids(bannerlator):
"""{folder: (appid, steam name)} from a bannerlator-game-configs checkout; Steam apps only."""
base = Path(bannerlator)
with open(base / "games_steam.json", encoding="utf-8") as source:
resolved = json.load(source)
with open(base / "steam_aliases.json", encoding="utf-8") as source:
aliases = json.load(source)
names = {}
try:
with open(base / "games_canonical.json", encoding="utf-8") as source:
names = {k: v.get("name") for k, v in json.load(source).items()}
except (OSError, ValueError):
pass
result = {}
for folder, entry in resolved.items():
if isinstance(entry, dict) and isinstance(entry.get("appid"), int):
result[folder] = str(entry["appid"])
for folder, appid in aliases.items():
if isinstance(appid, int):
result[folder] = str(appid)
return {folder: (appid, names.get(appid) or (resolved.get(folder) or {}).get("steam_name") or folder)
for folder, appid in result.items()}
# ------------------------------------------------------------------ packages
def fetch_catalog():
catalog = {}
for tag in RELEASES:
with urllib.request.urlopen(NIGHTLIES % tag, timeout=60) as response:
catalog[tag] = [asset["name"] for asset in json.load(response)["assets"]]
return catalog
VERSION = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
EXOTIC = ("mali", "sarek", "pre-reg", "special", "stripped", "low-latency", "binsem", "tfix", "tilting", "gamesir", "fix")
def parse_build(name):
"""(version tuple, async) of a GameHub DXVK/VKD3D build name, or None for builds left alone."""
low = (name or "").lower()
match = VERSION.search(low)
if not match or any(tag in low for tag in EXOTIC):
return None
version = (int(match.group(1)), int(match.group(2)), int(match.group(3) or 0))
return version, "async" in low
def catalog_versions(files, comp):
"""{version: file} for the plain builds of one release (exotic variants left out)."""
result = {}
for file in files:
low = file.lower()
if comp == "vkd3d" and not low.startswith("vkd3d-proton-"):
continue
parsed = parse_build(low.replace("-linux.wcp", ""))
if parsed:
result[parsed[0]] = file
return result
def package_for(comp, build, catalog):
"""(package ref, extra env, label) for a GameHub build, or None when no Linux build matches."""
parsed = parse_build(build)
if parsed is None:
return None
version, is_async = parsed
if comp == "dxvk":
release = "Dxvk-gplasync-Linux" if is_async else "Dxvk-Linux"
else:
release = "Vkd3d-proton-Linux"
versions = catalog_versions(catalog.get(release, []), comp)
file = versions.get(version)
if file is None:
same = [v for v in versions if v[:2] == version[:2]]
if not same:
return None
file = versions[max(same)]
env = {"DXVK_ASYNC": "1"} if comp == "dxvk" and is_async else {}
return {"file": file, "release": release}, env, file.replace("-linux.wcp", "")
# ------------------------------------------------------------------ agreement
def agreed(votes, sources_total=None, share=0.0):
"""The value independent sources agree on, and how many chose it; None when they do not."""
if not votes:
return None
counts = collections.Counter(votes.values())
value, count = counts.most_common(1)[0]
if count < MIN_SOURCES or count / len(votes) < AGREEMENT:
return None
if sources_total and count / sources_total < share:
return None
return value, count
def latest_vote(uploads, pick):
"""{source: value} with each source's most recent choice (None choices left out)."""
votes = {}
for upload in sorted(uploads, key=lambda u: u.time):
value = pick(upload)
if value is not None:
votes[upload.source] = value
return votes
def fex_env(field, value):
default, name = FEX_FIELDS[field]
if field == "SMCChecks":
return (name, value) if value in SMC_VALUES and value != default else None
if not isinstance(value, bool) or value == default:
return None
return name, "1" if value else "0"
def game_variants(uploads, catalog, defaults):
"""The variants (any-GPU FEX/env first, then one per family with packages) and their evidence."""
variants, evidence = [], []
sources = {u.source for u in uploads if u.arm64x}
fex = {}
for field in FEX_FIELDS:
votes = latest_vote(uploads, lambda u: u.fex_chosen().get(field))
found = agreed({k: json.dumps(v) for k, v in votes.items()}, len(sources), FEX_SHARE)
if found:
env = fex_env(field, json.loads(found[0]))
if env:
fex[env[0]] = env[1]
evidence.append("%s=%s (%d of %d sources)" % (env[0], env[1], found[1], len(votes)))
env_votes = collections.defaultdict(dict)
for upload in sorted(uploads, key=lambda u: u.time):
for name, value in upload.env.items():
env_votes[name][upload.source] = value
for name, votes in sorted(env_votes.items()):
found = agreed(votes, len({u.source for u in uploads}), FEX_SHARE)
if not found:
continue
kept, value = ENV_TRANSLATE.get((name, found[0]), (name, found[0]))
if RECIPE["env_allowed"](kept, value):
fex[kept] = value
evidence.append("%s=%s (%s=%s, %d of %d sources)" % (kept, value, name, found[0], found[1], len(votes)))
if fex:
variants.append({"env": fex})
for fam in ("A6XX", "A7XX_LOW", "A7XX", "A8XX"):
on = [u for u in uploads if u.family == fam and u.arm64x]
variant, notes = {}, []
for comp, pick in (("dxvk", lambda u: u.dxvk), ("vkd3d", lambda u: u.vkd3d)):
found = agreed(latest_vote(on, pick))
if not found or found[0] in defaults.get((fam, comp), ()):
continue
mapped = package_for(comp, found[0], catalog)
if mapped is None:
continue
ref, env, label = mapped
variant[comp] = ref
if env:
variant.setdefault("env", {}).update(env)
notes.append("%s %s from %s (%d sources)" % (comp.upper(), label, found[0], found[1]))
if variant:
variant["gpu"] = [fam]
variants.append(variant)
evidence.extend("%s: %s" % (fam, note) for note in notes)
return variants, evidence
def default_builds(uploads):
"""{(family, comp): builds} carried by at least DEFAULT_SHARE of uploads on that family."""
counts = collections.defaultdict(collections.Counter)
for upload in uploads:
if upload.family:
counts[upload.family, "dxvk"][upload.dxvk] += 1
counts[upload.family, "vkd3d"][upload.vkd3d] += 1
return {key: {b for b, n in c.items() if n / sum(c.values()) >= DEFAULT_SHARE} for key, c in counts.items()}
# ------------------------------------------------------------------ output
def apply_overrides(games, overrides):
for appid, entry in overrides.get("games", {}).items():
if entry is None or entry.get("blocked"):
games.pop(appid, None)
else:
games[appid] = entry
return games
def build(bannerhub, bannerlator, catalog, overrides=None, now=None):
folders = read_uploads(bannerhub)
ids = app_ids(bannerlator)
by_app = collections.defaultdict(list)
names = {}
for folder, uploads in folders.items():
if folder in ids:
appid, name = ids[folder]
by_app[appid].extend(uploads)
names[appid] = name
defaults = default_builds([u for uploads in folders.values() for u in uploads])
games, report = {}, []
for appid in sorted(by_app, key=int):
uploads = by_app[appid]
variants, evidence = game_variants(uploads, catalog, defaults)
if not variants:
continue
latest = time.strftime("%Y-%m", time.gmtime(max(u.time for u in uploads))) if any(u.time for u in uploads) else ""
note = "community: %d uploads, %d sources, latest %s" % (len(uploads), len({u.source for u in uploads}), latest)
for variant in variants:
variant["note"] = note
games[appid] = {"name": names[appid], "variants": variants}
report.append((appid, names[appid], len(uploads), evidence))
apply_overrides(games, overrides or {})
library = {
"version": 2,
"generated": time.strftime("%Y-%m-%d", time.gmtime(now or time.time())),
"sources": ["https://github.com/The412Banner/bannerhub-game-configs",
"https://github.com/The412Banner/bannerlator-game-configs"],
"games": games,
}
stats = {"uploads": sum(map(len, folders.values())), "folders": len(folders),
"steam_apps": len(by_app), "games": len(games), "defaults": {"%s %s" % k: sorted(filter(None, v)) for k, v in defaults.items()}}
return library, report, stats
def write_report(path, report, stats, overrides):
lines = ["# DroidDeck Proton (Auto) community library", "",
"Generated by `tools/recipes/convert.py`; do not edit by hand. See",
"[game-environment.md](game-environment.md#droiddeck-proton-auto) for the rules.", "",
"- Uploads read: %d in %d folders" % (stats["uploads"], stats["folders"]),
"- Resolved to Steam apps: %d" % stats["steam_apps"],
"- Games with a recipe: %d" % stats["games"], "",
"Builds treated as app defaults (ignored): " + "; ".join(
"%s: %s" % (k, ", ".join(v)) for k, v in sorted(stats["defaults"].items()) if v), "",
"| App | Game | Uploads | Kept |", "| --- | --- | --- | --- |"]
for appid, name, count, evidence in report:
lines.append("| %s | %s | %d | %s |" % (appid, name.replace("|", "/"), count, "<br>".join(evidence)))
tested = overrides.get("games", {})
if tested:
lines += ["", "## Overrides (tools/recipes/overrides.json)", ""]
for appid, entry in sorted(tested.items()):
lines.append("- %s: %s" % (appid, "blocked" if not entry or entry.get("blocked") else entry.get("name", "replaced")))
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--bannerhub", required=True)
parser.add_argument("--bannerlator", required=True)
parser.add_argument("--catalog", help="Nightlies asset names as JSON {release: [files]}; fetched when omitted")
parser.add_argument("--overrides", default=str(HERE / "overrides.json"))
parser.add_argument("--out", default=str(REPO / "app/src/main/assets/recipes/default.json"))
parser.add_argument("--report", default=str(REPO / "docs/development/dd-proton-library.md"))
args = parser.parse_args(argv)
if args.catalog:
with open(args.catalog, encoding="utf-8") as source:
catalog = json.load(source)
else:
catalog = fetch_catalog()
overrides = {}
if os.path.isfile(args.overrides):
with open(args.overrides, encoding="utf-8") as source:
overrides = json.load(source)
library, report, stats = build(args.bannerhub, args.bannerlator, catalog, overrides)
with open(args.out, "w", encoding="utf-8") as target:
json.dump(library, target, indent=1, sort_keys=True, ensure_ascii=False)
target.write("\n")
write_report(args.report, report, stats, overrides)
print("convert: %d uploads, %d Steam apps, %d games with a recipe -> %s" % (
stats["uploads"], stats["steam_apps"], stats["games"], args.out))
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
{
"games": {}
}
+34 -1
View File
@@ -375,17 +375,50 @@ class ProtonDefaultTest(unittest.TestCase):
self.assertEqual(COMPAT["find_source"](str(steam), COMPAT["OWN_TOOLS"][1][1]), str(steam / "steamapps/common/Proton 11.0 (ARM64)"))
self.assertEqual(COMPAT["find_source"](str(steam)), str(steam / "steamapps/common/Proton Experimental (ARM64)"))
self.assertEqual([(t["name"], t["dir"]) for t in COMPAT["tool_catalog"](str(steam), {})],
[(COMPAT["TOOL"], "Proton Experimental (ARM64)"), (COMPAT["TOOL_11"], "Proton 11.0 (ARM64)")])
[(COMPAT["TOOL"], "Proton Experimental (ARM64)"), (COMPAT["TOOL_11"], "Proton 11.0 (ARM64)"),
(COMPAT["RECIPE_TOOL"], "Proton Experimental (ARM64)")])
for name, sources in COMPAT["OWN_TOOLS"]:
COMPAT["build_tool"](str(steam / "compatibilitytools.d" / name), COMPAT["find_source"](str(steam), sources), sources, name)
self.assertEqual(COMPAT["adopt_extras"](str(steam / "compatibilitytools.d")), {})
self.assertEqual(sorted(os.listdir(steam / "compatibilitytools.d" / COMPAT["TOOL_11"])), sorted(COMPAT["OWN_FILES"]))
launcher = COMPAT["launcher_sh"](COMPAT["OWN_TOOLS"][1][1])
self.assertNotIn("DROIDDECK_RECIPES", launcher)
self.assertIn('for name in "Proton 11.0 (ARM64)"; do', launcher)
self.assertIn("${major%%.*}", launcher)
class RecipeToolTest(unittest.TestCase):
def test_the_auto_tool_is_registered_beside_the_valve_one(self):
with tempfile.TemporaryDirectory() as tmp:
steam = Path(tmp) / "Steam"
(steam / "steamapps/common" / COMPAT["SOURCES"][0] / "files/bin-arm64").mkdir(parents=True)
tools = steam / "compatibilitytools.d"
for name, sources in COMPAT["OWN_TOOLS"]:
COMPAT["build_tool"](str(tools / name), COMPAT["find_source"](str(steam), sources), sources, name)
auto = tools / COMPAT["RECIPE_TOOL"]
self.assertEqual(sorted(os.listdir(auto)), sorted(COMPAT["OWN_FILES"]))
self.assertIn('"display_name" "DroidDeck Proton (Auto)', (auto / "compatibilitytool.vdf").read_text())
launcher = (auto / COMPAT["LAUNCHER"]).read_text()
self.assertIn("verb=$1; shift\nexport DROIDDECK_RECIPES=1\n", launcher)
self.assertIn('for name in "Proton Experimental (ARM64)" "Proton 11.0 (ARM64)"; do', launcher)
self.assertNotIn("DROIDDECK_RECIPES", (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"]).read_text())
catalog = COMPAT["tool_catalog"](str(steam), {})
self.assertEqual(catalog[-1], {"name": COMPAT["RECIPE_TOOL"], "display": "DroidDeck Proton (Auto)",
"dir": "Proton Experimental (ARM64)", "valve": True})
# The app's default request names a depot directory: it still means the plain tool.
self.assertEqual(COMPAT["requested_tool"]({"dir": "Proton Experimental (ARM64)", "valve": True}, catalog), COMPAT["TOOL"])
def test_a_game_set_to_the_auto_tool_keeps_it(self):
names = [COMPAT["TOOL"], COMPAT["RECIPE_TOOL"]]
current = {"0": COMPAT["TOOL"], "42": COMPAT["RECIPE_TOOL"]}
changes, kept = COMPAT["plan_mapping"](current, ["42", "43"], names, COMPAT["TOOL"], {})
self.assertEqual(changes, {"43": COMPAT["TOOL"]})
self.assertNotIn("42", kept)
changes, _ = COMPAT["plan_mapping"](current, ["42"], [COMPAT["TOOL"]], COMPAT["TOOL"], {})
self.assertEqual(changes, {"42": COMPAT["TOOL"]})
class DirectAudioPrefixTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
+333
View File
@@ -0,0 +1,333 @@
import contextlib
import hashlib
import io
import json
import os
from pathlib import Path
import runpy
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from test_esync_packs import make_pack, make_tool
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
RECIPE = runpy.run_path(str(BIN / "droiddeck-recipe"))
SYNC = runpy.run_path(str(BIN / "droiddeck-esync"))
GAME_ENV = runpy.run_path(str(BIN / "droiddeck-game-env"))
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
DXVK = "files/lib/wine/dxvk"
VKD3D = "files/lib/wine/vkd3d-proton"
PROTON = ("#!/usr/bin/python3\nimport json, os, sys\n"
"print(json.dumps([sys.argv[0], sys.argv[1:], {k: os.environ.get(k) for k in "
"('DXVK_HUD', 'CUSTOM', 'DROIDDECK_RECIPES', 'FEX_TSOENABLED')}]))\n")
def tree_hashes(path):
"""Every entry under `path`, with symlink targets and file contents, for before/after checks."""
result = {}
for folder, dirs, names in os.walk(path):
for name in dirs + names:
full = os.path.join(folder, name)
rel = os.path.relpath(full, path)
if os.path.islink(full):
result[rel] = "-> " + os.readlink(full)
elif os.path.isfile(full):
result[rel] = hashlib.sha256(Path(full).read_bytes()).hexdigest()
else:
result[rel] = "dir"
return result
class RecipeTestCase(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.tmp, True)
self.home = self.tmp / "home"
self.root = self.home / ".local/share/droiddeck-recipes"
self.store = self.root / "store"
self.store.mkdir(parents=True)
self.recipes = self.home / ".config/droiddeck/recipes.json"
self.recipes.parent.mkdir(parents=True)
RECIPE["store_root"].__globals__["ROOT"] = str(self.root)
self.depot = self.make_depot(self.tmp / "steamapps/common/Proton Experimental (ARM64)")
def make_depot(self, depot, line="1789159687 experimental-11.0-20260910b-arm64"):
for arch in ("aarch64-windows", "i386-windows"):
for comp, names in ((DXVK, ("d3d9.dll", "d3d11.dll", "dxgi.dll", "openvr_api_dxvk.dll")),
(VKD3D, ("d3d12.dll", "d3d12core.dll"))):
(depot / comp / arch).mkdir(parents=True, exist_ok=True)
for name in names:
(depot / comp / arch / name).write_bytes(b"valve " + name.encode() + arch.encode())
windows = depot / "files/lib/wine/aarch64-windows"
unix = depot / "files/lib/wine/aarch64-unix"
windows.mkdir(parents=True, exist_ok=True)
unix.mkdir(parents=True, exist_ok=True)
for name in ("libarm64ecfex.dll", "libwow64fex.dll", "kernel32.dll"):
(windows / name).write_bytes(b"valve " + name.encode())
for name in ("libarm64ecfex.so", "libwow64fex.so", "ntdll.so"):
(unix / name).write_bytes(b"valve " + name.encode())
(depot / "files/bin-arm64").mkdir(parents=True, exist_ok=True)
(depot / "files/share/wine").mkdir(parents=True, exist_ok=True)
(depot / "files/share/wine/wine.inf").write_text("inf")
(depot / "version").write_text(line + "\n")
(depot / "proton").write_text(PROTON)
(depot / "proton").chmod(0o755)
return depot
def make_package(self, name, files, complete=True):
package = self.store / name
for rel, data in files.items():
(package / rel).parent.mkdir(parents=True, exist_ok=True)
(package / rel).write_bytes(data)
(package / "files").mkdir(parents=True, exist_ok=True)
if complete:
(package / ".complete").write_text("1\n")
return package
def dxvk_package(self, name="dxvk-2.4-linux", with_i386=True):
files = {DXVK + "/aarch64-windows/d3d11.dll": b"recipe d3d11", DXVK + "/aarch64-windows/dxgi.dll": b"recipe dxgi"}
if with_i386:
files[DXVK + "/i386-windows/d3d11.dll"] = b"recipe d3d11 i386"
return self.make_package(name, files)
def write(self, games, version=1):
self.recipes.write_text(json.dumps({"version": version, "games": games}))
def lookup(self, app="42", env=None):
with contextlib.redirect_stderr(io.StringIO()) as said:
found = RECIPE["lookup"](env if env is not None else {"DROIDDECK_RECIPES": "1"}, app, str(self.recipes))
return found, said.getvalue()
def assemble(self, recipe, app="42"):
command = [str(self.depot / "proton"), "waitforexitandrun", "game.exe"]
with contextlib.redirect_stderr(io.StringIO()) as said:
result = RECIPE["assemble"](command, recipe, app, str(self.root))
return result, said.getvalue()
class LookupTest(RecipeTestCase):
def test_only_the_auto_tool_and_a_listed_game_get_a_recipe(self):
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package)}})
self.assertIsNone(self.lookup(env={})[0])
self.assertIsNone(self.lookup(env={"DROIDDECK_RECIPES": "0"})[0])
self.assertIsNone(self.lookup(app=None)[0])
self.assertIsNone(self.lookup(app="43")[0])
found, _ = self.lookup()
self.assertEqual(found["components"], {"dxvk": os.path.realpath(package)})
self.recipes.unlink()
self.assertEqual(self.lookup(), (None, ""))
def test_a_bad_file_is_no_recipe(self):
for text in ("{broken", json.dumps({"version": 2, "games": {}}), json.dumps([1]), json.dumps({"version": 1, "games": []})):
self.recipes.write_text(text)
found, said = self.lookup()
self.assertIsNone(found)
self.assertIn("stock Proton", said)
def test_packages_must_be_complete_and_in_the_store(self):
outside = self.tmp / "elsewhere"
(outside / "files").mkdir(parents=True)
(outside / ".complete").write_text("1")
partial = self.make_package("vkd3d-partial", {VKD3D + "/aarch64-windows/d3d12.dll": b"x"}, complete=False)
link = self.store / "escape"
os.symlink(outside, link)
self.write({"42": {"dxvk": str(outside), "vkd3d": str(partial), "fex": str(link)}})
found, _ = self.lookup()
self.assertEqual(found["components"], {})
self.assertEqual(found["skipped"], ["dxvk", "vkd3d", "fex"])
self.write({"42": {"dxvk": "relative/path", "fex": 7}})
self.assertEqual(self.lookup()[0]["skipped"], ["dxvk", "fex"])
def test_environment_is_limited_to_tuning_variables(self):
allowed = {"OPENSSL_ia32cap": "~0x20000000:~0x0", "DXVK_HUD": "version", "VKD3D_FEATURE_LEVEL": "12_1", "FEX_TSOENABLED": "1", "MESA_SHADER_CACHE_MAX_SIZE": "1G",
"PROTON_USE_WINED3D": "1", "mesa_glthread": "true", "WINEDLLOVERRIDES": "xinput1_3=n,b;d3dx9_43=n;libsentry=d;dinput8="}
denied = {"LD_PRELOAD": "/x.so", "WINEDLLPATH": "/x", "PROTON_LOG_DIR": "/x", "DXVK_CONFIG_FILE": "/x",
"VKD3D_SHADER_CACHE_PATH": "/x", "MESA_SHADER_CACHE_DIR": "/x", "FEX_ROOTFS": "/x", "FEX_THUNKHOSTLIBS": "/x",
"PATH": "/x", "HOME": "/x", "CUSTOM": "x", "DXVK_HUD2": "a\0b", "BAD-NAME": "x"}
self.write({"42": {"env": {**allowed, **denied}}})
found, said = self.lookup()
self.assertEqual(found["env"], allowed)
for name in denied:
self.assertIn(name, said)
self.assertFalse(RECIPE["env_allowed"]("OPENSSL_ia32cap", "/tmp/x"))
for value in ("../evil.dll=n", "a=n;b=x", "a=native", "a"):
self.assertFalse(RECIPE["env_allowed"]("WINEDLLOVERRIDES", value), value)
self.assertFalse(RECIPE["env_allowed"]("DXVK_HUD", "x" * 8193))
self.assertFalse(RECIPE["env_allowed"]("DXVK_HUD", 1))
class TreeTest(RecipeTestCase):
def test_the_tree_links_the_depot_and_replaces_only_the_component(self):
before = tree_hashes(self.depot)
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package), "note": "test"}})
command, said = self.assemble(self.lookup()[0])
dist = Path(command[0]).parent
self.assertEqual(command[1:], ["waitforexitandrun", "game.exe"])
self.assertEqual(dist.parent, self.root / "dist")
self.assertEqual(os.readlink(dist / "proton"), str(self.depot / "proton"))
self.assertEqual(os.readlink(dist / "files/share"), str(self.depot / "files/share"))
self.assertTrue((dist / VKD3D).is_symlink())
arm = dist / DXVK / "aarch64-windows"
self.assertFalse(arm.is_symlink())
real = Path(os.path.realpath(package))
self.assertEqual({p.name: os.readlink(p) for p in arm.iterdir()}, {
"d3d11.dll": str(real / DXVK / "aarch64-windows/d3d11.dll"),
"dxgi.dll": str(real / DXVK / "aarch64-windows/dxgi.dll"),
"openvr_api_dxvk.dll": str(self.depot / DXVK / "aarch64-windows/openvr_api_dxvk.dll"),
})
self.assertNotIn("d3d9.dll", os.listdir(arm))
self.assertEqual((dist / DXVK / "i386-windows/d3d11.dll").read_bytes(), b"recipe d3d11 i386")
self.assertEqual((dist / "files/lib/wine/aarch64-unix/ntdll.so").read_bytes(), b"valve ntdll.so")
self.assertIn("app 42: DXVK dxvk-2.4-linux, VKD3D stock, FEX stock, env 0 (test)", said)
self.assertEqual(tree_hashes(self.depot), before)
log = (self.root / "launches.log").read_text().splitlines()
self.assertEqual(log[-1].split("\t")[1:], ["42", "DXVK dxvk-2.4-linux, VKD3D stock, FEX stock, env 0 (test)"])
def test_fex_replaces_its_four_files_even_when_the_package_has_no_unix_half(self):
package = self.make_package("fex-2607", {"files/lib/wine/aarch64-windows/libarm64ecfex.dll": b"fex ec",
"files/lib/wine/aarch64-windows/libwow64fex.dll": b"fex wow"})
self.write({"42": {"fex": str(package)}})
dist = Path(self.assemble(self.lookup()[0])[0][0]).parent
windows = dist / "files/lib/wine/aarch64-windows"
unix = dist / "files/lib/wine/aarch64-unix"
self.assertEqual((windows / "libarm64ecfex.dll").read_bytes(), b"fex ec")
self.assertEqual(os.readlink(windows / "kernel32.dll"), str(self.depot / "files/lib/wine/aarch64-windows/kernel32.dll"))
self.assertEqual(sorted(os.listdir(unix)), ["ntdll.so"])
self.assertTrue((dist / DXVK).is_symlink())
def test_the_same_inputs_reuse_the_tree_and_changes_make_a_new_one(self):
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package)}})
recipe = self.lookup()[0]
first = self.assemble(recipe)[0][0]
marker = Path(first).parent / "marker"
marker.write_text("kept")
self.assertEqual(self.assemble(recipe)[0][0], first)
self.assertTrue(marker.exists())
(self.depot / "version").write_text("1789999999 experimental-11.0-20261001-arm64\n")
updated = self.assemble(recipe)[0][0]
self.assertNotEqual(updated, first)
os.utime(package / ".complete", (time.time() + 5, time.time() + 5))
self.assertNotEqual(self.assemble(recipe)[0][0], updated)
def test_a_recipe_without_packages_or_with_a_failure_is_the_stock_command(self):
stock = [str(self.depot / "proton"), "waitforexitandrun", "game.exe"]
self.write({"42": {"env": {"DXVK_HUD": "fps"}}})
command, said = self.assemble(self.lookup()[0])
self.assertEqual(command, stock)
self.assertIn("env 1", said)
self.assertFalse((self.root / "dist").exists())
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package), "vkd3d": str(self.store / "missing")}})
recipe = self.lookup()[0]
self.assertIn("VKD3D stock (package missing)", self.assemble(recipe)[1])
shutil.rmtree(package)
command, said = self.assemble(recipe)
self.assertEqual(command, stock)
self.assertIn("tree not assembled", said)
def test_old_trees_are_collected(self):
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package)}})
recipe = self.lookup()[0]
dist = Path(self.assemble(recipe)[0][0]).parent
folder = dist.parent
stale = folder / "0123456789abcdef"
stale.mkdir()
temp = folder / ".0123.tmp-1"
temp.mkdir()
fresh_temp = folder / ".4567.tmp-2"
fresh_temp.mkdir()
old = time.time() - RECIPE["DIST_AGE"] - 10
for path in (stale, dist, temp):
os.utime(path, (old, old))
RECIPE["collect"](str(folder), keep=dist.name)
self.assertFalse(stale.exists())
self.assertFalse(temp.exists())
self.assertTrue(fresh_temp.exists())
self.assertTrue(dist.exists())
class EsyncCompositionTest(RecipeTestCase):
def test_esync_builds_its_tree_on_top_of_the_recipes(self):
self.make_depot(self.tmp / "valve/Proton Experimental (ARM64)")
depot = make_tool(self.tmp / "valve", "Proton Experimental (ARM64)")
esync = self.home / ".local/share/droiddeck-esync"
esync.mkdir(parents=True)
pack = make_pack(esync, depot, "valve-experimental-r1")
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package)}})
with contextlib.redirect_stderr(io.StringIO()):
command = RECIPE["assemble"]([str(depot / "proton"), "waitforexitandrun", "game.exe"], self.lookup()[0], "42", str(self.root))
chosen, env = SYNC["select"](command, {"HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42",
"BL_SYNC_FALLBACK": "1", "BL_SYNC_MIN_NOFILE": "1"})
final = Path(chosen[0]).parent
self.assertEqual(final.parent, esync / "dist")
self.assertEqual(env["BL_SYNC_PACK"], pack["id"])
self.assertEqual((final / DXVK / "aarch64-windows/d3d11.dll").read_bytes(), b"recipe d3d11")
self.assertEqual((final / SYNC["NTDLL"]).read_bytes(), b"patched ntdll " + pack["id"].encode())
self.assertEqual((final / "files/lib/wine/aarch64-windows/libarm64ecfex.dll").read_bytes(), b"valve libarm64ecfex.dll")
self.assertIn("winevulkan.so", os.listdir(final / "files/lib/wine/aarch64-unix"))
self.assertIn("msidb", os.listdir(final / "files/bin-arm64"))
def test_esyncs_directories_are_real_in_the_recipe_tree(self):
# On the device a guest opendir() through a directory symlink fails (proot's fast path), so
# esync must find every directory it lists already real in this tree.
self.assertEqual(RECIPE["ESYNC_DIRS"], SYNC["REAL_DIRS"])
(self.depot / "files/bin-arm64/wineserver").write_bytes(b"server")
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package)}})
dist = Path(self.assemble(self.lookup()[0])[0][0]).parent
for rel in SYNC["REAL_DIRS"]:
self.assertTrue((dist / rel).is_dir() and not (dist / rel).is_symlink(), rel)
self.assertEqual(os.readlink(dist / "files/bin-arm64/wineserver"), str(self.depot / "files/bin-arm64/wineserver"))
class LaunchTest(RecipeTestCase):
def test_the_auto_launcher_reaches_proton_through_the_recipe_tree(self):
steam = self.tmp / "Steam"
depot = self.make_depot(steam / "steamapps/common" / COMPAT["SOURCES"][0])
tools = steam / "compatibilitytools.d"
package = self.dxvk_package()
self.write({"42": {"dxvk": str(package), "env": {"DXVK_HUD": "version", "FEX_TSOENABLED": "0"}}})
config = self.home / ".config/droiddeck/game-environment.json"
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared", "DXVK_HUD": "fps"},
"games": {"42": {"FEX_TSOENABLED": "1"}}}))
outputs = {}
for name in (COMPAT["TOOL"], COMPAT["RECIPE_TOOL"]):
COMPAT["build_tool"](str(tools / name), str(depot), COMPAT["SOURCES"], name)
wrapper = tools / name / COMPAT["LAUNCHER"]
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/droiddeck-game-env", str(BIN / "droiddeck-game-env")))
for prefix in ("/compatdata/42", "/compatdata/0"):
result = subprocess.run([str(wrapper), "waitforexitandrun", "game.exe"], text=True, capture_output=True,
env={"PATH": os.defpath, "HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": prefix,
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)})
self.assertEqual(result.returncode, 0, result.stderr)
outputs[name, prefix] = (json.loads(result.stdout), result.stderr)
(argv0, args, env), said = outputs[COMPAT["RECIPE_TOOL"], "/compatdata/42"]
self.assertEqual(Path(argv0).parent.parent, self.root / "dist")
self.assertEqual(args, ["waitforexitandrun", "game.exe"])
self.assertEqual(env, {"DXVK_HUD": "version", "CUSTOM": "shared", "DROIDDECK_RECIPES": None, "FEX_TSOENABLED": "1"})
self.assertIn("droiddeck-recipe: app 42: DXVK dxvk-2.4-linux", said)
for key in ((COMPAT["TOOL"], "/compatdata/42"), (COMPAT["TOOL"], "/compatdata/0"), (COMPAT["RECIPE_TOOL"], "/compatdata/0")):
(argv0, _, env), said = outputs[key]
self.assertEqual(argv0, str(depot / "proton"), key)
self.assertIsNone(env["DROIDDECK_RECIPES"], key)
self.assertNotIn("droiddeck-recipe", said, key)
self.assertEqual(outputs[COMPAT["TOOL"], "/compatdata/42"][0][2]["DXVK_HUD"], "fps")
def test_recipe_variables_join_the_automatic_fixes(self):
fixes = {"WINEDLLOVERRIDES": "libsentry=d", "FEX_MULTIBLOCK": "0"}
merged = GAME_ENV["recipe_fixes"](fixes, {"WINEDLLOVERRIDES": "xinput1_3=n", "FEX_MULTIBLOCK": "1"})
self.assertEqual(merged, {"WINEDLLOVERRIDES": "libsentry=d;xinput1_3=n", "FEX_MULTIBLOCK": "1"})
self.assertEqual(fixes["FEX_MULTIBLOCK"], "0")
if __name__ == "__main__":
unittest.main()
+189
View File
@@ -0,0 +1,189 @@
import json
import os
from pathlib import Path
import runpy
import shutil
import tempfile
import unittest
CONVERT = runpy.run_path(str(Path(__file__).resolve().parents[1] / "recipes/convert.py"))
CATALOG = {
"Dxvk-Linux": ["dxvk-1.10.3-linux.wcp", "dxvk-2.6.2-linux.wcp", "dxvk-low-latency-3.1.1-2-linux.wcp"],
"Dxvk-gplasync-Linux": ["dxvk-gplasync-2.3.1-1-linux.wcp", "dxvk-gplasync-2.6.2-1-linux.wcp", "dxvk-gplasync-3.1-1-binsem-linux.wcp"],
"Vkd3d-proton-Linux": ["vkd3d-proton-2.14.1-linux.wcp", "vkd3d-low-latency-3.0.1-2-linux.wcp"],
}
EXTREME = {"id": "local_Extreme", "name": "Extreme", "TSOEnabled": False, "HalfBarrierTSOEnabled": False,
"X87ReducedPrecision": True, "Multiblock": True, "SMCChecks": "mtrack", "userChangeSettingMap": None}
RECOMMENDED = {"id": "game_recommend_id", "name": "Game Presets", "TSOEnabled": True, "HalfBarrierTSOEnabled": True,
"X87ReducedPrecision": False, "Multiblock": True, "SMCChecks": "mtrack", "userChangeSettingMap": None}
def named(name):
return json.dumps({"name": name})
class ConvertTest(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.tmp, True)
self.bh = self.tmp / "bh"
self.bl = self.tmp / "bl"
(self.bh / "configs").mkdir(parents=True)
(self.bh / "configs/.gitkeep").write_text("")
self.bl.mkdir()
self.folders = {}
self.count = 0
def upload(self, folder, appid, token, soc="Adreno (TM) 740", fex=EXTREME, dxvk="dxvk-2.3.1-async",
vkd3d="vkd3d-2.12", container="proton10.0-arm64x-2", env="", device=None):
self.count += 1
self.folders[folder] = appid
settings = {"pc_ls_CONTAINER_LIST": named(container), "pc_ls_DXVK": named(dxvk), "pc_ls_VK3k": named(vkd3d),
"pc_ls_environment_variable": env}
if fex is not None:
settings["pc_ls_TRANSLATOR_CONFIG_APPLYING_FEX129505"] = json.dumps(fex)
meta = {"device": device or "Device %s" % token, "soc": soc}
if token:
meta["upload_token"] = token
path = self.bh / "configs" / folder
path.mkdir(exist_ok=True)
(path / ("%s-%s-%d.json" % (folder, meta["device"].replace(" ", "_"), 1780000000 + self.count))).write_text(
json.dumps({"meta": meta, "settings": settings}))
def build(self, overrides=None, catalog=CATALOG):
steam = {folder: {"appid": int(appid) if appid else None, "method": "naive", "steam_name": folder}
for folder, appid in self.folders.items()}
(self.bl / "games_steam.json").write_text(json.dumps(steam))
(self.bl / "steam_aliases.json").write_text("{}")
return CONVERT["build"](str(self.bh), str(self.bl), catalog, overrides or {}, now=0)
def test_agreeing_fex_choices_become_variables_and_defaults_do_not(self):
for token in "abc":
self.upload("Game", "42", token)
library, report, stats = self.build()
self.assertEqual(library["version"], 2)
game = library["games"]["42"]
self.assertEqual(game["name"], "Game")
self.assertEqual(game["variants"][0]["env"], {"FEX_TSOENABLED": "0", "FEX_HALFBARRIERTSOENABLED": "0",
"FEX_X87REDUCEDPRECISION": "1"})
self.assertNotIn("gpu", game["variants"][0])
self.assertIn("3 sources", game["variants"][0]["note"])
self.assertIn("FEX_TSOENABLED=0 (3 of 3 sources)", report[0][3])
def test_too_few_sources_a_split_or_the_recommended_profile_give_nothing(self):
for token in "ab":
self.upload("Two", "1", token)
for token in "ab":
self.upload("Split", "2", token)
for token in "cd":
self.upload("Split", "2", token, fex=dict(EXTREME, TSOEnabled=True, HalfBarrierTSOEnabled=True, X87ReducedPrecision=False))
for token in "abcd":
self.upload("Recommended", "3", token, fex=RECOMMENDED)
self.upload("Same uploader", "4", "a")
self.upload("Same uploader", "4", "a")
self.upload("Same uploader", "4", "a")
self.assertEqual(self.build()[0]["games"], {})
def test_fex_needs_half_of_the_games_sources(self):
for token in "abc":
self.upload("Game", "42", token)
for token in "defgh":
self.upload("Game", "42", token, fex=RECOMMENDED)
self.assertEqual(self.build()[0]["games"], {})
def test_a_changed_field_on_the_recommended_profile_counts(self):
changed = dict(RECOMMENDED, SMCChecks="none", userChangeSettingMap={"com.xj.winemu.bean.SMCChecksSetting": True})
for token in "abc":
self.upload("Game", "42", token, fex=changed)
self.assertEqual(self.build()[0]["games"]["42"]["variants"][0]["env"], {"FEX_SMCCHECKS": "none"})
def test_x64_containers_do_not_count_for_fex(self):
for token in "abc":
self.upload("Game", "42", token, container="proton9.0-x64-3")
self.assertEqual(self.build()[0]["games"], {})
def test_dxvk_per_family_maps_to_a_linux_package_and_skips_defaults(self):
# A build most uploads on a family carry is that app's default, not a choice.
for n in range(40):
self.upload("Filler", "9", "filler%d" % n, fex=RECOMMENDED)
self.upload("Filler", "9", "filler6-%d" % n, fex=RECOMMENDED, soc="Adreno (TM) 650")
for token in "abc":
self.upload("Async", "42", token, fex=RECOMMENDED, dxvk="dxvk-v2.6.2-1-async")
for token in "def":
self.upload("Plain", "43", token, fex=RECOMMENDED, dxvk="dxvk-1.10.3", soc="Adreno (TM) 650")
for token in "ghi":
self.upload("Default", "44", token, fex=RECOMMENDED)
for token in "jkl":
self.upload("Nothing on Linux", "45", token, fex=RECOMMENDED, dxvk="dxvk-1.5.5")
for token in "mno":
self.upload("Mali", "46", token, fex=RECOMMENDED, dxvk="dxvk-1.10.3", soc="Mali-G57")
games = self.build()[0]["games"]
self.assertEqual(games["42"]["variants"], [{
"gpu": ["A7XX"], "dxvk": {"file": "dxvk-gplasync-2.6.2-1-linux.wcp", "release": "Dxvk-gplasync-Linux"},
"env": {"DXVK_ASYNC": "1"}, "note": games["42"]["variants"][0]["note"]}])
self.assertEqual(games["43"]["variants"][0]["gpu"], ["A6XX"])
self.assertEqual(games["43"]["variants"][0]["dxvk"], {"file": "dxvk-1.10.3-linux.wcp", "release": "Dxvk-Linux"})
self.assertNotIn("env", games["43"]["variants"][0])
for appid in ("44", "45", "46"):
self.assertNotIn(appid, games)
def test_package_mapping(self):
self.assertEqual(CONVERT["package_for"]("dxvk", "dxvk-v2.6-1-async", CATALOG)[0]["file"], "dxvk-gplasync-2.6.2-1-linux.wcp")
self.assertEqual(CONVERT["package_for"]("vkd3d", "vkd3d-proton-2.14.1", CATALOG)[0]["file"], "vkd3d-proton-2.14.1-linux.wcp")
for comp, build in (("dxvk", "dxvk-v1.11.1-mali-fix"), ("dxvk", "dxvk-1.12.0-sarek-dyasync"), ("dxvk", "dxvk-3.1.1"),
("vkd3d", "vkd3d-proton-3.0.1"), ("dxvk", None)):
self.assertIsNone(CONVERT["package_for"](comp, build, CATALOG), build)
self.assertEqual(CONVERT["family"]("Adreno (TM) 720"), "A7XX_LOW")
self.assertEqual(CONVERT["family"]("Adreno (TM) 830"), "A8XX")
self.assertIsNone(CONVERT["family"]("Mali-G610"))
def test_only_allowed_variables_are_kept(self):
for token in "abc":
self.upload("Game", "42", token, fex=RECOMMENDED,
env='TU_DEBUG="noconform" WINEDLLOVERRIDES=xinput1_3=n,b DXVK_CONFIG_FILE=/sdcard/x.conf')
self.assertEqual(self.build()[0]["games"]["42"]["variants"][0]["env"], {"WINEDLLOVERRIDES": "xinput1_3=n,b"})
def test_wine_switches_become_protons_and_openssl_masks_are_kept(self):
for token in "abc":
self.upload("Game", "42", token, fex=RECOMMENDED, env="WINEESYNC=0 OPENSSL_ia32cap=~0x20000000 WINEMU_CPU_AFFINITY=127")
self.assertEqual(self.build()[0]["games"]["42"]["variants"][0]["env"],
{"PROTON_NO_ESYNC": "1", "OPENSSL_ia32cap": "~0x20000000"})
def test_uploads_without_an_appid_are_left_out(self):
for token in "abc":
self.upload("Repack", None, token)
library, _, stats = self.build()
self.assertEqual((library["games"], stats["steam_apps"]), ({}, 0))
def test_overrides_replace_and_block(self):
for token in "abc":
self.upload("Game", "42", token)
self.upload("Other", "43", token)
tested = {"name": "Game", "variants": [{"env": {"FEX_TSOENABLED": "1"}, "note": "tested"}]}
games = self.build({"games": {"42": tested, "43": {"blocked": True}, "44": tested}})[0]["games"]
self.assertEqual(games, {"42": tested, "44": tested})
def test_every_variable_and_package_passes_the_app_rules(self):
for token in "abc":
self.upload("Game", "42", token, dxvk="dxvk-v2.6.2-1-async")
for n in range(40):
self.upload("Filler", "9", "filler%d" % n, fex=RECOMMENDED)
for variant in self.build()[0]["games"]["42"]["variants"]:
for name, value in variant.get("env", {}).items():
self.assertTrue(CONVERT["RECIPE"]["env_allowed"](name, value), name)
def test_the_cli_writes_the_library_and_report(self):
for token in "abc":
self.upload("Game", "42", token)
self.build()
catalog = self.tmp / "catalog.json"
catalog.write_text(json.dumps(CATALOG))
out, report = self.tmp / "out.json", self.tmp / "report.md"
CONVERT["main"](["--bannerhub", str(self.bh), "--bannerlator", str(self.bl), "--catalog", str(catalog),
"--overrides", str(self.tmp / "missing.json"), "--out", str(out), "--report", str(report)])
self.assertIn("42", json.loads(out.read_text())["games"])
self.assertIn("| 42 | Game | 3 |", report.read_text())
if __name__ == "__main__":
unittest.main()