Merge branch 'main' into feat/performance-fixes-2

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-01 14:38:33 -04:00
commit 8f9a849c48
11 files changed
+827 -10

No files matched your search

+5 -1
View File
@@ -211,9 +211,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"
@@ -231,12 +231,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 +285,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<String> 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<String, File> 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 +360,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<String> cmd) {
private static void bindAdrenoStats(List<String> cmd, List<String> 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 +372,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";
}
/**
@@ -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
}
}
}
@@ -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/<pid>/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/<pid>/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/<pid>/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<String> =
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()
}
}
@@ -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<Feed>()
/** 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<String> {
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<String>, 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<String> {
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<String> =
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
}
}
}
@@ -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 }
}
}
@@ -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)
}
}
@@ -710,6 +710,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) })
+5 -1
View File
@@ -224,9 +224,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"
+144
View File
@@ -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 <sched.h>
#include <stddef.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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; }
+3 -2
View File
@@ -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