From 3bb74b70b6efacdc2fa7066da59aa3c3d4b6b76b Mon Sep 17 00:00:00 2001 From: wallentx Date: Sun, 11 Oct 2026 04:01:50 -0500 Subject: [PATCH] Apply PowerVR Steam and game compatibility during normal launches --- .../launcher/session/SessionFiles.kt | 1 + tools/linuxfs/desktop/droiddeck-gpu | 12 +- .../overlay/usr/local/bin/droiddeck-game-env | 58 ++++- .../overlay/usr/local/bin/droiddeck-session | 32 ++- .../usr/local/bin/droiddeck-steam-taskset | 17 ++ tools/tests/test_game_environment.py | 106 ++++++++++ tools/tests/test_gamescope_backend.py | 102 +++++++++ tools/tests/test_proton_wrapper.py | 198 ++++++++++++++++++ tools/tests/test_steam_helper_affinity.py | 91 ++++++++ tools/tests/test_steam_presentation.py | 73 +++++++ 10 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-taskset create mode 100644 tools/tests/test_gamescope_backend.py create mode 100644 tools/tests/test_proton_wrapper.py create mode 100644 tools/tests/test_steam_helper_affinity.py create mode 100644 tools/tests/test_steam_presentation.py 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 f69e4c837..a7115427c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -53,6 +53,7 @@ object SessionFiles { "usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install", "usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync", "usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat", + "usr/local/bin/droiddeck-steam-taskset" to "usr/local/libexec/droiddeck-steam/taskset", "usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex", "libblsession.so" to "usr/local/lib/libblsession.so", "libfakeinput.so" to "usr/local/lib/libfakeinput.so", diff --git a/tools/linuxfs/desktop/droiddeck-gpu b/tools/linuxfs/desktop/droiddeck-gpu index e68f55178..d220073f0 100644 --- a/tools/linuxfs/desktop/droiddeck-gpu +++ b/tools/linuxfs/desktop/droiddeck-gpu @@ -12,7 +12,12 @@ if [ -n "${BL_DEBUG_DIR:-}" ]; then fi width=${BL_WIDTH:-1280} height=${BL_HEIGHT:-720} -args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$width" -h "$height") +backend=${BL_GAMESCOPE_BACKEND:-wayland} +case "$backend" in + wayland|sdl) ;; + *) echo "== unsupported gamescope backend: $backend" >&2; exit 64 ;; +esac +args=(--backend "$backend" --expose-wayland -f -W "$width" -H "$height" -w "$width" -h "$height") if [ "${BL_FPS:-0}" -gt 0 ]; then args+=(-r "$BL_FPS") elif [ "${BL_REFRESH:-0}" -gt 0 ]; then @@ -24,6 +29,9 @@ echo "== droiddeck-gpu $(date -u +%H:%M:%S): $* (gamescope ${width}x${height} on # through gamescope's own Xwayland, as in a Steam session. export WAYLAND_DISPLAY=wayland-0 export GDK_BACKEND=x11 QT_QPA_PLATFORM=xcb SDL_VIDEODRIVER=x11 +# PowerVR's SDL backend presents to the app's Wayland compositor. Only the +# program inside gamescope uses its Xwayland display. +[ "$backend" = sdl ] && export SDL_VIDEODRIVER=wayland # GL on the GPU again (zink on Turnip, as the session sets it): the desktop itself draws GL in software. # Qt Quick back on its GPU renderer and KWin's software compositing not passed on, too. unset LIBGL_ALWAYS_SOFTWARE QT_QUICK_BACKEND KWIN_COMPOSE @@ -31,7 +39,7 @@ export MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink LIBGL_KOPPER_DRI2=tr [ -n "${DROIDDECK_GLTHREAD:-}" ] && export mesa_glthread=$DROIDDECK_GLTHREAD # AppImages cannot mount under proot; the menu entries already say so, a hand-typed one may not. export APPIMAGE_EXTRACT_AND_RUN=1 -gamescope "${args[@]}" -- /usr/local/bin/droiddeck-gpu-window "$@" +gamescope "${args[@]}" -- env SDL_VIDEODRIVER=x11 /usr/local/bin/droiddeck-gpu-window "$@" rc=$? echo "== droiddeck-gpu $(date -u +%H:%M:%S): exited $rc" exit $rc diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 067212e34..fc2a43679 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -271,6 +271,54 @@ def mark_install_scripts(command, env): print("droiddeck-game-env: install scripts not marked (%s)" % error, file=sys.stderr) +class SystemGraphicsError(ValueError): + pass + + +def system_graphics_launch(command, env): + """Use the APK's PowerVR compatibility files only for actual Proton game launches.""" + runtime = env.get("DROIDDECK_POWERVR_RUNTIME") + if not runtime or not game_id(env) or len(command) < 3 or command[1] != "waitforexitandrun": + return command, env + directory = Path(runtime) + if not directory.is_absolute(): + raise SystemGraphicsError("system Vulkan runtime path is not absolute") + required = ( + "run-proton.py", "wsi/libVkLayer_window_system_integration.so", + "wsi/VkLayer_window_system_integration.json", "lib/libVkLayer_hybris_compat.so", + *("dxvk/%s/%s.dll" % (arch, name) for arch in ("x32", "x64") + for name in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi")), + ) + try: + for name in required: + path = directory / name + if not path.is_file() or not path.stat().st_size: + raise SystemGraphicsError("missing system Vulkan component: %s" % name) + wrapper = directory / "run-proton.py" + if not os.access(wrapper, os.X_OK): + raise SystemGraphicsError("system Vulkan Proton wrapper is not executable") + except OSError as error: + raise SystemGraphicsError("system Vulkan component is unreadable: %s" % error) from error + result = dict(env) + result.update({ + "HYBRIS_BC_TEXTURES": "dxvk", + "VK_LAYER_PATH": "%s:%s" % (directory / "wsi", directory / "lib"), + "VK_INSTANCE_LAYERS": "VK_LAYER_window_system_integration:VK_LAYER_HYBRIS_compat", + "ENABLE_GAMESCOPE_WSI": "0", + "VK_LOADER_LAYERS_DISABLE": "*gamescope*", + "DISABLE_VK_LAYER_VALVE_steam_fossilize_1": "1", + }) + for name in ("DISABLE_WSI_LAYER", "PROTON_USE_WINED3D", "ENABLE_VK_LAYER_VALVE_steam_fossilize_1"): + result.pop(name, None) + return [str(wrapper), *command], result + + +def exec_proton(command, env): + # Synchronization selection has already picked the actual Proton executable. + command, env = system_graphics_launch(command, env) + os.execvpe(command[0], command, env) + + def main(): command = sys.argv[1:] if not command: @@ -309,13 +357,17 @@ def main(): launch, launch_env = select_sync(command, env, sync) if launch[0] != command[0]: try: - os.execvpe(launch[0], launch, launch_env) + exec_proton(launch, launch_env) except OSError as error: print("droiddeck-game-env: %s could not start (%s); stock launch" % (launch[0], error), file=sys.stderr) launch, launch_env = command, without_pack(launch_env, env) record_stock(sync, launch, launch_env) - os.execvpe(launch[0], launch, launch_env) + exec_proton(launch, launch_env) if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main()) + except SystemGraphicsError as error: + print("droiddeck-game-env: PowerVR graphics runtime unavailable (%s)" % error, file=sys.stderr) + sys.exit(1) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 94d9a6937..39e6cb4a7 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -225,7 +225,13 @@ if [ -z "${BL_INSIDE:-}" ]; then echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output" fi fi - args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") + backend=${BL_GAMESCOPE_BACKEND:-wayland} + case "$backend" in + wayland) ;; + sdl) export SDL_VIDEODRIVER=wayland ;; + *) echo "== unsupported gamescope backend: $backend"; exit 64 ;; + esac + args=(--backend "$backend" --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height") [ "$nested" != "$width" ] && args+=(-S stretch) && export BL_STRETCHED=1 # -r is what gamescope advertises as the refresh rate, and what a game reads as the display's. # Left unset it defaults to 60, so a 120 Hz panel offers only 60 Hz in game settings. The FPS @@ -764,6 +770,21 @@ BUSCONF export STEAM_RUNTIME=1 export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} export SDL_VIDEO_X11_DGAMOUSE=0 + bl_steam_path=$PATH + bl_steam_angle=vulkan + bl_steam_presentation=() + if [ "${BL_STEAM_GL_PRESENT:-}" = 1 ]; then + bl_steam_path="/usr/local/libexec/droiddeck-steam:$PATH" + # Keep the system driver for GPU rendering, but present Steam through ordinary DRI3. + # The inner compositor has neither android_wlegl nor the bridge's TAWC-DRI extension. + export DISABLE_GAMESCOPE_WSI=1 LIBGL_KOPPER_DISABLE=true + bl_steam_angle=gl + # CEF renders correctly internally, but its accelerated shared-texture output is + # corrupted on this path. Software web compositing avoids that handoff; Gamescope + # and the selected game driver still use the system GPU. + bl_steam_presentation=(-cef-disable-gpu-compositing) + echo "== steam: system PowerVR uses OpenGL/DRI3 through Zink with software CEF compositing" + fi # Decky is optional and session scoped: the app's marker opts in, and this supervisor is # reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used # only when its recorded architecture says so and droiddeck-fex has FEX and its runtime ready. @@ -890,7 +911,7 @@ BUSCONF done ) & fi - echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" + echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu ${bl_steam_presentation[*]} -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=$bl_steam_angle $*" # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. steam_updates=0 @@ -980,9 +1001,12 @@ BUSCONF /usr/local/bin/droiddeck-steam-desktop-ui & desktop_ui_pid=$! fi + # Keep Valve's optional webhelper CPU pinning from aborting browser startup. + PATH="$bl_steam_path" \ + BL_STEAM_WEBHELPER="$steam_root/steamrtarm64/steamwebhelper" \ "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -clientbeta "$steam_channel" \ - -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \ - -cef-use-gl=angle -cef-use-angle=vulkan "$@" + -no-cef-sandbox -cef-force-gpu "${bl_steam_presentation[@]}" -cef-ozone-platform=x11 \ + -cef-use-gl=angle "-cef-use-angle=$bl_steam_angle" "$@" rc=$? if [ -n "$desktop_ui_pid" ]; then kill "$desktop_ui_pid" 2>/dev/null || true diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-taskset b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-taskset new file mode 100644 index 000000000..8973fc7f5 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-taskset @@ -0,0 +1,17 @@ +#!/bin/bash +# Staged as taskset in a PATH directory used only by the Steam client. +# Valve's ARM64 helper uses `taskset 0x7c /path/to/steamwebhelper ...` and exits +# before starting the browser when Android denies sched_setaffinity. +set -u +if [ "$#" -ge 2 ] && [[ "$1" =~ ^(0[xX])?[0-9a-fA-F]+$ ]] && + [ -n "${BL_STEAM_WEBHELPER:-}" ] && [ "$2" -ef "$BL_STEAM_WEBHELPER" ]; then + mask=$1 + shift + # Pin this wrapper before exec; never retry a helper that has already run. + if ! /usr/bin/taskset -p "$mask" "$$" >/dev/null 2>&1; then + echo "droiddeck: Steam helper CPU pinning unavailable; using the allowed CPUs" >&2 + fi + exec -- "$@" +fi +# Queries, existing-process changes, and other programs retain taskset's behavior. +exec /usr/bin/taskset "$@" diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 1fa4cd739..babc5b2bf 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -7,6 +7,7 @@ import subprocess import sys import tempfile import unittest +from unittest.mock import patch BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" MODULE = runpy.run_path(str(BIN / "droiddeck-game-env")) @@ -25,6 +26,111 @@ def config_text(entries): return '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping +class SystemGraphicsLaunchTest(unittest.TestCase): + def runtime(self, root): + runtime = root / "system_vulkan" / ("a" * 64) + files = ["run-proton.py", "lib/libVkLayer_hybris_compat.so", + "wsi/libVkLayer_window_system_integration.so", + "wsi/VkLayer_window_system_integration.json"] + files += ["dxvk/%s/%s.dll" % (arch, name) for arch in ("x32", "x64") + for name in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi")] + for name in files: + target = runtime / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"component") + (runtime / "run-proton.py").chmod(0o755) + return runtime, files + + def test_other_gpus_and_non_game_calls_keep_the_original_launch(self): + command = ["/proton", "waitforexitandrun", "game.exe"] + base = {"STEAM_COMPAT_DATA_PATH": "/compatdata/42", "VK_LAYER_PATH": "original"} + selected = dict(base, DROIDDECK_POWERVR_RUNTIME="/not-installed") + cases = [(command, base), (command, dict(selected, STEAM_COMPAT_DATA_PATH="/compatdata/0")), + (command, dict(selected, STEAM_COMPAT_DATA_PATH="/compatdata/invalid")), + (["/proton", "run", "iscriptevaluator.exe"], selected), + (["/proton", "getcompatpath", "x"], selected), (command[:2], selected)] + for args, env in cases: + with self.subTest(args=args, env=env): + actual, inherited = MODULE["system_graphics_launch"](args, env) + self.assertIs(actual, args) + self.assertIs(inherited, env) + + def test_system_graphics_follow_game_configuration_and_preserve_other_keys(self): + with tempfile.TemporaryDirectory() as tmp: + runtime, _ = self.runtime(Path(tmp)) + base = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": "/compatdata/42"} + config = {"version": 1, "games": {"42": { + "PROTON_USE_WINED3D": "1", "DISABLE_WSI_LAYER": "1", + "ENABLE_VK_LAYER_VALVE_steam_fossilize_1": "1", "HYBRIS_BC_TEXTURES": "0", + "VK_LAYER_PATH": "old", "VK_INSTANCE_LAYERS": "old", "ENABLE_GAMESCOPE_WSI": "1", + "FEX_TSOENABLED": "1", "WINESERVER": "/selected/wineserver", "CUSTOM": "literal $value", + }}} + env = MODULE["apply_config"](base, config, "42") + command = ["/selected/proton", "waitforexitandrun", "game with spaces.exe"] + launch, result = MODULE["system_graphics_launch"](command, env) + self.assertEqual(launch, [str(runtime / "run-proton.py"), *command]) + self.assertEqual(result["HYBRIS_BC_TEXTURES"], "dxvk") + self.assertEqual(result["VK_LAYER_PATH"], f"{runtime}/wsi:{runtime}/lib") + self.assertEqual(result["VK_INSTANCE_LAYERS"], "VK_LAYER_window_system_integration:VK_LAYER_HYBRIS_compat") + self.assertEqual(result["VK_LOADER_LAYERS_DISABLE"], "*gamescope*") + self.assertEqual(result["ENABLE_GAMESCOPE_WSI"], "0") + self.assertEqual(result["DISABLE_VK_LAYER_VALVE_steam_fossilize_1"], "1") + for key in ("PROTON_USE_WINED3D", "DISABLE_WSI_LAYER", "ENABLE_VK_LAYER_VALVE_steam_fossilize_1"): + self.assertNotIn(key, result) + self.assertIn(key, env) + for key in ("FEX_TSOENABLED", "WINESERVER", "CUSTOM"): + self.assertEqual(result[key], env[key]) + + def test_incomplete_runtime_fails_before_any_proton_exec(self): + with tempfile.TemporaryDirectory() as tmp: + runtime, files = self.runtime(Path(tmp)) + env = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": "/compatdata/42"} + command = ["/proton", "waitforexitandrun", "game.exe"] + for name in files: + target = runtime / name + original = target.read_bytes() + for missing in (False, True): + with self.subTest(name=name, missing=missing): + target.unlink() if missing else target.write_bytes(b"") + with patch.object(os, "execvpe") as execute: + with self.assertRaises(MODULE["SystemGraphicsError"]): + MODULE["exec_proton"](command, env) + execute.assert_not_called() + target.write_bytes(original) + if name == "run-proton.py": + target.chmod(0o755) + (runtime / "run-proton.py").chmod(0o644) + with self.assertRaises(MODULE["SystemGraphicsError"]): + MODULE["system_graphics_launch"](command, env) + with self.assertRaises(MODULE["SystemGraphicsError"]): + MODULE["system_graphics_launch"](command, dict(env, DROIDDECK_POWERVR_RUNTIME="relative")) + + def test_wrapper_receives_the_sync_selected_proton(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + runtime, _ = self.runtime(root) + env = {"HOME": tmp, "DROIDDECK_POWERVR_RUNTIME": str(runtime), + "STEAM_COMPAT_DATA_PATH": "/compatdata/42"} + command = ["/stock/proton", "waitforexitandrun", "game.exe"] + selected = ["/sync-pack/proton", *command[1:]] + selected_env = dict(env, WINESERVER="/sync-pack/wineserver", BL_SYNC_PACK="selected") + main = MODULE["main"] + with patch.dict(os.environ, env, clear=True), patch.object(sys, "argv", ["game-env", *command]), \ + patch.dict(main.__globals__, {"load_sync": lambda: {}, + "select_sync": lambda *args: (selected, selected_env), + "win_components": lambda *args: ""}), \ + patch.object(os, "execvpe", side_effect=SystemExit(23)) as execute: + with self.assertRaises(SystemExit) as stopped: + main() + self.assertEqual(stopped.exception.code, 23) + execute.assert_called_once() + path, args, actual_env = execute.call_args.args + self.assertEqual(path, str(runtime / "run-proton.py")) + self.assertEqual(args, [path, *selected]) + self.assertEqual(actual_env["WINESERVER"], "/sync-pack/wineserver") + self.assertEqual(actual_env["BL_SYNC_PACK"], "selected") + + class GameEnvironmentTest(unittest.TestCase): def test_the_install_script_evaluator_gets_its_scripts_marked_first(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tools/tests/test_gamescope_backend.py b/tools/tests/test_gamescope_backend.py new file mode 100644 index 000000000..f995d1f60 --- /dev/null +++ b/tools/tests/test_gamescope_backend.py @@ -0,0 +1,102 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" +DESKTOP_GPU = Path(__file__).resolve().parents[1] / "linuxfs/desktop/droiddeck-gpu" + + +class GamescopeBackendTest(unittest.TestCase): + def select(self, backend=None): + source = SESSION.read_text() + start = source.index(' backend=${BL_GAMESCOPE_BACKEND:-wayland}') + end = source.index('\n', source.index(' args=(--backend ', start)) + script = 'width=1280; height=720; nested=1280\n' + source[start:end] + script += '\nprintf "%s\\n" "${args[0]}" "${args[1]}" "${SDL_VIDEODRIVER:-unset}"\n' + env = dict(os.environ) + env.pop("BL_GAMESCOPE_BACKEND", None) + env.pop("SDL_VIDEODRIVER", None) + if backend is not None: + env["BL_GAMESCOPE_BACKEND"] = backend + return subprocess.run(["bash", "-c", script], env=env, text=True, + capture_output=True, timeout=5) + + def test_default_retains_the_wayland_backend(self): + result = self.select() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(["--backend", "wayland", "unset"], result.stdout.splitlines()) + + def test_sdl_uses_the_host_wayland_vulkan_swapchain(self): + result = self.select("sdl") + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(["--backend", "sdl", "wayland"], result.stdout.splitlines()) + + def test_unknown_backend_fails_before_gamescope(self): + result = self.select("drm") + self.assertEqual(64, result.returncode) + self.assertIn("unsupported gamescope backend: drm", result.stdout) + + +class DesktopGamescopeBackendTest(unittest.TestCase): + def launch(self, backend=None): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + capture = root / "gamescope.json" + gamescope = root / "gamescope" + gamescope.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "Path(os.environ['GAMESCOPE_CAPTURE']).write_text(json.dumps({\n" + " 'args': sys.argv[1:],\n" + " 'sdl': os.environ.get('SDL_VIDEODRIVER'),\n" + " 'gdk': os.environ.get('GDK_BACKEND'),\n" + " 'qt': os.environ.get('QT_QPA_PLATFORM'),\n" + " 'wayland': os.environ.get('WAYLAND_DISPLAY'),\n" + "}))\n" + ) + gamescope.chmod(0o755) + env = dict(os.environ) + for name in ("BL_GAMESCOPE_BACKEND", "BL_DEBUG_DIR", "BL_FPS", "BL_REFRESH", "BL_HDR"): + env.pop(name, None) + env.update(PATH=f"{root}{os.pathsep}{env.get('PATH', '')}", + GAMESCOPE_CAPTURE=str(capture), WAYLAND_DISPLAY="desktop-0", + SDL_VIDEODRIVER="dummy", GDK_BACKEND="wayland", QT_QPA_PLATFORM="wayland") + if backend is not None: + env["BL_GAMESCOPE_BACKEND"] = backend + result = subprocess.run(["bash", str(DESKTOP_GPU), "demo", "one argument"], + env=env, text=True, capture_output=True, timeout=5) + return result, json.loads(capture.read_text()) if capture.exists() else None + + def assert_xwayland_application(self, capture): + args = capture["args"] + self.assertEqual(["env", "SDL_VIDEODRIVER=x11", "/usr/local/bin/droiddeck-gpu-window", + "demo", "one argument"], args[args.index("--") + 1:]) + self.assertEqual("x11", capture["gdk"]) + self.assertEqual("xcb", capture["qt"]) + self.assertEqual("wayland-0", capture["wayland"]) + + def test_default_keeps_wayland_and_xwayland_application(self): + result, capture = self.launch() + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(["--backend", "wayland"], capture["args"][:2]) + self.assertEqual("x11", capture["sdl"]) + self.assert_xwayland_application(capture) + + def test_sdl_uses_wayland_without_leaking_to_the_application(self): + result, capture = self.launch("sdl") + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(["--backend", "sdl"], capture["args"][:2]) + self.assertEqual("wayland", capture["sdl"]) + self.assert_xwayland_application(capture) + + def test_unknown_backend_fails_before_gamescope(self): + result, capture = self.launch("drm") + self.assertEqual(64, result.returncode) + self.assertIn("unsupported gamescope backend: drm", result.stderr) + self.assertIsNone(capture) diff --git a/tools/tests/test_proton_wrapper.py b/tools/tests/test_proton_wrapper.py new file mode 100644 index 000000000..8243000df --- /dev/null +++ b/tools/tests/test_proton_wrapper.py @@ -0,0 +1,198 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +TOOLS = Path(__file__).resolve().parents[1] +WRAPPER_SOURCE = TOOLS / "hybris/run-proton.py" +DLL_NAMES = ("d3d8.dll", "d3d9.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll") +DXVK_X64_ANCHOR = 'g_proton.arch_pe_dir("wine/dxvk", False) + f + ".dll"' +DXVK_X32_ANCHOR = 'g_proton.arch_pe_dir("wine/dxvk", True) + f + ".dll"' +PREFIX_ANCHOR = " # check whether any prefix config has changed" + + +PROTON_SOURCE = '''import json +import os +from pathlib import Path +import sys + +from neighbor import NEIGHBOR + + +class Proton: + def arch_pe_dir(self, directory, is32): + return str(Path(__file__).parent / "stock" / ("x32" if is32 else "x64")) + "/" + + +g_proton = Proton() + + +def prefix_marker(): + if True: + prefix_info = "stock" + # check whether any prefix config has changed + return prefix_info + + +def payloads(): + result = {"x64": {}, "x32": {}} + for f in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"): + x64 = g_proton.arch_pe_dir("wine/dxvk", False) + f + ".dll" + x32 = g_proton.arch_pe_dir("wine/dxvk", True) + f + ".dll" + result["x64"][f + ".dll"] = Path(x64).read_text() + result["x32"][f + ".dll"] = Path(x32).read_text() + return result + + +def main(): + record = { + "argv0": sys.argv[0], + "argv": sys.argv[1:], + "file": __file__, + "neighbor": NEIGHBOR, + "prefix": prefix_marker(), + "payloads": payloads(), + "openvr": g_proton.arch_pe_dir("wine/dxvk", False) + "openvr_api_dxvk.dll", + } + Path(os.environ["PROBE"]).write_text(json.dumps(record)) + raise SystemExit(int(os.environ.get("PROTON_EXIT", "0"))) + + +main() +''' + + +class ProtonWrapperTest(unittest.TestCase): + def fixture(self, root): + wrapper_dir = root / 'DXVK profile "quotes" $dollar `backticks`' + wrapper_dir.mkdir(parents=True) + wrapper = wrapper_dir / "run-proton.py" + shutil.copy2(WRAPPER_SOURCE, wrapper) + + dxvk = wrapper_dir / "dxvk" + for architecture in ("x32", "x64"): + payload = dxvk / architecture + payload.mkdir(parents=True) + for name in DLL_NAMES: + (payload / name).write_text(f"{architecture}:{name}") + + depot = root / "Proton depot with spaces" + depot.mkdir() + proton = depot / "selected proton.py" + proton.write_text(PROTON_SOURCE) + (depot / "neighbor.py").write_text('NEIGHBOR = "imported from selected depot"\n') + return {"wrapper": wrapper, "dxvk": dxvk, "depot": depot, "proton": proton, + "probe": root / "proton-ran.json"} + + def run_wrapper(self, fixture, *proton_args, check=False, exit_status=0): + command = [sys.executable, str(fixture["wrapper"])] + if check: + command.append("--check") + command.extend((str(fixture["proton"]), *proton_args)) + return subprocess.run( + command, + cwd=fixture["wrapper"].parent, + env={"PATH": os.defpath, "PROBE": str(fixture["probe"]), + "PROTON_EXIT": str(exit_status)}, + text=True, + capture_output=True, + ) + + def test_launch_uses_both_staged_payloads_and_selected_proton_context(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = self.fixture(Path(tmp)) + original_source = fixture["proton"].read_bytes() + arguments = ("waitforexitandrun", "game with spaces.exe", 'quoted "value"', + "$literal-dollar", "`literal-backticks`") + result = self.run_wrapper(fixture, *arguments) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(fixture["proton"].read_bytes(), original_source) + record = json.loads(fixture["probe"].read_text()) + self.assertEqual(record["argv0"], str(fixture["proton"])) + self.assertEqual(record["argv"], list(arguments)) + self.assertEqual(record["file"], str(fixture["proton"])) + self.assertEqual(record["neighbor"], "imported from selected depot") + self.assertEqual(record["prefix"], "stock\ndroiddeck-dxvk-profile-v1") + self.assertEqual(record["openvr"], str(fixture["depot"] / "stock/x64/openvr_api_dxvk.dll")) + for architecture in ("x32", "x64"): + self.assertEqual( + record["payloads"][architecture], + {name: f"{architecture}:{name}" for name in DLL_NAMES}, + ) + + def test_check_validates_without_executing_proton(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = self.fixture(Path(tmp)) + result = self.run_wrapper(fixture, check=True) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("payload present and Proton layout supported", result.stdout) + self.assertFalse(fixture["probe"].exists()) + + def test_check_rejects_malformed_source_before_its_top_level_code_runs(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = self.fixture(Path(tmp)) + source = fixture["proton"].read_text() + source = source.replace( + "import sys\n", 'import sys\n\nPath(os.environ["PROBE"]).write_text("top-level")\n', 1) + fixture["proton"].write_text(source + "\nthis is not valid =\n") + result = self.run_wrapper(fixture, check=True) + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("DroidDeck DXVK profile:", result.stderr) + self.assertFalse(fixture["probe"].exists()) + + def test_preflight_rejects_each_missing_or_empty_payload_before_proton_runs(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for state in ("missing", "empty"): + for architecture in ("x32", "x64"): + for name in DLL_NAMES: + with self.subTest(state=state, architecture=architecture, name=name): + fixture = self.fixture(root / f"{state}-{architecture}-{name}") + payload = fixture["dxvk"] / architecture / name + if state == "missing": + payload.unlink() + else: + payload.write_bytes(b"") + result = self.run_wrapper(fixture, "waitforexitandrun", "game.exe") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn(f"{state} DXVK file", result.stderr) + self.assertFalse(fixture["probe"].exists()) + + def test_preflight_rejects_changed_proton_layout_or_syntax_before_proton_runs(self): + mutations = [] + for label, anchor in (("x64", DXVK_X64_ANCHOR), ("x32", DXVK_X32_ANCHOR), + ("prefix", PREFIX_ANCHOR)): + mutations.extend(((f"missing-{label}", lambda text, anchor=anchor: text.replace(anchor, "missing_anchor", 1)), + (f"duplicate-{label}", lambda text, anchor=anchor: text + "\n# " + anchor + "\n"))) + mutations.append(("syntax-error", lambda text: text + "\nthis is not valid =\n")) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for label, mutate in mutations: + with self.subTest(layout=label): + fixture = self.fixture(root / label) + fixture["proton"].write_text(mutate(fixture["proton"].read_text())) + result = self.run_wrapper(fixture, "waitforexitandrun", "game.exe") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("DroidDeck DXVK profile:", result.stderr) + self.assertFalse(fixture["probe"].exists()) + + def test_proton_exit_status_is_propagated(self): + with tempfile.TemporaryDirectory() as tmp: + fixture = self.fixture(Path(tmp)) + result = self.run_wrapper(fixture, "waitforexitandrun", "game.exe", exit_status=23) + + self.assertEqual(result.returncode, 23, result.stderr) + self.assertTrue(fixture["probe"].exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_helper_affinity.py b/tools/tests/test_steam_helper_affinity.py new file mode 100644 index 000000000..97549ac05 --- /dev/null +++ b/tools/tests/test_steam_helper_affinity.py @@ -0,0 +1,91 @@ +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import unittest + +WRAPPER = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-steam-taskset" + + +class SteamHelperAffinityTest(unittest.TestCase): + def launch(self, status, form="helper", configured=True): + with tempfile.TemporaryDirectory(prefix="droiddeck-helper-") as temporary: + root = Path(temporary) + helper = root / "steamwebhelper" + helper.write_text(f"#!{sys.executable}\n" + + 'import json, os, sys\n' + 'with open(os.environ["STARTS"],"a") as f: f.write(json.dumps(sys.argv[1:])+"\\n")\n' + 'sys.exit(23)\n') + helper.chmod(0o755) + real = root / "real-taskset" + real.write_text(f"#!{sys.executable}\n" + + 'import json, os, sys\n' + 'with open(os.environ["TRACE"],"a") as f: f.write(json.dumps(sys.argv[1:])+"\\n")\n' + f'sys.exit({status})\n') + real.chmod(0o755) + wrapper = root / "taskset" + wrapper.write_text(WRAPPER.read_text().replace("/usr/bin/taskset", shlex.quote(str(real)))) + helper_path = helper + if form == "alias": + helper_path = root / "helper symlink" + helper_path.symlink_to(helper) + elif form == "other": + helper_path = root / "other/steamwebhelper" + helper_path.parent.mkdir() + helper_path.write_text(helper.read_text()) + helper_path.chmod(0o755) + args = ["0x7c", str(helper_path), "arg with spaces", "--literal=$HOME"] + if form == "query": + args = ["-p", "12345"] + elif form == "invalid": + args[0] = "not-a-mask" + elif form == "empty": + args = [] + env = dict(os.environ, TRACE=str(root / "trace"), STARTS=str(root / "starts")) + env.pop("BL_STEAM_WEBHELPER", None) + if configured: + env["BL_STEAM_WEBHELPER"] = str(helper) + result = subprocess.run(["bash", str(wrapper), *args], env=env, + capture_output=True, text=True, timeout=5) + trace = [json.loads(x) for x in (root / "trace").read_text().splitlines()] + starts = ([json.loads(x) for x in (root / "starts").read_text().splitlines()] + if (root / "starts").exists() else []) + return result, trace, starts, args + + def test_denied_pinning_still_executes_helper_once(self): + result, trace, starts, args = self.launch(1) + self.assertEqual(result.returncode, 23) + self.assertEqual(starts, [args[2:]]) + self.assertEqual(len(trace), 1) + self.assertEqual(trace[0][:2], ["-p", "0x7c"]) + self.assertRegex(trace[0][2], r"^[1-9][0-9]*$") + self.assertIn("using the allowed CPUs", result.stderr) + + def test_successful_pinning_preserves_helper_exit_status(self): + result, trace, starts, args = self.launch(0) + self.assertEqual(result.returncode, 23) + self.assertEqual(starts, [args[2:]]) + self.assertEqual(trace[0][:2], ["-p", "0x7c"]) + self.assertNotIn("pinning unavailable", result.stderr) + + def test_symlink_to_actual_helper_is_recognized(self): + result, _, starts, args = self.launch(1, "alias") + self.assertEqual(result.returncode, 23) + self.assertEqual(starts, [args[2:]]) + + def test_other_taskset_forms_and_targets_pass_through(self): + for form, configured in [("other", True), ("query", True), ("invalid", True), + ("empty", True), ("helper", False)]: + with self.subTest(form=form, configured=configured): + result, trace, starts, args = self.launch(47, form, configured) + self.assertEqual(result.returncode, 47) + self.assertEqual(trace, [args]) + self.assertEqual(starts, []) + self.assertNotIn("pinning unavailable", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_presentation.py b/tools/tests/test_steam_presentation.py new file mode 100644 index 000000000..21ce81b0d --- /dev/null +++ b/tools/tests/test_steam_presentation.py @@ -0,0 +1,73 @@ +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" + + +class SteamPresentationTest(unittest.TestCase): + def launch(self, enabled=None, inherited=None): + source = SESSION.read_text() + begin = source.index(" bl_steam_path=$PATH") + end = source.index("\n # Decky", begin) + command = source.index(' PATH="$bl_steam_path"') + command_end = source.index("\n rc=$?", command) + script = (source[begin:end] + '\n_bl_ui=(-gamepadui); _bl_cdp=(); _bl_lang=()\n' + + source[command:command_end]) + env = dict(os.environ) + for key in ("BL_STEAM_GL_PRESENT", "DISABLE_GAMESCOPE_WSI", "LIBGL_KOPPER_DISABLE"): + env.pop(key, None) + if enabled is not None: + env["BL_STEAM_GL_PRESENT"] = enabled + env.update(inherited or {}) + with tempfile.TemporaryDirectory(prefix="droiddeck-steam-gl-") as tmp: + program = Path(tmp) / "steamrtarm64/steam" + program.parent.mkdir() + program.write_text(f"#!{sys.executable}\n" + + 'import json, os, sys\nprint(json.dumps({"args":sys.argv[1:],' + '"disable":os.getenv("DISABLE_GAMESCOPE_WSI"),' + '"kopper":os.getenv("LIBGL_KOPPER_DISABLE"),' + '"path":os.getenv("PATH"),' + '"helper":os.getenv("BL_STEAM_WEBHELPER")}))\n') + program.chmod(0o755) + env.update(steam_root=tmp, steam_channel="steamdeck_publicbeta") + result = subprocess.run(["bash", "-c", script, "test", "steam://open/main"], + env=env, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + launch = json.loads(result.stdout.splitlines()[-1]) + self.assertEqual(enabled == "1", launch["path"].startswith("/usr/local/libexec/droiddeck-steam:")) + self.assertEqual(launch["helper"], str(Path(tmp) / "steamrtarm64/steamwebhelper")) + return launch + + def test_default_keeps_vulkan(self): + result = self.launch() + self.assertIn("-cef-use-angle=vulkan", result["args"]) + self.assertNotIn("-cef-disable-gpu-compositing", result["args"]) + self.assertIsNone(result["disable"]) + self.assertIsNone(result["kopper"]) + + def test_system_powervr_keeps_gpu_gl_with_software_web_compositing(self): + result = self.launch("1") + self.assertIn("-cef-use-angle=gl", result["args"]) + self.assertIn("-cef-use-gl=angle", result["args"]) + self.assertIn("-cef-force-gpu", result["args"]) + self.assertIn("-cef-disable-gpu-compositing", result["args"]) + self.assertNotIn("-cef-disable-gpu", result["args"]) + self.assertEqual(result["disable"], "1") + self.assertEqual(result["kopper"], "true") + self.assertEqual(result["args"][-1], "steam://open/main") + + def test_disabled_option_preserves_existing_environment(self): + result = self.launch("0", {"DISABLE_GAMESCOPE_WSI": "0", "LIBGL_KOPPER_DISABLE": "false"}) + self.assertIn("-cef-use-angle=vulkan", result["args"]) + self.assertNotIn("-cef-disable-gpu-compositing", result["args"]) + self.assertEqual(result["disable"], "0") + self.assertEqual(result["kopper"], "false") + + +if __name__ == "__main__": + unittest.main()