From b29ece82c8dced533a2cd728b98e22ccc9737f92 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 1 Oct 2026 12:53:17 -0400 Subject: [PATCH] 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()) {