mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
Merge branch 'main' into feat/flatpak-support
This commit is contained in:
22 files changed
+604
-214
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -126,7 +126,8 @@ class MainActivity : ComponentActivity() {
|
||||
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
|
||||
private var tuSysmem by mutableStateOf(false)
|
||||
private var zinkLazy by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(true)
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>(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<String>(2)
|
||||
|
||||
// The fastest display mode at the panel's current size.
|
||||
parts += try {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+57
-16
@@ -89,7 +89,17 @@ linuxfs_replaced=1
|
||||
rm -rf -- "${linuxfs_dir}"
|
||||
mkdir -p "${linuxfs_dir}"
|
||||
|
||||
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
|
||||
# Docker Desktop's VM restarts now and then, and until its engine has loaded its image store it
|
||||
# answers "No such image" for images it has. The rebuild that follows hangs on the registry
|
||||
# (the base image's credentials go through docker-credential-desktop), so give a restarting daemon
|
||||
# up to a minute before deciding the image is really missing.
|
||||
image_present=0
|
||||
for _ in $(seq 1 30); do
|
||||
if inspect_error=$(docker image inspect "${image_name}" 2>&1 >/dev/null); then image_present=1; break; fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "${image_present}" = 0 ]]; then
|
||||
echo "Build image ${image_name} not found (${inspect_error:-no error}); building it." >&2
|
||||
docker build --platform linux/amd64 -t "${image_name}" \
|
||||
-f "${repo_root}/tools/local-cross.Dockerfile" "${repo_root}"
|
||||
fi
|
||||
@@ -164,20 +174,39 @@ if [[ -z "${github_repo}" ]]; then
|
||||
github_repo=${github_repo%.git}
|
||||
fi
|
||||
|
||||
# Pinned downloads are kept between builds, named by their checksum, so a rebuild fetches nothing
|
||||
# it already has. A cached file is checked again before use; a bad one is fetched anew.
|
||||
cache_dir=${DROIDDECK_BUILD_CACHE:-"${HOME}/.cache/droiddeck-build"}
|
||||
mkdir -p "${cache_dir}"
|
||||
# cached <sha256> <name> <command...>: prints the cached path, running the command (which writes
|
||||
# to "$out") only when the cache has no file with that checksum.
|
||||
cached() {
|
||||
local sha=$1 name=$2 out
|
||||
shift 2
|
||||
out="${cache_dir}/${sha}-${name}"
|
||||
if [[ -f "${out}" ]] && printf '%s %s\n' "${sha}" "${out}" | shasum -a 256 -c - >/dev/null 2>&1; then
|
||||
echo "${out}"
|
||||
return 0
|
||||
fi
|
||||
rm -f "${out}.part"
|
||||
out="${out}.part" "$@" >&2
|
||||
printf '%s %s\n' "${sha}" "${out}.part" | shasum -a 256 -c - >&2
|
||||
mv "${out}.part" "${out}"
|
||||
echo "${out}"
|
||||
}
|
||||
|
||||
if [[ -f "${repo_root}/tools/gamescope/release.env" ]]; then
|
||||
. "${repo_root}/tools/gamescope/release.env"
|
||||
gamescope_archive="${staging_dir}/gamescope.tzst"
|
||||
gh release download "${GAMESCOPE_TAG}" -R "${github_repo}" -p gamescope.tzst -O "${gamescope_archive}"
|
||||
printf '%s %s\n' "${GAMESCOPE_SHA256}" "${gamescope_archive}" | shasum -a 256 -c -
|
||||
gamescope_archive=$(cached "${GAMESCOPE_SHA256}" gamescope.tzst \
|
||||
bash -c 'gh release download "$0" -R "$1" -p gamescope.tzst -O "$out"' "${GAMESCOPE_TAG}" "${github_repo}")
|
||||
zstd -dc "${gamescope_archive}" | tar -xf - -C "${linuxfs_dir}"
|
||||
test -f "${linuxfs_dir}/usr/local/bin/gamescope"
|
||||
fi
|
||||
|
||||
if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then
|
||||
. "${repo_root}/tools/wlroots/release.env"
|
||||
wlroots_archive="${staging_dir}/wlroots.tzst"
|
||||
gh release download "${WLROOTS_TAG}" -R "${github_repo}" -p wlroots.tzst -O "${wlroots_archive}"
|
||||
printf '%s %s\n' "${WLROOTS_SHA256}" "${wlroots_archive}" | shasum -a 256 -c -
|
||||
wlroots_archive=$(cached "${WLROOTS_SHA256}" wlroots.tzst \
|
||||
bash -c 'gh release download "$0" -R "$1" -p wlroots.tzst -O "$out"' "${WLROOTS_TAG}" "${github_repo}")
|
||||
zstd -dc "${wlroots_archive}" | tar -xf - -C "${linuxfs_dir}"
|
||||
test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
|
||||
fi
|
||||
@@ -187,9 +216,8 @@ mango_pkgs="${staging_dir}/mango-pkgs"
|
||||
mkdir -p "${mango_dir}" "${mango_pkgs}"
|
||||
while read -r package_sha256 package_url; do
|
||||
[[ -n "${package_url}" ]] || continue
|
||||
package_archive="${staging_dir}/$(basename "${package_url}")"
|
||||
curl -fsSL --retry 3 -o "${package_archive}" "${package_url}"
|
||||
printf '%s %s\n' "${package_sha256}" "${package_archive}" | shasum -a 256 -c -
|
||||
package_archive=$(cached "${package_sha256}" "$(basename "${package_url}")" \
|
||||
bash -c 'curl -fsSL --retry 3 -o "$out" "$0"' "${package_url}")
|
||||
zstd -dc "${package_archive}" | tar -xf - -C "${mango_pkgs}"
|
||||
done < <(grep -v '^#' "${repo_root}/tools/mangoapp/packages.txt")
|
||||
install -m644 "${mango_pkgs}/usr/bin/mangoapp" "${mango_dir}/mangoapp"
|
||||
@@ -201,11 +229,14 @@ install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/b
|
||||
|
||||
pa_source=${DROIDDECK_PA13_SOURCE_DIR:-"${staging_dir}/pulseaudio-13.0"}
|
||||
if [[ -z "${DROIDDECK_PA13_SOURCE_DIR:-}" ]]; then
|
||||
curl -fsSL -o "${staging_dir}/pulseaudio-13.0.tar.gz" \
|
||||
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
pa_tarball="${cache_dir}/pulseaudio-13.0.tar.gz"
|
||||
if [[ ! -s "${pa_tarball}" ]] || ! tar -tzf "${pa_tarball}" >/dev/null 2>&1; then
|
||||
curl -fsSL -o "${pa_tarball}.part" \
|
||||
https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
|
||||
mv "${pa_tarball}.part" "${pa_tarball}"
|
||||
fi
|
||||
mkdir -p "${pa_source}"
|
||||
tar -xzf "${staging_dir}/pulseaudio-13.0.tar.gz" \
|
||||
-C "${pa_source}" --strip-components=1
|
||||
tar -xzf "${pa_tarball}" -C "${pa_source}" --strip-components=1
|
||||
fi
|
||||
if [[ ! -f "${pa_source}/src/pulse/version.h.in" ]]; then
|
||||
echo "PulseAudio 13.0 source not found at ${pa_source}; set DROIDDECK_PA13_SOURCE_DIR." >&2
|
||||
@@ -214,7 +245,17 @@ fi
|
||||
|
||||
sink_output="${staging_dir}/sink-out"
|
||||
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
|
||||
"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
|
||||
# proot is rebuilt only when its sources (source.env, the patches, the build script) changed since
|
||||
# the libraries in jniLibs were built.
|
||||
proot_out="${repo_root}/app/src/main/jniLibs/arm64-v8a"
|
||||
proot_inputs=$(cd "${repo_root}/tools/proot" && find . -type f ! -name '*.pyc' | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -d' ' -f1)
|
||||
if [[ -f "${proot_out}/libproot.so" && -f "${proot_out}/libproot-loader.so" \
|
||||
&& "$(cat "${proot_out}/.proot-inputs" 2>/dev/null)" = "${proot_inputs}" ]]; then
|
||||
echo "proot: sources unchanged, keeping ${proot_out}/libproot.so"
|
||||
else
|
||||
"${repo_root}/tools/proot/build.sh" "${proot_out}"
|
||||
echo "${proot_inputs}" > "${proot_out}/.proot-inputs"
|
||||
fi
|
||||
|
||||
cp -p "${bundle_asset}" "${bundle_backup}"
|
||||
bundle_dir="${staging_dir}/pulseaudio-bundle"
|
||||
@@ -229,7 +270,7 @@ install -m755 "${sink_output}/module-aaudio-sink.so" \
|
||||
"${bundle_dir}/modules/arm64/module-aaudio-sink.so"
|
||||
install -m755 "${sink_output}/module-directaudio-sink.so" \
|
||||
"${bundle_dir}/modules/arm64/module-directaudio-sink.so"
|
||||
tar -cf - -C "${bundle_dir}" . | zstd -19 -c > "${staging_dir}/pulseaudio.tzst"
|
||||
tar -cf - -C "${bundle_dir}" . | zstd -19 -T0 -c > "${staging_dir}/pulseaudio.tzst"
|
||||
bundle_replaced=1
|
||||
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
|
||||
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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<CVulkanTexture> m_texture;
|
||||
@@ -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=$!
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,103 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: DroidDeck <droiddeck@users.noreply.github.com>
|
||||
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 <sys/types.h> /* uid_t, gid_t */
|
||||
+#include <stdbool.h> /* 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: DroidDeck <droiddeck@users.noreply.github.com>
|
||||
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 <stdint.h> /* uint*_t, UINT*_MAX, */
|
||||
#include <stdbool.h>
|
||||
#include <sys/resource.h>
|
||||
+#include <sys/utsname.h> /* uname(2), */
|
||||
+#include <stdio.h> /* sscanf(3), */
|
||||
+#include <asm/ioctls.h> /* TCGETS2, TCSETS*2, */
|
||||
+#include <asm/termbits.h> /* struct termios2, */
|
||||
+#include <linux/fs.h> /* FICLONE, */
|
||||
#include <assert.h> /* 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);
|
||||
Reference in new issue
Block a user