diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index e9cf26bd4..979b99a45 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -368,6 +368,7 @@ class MainActivity : ComponentActivity() { private var runningLabel by mutableStateOf(null) private var logsEnabled by mutableStateOf(true) private var agentCommands by mutableStateOf(false) + private var graphicsLogs by mutableStateOf(false) private var showRoms by mutableStateOf(false) private var homeAppSelected by mutableStateOf(false) private var homeScreenEnabled by mutableStateOf(false) @@ -563,7 +564,7 @@ class MainActivity : ComponentActivity() { busy = busy, stage = stage, percent = percent, desktopInstalled = desktopInstalled, desktopPresent = desktopPresent, offlineAccount = offlineAccount, offline = offline, - frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands, + frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands, graphicsLogs = graphicsLogs, steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGen = FrameGen.mode(this), lossless = lossless, @@ -709,6 +710,10 @@ class MainActivity : ComponentActivity() { SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this)) logsEnabled = SessionPrefs.logsEnabled(this) }, + onGraphicsLogs = { + SessionPrefs.setDetailedGraphicsLogs(this, !SessionPrefs.detailedGraphicsLogs(this)) + graphicsLogs = SessionPrefs.detailedGraphicsLogs(this) + }, onAgentCommands = { com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)) agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this) @@ -1606,6 +1611,7 @@ class MainActivity : ComponentActivity() { romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() } logsEnabled = SessionPrefs.logsEnabled(this) agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this) + graphicsLogs = SessionPrefs.detailedGraphicsLogs(this) runningLabel = if (SessionState.running) when (SessionState.mode) { SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop) SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program) diff --git a/app/src/main/java/com/droiddeck/launcher/session/GraphicsReport.kt b/app/src/main/java/com/droiddeck/launcher/session/GraphicsReport.kt new file mode 100644 index 000000000..ff53893d3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GraphicsReport.kt @@ -0,0 +1,174 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.os.Build +import android.system.Os +import com.droiddeck.launcher.core.DeviceSupport +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.gpu.DriverBundle +import com.droiddeck.launcher.gpu.DriverPairs +import com.droiddeck.launcher.gpu.GpuInfo +import com.droiddeck.launcher.gpu.SystemVulkanDriver +import com.droiddeck.launcher.gpu.TurnipReleases +import com.droiddeck.launcher.gpu.VulkanInfo +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * graphics-report.txt in each session's folder: which graphics path the session drew with and why, + * the GPU and its device nodes, the environment the guest got, and at the end what the compositor + * said about its driver. Written for a non-Adreno test report, where a black screen otherwise comes + * with nothing to read; the Share logs zip takes the whole folder, so it travels with the logs. + * Read-only: nothing here opens a GPU node or changes a setting. + */ +object GraphicsReport { + const val FILE = "graphics-report.txt" + const val GAMES_DIR = "games" + + /** One file in games/ past this is cut to its first part at the session's end. */ + private const val GAME_FILE_MAX = 20L * 1024 * 1024 + /** All of games/ past this loses its oldest launches. */ + private const val GAMES_TOTAL_MAX = 120L * 1024 * 1024 + + /** The guest keys the report repeats: everything that decides which Vulkan driver loads. */ + private val ENV_PREFIXES = listOf( + "VK_", "BL_VK", "BL_DRM", "BL_VKBRIDGE", "VKBRIDGE", "MESA_", "GALLIUM_", "ZINK", "TU_", "PANVK", + "DROIDDECK_POWERVR", "DROIDDECK_HYBRIS", "BL_GAME_LOGS", "LIBGL_", + ) + + /** Per-game graphics logs and the guest's Vulkan summary: on for a non-Adreno GPU, or by Setup's switch. */ + fun detailed(context: Context): Boolean = !DeviceSupport.adreno() || SessionPrefs.detailedGraphicsLogs(context) + + /** Writes the start of the report; never throws. */ + fun begin(context: Context, dir: File, guest: List) { + runCatching { File(dir, FILE).writeText(start(context, guest)) } + .onFailure { android.util.Log.w("GraphicsReport", "could not write $FILE", it) } + } + + private fun start(context: Context, guest: List): String = buildString { + val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) + appendLine("== graphics report, session start $time") + appendLine() + val gpu = runCatching { GpuInfo.detect() }.getOrNull() + appendLine("-- path") + appendLine("path: " + path(context)) + appendLine("driver mode: " + SessionPrefs.gpuDriverMode(context)) + appendLine("display driver: " + SessionPrefs.androidDriver(context).ifEmpty { "(auto)" }) + appendLine("runtime driver: " + SessionPrefs.linuxDriver(context).ifEmpty { "(runtime default)" }) + DriverBundle.active(context)?.let { b -> + appendLine("pair: ${b.name} ${b.version} (bundle ${b.id})") + release(context, b.id)?.let { appendLine("pair release: $it") } + } + if (gpu != null) { + val pairs = DriverPairs.from(TurnipReleases.cached(context)) + appendLine("auto would pick: " + (DriverPairs.recommendedKey(gpu, pairs) ?: "(no pair: system driver or bridge)")) + } + appendLine("mali bridge setting: " + SessionPrefs.vkBridge(context) + ", in use: " + DeviceSupport.vkBridge(context)) + appendLine() + appendLine("-- gpu") + if (gpu != null) { + appendLine("family: ${gpu.family} (${gpu.supportText})") + appendLine("name: ${gpu.name}" + if (gpu.model > 0) ", model ${gpu.model} (${gpu.modelSource})" else "") + } + appendLine("vulkan device (app): " + (VulkanInfo.cachedOrNull()?.get("device") ?: "(not probed)")) + appendLine("system vulkan hal: " + (SystemVulkanDriver.hal() ?: "none")) + read("/sys/class/misc/mali0/device/gpuinfo")?.let { appendLine("mali gpuinfo: $it") } + appendLine("android: ${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}") + val soc = if (Build.VERSION.SDK_INT >= 31) "${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}" else Build.HARDWARE + appendLine("soc: $soc, board ${Build.BOARD}, hardware ${Build.HARDWARE}") + appendLine("kernel: " + (System.getProperty("os.version") ?: "?")) + appendLine() + appendLine("-- gpu nodes (from the app; not opened)") + val dri = File("/dev/dri").list()?.sorted()?.map { "/dev/dri/$it" }.orEmpty() + for (path in listOf("/dev/kgsl-3d0", "/dev/mali0") + dri) appendLine(node(path)) + if (dri.isEmpty()) appendLine("/dev/dri: " + if (File("/dev/dri").exists()) "not listable" else "absent") + appendLine() + appendLine("-- guest environment (graphics)") + guest.filter { e -> ENV_PREFIXES.any { e.startsWith(it) } }.sorted().forEach { appendLine(it) } + appendLine() + } + + /** Which backend this session's Vulkan goes through, as the session decides it. */ + private fun path(context: Context): String = when { + DeviceSupport.adreno() -> "Turnip (Adreno, KGSL)" + DeviceSupport.vkBridge(context) -> "Mali bridge (#517): the phone's own Vulkan driver through the bridge" + DeviceSupport.openDriverActive(context) -> "open driver pair (PanVK / RADV)" + SystemVulkanDriver.usesDefault(SessionPrefs.linuxDriver(context)) && SystemVulkanDriver.isAvailable() -> + "system Vulkan (#570): ${SystemVulkanDriver.gpuName()} driver through libhybris" + SessionPrefs.linuxDriver(context).isNotEmpty() -> "imported runtime driver ${SessionPrefs.linuxDriver(context)}" + else -> "none for this GPU (no Adreno, no system HAL, no pair, bridge off)" + } + + /** The release tag and download a pair bundle came from, when the last release check knows it. */ + private fun release(context: Context, bundleId: String): String? { + val asset = TurnipReleases.cached(context)?.assets?.firstOrNull { a -> + val id = a.name.removeSuffix("-Pair.zip") + a.bundle && (bundleId == id || bundleId.startsWith("$id-")) + } + ?: return null + return "${asset.tag} ${asset.url}" + } + + private fun node(path: String): String { + val f = File(path) + if (!f.exists()) return "$path: absent" + val access = (if (f.canRead()) "r" else "-") + (if (f.canWrite()) "w" else "-") + val rdev = runCatching { Os.stat(path).st_rdev }.getOrNull() + val numbers = rdev?.let { " ${major(it)}:${minor(it)}" }.orEmpty() + val label = runCatching { String(Os.getxattr(path, "security.selinux")).trimEnd('\u0000') }.getOrNull() + return "$path: $access$numbers" + (label?.let { " $it" } ?: "") + } + + /** Linux's dev_t split (glibc's gnu_dev_major / gnu_dev_minor). */ + internal fun major(dev: Long): Long = ((dev ushr 8) and 0xfff) or ((dev ushr 32) and 0xfffff000L) + internal fun minor(dev: Long): Long = (dev and 0xff) or ((dev ushr 12) and 0xffffff00L) + + private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.takeIf { it.isNotEmpty() } + + /** The compositor's driver lines from the app's own log. */ + private val COMPOSITOR = Regex( + """vk_present|waylandcomp|vk_loader|compositor|vkCreateDevice|extension|adrenotools|TurnipDriver|""" + + """graphics driver|SystemVulkan|vkbridge|VkBridge|mali_gralloc|panvk|PanVK|radv|MESA|native buffer|first frame|present""", + RegexOption.IGNORE_CASE, + ) + + /** + * The end of the report, from the finished folder: the compositor's driver lines out of + * app.log, and games/ held to its size limits. Never throws. + */ + fun finish(dir: File) { + runCatching { + val report = File(dir, FILE) + if (!report.isFile) return + val lines = File(dir, "app.log").takeIf { it.isFile }?.useLines { seq -> seq.filter { COMPOSITOR.containsMatchIn(it) }.take(400).toList() }.orEmpty() + val games = File(dir, GAMES_DIR).listFiles()?.filter { it.isDirectory }?.sortedBy { it.name }.orEmpty() + report.appendText(buildString { + appendLine("-- compositor and driver lines from app.log (${lines.size})") + lines.forEach { appendLine(it) } + appendLine() + appendLine("-- game launches with detailed logs: ${games.size}") + games.forEach { appendLine(it.name) } + appendLine("== session end " + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())) + }) + capGames(File(dir, GAMES_DIR)) + }.onFailure { android.util.Log.w("GraphicsReport", "could not finish $FILE", it) } + } + + /** Cuts each file in [games] to [GAME_FILE_MAX] and drops the oldest launches past [GAMES_TOTAL_MAX]. */ + internal fun capGames(games: File, fileMax: Long = GAME_FILE_MAX, totalMax: Long = GAMES_TOTAL_MAX) { + if (!games.isDirectory) return + games.walkTopDown().filter { it.isFile && it.length() > fileMax }.forEach { f -> + runCatching { java.io.RandomAccessFile(f, "rw").use { it.setLength(fileMax) } } + } + val launches = games.listFiles()?.filter { it.isDirectory }?.sortedBy { it.name }.orEmpty().toMutableList() + fun size(d: File) = d.walkTopDown().filter { it.isFile }.sumOf { it.length() } + var total = launches.sumOf { size(it) } + while (total > totalMax && launches.size > 1) { + val oldest = launches.removeAt(0) + total -= size(oldest) + FileUtils.delete(oldest) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 8c21aa0e4..89b97883d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -547,6 +547,17 @@ object SessionPrefs { prefs(context).edit().putBoolean("logs", on).apply() } + /** + * Detailed graphics logs: per-game Proton, DXVK, vkd3d-proton and driver logs in the session's + * folder. Always on for a GPU that is not an Adreno (test drivers); opt-in on an Adreno. + */ + fun detailedGraphicsLogs(context: Context): Boolean = + prefs(context).getBoolean("detailedGraphicsLogs", false) + + fun setDetailedGraphicsLogs(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("detailedGraphicsLogs", on).apply() + } + /** Steam storage-call diagnostics are opt-in because they add timing work to file operations. */ fun storageDiagnosticsEnabled(context: Context): Boolean = prefs(context).getBoolean("storageDiagnostics", false) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 227845a45..03b98f7fb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -284,6 +284,7 @@ class SessionService : Service() { */ private fun collectSessionArtifacts(dir: File) { try { + GraphicsReport.finish(dir) SessionArtifacts.collect(this, dir, "session stopped") } finally { // Last, so everything above is in the file it is about - and only this session's: @@ -418,6 +419,10 @@ class SessionService : Service() { // The second library's name, for droiddeck-steam-library; the bind itself is made below. GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) } if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop") + // Detailed graphics logs: per-game logs and the guest's Vulkan summary (a GPU that is not + // an Adreno, or Setup's switch), and graphics-report.txt for every session. + if (GraphicsReport.detailed(this)) guest.add("BL_GAME_LOGS=1") + GraphicsReport.begin(this, sessionDir, guest) val shellGuest = ArrayList(guest).apply { add("SHELL=/bin/bash") add("TERM=xterm-256color") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 5fc3b2a53..b8da444cd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -99,6 +99,7 @@ class FrontEndState( val logsEnabled: Boolean, /** Setup's Debugging tools toggle (AgentAccess): off unless the user turns it on. */ val agentCommands: Boolean = false, + val graphicsLogs: Boolean = false, val steamGames: List, val emulators: List, val running: String?, @@ -208,6 +209,7 @@ class FrontEndActions( val onBrowseFiles: (File) -> Unit = {}, val onLogs: () -> Unit, val onAgentCommands: () -> Unit = {}, + val onGraphicsLogs: () -> Unit = {}, val onShareLogs: () -> Unit = {}, val onClearLogs: () -> Unit = {}, val onOffline: () -> Unit, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c232e16c1..dcb15a5bc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -410,6 +410,7 @@ internal fun SetupPanel( SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) { ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() } ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() } + ToggleRow(host, "graphics-logs", stringResource(R.string.setup_graphics_logs), stringResource(R.string.setup_graphics_logs_hint), s.graphicsLogs) { a.onGraphicsLogs() } ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs, progress = com.droiddeck.launcher.session.SessionLogShare.progress) ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e9cf33b70..1a4c2d47d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -679,6 +679,8 @@ Session logs Saved after each session Debugging tools + Detailed graphics logs + Each game\'s Proton, DXVK and driver logs in the session\'s logs. Always on when the GPU is not an Adreno. Latest session logs Saved session logs Keeps the newest %1$d diff --git a/app/src/test/java/com/droiddeck/launcher/session/GraphicsReportTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GraphicsReportTest.kt new file mode 100644 index 000000000..d71247247 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GraphicsReportTest.kt @@ -0,0 +1,39 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class GraphicsReportTest { + @get:Rule val tmp = TemporaryFolder() + + @Test fun deviceNumbersSplitAsLinuxDoes() { + // /dev/mali0 on the testers' phones: 10:103 (misc); a DRM render node: 226:128. + assertEquals(10L, GraphicsReport.major(10L shl 8 or 103L)) + assertEquals(103L, GraphicsReport.minor(10L shl 8 or 103L)) + assertEquals(226L, GraphicsReport.major(226L shl 8 or 128L)) + assertEquals(128L, GraphicsReport.minor(226L shl 8 or 128L)) + } + + @Test fun gameLogsAreCutPerFileAndOldestLaunchesGoFirst() { + val games = tmp.newFolder("games") + fun launch(name: String, bytes: Int) = File(games, name).apply { mkdirs() }.also { File(it, "steam-1.log").writeBytes(ByteArray(bytes)) } + val old = launch("20261012-100000-1", 60) + val mid = launch("20261012-110000-2", 60) + val new = launch("20261012-120000-3", 200) + GraphicsReport.capGames(games, fileMax = 100, totalMax = 150) + assertEquals(100L, File(new, "steam-1.log").length()) + assertFalse(old.exists()) + assertTrue(mid.exists()) + assertTrue(new.exists()) + } + + @Test fun aMissingGamesFolderIsFine() { + GraphicsReport.capGames(File(tmp.root, "none")) + GraphicsReport.finish(tmp.newFolder("session")) + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 9db66a1f8..8068c67d8 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -353,6 +353,48 @@ def system_graphics_launch(command, env): return [str(wrapper), *command], result +def game_logs(command, env, app_id, now=None): + """Detailed graphics logs for one game launch, in the session's log folder: Proton's own log, + DXVK's and vkd3d-proton's, and the Vulkan driver's startup messages. The app turns this on + (BL_GAME_LOGS=1) on a GPU that is not an Adreno, or with Setup's "Detailed graphics logs".""" + if env.get("BL_GAME_LOGS") != "1" or not env.get("BL_DEBUG_DIR") or len(command) < 3 \ + or command[1] != "waitforexitandrun": + return env + import time + stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(now)) + name = app_id or re.sub(r"[^A-Za-z0-9._-]+", "_", Path(command[2]).stem)[:40] or "game" + folder = Path(env["BL_DEBUG_DIR"]) / "games" / ("%s-%s" % (stamp, name)) + try: + folder.mkdir(parents=True, exist_ok=True) + except OSError as error: + print("droiddeck-game-env: no game log folder (%s)" % error, file=sys.stderr) + return env + result = dict(env) + result.update({ + "PROTON_LOG": "1", + "PROTON_LOG_DIR": str(folder), + "DXVK_LOG_PATH": str(folder), + "DXVK_LOG_LEVEL": env.get("DXVK_LOG_LEVEL", "info"), + "VKD3D_LOG_FILE": str(folder / "vkd3d.log"), + "VKD3D_DEBUG": env.get("VKD3D_DEBUG", "warn"), + "VKD3D_SHADER_DEBUG": env.get("VKD3D_SHADER_DEBUG", "none"), + "MESA_LOG_FILE": str(folder / "mesa.log"), + "MESA_DEBUG": env.get("MESA_DEBUG", "silent"), + "PANVK_DEBUG": env.get("PANVK_DEBUG", "startup"), + }) + try: + (folder / "launch.txt").write_text("\n".join([ + "app: %s" % (app_id or "-"), + "exe: %s" % command[2], + "proton: %s" % command[0], + "started: %s" % time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)), + ] + ["%s=%s" % (key, env[key]) for key in sorted(env) + if key.startswith(("VK_", "BL_VK", "BL_DRM", "MESA_", "PANVK", "DXVK_", "VKD3D", "ZINK", "TU_"))]) + "\n") + except OSError: + pass + return result + + def exec_proton(command, env): # Synchronization selection has already picked the actual Proton executable. command, env = system_graphics_launch(command, env) @@ -392,6 +434,7 @@ def main(): env = apply_config(env, {"version": 1}, app_id, fixes) except (OSError, ValueError, TypeError, AttributeError): print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) + env = game_logs(command, env, app_id) env = force_ssbs(command, env) command, env = start_without_steam(command, env, app_id) sync = load_sync() diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index a3424b399..f7d5ad96d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -163,6 +163,23 @@ if [ "${BL_VKBRIDGE:-0}" = 1 ]; then fi unset bl_vkb_icd fi +# Detailed graphics logs (BL_GAME_LOGS=1: a GPU that is not an Adreno, or Setup's switch): the +# session's own view of Vulkan, with the driver exactly as exported above, once per session. In the +# background with a timeout, so a driver that hangs here never holds the session up. +if [ "${BL_GAME_LOGS:-0}" = 1 ] && [ -z "${BL_INSIDE:-}" ] && [ -n "${BL_DEBUG_DIR:-}" ]; then + ( + { + echo "== VK_DRIVER_FILES=${VK_DRIVER_FILES:-} VK_ICD_FILENAMES=${VK_ICD_FILENAMES:-} BL_DRM_STANDIN=${BL_DRM_STANDIN:-}" + if command -v vulkaninfo >/dev/null 2>&1; then + timeout 10 vulkaninfo --summary + echo "== vulkaninfo exit: $?" + else + echo "== vulkaninfo is not in this runtime; GPU nodes:" + ls -l /dev/kgsl-3d0 /dev/mali0 /dev/dri 2>&1 + fi + } > "$BL_DEBUG_DIR/vulkan-summary.txt" 2>&1 + ) & +fi # The app's gamescope build (tools/gamescope) asks for realtime-priority Vulkan queues on this # switch instead of on CAP_SYS_NICE, which proot never has; it falls back to normal priority # where the driver refuses, and the runtime's own build ignores it. On KGSL Turnip (Mesa 26.2) it diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 9c31d2aae..41d127c42 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -373,6 +373,26 @@ class GameEnvironmentTest(unittest.TestCase): with self.assertRaises(ValueError): MODULE["apply_config"]({}, {**config, "dxvkConfigGames": []}, "42") + def test_game_logs_only_when_asked_and_only_for_game_launches(self): + game_logs = MODULE["game_logs"] + with tempfile.TemporaryDirectory() as tmp: + command = ["/proton", "waitforexitandrun", "/games/Hades/Hades.exe"] + env = {"BL_DEBUG_DIR": tmp, "VK_DRIVER_FILES": "/icd.json"} + self.assertEqual(game_logs(command, env, "1145360", now=0), env) + on = dict(env, BL_GAME_LOGS="1") + self.assertEqual(game_logs(["/proton", "run", "x.exe"], on, "1145360", now=0), on) + result = game_logs(command, on, "1145360", now=0) + folder = Path(result["PROTON_LOG_DIR"]) + self.assertEqual(folder.parent, Path(tmp) / "games") + self.assertTrue(folder.name.endswith("-1145360")) + self.assertEqual(result["DXVK_LOG_PATH"], str(folder)) + self.assertEqual(result["PROTON_LOG"], "1") + launch = (folder / "launch.txt").read_text() + self.assertIn("VK_DRIVER_FILES=/icd.json", launch) + self.assertNotIn("PROTON_LOG", env) + named = game_logs(command, on, None, now=0) + self.assertTrue(Path(named["PROTON_LOG_DIR"]).name.endswith("-Hades")) + def test_game_ids_and_probes(self): for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"): self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))