Apply PowerVR Steam and game compatibility during normal launches

This commit is contained in:
wallentx committed 2026-10-11 04:01:50 -05:00
1 parent 844a1a2473
commit 8cf0373b5e
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",
+10 -2
View File
@@ -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 "$@"
+106
View File
@@ -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:
+102
View File
@@ -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)
+198
View File
@@ -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()
+91
View File
@@ -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()
+73
View File
@@ -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()