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/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/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index ef30bfb5e..6de326cdc 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) @@ -525,8 +526,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) @@ -915,6 +917,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 }, @@ -922,6 +925,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 -> @@ -966,6 +970,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() @@ -1015,6 +1020,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/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index f077fe025..3328e71a9 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/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/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/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index ec0d09559..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). * @@ -227,8 +241,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 +271,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 +331,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..13400e511 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). @@ -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, 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..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, @@ -86,7 +88,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( @@ -94,16 +96,21 @@ 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( 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( 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/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 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) 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/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; diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 1d0ee603a..f1ed2b96b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -73,10 +73,21 @@ 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. -export GAMESCOPE_FORCE_VULKAN_REALTIME=${BL_GAMESCOPE_REALTIME:-1} +# 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, 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 +# 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} @@ -270,7 +281,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 @@ -378,8 +389,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 @@ -391,7 +403,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 @@ -562,7 +574,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 diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md index e97ffaa7e..ef781dc8a 100644 --- a/tools/proot/PATCHES.md +++ b/tools/proot/PATCHES.md @@ -65,3 +65,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);