From f274a1ad68d6f36dc8db153bc0ec6a585489112d Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 12:38:30 -0400 Subject: [PATCH 1/5] fix: keep the performance overlay running and fed under an enforcing SELinux policy --- .../launcher/runtime/LinuxRuntime.java | 97 ++++++++++++++++- .../launcher/session/GpuStatsComponent.kt | 100 ++++++++++++++++++ .../launcher/session/SessionService.kt | 7 ++ 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt 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..ed897811c 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -238,6 +238,7 @@ public final class LinuxRuntime { bindGpuNode(context, cmd); bindAdrenoStats(cmd); bindCpuTemps(cmd, root); + bindCpuHwmon(context.getCacheDir(), cmd); if (extraBinds != null) { for (String spec : extraBinds) bind(cmd, spec); } @@ -282,12 +283,94 @@ 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"); + } + } + + /** + * MangoHud's CPU temperature: it looks first in {@code /sys/class/hwmon} for a sensor named + * {@code cpuN_thermal} (a mainline Arm kernel's), then at thermal zones of a type it knows; an + * Android kernel has neither - its hwmon holds the PMIC's sensors and its zones are named by + * the vendor (cpu-1-0-usr) - so the overlay showed no CPU temperature, and where the app may + * not list hwmon the overlay logged an error twice a second. The session's hwmon is one sensor + * named so, reading a CPU zone the app can read. Nothing else in a phone's hwmon is read by it. + */ + private static void bindCpuHwmon(File cacheDir, List cmd) { + String zone = null; + 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()) { + zone = temp.getPath(); + break; + } + } + if (zone == null) return; + File sensor = new File(cacheDir, "hwmon/hwmon0"); + try { + if (!sensor.isDirectory() && !sensor.mkdirs()) return; + Files.write(new File(sensor, "name").toPath(), "cpu0_thermal\n".getBytes(StandardCharsets.US_ASCII)); + File input = new File(sensor, "temp1_input"); + Files.deleteIfExists(input.toPath()); + Os.symlink(zone, input.getPath()); + } catch (IOException | ErrnoException e) { + return; + } + bind(cmd, sensor.getParent() + ":/sys/class/hwmon"); + android.util.Log.i("LinuxRuntime", "hud: cpu temp from " + zone); + } + + /** 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"); } /** @@ -300,9 +383,9 @@ public final class LinuxRuntime { */ private static void bindAdrenoStats(List cmd) { 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"}, @@ -312,6 +395,16 @@ public final class LinuxRuntime { for (String[] stat : stats) { if (stat[0] != null) 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/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt new file mode 100644 index 000000000..2427787e5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -0,0 +1,100 @@ +package com.droiddeck.launcher.session + +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 Adreno's stats as mangoapp (Deck mode's performance overlay) reads them. + * + * MangoHud takes an Adreno's load and temperature from `/sys/class/kgsl/kgsl-3d0` and ends the + * process on a file there it may not read - not a file that is missing, which it skips. Under an + * enforcing SELinux policy (every retail phone) KGSL's sysfs is refused to apps, so mangoapp died + * on start and the session ran with no overlay. Where that is so, a directory of our own is bound + * there instead: `gpu_busy_percentage` worked out from `gpubusy` (busy and total cycles of the + * last sample) and `clock_mhz` from `gpuclk` - files with labels of their own that policies often + * leave to apps for GPU profilers - when they are readable, and `temp` linked to the GPU's + * thermal zone when there is one. What is not readable is left out, and the overlay shows no line + * for it. Where KGSL's own files are readable, nothing is done. + */ +class GpuStatsComponent(val dir: File) : SessionPart() { + @Volatile private var running = false + private var thread: Thread? = null + private val load = File(dir, "gpu_busy_percentage") + private val clock = File(dir, "clock_mhz") + + /** Writes the directory; true when it should be bound over [KGSL]. */ + fun prepare(): Boolean { + if (!File(KGSL).exists() || File(KGSL, "gpu_busy_percentage").canRead()) return false + return try { + dir.mkdirs() + dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } + val busy = File(KGSL, "gpubusy").canRead() + if (busy) writeLoad(0) + val clk = File(KGSL, "gpuclk").canRead() + if (clk) readClock()?.let { write(clock, String.format("%5d\n", it)) } + val temp = LinuxRuntime.gpuTempSource() + if (temp != null) Os.symlink(temp, File(dir, "temp").path) + Log.i(TAG, "hud: kgsl stats from the session's own: load " + (if (busy) "from gpubusy" else "none") + + ", clock " + (if (clk) "from gpuclk" else "none") + ", temp " + (temp ?: "none")) + true + } catch (e: Exception) { + Log.w(TAG, "hud: could not stand in for kgsl stats: $e") + false + } + } + + override fun start() { + if (!load.exists() && !clock.exists()) return + running = true + thread = Thread({ + while (running) { + try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } + if (!running) break + if (load.exists()) readBusy()?.let { writeLoad(it) } + if (clock.exists()) readClock()?.let { write(clock, String.format("%5d\n", it)) } + } + }, "gpu-stats").apply { isDaemon = true; start() } + } + + override fun stop() { + running = false + thread?.interrupt() + thread = null + } + + private fun readBusy(): Int? = try { + val parts = File(KGSL, "gpubusy").readText().trim().split(Regex("\\s+")) + val busy = parts[0].toLong() + val total = parts[1].toLong() + if (total > 0) (busy * 100 / total).toInt().coerceIn(0, 100) else 0 + } catch (e: Exception) { + null + } + + /** The GPU clock in MHz; gpuclk is in Hz. */ + private fun readClock(): Int? = try { + (File(KGSL, "gpuclk").readText().trim().toLong() / 1_000_000).toInt() + } catch (e: Exception) { + null + } + + private fun writeLoad(percent: Int) = write(load, String.format("%3d %%\n", percent)) + + /** In place and at one length, so a reader that keeps the file open sees each value whole. */ + private fun write(file: File, text: String) { + try { + RandomAccessFile(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" + private const val PERIOD_MS = 500L + } +} 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 bb4a7dd89..c3e98f688 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -688,6 +688,13 @@ class SessionService : Service() { battery.attach(this) components.add(battery) binds.add(battery.dir.path + ":/sys/class/power_supply") + // 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.add(gpuStats.dir.path + ":" + GpuStatsComponent.KGSL) + } // 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) }) From 6f9f26553035b72969460bc97f2121c5ddf7c08a Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 12:53:17 -0400 Subject: [PATCH 2/5] fix: give the session a live /proc/stat and read GPU load and clock where other overlays do --- .../launcher/runtime/LinuxRuntime.java | 5 +- .../launcher/session/CpuStatComponent.kt | 110 ++++++++++++++++++ .../launcher/session/GpuStatsComponent.kt | 53 +++++---- .../launcher/session/SessionService.kt | 7 ++ 4 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/CpuStatComponent.kt 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 ed897811c..01cabd597 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -231,7 +231,10 @@ 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]); } } 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/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index 2427787e5..4f8e56c78 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -14,9 +14,10 @@ import java.io.RandomAccessFile * process on a file there it may not read - not a file that is missing, which it skips. Under an * enforcing SELinux policy (every retail phone) KGSL's sysfs is refused to apps, so mangoapp died * on start and the session ran with no overlay. Where that is so, a directory of our own is bound - * there instead: `gpu_busy_percentage` worked out from `gpubusy` (busy and total cycles of the - * last sample) and `clock_mhz` from `gpuclk` - files with labels of their own that policies often - * leave to apps for GPU profilers - when they are readable, and `temp` linked to the GPU's + * there instead: `gpu_busy_percentage` and `clock_mhz` from Qualcomm's `/sys/kernel/gpu` (load + * and MHz, under the plain sysfs label - where other Android overlays read them), else from KGSL's + * `gpubusy` (busy and total cycles of the last sample) and `gpuclk` (Hz), files with labels of + * their own that policies often leave to apps for GPU profilers; and `temp` linked to the GPU's * thermal zone when there is one. What is not readable is left out, and the overlay shows no line * for it. Where KGSL's own files are readable, nothing is done. */ @@ -32,14 +33,12 @@ class GpuStatsComponent(val dir: File) : SessionPart() { return try { dir.mkdirs() dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } - val busy = File(KGSL, "gpubusy").canRead() - if (busy) writeLoad(0) - val clk = File(KGSL, "gpuclk").canRead() - if (clk) readClock()?.let { write(clock, String.format("%5d\n", it)) } + val busy = readBusy()?.also { writeLoad(it) } != null + val clk = readClock()?.also { write(clock, String.format("%5d\n", it)) } != null val temp = LinuxRuntime.gpuTempSource() if (temp != null) Os.symlink(temp, File(dir, "temp").path) - Log.i(TAG, "hud: kgsl stats from the session's own: load " + (if (busy) "from gpubusy" else "none") + - ", clock " + (if (clk) "from gpuclk" else "none") + ", temp " + (temp ?: "none")) + Log.i(TAG, "hud: kgsl stats from the session's own: load " + (if (busy) loadFrom else "none") + + ", clock " + (if (clk) clockFrom else "none") + ", temp " + (temp ?: "none")) true } catch (e: Exception) { Log.w(TAG, "hud: could not stand in for kgsl stats: $e") @@ -66,20 +65,30 @@ class GpuStatsComponent(val dir: File) : SessionPart() { thread = null } - private fun readBusy(): Int? = try { - val parts = File(KGSL, "gpubusy").readText().trim().split(Regex("\\s+")) - val busy = parts[0].toLong() - val total = parts[1].toLong() - if (total > 0) (busy * 100 / total).toInt().coerceIn(0, 100) else 0 - } catch (e: Exception) { - null + private var loadFrom = "" + private var clockFrom = "" + + /** GPU load in percent: /sys/kernel/gpu's "NN %", else KGSL's busy and total cycles. */ + private fun readBusy(): Int? { + runCatching { File(QCOM_GPU, "gpu_busy").readText().trim().removeSuffix("%").trim().toInt() }.getOrNull()?.let { + loadFrom = "$QCOM_GPU/gpu_busy" + return it.coerceIn(0, 100) + } + return runCatching { + val parts = File(KGSL, "gpubusy").readText().trim().split(Regex("\\s+")) + val total = parts[1].toLong() + if (total > 0) (parts[0].toLong() * 100 / total).toInt().coerceIn(0, 100) else 0 + }.getOrNull()?.also { loadFrom = "$KGSL/gpubusy" } } - /** The GPU clock in MHz; gpuclk is in Hz. */ - private fun readClock(): Int? = try { - (File(KGSL, "gpuclk").readText().trim().toLong() / 1_000_000).toInt() - } catch (e: Exception) { - null + /** GPU clock in MHz: /sys/kernel/gpu's, else KGSL's gpuclk in Hz. */ + private fun readClock(): Int? { + runCatching { File(QCOM_GPU, "gpu_clock").readText().trim().toInt() }.getOrNull()?.let { + clockFrom = "$QCOM_GPU/gpu_clock" + return it + } + return runCatching { (File(KGSL, "gpuclk").readText().trim().toLong() / 1_000_000).toInt() }.getOrNull() + ?.also { clockFrom = "$KGSL/gpuclk" } } private fun writeLoad(percent: Int) = write(load, String.format("%3d %%\n", percent)) @@ -95,6 +104,8 @@ class GpuStatsComponent(val dir: File) : SessionPart() { companion object { private const val TAG = "SessionService" const val KGSL = "/sys/class/kgsl/kgsl-3d0" + /** Qualcomm's own GPU summary, beside KGSL in vendor kernels. */ + private const val QCOM_GPU = "/sys/kernel/gpu" private const val PERIOD_MS = 500L } } 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 c3e98f688..569a9153a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -688,6 +688,13 @@ class SessionService : Service() { battery.attach(this) components.add(battery) binds.add(battery.dir.path + ":/sys/class/power_supply") + // 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'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()) { From e4a5bf45b9053c4c7349eec73b7a0b38c13a5fb9 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 12:58:47 -0400 Subject: [PATCH 3/5] fix: feed the overlay's GPU stats from every Adreno source other Android overlays read --- .../launcher/session/GpuStatsComponent.kt | 136 +++++++++++------- 1 file changed, 87 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index 4f8e56c78..2151f2174 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -1,6 +1,5 @@ package com.droiddeck.launcher.session -import android.system.Os import android.util.Log import com.droiddeck.launcher.core.SessionPart import com.droiddeck.launcher.runtime.LinuxRuntime @@ -10,35 +9,39 @@ import java.io.RandomAccessFile /** * The Adreno's stats as mangoapp (Deck mode's performance overlay) reads them. * - * MangoHud takes an Adreno's load and temperature from `/sys/class/kgsl/kgsl-3d0` and ends the - * process on a file there it may not read - not a file that is missing, which it skips. Under an - * enforcing SELinux policy (every retail phone) KGSL's sysfs is refused to apps, so mangoapp died - * on start and the session ran with no overlay. Where that is so, a directory of our own is bound - * there instead: `gpu_busy_percentage` and `clock_mhz` from Qualcomm's `/sys/kernel/gpu` (load - * and MHz, under the plain sysfs label - where other Android overlays read them), else from KGSL's - * `gpubusy` (busy and total cycles of the last sample) and `gpuclk` (Hz), files with labels of - * their own that policies often leave to apps for GPU profilers; and `temp` linked to the GPU's - * thermal zone when there is one. What is not readable is left out, and the overlay shows no line - * for it. Where KGSL's own files are readable, nothing is done. + * 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. */ class GpuStatsComponent(val dir: File) : SessionPart() { @Volatile private var running = false private var thread: Thread? = null - private val load = File(dir, "gpu_busy_percentage") - private val clock = File(dir, "clock_mhz") + 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) /** Writes the directory; true when it should be bound over [KGSL]. */ fun prepare(): Boolean { - if (!File(KGSL).exists() || File(KGSL, "gpu_busy_percentage").canRead()) return false + 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()) } - val busy = readBusy()?.also { writeLoad(it) } != null - val clk = readClock()?.also { write(clock, String.format("%5d\n", it)) } != null - val temp = LinuxRuntime.gpuTempSource() - if (temp != null) Os.symlink(temp, File(dir, "temp").path) - Log.i(TAG, "hud: kgsl stats from the session's own: load " + (if (busy) loadFrom else "none") + - ", clock " + (if (clk) clockFrom else "none") + ", temp " + (temp ?: "none")) + feeds.clear() + loadFeed()?.let { feeds.add(it) } + pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { feeds.add(it) } + 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") @@ -47,14 +50,13 @@ class GpuStatsComponent(val dir: File) : SessionPart() { } override fun start() { - if (!load.exists() && !clock.exists()) return + if (feeds.isEmpty()) return running = true thread = Thread({ while (running) { try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } if (!running) break - if (load.exists()) readBusy()?.let { writeLoad(it) } - if (clock.exists()) readClock()?.let { write(clock, String.format("%5d\n", it)) } + for (feed in feeds) feed.read()?.let { write(feed, it) } } }, "gpu-stats").apply { isDaemon = true; start() } } @@ -65,38 +67,33 @@ class GpuStatsComponent(val dir: File) : SessionPart() { thread = null } - private var loadFrom = "" - private var clockFrom = "" - - /** GPU load in percent: /sys/kernel/gpu's "NN %", else KGSL's busy and total cycles. */ - private fun readBusy(): Int? { - runCatching { File(QCOM_GPU, "gpu_busy").readText().trim().removeSuffix("%").trim().toInt() }.getOrNull()?.let { - loadFrom = "$QCOM_GPU/gpu_busy" - return it.coerceIn(0, 100) + /** 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 runCatching { - val parts = File(KGSL, "gpubusy").readText().trim().split(Regex("\\s+")) - val total = parts[1].toLong() - if (total > 0) (parts[0].toLong() * 100 / total).toInt().coerceIn(0, 100) else 0 - }.getOrNull()?.also { loadFrom = "$KGSL/gpubusy" } + return if (read() != null) Feed(File(dir, "gpu_busy_percentage"), gpubusy, read, "%3d %%\n") else null } - /** GPU clock in MHz: /sys/kernel/gpu's, else KGSL's gpuclk in Hz. */ - private fun readClock(): Int? { - runCatching { File(QCOM_GPU, "gpu_clock").readText().trim().toInt() }.getOrNull()?.let { - clockFrom = "$QCOM_GPU/gpu_clock" - return it + /** 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 runCatching { (File(KGSL, "gpuclk").readText().trim().toLong() / 1_000_000).toInt() }.getOrNull() - ?.also { clockFrom = "$KGSL/gpuclk" } + return null } - private fun writeLoad(percent: Int) = write(load, String.format("%3d %%\n", percent)) - /** In place and at one length, so a reader that keeps the file open sees each value whole. */ - private fun write(file: File, text: String) { + private fun write(feed: Feed, value: Long) { try { - RandomAccessFile(file, "rw").use { it.seek(0); it.write(text.toByteArray()) } + RandomAccessFile(feed.file, "rw").use { it.seek(0); it.write(String.format(feed.format, value).toByteArray()) } } catch (e: Exception) { } } @@ -104,8 +101,49 @@ class GpuStatsComponent(val dir: File) : SessionPart() { companion object { private const val TAG = "SessionService" const val KGSL = "/sys/class/kgsl/kgsl-3d0" - /** Qualcomm's own GPU summary, beside KGSL in vendor kernels. */ - private const val QCOM_GPU = "/sys/kernel/gpu" 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 + } } } From a3fafcb10ed5f019b82a6670800b1ed2238084aa Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 13:28:26 -0400 Subject: [PATCH 4/5] feat: feed the overlay's fan, GPU memory and Valve msm reader paths --- .github/workflows/build.yml | 6 +- .../launcher/runtime/LinuxRuntime.java | 41 ++--- .../launcher/session/GpuMemComponent.kt | 115 ++++++++++++++ .../launcher/session/GpuStatsComponent.kt | 35 ++++- .../launcher/session/HwmonComponent.kt | 140 +++++++++++++++++ .../launcher/session/SessionService.kt | 15 +- tools/build_local.sh | 6 +- tools/mangoapp/libtracefs-shim.c | 144 ++++++++++++++++++ tools/mangoapp/packages.txt | 5 +- 9 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt create mode 100644 tools/mangoapp/libtracefs-shim.c 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/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index 01cabd597..22feae5ce 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -239,9 +239,8 @@ public final class LinuxRuntime { } } bindGpuNode(context, cmd); - bindAdrenoStats(cmd); + bindAdrenoStats(cmd, extraBinds); bindCpuTemps(cmd, root); - bindCpuHwmon(context.getCacheDir(), cmd); if (extraBinds != null) { for (String spec : extraBinds) bind(cmd, spec); } @@ -333,36 +332,13 @@ public final class LinuxRuntime { } } - /** - * MangoHud's CPU temperature: it looks first in {@code /sys/class/hwmon} for a sensor named - * {@code cpuN_thermal} (a mainline Arm kernel's), then at thermal zones of a type it knows; an - * Android kernel has neither - its hwmon holds the PMIC's sensors and its zones are named by - * the vendor (cpu-1-0-usr) - so the overlay showed no CPU temperature, and where the app may - * not list hwmon the overlay logged an error twice a second. The session's hwmon is one sensor - * named so, reading a CPU zone the app can read. Nothing else in a phone's hwmon is read by it. - */ - private static void bindCpuHwmon(File cacheDir, List cmd) { - String zone = null; + /** 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()) { - zone = temp.getPath(); - break; - } + if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) return temp.getPath(); } - if (zone == null) return; - File sensor = new File(cacheDir, "hwmon/hwmon0"); - try { - if (!sensor.isDirectory() && !sensor.mkdirs()) return; - Files.write(new File(sensor, "name").toPath(), "cpu0_thermal\n".getBytes(StandardCharsets.US_ASCII)); - File input = new File(sensor, "temp1_input"); - Files.deleteIfExists(input.toPath()); - Os.symlink(zone, input.getPath()); - } catch (IOException | ErrnoException e) { - return; - } - bind(cmd, sensor.getParent() + ":/sys/class/hwmon"); - android.util.Log.i("LinuxRuntime", "hud: cpu temp from " + zone); + return null; } /** The file the GPU's temperature reads from, or null where the app may read none. */ @@ -384,7 +360,7 @@ 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 = gpuTempSource(); String[][] stats = { @@ -396,7 +372,10 @@ 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. 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..a1bab5dbb --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt @@ -0,0 +1,115 @@ +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' `smaps` - 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. + */ +class GpuMemComponent(private val file: 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) 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//smaps (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 smaps cannot be read. */ + private fun kgslMappedKb(pid: String): Long? = runCatching { + var total = 0L + var inKgsl = false + File("/proc/$pid/smaps").forEachLine { line -> + if (MAPPING.containsMatchIn(line)) { + inKgsl = line.contains("kgsl-3d0") || line.contains("/dev/dri/renderD") + } else if (inKgsl && line.startsWith("Size:")) { + total += line.substring(5).trim().removeSuffix("kB").trim().toLongOrNull() ?: 0L + } + } + total + }.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" + private const val PERIOD_MS = 2_000L + /** A mapping's header in smaps: its address range first, then its fields and path. */ + private val MAPPING = Regex("^[0-9a-f]+-[0-9a-f]+ ") + + 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 index 2151f2174..9a2cac46e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -19,6 +19,11 @@ import java.io.RandomAccessFile * 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 @@ -26,7 +31,17 @@ class GpuStatsComponent(val dir: File) : SessionPart() { 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) + 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 { @@ -35,8 +50,16 @@ class GpuStatsComponent(val dir: File) : SessionPart() { dir.mkdirs() dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) } feeds.clear() - loadFeed()?.let { feeds.add(it) } - pick("clock_mhz", clockSources(), "%5d\n") { mhz(it) }?.let { feeds.add(it) } + 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 -> @@ -93,7 +116,8 @@ class GpuStatsComponent(val dir: File) : SessionPart() { /** 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 { - RandomAccessFile(feed.file, "rw").use { it.seek(0); it.write(String.format(feed.format, value).toByteArray()) } + 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) { } } @@ -101,6 +125,9 @@ class GpuStatsComponent(val dir: File) : SessionPart() { 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. */ 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..e325b49de --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt @@ -0,0 +1,140 @@ +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) : 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 } + if (running) 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/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 569a9153a..0993af357 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -688,6 +688,13 @@ 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")) + 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()) { @@ -695,12 +702,18 @@ class SessionService : Service() { 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")) + 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.add(gpuStats.dir.path + ":" + GpuStatsComponent.KGSL) + 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). 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/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 From 2f99f3a50cf56be68a642d904d775247069469ad Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 13:50:10 -0400 Subject: [PATCH 5/5] perf: read GPU mapping sizes from maps and run costly overlay feeds only while it shows --- .../launcher/session/GpuMemComponent.kt | 37 ++++++++++--------- .../launcher/session/HwmonComponent.kt | 5 ++- .../launcher/session/OverlayShown.kt | 17 +++++++++ .../launcher/session/SessionService.kt | 4 +- 4 files changed, 42 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt index a1bab5dbb..efa6a7956 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt @@ -16,12 +16,15 @@ import java.io.File * 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' `smaps` - only the + * - 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. + * 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) : SessionPart() { +class GpuMemComponent(private val file: File, private val root: File) : SessionPart() { @Volatile private var running = false private var thread: Thread? = null private var source = "" @@ -39,7 +42,7 @@ class GpuMemComponent(private val file: File) : SessionPart() { thread = Thread({ while (running) { try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } - if (running) sample()?.let { write(it) } + if (running && OverlayShown.check(root)) sample()?.let { write(it) } } }, "gpu-mem").apply { isDaemon = true; start() } } @@ -69,7 +72,7 @@ class GpuMemComponent(private val file: File) : SessionPart() { mappedKb += kb anyMap = true } - if (anyMap) { source = "/proc//smaps (KGSL mappings)"; return mappedKb * 1024 } + if (anyMap) { source = "/proc//maps (KGSL mappings)"; return mappedKb * 1024 } return null } @@ -77,18 +80,19 @@ class GpuMemComponent(private val file: File) : SessionPart() { 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 smaps cannot be read. */ + /** 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 total = 0L - var inKgsl = false - File("/proc/$pid/smaps").forEachLine { line -> - if (MAPPING.containsMatchIn(line)) { - inKgsl = line.contains("kgsl-3d0") || line.contains("/dev/dri/renderD") - } else if (inKgsl && line.startsWith("Size:")) { - total += line.substring(5).trim().removeSuffix("kB").trim().toLongOrNull() ?: 0L + 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) + } } } - total + bytes / 1024 }.getOrNull() /** Whole, renamed into place: the reader opens either the last value or this one. */ @@ -105,9 +109,8 @@ class GpuMemComponent(private val file: File) : SessionPart() { companion object { private const val TAG = "SessionService" private const val KGSL_ROOT = "/sys/class/kgsl/kgsl" - private const val PERIOD_MS = 2_000L - /** A mapping's header in smaps: its address range first, then its fields and path. */ - private val MAPPING = Regex("^[0-9a-f]+-[0-9a-f]+ ") + /** 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/HwmonComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt index e325b49de..fb0048933 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt @@ -27,7 +27,7 @@ import java.io.RandomAccessFile * * Nothing in a phone's own hwmon is read by the overlay, so standing in for it loses nothing. */ -class HwmonComponent(val dir: File) : SessionPart() { +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 @@ -65,7 +65,8 @@ class HwmonComponent(val dir: File) : SessionPart() { thread = Thread({ while (running) { try { Thread.sleep(FAN_PERIOD_MS) } catch (e: InterruptedException) { break } - if (running) writeFan(sampleFan()) + // 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() } } 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 0993af357..f0197bf9b 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -689,7 +689,7 @@ class SessionService : Service() { 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")) + val hwmon = HwmonComponent(File(filesDir, "session/sys/hwmon"), LinuxRuntime.rootDir(this)) if (hwmon.prepare()) { hwmon.attach(this) components.add(hwmon) @@ -703,7 +703,7 @@ class SessionService : Service() { 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")) + val gpuMem = GpuMemComponent(File(LinuxRuntime.rootDir(this), "run/droiddeck-hud/gpu-mem"), LinuxRuntime.rootDir(this)) if (gpuMem.prepare()) { gpuMem.attach(this) components.add(gpuMem)