diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 338dce61c..7d4254e13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -208,9 +208,13 @@ jobs: rm mango-pkgs/pkg.tar.zst done install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp" - for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do + for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do cp -L "mango-pkgs/usr/lib/$so" "$M/$so" done + # Ours, not the package's: GPU memory without tracefs (tools/mangoapp/libtracefs-shim.c). + aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \ + -o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c + aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1" install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp" rm -rf mango-pkgs ls -l "$M" diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt index 1f8a7233d..3dd7ec189 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt @@ -47,7 +47,7 @@ object GuestCommand { .forEach { cmd.add("-b"); cmd.add(it) } cmd += listOf( "/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8", - "PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}", + "PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}", "XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}", "XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share", ) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 6ad52da27..43d068701 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -34,6 +34,14 @@ public final class LinuxRuntime { /** Shortcut extra naming which of the modes above a Linux entry launches. */ public static final String EXTRA_LINUX_MODE = "linux_mode"; private static final String KGSL_DEVICE = "/dev/kgsl-3d0"; + /** + * Where the guest sees a command's XDG_RUNTIME_DIR. A Unix socket's path must fit in 108 bytes, + * and libwayland checks the guest's path before proot ever sees it: with the app on an SD card + * adopted as internal storage the files directory is /mnt/expand//user/0//files, + * and gamescope-0 under it is one byte over, so gamescope could not open its socket and the + * session ended at once. A long host path behind it is proot's to shorten. + */ + public static final String GUEST_RUNTIME_DIR = "/run/droiddeck"; /** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */ public static final String DEBUG_LOG_DIR = "DroidDeck"; @@ -185,7 +193,10 @@ public final class LinuxRuntime { bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux"); bind(cmd, context.getFilesDir().getPath()); bind(cmd, context.getCacheDir().getPath()); - if (runtimeDir != null) bind(cmd, runtimeDir.getPath()); + if (runtimeDir != null) { + bind(cmd, runtimeDir.getPath()); + bind(cmd, runtimeDir.getPath() + ":" + GUEST_RUNTIME_DIR); + } if (sessionRoot != null) bind(cmd, sessionRoot.getPath()); if (externalStorage != null && externalStorage.isDirectory()) { bind(cmd, externalStorage.getPath()); @@ -231,12 +242,15 @@ public final class LinuxRuntime { }; for (String[] entry : procFiles) { File fake = new File(fakeProc, entry[0]); - if (fake.isFile() && !new File(entry[1]).canRead()) { + // A live stand-in the session binds itself (CpuStatComponent's /proc/stat) wins. + boolean live = false; + if (extraBinds != null) for (String spec : extraBinds) live |= spec.endsWith(":" + entry[1]); + if (!live && fake.isFile() && !new File(entry[1]).canRead()) { bind(cmd, fake.getPath() + ":" + entry[1]); } } bindGpuNode(context, cmd); - bindAdrenoStats(cmd); + bindAdrenoStats(cmd, extraBinds); bindCpuTemps(cmd, root); if (extraBinds != null) { for (String spec : extraBinds) bind(cmd, spec); @@ -282,12 +296,71 @@ public final class LinuxRuntime { if (!Files.isSymbolicLink(subsystem.toPath())) { Os.symlink("/sys/bus/platform", subsystem.getPath()); } + // What MangoHud names a GPU's driver by; msm_drm is how an Adreno's render node reads + // on a mainline kernel, and the driver it reads the load of (bindAdrenoStats). + File driver = new File(device, "driver"); + if (!Files.isSymbolicLink(driver.toPath())) { + Os.symlink("/sys/bus/platform/drivers/msm_drm", driver.getPath()); + } } catch (IOException | ErrnoException e) { return; } bind(cmd, new File(base, "sys").getPath() + ":/sys/dev/char"); bind(cmd, dri.getPath() + ":/dev/dri"); bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node); + bindDrmClass(base, cmd, node, major + ":" + minor); + } + + /** + * MangoHud (Deck mode's performance overlay, mangoapp) finds the GPU by listing + * {@code /sys/class/drm}, and an exception from that listing ends the process: under an + * enforcing SELinux policy - every retail phone - an app may not list it, so mangoapp died on + * start, gamescope restarted it until the session gave up, and there was no overlay. Where the + * app cannot list it, the session's {@code /sys/class/drm} is ours: the render node above, + * named as an Adreno's is. MangoHud then reads the GPU's stats from KGSL's sysfs, which such a + * device refuses too - SessionService's GpuStatsComponent stands in for that. Where the + * listing is allowed it is left alone. + */ + private static void bindDrmClass(File base, List cmd, String node, String devNumbers) { + if (new File("/sys/class/drm").list() != null) return; + File drmClass = new File(base, "class"); + try { + if (!drmClass.isDirectory() && !drmClass.mkdirs()) return; + // Only symlinks are ever made here; deleting them must not follow one into its target. + File[] old = drmClass.listFiles(); + if (old != null) for (File f : old) Files.deleteIfExists(f.toPath()); + Os.symlink("/sys/dev/char/" + devNumbers, new File(drmClass, node).getPath()); + } catch (IOException | ErrnoException e) { + return; + } + bind(cmd, drmClass.getPath() + ":/sys/class/drm"); + android.util.Log.i("LinuxRuntime", "hud: /sys/class/drm is not listable here; the session's lists " + node); + // Also refused to list, also asked for by MangoHud twice a second - an error line each + // time in the session log - and holding nothing it reads on an Adreno phone. + if (new File("/sys/class/powercap").list() == null) { + File empty = new File(base, "empty-powercap"); + if (empty.isDirectory() || empty.mkdirs()) bind(cmd, empty.getPath() + ":/sys/class/powercap"); + } + } + + /** The temperature of a CPU zone the app may read (the first by name), or null. */ + public static String cpuTempSource() { + for (java.util.Map.Entry e : new java.util.TreeMap<>(thermalZones()).entrySet()) { + File temp = new File(e.getValue(), "temp"); + if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) return temp.getPath(); + } + return null; + } + + /** The file the GPU's temperature reads from, or null where the app may read none. */ + public static String gpuTempSource() { + String kgsl = "/sys/class/kgsl/kgsl-3d0/"; + return firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu")); + } + + private static String gpuLoadSource() { + String kgsl = "/sys/class/kgsl/kgsl-3d0/"; + return firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"); } /** @@ -298,11 +371,11 @@ public final class LinuxRuntime { * Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left * alone. */ - private static void bindAdrenoStats(List cmd) { + private static void bindAdrenoStats(List cmd, List extraBinds) { String kgsl = "/sys/class/kgsl/kgsl-3d0/"; - String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu")); + String gpuTemp = gpuTempSource(); String[][] stats = { - {firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"}, + {gpuLoadSource(), "/sys/kernel/debug/dri/0/perf_now"}, {firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"), "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"}, {gpuTemp, "/sys/class/thermal/thermal_zone28/temp"}, @@ -310,8 +383,21 @@ public final class LinuxRuntime { {thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"}, }; for (String[] stat : stats) { - if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]); + // A value the session feeds itself (GpuStatsComponent, where KGSL is refused) wins. + boolean fed = false; + if (extraBinds != null) for (String spec : extraBinds) fed |= spec.endsWith(":" + stat[1]); + if (stat[0] != null && !fed) bind(cmd, stat[0] + ":" + stat[1]); } + // Which of the overlay's GPU values this device lets the app read: a phone under an + // enforcing policy refuses some of them, and the overlay then has no line for that value. + android.util.Log.i("LinuxRuntime", "hud sources: gpu load " + orNone(stats[0][0]) + ", gpu clock " + + orNone(stats[1][0]) + ", gpu temp " + orNone(gpuTemp) + ", ddr temp " + orNone(stats[4][0]) + + ", thermal zones " + thermalZones().size() + + ", kgsl gpubusy " + (new File(kgsl + "gpubusy").canRead() ? "readable" : "refused")); + } + + private static String orNone(String path) { + return path != null ? path : "none"; } /** diff --git a/app/src/main/java/com/droiddeck/launcher/session/CpuStatComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/CpuStatComponent.kt new file mode 100644 index 000000000..7c5aa9a28 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/CpuStatComponent.kt @@ -0,0 +1,110 @@ +package com.droiddeck.launcher.session + +import android.os.SystemClock +import android.util.Log +import com.droiddeck.launcher.core.SessionPart +import java.io.File + +/** + * A `/proc/stat` that moves. + * + * Android has refused apps `/proc/stat` since 8.0, and the session's stand-in for it was one + * `cpu0` line of zeros - enough for glibc and Steam to start, but every CPU load in the session + * read 0 % (the performance overlay showed one core, idle). The kernel still lets apps read each + * core's idle residency (`cpuidle/stateN/time`, µs, the same label as the cpufreq files), which is + * how overlays on Android work out CPU load: a core's busy time is the time that passed less the + * time it spent idle. This writes `/proc/stat` from that, one line per core, refreshed twice a + * second. Counters only grow - a sample's idle is never more than the time that passed - so a + * reader taking the difference of two reads never sees a negative. Where `/proc/stat` is readable, + * or the idle times are not, nothing is done. + */ +class CpuStatComponent(val file: File) : SessionPart() { + @Volatile private var running = false + private var thread: Thread? = null + private val cores = (0 until coreCount()).toList() + private val busy = LongArray(cores.size) + private val idle = LongArray(cores.size) + private val lastIdleUs = LongArray(cores.size) + private var lastMs = 0L + private val bootSeconds = (System.currentTimeMillis() - SystemClock.elapsedRealtime()) / 1000 + + /** Writes the first sample; true when it should be bound over `/proc/stat`. */ + fun prepare(): Boolean { + if (File("/proc/stat").canRead() || cores.isEmpty() || idleUs(0) == null) return false + lastMs = SystemClock.elapsedRealtime() + for (i in cores) lastIdleUs[i] = idleUs(i) ?: 0L + write() + Log.i(TAG, "hud: /proc/stat from the cores' idle times (${cores.size} cores)") + return true + } + + override fun start() { + running = true + thread = Thread({ + while (running) { + try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } + if (!running) break + sample() + write() + } + }, "cpu-stat").apply { isDaemon = true; start() } + } + + override fun stop() { + running = false + thread?.interrupt() + thread = null + } + + private fun sample() { + val now = SystemClock.elapsedRealtime() + val elapsed = (now - lastMs) / MS_PER_TICK + if (elapsed <= 0) return + lastMs += elapsed * MS_PER_TICK + for (i in cores) { + // An offline core has no idle residency to count and runs nothing: all idle. + val us = idleUs(i) + val idleTicks = if (us == null) elapsed else ((us - lastIdleUs[i]) / US_PER_TICK).coerceIn(0, elapsed) + if (us != null) lastIdleUs[i] = us + idle[i] += idleTicks + busy[i] += elapsed - idleTicks + } + } + + /** The whole file at once, renamed into place: a reader opens either the last sample or this one. */ + private fun write() { + val text = StringBuilder() + text.append("cpu ${busy.sum()} 0 0 ${idle.sum()} 0 0 0 0 0 0\n") + for (i in cores) text.append("cpu$i ${busy[i]} 0 0 ${idle[i]} 0 0 0 0 0 0\n") + text.append("intr 0\nctxt 0\nbtime $bootSeconds\nprocesses 1\nprocs_running 1\nprocs_blocked 0\n") + try { + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(text.toString()) + if (!tmp.renameTo(file)) tmp.delete() + } catch (e: Exception) { + } + } + + companion object { + private const val TAG = "SessionService" + private const val PERIOD_MS = 500L + /** /proc/stat counts in USER_HZ, 100 a second on every Linux the guest runs. */ + private const val MS_PER_TICK = 10L + private const val US_PER_TICK = 10_000L + + private fun coreCount(): Int = + File("/sys/devices/system/cpu").list()?.count { it.matches(Regex("cpu\\d+")) } ?: 0 + + /** The core's total idle residency in µs, or null when it has none to read (offline). */ + private fun idleUs(core: Int): Long? { + val states = File("/sys/devices/system/cpu/cpu$core/cpuidle").listFiles { f -> f.name.startsWith("state") } + ?: return null + if (states.isEmpty()) return null + var total = 0L + for (state in states) { + total += runCatching { File(state, "time").readText().trim().toLong() }.getOrNull() ?: return null + } + return total + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt new file mode 100644 index 000000000..efa6a7956 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt @@ -0,0 +1,118 @@ +package com.droiddeck.launcher.session + +import android.util.Log +import com.droiddeck.launcher.core.SessionPart +import java.io.File + +/** + * The GPU memory in use, for the performance overlay's VRAM and PVRAM lines. + * + * Valve's mangoapp reads it from the kernel's gpu_mem_total tracepoint, which no Android app may + * reach, so both lines read 0. Our libtracefs for mangoapp (tools/mangoapp/libtracefs-shim.c) hands + * it what this writes to [file] - `/run/droiddeck-hud/gpu-mem` in the guest - every few seconds, in + * bytes, from the best of what KGSL lets the app read: + * + * - `/sys/class/kgsl/kgsl/page_alloc`, every GPU page on the device - what the tracepoint's total + * counts, where the policy allows it; + * - else KGSL's own accounting of each of the session's processes + * (`/sys/class/kgsl/kgsl/proc//gpumem_mapped` and `gpumem_unmapped`); + * - else the size of the KGSL mappings in each of the session's processes' `maps` - only the + * buffers the CPU has mapped, so less than the GPU holds (about 40 % of KGSL's own count in a + * game on an AYN Thor), but always readable: every process the app can see in `/proc` is one of + * its own. `maps` rather than `smaps`, which gives the same sizes but walks page tables to do it: + * in a game, 180,000 lines and most of a second of CPU a pass. + * + * Sampled only while the overlay shows (OverlayShown). + */ +class GpuMemComponent(private val file: File, private val root: File) : SessionPart() { + @Volatile private var running = false + private var thread: Thread? = null + private var source = "" + + /** True when there is a source to write from. */ + fun prepare(): Boolean { + val bytes = sample() ?: return false + write(bytes) + Log.i(TAG, "hud: gpu memory from $source (${bytes / (1024 * 1024)} MiB now)") + return true + } + + override fun start() { + running = true + thread = Thread({ + while (running) { + try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } + if (running && OverlayShown.check(root)) sample()?.let { write(it) } + } + }, "gpu-mem").apply { isDaemon = true; start() } + } + + override fun stop() { + running = false + thread?.interrupt() + thread = null + file.delete() + } + + private fun sample(): Long? { + readLong("$KGSL_ROOT/page_alloc")?.let { source = "$KGSL_ROOT/page_alloc"; return it } + val pids = sessionPids() + var perProcess = 0L + var anyProcess = false + for (pid in pids) { + val mapped = readLong("$KGSL_ROOT/proc/$pid/gpumem_mapped") ?: continue + perProcess += mapped + (readLong("$KGSL_ROOT/proc/$pid/gpumem_unmapped") ?: 0L) + anyProcess = true + } + if (anyProcess) { source = "$KGSL_ROOT/proc//gpumem_*"; return perProcess } + var mappedKb = 0L + var anyMap = false + for (pid in pids) { + val kb = kgslMappedKb(pid) ?: continue + mappedKb += kb + anyMap = true + } + if (anyMap) { source = "/proc//maps (KGSL mappings)"; return mappedKb * 1024 } + return null + } + + /** The processes this app may see in /proc: Android hides every other uid's. */ + private fun sessionPids(): List = + File("/proc").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) }.orEmpty() + + /** Size of a process's mappings of the KGSL device, in kB; null where its maps cannot be read. */ + private fun kgslMappedKb(pid: String): Long? = runCatching { + var bytes = 0L + File("/proc/$pid/maps").forEachLine { line -> + if (line.endsWith("kgsl-3d0") || line.contains("/dev/dri/renderD")) { + val dash = line.indexOf('-') + val space = line.indexOf(' ') + if (dash > 0 && space > dash) { + bytes += line.substring(dash + 1, space).toLong(16) - line.substring(0, dash).toLong(16) + } + } + } + bytes / 1024 + }.getOrNull() + + /** Whole, renamed into place: the reader opens either the last value or this one. */ + private fun write(bytes: Long) { + try { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText("$bytes\n") + if (!tmp.renameTo(file)) tmp.delete() + } catch (e: Exception) { + } + } + + companion object { + private const val TAG = "SessionService" + private const val KGSL_ROOT = "/sys/class/kgsl/kgsl" + /** GPU memory moves slowly; a pass over every process's maps is ~40 ms of CPU in a game. */ + private const val PERIOD_MS = 5_000L + + private fun readLong(path: String): Long? = + runCatching { File(path).readText().trim().toLong() }.getOrNull() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt new file mode 100644 index 000000000..9a2cac46e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -0,0 +1,176 @@ +package com.droiddeck.launcher.session + +import android.util.Log +import com.droiddeck.launcher.core.SessionPart +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File +import java.io.RandomAccessFile + +/** + * The Adreno's stats as mangoapp (Deck mode's performance overlay) reads them. + * + * MangoHud takes an Adreno's load, clock and temperature from `gpu_busy_percentage`, `clock_mhz` + * and `temp` in `/sys/class/kgsl/kgsl-3d0`, and ends the process on one of them it may not read - + * not on one that is missing, which it skips. An enforcing SELinux policy (every retail phone) + * refuses apps some or all of KGSL's sysfs, each vendor differently, so mangoapp died on start and + * the session ran with no overlay. Where any of the three is refused, a directory of our own is + * bound there instead, each value fed twice a second from the first source this device lets the + * app read - the places other Android overlays (GameNative, WinNative, Bannerlator) read an + * Adreno's numbers from, across Snapdragon generations and vendor kernels. A value with no + * readable source is left out, and the overlay shows no line for it. Where all three are + * readable, nothing is done. + * + * Valve's build also reads load and clock where a mainline msm kernel has them (debugfs + * `perf_now`, the GPU's devfreq `cur_freq` in Hz) - the paths LinuxRuntime.bindAdrenoStats points + * at KGSL's own files when it may - so the same values are fed there too, in the same formats + * ([binds]). + */ +class GpuStatsComponent(val dir: File) : SessionPart() { + @Volatile private var running = false + private var thread: Thread? = null + private val feeds = ArrayList() + + /** One of MangoHud's files and where its value comes from. */ + private class Feed( + val file: File, val source: String, val read: () -> Long?, val format: String, val percent: Boolean = false, + ) + + /** Where Valve's msm reader looks, and the feed for it, as host:guest binds. */ + fun binds(): List { + val list = mutableListOf(dir.path + ":" + KGSL) + if (File(dir, "perf_now").exists()) list.add(File(dir, "perf_now").path + ":" + MSM_LOAD) + if (File(dir, "cur_freq").exists()) list.add(File(dir, "cur_freq").path + ":" + MSM_CLOCK) + return list + } + + /** Writes the directory; true when it should be bound over [KGSL]. */ + fun prepare(): Boolean { + if (!File(KGSL).exists() || MANGOHUD_FILES.none { File(KGSL, it).let { f -> f.exists() && !f.canRead() } }) return false + return try { + dir.mkdirs() + dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } + feeds.clear() + loadFeed()?.let { load -> + feeds.add(load) + // KGSL's gpu_busy_percentage reads "37 %"; padded after, so a shorter value + // written over a longer one leaves no digits behind. + feeds.add(Feed(File(dir, "perf_now"), load.source, load.read, "%-6s\n", percent = true)) + } + pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { clock -> + feeds.add(clock) + feeds.add(Feed(File(dir, "cur_freq"), clock.source, { clock.read()?.times(1_000_000) }, "%-10d\n")) + } + pick("temp", tempSources(), "%6d\n") { milliCelsius(it) }?.let { feeds.add(it) } + feeds.forEach { feed -> feed.read()?.let { write(feed, it) } } + Log.i(TAG, "hud: kgsl stats from the session's own: " + MANGOHUD_FILES.joinToString { name -> + "$name " + (feeds.firstOrNull { it.file.name == name }?.source ?: "none") + }) + true + } catch (e: Exception) { + Log.w(TAG, "hud: could not stand in for kgsl stats: $e") + false + } + } + + override fun start() { + if (feeds.isEmpty()) return + running = true + thread = Thread({ + while (running) { + try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } + if (!running) break + for (feed in feeds) feed.read()?.let { write(feed, it) } + } + }, "gpu-stats").apply { isDaemon = true; start() } + } + + override fun stop() { + running = false + thread?.interrupt() + thread = null + } + + /** Load: a file that states it in percent, else KGSL's busy and total of the last sample. */ + private fun loadFeed(): Feed? { + pick("gpu_busy_percentage", LOAD_SOURCES, "%3d %%\n") { it.coerceIn(0, 100) }?.let { return it } + val gpubusy = "$KGSL/gpubusy" + val read = { + runCatching { + val parts = File(gpubusy).readText().trim().split(Regex("\\s+")) + val total = parts[1].toLong() + if (total > 0) (parts[0].toLong() * 100 / total).coerceIn(0, 100) else 0L + }.getOrNull() + } + return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null + } + + /** The first of [sources] that reads as a number, its value put in MangoHud's unit by [unit]. */ + private fun pick(name: String, sources: List, format: String, unit: (Long) -> Long?): Feed? { + for (source in sources) { + val read = { firstNumber(source)?.let(unit) } + if (read() != null) return Feed(File(dir, name), source, read, format) + } + return null + } + + /** In place and at one length, so a reader that keeps the file open sees each value whole. */ + private fun write(feed: Feed, value: Long) { + try { + val text = if (feed.percent) String.format(feed.format, "$value %") else String.format(feed.format, value) + RandomAccessFile(feed.file, "rw").use { it.seek(0); it.write(text.toByteArray()) } + } catch (e: Exception) { + } + } + + companion object { + private const val TAG = "SessionService" + const val KGSL = "/sys/class/kgsl/kgsl-3d0" + /** Must match LinuxRuntime.bindAdrenoStats. */ + const val MSM_LOAD = "/sys/kernel/debug/dri/0/perf_now" + const val MSM_CLOCK = "/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq" + private const val PERIOD_MS = 500L + private val MANGOHUD_FILES = listOf("gpu_busy_percentage", "clock_mhz", "temp") + /** Qualcomm's own GPU summary, beside KGSL in vendor kernels, under the plain sysfs label. */ + private const val QCOM_GPU = "/sys/kernel/gpu" + private val LOAD_SOURCES = listOf( + "$KGSL/gpu_busy_percentage", "$KGSL/devfreq/gpu_load", "$KGSL/gpuload", "$QCOM_GPU/gpu_busy", + ) + /** The GPU's devfreq node is named by its register address, which differs between Snapdragons. */ + private val DEVFREQ_NODES = listOf( + "kgsl-3d0", "3d00000.qcom,kgsl-3d0", "2c00000.qcom,kgsl-3d0", "5000000.qcom,kgsl-3d0", "5900000.qcom,kgsl-3d0", + ) + + private fun clockSources(): List { + val sources = mutableListOf( + "$KGSL/clock_mhz", "$KGSL/gpu_clock", "$KGSL/gpuclk", "$KGSL/devfreq/cur_freq", "$QCOM_GPU/gpu_clock", + ) + DEVFREQ_NODES.forEach { sources.add("/sys/class/devfreq/$it/cur_freq") } + File("/sys/class/devfreq").list()?.sorted()?.forEach { node -> + if (node.contains("kgsl") || node.contains("gpu")) sources.add("/sys/class/devfreq/$node/cur_freq") + } + return sources.distinct() + } + + private fun tempSources(): List = + listOfNotNull("$KGSL/temp", "$KGSL/devfreq/temp", "$QCOM_GPU/temp", LinuxRuntime.gpuTempSource()).distinct() + + /** The first integer in the file ("550", "37 %", "550000000"), or null. */ + private fun firstNumber(path: String): Long? = + runCatching { Regex("-?\\d+").find(File(path).readText())?.value?.toLong() }.getOrNull() + + /** Hz, kHz or MHz to MHz, as the files differ; a stopped clock reads 0. */ + private fun mhz(raw: Long): Long? = when { + raw < 0 -> null + raw > 10_000_000 -> raw / 1_000_000 + raw > 10_000 -> raw / 1_000 + else -> raw + } + + /** °C or m°C to MangoHud's m°C; nothing a GPU reads at is below 1 °C or above 1000. */ + private fun milliCelsius(raw: Long): Long? = when { + raw <= 0 -> null + raw < 1000 -> raw * 1000 + else -> raw + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt new file mode 100644 index 000000000..fb0048933 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt @@ -0,0 +1,141 @@ +package com.droiddeck.launcher.session + +import android.os.SystemClock +import android.system.Os +import android.util.Log +import com.droiddeck.launcher.core.SessionPart +import com.droiddeck.launcher.runtime.LinuxRuntime +import java.io.File +import java.io.RandomAccessFile + +/** + * The session's `/sys/class/hwmon`, as mangoapp (Deck mode's performance overlay) looks there. + * + * MangoHud finds a CPU's temperature by an hwmon sensor named `cpuN_thermal`, and an Adreno's + * temperature, fan and power by one whose name has `gpuss` in it - a mainline Arm kernel's names. + * An Android kernel has neither (its hwmon holds the PMIC's sensors, or nothing), and where the + * app may not list hwmon at all the overlay logged an error twice a second. So the overlay showed + * no CPU temperature and no fan. This is two sensors named so: + * + * - `hwmon0` (`cpu0_thermal`): `temp1_input` linked to a CPU thermal zone the app can read; + * - `hwmon1` (`gpuss0_thermal`): `temp1_input` linked to the GPU's zone, and `fan1_input` with + * the device's fan speed where it has a fan the app can read - an AYN handheld's tachometer + * (`/sys/class/gpio5_pwm2/speed`), or a `fan*_input` in the device's own hwmon; + * - `hwmon2` (`slg4ax46073v`): the same fan, for Valve's own Qualcomm reader (msm.cpp), which + * looks for its fan controller by that name and halves what it reads - that part reports twice + * the real speed - so this one holds twice it. + * + * Nothing in a phone's own hwmon is read by the overlay, so standing in for it loses nothing. + */ +class HwmonComponent(val dir: File, private val root: File) : SessionPart() { + @Volatile private var running = false + private var thread: Thread? = null + private var fanSource: String? = null + private val fanFile = File(dir, "hwmon1/fan1_input") + private val valveFanFile = File(dir, "hwmon2/fan1_input") + private var lastRpm = 0L + private var lastRpmAt = 0L + + /** Writes the sensors; true when the directory should be bound over `/sys/class/hwmon`. */ + fun prepare(): Boolean = try { + dir.mkdirs() + clear(dir) + val cpu = LinuxRuntime.cpuTempSource() + if (cpu != null) sensor("hwmon0", "cpu0_thermal").also { link(cpu, File(it, "temp1_input")) } + val gpu = LinuxRuntime.gpuTempSource() + fanSource = FAN_SOURCES().firstOrNull { readNumber(it) != null } + if (gpu != null || fanSource != null) { + val sensor = sensor("hwmon1", "gpuss0_thermal") + if (gpu != null) link(gpu, File(sensor, "temp1_input")) + if (fanSource != null) { + sensor("hwmon2", "slg4ax46073v") + writeFan(sampleFan()) + } + } + Log.i(TAG, "hud: hwmon cpu temp ${cpu ?: "none"}, gpu temp ${gpu ?: "none"}, fan ${fanSource ?: "none"}") + cpu != null || gpu != null || fanSource != null + } catch (e: Exception) { + Log.w(TAG, "hud: could not write the session's hwmon: $e") + false + } + + override fun start() { + if (fanSource == null) return + running = true + thread = Thread({ + while (running) { + try { Thread.sleep(FAN_PERIOD_MS) } catch (e: InterruptedException) { break } + // A tachometer read busy-waits 100 ms in the kernel: only while the overlay shows. + if (running && OverlayShown.check(root)) writeFan(sampleFan()) + } + }, "hwmon-fan").apply { isDaemon = true; start() } + } + + override fun stop() { + running = false + thread?.interrupt() + thread = null + } + + /** + * The fan's speed in RPM. An AYN tachometer counts pulses over a short window per read and + * often catches none, reading 0 while the fan turns; a reading is held until a newer one + * comes or [FAN_HOLD_MS] passes with only zeros, when the fan is taken to have stopped. + */ + private fun sampleFan(): Long { + val now = SystemClock.elapsedRealtime() + val rpm = fanSource?.let { readNumber(it) } ?: 0L + if (rpm > 0) { + lastRpm = rpm + lastRpmAt = now + } else if (now - lastRpmAt > FAN_HOLD_MS) { + lastRpm = 0 + } + return lastRpm + } + + /** In place and at one length, so a reader that keeps the file open sees each value whole. */ + private fun writeFan(rpm: Long) { + for ((file, value) in listOf(fanFile to rpm, valveFanFile to rpm * 2)) { + try { + RandomAccessFile(file, "rw").use { it.seek(0); it.write(String.format("%6d\n", value).toByteArray()) } + } catch (e: Exception) { + } + } + } + + private fun sensor(name: String, label: String): File = + File(dir, name).apply { mkdirs(); File(this, "name").writeText("$label\n") } + + private fun link(target: String, link: File) { + java.nio.file.Files.deleteIfExists(link.toPath()) + Os.symlink(target, link.path) + } + + /** Only files and links are made here; deleting a link must not follow it into sysfs. */ + private fun clear(root: File) { + root.listFiles()?.forEach { f -> + if (!java.nio.file.Files.isSymbolicLink(f.toPath()) && f.isDirectory) clear(f) + java.nio.file.Files.deleteIfExists(f.toPath()) + } + } + + companion object { + private const val TAG = "SessionService" + /** The AYN tachometer reads 0 if read again within a couple of seconds. */ + private const val FAN_PERIOD_MS = 3_000L + private const val FAN_HOLD_MS = 10_000L + + private val FAN_SOURCES = { + val sources = mutableListOf("/sys/class/gpio5_pwm2/speed", "/sys/class/gpio5_pwm/speed") + File("/sys/class/hwmon").listFiles()?.sortedBy { it.name }?.forEach { hwmon -> + hwmon.listFiles { f -> f.name.matches(Regex("fan\\d+_input")) }?.sortedBy { it.name } + ?.forEach { sources.add(it.path) } + } + sources + } + + private fun readNumber(path: String): Long? = + runCatching { File(path).readText().trim().toLong() }.getOrNull()?.takeIf { it >= 0 } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt b/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt new file mode 100644 index 000000000..6c1c44700 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt @@ -0,0 +1,17 @@ +package com.droiddeck.launcher.session + +import java.io.File + +/** + * Whether Deck mode's performance overlay is on screen. The client sets its level by rewriting + * mangoapp's config (bannerlator-session makes it, `/tmp/mangohud.*`), and `no_display` is the + * level that hides it. The feeds whose reads cost something - the fan's tachometer busy-waits in + * the kernel, GPU memory walks every process's mappings - only run while it shows. + */ +object OverlayShown { + fun check(root: File): Boolean { + val config = File(root, "tmp").listFiles { f -> f.name.startsWith("mangohud.") } + ?.maxByOrNull { it.lastModified() } ?: return false + return runCatching { !config.readText().contains("no_display") }.getOrDefault(false) + } +} 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 413cc386e..17eb197dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -277,7 +277,7 @@ class SessionService : Service() { // The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the // same set-up as a Steam session: it gets what the client and its games are started with. val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP - addClientEnvironment(guest, runtimeDir, steamHere) + addClientEnvironment(guest, steamHere) val pulse = startAudio(guest, sessionDir) @@ -483,7 +483,7 @@ class SessionService : Service() { } /** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */ - private fun addClientEnvironment(guest: MutableList, runtimeDir: File, steamHere: Boolean) { + private fun addClientEnvironment(guest: MutableList, steamHere: Boolean) { guest.add("/usr/bin/env") guest.add("-i") guest.add("HOME=/root") @@ -494,7 +494,7 @@ class SessionService : Service() { // Without this the session is UTC: the client's clock, its logs and every timestamp in a // session bundle sit hours off the device's. Bannerlator carries the same line. guest.add("TZ=" + java.util.TimeZone.getDefault().id) - guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path) + guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR) guest.add("XDG_SESSION_TYPE=wayland") guest.add("WAYLAND_DISPLAY=wayland-0") guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1") @@ -689,6 +689,33 @@ class SessionService : Service() { battery.attach(this) components.add(battery) binds.add(battery.dir.path + ":/sys/class/power_supply") + // The overlay's CPU and GPU temperatures and the fan, as hwmon sensors it knows by name. + val hwmon = HwmonComponent(File(filesDir, "session/sys/hwmon"), LinuxRuntime.rootDir(this)) + if (hwmon.prepare()) { + hwmon.attach(this) + components.add(hwmon) + binds.add(hwmon.dir.path + ":/sys/class/hwmon") + } + // CPU load for everything in the session that reads /proc/stat, the overlay among them. + val cpuStat = CpuStatComponent(File(filesDir, "session/proc-stat")) + if (cpuStat.prepare()) { + cpuStat.attach(this) + components.add(cpuStat) + binds.add(cpuStat.file.path + ":/proc/stat") + } + // The GPU memory in use for the overlay's VRAM lines, which it would read from tracefs. + val gpuMem = GpuMemComponent(File(LinuxRuntime.rootDir(this), "run/droiddeck-hud/gpu-mem"), LinuxRuntime.rootDir(this)) + if (gpuMem.prepare()) { + gpuMem.attach(this) + components.add(gpuMem) + } + // The GPU's load and temperature for the performance overlay, where KGSL's sysfs is refused. + val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0")) + if (gpuStats.prepare()) { + gpuStats.attach(this) + components.add(gpuStats) + binds.addAll(gpuStats.binds()) + } // Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this // listener, which drives the phone's vibrator (see RumbleComponent). if (controllersOn) components.add(RumbleComponent().also { it.attach(this) }) diff --git a/tools/build_local.sh b/tools/build_local.sh index 484e1f47b..b4ca4a6ab 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -221,9 +221,13 @@ while read -r package_sha256 package_url; do 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" -for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1 libtracefs.so.1; do +for library in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do cp -L "${mango_pkgs}/usr/lib/${library}" "${mango_dir}/${library}" done +# Ours, not the package's: GPU memory without tracefs (tools/mangoapp/libtracefs-shim.c). +docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" -v "${repo_root}:/src" -w /src "${image_name}" \ + aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \ + -o app/src/main/assets/linuxfs/usr/local/lib/mangoapp/libtracefs.so.1 tools/mangoapp/libtracefs-shim.c mkdir -p "${linuxfs_dir}/usr/local/bin" install -m644 "${repo_root}/tools/mangoapp/mangoapp" "${linuxfs_dir}/usr/local/bin/mangoapp" diff --git a/tools/gamescope/PATCHES.md b/tools/gamescope/PATCHES.md index ad4fc4b34..1952e6c9b 100644 --- a/tools/gamescope/PATCHES.md +++ b/tools/gamescope/PATCHES.md @@ -39,5 +39,13 @@ library list, before anything is published. then focus is handed over again. `GAMESCOPE_RESTORE_FOCUS_WINDOW` on the root window asks for the same restore from outside (the session script's resume watcher). +- `0113-steam-overlay-keeps-game-keyboard-focus.patch` - this app: the Steam client opens the Quick + Access Menu and Steam menu over a game with `STEAM_INPUT_FOCUS` 1, which moves X keyboard focus to + its overlay; wine then deactivates the game and a fullscreen game minimizes itself, freezing + behind the menu instead of running on as on a Steam Deck. Steam's own overlay taking input now + keeps keyboard focus on the game, as mode 2 does; its input comes from the controller through + Steam Input, not the X keyboard. The pointer warps gamescope makes as input moves to Steam and + back are skipped around it, since the game - still taking input - saw them as a mouse jump. + Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has. diff --git a/tools/gamescope/patches/0113-steam-overlay-keeps-game-keyboard-focus.patch b/tools/gamescope/patches/0113-steam-overlay-keeps-game-keyboard-focus.patch new file mode 100644 index 000000000..08ef94074 --- /dev/null +++ b/tools/gamescope/patches/0113-steam-overlay-keeps-game-keyboard-focus.patch @@ -0,0 +1,104 @@ +From: DroidDeck +Subject: [PATCH] steamcompmgr: keep the game's keyboard focus under Steam's overlay + +The Steam client opens its Quick Access Menu and Steam menu over a game by setting +STEAM_OVERLAY 1 and STEAM_INPUT_FOCUS 1 on its window. Mode 1 moves X keyboard focus to the +overlay, so the game's window gets FocusOut: wine moves the foreground to the desktop, a fullscreen +game deactivates and minimizes itself (WM_CHANGE_STATE IconicState), and it stops rendering behind +the menu - frozen, all threads asleep, until the menu closes (Alan Wake's American Nightmare, +Proton Experimental ARM64). On a Steam Deck the game runs on behind the menu. + +Mode 2 already means "input to the overlay, keyboard focus stays on the focus window". Treat +Steam's own overlay taking input in mode 1 the same way at the three places mode 2 is honoured. +The overlay's input does not need X keyboard focus: the controller drives it through Steam Input. +Measured by rewriting the property to 2 by hand: the game kept running behind the menu and the +controller still moved only the menu. + +With the game keeping focus, the pointer resets gamescope makes when input moves to Steam (warp +to the centre, park in the corner) and back (warp to the centre) reached it as a sudden mouse +move: a mouse-look camera spun every time the menu opened and closed. Skip them while Steam's +overlay opens over a game that keeps focus, and as it closes - by then the overlay has dropped its +STEAM_INPUT_FOCUS, so whether the opening kept focus is remembered. + +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 372a82c..3fab3c6 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -1321,6 +1321,22 @@ window_is_steam( steamcompmgr_win_t *w ) + return w && ( w->isSteamLegacyBigPicture || w->appID == 769 ); + } + ++// Whether keyboard focus stays on the focus window while this window has input focus. ++// STEAM_INPUT_FOCUS 2 asks for that. The Steam client also opens its Quick Access Menu and Steam ++// menu over a game with 1, which moves X keyboard focus to the overlay: wine then deactivates the ++// game, a fullscreen game minimizes itself, and it froze behind the menu instead of running on as ++// on a Steam Deck. The overlay's own input does not need X keyboard focus (the controller drives ++// it through Steam Input), so Steam's overlay keeps the game's keyboard focus too. ++static bool ++window_keeps_game_keyboard_focus( steamcompmgr_win_t *w ) ++{ ++ if ( !w ) ++ return false; ++ if ( w->inputFocusMode == 2 ) ++ return true; ++ return w->inputFocusMode == 1 && w->isOverlay && window_is_steam( w ); ++} ++ + static bool + window_is_vr_scene_app( steamcompmgr_win_t *w ) + { +@@ -4728,7 +4744,7 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win + + if ( gamescope::VirtualConnectorIsSingleOutput() ) + { +- if ( inputFocus && inputFocus->inputFocusMode == 2 ) ++ if ( window_keeps_game_keyboard_focus( inputFocus ) ) + keyboardFocusWin = ctx->focus.focusWindow; + } + else +@@ -4834,12 +4850,26 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win + if ( ctx->focus.inputFocusWindow != inputFocus || + ctx->focus.inputFocusMode != inputFocus->inputFocusMode ) + { ++ // Steam's overlay opening over a game, or closing again, while the game keeps keyboard focus ++ // (window_keeps_game_keyboard_focus): the game is still taking input, so a pointer warp ++ // reaches it as a sudden mouse move - the camera of a mouse-look game spun as the Quick ++ // Access Menu opened and as it closed. The overlay is driven by the controller and the ++ // cursor is hidden, so leave the pointer where it is. Closing, the overlay has already ++ // dropped its STEAM_INPUT_FOCUS, so whether the opening kept focus is remembered. ++ static bool s_bOverlayKeptGameFocus = false; ++ bool bOverlayKeepsGameFocus = window_keeps_game_keyboard_focus( inputFocus ) && ++ ctx->focus.focusWindow && !window_is_steam( ctx->focus.focusWindow ); ++ bool bSkipPointerReset = bOverlayKeepsGameFocus || s_bOverlayKeptGameFocus; ++ s_bOverlayKeptGameFocus = bOverlayKeepsGameFocus; ++ + // If the window doesn't want focus when hidden, move it away + // as we are going to hide it straight after. + // otherwise, if we switch from wanting it to not + // (steam -> game) + // put us back in the centre of the screen. +- if (window_wants_no_focus_when_mouse_hidden(inputFocus)) ++ if ( bSkipPointerReset ) ++ ; ++ else if (window_wants_no_focus_when_mouse_hidden(inputFocus)) + ctx->focus.bResetToCorner = true; + else if ( window_wants_no_focus_when_mouse_hidden(inputFocus) != window_wants_no_focus_when_mouse_hidden(ctx->focus.inputFocusWindow) ) + ctx->focus.bResetToCenter = true; +@@ -5340,7 +5370,7 @@ determine_and_apply_focus( global_focus_t *pFocus ) + pFocus->inputFocusMode = pFocus->inputFocusWindow->inputFocusMode; + + if ( gamescope::VirtualConnectorIsSingleOutput() && +- pFocus->inputFocusMode == 2 ) ++ window_keeps_game_keyboard_focus( pFocus->inputFocusWindow ) ) + { + pFocus->keyboardFocusWindow = pFocus->overrideWindow + ? pFocus->overrideWindow +@@ -6485,7 +6515,7 @@ handle_wl_surface_id(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, uint32_t surfac + steamcompmgr_win_t *keyboardFocusWindow = pCurrentFocus->inputFocusWindow; + + if ( gamescope::VirtualConnectorIsSingleOutput() && +- keyboardFocusWindow && keyboardFocusWindow->inputFocusMode == 2 ) ++ window_keeps_game_keyboard_focus( keyboardFocusWindow ) ) + keyboardFocusWindow = pCurrentFocus->focusWindow; + + if ( w == keyboardFocusWindow ) diff --git a/tools/gamescope/release.env b/tools/gamescope/release.env index af715c671..55f3940d8 100644 --- a/tools/gamescope/release.env +++ b/tools/gamescope/release.env @@ -1,2 +1,2 @@ -GAMESCOPE_TAG=gamescope-3.16.29-p4 -GAMESCOPE_SHA256=e750fb34864bc2eb1160aeed29593e402bf9c8153fafd9fd18c77450bdd50574 +GAMESCOPE_TAG=gamescope-3.16.29-p5 +GAMESCOPE_SHA256=188144e5564e020d0bc485a1f4f90e1c5fd61d0d1d6dd29db21e8b019036a5c6 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 1f55e3089..cb6595340 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -172,6 +172,8 @@ if [ -z "${BL_INSIDE:-}" ]; then # HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1); # gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours. [ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled) + # Extra gamescope arguments for an experiment (--debug-focus, say), from Download/droiddeck-env. + [ -n "${BL_GAMESCOPE_ARGS:-}" ] && read -r -a _bl_extra <<<"$BL_GAMESCOPE_ARGS" && args+=("${_bl_extra[@]}") # Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by # mangoapp, which gamescope starts beside the client; the client sets the level by rewriting # MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu diff --git a/tools/mangoapp/libtracefs-shim.c b/tools/mangoapp/libtracefs-shim.c new file mode 100644 index 000000000..58506cada --- /dev/null +++ b/tools/mangoapp/libtracefs-shim.c @@ -0,0 +1,144 @@ +// libtracefs.so.1 for mangoapp: the GPU memory Valve's overlay reads, without the kernel's tracing. +// +// Valve's mangoapp (msm.cpp, ftrace_gpumem.c) takes the GPU's memory in use - the VRAM and PVRAM +// lines - from the gpu_mem_total tracepoint (pid 0, the device's total) through libtracefs. An +// Android app may not use tracefs at all: the instance could not be made, and both lines read 0. +// This stands in for the libtracefs calls mangoapp makes, and hands its record walker one +// gpu_mem_total record carrying what the app writes to /run/droiddeck-hud/gpu-mem (bytes; the +// session's GpuMemComponent, from KGSL's own accounting). Only mangoapp loads this copy. +// +// The walker reads libtraceevent's structures directly, so the ones below are libtraceevent +// 1.8.2's (event-parse.h), the version tools/mangoapp/packages.txt pins; tep_find_event_by_record +// and tep_read_number are answered here too, ahead of the real libtraceevent in mangoapp's lookup. + +#include +#include +#include +#include +#include +#include + +#define EXPORT __attribute__((visibility("default"))) + +struct tep_handle; +struct tep_event; +struct tep_print_arg; +struct tep_print_parse; + +struct tep_format_field { + struct tep_format_field *next; + struct tep_event *event; + char *type; + char *name; + char *alias; + int offset; + int size; + unsigned int arraylen; + unsigned int elementsize; + unsigned long flags; +}; + +struct tep_format { + int nr_common; + int nr_fields; + struct tep_format_field *common_fields; + struct tep_format_field *fields; +}; + +struct tep_print_fmt { + char *format; + struct tep_print_arg *args; + struct tep_print_parse *print_cache; +}; + +struct tep_event { + struct tep_handle *tep; + char *name; + int id; + int flags; + struct tep_format format; + struct tep_print_fmt print_fmt; + char *system; + void *handler; + void *context; +}; + +struct tep_record { + unsigned long long ts; + unsigned long long offset; + long long missed_events; + int record_size; + int size; + void *data; + int cpu; + int ref_count; + int locked; + void *priv; +}; + +// gpu_mem_total's record: gpu_id, pid, size - as the kernel lays it out after the common fields. +struct gpu_mem_total { + unsigned int gpu_id; + unsigned int pid; + unsigned long long size; +}; + +static struct tep_format_field size_field = { + .name = "size", .offset = offsetof(struct gpu_mem_total, size), .size = 8}; +static struct tep_format_field pid_field = { + .next = &size_field, .name = "pid", .offset = offsetof(struct gpu_mem_total, pid), .size = 4}; +static struct tep_event gpu_mem_event = { + .name = "gpu_mem_total", .system = "gpu_mem", .format = {.nr_fields = 2, .fields = &pid_field}}; + +// Never dereferenced by mangoapp: they only have to be non-null. +static char handle; +static char instance; + +static unsigned long long gpu_mem_bytes(void) { + unsigned long long bytes = 0; + FILE *f = fopen("/run/droiddeck-hud/gpu-mem", "r"); + if (f) { + if (fscanf(f, "%llu", &bytes) != 1) bytes = 0; + fclose(f); + } + return bytes; +} + +EXPORT void *tracefs_instance_create(const char *name) { (void)name; return &instance; } +EXPORT char *tracefs_instance_get_dir(void *inst) { (void)inst; return strdup("/run/droiddeck-hud"); } +EXPORT void tracefs_put_tracing_file(char *name) { free(name); } +EXPORT struct tep_handle *tracefs_local_events(const char *dir) { (void)dir; return (struct tep_handle *)&handle; } +EXPORT int tracefs_event_enable(void *inst, const char *system, const char *event) { (void)inst; (void)system; (void)event; return 0; } +EXPORT int tracefs_event_disable(void *inst, const char *system, const char *event) { (void)inst; (void)system; (void)event; return 0; } +EXPORT bool tracefs_instance_is_new(void *inst) { (void)inst; return false; } +EXPORT int tracefs_instance_destroy(void *inst) { (void)inst; return 0; } +EXPORT void tracefs_instance_free(void *inst) { (void)inst; } + +EXPORT int tracefs_iterate_raw_events(struct tep_handle *tep, void *inst, cpu_set_t *cpus, int cpu_size, + int (*callback)(struct tep_event *, struct tep_record *, int, void *), + void *context) { + (void)tep; (void)inst; (void)cpus; (void)cpu_size; + if (!callback) return -1; + struct gpu_mem_total data = {.gpu_id = 0, .pid = 0, .size = gpu_mem_bytes()}; + struct tep_record record = {.size = sizeof(data), .record_size = sizeof(data), .data = &data}; + callback(&gpu_mem_event, &record, 0, context); + return 0; +} + +EXPORT struct tep_event *tep_find_event_by_record(struct tep_handle *tep, struct tep_record *record) { + (void)tep; (void)record; + return &gpu_mem_event; +} + +EXPORT unsigned long long tep_read_number(struct tep_handle *tep, const void *ptr, int size) { + (void)tep; + switch (size) { + case 1: return *(const unsigned char *)ptr; + case 2: return *(const unsigned short *)ptr; + case 4: return *(const unsigned int *)ptr; + case 8: return *(const unsigned long long *)ptr; + default: return 0; + } +} + +EXPORT void tep_free(struct tep_handle *tep) { (void)tep; } diff --git a/tools/mangoapp/packages.txt b/tools/mangoapp/packages.txt index 5df866be0..9637e246a 100644 --- a/tools/mangoapp/packages.txt +++ b/tools/mangoapp/packages.txt @@ -1,4 +1,4 @@ -# Valve's aarch64 mangoapp (the Deck performance overlay) and the five libraries it needs that the +# Valve's aarch64 mangoapp (the Deck performance overlay) and the libraries it needs that the # runtime does not have, at the versions it was built against (its .BUILDINFO: fmt 10.2.0, # spdlog 1.13.0). CI fetches each, checks the sha256 and stages the files named below into the apk # under usr/local/lib/mangoapp; SessionFiles copies them into the runtime at every launch. @@ -7,5 +7,6 @@ ef7411229ea4e9b3ab4e36ac356a85b5072f362ee1de68b28d09c43f0b0ecc44 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/fmt-10.2.0-1-aarch64.pkg.tar.zst c9bfd2c3f15071329d381b9c2061d07503c4787ceb7001b9dfbf72a23c85cb04 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/spdlog-1.13.0-1-aarch64.pkg.tar.zst f9fae6c84a0382188f1cb20761424051593fd09b1456191cc250bd16e0a1d7a5 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/glfw-3.4-2-aarch64.pkg.tar.zst +# libtracefs is ours (libtracefs-shim.c), and reads libtraceevent's structures as 1.8.2 lays them +# out: moving libtraceevent off 1.8.2 means checking event-parse.h against the shim. 0cccdc9c92cb3cae1e37ebeb0ed20b93bb3b5000c8f955b63d0e07bd878c7018 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtraceevent-1%3A1.8.2-2-aarch64.pkg.tar.zst -47ffd6a953959d6a0df44539bef30a67d0b8c95cce0053273908d9d70ef7abc4 https://steamdeck-packages.steamos.cloud/archlinux-deckard/archlinux/mash-20240705.2/extra/os/aarch64/libtracefs-1.8.0-1-aarch64.pkg.tar.zst