mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e28baecc0c | ||
|
|
942bdf94e0 | ||
|
|
fb8643304b | ||
|
|
465c3cd594 | ||
|
|
c5d5ac904e |
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")
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
@@ -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.
|
||||
@@ -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):
|
||||
|
||||
Executable
+431
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"games": {}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user