Compare commits

...
13 changed files with 321 additions and 47 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ captures/
# copies cannot drift.
app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
# Kotlin compiler session files (build-tool state, never source).
@@ -126,7 +126,9 @@ class MainActivity : ComponentActivity() {
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
private var tuSysmem by mutableStateOf(false)
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var gamescopeRealtime by mutableStateOf(false)
private var sustainedPerformance by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
@@ -503,8 +505,9 @@ class MainActivity : ComponentActivity() {
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
// The microphone is on by default; ask once, with the storage prompt, so voice chat works
// without a trip to the settings. A refusal is not asked again - the toggle asks when used.
// The microphone is off by default. Someone who turned it on before the permission was
// granted is asked once here, with the storage prompt; a refusal is not asked again - the
// toggle asks when used.
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
wanted.add(Manifest.permission.RECORD_AUDIO)
@@ -893,6 +896,7 @@ class MainActivity : ComponentActivity() {
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
gamescopeRealtime = gamescopeRealtime, sustainedPerformance = sustainedPerformance,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
@@ -900,6 +904,8 @@ class MainActivity : ComponentActivity() {
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onSustainedPerformance = { on -> SessionPrefs.setSustainedPerformance(this, on); sustainedPerformance = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
@@ -944,6 +950,7 @@ class MainActivity : ComponentActivity() {
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
refreshAddedGames()
@@ -993,6 +1000,8 @@ class MainActivity : ComponentActivity() {
tuSysmem = SessionPrefs.tuSysmem(this)
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
sustainedPerformance = SessionPrefs.sustainedPerformance(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
@@ -849,9 +849,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return Pair(width and 1.inv(), height and 1.inv())
}
/**
* The rate the panel runs the session at: its fastest mode at the current size, which PerfMode
* asks for. The display's current rate is read too early for that - a phone still at 60 Hz
* when the surface arrives handed gamescope -r 60 and capped every game at 60.
*/
private fun refreshHz(): Float {
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val hz = display?.refreshRate ?: 60f
val cur = display?.mode
val best = if (cur == null) null else display.supportedModes
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
.maxOfOrNull { it.refreshRate }
val hz = maxOf(best ?: 0f, display?.refreshRate ?: 0f)
return if (hz > 1f) hz else 60f
}
@@ -146,6 +146,8 @@ object DeviceReport {
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("Sustained performance mode", SessionPrefs.sustainedPerformance(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
@@ -27,8 +27,9 @@ object GameEnvironment {
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
// The feature level is left to vkd3d-proton, which reports what the driver can do; forcing
// 12_2 sent games down DX12 Ultimate paths Turnip only emulates. 6_6 is WinNative's model.
"VKD3D_SHADER_MODEL" to "6_6",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
}
@@ -13,8 +13,8 @@ import android.util.Log
* The manifest already makes the app a game (appCategory + game_mode_config); this is the
* per-window half, applied when the session activity is created:
*
* - sustained performance mode (Android 7+): a clock floor that does not throttle away after
* two minutes, which suits an hour in Big Picture better than a boost that fades;
* - sustained performance mode (Android 7+), only when the Performance page asks for it: clocks
* the SoC can hold for hours, which on many devices caps the peak a session otherwise gets;
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
* 120 Hz for the menu, not only when a game votes for it;
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
@@ -30,7 +30,7 @@ object PerfMode {
val parts = ArrayList<String>(3)
// Sustained performance mode.
parts += if (Build.VERSION.SDK_INT >= 24) {
parts += if (!SessionPrefs.sustainedPerformance(a)) "sustained mode off" else if (Build.VERSION.SDK_INT >= 24) {
val pm = a.getSystemService(PowerManager::class.java)
if (pm?.isSustainedPerformanceModeSupported == true) {
try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" }
@@ -143,8 +143,8 @@ object SessionPrefs {
prefs(context).edit().putBoolean("directAudio", on).apply()
}
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
/** Whether the app has already asked for the microphone once at start-up. */
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
@@ -188,14 +188,39 @@ object SessionPrefs {
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
* it storms, and the session dies seconds after Big Picture appears.
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
* does run, it sits beside every game under FEX for nothing a controller-first session needs
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
*/
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
fun setNoXalia(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
/**
* Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1,
* which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
* whose GPU is waiting on the CPU.
*/
/**
* Whether the session window asks for Android's sustained performance mode. Off by default: it
* holds the SoC at clocks it can keep for hours, which on many devices is a cap well under
* the peak a game session gets without it (WinNative never asks for it).
*/
fun sustainedPerformance(context: Context): Boolean = prefs(context).getBoolean("sustainedPerformance", false)
fun setSustainedPerformance(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("sustainedPerformance", on).apply()
}
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
fun setGamescopeRealtime(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
}
/**
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
*
@@ -309,8 +334,12 @@ object SessionPrefs {
fun resolutionChosen(context: Context, mode: String): Boolean =
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
/**
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's own
* defaults. PERFORMANCE_TSO unless chosen: TSO only on the main path, x87 reduced precision
* and multiblock, as WinNative ships it.
*/
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "PERFORMANCE_TSO") ?: "PERFORMANCE_TSO"
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
@@ -240,10 +240,12 @@ class SessionService : Service() {
val override = File(Environment.getExternalStorageDirectory(), TU_DEBUG_SWITCH)
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
if (!override.isNullOrEmpty()) return override
if (SessionPrefs.tuSysmem(this)) return "sysmem"
if (linuxDriverId.isEmpty()) return null
val name = LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
return if (name.contains("710-720") || name.contains("710_720")) "sysmem" else null
// noconform: Turnip keeps the Vulkan features it has not passed conformance for (WinNative's
// default beside sysmem), which DXVK and vkd3d-proton reach for.
val sysmem = SessionPrefs.tuSysmem(this) || linuxDriverId.isNotEmpty() &&
LinuxVulkanDriverManager(this).getDriverName(linuxDriverId).lowercase()
.let { it.contains("710-720") || it.contains("710_720") }
return if (sysmem) "noconform,sysmem" else "noconform"
}
// ── The session ─────────────────────────────────────────────────────────────────────────
@@ -519,6 +521,8 @@ class SessionService : Service() {
// BL_STEAMDECK; it is the one that builds the command line).
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
// Zink packs its descriptor sets and shader interfaces tighter (WinNative's default).
guest.add("ZINK_DEBUG=compact")
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (SessionState.mode == MODE_STEAM) {
@@ -526,9 +530,12 @@ class SessionService : Service() {
SessionPrefs.writeForceFullscreenFlag(this)
}
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
// XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game
// a slice of a core for gamepad navigation the session already has.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// gamescope's realtime Vulkan queues (the session script turns this into
// GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on.
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -37,6 +37,8 @@ fun PerformancePage(
glThread: Boolean,
noGlError: Boolean,
noXalia: Boolean,
gamescopeRealtime: Boolean,
sustainedPerformance: Boolean,
prootNoSeccomp: Boolean,
guestHostname: String,
phantomWarning: String?,
@@ -46,6 +48,8 @@ fun PerformancePage(
onGlThread: (Boolean) -> Unit,
onNoGlError: (Boolean) -> Unit,
onNoXalia: (Boolean) -> Unit,
onGamescopeRealtime: (Boolean) -> Unit,
onSustainedPerformance: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
@@ -94,6 +98,11 @@ fun PerformancePage(
"Disables per-call GL validation.",
noGlError, onChange = onNoGlError,
)
ToggleRow(
host, "gsrealtime", "gamescope: realtime GPU queue",
"Gives the compositor's GPU work priority over the game's. Can smooth frame pacing, but can cost games GPU time.",
gamescopeRealtime, onChange = onGamescopeRealtime,
)
}
SettingsGroup("Session fixes") {
ToggleRow(
@@ -103,9 +112,14 @@ fun PerformancePage(
)
ToggleRow(
host, "xalia", "Skip Steam's xalia helper",
"Disables Proton's gamepad navigation helper. Try if sessions crash at startup.",
"Disables Proton's gamepad navigation helper, which costs every game CPU time. Turn off only if a game needs it.",
noXalia, onChange = onNoXalia,
)
ToggleRow(
host, "sustained", "Android sustained performance mode",
"Holds clocks the device can keep for hours. Steadier on hot devices, but usually a lower peak.",
sustainedPerformance, onChange = onSustainedPerformance,
)
ToggleRow(
host, "seccomp", "Run proot without seccomp",
"May fix missing syscall errors on some kernels, but can reduce performance.",
+129
View File
@@ -0,0 +1,129 @@
# DroidDeck - Performance findings
Why DroidDeck 0.2.0 ran slower than WinNative and Bannerlator for many people, what was found, what was
changed, what was measured, and what is still open. Written 2026-09-29 from the Discord reports, the GitHub
issues, and a day of measurement on an AYN Thor (SD 8 Gen 2, Adreno 740). Companion to
[progress-log.md](progress-log.md).
## What people reported
- **Discord (#general, 2026-09-26 to 09-29).** The same game at ~90-100 fps in WinNative and ~30-40 in
DroidDeck 0.1.7, ~25 in 0.2.0 (Adreno 732); "15 fps in a 2D Unity game" on an 8 Elite while Bannerlator's
Steam client is fine; "20 fps / 7 fps in the Steam client, all renders on CPU" on an Adreno 8xx; Bannerlator
locked at 120 fps where DroidDeck is not; "less performant, more compatible" as the usual summary.
- **[#65](https://github.com/Droid-Deck/DroidDeck/issues/65)** - the best report: Silksong at 85-90 fps on
0.1.5 and 37-50 on 0.2.0 on a Fold8, GPU 82% busy vs 16%, proot 3-5x busier, the game's main thread waiting
on `futex` and the wineserver pipe. History table: 90 (0.1.5), 103 (0.1.6), 58/110/48 (0.1.7), 47-50 (0.2.0).
- **[#64](https://github.com/Droid-Deck/DroidDeck/issues/64)** - Samsung's SSRM clamps CPU/GPU to ~60% in a
session (Tab S8 Ultra). A platform limit, separate from everything below; no sanctioned way around it found.
## Cause 1: proot's kompat extension traced every hot syscall (fixed, PR #81)
`--kernel-release` (added 2026-09-25 to give the guest the `DroidDeck` hostname, `53ce764`) loads proot's
kompat extension. Its filter list includes `futex`, `epoll_pwait`, `fcntl`, `pselect6`, `pipe2`, `eventfd2`,
`socket`, so each of those from every guest thread stopped in the single tracer, and it drops
`AT_SYSINFO_EHDR` on every `execve`, so glibc ran without the vDSO. With the real kernel release passed, every
one of those handlers is a no-op. Patch `tools/proot/patches/0011-kompat-utsname-only.patch` traces only
`uname`/`sethostname`/`setdomainname` in that case.
Guest micro-benchmarks (same rootfs, proot with vs without the patch):
| | old + `--kernel-release` | patched | no flag at all |
|---|---|---|---|
| futex ping-pong | 465 us | 102 us | 96 us |
| `epoll_pwait(0)` | 60 us | 0.87 us | 0.8 us |
| `fcntl(F_GETFL)` | 40-107 us | 0.39 us | 0.38 us |
| `clock_gettime` | 0.34 us | 0.19 us | 0.11-0.19 us |
A-B-A in Once Upon a KATAMARI (standing still, only `libproot.so` swapped): +2-5% fps, proot CPU ~56% ->
~35%. Small there because Katamari is limited by translated game code; sync-heavy games should gain more.
## Cause 2: a crash-looping mangoapp in Deck mode (fixed, PR #81)
`gamescopereaper --respawn -- mangoapp` restarts the overlay the moment it exits, with no backoff. On a build
whose session preload lacked the System V message queue emulation, mangoapp died right after creating its
swapchain and was restarted 132 times in ~2.5 minutes; each restart is a full exec under proot and the Steam
menu itself lagged. Local builds that skip rebuilding `libblsession.so` hit this; CI builds do not. The wrapper
(`tools/mangoapp/mangoapp`) now parks after more than 5 starts in 60 s.
## What Bannerlator and WinNative do differently
Read from their sources and a device that has Bannerlator installed:
- **Same proot patches** (0001-0009 from WinNative); the Steam client mode uses the same Valve ARM64 Proton, so
**the same server-side Wine synchronisation** (`wineserver: using server-side synchronization` in every
session log here). No structural gap remains once kompat is out.
- **Defaults differ.** Bannerlator ships Deck mode off (it notes Steam Input takes the pad and breaks games),
never passes `-steamos3`, does not set gamescope's realtime queue, runs xalia. WinNative defaults to
`TU_DEBUG=noconform,sysmem`, `ZINK_DEBUG=compact`, FEX `PERFORMANCE_TSO`, vkd3d shader model 6_6, and never
turns on sustained performance mode.
## Changes on `perf/steam-client-parity`
- xalia skipped by default (it ran ~10% of a core beside every game under FEX).
- gamescope realtime Vulkan queues: new toggle (Performance -> Client interface), **off** by default.
- Microphone off by default; no permission prompt at start-up.
- FEX default preset `PERFORMANCE_TSO`; `TU_DEBUG=noconform` always (plus `sysmem` for the 710-720 drivers or
when chosen); `ZINK_DEBUG=compact`; vkd3d shader model 6_6, feature level left to vkd3d-proton.
- Sustained performance mode is opt-in (Performance page); PerfMode logs "sustained mode off".
- The session gets the panel's fastest mode's refresh rate instead of the rate at the moment the surface arrived.
- Session polling without forks: the fill-screen and stop watchers wait on a FIFO read timeout and read files
with builtins; the redistributable seeder and the compat registrar (Python) start only when a prefix or the
Steam config actually changed, not every 5-15 s.
- The Steam page shows the saved Deck mode before the Performance page has been opened (it showed "off" while
the session ran Deck mode with mangoapp).
- `tools/build_local.sh`: caches its pinned downloads, skips an unchanged proot build, and says why it rebuilds
the Docker image; a warm build is ~30 s (it used to hang on a registry fetch when the daemon briefly reported
the image missing).
- Turnip **sysmem** was made a default and then reverted: it gained nothing on an Adreno 740.
## How it was measured
- **fps:** `BannerWayland [stats]` lines in logcat (`adb logcat -s BannerWayland:I`), every 10 s. No `su`, no
process on the device. The "screen" number counts presents and runs ~1.5x the game's own overlay counter in
Alan Wake (60 vs 38-40): compare like with like, and read the overlay for the game's real rate.
- **Scene:** Alan Wake's American Nightmare, first gameplay spot ("Secure the primary pipes"), standing still.
Reached without touching the screen: `~/.bl-autolaunch` is ignored once Steam is up, so the game is started with
`steam steam://rungameid/<id>` from a second proot invocation (the client forwards it), and A is pressed with
`sendevent` on the device's own gamepad node (`/dev/input/event12`) - the app only forwards events from a real
controller device, so `adb shell input` presses are dropped.
- **Thermals:** the SoC reaches ~92-95 C in any run, so each run starts only after the CPU zones are under 55 C.
A one-shot comparison made hot was misleading (the new build read 52-54 fps then, 58-61 cooled).
Alan Wake, cooled starts, fan at max, 60 s each (screen counter, six 10 s samples):
| Build | screen fps | game overlay | proot CPU | wineserver CPU |
|---|---|---|---|---|
| `main` (kompat fix, old defaults) | 56.9-58.6 (avg 57.7) | 39 | ~39% | ~30% |
| new defaults | 58.6-61.5 (avg 59.6) | 38-39 | ~27% | ~34% |
| new defaults, sysmem off | 59.3-62.9 (avg 61.1) | 38 | - | - |
Read: the defaults are worth at most a few percent here and inside the run-to-run noise (about +-1.5 fps). The
game's own frame rate stayed at 38-40 with CPU ~65% and GPU ~56-60%, so this scene is bound by something none
of these settings touch.
## Open leads, biggest first
1. **Wine synchronisation.** Proton 11 dropped esync; fsync needs `futex_waitv`, which Android's app sandbox
blocks even on a 6.12 kernel; ntsync is not in Android kernels. Every lock and event is a pipe round trip to
wineserver (30-34% CPU in Alan Wake, 18% of the main thread blocked in `read()` in #65). Winlator-family apps
run esync, which needs only `eventfd` and `poll`. Restoring esync in the ARM64 Proton is the largest
likely win and the largest piece of work; emulating `futex_waitv` in proot puts every wait back through the
tracer and is probably a dead end.
2. **The game's main thread under FEX.** Alan Wake's process uses 139-178% CPU with neither the CPU nor the GPU
saturated overall. FEX settings beyond the presets (SMC checks, multiblock, TSO variants) are worth a sweep
on a game whose main thread is the limit.
3. **#65's other 0.1.5 -> 0.2.0 differences, untested:** the fake `power_supply` tree, `BL_HDR`/`DXVK_HDR`, the
output width (1280 -> the panel's shape), `FEX_TSOENABLED` (1 -> 0), the tracer binary. Their Silksong setup
is sync-heavy and is the best test of the kompat fix.
4. **gamescope** calls `XQueryPointer` on every vblank in the Steam UI (patch `0100`); query only when a
gamepad cursor move is pending. Needs a new pinned gamescope release.
5. **Heavy games on a fresh install.** Metal Gear Solid V: Ground Zeroes sat on Steam's launch splash for 5+ minutes
with wineserver at ~50% CPU and the GPU at 12% (no window ever appeared); Bulletstorm took ~4.5 minutes to reach
its first logo, the game process at 45-105% CPU and the GPU at 9%, and the session ended a minute later. Neither
reached a scene worth measuring. Both are first launches (redistributable install scripts, shader work), and a
wineserver spinning at 50% while a game shows nothing is itself the server-side synchronisation cost of lead 1.
6. **Reports not yet reproduced:** software rendering on Adreno 8xx ("all renders on CPU"), Deck mode using
most of the RAM, Samsung SSRM clamping (#64), two DroidDeck packages both holding a session.
7. **Smaller:** proot tracer pinned to a big core (Max's note: no core wake-up lag), `steamrtarm64` on Proton's
`LD_LIBRARY_PATH`, the always-on wake and Wi-Fi locks, ADPF hints fed frame intervals instead of work time.
+1
View File
@@ -4,6 +4,7 @@ Running engineering log for the DroidDeck app (`com.droiddeck.launcher`; called
`com.steamdeck.launcher`, until 2026-09-23 - entries below that date keep the old name). Newest state first, then
the timeline, then lessons and backlog. Companion to the README (what the app *does*) and to
`docs/releases/` (what each version said) - this is *how it got here and where it stands*.
Performance work has its own write-up: [performance.md](performance.md).
---
+57 -16
View File
@@ -89,7 +89,17 @@ linuxfs_replaced=1
rm -rf -- "${linuxfs_dir}"
mkdir -p "${linuxfs_dir}"
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
# Docker Desktop's VM restarts now and then, and until its engine has loaded its image store it
# answers "No such image" for images it has. The rebuild that follows hangs on the registry
# (the base image's credentials go through docker-credential-desktop), so give a restarting daemon
# up to a minute before deciding the image is really missing.
image_present=0
for _ in $(seq 1 30); do
if inspect_error=$(docker image inspect "${image_name}" 2>&1 >/dev/null); then image_present=1; break; fi
sleep 2
done
if [[ "${image_present}" = 0 ]]; then
echo "Build image ${image_name} not found (${inspect_error:-no error}); building it." >&2
docker build --platform linux/amd64 -t "${image_name}" \
-f "${repo_root}/tools/local-cross.Dockerfile" "${repo_root}"
fi
@@ -164,20 +174,39 @@ if [[ -z "${github_repo}" ]]; then
github_repo=${github_repo%.git}
fi
# Pinned downloads are kept between builds, named by their checksum, so a rebuild fetches nothing
# it already has. A cached file is checked again before use; a bad one is fetched anew.
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
mkdir -p "${cache_dir}"
# cached <sha256> <name> <command...>: prints the cached path, running the command (which writes
# to "$out") only when the cache has no file with that checksum.
cached() {
local sha=$1 name=$2 out
shift 2
out="${cache_dir}/${sha}-${name}"
if [[ -f "${out}" ]] && printf '%s %s\n' "${sha}" "${out}" | shasum -a 256 -c - >/dev/null 2>&1; then
echo "${out}"
return 0
fi
rm -f "${out}.part"
out="${out}.part" "$@" >&2
printf '%s %s\n' "${sha}" "${out}.part" | shasum -a 256 -c - >&2
mv "${out}.part" "${out}"
echo "${out}"
}
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
. "${repo_root}/tools/gamescope/release.env"
gamescope_archive="${staging_dir}/gamescope.tzst"
gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}"
printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c -
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}")
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
fi
if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then
. "${repo_root}/tools/wlroots/release.env"
wlroots_archive="${staging_dir}/wlroots.tzst"
gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}"
printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c -
wlroots_archive=$(cached "${WLROOTS_SHA256}" wlroots.tzst \
bash -c 'gh release download "$0" -R "$1" -p wlroots.tzst -O "$out"' "${WLROOTS_TAG}" "${github_repo}")
zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}"
test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
fi
@@ -187,9 +216,8 @@ mango_pkgs="${staging_dir}/mango-pkgs"
mkdir -p "${mango_dir}" "${mango_pkgs}"
while read -r package_sha256 package_url; do
[[ -n "${package_url}" ]] || continue
package_archive="${staging_dir}/$(basename "${package_url}")"
curl -fsSL --retry 3 -o "${package_archive}" "${package_url}"
printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c -
package_archive=$(cached "${package_sha256}" "$(basename "${package_url}")" \
bash -c 'curl -fsSL --retry 3 -o "$out" "$0"' "${package_url}")
zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}"
done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt")
install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp"
@@ -201,11 +229,14 @@ install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/b
pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then
curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
pa_tarball="${cache_dir}/pulseaudio-13.0.tar.gz"
if [[ ! -s "${pa_tarball}" ]] || ! tar -tzf "${pa_tarball}" >/dev/null 2>&1; then
curl -fsSL -o "${pa_tarball}.part" \
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
mv "${pa_tarball}.part" "${pa_tarball}"
fi
mkdir -p "${pa_source}"
tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \
-C "${pa_source}" --strip-components=1
tar -xzf "${pa_tarball}" -C "${pa_source}" --strip-components=1
fi
if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
echo "PulseAudio 13.0 source not found at ${pa_source}; set DROIDDECK_PA13_SOURCE_DIR." >&2
@@ -214,7 +245,17 @@ fi
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
# proot is rebuilt only when its sources (source.env, the patches, the build script) changed since
# the libraries in jniLibs were built.
proot_out="${repo_root}/app/src/main/jniLibs/arm64-v8a"
proot_inputs=$(cd "${repo_root}/tools/proot" && find . -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -d' ' -f1)
if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \
&& "$(cat "${proot_out}/.proot-inputs" 2>/dev/null)" = "${proot_inputs}" ]]; then
echo "proot: sources unchanged, keeping ${proot_out}/libproot.so"
else
"${repo_root}/tools/proot/build.sh" "${proot_out}"
echo "${proot_inputs}" > "${proot_out}/.proot-inputs"
fi
cp -p "${bundle_asset}" "${bundle_backup}"
bundle_dir="${staging_dir}/pulseaudio-bundle"
@@ -229,7 +270,7 @@ install -m755 "${sink_output}/module-aaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-aaudio-sink.so"
install -m755 "${sink_output}/module-directaudio-sink.so" \
"${bundle_dir}/modules/arm64/module-directaudio-sink.so"
tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst"
tar -cf - -C "${bundle_dir}" . | zstd -19 -T0 -c > "${staging_dir}/pulseaudio.tzst"
bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
@@ -75,8 +75,9 @@ if [ -n "${BL_VK_DRIVER:-}" ]; then
fi
# The app's gamescope build (tools/gamescope) takes realtime-priority Vulkan queues on this
# switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority
# where the driver refuses, and the runtime's own build ignores it.
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1}
# where the driver refuses, and the runtime's own build ignores it. Off unless the app's
# Performance page turns it on (BL_GAMESCOPE_REALTIME=1).
export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0}
export XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config}
export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache}
@@ -371,11 +372,18 @@ case $mode in
# ~/.droiddeck-fill, and each change goes to gamescope as GAMESCOPE_FORCE_WINDOWS_FULLSCREEN on
# the root window, which it watches. What gamescope was started with is where this begins.
# Not on the desktop (BL_DESKTOP): no gamescope there, and the loop would outlive the client.
# The loops below poll for the whole session, and every sleep, cat or script they start is a
# fork and exec under proot. They wait with a read timeout on a FIFO nothing writes to and
# read files with builtins, so an idle beat costs no process at all.
_bl_fifo=$(mktemp -u "${TMPDIR:-/tmp}/bl-nap.XXXXXX") && mkfifo "$_bl_fifo" \
&& exec {_bl_nap}<>"$_bl_fifo" && rm -f "$_bl_fifo"
nap() { if [ -n "${_bl_nap:-}" ]; then read -r -t "$1" -u "$_bl_nap" _ || true; else sleep "$1"; fi; }
[ -z "${BL_DESKTOP:-}" ] && (
last=${BL_GAMESCOPE_FORCE_FULLSCREEN:-1}
while :; do
sleep 1
want=$(cat "$HOME/.droiddeck-fill" 2>/dev/null) || continue
nap 1
want=
read -r want <"$HOME/.droiddeck-fill" 2>/dev/null || [ -n "$want" ] || continue
case $want in 0|1) ;; *) continue ;; esac
[ "$want" = "$last" ] && continue
if xprop -root -f GAMESCOPE_FORCE_WINDOWS_FULLSCREEN 32c -set GAMESCOPE_FORCE_WINDOWS_FULLSCREEN "$want" 2>/dev/null; then
@@ -384,14 +392,37 @@ case $mode in
fi
done
) &
# Each beat only starts a script when there is something for it: the seeder when a prefix has
# a registry it has not marked, the registrar (Python, the heavier start) when a library, the
# tools directory or the client's config changed since its last run.
(
compat_stamp=$HOME/.cache/droiddeck-compat-stamp
needs_seed() {
local r
for r in "$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/bannerlator-sd/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$r" ] && [ ! -e "${r%/system.reg}/.bannerlator-redists" ] && return 0
done
return 1
}
needs_compat() {
local d
[ -e "$compat_stamp" ] || return 0
for d in "$steam_root/steamapps" "$steam_root/compatibilitytools.d" "$steam_root/config" \
/mnt/bannerlator-sd/steamapps; do
[[ -e $d && $d -nt $compat_stamp ]] && return 0
done
return 1
}
beat=0
for _ in $(seq 1 720); do
sleep 5
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
while [ "$beat" -lt 720 ]; do
nap 5
needs_seed && { bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true; }
beat=$((beat + 1))
if [ "$((beat % 3))" -eq 0 ]; then
if [ "$((beat % 3))" -eq 0 ] && needs_compat; then
: >"$compat_stamp"
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
: >"$compat_stamp"
fi
done
) &
@@ -558,7 +589,7 @@ BUSCONF
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
sleep 0.5
nap 0.5
done
) &
stop_watcher=$!