From 1760f008ae7a9238fca81ec15e99dd70dcacaa2e Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 8 Nov 2026 14:39:31 -0500 Subject: [PATCH] fix(desktop): Flatpak and Snap apps get the game GPU setting (#2295) The session's TU_DEBUG=sysmem is meant for the Steam client and Chromium-based apps. Flatpak and Snap apps inherited it, so emulators and other programs installed that way rendered through system memory. droiddeck-game-env now holds the Chromium check next to game_gpu, and a small droiddeck-game-gpu wrapper applies both to any program. The AppImage, Flatpak and Snap launchers use it, so the rule lives in one place. --- README.md | 2 +- .../launcher/session/SessionFiles.kt | 1 + .../usr/local/bin/droiddeck-appimage-run | 9 +- .../usr/local/bin/droiddeck-flatpak-run | 12 ++- .../overlay/usr/local/bin/droiddeck-game-env | 15 ++- .../overlay/usr/local/bin/droiddeck-game-gpu | 42 ++++++++ .../overlay/usr/local/bin/droiddeck-snap-run | 13 ++- tools/tests/test_game_environment.py | 40 ++++++++ tools/tests/test_game_gpu_launchers.py | 98 +++++++++++++++++++ 9 files changed, 221 insertions(+), 11 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-game-gpu create mode 100644 tools/tests/test_game_gpu_launchers.py diff --git a/README.md b/README.md index dfadf1bf5..3139abd28 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V - The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later). - DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling. - Zink on PowerVR keeps colour textures in colour with Icenowy Zheng's upstream [Mesa](https://gitlab.freedesktop.org/mesa/mesa) fix d55418dae7 (MIT), carried as a runtime patch. -- Games and AppImage programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`. +- Games and AppImage, Flatpak and Snap programs such as emulators use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client and Chromium-based apps keep `TU_DEBUG=sysmem`. - Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton. - [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1. - The arm64 client's sign in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 1b4e78ad0..3cefd44c0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -40,6 +40,7 @@ object SessionFiles { WinComponents.publish(context) val files = arrayOf( "usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env", + "usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu", "usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory", "usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents", "usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install", diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run index cb32d4696..b4a1910f2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run @@ -25,12 +25,6 @@ if [ -n "$(find -H "$APPDIR" -maxdepth 4 \( -name chrome-sandbox -o -name chrome flags+=(--ozone-platform=x11) fi fi -if [ -n "${DROIDDECK_GAME_TU_DEBUG+set}" ]; then - if [ ${#flags[@]} -eq 0 ]; then - if [ -n "$DROIDDECK_GAME_TU_DEBUG" ]; then export TU_DEBUG=$DROIDDECK_GAME_TU_DEBUG; else unset TU_DEBUG; fi - fi - unset DROIDDECK_GAME_TU_DEBUG -fi qt=$(cat "$dir/qt" 2>/dev/null) case $qt in wayland|xcb) ;; *) qt=auto ;; esac wayland=${GAMESCOPE_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}} @@ -61,6 +55,9 @@ if [ -x /usr/local/bin/droiddeck-fex ]; then *) run=(/usr/local/bin/droiddeck-fex run --mode "$fex" --for "$APPDIR" -- "$APPDIR/AppRun") ;; esac fi +gpu=("${BASH_SOURCE[0]%/*}/droiddeck-game-gpu") +[ ${#flags[@]} -gt 0 ] && gpu+=(--chromium) +run=("${gpu[@]}" -- "${run[@]}") echo "== appimage $ARGV0 ($APPDIR) ${flags[*]} FEX $fex Qt $qt${QT_QPA_PLATFORM:+ ($QT_QPA_PLATFORM)}" cd "$APPDIR" || exit 1 started=$(mktemp "${TMPDIR:-/tmp}/droiddeck-appimage.XXXXXX") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run index 08e6e7a26..6fdd22f3d 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run @@ -11,8 +11,16 @@ if [ -n "${BL_INSIDE:-}" ] || [ -n "${GAMESCOPE_WAYLAND_DISPLAY:-}" ]; then export XDG_SESSION_TYPE=x11 socket=(--nosocket=wayland --socket=x11) fi +app= +for arg in "$@"; do + case $arg in -*) ;; *) app=$arg; break ;; esac +done +gpu=("${BASH_SOURCE[0]%/*}/droiddeck-game-gpu") +location= +[ -n "$app" ] && location=$(flatpak info --show-location "$app" 2>/dev/null) +[ -n "$location" ] && gpu+=(--for "$location/files") echo "== flatpak run ${socket[*]} $*" if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] && command -v dbus-run-session >/dev/null 2>&1; then - exec dbus-run-session -- flatpak run "${socket[@]}" "$@" + exec "${gpu[@]}" -- dbus-run-session -- flatpak run "${socket[@]}" "$@" fi -exec flatpak run "${socket[@]}" "$@" +exec "${gpu[@]}" -- flatpak run "${socket[@]}" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 9fb3c39c6..a2e130b5a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -11,6 +11,7 @@ import tempfile from pathlib import Path GAME_TU_DEBUG = "DROIDDECK_GAME_TU_DEBUG" +CHROMIUM_FILES = {"chrome-sandbox", "chrome_100_percent.pak", "v8_context_snapshot.bin"} SYNC_KEYS = ("BL_SYNC", "BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC") SEED_REDISTS = "/usr/local/bin/droiddeck-seed-redists" PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC") @@ -346,11 +347,23 @@ def win_components(compat, app_id): return "" -def game_gpu(env): +def chromium_app(folder): + root = os.path.realpath(folder) + for top, dirs, files in os.walk(root): + if CHROMIUM_FILES.intersection(files) or CHROMIUM_FILES.intersection(dirs): + return True + if os.path.relpath(top, root).count(os.sep) >= 2: + dirs[:] = [] + return False + + +def game_gpu(env, keep=False): if GAME_TU_DEBUG not in env: return env result = env.copy() value = result.pop(GAME_TU_DEBUG) + if keep: + return result if value: result["TU_DEBUG"] = value else: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-gpu b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-gpu new file mode 100755 index 000000000..40055010d --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-gpu @@ -0,0 +1,42 @@ +#!/usr/bin/python3 +import os +import runpy +import sys + +BIN_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def main(argv): + keep = False + folder = None + while argv and argv[0] != "--": + if argv[0] == "--chromium": + keep = True + argv = argv[1:] + elif argv[0] == "--for" and len(argv) > 1: + folder = argv[1] + argv = argv[2:] + else: + break + if argv and argv[0] == "--": + argv = argv[1:] + if not argv: + print("usage: droiddeck-game-gpu [--chromium] [--for FOLDER] -- PROGRAM [ARGS...]", file=sys.stderr) + return 2 + env = dict(os.environ) + try: + game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-env")) + if folder and not keep: + keep = game_env["chromium_app"](folder) + env = game_env["game_gpu"](env, keep) + except Exception as error: + print("droiddeck-game-gpu: the game GPU setting was not applied (%s)" % error, file=sys.stderr) + try: + os.execvpe(argv[0], argv, env) + except OSError as error: + print("droiddeck-game-gpu: %s: %s" % (argv[0], error.strerror), file=sys.stderr) + return 127 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-snap-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-snap-run index 6529005a2..9e8acf9cc 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-snap-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-snap-run @@ -1,11 +1,13 @@ #!/usr/bin/python3 import os import re +import runpy import shlex import sys import yaml +BIN_DIR = os.path.dirname(os.path.abspath(__file__)) SNAP_DIR = "/snap" HOST_DIRS = ("/etc", "/home", "/root", "/tmp", "/run", "/var/snap", "/var/lib/snapd", "/var/tmp", "/var/log", "/media", "/mnt", "/snap", "/usr/src", "/lib/modules") @@ -59,6 +61,15 @@ def content_binds(name, meta, env): return out +def game_gpu(env, snap): + try: + game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-env")) + return game_env["game_gpu"](env, game_env["chromium_app"](snap)) + except Exception as error: + print(f"droiddeck-snap-run: the game GPU setting was not applied ({error})", file=sys.stderr) + return env + + def main(argv): if len(argv) < 3: print("usage: droiddeck-snap-run SNAP APP [ARG...]", file=sys.stderr) @@ -72,7 +83,7 @@ def main(argv): revision = os.path.basename(os.path.realpath(f"{SNAP_DIR}/{name}/current")) home = os.environ.get("HOME", "/root") snap = f"{SNAP_DIR}/{name}/{revision}" - env = dict(os.environ) + env = game_gpu(dict(os.environ), snap) for k in ("LD_PRELOAD", "LD_LIBRARY_PATH"): env.pop(k, None) env.update({ diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 6e21ec2de..48d08a0b5 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -902,6 +902,46 @@ class GameEnvironmentTest(unittest.TestCase): self.assertEqual(MODULE["apply_config"](env, config, "42")["TU_DEBUG"], "sysmem") self.assertNotIn("TU_DEBUG", MODULE["apply_config"](env, config, "43")) + def test_a_chromium_app_keeps_the_sessions_turnip_debug(self): + gpu = MODULE["game_gpu"] + self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "", "KEEP": "1"}, True), + {"TU_DEBUG": "sysmem", "KEEP": "1"}) + self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "nolrz"}, True), {"TU_DEBUG": "sysmem"}) + self.assertEqual(gpu({"TU_DEBUG": "sysmem"}, True), {"TU_DEBUG": "sysmem"}) + + def test_chromium_files_are_found_four_levels_deep_and_no_further(self): + chromium = MODULE["chromium_app"] + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "app" + (root / "usr/lib/tool").mkdir(parents=True) + (root / "usr/lib/tool/libtool.so").write_bytes(b"") + self.assertFalse(chromium(str(root))) + for name in ("chrome-sandbox", "chrome_100_percent.pak", "v8_context_snapshot.bin"): + found = root / "a/b/c" / name + found.parent.mkdir(parents=True, exist_ok=True) + found.write_bytes(b"") + self.assertTrue(chromium(str(root)), name) + found.unlink() + deep = root / "a/b/c/d/chrome-sandbox" + deep.parent.mkdir(parents=True) + deep.write_bytes(b"") + self.assertFalse(chromium(str(root))) + self.assertFalse(chromium(str(Path(tmp) / "missing"))) + + def test_chromium_detection_does_not_follow_links_inside_the_app(self): + chromium = MODULE["chromium_app"] + with tempfile.TemporaryDirectory() as tmp: + other = Path(tmp) / "other" + other.mkdir() + (other / "chrome-sandbox").write_bytes(b"") + root = Path(tmp) / "app" + root.mkdir() + (root / "linked").symlink_to(other, target_is_directory=True) + self.assertFalse(chromium(str(root))) + link = Path(tmp) / "app-link" + link.symlink_to(other, target_is_directory=True) + self.assertTrue(chromium(str(link))) + def test_the_vram_cap_comes_before_the_games_own_dxvk_settings(self): cap = MODULE["vram_cap"] self.assertEqual(cap({"BL_VRAM_MB": "4096"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 4096") diff --git a/tools/tests/test_game_gpu_launchers.py b/tools/tests/test_game_gpu_launchers.py new file mode 100644 index 000000000..71f39c22c --- /dev/null +++ b/tools/tests/test_game_gpu_launchers.py @@ -0,0 +1,98 @@ +import os +import runpy +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +KEYS = ("TU_DEBUG", "DROIDDECK_GAME_TU_DEBUG") +SESSION = {"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": ""} + + +class GameGpuLaunchersTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.out = self.tmp / "env" + self.tools = self.tmp / "tools" + self.tools.mkdir() + self.app = self.tmp / "deploy" + (self.app / "files/bin").mkdir(parents=True) + self.report = self.tools / "report" + self.report.write_text("#!/bin/sh\nfor k in %s; do eval \"echo $k=\\${$k-unset}\"; done > %s\n" + % (" ".join(KEYS), self.out)) + self.report.chmod(0o755) + flatpak = self.tools / "flatpak" + flatpak.write_text("#!/bin/sh\n" + "if [ \"$1\" = info ]; then [ \"$3\" = org.example.App ] && echo %s; exit 0; fi\n" + "exec %s\n" % (self.app, self.report)) + flatpak.chmod(0o755) + + def chromium(self): + (self.app / "files/lib/browser").mkdir(parents=True) + (self.app / "files/lib/browser/chrome_100_percent.pak").write_bytes(b"") + + def run_env(self, command, session): + env = {k: v for k, v in os.environ.items() if k not in KEYS + ("DBUS_SESSION_BUS_ADDRESS", "GAMESCOPE_WAYLAND_DISPLAY")} + env.update(session, PATH="%s:%s" % (self.tools, env.get("PATH", "/usr/bin:/bin")), + DBUS_SESSION_BUS_ADDRESS="unix:path=/dev/null") + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + return dict(line.split("=", 1) for line in self.out.read_text().splitlines()) + + def wrapper(self, *args, session=SESSION): + return self.run_env([sys.executable, str(BIN / "droiddeck-game-gpu"), *args, "--", str(self.report)], session) + + def flatpak(self, session=SESSION): + return self.run_env(["bash", str(BIN / "droiddeck-flatpak-run"), "--command=app", "org.example.App"], session) + + def test_the_wrapper_gives_a_program_the_game_setting(self): + self.assertEqual({"TU_DEBUG": "unset", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.wrapper()) + self.assertEqual("gmem", self.wrapper(session={"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "gmem"})["TU_DEBUG"]) + + def test_the_wrapper_keeps_the_session_setting_for_chromium(self): + self.assertEqual({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.wrapper("--chromium")) + self.assertEqual("unset", self.wrapper("--for", str(self.app / "files"))["TU_DEBUG"]) + self.chromium() + self.assertEqual("sysmem", self.wrapper("--for", str(self.app / "files"))["TU_DEBUG"]) + + def test_without_the_game_setting_the_wrapper_changes_nothing(self): + self.assertEqual("sysmem", self.wrapper(session={"TU_DEBUG": "sysmem"})["TU_DEBUG"]) + self.assertEqual("unset", self.wrapper(session={})["TU_DEBUG"]) + + def test_the_wrapper_reports_a_missing_program(self): + result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-gpu"), "--", str(self.tmp / "missing")], + capture_output=True, text=True, timeout=30) + self.assertEqual(127, result.returncode) + self.assertEqual(2, subprocess.run([sys.executable, str(BIN / "droiddeck-game-gpu")], capture_output=True, + timeout=30).returncode) + + def test_a_flatpak_app_gets_the_game_setting(self): + self.assertEqual({"TU_DEBUG": "unset", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.flatpak()) + self.assertEqual("nolrz", self.flatpak({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "nolrz"})["TU_DEBUG"]) + + def test_a_chromium_flatpak_keeps_the_session_setting(self): + self.chromium() + self.assertEqual({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "unset"}, self.flatpak()) + + def test_a_flatpak_without_the_game_setting_is_unchanged(self): + self.assertEqual("sysmem", self.flatpak({"TU_DEBUG": "sysmem"})["TU_DEBUG"]) + + def test_a_snap_gets_the_game_setting_unless_it_is_chromium(self): + try: + snap_run = runpy.run_path(str(BIN / "droiddeck-snap-run")) + except ImportError: + self.skipTest("PyYAML is not installed") + gpu = snap_run["game_gpu"] + snap = self.app / "files" + self.assertEqual({"KEEP": "1"}, gpu(dict(SESSION, KEEP="1"), str(snap))) + self.assertEqual({"TU_DEBUG": "nolrz"}, gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "nolrz"}, str(snap))) + self.chromium() + self.assertEqual({"TU_DEBUG": "sysmem"}, gpu(dict(SESSION), str(snap))) + + +if __name__ == "__main__": + unittest.main()