Files
DroidDeck/tools/tests/test_proton_wrapper.py
T
Stefan Zijlstra b93141b757 Experimental Samsung Xclipse support on the system Vulkan path
Detect Samsung's Vulkan HAL (vulkan.samsung.so) and its sgpu render node
alongside PowerVR, and run Xclipse through the same libhybris bridge:

- VK_LAYER_DROIDDECK_xclipse (tools/linuxfs/xclipse-layer), built with the
  glibc preloads and enabled only on Xclipse: sets bionic's environ for the
  driver's shader compiler, avoids immutable YCbCr samplers in compute
  layouts, forwards the KHR calibrated-timestamp entry points to the EXT
  ones, and reports textureCompressionBC and BC4/BC5 filtering over the
  compat layer's BC emulation.
- Upstream DXVK 2.6.2, pinned as a tar.gz component and selected on Xclipse:
  the driver has 128 bytes of push constants, DXVK 3 needs 256.
- android_wlegl accepts a zero stride and passes it through unchanged:
  Samsung's gralloc records none for GPU-only buffers.

Tested on a Galaxy A56 (Xclipse 540, driver 24.0.560): Steam Big Picture
and PICO PARK (D3D9).
2026-10-12 05:55:57 +02:00

229 lines
9.9 KiB
Python

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, env=None):
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), **(env or {})},
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_named_sibling_payload_replaces_the_default(self):
with tempfile.TemporaryDirectory() as tmp:
fixture = self.fixture(Path(tmp))
for architecture in ("x32", "x64"):
payload = fixture["wrapper"].parent / "dxvk-2.6.2" / architecture
payload.mkdir(parents=True)
for name in DLL_NAMES:
(payload / name).write_text(f"2.6.2 {architecture}:{name}")
result = self.run_wrapper(fixture, "waitforexitandrun", "game.exe",
env={"DROIDDECK_HYBRIS_DXVK": "dxvk-2.6.2"})
self.assertEqual(result.returncode, 0, result.stderr)
record = json.loads(fixture["probe"].read_text())
for architecture in ("x32", "x64"):
self.assertEqual(
record["payloads"][architecture],
{name: f"2.6.2 {architecture}:{name}" for name in DLL_NAMES},
)
def test_payload_names_outside_the_wrapper_directory_are_refused(self):
for name in ("../dxvk", "dxvk/../../x", "/tmp/dxvk", "other", ".dxvk"):
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
fixture = self.fixture(Path(tmp))
result = self.run_wrapper(fixture, "waitforexitandrun", "game.exe",
env={"DROIDDECK_HYBRIS_DXVK": name})
self.assertEqual(result.returncode, 1)
self.assertIn("invalid payload name", result.stderr)
self.assertFalse(fixture["probe"].exists())
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()