mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Apply PowerVR Steam and game compatibility during normal launches
This commit is contained in:
10 files changed
+681
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user