From 4914b2cc6920df931f2f3c70fac11e4a4a635241 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:33:33 -0400 Subject: [PATCH 1/6] perf: stop proot's fake_id0 and ioctl traps from stopping every stat, sendmsg and GPU ioctl fake_id0 (loaded by -i for Xwayland's set*id) now traces only the set*id family and the xattr fixups when the ids given are proot's own, and the seccomp filter traces ioctl only for the termios and FICLONE requests proot rewrites. The faccessat2 and statx exit stops are dropped on kernels that have the calls. --- tools/proot/PATCHES.md | 15 ++ .../patches/0012-fake_id0-identity-only.patch | 103 +++++++++++ ...ctl-by-request-and-kernel-exit-stops.patch | 169 ++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 tools/proot/patches/0012-fake_id0-identity-only.patch create mode 100644 tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index 503e4fbed..f2dc70854 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -55,3 +55,18 @@ Added by DroidDeck: and `setdomainname` and leaves the auxv as the kernel wrote it. On an SD 8 Gen 2 guest this took a futex ping-pong from 467 to 97 us, `epoll_pwait` from 60 to 0.8 us and `fcntl` from 40-107 to 0.4 us, and `clock_gettime` back to the vDSO. +- `0012-fake_id0-identity-only.patch` - `-i uid:gid` (for Xwayland's setgid/setuid before it runs + xkbcomp) loads fake_id0, whose filter traps every `fstat`/`newfstatat`/`stat` (entry and exit), + every `sendmsg` (all Wayland, X11, Chromium and PulseAudio traffic), `socket`, `getsockopt`, the + `get*id` family and the chown/chmod family. When the ids given are the ones proot really has and + are not 0, every one of those handlers is a no-op; fake_id0 now traces only the `set*id` family + and the xattr permission fixups, and leaves set-user-ID bits alone on `execve` (Android mounts the + app's data `nosuid`, so the kernel would not honour them either), which keeps the ids fixed. On an + x86_64 host build under the same options: `fstat` 41.7 -> 1.3 us, `sendmsg` 18.0 -> 2.5 us. +- `0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch` - every `ioctl` stopped on entry and + exit, which is every GPU submit and wait. The filter now traces only the requests enter.c and + exit.c act on (`TCSETSF`, the four termios2 requests, `FICLONE`) and allows the rest at once, and + the ioctl block is emitted first (after WinNative 53836ca9, which traces only the termios2 ones). + The `faccessat2` exit stop (termux 5ba8b95, for glibc's ENOSYS fallback on kernels before 5.8) and + the `statx` one (emulation on kernels before 4.11) are dropped when the running kernel is newer. + Host build: `ioctl` 31.6 -> 0.5 us; `stat`/`statx`/`faccessat2` about 44 -> 28 us (one stop). diff --git a/tools/proot/patches/0012-fake_id0-identity-only.patch b/tools/proot/patches/0012-fake_id0-identity-only.patch new file mode 100644 index 000000000..155b62b8e --- /dev/null +++ b/tools/proot/patches/0012-fake_id0-identity-only.patch @@ -0,0 +1,103 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 00:00:00 +0000 +Subject: [PATCH] fake_id0: only trace the set*id and xattr calls when the ids + are PRoot's own + +--- + src/extension/fake_id0/config.h | 5 ++++ + src/extension/fake_id0/fake_id0.c | 44 ++++++++++++++++++++++++++++++- + 2 files changed, 48 insertions(+), 1 deletion(-) + +diff --git a/src/extension/fake_id0/config.h b/src/extension/fake_id0/config.h +index 2e78783..5227ced 100644 +--- a/src/extension/fake_id0/config.h ++++ b/src/extension/fake_id0/config.h +@@ -2,6 +2,7 @@ + #define FAKE_ID0_CONFIG_H + + #include /* uid_t, gid_t */ ++#include /* bool, */ + + typedef struct { + uid_t ruid; +@@ -15,6 +16,10 @@ typedef struct { + gid_t fsgid; + + mode_t umask; ++ ++ /* The emulated ids are the ones PRoot really has: see ++ * identity_sysnums in fake_id0.c. */ ++ bool identity; + } Config; + + #endif /* FAKE_ID0_CONFIG_H */ +diff --git a/src/extension/fake_id0/fake_id0.c b/src/extension/fake_id0/fake_id0.c +index 9477f1c..fe08372 100644 +--- a/src/extension/fake_id0/fake_id0.c ++++ b/src/extension/fake_id0/fake_id0.c +@@ -352,6 +352,40 @@ static FilteredSysnum filtered_sysnums[] = { + FILTERED_SYSNUM_END, + }; + ++/* When the emulated ids are the ones PRoot really has, and the tracee ++ * is not root, every handler for the syscalls above but these is a ++ * no-op: stat and get*id report the real ids already, SCM_CREDENTIALS ++ * and SO_PEERCRED carry them, chown swaps an id for itself, and the ++ * permission and chroot fixups only apply to euid 0. The set*id ++ * family is still emulated (Android's app seccomp policy traps it) and ++ * so are the xattr permission fixups, which apply to any euid. An ++ * unprivileged set*id can only take an id the tracee already holds, ++ * and set-user-ID bits are not honoured in this mode (Android mounts ++ * the app's data nosuid, so the kernel would not honour them either), ++ * so the ids never change and the list stays sufficient. */ ++static FilteredSysnum identity_sysnums[] = { ++ { PR_fsetxattr, FILTER_SYSEXIT }, ++ { PR_lsetxattr, FILTER_SYSEXIT }, ++ { PR_setfsgid, FILTER_SYSEXIT }, ++ { PR_setfsgid32, FILTER_SYSEXIT }, ++ { PR_setfsuid, FILTER_SYSEXIT }, ++ { PR_setfsuid32, FILTER_SYSEXIT }, ++ { PR_setgid, FILTER_SYSEXIT }, ++ { PR_setgid32, FILTER_SYSEXIT }, ++ { PR_setregid, FILTER_SYSEXIT }, ++ { PR_setregid32, FILTER_SYSEXIT }, ++ { PR_setresgid, FILTER_SYSEXIT }, ++ { PR_setresgid32, FILTER_SYSEXIT }, ++ { PR_setresuid, FILTER_SYSEXIT }, ++ { PR_setresuid32, FILTER_SYSEXIT }, ++ { PR_setreuid, FILTER_SYSEXIT }, ++ { PR_setreuid32, FILTER_SYSEXIT }, ++ { PR_setuid, FILTER_SYSEXIT }, ++ { PR_setuid32, FILTER_SYSEXIT }, ++ { PR_setxattr, FILTER_SYSEXIT }, ++ FILTERED_SYSNUM_END, ++}; ++ + /** + * Restore the @node->mode for the given @node->path. + * +@@ -1051,6 +1085,11 @@ static int handle_sysexit_start(Tracee *tracee, Config *config) { + if ((int) result < 0 || tracee->status < 0 || sysnum != PR_execve) + return 0; + ++ /* The auxv already carries the real ids, and set-user-ID bits ++ * are not honoured in this mode: see identity_sysnums. */ ++ if (config->identity) ++ return 0; ++ + /* This has to be done before PRoot pushes the load + * script into tracee's stack. */ + if (!tracee->skip_proot_loader) +@@ -1119,7 +1158,10 @@ int fake_id0_callback(Extension *extension, ExtensionEvent event, intptr_t data1 + /* Set the umask to the typical linux value. */ + config->umask = 022; + +- extension->filtered_sysnums = filtered_sysnums; ++ config->identity = (uid != 0 && uid == (int) getuid() && gid == (int) getgid()); ++ extension->filtered_sysnums = config->identity ++ ? identity_sysnums ++ : filtered_sysnums; + return 0; + } + diff --git a/tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch b/tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch new file mode 100644 index 000000000..eaf62c565 --- /dev/null +++ b/tools/proot/patches/0013-seccomp-ioctl-by-request-and-kernel-exit-stops.patch @@ -0,0 +1,169 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: DroidDeck +Date: Tue, 29 Sep 2026 00:00:00 +0000 +Subject: [PATCH] seccomp: trace ioctl by request, drop exit stops the kernel + does not need + +--- + src/syscall/seccomp.c | 98 ++++++++++++++++++++++++++++++++++++++++++- + 1 file changed, 97 insertions(+), 1 deletion(-) + +diff --git a/src/syscall/seccomp.c b/src/syscall/seccomp.c +index 0b381bb..1116ab2 100644 +--- a/src/syscall/seccomp.c ++++ b/src/syscall/seccomp.c +@@ -39,6 +39,11 @@ + #include /* uint*_t, UINT*_MAX, */ + #include + #include ++#include /* uname(2), */ ++#include /* sscanf(3), */ ++#include /* TCGETS2, TCSETS*2, */ ++#include /* struct termios2, */ ++#include /* FICLONE, */ + #include /* assert(3), */ + + #include "syscall/seccomp.h" +@@ -166,6 +171,46 @@ static int add_trace_prlimit_stack(struct sock_fprog *program, word_t syscall, i + return add_statements(program, LENGTH_TRACE_PRLIMIT_STACK, statements); + } + ++/** ++ * Append to @program->filter the statements that trace @syscall ++ * (ioctl) only for the requests enter.c and exit.c act on - the ++ * termios requests Android's policy denies an app on a pty, rewritten ++ * to ones it allows, and FICLONE, whose EACCES is turned into ++ * EOPNOTSUPP - and allow any other request at once. Every GPU submit ++ * and wait is an ioctl, so they no longer stop in the tracer, and the ++ * block is emitted first so they are also spared the rest of the list. ++ * (After WinNative, maxjivi05, 53836ca9.) ++ */ ++static int add_trace_ioctl_requests(struct sock_fprog *program, word_t syscall, int flag) ++{ ++ const size_t request_offset = offsetof(struct seccomp_data, args[1]); ++ ++ #define LENGTH_TRACE_IOCTL 10 ++ struct sock_filter statements[LENGTH_TRACE_IOCTL] = { ++ /* Not this syscall: skip the block, the accumulator ++ * still holds the syscall number. */ ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, syscall, 0, 9), ++ ++ /* Load the request's low word into the accumulator. */ ++ BPF_STMT(BPF_LD + BPF_W + BPF_ABS, request_offset), ++ ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSF, 5, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCGETS2, 4, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETS2, 3, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSW2, 2, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, TCSETSF2, 1, 0), ++ BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, (uint32_t) FICLONE, 0, 1), ++ ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_TRACE + flag), ++ BPF_STMT(BPF_RET + BPF_K, SECCOMP_RET_ALLOW) ++ }; ++ ++ DEBUG_FILTER("FILTER: trace if syscall == %ld and a termios2/TCSETSF/FICLONE request\n", ++ syscall); ++ ++ return add_statements(program, LENGTH_TRACE_IOCTL, statements); ++} ++ + static size_t trace_length(Sysnum sysnum) + { + switch (sysnum) { +@@ -174,6 +219,8 @@ static size_t trace_length(Sysnum sysnum) + return LENGTH_TRACE_SYSCALL_IF_ARG; + case PR_prlimit64: + return LENGTH_TRACE_PRLIMIT_STACK; ++ case PR_ioctl: ++ return LENGTH_TRACE_IOCTL; + default: + return LENGTH_TRACE_SYSCALL; + } +@@ -191,6 +238,8 @@ static int add_trace(struct sock_fprog *program, Sysnum sysnum, word_t syscall, + return add_trace_syscall_if_arg(program, syscall, 0, RLIMIT_STACK, flag); + case PR_prlimit64: + return add_trace_prlimit_stack(program, syscall, flag); ++ case PR_ioctl: ++ return add_trace_ioctl_requests(program, syscall, flag); + default: + return add_trace_syscall(program, syscall, flag); + } +@@ -329,7 +378,7 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + + struct sock_fprog program = { .len = 0, .filter = NULL }; + size_t section_length; +- size_t i, j, k; ++ size_t i, j, k, pass; + int status; + + status = new_program_filter(&program); +@@ -355,8 +404,14 @@ static int set_seccomp_filters(const FilteredSysnum *sysnums) + if (status < 0) + goto end; + ++ /* Two passes: ioctl first, it is by far the most frequent ++ * syscall and almost every one of them is allowed. */ ++ for (pass = 0; pass < 2; pass++) + for (j = 0; j < seccomp_archs[i].nb_abis; j++) { + for (k = 0; sysnums[k].value != PR_void; k++) { ++ if ((sysnums[k].value == PR_ioctl) != (pass == 0)) ++ continue; ++ + /* Get the architecture specific syscall number. */ + syscall = detranslate_sysnum(seccomp_archs[i].abis[j], sysnums[k].value); + if (syscall == SYSCALL_AVOIDER) +@@ -572,12 +627,53 @@ int enable_syscall_filtering(const Tracee *tracee) + return 0; + } + ++/** ++ * Drop the exit stops PRoot only needs on kernels older than the one it ++ * runs on. The faccessat2 exit stop restores the arguments for glibc's ++ * fallback to faccessat on ENOSYS (kernels before 5.8, termux 5ba8b95); ++ * the statx one emulates the call when it fails, which only a kernel ++ * before 4.11 does for want of it - a statx Android's policy traps is ++ * emulated from SIGSYS instead. Both stops re-read and re-translate ++ * the path, and are paid on every call. ++ */ ++static void drop_unneeded_exit_stops(void) ++{ ++ static bool done = false; ++ struct utsname utsname; ++ int major, minor; ++ size_t i; ++ ++ if (done) ++ return; ++ done = true; ++ ++ if (uname(&utsname) < 0 || sscanf(utsname.release, "%d.%d", &major, &minor) != 2) ++ return; ++ ++ for (i = 0; proot_sysnums[i].value != PR_void; i++) { ++ switch (proot_sysnums[i].value) { ++ case PR_faccessat2: ++ if (major > 5 || (major == 5 && minor >= 8)) ++ proot_sysnums[i].flags &= ~FILTER_SYSEXIT; ++ break; ++ case PR_statx: ++ if (major > 4 || (major == 4 && minor >= 11)) ++ proot_sysnums[i].flags &= ~FILTER_SYSEXIT; ++ break; ++ default: ++ break; ++ } ++ } ++} ++ + static int collect_filtered_sysnums(TALLOC_CTX *context, const Tracee *tracee, + FilteredSysnum **filtered_sysnums) + { + Extension *extension; + int status; + ++ drop_unneeded_exit_stops(); ++ + /* Add the sysnums required by PRoot to the list of filtered + * sysnums. TODO: only if path translation is required. */ + status = merge_filtered_sysnums(context, filtered_sysnums, proot_sysnums); From e217cbe66dbef26f93fa87059544d1d27d6a365d Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 2/6] perf: pace buffer releases to the screen, read the requested refresh, drop sustained mode and ADPF A replaced buffer went straight back to a game with no frame limit, so a MAILBOX/IMMEDIATE swapchain drew frames that were never shown; it now comes back once per refresh as in WinNative. gamescope's -r and the compositor read the rate of the mode the activity asked for rather than the one the panel was still in, and the zero-copy layer votes it. Sustained performance mode, which caps clocks on most power HALs, and the ADPF session, which reported frame intervals as work, are gone. --- app/src/main/cpp/waylandcomp/src/compositor.c | 13 ++- .../com/droiddeck/launcher/SessionActivity.kt | 17 ++-- .../droiddeck/launcher/session/PerfHints.kt | 87 ------------------- .../droiddeck/launcher/session/PerfMode.kt | 17 ++-- .../launcher/wayland/CompositorHost.kt | 4 + .../launcher/wayland/WaylandCompositor.java | 3 +- 6 files changed, 30 insertions(+), 111 deletions(-) delete mode 100644 app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index d2f5f0595..8b2509a68 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -435,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) { * delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier * limit had spaced out, and the game's first frames after that waited on empty slots. */ static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) { - int limit = g_fps_limit; - if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; } - int64_t interval = 1000000000LL / limit, now = now_ns(); + const int limit = g_fps_limit; + /* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for + * frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced + * buffer straight back let it draw as fast as the GPU allowed and everything above the refresh + * rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05). + * One release per refresh is the back-pressure X11 gives, and the screen shows the same frames + * either way. */ + const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns; + if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; } + int64_t now = now_ns(); int64_t at = s->next_release_ns + interval; int64_t latest = now + interval * (int64_t)(s->releases_pending + 1); if (at < now) at = now; diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 82d9f86e6..71f52be82 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -47,7 +47,6 @@ import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.input.TouchpadGestures import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.LoadingState -import com.droiddeck.launcher.session.PerfHints import com.droiddeck.launcher.session.PerfHud import com.droiddeck.launcher.session.PerfMode import com.droiddeck.launcher.session.SessionPrefs @@ -203,8 +202,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode)) } window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - // Game-tier power policy for the whole session: a sustained clock floor, the panel's - // fastest mode, and the OS told it is in gameplay. Logged so a slow device says why. + // Game-tier power policy for the whole session: the panel's fastest mode, and the OS told + // it is in gameplay. Logged so a slow device says why. Log.i(TAG, "perf: " + PerfMode.apply(this)) goFullscreen() // The device's volume keys change the stream the session plays on (the relay and @@ -761,9 +760,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), ) if (!SessionState.running) SessionEvents.record("compositor.started") - // ADPF: the compositor thread's frame intervals go to the power HAL against the panel's - // period, so a long frame raises CPU clocks now rather than after the load averages up. - PerfHints.arm(this, refreshHz()) // The service owns everything below the compositor. It is started whenever no session is // running - NOT only when the compositor was just started: the compositor lives for the // whole process, so the second Play after a session ended used to re-attach the Surface, @@ -849,9 +845,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return Pair(width and 1.inv(), height and 1.inv()) } + /** + * The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands + * some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel - + * the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.) + */ private fun refreshHz(): Float { val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay - val hz = display?.refreshRate ?: 60f + val wanted = window.attributes.preferredDisplayModeId + val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate + ?: display?.refreshRate ?: 60f return if (hz > 1f) hz else 60f } diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt deleted file mode 100644 index da651314e..000000000 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfHints.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.droiddeck.launcher.session - -import android.content.Context -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.PerformanceHintManager -import android.util.Log -import androidx.annotation.RequiresApi -import com.droiddeck.launcher.wayland.WaylandCompositor -import java.util.concurrent.Executors - -/** - * ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the - * panel's frame period as its target and each presented frame's interval as the actual. When a - * frame runs long the power HAL raises CPU clocks for the next one at once, instead of the - * governor waiting for the load average to climb - the difference between a menu that stutters - * on the first scroll and one that does not. - * - * The session may only carry this process's threads, so the guest (gamescope, the Steam client - * under FEX) cannot join it; they are covered by the app-wide game category and sustained mode. - * Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns - * null and the log says so. - */ -object PerfHints { - private const val TAG = "PerfHints" - - @Volatile private var session: PerformanceHintManager.Session? = null - @Volatile private var target = 0L - /** Compositor thread only. */ - private var last = 0L - private val main = Handler(Looper.getMainLooper()) - /** Reports leave the compositor thread: each one is a binder call to the power HAL. */ - private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } } - - /** Opens the session once the compositor thread exists (it is started just before this). */ - fun arm(context: Context, refreshHz: Float) { - if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return } - val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong() - val s = session - if (s != null) { - if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } } - return - } - target = wanted - val app = context.applicationContext - var tries = 0 - main.post(object : Runnable { - override fun run() { - if (session != null) return - val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 } - if (tid == 0) { - if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid") - return - } - open(app, tid) - } - }) - } - - @RequiresApi(31) - private fun open(app: Context, tid: Int) { - val pm = app.getSystemService(PerformanceHintManager::class.java) - if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return } - val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null } - if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return } - last = 0L - session = s - Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs") - } - - /** Compositor thread, once per presented game frame; the interval since the last is the actual. */ - @JvmStatic - fun onFrame() { - val s = session ?: return - val now = System.nanoTime() - val prev = last - last = now - if (prev == 0L) return - val dt = now - prev - // Under half a millisecond is a double present; over half a second is a pause, not a frame. - if (dt < 500_000L || dt > 500_000_000L) return - worker.execute { - try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null } - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt index 16d142560..7e99840d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.app.GameManager import android.app.GameState import android.os.Build -import android.os.PowerManager import android.util.Log /** @@ -13,8 +12,10 @@ 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; + * - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at + * a level the device can hold indefinitely rather than holding a floor, which cost games and + * Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to + * the game; * - 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 @@ -27,15 +28,7 @@ object PerfMode { private const val TAG = "PerfMode" fun apply(a: Activity): String { - val parts = ArrayList(3) - - // Sustained performance mode. - parts += 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" } - } else "sustained mode unsupported" - } else "sustained mode needs Android 7" + val parts = ArrayList(2) // The fastest display mode at the panel's current size. parts += try { diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index 8f72523d9..973332542 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -41,6 +41,10 @@ object CompositorHost { } WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight) WaylandCompositor.nativeSetOutputRefreshRate(refreshHz) + // The cadence the zero-copy layer votes for. Left at 0 the system infers it from the rate + // already being achieved, and on a phone whose vendor picks clocks from that a session + // that has been slowed reads as one that wants to be. (WinNative, WaylandSession.) + WaylandCompositor.nativeSetLayerFrameRate(refreshHz) WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, ) diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index dd29b6ad1..048ff2ef1 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -91,7 +91,6 @@ public final class WaylandCompositor { long smoothed = frameIntervalNanos; frameIntervalNanos = smoothed == 0 ? interval : (smoothed * 3 + interval) / 4; } - com.droiddeck.launcher.session.PerfHints.onFrame(); GameListener l = gameListener; if (l != null) l.onGameFrame(); } @@ -218,7 +217,7 @@ public final class WaylandCompositor { * once per tick, so games run unthrottled and the screen shows their latest frame. */ public static native void nativeVsync(long frameTimeNanos); - /** The compositor thread's Linux tid, or 0 before it has started (see PerfHints). */ + /** The compositor thread's Linux tid, or 0 before it has started. */ public static native int nativeCompositorTid(); /** Shortcut launches: don't draw explorer's windows (desktop, taskbar, Start menu), matching the From caa7682c6f136cc21a23e74ad4d8630f388b77c6 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 3/6] perf: default to Turnip sysmem, compact Zink descriptors and the Performance + TSO FEX preset WinNative's defaults for every Linux session. Choices already saved by a user are kept. --- .../launcher/session/SessionPrefs.kt | 25 +++++++++++++++---- .../launcher/session/SessionService.kt | 6 ++++- .../launcher/ui/PerformanceDialog.kt | 4 +-- .../session/GameEnvironmentStoreTest.kt | 9 +++++++ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index ec0d09559..820c6e401 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -227,8 +227,13 @@ object SessionPrefs { return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) } } - /** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */ - fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false) + /** + * Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. + * On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the + * client's interface as many small render passes, each paying GMEM's load/store and binning, + * and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3). + */ + fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true) fun setTuSysmem(context: Context, on: Boolean) { prefs(context).edit().putBoolean("tuSysmem", on).apply() @@ -252,7 +257,10 @@ object SessionPrefs { fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false) fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() } - /** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */ + /** + * Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout + * (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default. + */ fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true) fun setZinkLazy(context: Context, on: Boolean) { @@ -309,8 +317,15 @@ 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 defaults. + * Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO + * and full-precision x87, which cost every x86 game time; a game that needs them can still be + * given another preset. + */ + fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET + + private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO" fun setFexPreset(context: Context, id: String) { prefs(context).edit().putString("fexPreset", id).apply() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 590dd834e..913328d5b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -513,7 +513,11 @@ class SessionService : Service() { // Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is // the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships // by default for its Zink path; a switch here because on one Fold the menus run at 14 fps. - if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy") + // compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default). + if (SessionPrefs.zinkLazy(this)) { + guest.add("ZINK_DESCRIPTORS=lazy") + guest.add("ZINK_DEBUG=compact") + } // The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling // thread, no GL error checks, and the client run as SteamOS runs it (the script reads // BL_STEAMDECK; it is the one that builds the command line). diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index 7756cd72f..16f7a0e50 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -86,7 +86,7 @@ fun PerformancePage( ) ToggleRow( host, "zink", "Zink: lazy descriptors", - "Recommended for drivers without descriptor buffers.", + "Lazy, compact descriptors. Recommended for drivers without descriptor buffers.", zinkLazy, onChange = onZinkLazy, ) ToggleRow( @@ -98,7 +98,7 @@ fun PerformancePage( SettingsGroup("Session fixes") { ToggleRow( host, "sysmem", "Turnip: sysmem rendering", - "Required on Adreno 710/720/722. May fix corruption on other Adreno GPUs, but can reduce performance.", + "On by default: faster for the Steam interface and most games. Required on Adreno 710/720/722.", tuSysmem, onChange = onTuSysmem, ) ToggleRow( diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt index 1fb648e73..604c9456a 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -49,6 +49,15 @@ class GameEnvironmentStoreTest { assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS")) } + @Test fun unchosenFexPresetPublishesPerformanceTso() { + GameEnvironmentStore.save(context, GameEnvironment.Config()) + val shared = JSONObject(guest.readText()).getJSONObject("shared") + assertEquals("1", shared.getString("FEX_TSOENABLED")) + assertEquals("0", shared.getString("FEX_HALFBARRIERTSOENABLED")) + assertEquals("1", shared.getString("FEX_X87REDUCEDPRECISION")) + assertEquals("1", shared.getString("FEX_MULTIBLOCK")) + } + @Test fun invalidDataCannotReplaceSavedConfiguration() { val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok")) GameEnvironmentStore.save(context, original) From a3f4c911e57ea7f038499b7a318016588bee456c Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 4/6] perf: poll without starting processes, and stop rewriting unchanged Steam files The session's watchers slept with sleep(1) and read files with cat, an exec each under proot; they now wait in bash itself. steam-compat writes its tool, the adopted wrappers and config.vdf only when they change, the games watcher probes ownership when its matches change or every five minutes instead of every 30 s, and Proton no longer inherits the client's steamrtarm64 library path. --- .../overlay/usr/local/bin/bannerlator-session | 25 ++++-- .../usr/local/bin/bannerlator-steam-compat | 87 +++++++++++++------ .../usr/local/bin/bannerlator-steam-games | 12 ++- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 68b10d670..de4c6918b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -73,10 +73,20 @@ if [ -n "${BL_VK_DRIVER:-}" ]; then fi unset bl_icd_lib fi -# The app's gamescope build (tools/gamescope) takes realtime-priority Vulkan queues on this +# The app's gamescope build (tools/gamescope) asks for 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. +# where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it +# changes nothing yet: the driver reports one submit-queue priority and creates every KGSL context +# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it. export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1} + +# A sleep that starts no process. Under proot every exec is a traced execve plus the loader's +# opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60 +# execs a minute each. read -t on a pipe nothing ever writes to waits in bash itself. +nap() { + [ -n "${bl_nap_fd:-}" ] || exec {bl_nap_fd}<> <(:) + read -r -t "$1" -u "$bl_nap_fd" _ 2>/dev/null || : +} 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} @@ -266,7 +276,7 @@ case $mode in for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true done - sleep 5 + nap 5 done ) & fi @@ -374,8 +384,9 @@ case $mode in [ -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 || [ -n "$want" ]; } < "$HOME/.droiddeck-fill" 2>/dev/null || 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 @@ -387,7 +398,7 @@ case $mode in ( beat=0 for _ in $(seq 1 720); do - sleep 5 + nap 5 bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true beat=$((beat + 1)) if [ "$((beat % 3))" -eq 0 ]; then @@ -558,7 +569,7 @@ BUSCONF "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true exit 0 fi - sleep 0.5 + nap 0.5 done ) & stop_watcher=$! diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 7040f2dde..28bb1f89d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -146,6 +146,26 @@ bl_game_cpus() { # game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these # titles are told to stay windowed at the session's own size - unless the user's launch options # already say something about the video mode, which are left as they are. +# The session puts the client's own libraries (steamrtarm64, its panorama/) ahead of everything for +# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against +# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime +# container that path never reaches Proton either. (WinNative's winnative-proton-launch.) +BL_LIBPATH_SETUP = """ +bl_libpath() { + [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 + local kept= dir old_ifs=$IFS + IFS=: + for dir in $LD_LIBRARY_PATH; do + case $dir in + "" | */steamrtarm64 | */steamrtarm64/*) ;; + *) kept=${kept:+$kept:}$dir ;; + esac + done + IFS=$old_ifs + if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi +} +""" + BL_IDTECH3_SETUP = """ bl_idtech3() { BL_EXTRA_ARGS=() @@ -233,6 +253,7 @@ for name in %s; do bl_directaudio "$depot" bl_game_cpus bl_idtech3 "$@" + bl_libpath exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" fi done @@ -244,7 +265,7 @@ exit 1 # something else. LAUNCHER_SH = LAUNCHER_SH.replace( "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP, 1) + + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) # A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of # that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot @@ -280,6 +301,7 @@ bl_components "$here" bl_directaudio "$here" bl_game_cpus bl_idtech3 "$@" +bl_libpath # Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be # started from its own tree. exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" @@ -369,6 +391,28 @@ def serialize(tokens): return "\n".join(out) + "\n" +def write_changed(path, text, mode=None): + """Writes `text` to `path` only when it differs, through a rename so a reader never sees half a + file. The session runs this script every 15 s for as long as the client does; rewriting files + that have not changed cost a python start's worth of writes each time, and config.vdf is the + client's own, which it may be writing at that moment. Returns whether anything was written.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + same = f.read() == text + except OSError: + same = False + if not same: + tmp = path + ".bannerlator" + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode: + os.chmod(path, mode) + return not same + + def first_key(path, section): """The single key naming a block inside `section`, or None. Used to keep a third-party tool's own internal name, so a mapping the user already made against it keeps working.""" @@ -442,14 +486,10 @@ def adopt_extras(tools): body = (EXTRA_WRAPPER_SH % entry_point).replace( 'here=$(dirname "$0")', 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP, 1) - with open(wrapper, "w", encoding="utf-8") as f: - f.write(body) - os.chmod(wrapper, 0o755) - with open(manifest, "w", encoding="utf-8") as f: - f.write(EXTRA_MANIFEST_VDF) - with open(compat_vdf, "w", encoding="utf-8") as f: - f.write(EXTRA_COMPAT_TOOL_VDF % (name, entry)) + + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) + write_changed(wrapper, body, 0o755) + write_changed(manifest, EXTRA_MANIFEST_VDF) + write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry)) except OSError as e: print("bannerlator-steam-compat: could not adopt %s (%s)" % (entry, e)) continue @@ -494,7 +534,8 @@ def register_default(config_path, apps=(), name=None, protect=()): # Tool names the user may deliberately have chosen (see adopt_extras); never repointed. keep = {n.lower() for n in protect} with open(config_path, encoding="utf-8", errors="replace") as f: - tokens = tokenize(f.read()) + original = f.read() + tokens = tokenize(original) steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"]) if steam is None: return @@ -521,10 +562,10 @@ def register_default(config_path, apps=(), name=None, protect=()): continue tokens[mapping + 1:mapping + 1] = [ '"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"250"', "}"] - tmp = config_path + ".bannerlator" - with open(tmp, "w", encoding="utf-8") as f: - f.write(serialize(tokens)) - os.replace(tmp, config_path) + # Nothing to change is the usual case on the session's beat: the client's own file is left alone. + text = serialize(tokens) + if text != serialize(tokenize(original)): + write_changed(config_path, text) def find_source(steam_root): @@ -542,18 +583,14 @@ def build_tool(tool_dir): """Our own manifests and the launcher. The depot is found at launch time, not here.""" if os.path.islink(tool_dir): os.unlink(tool_dir) - elif os.path.isdir(tool_dir): - # Earlier versions mirrored the depot in here as symlinks; start clean. + elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES): + # Earlier versions mirrored the depot in here as symlinks; start clean. A directory holding + # only our own files is kept, and each file rewritten only when it changed. shutil.rmtree(tool_dir) - os.makedirs(tool_dir) - with open(os.path.join(tool_dir, "toolmanifest.vdf"), "w", encoding="utf-8") as f: - f.write(TOOL_MANIFEST_VDF) - with open(os.path.join(tool_dir, "compatibilitytool.vdf"), "w", encoding="utf-8") as f: - f.write(COMPAT_TOOL_VDF % TOOL) - launcher = os.path.join(tool_dir, LAUNCHER) - with open(launcher, "w", encoding="utf-8") as f: - f.write(LAUNCHER_SH) - os.chmod(launcher, 0o755) + 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 % TOOL) + write_changed(os.path.join(tool_dir, LAUNCHER), LAUNCHER_SH, 0o755) def main(): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games index 1e6f2a1fd..3ab3e9844 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games @@ -135,6 +135,10 @@ def watch(steam_root, listing): candidates, sources = {}, {} next_lookup = 0 last_status = None + # The probe is a fresh python that loads steamclient.so, seconds of CPU under proot. Ownership + # only moves with a purchase or a new match, so it runs when the matches or their sources + # change, after a failed probe, and otherwise every five minutes - not on every 30 s beat. + probed, next_probe = None, 0 while True: now = time.monotonic() for game in games: @@ -150,13 +154,16 @@ def watch(steam_root, listing): if now >= next_lookup: next_lookup = now + 300 status = "waiting for a matching Steam title" - if candidates: + key = (dict(candidates), dict(sources)) + if candidates and (key != probed or now >= next_probe): + probed, next_probe = key, now + 300 try: result = subprocess.run([sys.executable, __file__, "probe", steam_root, *map(str, sorted(set(candidates.values())))], capture_output=True, text=True, timeout=15) if result.returncode != 0: status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode)) + probed = None else: line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP=")) snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP=")) @@ -168,6 +175,9 @@ def watch(steam_root, listing): status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values())) except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error: status = "ownership check unavailable: " + str(error) + probed = None + elif candidates: + status = last_status if status != last_status: print("bannerlator-steam-games: " + status, flush=True) last_status = status From 736b7bed5e3a79f9aef386fbd4c48b13ed3af4e2 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Tue, 29 Sep 2026 16:41:18 -0400 Subject: [PATCH 5/6] perf(gamescope): ask X for the pointer only while a cursor is drawn The gamepad-cursor port queried the X pointer on every vblank, a blocking round trip to Xwayland on the paint thread. With no cursor image the position comes from wlserver as upstream; while the cursor is hidden for inactivity X is asked every 50 ms. The realtime-queue comment now says it is a no-op on KGSL Turnip. --- tools/gamescope/PATCHES.md | 7 +- ...00-realtime-queue-and-gamepad-cursor.patch | 87 ++++++++++++------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index 8fa264904..fdc158aa7 100644 --- a/tools/gamescope/PATCHES.md +++ b/tools/gamescope/PATCHES.md @@ -14,8 +14,11 @@ library list, before anything is published. - `0020-color-p3-red-is-wide-gamut.patch` - Armada, verbatim. - `0100-realtime-queue-and-gamepad-cursor.patch` - this app, two of Armada's ported by hand onto 3.16.29: realtime-priority Vulkan queues on request (`GAMESCOPE_FORCE_VULKAN_REALTIME=1`) - without CAP_SYS_NICE, which proot can never have; and the gamepad-driven cursor sprite following - the X pointer that XTest moves (it sat frozen). + without CAP_SYS_NICE, which proot can never have (a no-op on KGSL Turnip, which has a single + submit-queue priority); and the gamepad-driven cursor sprite following the X pointer that XTest + moves (it sat frozen). The X pointer is asked for only while a cursor image is drawn - every + vblank while shown, every 50 ms while hidden for inactivity - since each ask is a blocking round + trip to Xwayland on the paint thread; with no image, wlserver's position is used as upstream does. - `0110-wayland-backend-touch.patch` - this app: the nested Wayland backend bound only the host's pointer and keyboard, so a finger on the phone's screen never reached Steam. It now binds `wl_touch` too and hands each finger to wlserver's touch path (`wlserver_touchdown` / `motion` / diff --git a/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch b/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch index a49a0aa1a..0e390b099 100644 --- a/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch +++ b/tools/gamescope/patches/0100-realtime-queue-and-gamepad-cursor.patch @@ -7,8 +7,8 @@ index 54e4efc..024af02 100644 float queuePriorities = 1.0f; + // Without CAP_SYS_NICE gamescope never asks for realtime-priority queues. Under proot on -+ // Android there is no capability to have, but the driver may still grant the priority (Turnip -+ // does on Adreno), and the existing fallback below covers a driver that refuses. Opt in with ++ // Android there is no capability to have; the fallback below covers a driver that refuses. KGSL ++ // Turnip accepts but ignores it (one submit-queue priority, contexts created alike). Opt in with + // GAMESCOPE_FORCE_VULKAN_REALTIME=1. (Armada's --force-vulkan-realtime, as an environment switch.) + const char *pszForceRealtime = getenv( "GAMESCOPE_FORCE_VULKAN_REALTIME" ); + const bool useRealtimeVulkanQueue = gamescope::Process::HasCapSysNice() || ( pszForceRealtime && atoi( pszForceRealtime ) != 0 ); @@ -52,27 +52,10 @@ index 54e4efc..024af02 100644 fprintf(stderr, "vkCreateDevice failed with a high-priority queue (compute). Falling back to regular priority (all).\n"); queueCreateInfos[0].pNext = nullptr; diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 18b799e..16f7846 100644 +index 10990b8..08afbc4 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -2020,23 +2020,41 @@ MouseCursor::MouseCursor(xwayland_ctx_t *ctx) - - void MouseCursor::UpdatePosition() - { -+ // The gamepad-driven cursor is moved through XTest, which moves the X pointer and nothing -+ // else: wlserver's copy of the position never changes, so the sprite sat frozen while the -+ // pointer moved. Ask X where the pointer is. (Armada 0001, on 3.16.29.) -+ Window root_ret, child_ret; -+ int root_x = m_x, root_y = m_y, win_x = 0, win_y = 0; -+ unsigned int mask; -+ XQueryPointer( m_ctx->dpy, DefaultRootWindow( m_ctx->dpy ), -+ &root_ret, &child_ret, -+ &root_x, &root_y, -+ &win_x, &win_y, -+ &mask ); -+ - wlserver_lock(); - struct wlr_pointer_constraint_v1 *pConstraint = wlserver.GetCursorConstraint(); +@@ -2025,18 +2025,53 @@ void MouseCursor::UpdatePosition() if ( pConstraint && pConstraint->current.cursor_hint.enabled ) { m_bConstrained = true; @@ -84,23 +67,61 @@ index 18b799e..16f7846 100644 } - else + m_bConstrained = false; -+ wlserver_unlock(); -+ if ( root_x != m_x || root_y != m_y ) ++ // Nothing to draw (the gamepad UI, most games): the position only feeds focus and zoom, and ++ // wlserver's copy is what stock gamescope uses for those. No round trip to X. ++ if ( m_imageEmpty ) { - m_bConstrained = false; - -- m_x = wlserver.mouse_surface_cursorx; -- m_y = wlserver.mouse_surface_cursory; + m_x = wlserver.mouse_surface_cursorx; + m_y = wlserver.mouse_surface_cursory; ++ wlserver_unlock(); ++ return; + } + wlserver_unlock(); ++ ++ // The gamepad-driven cursor is moved through XTest, which moves the X pointer and nothing ++ // else: wlserver's copy of the position never changes, so the sprite sat frozen while the ++ // pointer moved. Ask X where the pointer is. (Armada 0001, on 3.16.29.) That is a blocking ++ // round trip to Xwayland on the paint thread, which waits while Xwayland is busy drawing the ++ // client's interface: once per vblank only while the sprite is shown, and every 50 ms while ++ // it is hidden for inactivity - enough to bring it back within a few frames of a move. ++ if ( wlserver.bCursorHidden ) ++ { ++ const uint64_t now = get_time_in_nanos(); ++ if ( now - m_ulLastPointerQuery < 50'000'000ul ) ++ return; ++ m_ulLastPointerQuery = now; ++ } ++ Window root_ret, child_ret; ++ int root_x = m_x, root_y = m_y, win_x = 0, win_y = 0; ++ unsigned int mask; ++ XQueryPointer( m_ctx->dpy, DefaultRootWindow( m_ctx->dpy ), ++ &root_ret, &child_ret, ++ &root_x, &root_y, ++ &win_x, &win_y, ++ &mask ); ++ ++ if ( root_x != m_x || root_y != m_y ) ++ { + m_x = root_x; + m_y = root_y; -+ if ( !m_imageEmpty ) -+ { -+ wlserver.ulLastMovedCursorTime = get_time_in_nanos(); -+ wlserver.bCursorHidden = !wlserver.bCursorHasImage; -+ hasRepaintNonBasePlane = true; -+ } - } -- wlserver_unlock(); ++ wlserver.ulLastMovedCursorTime = get_time_in_nanos(); ++ wlserver.bCursorHidden = !wlserver.bCursorHasImage; ++ hasRepaintNonBasePlane = true; ++ } } void MouseCursor::checkSuspension() +diff --git a/src/steamcompmgr.hpp b/src/steamcompmgr.hpp +index c428784..bd62fd1 100644 +--- a/src/steamcompmgr.hpp ++++ b/src/steamcompmgr.hpp +@@ -109,6 +109,7 @@ private: + + int m_x = 0, m_y = 0; + bool m_bConstrained = false; ++ uint64_t m_ulLastPointerQuery = 0; + int m_hotspotX = 0, m_hotspotY = 0; + + gamescope::OwningRc m_texture; From 5a61d37c5d93e4fd493fef9368ae3d1d9559cec5 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:00:10 -0400 Subject: [PATCH 6/6] perf: faster local build, skip xalia and gamescope realtime by default, mic off by default, show the saved Deck mode (#85) * perf: cache pinned downloads, skip unchanged proot rebuilds, and wait out a Docker Desktop restart in the local build * perf: skip xalia and gamescope realtime queues by default, add a realtime toggle, default the mic off * fix: show the saved Steam Deck mode on the Steam page before Performance has been opened --- .gitignore | 1 + .../com/droiddeck/launcher/MainActivity.kt | 12 ++- .../droiddeck/launcher/core/DeviceReport.kt | 1 + .../launcher/session/SessionPrefs.kt | 22 +++++- .../launcher/session/SessionService.kt | 7 +- .../launcher/ui/PerformanceDialog.kt | 9 ++- tools/build_local.sh | 73 +++++++++++++++---- .../overlay/usr/local/bin/bannerlator-session | 5 +- 8 files changed, 102 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 965928cb6..3240a2ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -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). diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e40c575c9..c35d2c20b 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -126,7 +126,8 @@ class MainActivity : ComponentActivity() { private var gameCores by mutableStateOf>(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 prootNoSeccomp by mutableStateOf(false) private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME) private var phantomWarning by mutableStateOf(null) @@ -503,8 +504,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 +895,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, prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, @@ -900,6 +903,7 @@ 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 }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) }, onClientCore = { core, on -> @@ -944,6 +948,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 +998,7 @@ class MainActivity : ComponentActivity() { tuSysmem = SessionPrefs.tuSysmem(this) zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) + gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) guestHostname = SessionPrefs.guestHostname(this) refreshPhantomStatus() diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt index 80550be44..944b752a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -146,6 +146,7 @@ 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("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) k("Guest host name", SessionPrefs.guestHostname(context)) k("DirectAudio for games", SessionPrefs.directAudio(context)) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 820c6e401..07b2e3fba 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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,28 @@ 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. + */ + 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). * diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 913328d5b..13400e511 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -530,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, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt index 16f7a0e50..1083babe2 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -37,6 +37,7 @@ fun PerformancePage( glThread: Boolean, noGlError: Boolean, noXalia: Boolean, + gamescopeRealtime: Boolean, prootNoSeccomp: Boolean, guestHostname: String, phantomWarning: String?, @@ -46,6 +47,7 @@ fun PerformancePage( onGlThread: (Boolean) -> Unit, onNoGlError: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, + onGamescopeRealtime: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, onGuestHostname: (String) -> Unit, onClientCore: (Int, Boolean) -> Unit, @@ -94,6 +96,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,7 +110,7 @@ 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( diff --git a/tools/build_local.sh b/tools/build_local.sh index ec3028de3..484e1f47b 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -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 : 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}" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index de4c6918b..f46fbca1d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -77,8 +77,9 @@ fi # 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. On KGSL Turnip (Mesa 26.2) it # changes nothing yet: the driver reports one submit-queue priority and creates every KGSL context -# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it. -export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1} +# with the same flags, so no priority reaches the GPU. Kept for a driver that maps it, and off +# unless the app's Performance page turns it on (BL_GAMESCOPE_REALTIME=1). +export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-0} # A sleep that starts no process. Under proot every exec is a traced execve plus the loader's # opens and maps, and the watchers below poll for the whole session: `sleep 1` in a loop was 60