mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge branch 'main' into upscaler-setting
This commit is contained in:
commit
074e4f090b
16 files changed
+959
-17
No files matched your search
@@ -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"
|
||||
|
||||
@@ -47,7 +47,7 @@ object GuestCommand {
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
|
||||
@@ -34,6 +34,14 @@ public final class LinuxRuntime {
|
||||
/** Shortcut extra naming which of the modes above a Linux entry launches. */
|
||||
public static final String EXTRA_LINUX_MODE = "linux_mode";
|
||||
private static final String KGSL_DEVICE = "/dev/kgsl-3d0";
|
||||
/**
|
||||
* Where the guest sees a command's XDG_RUNTIME_DIR. A Unix socket's path must fit in 108 bytes,
|
||||
* and libwayland checks the guest's path before proot ever sees it: with the app on an SD card
|
||||
* adopted as internal storage the files directory is /mnt/expand/<uuid>/user/0/<package>/files,
|
||||
* and gamescope-0 under it is one byte over, so gamescope could not open its socket and the
|
||||
* session ended at once. A long host path behind it is proot's to shorten.
|
||||
*/
|
||||
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
|
||||
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
|
||||
public static final String DEBUG_LOG_DIR = "DroidDeck";
|
||||
|
||||
@@ -185,7 +193,10 @@ public final class LinuxRuntime {
|
||||
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
|
||||
bind(cmd, context.getFilesDir().getPath());
|
||||
bind(cmd, context.getCacheDir().getPath());
|
||||
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
|
||||
if (runtimeDir != null) {
|
||||
bind(cmd, runtimeDir.getPath());
|
||||
bind(cmd, runtimeDir.getPath() + ":" + GUEST_RUNTIME_DIR);
|
||||
}
|
||||
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
|
||||
if (externalStorage != null && externalStorage.isDirectory()) {
|
||||
bind(cmd, externalStorage.getPath());
|
||||
@@ -231,12 +242,15 @@ public final class LinuxRuntime {
|
||||
};
|
||||
for (String[] entry : procFiles) {
|
||||
File fake = new File(fakeProc, entry[0]);
|
||||
if (fake.isFile() && !new File(entry[1]).canRead()) {
|
||||
// A live stand-in the session binds itself (CpuStatComponent's /proc/stat) wins.
|
||||
boolean live = false;
|
||||
if (extraBinds != null) for (String spec : extraBinds) live |= spec.endsWith(":" + entry[1]);
|
||||
if (!live && fake.isFile() && !new File(entry[1]).canRead()) {
|
||||
bind(cmd, fake.getPath() + ":" + entry[1]);
|
||||
}
|
||||
}
|
||||
bindGpuNode(context, cmd);
|
||||
bindAdrenoStats(cmd);
|
||||
bindAdrenoStats(cmd, extraBinds);
|
||||
bindCpuTemps(cmd, root);
|
||||
if (extraBinds != null) {
|
||||
for (String spec : extraBinds) bind(cmd, spec);
|
||||
@@ -282,12 +296,71 @@ public final class LinuxRuntime {
|
||||
if (!Files.isSymbolicLink(subsystem.toPath())) {
|
||||
Os.symlink("/sys/bus/platform", subsystem.getPath());
|
||||
}
|
||||
// What MangoHud names a GPU's driver by; msm_drm is how an Adreno's render node reads
|
||||
// on a mainline kernel, and the driver it reads the load of (bindAdrenoStats).
|
||||
File driver = new File(device, "driver");
|
||||
if (!Files.isSymbolicLink(driver.toPath())) {
|
||||
Os.symlink("/sys/bus/platform/drivers/msm_drm", driver.getPath());
|
||||
}
|
||||
} catch (IOException | ErrnoException e) {
|
||||
return;
|
||||
}
|
||||
bind(cmd, new File(base, "sys").getPath() + ":/sys/dev/char");
|
||||
bind(cmd, dri.getPath() + ":/dev/dri");
|
||||
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
|
||||
bindDrmClass(base, cmd, node, major + ":" + minor);
|
||||
}
|
||||
|
||||
/**
|
||||
* MangoHud (Deck mode's performance overlay, mangoapp) finds the GPU by listing
|
||||
* {@code /sys/class/drm}, and an exception from that listing ends the process: under an
|
||||
* enforcing SELinux policy - every retail phone - an app may not list it, so mangoapp died on
|
||||
* start, gamescope restarted it until the session gave up, and there was no overlay. Where the
|
||||
* app cannot list it, the session's {@code /sys/class/drm} is ours: the render node above,
|
||||
* named as an Adreno's is. MangoHud then reads the GPU's stats from KGSL's sysfs, which such a
|
||||
* device refuses too - SessionService's GpuStatsComponent stands in for that. Where the
|
||||
* listing is allowed it is left alone.
|
||||
*/
|
||||
private static void bindDrmClass(File base, List<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 +371,11 @@ public final class LinuxRuntime {
|
||||
* Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left
|
||||
* alone.
|
||||
*/
|
||||
private static void bindAdrenoStats(List<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 +383,21 @@ public final class LinuxRuntime {
|
||||
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
|
||||
};
|
||||
for (String[] stat : stats) {
|
||||
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
|
||||
// A value the session feeds itself (GpuStatsComponent, where KGSL is refused) wins.
|
||||
boolean fed = false;
|
||||
if (extraBinds != null) for (String spec : extraBinds) fed |= spec.endsWith(":" + stat[1]);
|
||||
if (stat[0] != null && !fed) bind(cmd, stat[0] + ":" + stat[1]);
|
||||
}
|
||||
// Which of the overlay's GPU values this device lets the app read: a phone under an
|
||||
// enforcing policy refuses some of them, and the overlay then has no line for that value.
|
||||
android.util.Log.i("LinuxRuntime", "hud sources: gpu load " + orNone(stats[0][0]) + ", gpu clock "
|
||||
+ orNone(stats[1][0]) + ", gpu temp " + orNone(gpuTemp) + ", ddr temp " + orNone(stats[4][0])
|
||||
+ ", thermal zones " + thermalZones().size()
|
||||
+ ", kgsl gpubusy " + (new File(kgsl + "gpubusy").canRead() ? "readable" : "refused"));
|
||||
}
|
||||
|
||||
private static String orNone(String path) {
|
||||
return path != null ? path : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ class SessionService : Service() {
|
||||
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
|
||||
// same set-up as a Steam session: it gets what the client and its games are started with.
|
||||
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
addClientEnvironment(guest, runtimeDir, steamHere)
|
||||
addClientEnvironment(guest, steamHere)
|
||||
|
||||
val pulse = startAudio(guest, sessionDir)
|
||||
|
||||
@@ -483,7 +483,7 @@ class SessionService : Service() {
|
||||
}
|
||||
|
||||
/** The guest's base environment: paths, the display, the GL/Vulkan stack and the client's switches. */
|
||||
private fun addClientEnvironment(guest: MutableList<String>, runtimeDir: File, steamHere: Boolean) {
|
||||
private fun addClientEnvironment(guest: MutableList<String>, steamHere: Boolean) {
|
||||
guest.add("/usr/bin/env")
|
||||
guest.add("-i")
|
||||
guest.add("HOME=/root")
|
||||
@@ -494,7 +494,7 @@ class SessionService : Service() {
|
||||
// Without this the session is UTC: the client's clock, its logs and every timestamp in a
|
||||
// session bundle sit hours off the device's. Bannerlator carries the same line.
|
||||
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
|
||||
guest.add("XDG_RUNTIME_DIR=" + runtimeDir.path)
|
||||
guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR)
|
||||
guest.add("XDG_SESSION_TYPE=wayland")
|
||||
guest.add("WAYLAND_DISPLAY=wayland-0")
|
||||
guest.add("GAMESCOPE_FORCE_GENERAL_QUEUE=1")
|
||||
@@ -689,6 +689,33 @@ class SessionService : Service() {
|
||||
battery.attach(this)
|
||||
components.add(battery)
|
||||
binds.add(battery.dir.path + ":/sys/class/power_supply")
|
||||
// The overlay's CPU and GPU temperatures and the fan, as hwmon sensors it knows by name.
|
||||
val hwmon = HwmonComponent(File(filesDir, "session/sys/hwmon"), LinuxRuntime.rootDir(this))
|
||||
if (hwmon.prepare()) {
|
||||
hwmon.attach(this)
|
||||
components.add(hwmon)
|
||||
binds.add(hwmon.dir.path + ":/sys/class/hwmon")
|
||||
}
|
||||
// CPU load for everything in the session that reads /proc/stat, the overlay among them.
|
||||
val cpuStat = CpuStatComponent(File(filesDir, "session/proc-stat"))
|
||||
if (cpuStat.prepare()) {
|
||||
cpuStat.attach(this)
|
||||
components.add(cpuStat)
|
||||
binds.add(cpuStat.file.path + ":/proc/stat")
|
||||
}
|
||||
// The GPU memory in use for the overlay's VRAM lines, which it would read from tracefs.
|
||||
val gpuMem = GpuMemComponent(File(LinuxRuntime.rootDir(this), "run/droiddeck-hud/gpu-mem"), LinuxRuntime.rootDir(this))
|
||||
if (gpuMem.prepare()) {
|
||||
gpuMem.attach(this)
|
||||
components.add(gpuMem)
|
||||
}
|
||||
// The GPU's load and temperature for the performance overlay, where KGSL's sysfs is refused.
|
||||
val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"))
|
||||
if (gpuStats.prepare()) {
|
||||
gpuStats.attach(this)
|
||||
components.add(gpuStats)
|
||||
binds.addAll(gpuStats.binds())
|
||||
}
|
||||
// Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this
|
||||
// listener, which drives the phone's vibrator (see RumbleComponent).
|
||||
if (controllersOn) components.add(RumbleComponent().also { it.attach(this) })
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -39,5 +39,13 @@ library list, before anything is published.
|
||||
then focus is handed over again. `GAMESCOPE_RESTORE_FOCUS_WINDOW` on the root window asks for the
|
||||
same restore from outside (the session script's resume watcher).
|
||||
|
||||
- `0113-steam-overlay-keeps-game-keyboard-focus.patch` - this app: the Steam client opens the Quick
|
||||
Access Menu and Steam menu over a game with `STEAM_INPUT_FOCUS` 1, which moves X keyboard focus to
|
||||
its overlay; wine then deactivates the game and a fullscreen game minimizes itself, freezing
|
||||
behind the menu instead of running on as on a Steam Deck. Steam's own overlay taking input now
|
||||
keeps keyboard focus on the game, as mode 2 does; its input comes from the controller through
|
||||
Steam Input, not the X keyboard. The pointer warps gamescope makes as input moves to Steam and
|
||||
back are skipped around it, since the game - still taking input - saw them as a mouse jump.
|
||||
|
||||
Sixteen more of Armada's patches are DRM/lease/HDR-on-KMS work for a native display, which this
|
||||
app's Wayland-hosted gamescope never reaches, or need a newer gamescope than the runtime has.
|
||||
@@ -0,0 +1,104 @@
|
||||
From: DroidDeck
|
||||
Subject: [PATCH] steamcompmgr: keep the game's keyboard focus under Steam's overlay
|
||||
|
||||
The Steam client opens its Quick Access Menu and Steam menu over a game by setting
|
||||
STEAM_OVERLAY 1 and STEAM_INPUT_FOCUS 1 on its window. Mode 1 moves X keyboard focus to the
|
||||
overlay, so the game's window gets FocusOut: wine moves the foreground to the desktop, a fullscreen
|
||||
game deactivates and minimizes itself (WM_CHANGE_STATE IconicState), and it stops rendering behind
|
||||
the menu - frozen, all threads asleep, until the menu closes (Alan Wake's American Nightmare,
|
||||
Proton Experimental ARM64). On a Steam Deck the game runs on behind the menu.
|
||||
|
||||
Mode 2 already means "input to the overlay, keyboard focus stays on the focus window". Treat
|
||||
Steam's own overlay taking input in mode 1 the same way at the three places mode 2 is honoured.
|
||||
The overlay's input does not need X keyboard focus: the controller drives it through Steam Input.
|
||||
Measured by rewriting the property to 2 by hand: the game kept running behind the menu and the
|
||||
controller still moved only the menu.
|
||||
|
||||
With the game keeping focus, the pointer resets gamescope makes when input moves to Steam (warp
|
||||
to the centre, park in the corner) and back (warp to the centre) reached it as a sudden mouse
|
||||
move: a mouse-look camera spun every time the menu opened and closed. Skip them while Steam's
|
||||
overlay opens over a game that keeps focus, and as it closes - by then the overlay has dropped its
|
||||
STEAM_INPUT_FOCUS, so whether the opening kept focus is remembered.
|
||||
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index 372a82c..3fab3c6 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -1321,6 +1321,22 @@ window_is_steam( steamcompmgr_win_t *w )
|
||||
return w && ( w->isSteamLegacyBigPicture || w->appID == 769 );
|
||||
}
|
||||
|
||||
+// Whether keyboard focus stays on the focus window while this window has input focus.
|
||||
+// STEAM_INPUT_FOCUS 2 asks for that. The Steam client also opens its Quick Access Menu and Steam
|
||||
+// menu over a game with 1, which moves X keyboard focus to the overlay: wine then deactivates the
|
||||
+// game, a fullscreen game minimizes itself, and it froze behind the menu instead of running on as
|
||||
+// on a Steam Deck. The overlay's own input does not need X keyboard focus (the controller drives
|
||||
+// it through Steam Input), so Steam's overlay keeps the game's keyboard focus too.
|
||||
+static bool
|
||||
+window_keeps_game_keyboard_focus( steamcompmgr_win_t *w )
|
||||
+{
|
||||
+ if ( !w )
|
||||
+ return false;
|
||||
+ if ( w->inputFocusMode == 2 )
|
||||
+ return true;
|
||||
+ return w->inputFocusMode == 1 && w->isOverlay && window_is_steam( w );
|
||||
+}
|
||||
+
|
||||
static bool
|
||||
window_is_vr_scene_app( steamcompmgr_win_t *w )
|
||||
{
|
||||
@@ -4728,7 +4744,7 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win
|
||||
|
||||
if ( gamescope::VirtualConnectorIsSingleOutput() )
|
||||
{
|
||||
- if ( inputFocus && inputFocus->inputFocusMode == 2 )
|
||||
+ if ( window_keeps_game_keyboard_focus( inputFocus ) )
|
||||
keyboardFocusWin = ctx->focus.focusWindow;
|
||||
}
|
||||
else
|
||||
@@ -4834,12 +4850,26 @@ void xwayland_ctx_t::DetermineAndApplyFocus( const std::vector< steamcompmgr_win
|
||||
if ( ctx->focus.inputFocusWindow != inputFocus ||
|
||||
ctx->focus.inputFocusMode != inputFocus->inputFocusMode )
|
||||
{
|
||||
+ // Steam's overlay opening over a game, or closing again, while the game keeps keyboard focus
|
||||
+ // (window_keeps_game_keyboard_focus): the game is still taking input, so a pointer warp
|
||||
+ // reaches it as a sudden mouse move - the camera of a mouse-look game spun as the Quick
|
||||
+ // Access Menu opened and as it closed. The overlay is driven by the controller and the
|
||||
+ // cursor is hidden, so leave the pointer where it is. Closing, the overlay has already
|
||||
+ // dropped its STEAM_INPUT_FOCUS, so whether the opening kept focus is remembered.
|
||||
+ static bool s_bOverlayKeptGameFocus = false;
|
||||
+ bool bOverlayKeepsGameFocus = window_keeps_game_keyboard_focus( inputFocus ) &&
|
||||
+ ctx->focus.focusWindow && !window_is_steam( ctx->focus.focusWindow );
|
||||
+ bool bSkipPointerReset = bOverlayKeepsGameFocus || s_bOverlayKeptGameFocus;
|
||||
+ s_bOverlayKeptGameFocus = bOverlayKeepsGameFocus;
|
||||
+
|
||||
// If the window doesn't want focus when hidden, move it away
|
||||
// as we are going to hide it straight after.
|
||||
// otherwise, if we switch from wanting it to not
|
||||
// (steam -> game)
|
||||
// put us back in the centre of the screen.
|
||||
- if (window_wants_no_focus_when_mouse_hidden(inputFocus))
|
||||
+ if ( bSkipPointerReset )
|
||||
+ ;
|
||||
+ else if (window_wants_no_focus_when_mouse_hidden(inputFocus))
|
||||
ctx->focus.bResetToCorner = true;
|
||||
else if ( window_wants_no_focus_when_mouse_hidden(inputFocus) != window_wants_no_focus_when_mouse_hidden(ctx->focus.inputFocusWindow) )
|
||||
ctx->focus.bResetToCenter = true;
|
||||
@@ -5340,7 +5370,7 @@ determine_and_apply_focus( global_focus_t *pFocus )
|
||||
pFocus->inputFocusMode = pFocus->inputFocusWindow->inputFocusMode;
|
||||
|
||||
if ( gamescope::VirtualConnectorIsSingleOutput() &&
|
||||
- pFocus->inputFocusMode == 2 )
|
||||
+ window_keeps_game_keyboard_focus( pFocus->inputFocusWindow ) )
|
||||
{
|
||||
pFocus->keyboardFocusWindow = pFocus->overrideWindow
|
||||
? pFocus->overrideWindow
|
||||
@@ -6485,7 +6515,7 @@ handle_wl_surface_id(xwayland_ctx_t *ctx, steamcompmgr_win_t *w, uint32_t surfac
|
||||
steamcompmgr_win_t *keyboardFocusWindow = pCurrentFocus->inputFocusWindow;
|
||||
|
||||
if ( gamescope::VirtualConnectorIsSingleOutput() &&
|
||||
- keyboardFocusWindow && keyboardFocusWindow->inputFocusMode == 2 )
|
||||
+ window_keeps_game_keyboard_focus( keyboardFocusWindow ) )
|
||||
keyboardFocusWindow = pCurrentFocus->focusWindow;
|
||||
|
||||
if ( w == keyboardFocusWindow )
|
||||
@@ -1,2 +1,2 @@
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p4
|
||||
GAMESCOPE_SHA256=e750fb34864bc2eb1160aeed29593e402bf9c8153fafd9fd18c77450bdd50574
|
||||
GAMESCOPE_TAG=gamescope-3.16.29-p5
|
||||
GAMESCOPE_SHA256=188144e5564e020d0bc485a1f4f90e1c5fd61d0d1d6dd29db21e8b019036a5c6
|
||||
@@ -172,6 +172,8 @@ if [ -z "${BL_INSIDE:-}" ]; then
|
||||
# HDR10: the app opened its compositor's colour-management gate for this session (BL_HDR=1);
|
||||
# gamescope then offers HDR to its clients through the same wp_color_manager_v1 it binds on ours.
|
||||
[ "${BL_HDR:-0}" = 1 ] && args+=(--hdr-enabled)
|
||||
# Extra gamescope arguments for an experiment (--debug-focus, say), from Download/droiddeck-env.
|
||||
[ -n "${BL_GAMESCOPE_ARGS:-}" ] && read -r -a _bl_extra <<<"$BL_GAMESCOPE_ARGS" && args+=("${_bl_extra[@]}")
|
||||
# Deck mode's performance overlay (Quick Access Menu → Performance → Overlay Level) is drawn by
|
||||
# mangoapp, which gamescope starts beside the client; the client sets the level by rewriting
|
||||
# MANGOHUD_CONFIGFILE. The apk stages Valve's aarch64 build (tools/mangoapp) - without it the menu
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
Reference in new issue
Block a user