mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
2855 lines
172 KiB
Python
2855 lines
172 KiB
Python
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
import runpy
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from unittest import mock
|
|
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"))
|
|
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
|
|
STATE = tempfile.TemporaryDirectory()
|
|
COMPAT["main"].__globals__["COMPAT_DIR"] = STATE.name
|
|
|
|
|
|
class SupergiantDotnetReporterTest(unittest.TestCase):
|
|
def test_allows_the_builtin_mscoree_for_supergiant_reporter(self):
|
|
game = mock.Mock(files={"basecampbugreporter.native.dll"})
|
|
self.assertEqual(
|
|
({"WINEDLLOVERRIDES": "mscoree=n,b"}, [], "Supergiant: allow the builtin mscoree"),
|
|
MODULE["supergiant_dotnet_reporter"](game),
|
|
)
|
|
|
|
def test_ignores_games_without_the_supergiant_reporter(self):
|
|
game = mock.Mock(files={"enginewin64s.dll"})
|
|
self.assertIsNone(MODULE["supergiant_dotnet_reporter"](game))
|
|
|
|
|
|
def mapping_of(text):
|
|
tokens = COMPAT["tokenize"](text)
|
|
return COMPAT["read_mapping"](tokens, COMPAT["mapping_block"](tokens))
|
|
|
|
|
|
def config_text(entries):
|
|
mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, name, "75" if app == "0" else "250") for app, name in entries)
|
|
return '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping
|
|
|
|
|
|
class RenderPathDefaultsTest(unittest.TestCase):
|
|
DRIVER = "/drivers/DD-Turnip_v0.4.0/icd.json"
|
|
COMMAND = ["proton", "waitforexitandrun", "/games/Spintires/SpinTires.exe"]
|
|
|
|
def apply(self, env=None, command=None, model="Adreno722", library="libvulkan_freedreno.so"):
|
|
env = dict({"VK_DRIVER_FILES": self.DRIVER} if env is None else env)
|
|
manifest = json.dumps({"ICD": {"library_path": library}})
|
|
with mock.patch.object(Path, "read_text", return_value=model), \
|
|
mock.patch.object(Path, "is_file", return_value=False), \
|
|
mock.patch("builtins.open", mock.mock_open(read_data=manifest)):
|
|
note = MODULE["render_path_defaults"](command or self.COMMAND, env)
|
|
return env, note
|
|
|
|
def test_tested_render_path_keeps_other_options(self):
|
|
env, note = self.apply({"VK_DRIVER_FILES": self.DRIVER, "DXVK_CONFIG": "d3d9.maxFrameLatency = 2; ", "CUSTOM": "kept"})
|
|
self.assertEqual(env["DXVK_CONFIG"], "d3d9.maxFrameLatency = 2; dxvk.tilerMode = False")
|
|
self.assertEqual(env["CUSTOM"], "kept")
|
|
self.assertIsNotNone(note)
|
|
self.assertEqual(self.apply()[0]["DXVK_CONFIG"], "dxvk.tilerMode = False")
|
|
self.assertIsNotNone(self.apply({"VK_ICD_FILENAMES": self.DRIVER})[1])
|
|
|
|
def test_other_games_devices_and_drivers_keep_their_environment(self):
|
|
for changes in (
|
|
{"command": ["proton", "waitforexitandrun", "/games/MudRunner.exe"]},
|
|
{"command": ["proton", "run", self.COMMAND[2]]},
|
|
{"command": ["proton"]},
|
|
{"model": "Adreno740"},
|
|
{"model": "Mali-G715"},
|
|
{"library": "libvulkan.so"},
|
|
{"env": {}},
|
|
{"env": {"VK_DRIVER_FILES": "/drivers/new-turnip/icd.json"}},
|
|
{"env": {"VK_DRIVER_FILES": "", "VK_ICD_FILENAMES": self.DRIVER}},
|
|
):
|
|
with self.subTest(changes=changes):
|
|
env, note = self.apply(**changes)
|
|
self.assertIsNone(note)
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
|
|
def test_explicit_environment_and_config_files_take_precedence(self):
|
|
for value in ("dxvk.tilerMode = Auto", "d3d9.maxFrameLatency = 1; dxvk.tilerMode=True", "dxvk.tilerMode = False"):
|
|
env, note = self.apply({"VK_DRIVER_FILES": self.DRIVER, "DXVK_CONFIG": value})
|
|
self.assertIsNone(note)
|
|
self.assertEqual(env["DXVK_CONFIG"], value)
|
|
env, note = self.apply({"VK_DRIVER_FILES": self.DRIVER, "DXVK_CONFIG_FILE": "/games/custom.conf"})
|
|
self.assertIsNone(note)
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
with mock.patch.object(Path, "is_file", return_value=True):
|
|
env = {"VK_DRIVER_FILES": self.DRIVER}
|
|
self.assertIsNone(MODULE["render_path_defaults"](self.COMMAND, env))
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
|
|
def test_unreadable_or_invalid_driver_metadata_leaves_launch_unchanged(self):
|
|
for data in ("not-json", "{}", '{"ICD":{"library_path":null}}'):
|
|
with mock.patch.object(Path, "read_text", return_value="Adreno722"), \
|
|
mock.patch.object(Path, "is_file", return_value=False), \
|
|
mock.patch("builtins.open", mock.mock_open(read_data=data)):
|
|
env = {"VK_DRIVER_FILES": self.DRIVER}
|
|
self.assertIsNone(MODULE["render_path_defaults"](self.COMMAND, env))
|
|
self.assertNotIn("DXVK_CONFIG", env)
|
|
with mock.patch.object(Path, "read_text", side_effect=PermissionError), \
|
|
mock.patch.object(Path, "is_file", return_value=False):
|
|
self.assertIsNone(MODULE["render_path_defaults"](self.COMMAND, {"VK_DRIVER_FILES": self.DRIVER}))
|
|
|
|
|
|
class DirtTelemetryFixTest(unittest.TestCase):
|
|
SHOWDOWN_ON = '<x><motion enabled="true" ip="127.0.0.1" port="20777" delay="1" extradata="3" /></x>'
|
|
SHOWDOWN_DEFAULT = '<x><motion enabled="true" ip="dbox" port="20777" delay="1" extradata="0" /></x>'
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
root = Path(self.tmp.name)
|
|
self.prefix = root / "compatdata/2848447563"
|
|
self.proton = root / "Proton/proton"
|
|
self.own = root / "Proton/files/lib/wine/i386-windows/ws2_32.dll"
|
|
self.own.parent.mkdir(parents=True)
|
|
self.own.write_bytes(b"MZ wine ws2_32")
|
|
self.proton.write_text("")
|
|
self.target = self.prefix / "pfx/drive_c/windows/syswow64/ws2_32.dll"
|
|
self.target.parent.mkdir(parents=True)
|
|
self.target.symlink_to(self.own)
|
|
self.stand_in = root / "droiddeck-dirt/ws2_32.dll"
|
|
self.stand_in.parent.mkdir()
|
|
self.stand_in.write_bytes(b"MZ droiddeck-dirt-ws2_32 stand-in")
|
|
self.patch = mock.patch.dict(MODULE["dirt_ws2_wanted"].__globals__, {"DIRT_WS2": str(self.stand_in)})
|
|
self.patch.start()
|
|
|
|
def tearDown(self):
|
|
self.patch.stop()
|
|
self.tmp.cleanup()
|
|
|
|
def config(self, folder, text):
|
|
path = self.prefix / "pfx/drive_c/users/steamuser/Documents/My Games" / folder / "hardwaresettings/hardware_settings_config.xml"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
|
|
def command(self, exe="showdown.exe"):
|
|
return [str(self.proton), "waitforexitandrun", "/root/Storage/games/DiRT Showdown/" + exe]
|
|
|
|
def env(self):
|
|
return {"STEAM_COMPAT_DATA_PATH": str(self.prefix)}
|
|
|
|
def test_telemetry_counts_as_on_when_the_game_sends_somewhere(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_DEFAULT)
|
|
self.assertFalse(MODULE["dirt_telemetry_on"](self.prefix, "dirtshowdown"))
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.assertTrue(MODULE["dirt_telemetry_on"](self.prefix, "dirtshowdown"))
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON.replace('enabled="true"', 'enabled="false"'))
|
|
self.assertFalse(MODULE["dirt_telemetry_on"](self.prefix, "dirtshowdown"))
|
|
self.config("DiRT3", '<x><motion enabled="true" ip="192.168.1.20" port="20777" /></x>')
|
|
self.assertTrue(MODULE["dirt_telemetry_on"](self.prefix, "dirt3"))
|
|
|
|
def test_stand_in_is_wanted_only_for_the_dirt_games_with_telemetry_on_and_a_proton_without_the_fix(self):
|
|
wanted = MODULE["dirt_ws2_wanted"]
|
|
self.config("DiRT Showdown", self.SHOWDOWN_DEFAULT)
|
|
self.assertIsNone(wanted(self.command(), self.env()))
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.assertEqual(wanted(self.command(), self.env()), str(self.prefix))
|
|
self.assertEqual(wanted(self.command("Showdown_AVX.exe"), self.env()), str(self.prefix))
|
|
self.assertIsNone(wanted(self.command("DiRTRally2.exe"), self.env()))
|
|
self.own.write_bytes(b"MZ wine ws2_32 " + "showdown_avx.exe".encode("utf-16-le"))
|
|
self.assertIsNone(wanted(self.command(), self.env()))
|
|
|
|
def test_swap_keeps_the_prefix_link_and_restore_puts_it_back(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
saved = self.target.with_name("ws2_32.dll.droiddeck-original")
|
|
self.assertFalse(self.target.is_symlink())
|
|
self.assertEqual(self.target.read_bytes(), self.stand_in.read_bytes())
|
|
self.assertEqual(os.readlink(saved), str(self.own))
|
|
self.assertEqual(self.own.read_bytes(), b"MZ wine ws2_32")
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
self.assertEqual(os.readlink(saved), str(self.own))
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(saved))
|
|
self.assertFalse(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
|
|
def test_restore_keeps_a_file_proton_put_back_meanwhile(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
newer = self.own.with_name("ws2_32-newer.dll")
|
|
newer.write_bytes(b"MZ newer wine ws2_32")
|
|
self.target.unlink()
|
|
self.target.symlink_to(newer)
|
|
self.assertFalse(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(newer))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_after_a_proton_switch_the_running_protons_file_goes_back(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
other = Path(self.tmp.name) / "GE-Proton/proton"
|
|
other_own = other.parent / "files/lib/wine/i386-windows/ws2_32.dll"
|
|
other_own.parent.mkdir(parents=True)
|
|
other_own.write_bytes(b"MZ GE wine ws2_32")
|
|
other.write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, other))
|
|
self.assertEqual(os.readlink(self.target), str(other_own))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_restore_follows_a_proton_reached_through_a_linked_folder(self):
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
pack = Path(self.tmp.name) / "pack"
|
|
(pack / "files/lib/wine").mkdir(parents=True)
|
|
(pack / "files/lib/wine/i386-windows").symlink_to(self.own.parent)
|
|
(pack / "proton").write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, pack / "proton"))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
|
|
def test_a_stand_in_left_without_the_saved_link_is_replaced_by_protons_own_file(self):
|
|
self.target.unlink()
|
|
self.target.write_bytes(self.stand_in.read_bytes())
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
|
|
def test_launch_swaps_the_stand_in_in_for_the_game_and_back_afterwards(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.proton.write_text("#!" + sys.executable + "\nimport os, sys\n"
|
|
"target = os.path.join(os.environ['STEAM_COMPAT_DATA_PATH'], 'pfx/drive_c/windows/syswow64/ws2_32.dll')\n"
|
|
"print(os.environ.get('WINEDLLOVERRIDES', ''))\n"
|
|
"print(os.path.islink(target), b'droiddeck-dirt-ws2_32' in open(target, 'rb').read())\n")
|
|
self.proton.chmod(0o755)
|
|
runner = Path(self.tmp.name) / "run.py"
|
|
runner.write_text("import runpy, sys\nmain = runpy.run_path(sys.argv[1])['main']\nmain.__globals__['DIRT_WS2'] = sys.argv[2]\n"
|
|
"sys.argv = [sys.argv[1]] + sys.argv[3:]\nsys.exit(main())\n")
|
|
home = Path(self.tmp.name) / "home"
|
|
home.mkdir()
|
|
result = subprocess.run([sys.executable, str(runner), str(BIN / "droiddeck-game-env"), str(self.stand_in), *self.command()],
|
|
env={**os.environ, "HOME": str(home), "STEAM_COMPAT_DATA_PATH": str(self.prefix)}, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
overrides, state = result.stdout.splitlines()[-2:]
|
|
self.assertIn("ws2_32=n,b", overrides.split(";"))
|
|
self.assertEqual(state, "False True")
|
|
self.assertIn("DiRT telemetry fix: ws2_32 stand-in", result.stderr)
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_a_proton_that_cannot_start_leaves_the_prefix_as_it_was(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_ON)
|
|
self.proton.write_text("not a program")
|
|
self.proton.chmod(0o644)
|
|
runner = Path(self.tmp.name) / "run.py"
|
|
runner.write_text("import runpy, sys\nmain = runpy.run_path(sys.argv[1])['main']\nmain.__globals__['DIRT_WS2'] = sys.argv[2]\n"
|
|
"sys.argv = [sys.argv[1]] + sys.argv[3:]\nsys.exit(main())\n")
|
|
home = Path(self.tmp.name) / "home"
|
|
home.mkdir()
|
|
result = subprocess.run([sys.executable, str(runner), str(BIN / "droiddeck-game-env"), str(self.stand_in), *self.command()],
|
|
env={**os.environ, "HOME": str(home), "STEAM_COMPAT_DATA_PATH": str(self.prefix)}, text=True, capture_output=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("DiRT telemetry fix: ws2_32 stand-in", result.stderr)
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(self.target.with_name("ws2_32.dll.droiddeck-original")))
|
|
|
|
def test_launch_with_telemetry_off_leaves_the_prefix_alone(self):
|
|
self.config("DiRT Showdown", self.SHOWDOWN_DEFAULT)
|
|
self.proton.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('WINEDLLOVERRIDES', ''))\n")
|
|
self.proton.chmod(0o755)
|
|
home = Path(self.tmp.name) / "home"
|
|
home.mkdir()
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), *self.command()],
|
|
env={**os.environ, "HOME": str(home), "STEAM_COMPAT_DATA_PATH": str(self.prefix)}, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertNotIn("ws2_32=n,b", result.stdout)
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
|
|
def test_a_failed_put_back_does_not_end_the_launch_with_an_error(self):
|
|
def fail():
|
|
raise OSError(28, "No space left on device")
|
|
stderr = io.StringIO()
|
|
with mock.patch("sys.stderr", stderr):
|
|
code = MODULE["start"]([sys.executable, "-c", "raise SystemExit(0)"], dict(os.environ), None, fail)
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("not put back", stderr.getvalue())
|
|
|
|
def test_a_half_done_swap_is_put_back(self):
|
|
saved = self.target.with_name("ws2_32.dll.droiddeck-original")
|
|
os.replace(self.target, saved)
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, self.proton))
|
|
self.assertEqual(os.readlink(self.target), str(self.own))
|
|
self.assertFalse(os.path.lexists(saved))
|
|
|
|
def test_a_native_ws2_32_goes_back_as_it_was_after_a_proton_switch(self):
|
|
self.target.unlink()
|
|
self.target.write_bytes(b"MZ a mod's own ws2_32")
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
other = Path(self.tmp.name) / "GE-Proton/proton"
|
|
(other.parent / "files/lib/wine/i386-windows").mkdir(parents=True)
|
|
(other.parent / "files/lib/wine/i386-windows/ws2_32.dll").write_bytes(b"MZ GE wine ws2_32")
|
|
other.write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, other))
|
|
self.assertFalse(self.target.is_symlink())
|
|
self.assertEqual(self.target.read_bytes(), b"MZ a mod's own ws2_32")
|
|
|
|
def test_a_link_to_a_mods_ws2_32_goes_back_as_it_was(self):
|
|
mod = Path(self.tmp.name) / "mods/ws2_32.dll"
|
|
mod.parent.mkdir()
|
|
mod.write_bytes(b"MZ a mod's ws2_32")
|
|
self.target.unlink()
|
|
self.target.symlink_to(mod)
|
|
MODULE["swap_dirt_ws2"](self.prefix)
|
|
other = Path(self.tmp.name) / "GE-Proton/proton"
|
|
(other.parent / "files/lib/wine/i386-windows").mkdir(parents=True)
|
|
(other.parent / "files/lib/wine/i386-windows/ws2_32.dll").write_bytes(b"MZ GE wine ws2_32")
|
|
other.write_text("")
|
|
self.assertTrue(MODULE["restore_dirt_ws2"](self.prefix, other))
|
|
self.assertEqual(os.readlink(self.target), str(mod))
|
|
|
|
def test_start_gives_the_signal_handlers_back(self):
|
|
import signal
|
|
before = {s: signal.getsignal(s) for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
|
|
MODULE["start"]([sys.executable, "-c", "pass"], dict(os.environ), None, lambda: None)
|
|
self.assertEqual(before, {s: signal.getsignal(s) for s in before})
|
|
|
|
def test_start_puts_the_file_back_after_the_game_exits(self):
|
|
calls = []
|
|
code = MODULE["start"]([sys.executable, "-c", "raise SystemExit(3)"], dict(os.environ), None, lambda: calls.append("restored"))
|
|
self.assertEqual(code, 3)
|
|
self.assertEqual(calls, ["restored"])
|
|
with self.assertRaises(OSError):
|
|
MODULE["start"]([str(Path(self.tmp.name) / "missing")], dict(os.environ), None, lambda: calls.append("again"))
|
|
self.assertEqual(calls, ["restored"])
|
|
|
|
|
|
class EgoCorePlanTest(unittest.TestCase):
|
|
|
|
@staticmethod
|
|
def rule(op, value, worker_map):
|
|
return (' <restrict_if name="cores" op="%s" value="%d">\r\n <restrict_add>\r\n'
|
|
' <threadStrategy parallelUpdateRender="true" workerMapFile="system/%s" updateProcessor="2" />\r\n'
|
|
' </restrict_add>\r\n </restrict_if>\r\n' % (op, value, worker_map))
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.system = Path(self.tmp.name) / "DiRT 3/system"
|
|
self.system.mkdir(parents=True)
|
|
cpu = "".join(self.rule("int_eq", n, "workerMap%dCore.xml" % n) for n in (2, 3, 4, 6)) + self.rule("int_ge", 8, "workerMap8Core.xml")
|
|
self.shipped = ('<?xml version="1.0" encoding="UTF-8" ?>\r\n<hardware_settings_restrictions>\r\n <cpu>\r\n'
|
|
' <restrict_info>\r\n' + cpu +
|
|
' <restrict_else>\r\n <restrict_add>\r\n'
|
|
' <threadStrategy parallelUpdateRender="false" workerMapFile="system/workerMap.xml" />\r\n'
|
|
' </restrict_add>\r\n </restrict_else>\r\n </restrict_info>\r\n </cpu>\r\n'
|
|
' <audio_card>\r\n' + self.rule("int_eq", 5, "audio.xml") + ' </audio_card>\r\n'
|
|
'</hardware_settings_restrictions>').encode("latin-1")
|
|
self.path = self.system / "hardware_settings_restrictions.xml"
|
|
self.path.write_bytes(self.shipped)
|
|
for name in ("workerMap.xml", "workerMap2Core.xml", "workerMap3Core.xml", "workerMap4Core.xml", "workerMap6Core.xml", "workerMap8Core.xml"):
|
|
(self.system / name).write_text("<workerMap />")
|
|
self.command = ["/proton", "waitforexitandrun", str(self.system.parent / "dirt3_game.exe")]
|
|
self.saved = self.path.with_name(self.path.name + ".droiddeck-original")
|
|
|
|
def plan(self, topology, command=None):
|
|
env = {} if topology is None else {"WINE_CPU_TOPOLOGY": topology}
|
|
return MODULE["ego_core_plan"](command or self.command, env)
|
|
|
|
def test_five_cores_get_a_copy_of_the_four_core_plan(self):
|
|
note = self.plan("5:7,3,4,5,6")
|
|
self.assertIn("5 cores", note)
|
|
self.assertIn("4-core plan", note)
|
|
text = self.path.read_bytes()
|
|
four = self.rule("int_eq", 4, "workerMap4Core.xml").encode("latin-1")
|
|
five = self.rule("int_eq", 5, "workerMap4Core.xml").encode("latin-1")
|
|
self.assertEqual(text, self.shipped.replace(four, four + five))
|
|
self.assertNotIn(b"\n", text.replace(b"\r\n", b""))
|
|
self.assertEqual(self.saved.read_bytes(), self.shipped)
|
|
self.assertEqual(sorted(p.name for p in self.system.glob("*droiddeck-tmp*")), [])
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6"))
|
|
self.assertEqual(self.path.read_bytes(), text)
|
|
self.assertEqual(self.saved.read_bytes(), self.shipped)
|
|
|
|
def test_seven_cores_get_the_six_core_plan(self):
|
|
self.assertIn("6-core plan", self.plan("7:7,1,2,3,4,5,6"))
|
|
self.assertIn(b'value="7">\r\n <restrict_add>\r\n <threadStrategy parallelUpdateRender="true" '
|
|
b'workerMapFile="system/workerMap6Core.xml"', self.path.read_bytes())
|
|
|
|
def test_counts_with_a_plan_are_left_alone(self):
|
|
for topology in ("2:7,6", "4:7,3,4,5", "6:7,3,4,5,6,2", "8:7,2,3,4,5,6,0,1", "12:0,1,2,3,4,5,6,7,8,9,10,11"):
|
|
self.assertIsNone(self.plan(topology), topology)
|
|
self.assertEqual(self.path.read_bytes(), self.shipped)
|
|
self.assertFalse(os.path.lexists(self.saved))
|
|
|
|
def test_nothing_changes_without_a_core_count_a_restrictions_file_a_smaller_plan_or_its_worker_map(self):
|
|
self.assertIsNone(self.plan(None))
|
|
self.assertIsNone(self.plan(""))
|
|
self.assertIsNone(self.plan("x:1"))
|
|
self.assertIsNone(self.plan("\u00b2:7"))
|
|
self.assertIsNone(self.plan("1:7"))
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6", ["/proton", "waitforexitandrun"]))
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6", ["/proton", "waitforexitandrun", "/elsewhere/game.exe"]))
|
|
(self.system / "workerMap4Core.xml").unlink()
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6"))
|
|
self.assertEqual(self.path.read_bytes(), self.shipped)
|
|
self.assertFalse(os.path.lexists(self.saved))
|
|
|
|
def test_an_unknown_comparison_counts_as_a_plan(self):
|
|
self.path.write_bytes(self.shipped.replace(b'op="int_ge" value="8"', b'op="int_between" value="8"'))
|
|
self.assertIsNone(self.plan("5:7,3,4,5,6"))
|
|
|
|
def test_main_adds_the_plan_before_the_game_starts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = Path(tmp) / "probe"
|
|
probe.write_text('#!/bin/sh\ngrep -c \'value="5"\' "%s"\n' % self.path)
|
|
probe.chmod(0o755)
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith(("WINE", "PROTON", "DROIDDECK", "BL_", "STEAM"))}
|
|
env.update(HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/3791535942", WINE_CPU_TOPOLOGY="5:7,3,4,5,6")
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", self.command[2]],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), "2", result.stderr)
|
|
self.assertIn("added a copy of its 4-core plan", result.stderr)
|
|
|
|
|
|
class SystemGraphicsLaunchTest(unittest.TestCase):
|
|
def test_start_configures_ubisoft_with_proton_before_wrapping_the_game(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
runtime, _ = self.runtime(root)
|
|
prefix = root / "42" / "pfx"
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
(prefix / "user.reg").write_text("WINE REGISTRY Version 2\n")
|
|
env = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": str(prefix.parent)}
|
|
command = ["/selected/proton", "waitforexitandrun", "game.exe"]
|
|
with mock.patch("subprocess.run") as run, mock.patch("os.execvpe") as execute:
|
|
MODULE["start"](command, env, None)
|
|
self.assertEqual(run.call_count, 2)
|
|
for call in run.call_args_list:
|
|
self.assertEqual(call.args[0][:3], ["/selected/proton", "runinprefix", "reg.exe"])
|
|
self.assertEqual(execute.call_args.args[:2], (str(runtime / "run-proton.py"),
|
|
[str(runtime / "run-proton.py"), *command]))
|
|
|
|
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_xclipse_selection_adds_its_layers_layer_path_and_dxvk_payload(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
runtime, _ = self.runtime(Path(tmp))
|
|
env = {"DROIDDECK_POWERVR_RUNTIME": str(runtime), "STEAM_COMPAT_DATA_PATH": "/compatdata/42",
|
|
"DROIDDECK_HYBRIS_LAYERS": "VK_LAYER_DROIDDECK_xclipse:VK_LAYER_HYBRIS_compat",
|
|
"DROIDDECK_HYBRIS_LAYER_PATH": "/usr/local/lib/droiddeck-xclipse",
|
|
"DROIDDECK_HYBRIS_DXVK": "dxvk-2.6.2"}
|
|
command = ["/proton", "waitforexitandrun", "game.exe"]
|
|
# The selected payload is required like the default one.
|
|
with self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["system_graphics_launch"](command, env)
|
|
for arch in ("x32", "x64"):
|
|
for name in ("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi"):
|
|
target = runtime / "dxvk-2.6.2" / arch / (name + ".dll")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(b"component")
|
|
launch, result = MODULE["system_graphics_launch"](command, env)
|
|
self.assertEqual(launch, [str(runtime / "run-proton.py"), *command])
|
|
self.assertEqual(result["VK_INSTANCE_LAYERS"],
|
|
"VK_LAYER_window_system_integration:VK_LAYER_DROIDDECK_xclipse:VK_LAYER_HYBRIS_compat")
|
|
self.assertEqual(result["VK_LAYER_PATH"], f"{runtime}/wsi:{runtime}/lib:/usr/local/lib/droiddeck-xclipse")
|
|
self.assertEqual(result["DROIDDECK_HYBRIS_DXVK"], "dxvk-2.6.2")
|
|
for name in ("../dxvk", "other", "dxvk/x"):
|
|
with self.subTest(name=name), self.assertRaises(MODULE["SystemGraphicsError"]):
|
|
MODULE["system_graphics_launch"](command, dict(env, DROIDDECK_HYBRIS_DXVK=name))
|
|
|
|
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 GameDebugLoggingTest(unittest.TestCase):
|
|
def test_maintenance_precision_survives_run_with_performance_game_settings(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "shared": {"FEX_X87REDUCEDPRECISION": "1"},
|
|
"games": {"42": {"FEX_X87REDUCEDPRECISION": "1"}}}))
|
|
probe = home / "proton"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ['FEX_X87REDUCEDPRECISION'])\n")
|
|
probe.chmod(0o755)
|
|
base = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/42")}
|
|
maintenance = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "run"],
|
|
env={**base, "DROIDDECK_GOG_MAINTENANCE": "1", "FEX_X87REDUCEDPRECISION": "0"}, text=True, capture_output=True, check=True)
|
|
game = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env={**base, "FEX_X87REDUCEDPRECISION": "1"}, text=True, capture_output=True, check=True)
|
|
self.assertEqual("0", maintenance.stdout.strip())
|
|
self.assertEqual("1", game.stdout.strip())
|
|
|
|
def test_real_game_launch_without_config_file_keeps_the_base_environment(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
probe = home / "proton"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ['PROTON_LOG'])\n")
|
|
probe.chmod(0o755)
|
|
result = subprocess.run(
|
|
[sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env={**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/42"),
|
|
"BL_DEBUG_DIR": tmp, "PROTON_LOG": "0"},
|
|
text=True, capture_output=True, check=True,
|
|
)
|
|
self.assertEqual(result.stdout.strip(), "0")
|
|
|
|
def test_game_debug_logs_override_only_diagnostic_values_and_keep_raw_settings(self):
|
|
with tempfile.TemporaryDirectory(prefix="game logs '") as debug_dir:
|
|
inherited = {"BL_DEBUG_DIR": debug_dir, "PROTON_LOG": "0", "WINEDEBUG": "-all,+relay",
|
|
"PROTON_LOG_DIR": "/old", "PROTON_CRASH_REPORT_DIR": "/old-crashes",
|
|
"DXVK_LOG_PATH": "/old-dxvk", "DXVK_LOG_LEVEL": "none", "VKD3D_DEBUG": "none",
|
|
"CUSTOM": "$(touch never); 'quoted' = literal", "DXVK_CONFIG": "dxvk.tearFree = True"}
|
|
config = {"version": 1, "gameDebugLogs": True,
|
|
"shared": {"PROTON_LOG": "0", "BL_DEBUG_DIR": "/profile-redirect"},
|
|
"games": {"42": {"PROTON_LOG_DIR": None, "DXVK_LOG_PATH": "/game-redirect"}}}
|
|
normal = MODULE["apply_config"](inherited, config, "42")
|
|
debug = MODULE["apply_game_debug_logs"](normal, config, inherited["BL_DEBUG_DIR"])
|
|
self.assertEqual(debug["PROTON_LOG"], "1")
|
|
for key in ("PROTON_LOG_DIR", "PROTON_CRASH_REPORT_DIR", "DXVK_LOG_PATH"):
|
|
self.assertEqual(debug[key], debug_dir)
|
|
self.assertEqual(debug["DXVK_LOG_LEVEL"], "debug")
|
|
self.assertEqual(debug["VKD3D_DEBUG"], "info")
|
|
self.assertIn("+relay", debug["WINEDEBUG"].split(","))
|
|
self.assertTrue(debug["WINEDEBUG"].endswith("+loaddll,+mscoree"))
|
|
for key in ("CUSTOM", "DXVK_CONFIG", "BL_DEBUG_DIR"):
|
|
self.assertEqual(debug[key], normal[key])
|
|
self.assertEqual(normal["PROTON_LOG"], "0")
|
|
self.assertEqual(inherited["PROTON_LOG_DIR"], "/old")
|
|
self.assertIs(MODULE["apply_game_debug_logs"](normal, {**config, "gameDebugLogs": False}, debug_dir), normal)
|
|
self.assertEqual(MODULE["apply_game_debug_logs"](debug, config, debug_dir), debug)
|
|
|
|
def test_wine_debug_channels_are_formatted_deduplicated_and_diagnostics_win(self):
|
|
formatted = MODULE["game_debug_channels"](
|
|
" +relay, trace-vulkan, -all, -seh, +relay, trace+vulkan, garbage, $(touch never) ",
|
|
"+seh,warn-foo,warn+foo,+relay",
|
|
)
|
|
self.assertEqual(formatted, "-all,trace+vulkan,warn+foo,+relay," +
|
|
"+timestamp,+pid,+tid,+seh,+unwind,+threadname,+debugstr,+loaddll,+mscoree")
|
|
self.assertEqual(MODULE["game_debug_channels"](formatted, "1"), formatted)
|
|
self.assertNotIn("-seh", formatted)
|
|
for value in ("x\0,+relay", "+" + "x" * 8192, 1, None):
|
|
self.assertEqual(MODULE["game_debug_channels"](value), MODULE["game_debug_channels"]())
|
|
oversized = ",".join("+channel_" + str(i) for i in range(550))
|
|
other = ",".join("+other_" + str(i) for i in range(550))
|
|
self.assertLessEqual(len(MODULE["game_debug_channels"](oversized, other)), 8192)
|
|
self.assertTrue(MODULE["game_debug_channels"](oversized, other).endswith("+loaddll,+mscoree"))
|
|
|
|
def test_game_debug_logs_require_opt_in_and_a_real_session_log_directory(self):
|
|
env = {"PROTON_LOG": "user", "CUSTOM": "keep"}
|
|
for config in ({}, {"version": 1}, {"version": 1, "gameDebugLogs": False},
|
|
{"version": 1, "gameDebugLogs": "true"}, {"version": 2, "gameDebugLogs": True}):
|
|
self.assertIs(MODULE["apply_game_debug_logs"](env, config, "/"), env)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
not_a_directory = Path(tmp) / "file"
|
|
not_a_directory.write_text("file")
|
|
for path in (None, "", "relative", "bad\0path", str(not_a_directory), str(Path(tmp) / "missing")):
|
|
with self.subTest(path=path):
|
|
self.assertIs(MODULE["apply_game_debug_logs"](env, {"version": 1, "gameDebugLogs": True}, path), env)
|
|
|
|
def test_game_debug_toggle_is_live_preserves_command_and_excludes_probes(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config_path = home / ".config/droiddeck/game-environment.json"
|
|
config_path.parent.mkdir(parents=True)
|
|
debug_dir = home / "session logs '$literal; directory'"
|
|
debug_dir.mkdir()
|
|
probe = home / "fake proton"
|
|
probe.write_text("#!" + sys.executable + "\nimport json, os, sys\n"
|
|
"print(json.dumps({'env': dict(os.environ), 'argv': sys.argv[1:]}))\n")
|
|
probe.chmod(0o755)
|
|
literal = "$(touch " + str(home / "never-created") + "); 'literal'=value"
|
|
config = {"version": 1, "gameDebugLogs": True,
|
|
"shared": {"PROTON_LOG": "0", "WINEDEBUG": "-all,+relay", "CUSTOM": literal,
|
|
"BL_DEBUG_DIR": "/profile-must-not-redirect"}}
|
|
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/42"),
|
|
"BL_DEBUG_DIR": str(debug_dir), "PROTON_LOG": "0", "WINEDEBUG": "-all"}
|
|
command = [str(probe), "waitforexitandrun", "game 'with quotes'.exe", "argument with spaces", "$(literal)", "%command%"]
|
|
|
|
def launch(arguments, environment):
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), *arguments],
|
|
env=environment, text=True, capture_output=True, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
for enabled in (True, False, True):
|
|
config_path.write_text(json.dumps({**config, "gameDebugLogs": enabled}))
|
|
result = launch(command, env)
|
|
self.assertEqual(result["argv"], command[1:])
|
|
self.assertEqual(result["env"]["CUSTOM"], literal)
|
|
self.assertEqual(result["env"]["PROTON_LOG"], "1" if enabled else "0")
|
|
if enabled:
|
|
self.assertEqual(result["env"]["PROTON_LOG_DIR"], str(debug_dir))
|
|
self.assertIn("+relay", result["env"]["WINEDEBUG"].split(","))
|
|
self.assertIn("+seh", result["env"]["WINEDEBUG"].split(","))
|
|
else:
|
|
self.assertEqual(result["env"]["WINEDEBUG"], "-all,+relay")
|
|
for verb, prefix in (("run", "42"), ("waitforexitandrun", "0"), ("waitforexitandrun", "not-a-game")):
|
|
result = launch([str(probe), verb], {**env, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata" / prefix)})
|
|
self.assertEqual(result["env"]["PROTON_LOG"], "0")
|
|
self.assertEqual(result["env"]["WINEDEBUG"], "-all")
|
|
result = launch(command, {**env, "STEAM_COMPAT_DATA_PATH": str(home / "compatdata/-1")})
|
|
self.assertEqual(result["env"]["PROTON_LOG"], "1")
|
|
self.assertFalse((home / "never-created").exists())
|
|
self.assertEqual(json.loads(config_path.read_text())["shared"], config["shared"])
|
|
|
|
|
|
class ReadConfigMappingTest(unittest.TestCase):
|
|
def test_missing_parent_or_unbalanced_vdf_is_not_treated_as_absent_choices(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text('"InstallConfigStore" { "Software" { "Other" {} } }')
|
|
self.assertIsNone(COMPAT["read_config_mapping"](str(config)))
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" {} }')
|
|
self.assertIsNone(COMPAT["read_config_mapping"](str(config)))
|
|
|
|
def test_valid_steam_parent_without_compat_mapping_has_no_per_app_choices(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" {} } } }')
|
|
self.assertEqual(COMPAT["read_config_mapping"](str(config)), {})
|
|
|
|
|
|
class GameEnvironmentTest(unittest.TestCase):
|
|
def test_component_filter_and_prefix_repair_use_per_game_proton_architecture(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "shared": {"PROTON_USE_ARM64": "1"},
|
|
"games": {"42": {"PROTON_USE_ARM64": "0"}}}))
|
|
seen_components, seen_repair, seen_start = [], [], []
|
|
globals = MODULE["main"].__globals__
|
|
stubs = {
|
|
"game_gpu": lambda env: env,
|
|
"mark_install_scripts": lambda command, env: [],
|
|
"install_scripts_done": lambda scripts, env: False,
|
|
"scripts_digest": lambda scripts: None,
|
|
"win_components": lambda *args: seen_components.append(args[3]) or "",
|
|
"repair_foreign_system_dlls": lambda *args: seen_repair.append(args[2]) or [],
|
|
"gl_vsync": lambda env: False,
|
|
"game_images": lambda env: None,
|
|
"skip_red_launcher": lambda command: command,
|
|
"ubisoft_overlay_off": lambda env: None,
|
|
"project_zomboid_jvm": lambda command: None,
|
|
"engine_fixes": lambda *args: ({}, [], []),
|
|
"dirt_ws2_wanted": lambda *args: None,
|
|
"apply_game_debug_logs": lambda env, config, debug_dir: env,
|
|
"offline_without_anticheat": lambda command, env: command,
|
|
"game_logs": lambda command, env, app_id: env,
|
|
"directaudio_settings": lambda env: env,
|
|
"vram_cap": lambda env: env,
|
|
"render_path_defaults": lambda *args: None,
|
|
"bridge_feature_level": lambda env: None,
|
|
"force_ssbs": lambda command, env: env,
|
|
"game_cores": lambda env: None,
|
|
"cpu_topology": lambda env, command: None,
|
|
"ego_core_plan": lambda *args: None,
|
|
"runpy": mock.Mock(run_path=lambda *args: {"launch": lambda command, env: None}),
|
|
"load_sync": lambda: {},
|
|
"select_sync": lambda command, env, sync: (command, env),
|
|
"start": lambda command, env, *args: seen_start.append(env) or 7,
|
|
}
|
|
environment = {"HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "PROTON_USE_ARM64": "1"}
|
|
command = ["/stock/proton", "waitforexitandrun", "/game/game.exe"]
|
|
with patch.dict(os.environ, environment, clear=True), patch.object(sys, "argv", ["game-env", *command]), \
|
|
patch.dict(globals, stubs):
|
|
self.assertEqual(MODULE["main"](), 7)
|
|
self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_components])
|
|
self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_repair])
|
|
self.assertEqual(["0"], [env["PROTON_USE_ARM64"] for env in seen_start])
|
|
|
|
def test_optional_xbox_loader_failure_keeps_stock_launch(self):
|
|
from unittest.mock import patch
|
|
for error in (FileNotFoundError('missing'), RuntimeError('module initialization failed')):
|
|
calls = []
|
|
with patch.dict(os.environ, {}, clear=True), patch('sys.argv', ['droiddeck-game-env', '/stock', 'run']), \
|
|
patch('runpy.run_path', side_effect=error), patch('os.execvpe', side_effect=lambda *args: calls.append(args)), \
|
|
patch.dict(MODULE['main'].__globals__, load_sync=lambda: None, select_sync=lambda command, env, sync: (command, env)):
|
|
MODULE['main']()
|
|
self.assertEqual(calls[0][0:2], ('/stock', ['/stock', 'run']))
|
|
|
|
|
|
def test_directaudio_capture_follows_the_session_microphone_setting(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
probe = home / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('AUDIO_DIRECT_MIC', 'unset'))\n")
|
|
probe.chmod(0o755)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
for enabled, override, expected in (("1", None, "1"), ("0", "1", "0"), ("1", "0", "0")):
|
|
with self.subTest(enabled=enabled, override=override):
|
|
profile = {} if override is None else {
|
|
"AUDIO_DIRECT_MIC": override, "BL_MIC_ENABLED": override,
|
|
}
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": profile}}))
|
|
env = dict(os.environ, HOME=tmp, BL_MIC_ENABLED=enabled,
|
|
AUDIO_DIRECT_MIC="1", STEAM_COMPAT_DATA_PATH="/compatdata/42")
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"),
|
|
str(probe), "waitforexitandrun"],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected)
|
|
|
|
def test_game_profile_directaudio_settings_reach_the_driver(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
probe = home / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(' '.join(sorted(k + '=' + v for k, v in os.environ.items() if k.startswith('BANNER_AUDIO_DIRECT_'))))\n")
|
|
probe.chmod(0o755)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": {"AUDIO_DIRECT_MS": "80"}}}))
|
|
base = {key: value for key, value in os.environ.items() if "AUDIO_DIRECT_" not in key}
|
|
base.update(HOME=tmp, BL_MIC_ENABLED="0", STEAM_COMPAT_DATA_PATH="/compatdata/42",
|
|
AUDIO_DIRECT_MS="60", AUDIO_DIRECT_PERIOD_MS="20", AUDIO_DIRECT_MIC="1",
|
|
BANNER_AUDIO_DIRECT_MS="60", BANNER_AUDIO_DIRECT_MIC="1")
|
|
for driver, expected in (("directaudio,pulse", "BANNER_AUDIO_DIRECT_MIC=0 BANNER_AUDIO_DIRECT_MS=80 BANNER_AUDIO_DIRECT_PERIOD_MS=20"),
|
|
("pulse", "BANNER_AUDIO_DIRECT_MIC=1 BANNER_AUDIO_DIRECT_MS=60")):
|
|
with self.subTest(driver=driver):
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env=dict(base, WINE_AUDIO_DRIVER=driver), text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected)
|
|
|
|
def test_desktop_games_restart_on_the_gpu_with_steams_preloads_handed_back(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
gpu = home / "gpu"
|
|
gpu.write_text("#!/bin/bash\necho \"gpu LD_PRELOAD=${LD_PRELOAD-unset}\"\nexport BL_DESKTOP_GPU_CHILD=1\nexec \"$@\"\n")
|
|
gpu.chmod(0o755)
|
|
launcher = home / "launcher"
|
|
setup = COMPAT["BL_DESKTOP_GPU_SETUP"].replace("/usr/local/bin/droiddeck-gpu", str(gpu))
|
|
launcher.write_text("#!/bin/bash\nverb=$1; shift\n" + setup + "echo \"game $verb $* LD_PRELOAD=${LD_PRELOAD-unset} saved=${BL_GPU_SAVED_LD_PRELOAD-unset}\"\n")
|
|
launcher.chmod(0o755)
|
|
base = {key: value for key, value in os.environ.items() if not key.startswith("BL_")}
|
|
base["LD_PRELOAD"] = "overlay.so"
|
|
for extra, args, expected in (
|
|
({"BL_DESKTOP": "1"}, ["waitforexitandrun", "game.exe"],
|
|
["gpu LD_PRELOAD=unset", "game waitforexitandrun game.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({"BL_DESKTOP": "1", "BL_DESKTOP_GPU": "Off"}, ["waitforexitandrun", "game.exe"],
|
|
["game waitforexitandrun game.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({"BL_DESKTOP": "1"}, ["run", "setup.exe"],
|
|
["game run setup.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({"BL_DESKTOP": "1"}, ["waitforexitandrun", "iscriptevaluator.exe"],
|
|
["game waitforexitandrun iscriptevaluator.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
({}, ["waitforexitandrun", "game.exe"],
|
|
["game waitforexitandrun game.exe LD_PRELOAD=overlay.so saved=unset"]),
|
|
):
|
|
with self.subTest(extra=extra, args=args):
|
|
result = subprocess.run([str(launcher)] + args, env=dict(base, **extra),
|
|
text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.splitlines(), expected)
|
|
|
|
def test_the_install_script_evaluator_gets_its_scripts_marked_first(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
script = root / "legacycompat/evaluatorscript_409710.vdf"
|
|
script.parent.mkdir(parents=True)
|
|
script.write_text("{}")
|
|
calls = []
|
|
run = MODULE["mark_install_scripts"].__globals__["subprocess"].run
|
|
MODULE["mark_install_scripts"].__globals__["subprocess"].run = lambda args, **kw: calls.append((args, kw["env"]))
|
|
try:
|
|
env = {"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(root)}
|
|
MODULE["mark_install_scripts"](["/proton", "run", str(root / "legacycompat/iscriptevaluator.exe"), "legacycompat\\evaluatorscript_409710.vdf"], env)
|
|
MODULE["mark_install_scripts"](["/proton", "waitforexitandrun", "/game/Game.exe"], env)
|
|
finally:
|
|
MODULE["mark_install_scripts"].__globals__["subprocess"].run = run
|
|
self.assertEqual(len(calls), 1)
|
|
self.assertEqual(calls[0][0][1], str(root))
|
|
self.assertEqual(calls[0][1]["DROIDDECK_SEED_SCRIPTS"], str(script))
|
|
|
|
SCRIPT = ('"evaluatorscript" { "0" { "appid" "228980" "compat_installscript" { "run process" { '
|
|
'"x64 14.28.29334.0" { "hasrunkey" "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2019" '
|
|
'"process 1" "%INSTALLDIR%\\\\x64.cmd" "requirement_os" { "Is64BitWindows" "1" } } '
|
|
'"Setup" { "process 1" "%INSTALLDIR%\\\\setup.exe" } } } } }')
|
|
REDIST = "[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2019] 1\n\"x64 14.28.29334.0\"=dword:00000001\n"
|
|
SETUP = "[Software\\\\Valve\\\\Steam\\\\Apps\\\\228980] 1\n\"Setup\"=dword:00000001\n"
|
|
|
|
def test_install_script_run_keys_default_to_the_app_key(self):
|
|
keys = MODULE["run_keys"](MODULE["read_vdf"](self.SCRIPT))
|
|
self.assertEqual(keys, {
|
|
("hkey_local_machine\\software\\valve\\steam\\apps\\commonredist\\vcredist\\2019", "x64 14.28.29334.0"),
|
|
("hkey_local_machine\\software\\valve\\steam\\apps\\228980", "setup"),
|
|
})
|
|
with self.assertRaises(ValueError):
|
|
MODULE["read_vdf"]('"a" { "b" }')
|
|
|
|
def test_install_scripts_count_as_done_only_when_recorded_and_still_marked(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
script = Path(tmp) / "evaluatorscript_1.vdf"
|
|
script.write_text(self.SCRIPT)
|
|
prefix = Path(tmp) / "compat/pfx"
|
|
env = {"STEAM_COMPAT_DATA_PATH": str(prefix.parent)}
|
|
done = MODULE["install_scripts_done"]
|
|
self.assertFalse(done([str(script)], env))
|
|
prefix.mkdir(parents=True)
|
|
MODULE["record_install_scripts"](MODULE["scripts_digest"]([str(script)]), env)
|
|
self.assertFalse(done([str(script)], env))
|
|
(prefix / "system.reg").write_text(self.REDIST)
|
|
self.assertFalse(done([str(script)], env))
|
|
(prefix / "system.reg").write_text(self.REDIST + self.SETUP)
|
|
self.assertTrue(done([str(script)], env))
|
|
script.write_text(self.SCRIPT.replace("x64.cmd", "x64-update.cmd"))
|
|
self.assertFalse(done([str(script)], env))
|
|
script.write_text('"InstallScript" { "Registry" { "HKEY_LOCAL_MACHINE\\\\Software\\\\Game" { "string" { "Path" "%INSTALLDIR%" } } } }')
|
|
MODULE["record_install_scripts"](MODULE["scripts_digest"]([str(script)]), env)
|
|
self.assertFalse(done([str(script)], env))
|
|
|
|
def test_the_evaluator_is_skipped_once_it_finished_the_same_scripts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
(root / "legacycompat").mkdir()
|
|
evaluator = root / "legacycompat/iscriptevaluator.exe"
|
|
evaluator.write_bytes(b"evaluator")
|
|
script = root / "legacycompat/evaluatorscript_1.vdf"
|
|
prefix = root / "compatdata/1/pfx"
|
|
prefix.mkdir(parents=True)
|
|
(prefix / "system.reg").write_text(self.REDIST + self.SETUP)
|
|
runs = root / "runs"
|
|
proton = root / "proton"
|
|
proton.write_text("#!/bin/sh\necho run >> %s\nexit ${EXIT_CODE:-0}\n" % shlex.quote(str(runs)))
|
|
proton.chmod(0o755)
|
|
|
|
def evaluate(exit_code="0"):
|
|
script.write_text(self.SCRIPT if exit_code != "changed" else self.SCRIPT.replace("setup.exe", "setup-update.exe"))
|
|
env = dict(os.environ, HOME=tmp, STEAM_COMPAT_CLIENT_INSTALL_PATH=str(root),
|
|
STEAM_COMPAT_DATA_PATH=str(prefix.parent), EXIT_CODE="0" if exit_code == "changed" else exit_code)
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(proton), "run",
|
|
str(evaluator), "legacycompat\\evaluatorscript_1.vdf"],
|
|
env=env, text=True, capture_output=True)
|
|
return result.returncode, len(runs.read_text().splitlines()) if runs.exists() else 0
|
|
|
|
self.assertEqual(evaluate("3"), (3, 1))
|
|
self.assertFalse((prefix / ".droiddeck-install-scripts").exists())
|
|
self.assertEqual(evaluate(), (0, 2))
|
|
self.assertEqual(evaluate(), (0, 2))
|
|
self.assertEqual(evaluate("changed"), (0, 3))
|
|
|
|
def test_the_mali_bridge_caps_the_vkd3d_feature_level(self):
|
|
bridge = {"BL_VKBRIDGE": "1", "VKD3D_FEATURE_LEVEL": "12_2"}
|
|
self.assertIn("capped", MODULE["bridge_feature_level"](bridge))
|
|
self.assertEqual(bridge["VKD3D_FEATURE_LEVEL"], "12_0")
|
|
for env in ({"BL_VKBRIDGE": "1", "VKD3D_FEATURE_LEVEL": "11_1"}, {"VKD3D_FEATURE_LEVEL": "12_2"}, {"BL_VKBRIDGE": "1"}):
|
|
before = dict(env)
|
|
self.assertIsNone(MODULE["bridge_feature_level"](env))
|
|
self.assertEqual(env, before)
|
|
|
|
def test_profile_precedence_and_unset(self):
|
|
env = {"KEEP": "inherited", "REMOVE": "inherited", "CUSTOM": "launch option"}
|
|
config = {"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"REMOVE": None, "CUSTOM": "game", "EMPTY": ""}}}
|
|
self.assertEqual(MODULE["apply_config"](env, config, "42"), {"KEEP": "inherited", "CUSTOM": "game", "EMPTY": ""})
|
|
self.assertEqual(env["REMOVE"], "inherited")
|
|
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
|
|
|
|
def test_games_get_their_own_turnip_debug_and_a_game_profile_still_wins(self):
|
|
gpu = MODULE["game_gpu"]
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "", "KEEP": "1"}), {"KEEP": "1"})
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "sysmem"}), {"TU_DEBUG": "sysmem"})
|
|
self.assertEqual(gpu({"DROIDDECK_GAME_TU_DEBUG": "nolrz"}), {"TU_DEBUG": "nolrz"})
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem"}), {"TU_DEBUG": "sysmem"})
|
|
config = {"version": 1, "games": {"42": {"TU_DEBUG": "sysmem"}}}
|
|
env = gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": ""})
|
|
self.assertEqual(MODULE["apply_config"](env, config, "42")["TU_DEBUG"], "sysmem")
|
|
self.assertNotIn("TU_DEBUG", MODULE["apply_config"](env, config, "43"))
|
|
|
|
def test_a_chromium_app_keeps_the_sessions_turnip_debug(self):
|
|
gpu = MODULE["game_gpu"]
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "", "KEEP": "1"}, True),
|
|
{"TU_DEBUG": "sysmem", "KEEP": "1"})
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem", "DROIDDECK_GAME_TU_DEBUG": "nolrz"}, True), {"TU_DEBUG": "sysmem"})
|
|
self.assertEqual(gpu({"TU_DEBUG": "sysmem"}, True), {"TU_DEBUG": "sysmem"})
|
|
|
|
def test_chromium_files_are_found_four_levels_deep_and_no_further(self):
|
|
chromium = MODULE["chromium_app"]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp) / "app"
|
|
(root / "usr/lib/tool").mkdir(parents=True)
|
|
(root / "usr/lib/tool/libtool.so").write_bytes(b"")
|
|
self.assertFalse(chromium(str(root)))
|
|
for name in ("chrome-sandbox", "chrome_100_percent.pak", "v8_context_snapshot.bin"):
|
|
found = root / "a/b/c" / name
|
|
found.parent.mkdir(parents=True, exist_ok=True)
|
|
found.write_bytes(b"")
|
|
self.assertTrue(chromium(str(root)), name)
|
|
found.unlink()
|
|
deep = root / "a/b/c/d/chrome-sandbox"
|
|
deep.parent.mkdir(parents=True)
|
|
deep.write_bytes(b"")
|
|
self.assertFalse(chromium(str(root)))
|
|
self.assertFalse(chromium(str(Path(tmp) / "missing")))
|
|
|
|
def test_chromium_detection_does_not_follow_links_inside_the_app(self):
|
|
chromium = MODULE["chromium_app"]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
other = Path(tmp) / "other"
|
|
other.mkdir()
|
|
(other / "chrome-sandbox").write_bytes(b"")
|
|
root = Path(tmp) / "app"
|
|
root.mkdir()
|
|
(root / "linked").symlink_to(other, target_is_directory=True)
|
|
self.assertFalse(chromium(str(root)))
|
|
link = Path(tmp) / "app-link"
|
|
link.symlink_to(other, target_is_directory=True)
|
|
self.assertTrue(chromium(str(link)))
|
|
|
|
def test_the_vram_cap_comes_before_the_games_own_dxvk_settings(self):
|
|
cap = MODULE["vram_cap"]
|
|
self.assertEqual(cap({"BL_VRAM_MB": "4096"})["DXVK_CONFIG"], "dxgi.maxDeviceMemory = 4096")
|
|
self.assertEqual(cap({"BL_VRAM_MB": "2048", "DXVK_CONFIG": "dxvk.maxFrameRate = 60;"})["DXVK_CONFIG"],
|
|
"dxgi.maxDeviceMemory = 2048; dxvk.maxFrameRate = 60")
|
|
for off in ({}, {"BL_VRAM_MB": "0"}, {"BL_VRAM_MB": "lots"}, {"BL_VRAM_MB": "²"}, {"BL_VRAM_MB": "-1"}):
|
|
self.assertNotIn("DXVK_CONFIG", cap(off))
|
|
self.assertNotIn("DROIDDECK_VRAM_CAP", cap(off))
|
|
self.assertNotIn("DROIDDECK_VRAM_CAP_MB", cap(off))
|
|
|
|
def test_the_vram_cap_also_turns_on_the_vulkan_layer_that_holds_the_game_to_it(self):
|
|
env = MODULE["vram_cap"]({"BL_VRAM_MB": "3072", "DXVK_CONFIG": "dxgi.maxDeviceMemory = 16384"})
|
|
self.assertEqual((env["DROIDDECK_VRAM_CAP"], env["DROIDDECK_VRAM_CAP_MB"]), ("1", "3072"))
|
|
|
|
def test_a_games_wine_sync_choice_also_reaches_its_setup_runs(self):
|
|
env = {"BL_SYNC": "1", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "0", "OTHER": "kept"}
|
|
config = {"version": 1, "games": {"42": {"BL_SYNC": "0", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "FEX_TSOENABLED": "1"}}}
|
|
chosen = MODULE["apply_config"](env, MODULE["sync_config"](config, "42"), "42")
|
|
self.assertEqual(chosen, {"BL_SYNC": "0", "BL_SYNC_FALLBACK": "1", "BL_FSYNC_FIRST": "1", "PROTON_NO_ESYNC": "1", "OTHER": "kept"})
|
|
|
|
def test_component_profiles_override_general_and_restore_it_for_the_next_game(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
home = root / "home"
|
|
component_dir = home / ".local/share/droiddeck-components"
|
|
base = root / "proton"
|
|
target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll"
|
|
leftover = target.with_name("d3d8.dll")
|
|
component_dir.mkdir(parents=True)
|
|
target.parent.mkdir(parents=True)
|
|
(base / "version").write_text("1 build-1\n")
|
|
general = root / "general.dll"
|
|
game = root / "game.dll"
|
|
general.write_text("general")
|
|
game.write_text("game")
|
|
target.write_text("old")
|
|
leftover.write_text("old")
|
|
digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
|
|
(component_dir / "desired.tsv").write_text(
|
|
"*\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n"
|
|
"42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n"
|
|
"42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d8.dll\t-\t-\n"
|
|
% (digest(general), general, digest(game), game)
|
|
)
|
|
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
|
|
env = dict(os.environ, HOME=str(home), STEAM_COMPAT_DATA_PATH="/steam/compatdata/42")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
self.assertEqual(target.read_text(), "game")
|
|
self.assertFalse(leftover.exists())
|
|
|
|
env["STEAM_COMPAT_DATA_PATH"] = "/steam/compatdata/43"
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
self.assertEqual(target.read_text(), "general")
|
|
|
|
def test_system_powervr_preserves_dxvk_profiles_without_applying_them(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
home = root / "home"
|
|
component_dir = home / ".local/share/droiddeck-components"
|
|
component_dir.mkdir(parents=True)
|
|
base = root / "proton"
|
|
paths = {
|
|
"dxvk": "files/lib/wine/dxvk/aarch64-windows/d3d11.dll",
|
|
"fex": "files/lib/wine/aarch64-windows/libarm64ecfex.dll",
|
|
"vkd3d": "files/lib/wine/vkd3d-proton/aarch64-windows/d3d12.dll",
|
|
}
|
|
rows = []
|
|
for comp, rel in paths.items():
|
|
target = base / rel
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text("bundled " + comp)
|
|
for scope in ("*", "42"):
|
|
source = root / (comp + ("-general" if scope == "*" else "-game"))
|
|
source.write_text(source.name)
|
|
rows.append("%s\tproton\tbuild-1\t%s\t%s\t%s\t%s\n" % (
|
|
scope, comp, rel, hashlib.sha256(source.read_bytes()).hexdigest(), source,
|
|
))
|
|
leftover = (base / paths["dxvk"]).with_name("d3d8.dll")
|
|
leftover.write_text("bundled d3d8")
|
|
rows.append("42\tproton\tbuild-1\tdxvk\t%s\t-\t-\n" % leftover.relative_to(base))
|
|
(base / "version").write_text("1 build-1\n")
|
|
selection = component_dir / "desired.tsv"
|
|
selection.write_text("".join(rows))
|
|
saved = selection.read_bytes()
|
|
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
|
|
env = dict(os.environ, HOME=str(home), STEAM_COMPAT_DATA_PATH="/steam/compatdata/42",
|
|
DROIDDECK_POWERVR_RUNTIME="/system_vulkan/runtime")
|
|
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
|
|
self.assertEqual((base / paths["dxvk"]).read_text(), "bundled dxvk")
|
|
self.assertEqual(leftover.read_text(), "bundled d3d8")
|
|
for comp in ("fex", "vkd3d"):
|
|
self.assertEqual((base / paths[comp]).read_text(), comp + "-game")
|
|
self.assertEqual(selection.read_bytes(), saved)
|
|
|
|
# A subsequent manual-driver session still applies the saved per-game DXVK choice.
|
|
env.pop("DROIDDECK_POWERVR_RUNTIME")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
|
|
self.assertEqual((base / paths["dxvk"]).read_text(), "dxvk-game")
|
|
self.assertFalse(leftover.exists())
|
|
self.assertEqual(selection.read_bytes(), saved)
|
|
|
|
def test_component_scope_matches_environment_scope_for_shortcut_ids(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
component_dir = root / ".local/share/droiddeck-components"
|
|
base = root / "proton"
|
|
target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll"
|
|
component_dir.mkdir(parents=True)
|
|
target.parent.mkdir(parents=True)
|
|
(base / "version").write_text("1 build-1\n")
|
|
rows = []
|
|
for scope in ("*", "42", "4294967295", "2147483648"):
|
|
source = root / ("default" if scope == "*" else scope)
|
|
source.write_text(scope)
|
|
rows.append("%s\tproton\tbuild-1\tdxvk\t%s\t%s\t%s\n" % (
|
|
scope, target.relative_to(base),
|
|
hashlib.sha256(source.read_bytes()).hexdigest(), source,
|
|
))
|
|
(component_dir / "desired.tsv").write_text("".join(rows))
|
|
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
|
|
for value in (
|
|
"42", "00042", "43", "-1", "-0001", "4294967295", "-2147483648",
|
|
"2147483648", "-1///", "0", "-0", "000", "-2147483649", "4294967296",
|
|
"999999999999999999999999999", "not-an-id", "1+1", "",
|
|
):
|
|
with self.subTest(prefix=value):
|
|
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/" + value)
|
|
target.write_text("untouched")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
|
|
scope = MODULE["game_id"](env)
|
|
expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*"
|
|
self.assertEqual(target.read_text(), expected)
|
|
for app, expected in (("42", "42"), ("4294967295", "4294967295"), ("43", "*"), ("", "*"), ("x", "*")):
|
|
with self.subTest(shared=app):
|
|
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/2929927072", SteamAppId=app)
|
|
target.write_text("untouched")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
|
|
self.assertEqual(MODULE["game_id"](env), app if app in {"42", "4294967295", "43"} else "2929927072")
|
|
self.assertEqual(target.read_text(), expected)
|
|
for game, expected in (
|
|
(str(42 << 32 | 0x02000000), "42"), (str(4294967295 << 32 | 0x02000000), "4294967295"),
|
|
(str(42 << 32 | 0x02000001), "*"), (str(42 << 32 | 0x02000000 | 1 << 64), "*"), ("33554432", "*"), ("x", "*"),
|
|
):
|
|
with self.subTest(direct=game):
|
|
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/2929927072", SteamAppId="0", SteamGameId=game)
|
|
target.write_text("untouched")
|
|
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
|
|
self.assertEqual(MODULE["game_id"](env), expected if expected != "*" else "2929927072")
|
|
self.assertEqual(target.read_text(), expected)
|
|
|
|
def test_engine_fixes_follow_the_games_files(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "Godot Game"
|
|
(game / "data_game_windows_x86_64").mkdir(parents=True)
|
|
for name in ("Game.exe", "Game.pck", "libsentry.windows.release.x86_64.dll", "data_game_windows_x86_64/coreclr.dll"):
|
|
(game / name).touch()
|
|
env, extra, notes = MODULE["engine_fixes"](str(game / "Game.exe"), [])
|
|
self.assertEqual(env["FEX_TSOENABLED"], "1")
|
|
self.assertNotIn("FEX_MULTIBLOCK", env)
|
|
self.assertEqual(env["WINEDLLOVERRIDES"], "libsentry.windows.release.x86_64=d")
|
|
self.assertEqual(extra, ["--rendering-driver", "vulkan"])
|
|
self.assertEqual(MODULE["engine_fixes"](str(game / "Game.exe"), ["--rendering-driver", "opengl3"])[1], [])
|
|
script = Path(tmp) / "GDScript Game"
|
|
script.mkdir()
|
|
for name in ("Game.exe", "Game.pck", "libsentry.windows.release.x86_64.dll"):
|
|
(script / name).touch()
|
|
env, extra, notes = MODULE["engine_fixes"](str(script / "Game.exe"), [])
|
|
self.assertNotIn("WINEDLLOVERRIDES", env)
|
|
self.assertEqual(extra, ["--rendering-driver", "vulkan"])
|
|
other = Path(tmp) / "Other"
|
|
other.mkdir()
|
|
(other / "Other.exe").touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
|
|
|
|
def test_easy_anti_cheat_games_start_offline_only_when_opted_in(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "ELDEN RING/Game"
|
|
(game / "EasyAntiCheat").mkdir(parents=True)
|
|
for name in ("start_protected_game.exe", "eldenring.exe"):
|
|
(game / name).touch()
|
|
(game / "EasyAntiCheat/settings.json").write_text('\ufeff{ "executable" : "eldenring.exe" }', encoding="utf-8")
|
|
command = ["proton", "waitforexitandrun", str(game / "start_protected_game.exe"), "-arg"]
|
|
offline = MODULE["offline_without_anticheat"]
|
|
self.assertEqual(offline(command, {}), command)
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "0"}), command)
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}),
|
|
["proton", "waitforexitandrun", str(game / "eldenring.exe"), "-arg"])
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "../../escape.exe" }')
|
|
(Path(tmp) / "escape.exe").touch()
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "missing.exe" }')
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "link/escape.exe" }')
|
|
outside = Path(tmp) / "outside"
|
|
outside.mkdir()
|
|
(outside / "escape.exe").touch()
|
|
(game / "link").symlink_to(outside, target_is_directory=True)
|
|
self.assertEqual(offline(command, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), command)
|
|
(game / "EasyAntiCheat/settings.json").write_text('{ "executable" : "eldenring.exe" }')
|
|
plain = ["proton", "waitforexitandrun", str(game / "eldenring.exe")]
|
|
self.assertEqual(offline(plain, {"DROIDDECK_ANTICHEAT_OFFLINE": "1"}), plain)
|
|
self.assertEqual(offline(["proton", "run", command[2]],
|
|
{"DROIDDECK_ANTICHEAT_OFFLINE": "1"}),
|
|
["proton", "run", command[2]])
|
|
|
|
def test_easy_anti_cheat_forwarding_through_entrypoint(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
game = home / "ELDEN RING/Game"
|
|
(game / "EasyAntiCheat").mkdir(parents=True)
|
|
bootstrapper = game / "start_protected_game.exe"
|
|
target = game / "eldenring.exe"
|
|
bootstrapper.touch()
|
|
target.touch()
|
|
(game / "EasyAntiCheat/settings.json").write_text(json.dumps({"executable": "eldenring.exe"}))
|
|
proton = home / "fake-proton"
|
|
proton.write_text("#!" + sys.executable + "\nimport json, sys\nprint(json.dumps(sys.argv[1:]))\n")
|
|
proton.chmod(0o755)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
env = dict(os.environ, HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/42")
|
|
env.pop("DROIDDECK_ANTICHEAT_OFFLINE", None)
|
|
for setting, expected in (("1", target), ("0", bootstrapper), (None, bootstrapper)):
|
|
with self.subTest(setting=setting):
|
|
values = {} if setting is None else {"DROIDDECK_ANTICHEAT_OFFLINE": setting}
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": values}}))
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"),
|
|
str(proton), "waitforexitandrun", str(bootstrapper), "-arg"],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(json.loads(result.stdout.strip()),
|
|
["waitforexitandrun", str(expected), "-arg"])
|
|
|
|
def test_deep_rock_gdk_avoids_the_raytracing_pipeline_crash(self):
|
|
game = "/game/FSD/Binaries/WinGDK/FSD-WinGDK-Shipping.exe"
|
|
env, extra, notes = MODULE["engine_fixes"](game, [])
|
|
self.assertEqual(extra, ["-noraytracing", "-ini:Engine:[/Script/Engine.RendererSettings]:r.RayTracing=False"])
|
|
self.assertEqual(env, {})
|
|
self.assertTrue(notes)
|
|
for choice in ("-raytracing", "-noraytracing"):
|
|
self.assertEqual(MODULE["engine_fixes"](game, [choice]), ({}, [], []))
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/FSD-Win64-Shipping.exe", []), ({}, [], []))
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/Other-WinGDK-Shipping.exe", []), ({}, [], []))
|
|
|
|
def test_arkham_city_gets_full_x87_precision_over_the_preset(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
city = Path(tmp) / "Batman Arkham City GOTY/Binaries/Win32"
|
|
city.mkdir(parents=True)
|
|
for name in ("BatmanAC.exe", "BmLauncher.exe"):
|
|
(city / name).touch()
|
|
for exe in ("BatmanAC.exe", "BmLauncher.exe"):
|
|
env, extra, notes = MODULE["engine_fixes"](str(city / exe), [])
|
|
self.assertEqual((env, extra), ({"FEX_X87REDUCEDPRECISION": "0"}, []))
|
|
config = {"version": 1, "shared": {"FEX_X87REDUCEDPRECISION": "1"}}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "0")
|
|
config["games"] = {"200260": {"FEX_X87REDUCEDPRECISION": "1"}}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "200260", env)["FEX_X87REDUCEDPRECISION"], "1")
|
|
asylum = Path(tmp) / "Batman Arkham Asylum GOTY/Binaries"
|
|
asylum.mkdir(parents=True)
|
|
for name in ("ShippingPC-BmGame.exe", "BmLauncher.exe"):
|
|
(asylum / name).touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], []))
|
|
|
|
def test_d3dgear_capture_hook_beside_the_game_is_turned_off(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "The Crew 2"
|
|
game.mkdir()
|
|
for name in ("TheCrew2.exe", "TheCrew2_BE.exe", "d3dgear64.dll"):
|
|
(game / name).touch()
|
|
env, extra, notes = MODULE["engine_fixes"](str(game / "TheCrew2.exe"), [])
|
|
self.assertEqual((env, extra), ({"WINEDLLOVERRIDES": "d3dgear64=d"}, []))
|
|
self.assertTrue(notes)
|
|
(game / "d3dgear64.dll").rename(game / "D3DGear64.DLL")
|
|
self.assertEqual(MODULE["engine_fixes"](str(game / "TheCrew2.exe"), [])[0], {"WINEDLLOVERRIDES": "d3dgear64=d"})
|
|
(game / "D3DGear64.DLL").unlink()
|
|
self.assertEqual(MODULE["engine_fixes"](str(game / "TheCrew2.exe"), []), ({}, [], []))
|
|
|
|
def test_doom_2016_keeps_the_swapchain_image_count_it_asks_for(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
game = Path(tmp) / "DOOM (2016)"
|
|
game.mkdir()
|
|
for name in ("DOOMx64.exe", "DOOMx64vk.exe", "Galaxy64.dll"):
|
|
(game / name).touch()
|
|
images = {
|
|
"GAMESCOPE_WSI_MIN_IMAGE_COUNT": None,
|
|
"GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT": None,
|
|
"vk_x11_strict_image_count": "true",
|
|
}
|
|
for exe in ("DOOMx64.exe", "DOOMx64vk.exe"):
|
|
env, extra, notes = MODULE["engine_fixes"](str(game / exe), [])
|
|
self.assertEqual((env, extra), (images, []))
|
|
self.assertTrue(notes)
|
|
launched = {"GAMESCOPE_WSI_MIN_IMAGE_COUNT": "8", "GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT": "1"}
|
|
result = MODULE["apply_config"](launched, {"version": 1}, "3566086475", env)
|
|
self.assertNotIn("GAMESCOPE_WSI_MIN_IMAGE_COUNT", result)
|
|
self.assertNotIn("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT", result)
|
|
self.assertEqual(result["vk_x11_strict_image_count"], "true")
|
|
config = {"version": 1, "games": {"3566086475": {"GAMESCOPE_WSI_MIN_IMAGE_COUNT": "3"}}}
|
|
self.assertEqual(MODULE["apply_config"](launched, config, "3566086475", env)["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "3")
|
|
eternal = Path(tmp) / "DOOMEternal"
|
|
eternal.mkdir()
|
|
(eternal / "DOOMEternalx64vk.exe").touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(eternal / "DOOMEternalx64vk.exe"), []), ({}, [], []))
|
|
other = Path(tmp) / "Other"
|
|
other.mkdir()
|
|
for name in ("Other.exe", "DOOMx64vk.exe"):
|
|
(other / name).touch()
|
|
self.assertEqual(MODULE["engine_fixes"](str(other / "Other.exe"), []), ({}, [], []))
|
|
|
|
def test_dll_overrides_from_several_fixes_are_all_kept(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for name in ("Game.exe", "Game.pck", "GodotSharp.dll", "libsentry.windows.release.x86_64.dll", "d3dgear64.dll"):
|
|
(Path(tmp) / name).touch()
|
|
env = MODULE["engine_fixes"](str(Path(tmp) / "Game.exe"), [])[0]
|
|
self.assertEqual(env["WINEDLLOVERRIDES"], "libsentry.windows.release.x86_64=d;d3dgear64=d")
|
|
|
|
def test_black_ops_iii_and_dark_souls_remastered_skip_protonfixes(self):
|
|
for exe in ("/game/Call of Duty Black Ops III/BlackOps3.exe", "/game/DARK SOULS REMASTERED/DarkSoulsRemastered.exe"):
|
|
env, extra, notes = MODULE["engine_fixes"](exe, [])
|
|
self.assertEqual((env, extra), ({"PROTONFIXES_DISABLE": "1"}, []))
|
|
self.assertTrue(notes)
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/Game/DarkSoulsII.exe", []), ({}, [], []))
|
|
|
|
def test_little_nightmares_keeps_its_render_thread(self):
|
|
env, extra, notes = MODULE["engine_fixes"]("/game/Little Nightmares/Atlas/Binaries/Win64/LittleNightmares.exe", [])
|
|
self.assertEqual((env, extra), ({"PROTONFIXES_DISABLE": "1"}, []))
|
|
self.assertTrue(notes)
|
|
self.assertEqual(MODULE["engine_fixes"]("/game/Little Nightmares II/Moonlight/Binaries/Win64/LittleNightmaresII.exe", []), ({}, [], []))
|
|
|
|
def test_game_cores_are_parsed_from_lists_and_ranges(self):
|
|
parse = MODULE["parse_cores"]
|
|
self.assertEqual(parse("0-3,6"), {0, 1, 2, 3, 6})
|
|
self.assertEqual(parse(" 4, 5 "), {4, 5})
|
|
for bad in ("", "a", "3-1", "1,,2", "-1", "1-"):
|
|
self.assertIsNone(parse(bad), bad)
|
|
|
|
def test_a_profiles_game_cores_pin_the_game_within_the_online_cores(self):
|
|
calls = []
|
|
os_mod = MODULE["os"]
|
|
with mock.patch.dict(MODULE["game_cores"].__globals__, {"online_cores": lambda: {0, 1, 2, 3, 4, 5, 6, 7}}), \
|
|
mock.patch.object(os_mod, "sched_setaffinity", side_effect=lambda pid, cores: calls.append(set(cores)), create=True):
|
|
MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "0-2,9"})
|
|
MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "junk"})
|
|
MODULE["game_cores"]({"DROIDDECK_GAME_CORES": "12-13"})
|
|
MODULE["game_cores"]({})
|
|
self.assertEqual(calls, [{0, 1, 2}])
|
|
self.assertEqual(MODULE["parse_cores"]("0,2-7"), {0, 2, 3, 4, 5, 6, 7})
|
|
|
|
def test_windows_games_see_only_the_cores_they_are_pinned_to(self):
|
|
os_mod = MODULE["os"]
|
|
online = {"online_cores": lambda: {0, 1, 2, 3, 4, 5, 6, 7}}
|
|
cases = [
|
|
({"BL_GAME_CPUS": "6,7"}, {6, 7}, "2:6,7", "2:6,7"),
|
|
({"DROIDDECK_GAME_CORES": "0-2"}, {0, 1, 2}, "3:0,1,2", "3:0,1,2"),
|
|
({"BL_GAME_CPUS": "0-7"}, set(range(8)), None, None),
|
|
({}, {6, 7}, None, None),
|
|
({"BL_GAME_CPUS": "6,7", "WINE_CPU_TOPOLOGY": "4:0,1,2,3"}, {6, 7}, "4:0,1,2,3", "4:0,1,2,3"),
|
|
({"WINE_CPU_TOPOLOGY": "2:0,1"}, set(range(8)), "2:0,1", "2:0,1"),
|
|
({"BL_GAME_CPUS": "6,7", "WINE_CPU_TOPOLOGY": ""}, {6, 7}, "", None),
|
|
({"BL_GAME_CPUS": "6,7", "PROTON_CPU_TOPOLOGY": "1:7"}, {6, 7}, None, "1:7"),
|
|
({"BL_GAME_CPUS": "9"}, {9}, None, None),
|
|
]
|
|
for env, affinity, expected, proton in cases:
|
|
env["DROIDDECK_FASTEST_CORES"] = "0"
|
|
with mock.patch.dict(MODULE["cpu_topology"].__globals__, online), \
|
|
mock.patch.object(os_mod, "sched_getaffinity", return_value=affinity, create=True):
|
|
MODULE["cpu_topology"](env)
|
|
self.assertEqual(env.get("WINE_CPU_TOPOLOGY"), expected, env)
|
|
self.assertEqual(env.get("PROTON_CPU_TOPOLOGY"), proton, env)
|
|
|
|
def test_only_games_get_the_larger_swapchain(self):
|
|
game = {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/945360"}
|
|
self.assertTrue(MODULE["game_images"](game))
|
|
self.assertEqual(game["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "8")
|
|
self.assertEqual(game["GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT"], "1")
|
|
own = {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/945360", "GAMESCOPE_WSI_MIN_IMAGE_COUNT": "3"}
|
|
MODULE["game_images"](own)
|
|
self.assertEqual(own["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "3")
|
|
for env in ({}, {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}):
|
|
self.assertFalse(MODULE["game_images"](env))
|
|
self.assertNotIn("GAMESCOPE_WSI_MIN_IMAGE_COUNT", env)
|
|
|
|
def test_opengl_games_do_not_wait_for_the_screen_refresh(self):
|
|
cases = [
|
|
({}, "0"),
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/-686078602"}, "0"),
|
|
({"vblank_mode": "2"}, "2"),
|
|
({"vblank_mode": ""}, ""),
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}, None),
|
|
({"STEAM_COMPAT_DATA_PATH": ""}, None),
|
|
]
|
|
for env, expected in cases:
|
|
env.setdefault("STEAM_COMPAT_DATA_PATH", "/steam/compatdata/3608888694")
|
|
added = expected == "0" and "vblank_mode" not in env
|
|
self.assertEqual(MODULE["gl_vsync"](env), added, env)
|
|
self.assertEqual(env.get("vblank_mode"), expected, env)
|
|
said = "OpenGL does not wait for the screen refresh"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = Path(tmp) / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('vblank_mode', 'unset'))\n")
|
|
probe.chmod(0o755)
|
|
config = Path(tmp) / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
for inherited, edits, expected in (({}, None, "0"), ({"vblank_mode": "3"}, None, "3"),
|
|
({}, {"vblank_mode": None}, "unset"), ({}, {"vblank_mode": "2"}, "2"),
|
|
({"vblank_mode": "3"}, {"vblank_mode": None}, "unset")):
|
|
with self.subTest(inherited=inherited, edits=edits):
|
|
config.write_text(json.dumps({"version": 1, "games": {"42": edits or {}}}))
|
|
env = {name: value for name, value in os.environ.items() if name != "vblank_mode"}
|
|
env.update(HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/42", **inherited)
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected)
|
|
self.assertEqual(said in result.stderr, expected == "0" and not inherited, result.stderr)
|
|
|
|
def test_teardown_keeps_its_own_vsync_unless_the_player_chose(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
probe = Path(tmp) / "probe"
|
|
probe.write_text("#!" + sys.executable + "\nimport os\nprint(os.environ.get('vblank_mode', 'unset'))\n")
|
|
probe.chmod(0o755)
|
|
game = Path(tmp) / "Teardown/teardown.exe"
|
|
game.parent.mkdir()
|
|
game.write_bytes(b"MZ")
|
|
config = Path(tmp) / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
for inherited, edits, shared, expected in (({}, None, None, "2"), ({"vblank_mode": "0"}, None, None, "0"),
|
|
({}, {"vblank_mode": "1"}, None, "1"),
|
|
({}, None, {"vblank_mode": "1"}, "1")):
|
|
with self.subTest(inherited=inherited, edits=edits, shared=shared):
|
|
config.write_text(json.dumps({"version": 1, "shared": shared or {}, "games": {"1167630": edits or {}}}))
|
|
env = {name: value for name, value in os.environ.items() if name != "vblank_mode"}
|
|
env.update(HOME=tmp, STEAM_COMPAT_DATA_PATH="/compatdata/1167630", **inherited)
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", str(game)],
|
|
env=env, text=True, capture_output=True, check=True)
|
|
self.assertEqual(result.stdout.strip(), expected, result.stderr)
|
|
|
|
def test_windows_cpu_0_is_the_fastest_core(self):
|
|
os_mod = MODULE["os"]
|
|
pocket_fit = {0: 379, 1: 379, 2: 923, 3: 923, 4: 923, 5: 867, 6: 867, 7: 1024}
|
|
every = set(range(8))
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton = Path(tmp) / "proton"
|
|
proton.write_text('default_cpu_limit = {\n "19900" : "16", # Far Cry 2\n'
|
|
' "2229830" : "1", # Command & Conquer\n "11440" : "4", # DiRT\n'
|
|
' "1038250" : "P", # DIRT 5\n "618970" : 4, # Outcast, written as Proton 10 does\n'
|
|
'}\n\nother = {\n "620" : "2",\n}\n')
|
|
command = [str(proton), "waitforexitandrun", "/game/game.exe"]
|
|
cases = [
|
|
({}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"BL_GAME_CPUS": "6,7"}, {6, 7}, pocket_fit, "2:7,6", "2:7,6"),
|
|
({"DROIDDECK_GAME_CORES": "0-2"}, {0, 1, 2}, pocket_fit, "3:2,0,1", "3:2,0,1"),
|
|
({"BL_GAME_CPUS": "0-7"}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"WINE_CPU_TOPOLOGY": "2:0,1"}, every, pocket_fit, "2:0,1", "2:0,1"),
|
|
({"WINE_CPU_TOPOLOGY": ""}, every, pocket_fit, "", None),
|
|
({"PROTON_CPU_TOPOLOGY": "1:0"}, every, pocket_fit, None, "1:0"),
|
|
({"DROIDDECK_FASTEST_CORES": "0"}, every, pocket_fit, None, None),
|
|
({"SteamGameId": "2229830"}, every, pocket_fit, "1:7", "1:7"),
|
|
({"SteamGameId": "11440"}, every, pocket_fit, "4:7,2,3,4", "4:7,2,3,4"),
|
|
({"SteamGameId": "19900"}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"SteamGameId": "1038250"}, every, pocket_fit, None, None),
|
|
({"SteamGameId": "618970"}, every, pocket_fit, "4:7,2,3,4", "4:7,2,3,4"),
|
|
({"SteamGameId": "620"}, every, pocket_fit, "8:7,2,3,4,5,6,0,1", "8:7,2,3,4,5,6,0,1"),
|
|
({"SteamGameId": "2229830", "BL_GAME_CPUS": "0,1"}, {0, 1}, pocket_fit, "2:0,1", "2:0,1"),
|
|
({"SteamGameId": "2229830", "DROIDDECK_GAME_CORES": "12-13"}, every, pocket_fit, "1:7", "1:7"),
|
|
({"SteamGameId": "11440", "BL_GAME_CPUS": "0-7"}, every, pocket_fit, "4:7,2,3,4", "4:7,2,3,4"),
|
|
({}, every, {core: 100 for core in every}, None, None),
|
|
({}, every, None, None, None),
|
|
({"SteamGameId": "11440"}, every, None, "4:0,1,2,3", "4:0,1,2,3"),
|
|
({"BL_GAME_CPUS": "9"}, {9}, pocket_fit, None, None),
|
|
]
|
|
cases += [
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}, every, pocket_fit, None, None),
|
|
({"STEAM_COMPAT_DATA_PATH": ""}, every, pocket_fit, None, None),
|
|
({"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0", "BL_GAME_CPUS": "6,7"}, {6, 7}, pocket_fit, "2:6,7", "2:6,7"),
|
|
]
|
|
for env, affinity, speeds, expected, proton_topology in cases:
|
|
env.setdefault("STEAM_COMPAT_DATA_PATH", "/steam/compatdata/3608888694")
|
|
with mock.patch.dict(MODULE["cpu_topology"].__globals__, {"online_cores": lambda: every}), \
|
|
mock.patch.dict(MODULE["fastest_first"].__globals__, {"core_speeds": lambda cores, speeds=speeds: speeds}), \
|
|
mock.patch.object(os_mod, "sched_getaffinity", return_value=affinity, create=True):
|
|
MODULE["cpu_topology"](env, command)
|
|
self.assertEqual(env.get("WINE_CPU_TOPOLOGY"), expected, env)
|
|
self.assertEqual(env.get("PROTON_CPU_TOPOLOGY"), proton_topology, env)
|
|
self.assertIsNone(MODULE["proton_cpu_limit"](["/missing/proton"], {"SteamGameId": "11440"}))
|
|
self.assertIsNone(MODULE["proton_cpu_limit"](command, {}))
|
|
self.assertEqual(MODULE["proton_cpu_limit"](command, {"SteamGameId": "1038250"}), "P")
|
|
self.assertEqual(MODULE["proton_cpu_limit"](command, {"SteamGameId": "618970"}), 4)
|
|
|
|
def test_core_speeds_read_capacity_then_top_clock(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for core, capacity, clock in ((0, "379", "2265600"), (1, None, "3302400")):
|
|
folder = Path(tmp) / ("cpu%d" % core) / "cpufreq"
|
|
folder.mkdir(parents=True)
|
|
(folder / "cpuinfo_max_freq").write_text(clock + "\n")
|
|
if capacity:
|
|
(folder.parent / "cpu_capacity").write_text(capacity + "\n")
|
|
speeds = MODULE["core_speeds"]
|
|
self.assertEqual(speeds([0], tmp), {0: 379})
|
|
self.assertEqual(speeds([0, 1], tmp), {0: 2265600, 1: 3302400})
|
|
self.assertIsNone(speeds([0, 2], tmp))
|
|
|
|
def test_titles_with_former_feature_level_workarounds_keep_detected_capabilities(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
names = (
|
|
"StellarBlade/SB.exe", "StellarBlade/.droiddeck-launch.bat",
|
|
"StellarBlade/SB/Binaries/Win64/SB-Win64-Shipping.exe",
|
|
"Caravan/CaravanSandWitch.exe", "Caravan/.droiddeck-launch.bat",
|
|
"Caravan/CaravanSandWitch/Binaries/Win64/CaravanSandWitch-Win64-Shipping.exe",
|
|
"RoboCop/RoboCop.exe", "RoboCop/RoboCop/Binaries/Win64/RoboCop-Win64-Shipping.exe",
|
|
"ReSkate/Skate.exe", "ReSkate/ReSkate.dll", "ReSkate/ReSkateLauncher.exe",
|
|
"Cyberpunk/REDprelauncher.exe", "Cyberpunk/bin/x64/Cyberpunk2077.exe",
|
|
)
|
|
for name in names:
|
|
p = root / name
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.touch()
|
|
for name in names:
|
|
if name.endswith(".dll"):
|
|
continue
|
|
env, extra, notes = MODULE["engine_fixes"](str(root / name), [])
|
|
self.assertNotIn("VKD3D_FEATURE_LEVEL", env, name)
|
|
self.assertNotIn("VKD3D_SHADER_MODEL", env, name)
|
|
for values in ({}, {"VKD3D_FEATURE_LEVEL": "12_2", "VKD3D_SHADER_MODEL": "6_9"}):
|
|
config = {"version": 1, "shared": values}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42", env), values, name)
|
|
config = {"version": 1, "shared": {}, "games": {"42": values}}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42", env), values, name)
|
|
|
|
def test_unity_players_keep_tso_over_the_preset(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
def game(*names):
|
|
for name in names:
|
|
(Path(tmp) / name).parent.mkdir(parents=True, exist_ok=True)
|
|
(Path(tmp) / name).touch()
|
|
return str(Path(tmp) / names[0])
|
|
|
|
il2cpp = game("Megabonk/Megabonk.x86_64", "Megabonk/UnityPlayer.so", "Megabonk/GameAssembly.so")
|
|
mono = game("Mono/Game.x86_64", "Mono/UnityPlayer.so")
|
|
windows = game("Crab Game/Crab Game.exe", "Crab Game/UnityPlayer.dll", "Crab Game/GameAssembly.dll")
|
|
windows_mono = game("RoR2/Risk of Rain 2.exe", "RoR2/UnityPlayer.dll", "RoR2/MonoBleedingEdge/mono-2.0-bdwgc.dll")
|
|
for exe in (il2cpp, mono, windows, windows_mono):
|
|
env, extra, notes = MODULE["engine_fixes"](exe, [])
|
|
self.assertEqual((env, extra, notes), ({"FEX_TSOENABLED": "1"}, [], ["Unity: FEX with TSO"]))
|
|
performance = {"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_VECTORTSOENABLED": "0"}}
|
|
result = MODULE["apply_config"]({}, performance, "3405340", env)
|
|
self.assertEqual((result["FEX_TSOENABLED"], result["FEX_VECTORTSOENABLED"]), ("1", "0"))
|
|
performance["games"] = {"3405340": {"FEX_TSOENABLED": "0"}}
|
|
self.assertEqual(MODULE["apply_config"]({}, performance, "3405340", env)["FEX_TSOENABLED"], "0")
|
|
|
|
def test_a_new_fix_only_needs_registering(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
(Path(tmp) / "Game.exe").touch()
|
|
(Path(tmp) / "engine.dll").touch()
|
|
check = lambda game: ({"ENGINE": "1"}, ["--flag"], "engine") if "engine.dll" in game.files else None
|
|
MODULE["FIXES"].append(check)
|
|
try:
|
|
self.assertEqual(MODULE["engine_fixes"](str(Path(tmp) / "Game.exe"), []), ({"ENGINE": "1"}, ["--flag"], ["engine"]))
|
|
finally:
|
|
MODULE["FIXES"].remove(check)
|
|
|
|
def test_engine_fixes_sit_between_shared_and_game_profiles(self):
|
|
env = {"WINEDLLOVERRIDES": "dxgi=n"}
|
|
fixes = {"FEX_MULTIBLOCK": "0", "WINEDLLOVERRIDES": "libsentry=d"}
|
|
config = {"version": 1, "shared": {"FEX_MULTIBLOCK": "1"}, "games": {"42": {"FEX_TSOENABLED": "0"}}}
|
|
result = MODULE["apply_config"](env, config, "42", {**fixes, "FEX_TSOENABLED": "1"})
|
|
self.assertEqual(result["FEX_MULTIBLOCK"], "0")
|
|
self.assertEqual(result["FEX_TSOENABLED"], "0")
|
|
self.assertEqual(result["WINEDLLOVERRIDES"], "dxgi=n;libsentry=d")
|
|
|
|
def test_invalid_configuration_is_atomic(self):
|
|
env = {"ORIGINAL": "unchanged"}
|
|
for entries in ({"A": "ok", "BAD=KEY": "x"}, {"A": "bad\0value"}, {"A": 1}):
|
|
with self.assertRaises(ValueError):
|
|
MODULE["apply_config"](env, {"version": 1, "shared": entries}, "42")
|
|
self.assertEqual(env, {"ORIGINAL": "unchanged"})
|
|
|
|
def test_texture_filtering_is_appended_to_dxvk_config(self):
|
|
options = "d3d9.samplerAnisotropy = 16; d3d11.samplerAnisotropy = 16"
|
|
config = {"version": 1, "shared": {}, "games": {}, "dxvkConfig": options}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42")["DXVK_CONFIG"], options)
|
|
own = {"DXVK_CONFIG": "dxvk.maxFrameRate = 60; "}
|
|
self.assertEqual(MODULE["apply_config"](own, config, "42")["DXVK_CONFIG"], "dxvk.maxFrameRate = 60; " + options)
|
|
shared = {"version": 1, "shared": {"DXVK_CONFIG": "dxvk.tearFree = True"}, "games": {}, "dxvkConfig": options}
|
|
self.assertEqual(MODULE["apply_config"]({}, shared, "42")["DXVK_CONFIG"], "dxvk.tearFree = True; " + options)
|
|
self.assertNotIn("DXVK_CONFIG", MODULE["apply_config"]({}, {**config, "dxvkConfig": ""}, "42"))
|
|
for bad in (1, "a\0b", "x" * 8193):
|
|
with self.assertRaises(ValueError):
|
|
MODULE["apply_config"]({}, {**config, "dxvkConfig": bad}, "42")
|
|
|
|
def test_game_texture_filtering_overrides_default_without_leaking(self):
|
|
default = "d3d9.samplerAnisotropy = 16"
|
|
game = "d3d9.samplerAnisotropy = 4"
|
|
config = {
|
|
"version": 1,
|
|
"shared": {},
|
|
"games": {},
|
|
"dxvkConfig": default,
|
|
"dxvkConfigGames": {"42": game, "43": ""},
|
|
}
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "42")["DXVK_CONFIG"], game)
|
|
self.assertNotIn("DXVK_CONFIG", MODULE["apply_config"]({}, config, "43"))
|
|
self.assertEqual(MODULE["apply_config"]({}, config, "44")["DXVK_CONFIG"], default)
|
|
with self.assertRaises(ValueError):
|
|
MODULE["apply_config"]({}, {**config, "dxvkConfigGames": []}, "42")
|
|
|
|
def test_game_logs_only_when_asked_and_only_for_game_launches(self):
|
|
game_logs = MODULE["game_logs"]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
command = ["/proton", "waitforexitandrun", "/games/Hades/Hades.exe"]
|
|
env = {"BL_DEBUG_DIR": tmp, "VK_DRIVER_FILES": "/icd.json"}
|
|
self.assertEqual(game_logs(command, env, "1145360", now=0), env)
|
|
on = dict(env, BL_GAME_LOGS="1")
|
|
self.assertEqual(game_logs(["/proton", "run", "x.exe"], on, "1145360", now=0), on)
|
|
result = game_logs(command, on, "1145360", now=0)
|
|
folder = Path(result["PROTON_LOG_DIR"])
|
|
self.assertEqual(folder.parent, Path(tmp) / "games")
|
|
self.assertTrue(folder.name.endswith("-1145360"))
|
|
self.assertEqual(result["DXVK_LOG_PATH"], str(folder))
|
|
self.assertEqual(result["PROTON_LOG"], "1")
|
|
launch = (folder / "launch.txt").read_text()
|
|
self.assertIn("VK_DRIVER_FILES=/icd.json", launch)
|
|
self.assertNotIn("PROTON_LOG", env)
|
|
named = game_logs(command, on, None, now=0)
|
|
self.assertTrue(Path(named["PROTON_LOG_DIR"]).name.endswith("-Hades"))
|
|
|
|
def test_rapid_game_launches_keep_separate_diagnostics(self):
|
|
with tempfile.TemporaryDirectory() as tmp, mock.patch("subprocess.Popen"):
|
|
env = {"BL_GAME_LOGS": "1", "BL_DEBUG_DIR": tmp}
|
|
first = MODULE["game_logs"](["/proton", "waitforexitandrun", "first.exe"], env, "42", now=0)
|
|
second = MODULE["game_logs"](["/proton", "waitforexitandrun", "second.exe"], env, "42", now=0)
|
|
self.assertNotEqual(first["PROTON_LOG_DIR"], second["PROTON_LOG_DIR"])
|
|
self.assertIn("exe: first.exe", (Path(first["PROTON_LOG_DIR"]) / "launch.txt").read_text())
|
|
self.assertIn("exe: second.exe", (Path(second["PROTON_LOG_DIR"]) / "launch.txt").read_text())
|
|
|
|
def test_open_driver_pairs_report_the_drivers_own_vkd3d_caps(self):
|
|
apply = MODULE["apply_config"]
|
|
config = {"version": 1, "shared": {"VKD3D_FEATURE_LEVEL": "12_2", "VKD3D_SHADER_MODEL": "6_9"},
|
|
"games": {"42": {"VKD3D_SHADER_MODEL": "6_6"}}}
|
|
adreno = apply({}, config, "1")
|
|
self.assertEqual(adreno["VKD3D_FEATURE_LEVEL"], "12_2")
|
|
pair = apply({"BL_OPEN_DRIVER": "1"}, config, "1")
|
|
self.assertNotIn("VKD3D_FEATURE_LEVEL", pair)
|
|
self.assertNotIn("VKD3D_SHADER_MODEL", pair)
|
|
self.assertEqual(pair["DROIDDECK_VKD3D_CAPS"], "driver")
|
|
own = apply({"BL_OPEN_DRIVER": "1"}, config, "42")
|
|
self.assertEqual(own["VKD3D_SHADER_MODEL"], "6_6")
|
|
self.assertNotIn("VKD3D_FEATURE_LEVEL", own)
|
|
|
|
def test_game_ids_and_probes(self):
|
|
for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"):
|
|
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
|
|
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42")
|
|
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
|
|
shared = {"STEAM_COMPAT_DATA_PATH": "/a/compatdata/2929927072", "SteamAppId": "2424921676"}
|
|
self.assertEqual(MODULE["game_id"](shared), "2424921676")
|
|
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/2929927072"}), "2929927072")
|
|
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42", "SteamAppId": "7"}), "42")
|
|
|
|
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
probe = home / "fake-proton"
|
|
probe.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ.get('CUSTOM'), sys.argv[1:]]))\n")
|
|
probe.chmod(0o755)
|
|
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
|
|
for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"):
|
|
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}}))
|
|
result = subprocess.check_output([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True)
|
|
self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]])
|
|
self.assertFalse((home / "injected").exists())
|
|
for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")):
|
|
output = subprocess.check_output([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True)
|
|
self.assertEqual(json.loads(output)[0], "original")
|
|
config.write_text("{broken")
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True)
|
|
self.assertEqual(json.loads(result.stdout)[0], "original")
|
|
|
|
def test_generated_valve_and_third_party_launchers_apply_configuration(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
steam = home / "Steam"
|
|
tools = steam / "compatibilitytools.d"
|
|
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
|
|
(depot / "files/bin-arm64").mkdir(parents=True)
|
|
extra = tools / "custom-proton"
|
|
extra.mkdir(parents=True)
|
|
for base in (depot, extra):
|
|
proton = base / "proton"
|
|
proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n")
|
|
proton.chmod(0o755)
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(depot))
|
|
COMPAT["adopt_extras"](str(tools))
|
|
config = home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}}))
|
|
wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"])
|
|
for wrapper in wrappers:
|
|
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/droiddeck-game-env", str(BIN / "droiddeck-game-env")))
|
|
result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"],
|
|
env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)},
|
|
text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 7, result.stderr)
|
|
self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]])
|
|
|
|
def test_default_is_compatible_tool_and_labels_mark_it(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
label = home / "label"
|
|
label.write_text("Compatible\n")
|
|
COMPAT["display_name"].__globals__["LABEL_FILE"] = str(label)
|
|
tools = home / "compatibilitytools.d"
|
|
extra = tools / "proton-cachyos-11"
|
|
extra.mkdir(parents=True)
|
|
(extra / "proton").write_text("")
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
|
|
vdf = tools / COMPAT["TOOL"] / "compatibilitytool.vdf"
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None)
|
|
self.assertIn('"display_name" "Proton ARM64 (Compatible)"', vdf.read_text())
|
|
for source in COMPAT["SOURCES"]:
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(home / "steamapps/common" / source))
|
|
self.assertIn('"display_name" "%s ARM64 (Compatible)"' % source.replace(" (ARM64)", ""), vdf.read_text())
|
|
protect = COMPAT["adopt_extras"](str(tools))
|
|
self.assertIn('"display_name" "proton-cachyos-11 (Compatible)"', (extra / "compatibilitytool.vdf").read_text())
|
|
config = home / "config.vdf"
|
|
mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % entry for entry in (
|
|
("0", "GE-Proton10-25", "75"), ("42", "proton_experimental_arm64", "250"), ("43", "proton-cachyos-11", "250")))
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping)
|
|
COMPAT["register_default"](str(config), ["44"], protect=protect)
|
|
tokens = COMPAT["tokenize"](config.read_text())
|
|
names = {tokens[i - 1].strip('"'): tokens[i + 2].strip('"') for i in range(1, len(tokens) - 2) if tokens[i] == "{" and tokens[i + 1] == '"name"'}
|
|
self.assertEqual(names, {"0": COMPAT["TOOL"], "42": COMPAT["TOOL"], "43": "proton-cachyos-11", "44": COMPAT["TOOL"]})
|
|
|
|
def test_existing_install_moves_to_new_tool_name(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
(home / "label").write_text("Compatible\n")
|
|
COMPAT["main"].__globals__["LABEL_FILE"] = str(home / "label")
|
|
steam = home / "Steam"
|
|
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
|
|
(depot / "files/bin-arm64").mkdir(parents=True)
|
|
(steam / "steamapps/appmanifest_42.acf").write_text("")
|
|
legacy = steam / "compatibilitytools.d" / COMPAT["LEGACY_TOOL"]
|
|
legacy.mkdir(parents=True)
|
|
for name in COMPAT["OWN_FILES"]:
|
|
(legacy / name).write_text("old")
|
|
(steam / "config").mkdir()
|
|
config = steam / "config/config.vdf"
|
|
mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, COMPAT["LEGACY_TOOL"], priority)
|
|
for app, priority in (("0", "75"), ("42", "250")))
|
|
config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping)
|
|
argv = sys.argv
|
|
try:
|
|
sys.argv = ["steam-compatibility", str(steam)]
|
|
COMPAT["main"]()
|
|
migrated = config.read_text()
|
|
COMPAT["main"]()
|
|
finally:
|
|
sys.argv = argv
|
|
self.assertFalse(legacy.exists())
|
|
self.assertIn('"display_name" "Proton Experimental ARM64 (Compatible)"',
|
|
(steam / "compatibilitytools.d" / COMPAT["TOOL"] / "compatibilitytool.vdf").read_text())
|
|
self.assertNotIn(COMPAT["LEGACY_TOOL"], migrated)
|
|
self.assertEqual(migrated.count('"%s"' % COMPAT["TOOL"]), 2)
|
|
self.assertEqual(config.read_text(), migrated)
|
|
|
|
def test_launchers_and_adopted_protons_from_before_the_rename_move_over(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
(home / "label").write_text("Compatible\n")
|
|
COMPAT["main"].__globals__["LABEL_FILE"] = str(home / "label")
|
|
tools = home / "compatibilitytools.d"
|
|
own = tools / COMPAT["TOOL"]
|
|
own.mkdir(parents=True)
|
|
for name in ("toolmanifest.vdf", "compatibilitytool.vdf", COMPAT["LEGACY_LAUNCHER"]):
|
|
(own / name).write_text("old")
|
|
spare = tools / COMPAT["TOOL_11"]
|
|
spare.mkdir()
|
|
for name in ("toolmanifest.vdf", "compatibilitytool.vdf", COMPAT["LEGACY_LAUNCHER"]):
|
|
(spare / name).write_text("old")
|
|
extra = tools / "GE-Proton11-7"
|
|
extra.mkdir()
|
|
(extra / "proton").write_text("")
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/%s %%verb%%" }' % COMPAT["LEGACY_EXTRA_WRAPPER"])
|
|
(extra / ("toolmanifest.vdf" + COMPAT["LEGACY_ORIGINAL_SUFFIX"])).write_text('"manifest" { "commandline" "/proton %verb%" }')
|
|
(extra / COMPAT["LEGACY_EXTRA_WRAPPER"]).write_text("old")
|
|
COMPAT["build_tool"](str(own), None)
|
|
COMPAT["remove_tool"](str(spare))
|
|
self.assertEqual(COMPAT["adopt_extras"](str(tools)), {"GE-Proton11-7": "GE-Proton11-7"})
|
|
self.assertEqual(sorted(os.listdir(own)), sorted(COMPAT["OWN_FILES"]))
|
|
self.assertFalse(spare.exists())
|
|
self.assertEqual(sorted(os.listdir(extra)), sorted(["proton", "toolmanifest.vdf", "compatibilitytool.vdf",
|
|
COMPAT["EXTRA_WRAPPER"], "toolmanifest.vdf" + COMPAT["ORIGINAL_SUFFIX"]]))
|
|
self.assertIn("/%s %%verb%%" % COMPAT["EXTRA_WRAPPER"], (extra / "toolmanifest.vdf").read_text())
|
|
self.assertIn('"/proton %verb%"', (extra / ("toolmanifest.vdf" + COMPAT["ORIGINAL_SUFFIX"])).read_text())
|
|
self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text())
|
|
self.assertIn("GE-Proton11-7", [entry["name"] for entry in COMPAT["tool_catalog"](str(home), None)])
|
|
|
|
def test_droiddeck_proton_is_adopted_and_labelled_like_any_other_extra(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
home = Path(tmp)
|
|
(home / "label").write_text("Compatible\n")
|
|
COMPAT["display_name"].__globals__["LABEL_FILE"] = str(home / "label")
|
|
tools = home / "compatibilitytools.d"
|
|
extra = tools / "DroidDeck-Proton 11-7.5"
|
|
extra.mkdir(parents=True)
|
|
(extra / "proton").write_text("")
|
|
(extra / "toolmanifest.vdf").write_text('"manifest" { "version" "2" "commandline" "/proton %verb%" "require_tool_appid" "4185400" }')
|
|
(extra / "compatibilitytool.vdf").write_text(
|
|
'"compatibilitytools" { "compat_tools" { "DroidDeck-Proton-11-7.5" { "install_path" "." '
|
|
'"display_name" "DroidDeck-Proton 11-7.5" "from_oslist" "windows" "to_oslist" "linux" } } }')
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None)
|
|
protect = COMPAT["adopt_extras"](str(tools))
|
|
self.assertEqual(protect, {"DroidDeck-Proton-11-7.5": "DroidDeck-Proton 11-7.5"})
|
|
compat = (extra / "compatibilitytool.vdf").read_text()
|
|
self.assertIn('"DroidDeck-Proton-11-7.5"', compat)
|
|
self.assertIn('"display_name" "DroidDeck-Proton 11-7.5 (Compatible)"', compat)
|
|
self.assertNotIn("require_tool_appid", (extra / "toolmanifest.vdf").read_text())
|
|
self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text())
|
|
catalog = COMPAT["tool_catalog"](str(home), None)
|
|
self.assertIn({"name": "DroidDeck-Proton-11-7.5", "display": "DroidDeck-Proton 11-7.5 (Compatible)",
|
|
"dir": "DroidDeck-Proton 11-7.5", "valve": False}, catalog)
|
|
config = home / "config.vdf"
|
|
config.write_text(config_text([("0", COMPAT["TOOL"]), ("42", "DroidDeck-Proton-11-7.5")]))
|
|
COMPAT["register_default"](str(config), ["42", "43"], protect=protect)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5", "43": COMPAT["TOOL"]})
|
|
changes, _ = COMPAT["plan_mapping"]({"0": COMPAT["TOOL"], "42": "DroidDeck-Proton-11-7.5"}, ["42"], [COMPAT["TOOL"]], COMPAT["TOOL"], {})
|
|
self.assertEqual(changes, {"42": COMPAT["TOOL"]})
|
|
|
|
def test_both_proton_wrappers_call_environment_launcher(self):
|
|
self.assertIn('bl_directaudio "$depot" "$@"', COMPAT["LAUNCHER_SH"])
|
|
self.assertIn('bl_directaudio "$here" "$@"', COMPAT["EXTRA_WRAPPER_SH"] % "proton")
|
|
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
|
|
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
|
|
self.assertIn('bl_run ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', script)
|
|
|
|
def test_both_proton_wrappers_answer_steams_driver_query_without_wine(self):
|
|
scripts = (COMPAT["launcher_sh"](), COMPAT["extra_wrapper_sh"]("proton"))
|
|
query = "/root/.local/share/Steam/steamrtarm64/../bin/d3ddriverquery64.exe"
|
|
probe = "/root/.local/share/Steam/steamapps/compatdata/0"
|
|
for script in scripts:
|
|
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
|
|
self.assertLess(script.index('case "$verb:${1##*/}:'), script.index("bl_components "))
|
|
for args, prefix in (([query, "-d3d12"], probe), ([query], probe + "/"),
|
|
([query.replace("64.exe", ".exe")], probe)):
|
|
result = subprocess.run(["bash", "-s", "run"] + args, input=script, text=True, capture_output=True,
|
|
env={"PATH": os.defpath, "HOME": "/nonexistent", "STEAM_COMPAT_DATA_PATH": prefix},
|
|
timeout=10)
|
|
self.assertEqual(result.returncode, 1, result.stderr)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("D3D driver query is not run", result.stderr)
|
|
skip = COMPAT["BL_DRIVER_QUERY_SKIP"]
|
|
for verb, args, prefix in (
|
|
("getcompatpath", [query], probe),
|
|
("run", [query], "/root/.local/share/Steam/steamapps/compatdata/2276548447"),
|
|
("run", [query], None),
|
|
("waitforexitandrun", ["/games/AIO/AIO-Graphics-Test-64bit.exe", "--cube", "dx11"], probe),
|
|
("run", ["/games/Tool/tool.exe", query], probe),
|
|
("run", ["/games/d3ddriverquery64.exe.bak"], probe)):
|
|
env = {"PATH": os.defpath}
|
|
if prefix is not None:
|
|
env["STEAM_COMPAT_DATA_PATH"] = prefix
|
|
out = subprocess.run(["bash", "-c", "verb=" + verb + "\n" + skip + "echo continued", "_"] + args,
|
|
env=env, capture_output=True, text=True, check=True).stdout
|
|
self.assertEqual(out, "continued\n", (verb, args, prefix))
|
|
|
|
def test_wrappers_preload_the_session_library_before_the_input_shim(self):
|
|
overlay = "/root/.local/share/Steam/ubuntu12_64/gameoverlayrenderer.so"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
for name in ("libblsession.so", "libfakeinput.so"):
|
|
(Path(tmp) / name).write_bytes(b"")
|
|
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
|
|
start = script.index("for fake in ")
|
|
block = script[start:script.index("\ndone\n", start) + 6].replace("/usr/local/lib/", tmp + "/")
|
|
for inherited, expected in (
|
|
(None, ["libblsession.so", "libfakeinput.so"]),
|
|
(overlay, ["libblsession.so", "libfakeinput.so", overlay]),
|
|
(tmp + "/libfakeinput.so:" + overlay, ["libblsession.so", "libfakeinput.so", overlay]),
|
|
(tmp + "/libblsession.so:" + tmp + "/libfakeinput.so", ["libblsession.so", "libfakeinput.so"])):
|
|
env = {"PATH": os.defpath}
|
|
if inherited is not None:
|
|
env["LD_PRELOAD"] = inherited
|
|
out = subprocess.run(["bash", "-c", block + 'printf %s "$LD_PRELOAD"'], env=env,
|
|
capture_output=True, text=True, check=True).stdout
|
|
self.assertEqual([entry.replace(tmp + "/", "") for entry in out.split(":")], expected, inherited)
|
|
|
|
|
|
class ProtonDefaultTest(unittest.TestCase):
|
|
TOOL = COMPAT["TOOL"]
|
|
CATALOG = [
|
|
{"name": COMPAT["TOOL"], "display": "Proton Experimental ARM64", "dir": "Proton Experimental (ARM64)", "valve": True},
|
|
{"name": COMPAT["TOOL_11"], "display": "Proton 11.0 ARM64", "dir": "Proton 11.0 (ARM64)", "valve": True},
|
|
{"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": False},
|
|
]
|
|
NAMES = [t["name"] for t in CATALOG]
|
|
|
|
def plan(self, current, installed, default, auto):
|
|
return COMPAT["plan_mapping"](current, installed, self.NAMES, default, auto)
|
|
|
|
def test_new_installs_and_valve_picks_follow_the_default(self):
|
|
changes, auto = self.plan({"0": self.TOOL, "10": "proton_experimental", "11": "proton_11_arm64", "12": "GE-Proton11-7", "13": "steamlinuxruntime"},
|
|
["10", "11", "12", "13", "14"], "GE-Proton11-7", {})
|
|
self.assertEqual(changes, {"0": "GE-Proton11-7", "10": "GE-Proton11-7", "11": "GE-Proton11-7", "14": "GE-Proton11-7"})
|
|
self.assertEqual(auto, {"10": "GE-Proton11-7", "11": "GE-Proton11-7", "14": "GE-Proton11-7"})
|
|
|
|
def test_windows_only_titles_still_follow_the_default(self):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL}, ["42"], self.NAMES, self.TOOL, {})
|
|
self.assertEqual(changes, {"42": self.TOOL})
|
|
self.assertEqual(auto, {"42": self.TOOL})
|
|
|
|
def test_native_arm64_default_clears_only_our_auto_mapping(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text(config_text([("0", self.TOOL), ("42", self.TOOL)]))
|
|
(Path(tmp) / "state.json").write_text(json.dumps({
|
|
"default": self.TOOL,
|
|
"auto": {"42": self.TOOL},
|
|
}))
|
|
globals_ = COMPAT["register_default"].__globals__
|
|
original_native_targets = globals_["native_targets"]
|
|
globals_["native_targets"] = lambda _root, _apps: {"42": None}
|
|
try:
|
|
COMPAT["register_default"](
|
|
str(config), ["42"], catalog=self.CATALOG, directory=tmp)
|
|
first = config.read_bytes()
|
|
self.assertEqual(mapping_of(first.decode()), {"0": self.TOOL})
|
|
self.assertEqual(COMPAT["tokenize"](first.decode()),
|
|
COMPAT["tokenize"](config_text([("0", self.TOOL)])))
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["42"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(config.read_bytes(), first)
|
|
finally:
|
|
globals_["native_targets"] = original_native_targets
|
|
|
|
def test_saved_native_choice_survives_valve_pick_without_appinfo(self):
|
|
app = "452280"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
steam = root / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
(steam / "steamapps").mkdir()
|
|
(steam / "steamapps" / ("appmanifest_%s.acf" % app)).write_text("")
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
(root / "state.json").write_text(json.dumps({
|
|
"default": self.TOOL,
|
|
"auto": {},
|
|
"native_choices": [app],
|
|
}))
|
|
|
|
globals_ = COMPAT["register_default"].__globals__
|
|
original_native_targets = globals_["native_targets"]
|
|
globals_["native_targets"] = lambda _root, _apps: {}
|
|
try:
|
|
COMPAT["register_default"](
|
|
str(config), [], catalog=self.CATALOG, directory=tmp, picks=[app])
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL})
|
|
state = json.loads((root / "state.json").read_text())
|
|
self.assertEqual(state["auto"], {})
|
|
self.assertEqual(state["native_choices"], [app])
|
|
finally:
|
|
globals_["native_targets"] = original_native_targets
|
|
|
|
def test_shortcuts_scanner_keeps_native_choice_for_unrelated_app_list(self):
|
|
app, added_shortcut = "452280", "3537741571"
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
steam = root / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
(steam / "steamapps").mkdir()
|
|
(steam / "steamapps" / ("appmanifest_%s.acf" % app)).write_text("")
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
(root / "state.json").write_text(json.dumps({
|
|
"default": self.TOOL,
|
|
"auto": {},
|
|
"native_choices": [app],
|
|
}))
|
|
|
|
globals_ = COMPAT["register_default"].__globals__
|
|
original_native_targets = globals_["native_targets"]
|
|
globals_["native_targets"] = lambda _root, wanted: {
|
|
app: COMPAT["NATIVE_CHOICE"]
|
|
} if app in wanted else {}
|
|
try:
|
|
COMPAT["register_default"](
|
|
str(config), [app], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(json.loads((root / "state.json").read_text())["native_choices"], [app])
|
|
|
|
COMPAT["register_default"](
|
|
str(config), [added_shortcut], catalog=self.CATALOG, directory=tmp)
|
|
state = json.loads((root / "state.json").read_text())
|
|
self.assertEqual(state["native_choices"], [app])
|
|
self.assertEqual(state["auto"], {added_shortcut: self.TOOL})
|
|
self.assertEqual(mapping_of(config.read_text()), {
|
|
"0": self.TOOL,
|
|
added_shortcut: self.TOOL,
|
|
})
|
|
finally:
|
|
globals_["native_targets"] = original_native_targets
|
|
|
|
def test_a_listed_title_with_a_bundled_arm64_linux_build_defaults_to_fex(self):
|
|
globals_ = COMPAT["native_targets"].__globals__
|
|
original_platforms = globals_["appinfo_platforms"]
|
|
globals_["appinfo_platforms"] = lambda _path, wanted: {
|
|
app: ({"linux", "windows"}, "64", "game") for app in ("4385780", "42") if app in wanted
|
|
}
|
|
try:
|
|
native = COMPAT["native_targets"]("/nonexistent", ["4385780", "42"])
|
|
finally:
|
|
globals_["appinfo_platforms"] = original_platforms
|
|
self.assertEqual(native["4385780"], COMPAT["FEX_TOOL"])
|
|
self.assertIs(native["42"], COMPAT["NATIVE_CHOICE"])
|
|
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL}, ["4385780"], self.NAMES, self.TOOL, {}, native)
|
|
self.assertEqual(changes, {"4385780": COMPAT["FEX_TOOL"]})
|
|
self.assertEqual(auto, {"4385780": COMPAT["FEX_TOOL"]})
|
|
|
|
for choice in (self.TOOL, "GE-Proton11-7"):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "4385780": choice}, ["4385780"], self.NAMES, self.TOOL, {}, native)
|
|
self.assertEqual(changes, {})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_a_cleared_fex_default_for_a_listed_title_is_kept_as_a_native_choice(self):
|
|
app = "4385780"
|
|
fex = COMPAT["FEX_TOOL"]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
(Path(tmp) / "state.json").write_text(json.dumps({"default": self.TOOL, "auto": {}}))
|
|
|
|
globals_ = COMPAT["native_targets"].__globals__
|
|
original_platforms = globals_["appinfo_platforms"]
|
|
globals_["appinfo_platforms"] = lambda _path, wanted: {
|
|
app: ({"linux", "windows"}, "64", "game")
|
|
} if app in wanted else {}
|
|
try:
|
|
def run():
|
|
COMPAT["register_default"](str(config), [app], catalog=self.CATALOG, directory=tmp)
|
|
return json.loads((Path(tmp) / "state.json").read_text())
|
|
|
|
state = run()
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, app: fex})
|
|
self.assertEqual(state["auto"], {app: fex})
|
|
|
|
config.write_text(config_text([("0", self.TOOL), (app, "")]))
|
|
state = run()
|
|
cleared = config.read_bytes()
|
|
self.assertEqual(mapping_of(cleared.decode()), {"0": self.TOOL, app: ""})
|
|
self.assertEqual(state["auto"], {})
|
|
self.assertEqual(state["native_choices"], [app])
|
|
run()
|
|
self.assertEqual(config.read_bytes(), cleared)
|
|
|
|
config.write_text(config_text([("0", self.TOOL), (app, fex)]))
|
|
state = run()
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, app: fex})
|
|
self.assertEqual(state["native_choices"], [])
|
|
finally:
|
|
globals_["appinfo_platforms"] = original_platforms
|
|
|
|
def test_factorio_preserves_an_observed_native_choice_despite_ambiguous_metadata(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
config = steam / "config/config.vdf"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
state = {"default": self.TOOL, "auto": {}}
|
|
(Path(tmp) / "state.json").write_text(json.dumps(state))
|
|
|
|
globals_ = COMPAT["native_targets"].__globals__
|
|
original_platforms = globals_["appinfo_platforms"]
|
|
globals_["appinfo_platforms"] = lambda _path, wanted: {
|
|
"427520": ({"linux", "macos", "windows"}, "", "game")
|
|
} if "427520" in wanted else {}
|
|
try:
|
|
native = COMPAT["native_targets"](str(steam), ["427520"])
|
|
self.assertIs(native["427520"], COMPAT["NATIVE_CHOICE"])
|
|
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL}, ["427520"], self.NAMES, self.TOOL, {}, native)
|
|
self.assertEqual(changes, {"427520": self.TOOL})
|
|
self.assertEqual(auto, {"427520": self.TOOL})
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": self.TOOL})
|
|
|
|
state["auto"] = {"427520": self.TOOL}
|
|
(Path(tmp) / "state.json").write_text(json.dumps(state))
|
|
config.write_text(config_text([("0", self.TOOL)]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
first = config.read_bytes()
|
|
self.assertEqual(mapping_of(first.decode()), {"0": self.TOOL})
|
|
self.assertEqual(COMPAT["tokenize"](first.decode()),
|
|
COMPAT["tokenize"](config_text([("0", self.TOOL)])))
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], ["427520"])
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(config.read_bytes(), first)
|
|
|
|
config.write_text(config_text([("0", self.TOOL), ("427520", "")]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
native_choice = config.read_bytes()
|
|
self.assertEqual(mapping_of(native_choice.decode()), {"0": self.TOOL, "427520": ""})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["auto"], {})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], ["427520"])
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(config.read_bytes(), native_choice)
|
|
|
|
for choice in (COMPAT["FEX_TOOL"], COMPAT["TOOL_11"], "GE-Proton11-7"):
|
|
config.write_text(config_text([("0", self.TOOL), ("427520", choice)]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": choice})
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["native_choices"], [])
|
|
|
|
state = json.loads((Path(tmp) / "state.json").read_text())
|
|
state["native_choices"] = ["427520"]
|
|
state["auto"] = {}
|
|
(Path(tmp) / "state.json").write_text(json.dumps(state))
|
|
config.write_text(config_text([("0", self.TOOL), ("427520", "proton_experimental")]))
|
|
COMPAT["register_default"](
|
|
str(config), ["427520"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": self.TOOL, "427520": self.TOOL})
|
|
saved = json.loads((Path(tmp) / "state.json").read_text())
|
|
self.assertEqual(saved["native_choices"], [])
|
|
self.assertEqual(saved["auto"], {})
|
|
finally:
|
|
globals_["appinfo_platforms"] = original_platforms
|
|
|
|
def test_native_arm64_preserves_a_proton_mapping_without_managed_state(self):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "42": self.TOOL}, ["42"], self.NAMES, self.TOOL,
|
|
{"42": self.TOOL}, {"42": None}, managed=())
|
|
self.assertEqual(changes, {})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_native_arm64_keeps_a_chosen_valve_proton_through_its_wrapper(self):
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "42": "proton_experimental"}, ["42"], self.NAMES,
|
|
self.TOOL, {}, {"42": None})
|
|
self.assertEqual(changes, {"42": self.TOOL})
|
|
self.assertEqual(auto, {})
|
|
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
{"0": self.TOOL, "42": self.TOOL}, ["42"], self.NAMES,
|
|
self.TOOL, {}, {"42": None})
|
|
self.assertEqual(changes, {})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_native_arm64_keeps_explicit_runtime_fex_and_proton_profiles(self):
|
|
fex = COMPAT["FEX_TOOL"]
|
|
current = {
|
|
"0": self.TOOL,
|
|
"42": COMPAT["TOOL_11"],
|
|
"43": "GE-Proton11-7",
|
|
"44": "steamlinuxruntime_arm64",
|
|
"45": fex,
|
|
"46": "",
|
|
}
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
current, ["42", "43", "44", "45", "46"], self.NAMES, self.TOOL, {},
|
|
{app: None for app in ("42", "43", "44", "45", "46")},
|
|
overrides={"46": "GE-Proton11-7"},
|
|
)
|
|
self.assertEqual(changes, {"46": "GE-Proton11-7"})
|
|
self.assertEqual(auto, {})
|
|
|
|
def test_titles_following_the_default_move_with_it_and_picks_stay(self):
|
|
current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL}
|
|
auto = {"10": self.TOOL, "11": self.TOOL, "12": self.TOOL}
|
|
changes, auto = self.plan(current, ["10", "11", "12"], COMPAT["TOOL_11"], auto)
|
|
self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
self.assertEqual(auto, {"10": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
|
|
def test_a_removed_tool_falls_back_to_the_default(self):
|
|
changes, auto = self.plan({"0": self.TOOL, "10": "GE-Proton10-1", "11": "droiddeck-proton-12-arm64"}, ["10", "11"], self.TOOL, {"10": "GE-Proton10-1"})
|
|
self.assertEqual(changes, {"10": self.TOOL, "11": self.TOOL})
|
|
self.assertEqual(auto, {"10": self.TOOL, "11": self.TOOL})
|
|
|
|
def test_uninstalled_titles_are_forgotten(self):
|
|
changes, auto = self.plan({"0": self.TOOL}, [], self.TOOL, {"10": self.TOOL})
|
|
self.assertEqual((changes, auto), ({}, {}))
|
|
|
|
def test_per_game_proton_override_and_inherit_follow_steam_mappings(self):
|
|
current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL}
|
|
overrides = {"10": "GE-Proton11-7", "11": None}
|
|
changes, auto = COMPAT["plan_mapping"](
|
|
current, ["10", "11", "12"], self.NAMES, COMPAT["TOOL_11"],
|
|
{"10": self.TOOL, "12": self.TOOL}, overrides=overrides)
|
|
self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": "GE-Proton11-7", "11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
self.assertEqual(auto, {"11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
|
|
|
|
def test_per_game_requests_resolve_identity_and_defer_a_new_live_tool(self):
|
|
requests = {
|
|
"10": {"valve": False, "dir": "GE-Proton11-7"},
|
|
"11": None,
|
|
"12": {"valve": True, "dir": "Proton 11.0 (ARM64)"},
|
|
"0": None,
|
|
}
|
|
self.assertEqual(
|
|
COMPAT["requested_games"](requests, self.CATALOG),
|
|
{"10": "GE-Proton11-7", "11": None, "12": COMPAT["TOOL_11"]},
|
|
)
|
|
self.assertEqual(
|
|
COMPAT["requested_games"](requests, self.CATALOG, [self.TOOL, COMPAT["TOOL_11"]]),
|
|
{"10": COMPAT["DEFERRED_GAME_TOOL"], "11": None, "12": COMPAT["TOOL_11"]},
|
|
)
|
|
|
|
def test_missing_per_game_proton_is_preserved_during_default_registration(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text(config_text([
|
|
("0", self.TOOL),
|
|
("10", "GE-Proton9-1"),
|
|
("11", self.TOOL),
|
|
]))
|
|
(Path(tmp) / "games.json").write_text(json.dumps({
|
|
"version": 1,
|
|
"games": {
|
|
"10": {"valve": False, "dir": "GE-Proton9-1"},
|
|
},
|
|
}))
|
|
|
|
COMPAT["register_default"](
|
|
str(config), ["10", "11"], default=COMPAT["TOOL_11"],
|
|
catalog=self.CATALOG, directory=tmp,
|
|
)
|
|
|
|
self.assertEqual(mapping_of(config.read_text()), {
|
|
"0": COMPAT["TOOL_11"],
|
|
"10": "GE-Proton9-1",
|
|
"11": COMPAT["TOOL_11"],
|
|
})
|
|
|
|
def test_the_last_side_to_change_wins(self):
|
|
state = {"default": self.TOOL, "applied": 5}
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 5, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=100)
|
|
self.assertEqual((default, state["source"], state["seq"]), ("GE-Proton11-7", "steam", 100))
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 6, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=200)
|
|
self.assertEqual((default, state["source"], state["seq"], state["applied"]), (COMPAT["TOOL_11"], "app", 200, 6))
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 6}, self.CATALOG, adopt_steam=False, now=300)
|
|
self.assertEqual((default, state["seq"]), (COMPAT["TOOL_11"], 200))
|
|
default, state = COMPAT["choose_default"]("proton_experimental", state, {"seq": 6}, self.CATALOG, now=300)
|
|
self.assertEqual(default, COMPAT["TOOL_11"])
|
|
|
|
def test_a_request_for_a_missing_tool_is_dropped(self):
|
|
default, state = COMPAT["choose_default"](self.TOOL, {"default": self.TOOL}, {"seq": 9, "dir": "GE-Proton9-1", "valve": False}, self.CATALOG, now=1)
|
|
self.assertEqual((default, state["applied"]), (self.TOOL, 9))
|
|
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 9, "dir": "GE-Proton9-1", "valve": False}, self.CATALOG, now=2)
|
|
self.assertEqual(default, "GE-Proton11-7")
|
|
|
|
def test_settle_before_the_client_starts(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
config = Path(tmp) / "config.vdf"
|
|
config.write_text(config_text([("0", self.TOOL), ("10", "proton_experimental"), ("11", "GE-Proton11-7")]))
|
|
(Path(tmp) / "request.json").write_text(json.dumps({"seq": 3, "dir": "Proton 11.0 (ARM64)", "valve": True}))
|
|
default = COMPAT["register_default"](str(config), ["10", "11", "12"], catalog=self.CATALOG, directory=tmp, settle=True)
|
|
self.assertEqual(default, COMPAT["TOOL_11"])
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "11": "GE-Proton11-7", "12": COMPAT["TOOL_11"]})
|
|
state = json.loads((Path(tmp) / "state.json").read_text())
|
|
self.assertEqual((state["applied"], state["source"], state["tools"]), (3, "app", self.CATALOG))
|
|
config.write_text(config_text([("0", "GE-Proton11-7"), ("10", COMPAT["TOOL_11"]), ("11", "GE-Proton11-7"), ("12", COMPAT["TOOL_11"])]))
|
|
default = COMPAT["register_default"](str(config), ["10", "11", "12"], catalog=self.CATALOG, directory=tmp, settle=True)
|
|
self.assertEqual(default, "GE-Proton11-7")
|
|
self.assertEqual(mapping_of(config.read_text())["10"], "GE-Proton11-7")
|
|
self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["source"], "steam")
|
|
config.write_text(config_text([("0", "proton_experimental")]))
|
|
default = COMPAT["register_default"](str(config), ["10"], catalog=self.CATALOG, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": "GE-Proton11-7", "10": "GE-Proton11-7"})
|
|
|
|
def test_the_shortcut_writer_keeps_the_chosen_default(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
for name in COMPAT["SOURCES"]:
|
|
(steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True)
|
|
(steam / "config").mkdir()
|
|
config = steam / "config/config.vdf"
|
|
config.write_text(config_text([("0", COMPAT["TOOL_11"]), ("10", COMPAT["TOOL_11"]), ("3044416433", COMPAT["TOOL_11"])]))
|
|
(Path(tmp) / "state.json").write_text(json.dumps({"default": COMPAT["TOOL_11"], "auto": {"10": COMPAT["TOOL_11"], "3044416433": COMPAT["TOOL_11"]}}))
|
|
COMPAT["register_default"](str(config), apps=["3044416433", "3212965118"], protect={}, directory=tmp)
|
|
self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "3044416433": COMPAT["TOOL_11"], "3212965118": COMPAT["TOOL_11"]})
|
|
|
|
def test_each_valve_depot_gets_its_own_tool(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
steam = Path(tmp) / "Steam"
|
|
for name in COMPAT["SOURCES"]:
|
|
(steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True)
|
|
self.assertEqual(COMPAT["find_source"](str(steam), COMPAT["OWN_TOOLS"][1][1]), str(steam / "steamapps/common/Proton 11.0 (ARM64)"))
|
|
self.assertEqual(COMPAT["find_source"](str(steam)), str(steam / "steamapps/common/Proton Experimental (ARM64)"))
|
|
self.assertEqual([(t["name"], t["dir"]) for t in COMPAT["tool_catalog"](str(steam), {})],
|
|
[(COMPAT["TOOL"], "Proton Experimental (ARM64)"), (COMPAT["TOOL_11"], "Proton 11.0 (ARM64)")])
|
|
for name, sources in COMPAT["OWN_TOOLS"]:
|
|
COMPAT["build_tool"](str(steam / "compatibilitytools.d" / name), COMPAT["find_source"](str(steam), sources), sources, name)
|
|
self.assertEqual(COMPAT["adopt_extras"](str(steam / "compatibilitytools.d")), {})
|
|
self.assertEqual(sorted(os.listdir(steam / "compatibilitytools.d" / COMPAT["TOOL_11"])), sorted(COMPAT["OWN_FILES"]))
|
|
launcher = COMPAT["launcher_sh"](COMPAT["OWN_TOOLS"][1][1])
|
|
self.assertIn('for name in "Proton 11.0 (ARM64)"; do', launcher)
|
|
self.assertIn("${major%%.*}", launcher)
|
|
|
|
|
|
|
|
class DirectAudioPrefixTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
base = Path(self.tmp.name)
|
|
self.tool = base / "tool"
|
|
(self.tool / "files/bin-arm64").mkdir(parents=True)
|
|
wine = self.tool / "files/bin-arm64/wine"
|
|
wine.write_text("#!/bin/sh\necho wine-11.0-4c8f2e1 '(Staging)'\n")
|
|
wine.chmod(0o755)
|
|
(self.tool / "files/lib/wine/aarch64-unix").mkdir(parents=True)
|
|
self.winepulse = self.tool / "files/lib/wine/aarch64-unix/winepulse.so"
|
|
self.winepulse.write_bytes(b"\x7fELF classic wine 11 pulse driver")
|
|
self.sets = base / "directaudio"
|
|
for set_name in ("linux-wine11", "linux-wine11-systhread"):
|
|
for arch in ("aarch64-windows", "i386-windows", "aarch64-unix"):
|
|
(self.sets / set_name / "lib/wine" / arch).mkdir(parents=True)
|
|
(self.sets / set_name / "lib/wine/aarch64-windows/winedirectaudio.drv").write_bytes(b"arm64x-" + set_name.encode())
|
|
(self.sets / set_name / "lib/wine/i386-windows/winedirectaudio.drv").write_bytes(b"i386")
|
|
self.audio = self.sets / "linux-wine11"
|
|
self.compat = base / "compat"
|
|
self.windows = self.compat / "pfx/drive_c/windows"
|
|
for folder in ("system32", "syswow64"):
|
|
(self.windows / folder).mkdir(parents=True)
|
|
self.reg = self.compat / "pfx/user.reg"
|
|
|
|
def run_setup(self, verb="waitforexitandrun", target="game.exe", cwd=None, extra=None):
|
|
script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nverb=$2\nbl_directaudio "$1" "$3"\necho "WINEDLLPATH=$WINEDLLPATH"\necho "WINE_AUDIO_DRIVER=${WINE_AUDIO_DRIVER:-}"\nenv | grep "^BANNER_AUDIO_DIRECT_" | sort\n'
|
|
steam = self.compat.parent / "Steam"
|
|
env = dict(os.environ, BL_DIRECTAUDIO=str(self.sets), STEAM_COMPAT_DATA_PATH=str(self.compat),
|
|
STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), **(extra or {}))
|
|
env.pop("WINEDLLPATH", None)
|
|
env.pop("WINE_AUDIO_DRIVER", None)
|
|
return subprocess.run(["bash", "-c", script, "bash", str(self.tool), verb, target], cwd=cwd, env=env,
|
|
capture_output=True, text=True, check=True)
|
|
|
|
def audio_values(self):
|
|
found, current = [], None
|
|
for line in self.reg.read_text().splitlines():
|
|
if line.startswith("["):
|
|
current = line.rsplit("] ", 1)[0] + "]"
|
|
elif line.startswith('"Audio"='):
|
|
found.append((current, line))
|
|
return found
|
|
|
|
def link(self, folder):
|
|
path = self.windows / folder / "winedirectaudio.drv"
|
|
return os.readlink(path) if path.is_symlink() else None
|
|
|
|
def test_steam_installscript_evaluator_does_not_change_prefix_audio(self):
|
|
legacycompat = self.compat.parent / "Steam/legacycompat"
|
|
legacycompat.mkdir(parents=True)
|
|
(legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
|
|
result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat)
|
|
|
|
self.assertIn("DirectAudio skipped for Steam's install-script evaluator", result.stderr)
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
|
|
self.assertEqual(self.reg.read_text(), "WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
self.assertIsNone(self.link("system32"))
|
|
self.assertIsNone(self.link("syswow64"))
|
|
|
|
def test_same_basename_outside_steam_legacycompat_keeps_normal_setup(self):
|
|
legacycompat = self.compat.parent / "Steam/legacycompat"
|
|
legacycompat.mkdir(parents=True)
|
|
(legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
|
|
other = self.compat.parent / "other"
|
|
other.mkdir()
|
|
(other / "iscriptevaluator.exe").write_bytes(b"unrelated executable")
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
|
|
result = self.run_setup(target="iscriptevaluator.exe", cwd=other)
|
|
|
|
self.assertIn("DirectAudio selected", result.stderr)
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
|
|
def test_audio_direct_settings_reach_the_driver_under_its_own_names(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
result = self.run_setup(extra={"AUDIO_DIRECT_MS": "60", "AUDIO_DIRECT_PERIOD_MS": "20",
|
|
"AUDIO_DIRECT_RELAY": "/run/relay.sock", "OTHER_AUDIO_DIRECT_MS": "5"})
|
|
lines = [line for line in result.stdout.splitlines() if line.startswith("BANNER_")]
|
|
self.assertEqual(lines, ["BANNER_AUDIO_DIRECT_MS=60", "BANNER_AUDIO_DIRECT_PERIOD_MS=20",
|
|
"BANNER_AUDIO_DIRECT_RELAY=/run/relay.sock"])
|
|
|
|
def test_audio_direct_settings_are_not_handed_on_when_directaudio_stays_off(self):
|
|
legacycompat = self.compat.parent / "Steam/legacycompat"
|
|
legacycompat.mkdir(parents=True)
|
|
(legacycompat / "iscriptevaluator.exe").write_bytes(b"Steam install-script evaluator")
|
|
result = self.run_setup(target="iscriptevaluator.exe", cwd=legacycompat, extra={"AUDIO_DIRECT_MS": "60"})
|
|
self.assertNotIn("BANNER_AUDIO_DIRECT_MS", result.stdout)
|
|
|
|
def test_non_launch_verbs_preserve_audio_configuration(self):
|
|
for verb in ("getcompatpath", "getnativepath", "destroyprefix"):
|
|
with self.subTest(verb=verb):
|
|
self.reg.write_text('WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\Drivers] 1\n"Audio"="pulse"\n')
|
|
before = self.reg.read_bytes()
|
|
result = self.run_setup(verb)
|
|
self.assertEqual(self.reg.read_bytes(), before)
|
|
self.assertIsNone(self.link("system32"))
|
|
self.assertIsNone(self.link("syswow64"))
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
|
|
|
|
def test_other_windows_launch_verbs_still_set_up_audio(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
for verb in ("run", "runinprefix"):
|
|
with self.subTest(verb=verb):
|
|
result = self.run_setup(verb)
|
|
self.assertIn("DirectAudio ready", result.stderr)
|
|
self.assertIn("WINE_AUDIO_DRIVER=directaudio,pulse\n", result.stdout)
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
|
|
def test_the_driver_is_placed_and_selected_the_way_wine_reads_it(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n;; All keys relative to \\\\User\\\\S-1-5-21-0-0-0-1000\n\n#arch=win64\n")
|
|
result = self.run_setup()
|
|
self.assertIn("DirectAudio selected", result.stderr)
|
|
self.assertIn("WINEDLLPATH=%s/lib/wine" % self.audio, result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=directaudio,pulse\n", result.stdout)
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
self.assertEqual(self.link("syswow64"), str(self.audio / "lib/wine/i386-windows/winedirectaudio.drv"))
|
|
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x-linux-wine11")
|
|
self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
|
|
before = self.reg.read_text()
|
|
self.assertNotIn("DirectAudio selected", self.run_setup().stderr)
|
|
self.assertEqual(self.reg.read_text(), before)
|
|
self.assertEqual(sorted(os.listdir(self.windows / "system32")), ["winedirectaudio.drv"])
|
|
|
|
def test_without_a_prefix_it_waits_for_the_next_launch(self):
|
|
result = self.run_setup()
|
|
self.assertIn("takes effect the next time this game starts", result.stderr)
|
|
self.assertEqual(self.link("system32"), None)
|
|
|
|
def test_the_system_thread_set_is_picked_from_winepulse_not_the_version(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
self.winepulse.write_bytes(b"\x7fELF cachyos pulse driver PsCreateSystemThread NtSetInformationThread")
|
|
result = self.run_setup()
|
|
systhread = self.sets / "linux-wine11-systhread"
|
|
self.assertIn("DirectAudio ready (Wine 11, linux-wine11-systhread)", result.stderr)
|
|
self.assertIn("WINEDLLPATH=%s/lib/wine" % systhread, result.stdout)
|
|
self.assertEqual(self.link("system32"), str(systhread / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x-linux-wine11-systhread")
|
|
|
|
def test_no_set_for_the_interface_leaves_protons_own_audio(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n\n#arch=win64\n")
|
|
self.winepulse.write_bytes(b"\x7fELF PsCreateSystemThread")
|
|
shutil.rmtree(self.sets / "linux-wine11-systhread")
|
|
result = self.run_setup()
|
|
self.assertIn("no driver set for the linux-wine11-systhread interface", result.stderr)
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertIn("WINE_AUDIO_DRIVER=\n", result.stdout)
|
|
self.assertFalse((self.windows / "system32/winedirectaudio.drv").exists())
|
|
|
|
def test_older_selections_are_upgraded(self):
|
|
self.reg.write_text('WINE REGISTRY Version 2\n\n[SoftwareWineDrivers] 1790995967\n#time=1dd52e24550e980\n"Audio"="directaudio"\n'
|
|
'\n[Software\\\\Wine\\\\Drivers] 1790995986\n#time=1dd52e250b31ec4\n"Audio"="directaudio"\n')
|
|
self.assertIn("DirectAudio selected", self.run_setup().stderr)
|
|
self.assertEqual(self.audio_values(), [("[SoftwareWineDrivers]", '"Audio"="directaudio"'),
|
|
("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio"'),
|
|
("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')])
|
|
self.assertNotIn("DirectAudio selected", self.run_setup().stderr)
|
|
|
|
def test_a_copied_driver_is_replaced_by_the_staged_one(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
(self.windows / "system32/winedirectaudio.drv").write_bytes(b"stale copy")
|
|
self.run_setup()
|
|
self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv"))
|
|
|
|
def test_nothing_is_selected_without_the_driver_in_the_prefix(self):
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
shutil.rmtree(self.windows)
|
|
self.assertIn("could not be placed", self.run_setup().stderr)
|
|
self.assertEqual(self.audio_values(), [])
|
|
(self.windows / "system32").mkdir(parents=True)
|
|
(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv").unlink()
|
|
self.assertIn("could not be placed", self.run_setup().stderr)
|
|
self.assertEqual(self.audio_values(), [])
|
|
|
|
def test_a_new_prefix_waits_for_the_next_launch(self):
|
|
shutil.rmtree(self.compat / "pfx")
|
|
self.assertIn("no prefix yet", self.run_setup().stderr)
|
|
self.assertFalse(self.reg.exists())
|
|
|
|
def test_another_wine_major_is_left_alone(self):
|
|
(self.tool / "files/bin-arm64/wine").write_text("#!/bin/sh\necho wine-10.0\n")
|
|
self.reg.write_text("WINE REGISTRY Version 2\n")
|
|
result = self.run_setup()
|
|
self.assertIn("left off", result.stderr)
|
|
self.assertIn("WINEDLLPATH=\n", result.stdout)
|
|
self.assertEqual(self.audio_values(), [])
|
|
self.assertIsNone(self.link("system32"))
|
|
|
|
|
|
class ProjectZomboidJvmTest(unittest.TestCase):
|
|
ARGS = ["-Djava.awt.headless=true", "-Xmx3072m", "-XX:+UseZGC", "-XX:-CreateCoredumpOnCrash"]
|
|
|
|
def launch(self, tmp, args=None, stem="ProjectZomboid64"):
|
|
settings = Path(tmp) / (stem + ".json")
|
|
settings.write_text(json.dumps({"mainClass": "zombie/gameStates/MainScreenState", "vmArgs": args or self.ARGS}))
|
|
command = ["proton", "waitforexitandrun", str(Path(tmp) / (stem + ".exe"))]
|
|
return settings, command
|
|
|
|
def test_zgc_becomes_g1_and_the_c1_jit_flag_is_added_once(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings, command = self.launch(tmp)
|
|
MODULE["project_zomboid_jvm"](command)
|
|
data = json.loads(settings.read_text())
|
|
self.assertEqual(data["vmArgs"], ["-Djava.awt.headless=true", "-Xmx3072m", "-XX:-CreateCoredumpOnCrash",
|
|
"-XX:+UseG1GC", "-XX:TieredStopAtLevel=1"])
|
|
self.assertEqual(data["mainClass"], "zombie/gameStates/MainScreenState")
|
|
before = settings.read_text()
|
|
MODULE["project_zomboid_jvm"](command)
|
|
self.assertEqual(settings.read_text(), before)
|
|
|
|
def test_other_collectors_and_windows_specific_lists_are_rewritten(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings, command = self.launch(tmp)
|
|
settings.write_text(json.dumps({"vmArgs": ["-XX:+UseParallelGC", "-Xmx1g"],
|
|
"windows": {"10.0.17134": {"vmArgs": ["-XX:+UseZGC", "-XX:+UseG1GC"]}}}))
|
|
MODULE["project_zomboid_jvm"](command)
|
|
data = json.loads(settings.read_text())
|
|
flags = ["-XX:+UseG1GC", "-XX:TieredStopAtLevel=1"]
|
|
self.assertEqual(data["vmArgs"], ["-Xmx1g"] + flags)
|
|
self.assertEqual(data["windows"]["10.0.17134"]["vmArgs"], flags)
|
|
|
|
def test_other_games_and_unreadable_files_are_left_alone(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings, command = self.launch(tmp, stem="Other")
|
|
before = settings.read_text()
|
|
MODULE["project_zomboid_jvm"](command)
|
|
self.assertEqual(settings.read_text(), before)
|
|
settings.write_text("{")
|
|
MODULE["project_zomboid_jvm"](command[:2] + [str(Path(tmp) / "Other.exe")])
|
|
(Path(tmp) / "ProjectZomboid64.json").write_text("{")
|
|
MODULE["project_zomboid_jvm"](command[:2] + [str(Path(tmp) / "ProjectZomboid64.exe")])
|
|
self.assertEqual((Path(tmp) / "ProjectZomboid64.json").read_text(), "{")
|
|
|
|
|
|
class UbisoftOverlayTest(unittest.TestCase):
|
|
SETTINGS = (
|
|
"language:\n code: en-US\n"
|
|
"overlay:\n enabled: true\n forceunhookgame: false\n fps_enabled: false\n warning_enabled: true\n"
|
|
"patchTrigger:\n source: USER_RESTART\n"
|
|
)
|
|
|
|
def test_the_overlay_and_its_prompt_are_turned_off_and_the_rest_is_kept(self):
|
|
got = MODULE["overlay_off"](self.SETTINGS)
|
|
self.assertEqual(got, self.SETTINGS.replace("enabled: true", "enabled: false"))
|
|
self.assertEqual(MODULE["overlay_off"](got), got)
|
|
|
|
def test_missing_keys_or_section_are_added(self):
|
|
self.assertEqual(MODULE["overlay_off"]("overlay:\n fps_enabled: true\nmisc:\n a: 1\n"),
|
|
"overlay:\n fps_enabled: true\n enabled: false\n warning_enabled: false\nmisc:\n a: 1\n")
|
|
self.assertEqual(MODULE["overlay_off"](""), "overlay:\n enabled: false\n warning_enabled: false\n")
|
|
|
|
def test_blank_lines_inside_the_overlay_section_do_not_end_it(self):
|
|
text = "overlay:\n enabled: true\n\n warning_enabled: true\n\nmisc:\n a: 1\n"
|
|
self.assertEqual(MODULE["overlay_off"](text), text.replace("true", "false"))
|
|
self.assertEqual(MODULE["overlay_off"]("overlay:\n fps_enabled: true\n\nmisc:\n a: 1\n"),
|
|
"overlay:\n fps_enabled: true\n enabled: false\n warning_enabled: false\n\nmisc:\n a: 1\n")
|
|
|
|
def test_an_annotated_overlay_header_is_the_same_section(self):
|
|
self.assertEqual(MODULE["overlay_off"]("overlay: # in-game\n enabled: true\n"),
|
|
"overlay: # in-game\n enabled: false\n warning_enabled: false\n")
|
|
|
|
def test_only_prefixes_with_ubisoft_connect_are_touched(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "447040/pfx"
|
|
env = {"STEAM_COMPAT_DATA_PATH": str(prefix.parent)}
|
|
settings = prefix / MODULE["UBISOFT_SETTINGS"]
|
|
prefix.mkdir(parents=True)
|
|
MODULE["ubisoft_overlay_off"](env)
|
|
self.assertFalse(settings.exists())
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
MODULE["ubisoft_overlay_off"](env)
|
|
self.assertEqual(settings.read_text(), "overlay:\n enabled: false\n warning_enabled: false\n")
|
|
settings.write_text(self.SETTINGS)
|
|
settings.chmod(0o644)
|
|
MODULE["ubisoft_overlay_off"](env)
|
|
self.assertEqual(settings.read_text(), self.SETTINGS.replace("enabled: true", "enabled: false"))
|
|
self.assertEqual(settings.stat().st_mode & 0o777, 0o644)
|
|
self.assertEqual(sorted(p.name for p in settings.parent.iterdir()), ["settings.yaml"])
|
|
|
|
|
|
class UbisoftCompositionTest(unittest.TestCase):
|
|
def test_writes_through_wine_without_replacing_a_live_servers_registry(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "pfx"
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
registry = prefix / "user.reg"
|
|
registry.write_text("registry last saved by Wine\n")
|
|
env = {"STEAM_COMPAT_DATA_PATH": tmp, "WINESERVER": "/selected/wineserver"}
|
|
keys = []
|
|
|
|
def write_registry(argv, **kwargs):
|
|
self.assertEqual(argv[:4], ["/selected/proton", "runinprefix", "reg.exe", "add"])
|
|
self.assertEqual(argv[5:], ["/v", "dcomp", "/t", "REG_SZ", "/d", "", "/f"])
|
|
self.assertIs(kwargs["env"], env)
|
|
self.assertTrue(kwargs["check"])
|
|
keys.append(argv[4])
|
|
registry.write_text("newer state saved by the active Wine server\n")
|
|
return subprocess.CompletedProcess(argv, 0)
|
|
|
|
with mock.patch("subprocess.run", side_effect=write_registry) as run:
|
|
MODULE["ubisoft_composition_off"]("/selected/proton", env)
|
|
self.assertEqual(run.call_count, 2)
|
|
self.assertEqual(registry.read_text(), "newer state saved by the active Wine server\n")
|
|
self.assertEqual(keys, [r"HKCU\Software\Wine\AppDefaults\%s\DllOverrides" % program
|
|
for program in ("upc.exe", "UplayWebCore.exe")])
|
|
self.assertEqual(list(prefix.glob(".ubisoft-*.reg")), [])
|
|
|
|
def test_other_prefixes_and_uninitialized_registries_are_untouched(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "pfx"
|
|
prefix.mkdir()
|
|
registry = prefix / "user.reg"
|
|
registry.write_text("WINE REGISTRY Version 2\n")
|
|
env = {"STEAM_COMPAT_DATA_PATH": tmp}
|
|
with mock.patch("subprocess.run") as run:
|
|
MODULE["ubisoft_composition_off"]("proton", env)
|
|
self.assertEqual(registry.read_text(), "WINE REGISTRY Version 2\n")
|
|
registry.unlink()
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
MODULE["ubisoft_composition_off"]("proton", env)
|
|
self.assertFalse(registry.exists())
|
|
run.assert_not_called()
|
|
|
|
def test_failed_registry_write_keeps_the_game_launch_available(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
prefix = Path(tmp) / "pfx"
|
|
(prefix / MODULE["UBISOFT_LAUNCHER"]).mkdir(parents=True)
|
|
(prefix / "user.reg").write_text("WINE REGISTRY Version 2\n")
|
|
for failed_write in (0, 1):
|
|
stderr = io.StringIO()
|
|
results = [subprocess.CompletedProcess([], 0)] * failed_write
|
|
results.append(subprocess.CalledProcessError(1, "proton"))
|
|
with mock.patch("subprocess.run", side_effect=results) as run, mock.patch("sys.stderr", stderr):
|
|
MODULE["ubisoft_composition_off"]("proton", {"STEAM_COMPAT_DATA_PATH": tmp})
|
|
self.assertEqual(run.call_count, failed_write + 1)
|
|
self.assertIn("configuration failed", stderr.getvalue())
|
|
self.assertNotIn("uses CEF", stderr.getvalue())
|
|
self.assertEqual(list(prefix.glob(".ubisoft-*.reg")), [])
|
|
|
|
|
|
|
|
def pe_image(machine):
|
|
head = bytearray(64)
|
|
head[:2] = b"MZ"
|
|
head[60:64] = (64).to_bytes(4, "little")
|
|
return bytes(head) + b"PE\0\0" + machine.to_bytes(2, "little") + b"\0" * 32
|
|
|
|
|
|
def arm64ec_pe_image():
|
|
image = bytearray(0x400)
|
|
image[:2] = b"MZ"
|
|
image[60:64] = (64).to_bytes(4, "little")
|
|
image[64:68] = b"PE\0\0"
|
|
coff = 68
|
|
image[coff:coff + 2] = (0x8664).to_bytes(2, "little")
|
|
image[coff + 2:coff + 4] = (1).to_bytes(2, "little")
|
|
image[coff + 16:coff + 18] = (240).to_bytes(2, "little")
|
|
optional = coff + 20
|
|
image[optional:optional + 2] = (0x20B).to_bytes(2, "little")
|
|
image[optional + 108:optional + 112] = (16).to_bytes(4, "little")
|
|
directory = optional + 112 + 10 * 8
|
|
image[directory:directory + 4] = (0x1000).to_bytes(4, "little")
|
|
image[directory + 4:directory + 8] = (0xD0).to_bytes(4, "little")
|
|
section = optional + 240
|
|
image[section + 8:section + 12] = (0x200).to_bytes(4, "little")
|
|
image[section + 12:section + 16] = (0x1000).to_bytes(4, "little")
|
|
image[section + 16:section + 20] = (0x200).to_bytes(4, "little")
|
|
image[section + 20:section + 24] = (0x200).to_bytes(4, "little")
|
|
image[0x200:0x204] = (0xD0).to_bytes(4, "little")
|
|
image[0x2C8:0x2D0] = (0x10000000).to_bytes(8, "little")
|
|
return bytes(image)
|
|
|
|
|
|
class ForeignSystemDllTest(unittest.TestCase):
|
|
def prefix(self, tmp, arm=True):
|
|
proton = Path(tmp) / "Proton"
|
|
dist = proton / "files/lib/wine"
|
|
if arm:
|
|
(dist / "aarch64-unix").mkdir(parents=True)
|
|
else:
|
|
(dist / "x86_64-unix").mkdir(parents=True)
|
|
for libdir, machine in (("aarch64-windows", 0xAA64), ("i386-windows", 0x14C)):
|
|
(dist / libdir).mkdir(parents=True)
|
|
for name in ("ucrtbase.dll", "rpcrt4.dll"):
|
|
(dist / libdir / name).write_bytes(pe_image(machine))
|
|
windows = Path(tmp) / "compat/pfx/drive_c/windows"
|
|
for folder in ("system32", "syswow64"):
|
|
(windows / folder).mkdir(parents=True)
|
|
return proton / "proton", Path(tmp) / "compat", windows
|
|
|
|
def run_repair(self, compat, proton):
|
|
return MODULE["repair_foreign_system_dlls"](str(compat), str(proton))
|
|
|
|
def test_x64_dll_in_arm64_system32_is_replaced_by_the_builtin(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
(windows / "system32/UCRTBASE.DLL").write_bytes(pe_image(0x8664))
|
|
(windows / "system32/rpcrt4.dll").write_bytes(pe_image(0xAA64))
|
|
fixed = self.run_repair(compat, proton)
|
|
self.assertEqual(["system32/UCRTBASE.DLL (machine 0x8664)"], fixed)
|
|
self.assertTrue((windows / "system32/UCRTBASE.DLL").is_symlink())
|
|
self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/UCRTBASE.DLL"))
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](windows / "system32/UCRTBASE.DLL.foreign-arch"))
|
|
self.assertFalse((windows / "system32/rpcrt4.dll").is_symlink())
|
|
self.assertEqual([], self.run_repair(compat, proton))
|
|
|
|
def test_64_bit_dll_in_syswow64_is_replaced(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
(windows / "syswow64/ucrtbase.dll").write_bytes(pe_image(0xAA64))
|
|
self.assertEqual(["syswow64/ucrtbase.dll (machine 0xaa64)"], self.run_repair(compat, proton))
|
|
self.assertEqual(0x14C, MODULE["pe_machine"](windows / "syswow64/ucrtbase.dll"))
|
|
|
|
def test_matching_ec_symlinked_unknown_and_non_pe_files_are_left_alone(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
system32 = windows / "system32"
|
|
(system32 / "ucrtbase.dll").write_bytes(pe_image(0xA641))
|
|
(system32 / "userlib.dll").write_bytes(pe_image(0x8664))
|
|
(system32 / "rpcrt4.dll").write_bytes(b"not a pe file")
|
|
self.assertEqual([], self.run_repair(compat, proton))
|
|
self.assertFalse((system32 / "ucrtbase.dll").is_symlink())
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](system32 / "userlib.dll"))
|
|
|
|
def test_x64_visual_cpp_runtime_in_arm64_system32_is_kept(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
builtins = proton.parent / "files/lib/wine/aarch64-windows"
|
|
for name in ("msvcp140.dll", "VCRUNTIME140.dll", "msvcp_win.dll"):
|
|
(builtins / name.lower()).write_bytes(pe_image(0xAA64))
|
|
(windows / "system32" / name).write_bytes(pe_image(0x8664))
|
|
self.assertEqual(["system32/msvcp_win.dll (machine 0x8664)"], self.run_repair(compat, proton))
|
|
self.assertFalse((windows / "system32/msvcp140.dll").is_symlink())
|
|
self.assertFalse((windows / "system32/VCRUNTIME140.dll").is_symlink())
|
|
|
|
def test_final_arm64ec_image_is_not_repaired_as_a_foreign_x64_dll(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
dll = windows / "system32/ucrtbase.dll"
|
|
dll.write_bytes(arm64ec_pe_image())
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](dll))
|
|
self.assertTrue(MODULE["PE"]["machine_compatible"](dll, frozenset((0xAA64, 0xA641))))
|
|
self.assertFalse(MODULE["PE"]["machine_compatible"](dll, frozenset((0x8664,))))
|
|
self.assertEqual([], self.run_repair(compat, proton))
|
|
self.assertFalse(dll.is_symlink())
|
|
|
|
def test_component_owned_and_native_first_foreign_dlls_are_still_replaced(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
(windows / "system32/ucrtbase.dll").write_bytes(pe_image(0x8664))
|
|
(windows / "system32/rpcrt4.dll").write_bytes(pe_image(0x8664))
|
|
state = {"files": {"drive_c/windows/system32/ucrtbase.dll": {}}}
|
|
(compat / ".droiddeck-wincomponents.json").write_text(json.dumps(state))
|
|
reg = 'WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\DllOverrides] 1\n"RPCRT4.DLL"="native,builtin"\n'
|
|
(compat / "pfx/user.reg").write_text(reg)
|
|
env = {"WINEDLLOVERRIDES": "ucrtbase=n,b"}
|
|
self.assertEqual(["system32/rpcrt4.dll (machine 0x8664)", "system32/ucrtbase.dll (machine 0x8664)"],
|
|
sorted(MODULE["repair_foreign_system_dlls"](str(compat), str(proton), env)))
|
|
self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/ucrtbase.dll"))
|
|
self.assertEqual(0xAA64, MODULE["pe_machine"](windows / "system32/rpcrt4.dll"))
|
|
|
|
def test_newest_foreign_copy_is_the_only_backup_and_failed_final_replace_restores_it(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
dll = windows / "system32/ucrtbase.dll"
|
|
dll.write_bytes(pe_image(0x8664))
|
|
backup = windows / "system32/ucrtbase.dll.foreign-arch"
|
|
backup.write_bytes(pe_image(0x1234))
|
|
self.assertEqual(["system32/ucrtbase.dll (machine 0x8664)"], self.run_repair(compat, proton))
|
|
self.assertEqual(0x1234, MODULE["pe_machine"](backup))
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](backup.with_name(backup.name + ".1")))
|
|
self.assertEqual(0xAA64, MODULE["pe_machine"](dll))
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
dll = windows / "system32/ucrtbase.dll"
|
|
dll.write_bytes(pe_image(0x8664))
|
|
backup = windows / "system32/ucrtbase.dll.foreign-arch"
|
|
backup.write_bytes(pe_image(0x1234))
|
|
real = os.replace
|
|
def flaky_with_backup(src, dst):
|
|
if str(src).endswith(".bl-new"):
|
|
raise OSError("boom")
|
|
return real(src, dst)
|
|
with mock.patch("os.replace", side_effect=flaky_with_backup):
|
|
self.assertEqual([], self.run_repair(compat, proton))
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](dll))
|
|
self.assertFalse((windows / "system32/ucrtbase.dll.foreign-arch.1").exists())
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp)
|
|
dll = windows / "system32/ucrtbase.dll"
|
|
dll.write_bytes(pe_image(0x8664))
|
|
real = os.replace
|
|
def flaky(src, dst):
|
|
if str(src).endswith(".bl-new"):
|
|
raise OSError("boom")
|
|
return real(src, dst)
|
|
with mock.patch("os.replace", side_effect=flaky):
|
|
self.assertEqual([], self.run_repair(compat, proton))
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](dll))
|
|
self.assertFalse(dll.is_symlink())
|
|
self.assertFalse((windows / "system32/ucrtbase.dll.foreign-arch").exists())
|
|
self.assertFalse((windows / "system32/ucrtbase.dll.bl-new").is_symlink())
|
|
|
|
def test_non_arm64_proton_and_missing_prefix_are_untouched(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proton, compat, windows = self.prefix(tmp, arm=False)
|
|
(windows / "system32/ucrtbase.dll").write_bytes(pe_image(0x8664))
|
|
self.assertEqual([], self.run_repair(compat, proton))
|
|
self.assertEqual(0x8664, MODULE["pe_machine"](windows / "system32/ucrtbase.dll"))
|
|
self.assertEqual([], self.run_repair(Path(tmp) / "missing", proton))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class ForceSsbsTest(unittest.TestCase):
|
|
def test_libssbs_is_preloaded_only_when_asked_and_only_into_arm64_proton(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
arm = Path(tmp) / "arm"
|
|
(arm / "files/lib/wine/aarch64-unix").mkdir(parents=True)
|
|
x86 = Path(tmp) / "x86"
|
|
(x86 / "files/lib/wine/x86_64-unix").mkdir(parents=True)
|
|
lib = Path(tmp) / "libssbs.so"
|
|
lib.write_bytes(b"\x7fELF")
|
|
g = MODULE["force_ssbs"].__globals__
|
|
old = g["SSBS_LIB"]
|
|
g["SSBS_LIB"] = str(lib)
|
|
try:
|
|
env = {"LD_PRELOAD": "/usr/local/lib/libblsession.so"}
|
|
self.assertEqual(MODULE["force_ssbs"]([str(arm / "proton"), "waitforexitandrun"], env), env)
|
|
on = dict(env, DROIDDECK_FORCE_SSBS="1")
|
|
got = MODULE["force_ssbs"]([str(arm / "proton"), "waitforexitandrun"], on)
|
|
self.assertEqual(got["LD_PRELOAD"], "/usr/local/lib/libblsession.so:" + str(lib))
|
|
self.assertEqual(MODULE["force_ssbs"]([str(arm / "proton")], got)["LD_PRELOAD"], got["LD_PRELOAD"])
|
|
self.assertEqual(MODULE["force_ssbs"]([str(x86 / "proton")], on), on)
|
|
finally:
|
|
g["SSBS_LIB"] = old
|