mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
1189 lines
73 KiB
Python
1189 lines
73 KiB
Python
import contextlib
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shutil
|
|
import stat
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
|
SYNC = runpy.run_path(str(BIN / "droiddeck-esync"))
|
|
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
|
|
GLOBALS = SYNC["select"].__globals__
|
|
NTDLL = SYNC["NTDLL"]
|
|
WINESERVER = SYNC["WINESERVER"]
|
|
COPY = list(SYNC["DEFAULT_COPY"])
|
|
REAL_DIRS = SYNC["REAL_DIRS"]
|
|
RECORD = SYNC["RECORD"]
|
|
VALVE_LINE = "1789159687 experimental-11.0-20260910b-arm64"
|
|
GE_LINE = "1789520806 GE-Proton11-7"
|
|
EXPORTS = ("NtClose", "NtCreateEvent", "wine_server_call")
|
|
WATCHED = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "BL_SYNC", "CUSTOM")
|
|
PROTON = ("#!/usr/bin/python3\nimport json, os, sys\n"
|
|
"print(json.dumps([sys.argv[0], sys.argv[1:], {k: os.environ.get(k) for k in %r}]))\n" % (WATCHED,))
|
|
MANIFEST = '"manifest" { "commandline" "/proton %verb%" }'
|
|
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
|
|
|
|
def elf(exported=(), weak=(), local=(), undefined=(), salt=b""):
|
|
strings = b"\0"
|
|
offsets = {}
|
|
for name in list(exported) + list(weak) + list(local) + list(undefined):
|
|
if name not in offsets:
|
|
offsets[name] = len(strings)
|
|
strings += name.encode() + b"\0"
|
|
symbols = [bytes(24)]
|
|
for group, binding, shndx in ((exported, 1, 7), (weak, 2, 7), (local, 0, 7), (undefined, 1, 0)):
|
|
symbols += [struct.pack("<IBBHQQ", offsets[name], binding << 4 | 2, 0, shndx, 0, 0) for name in group]
|
|
dynsym = b"".join(symbols)
|
|
body = dynsym + strings
|
|
body += bytes(-len(body) % 8)
|
|
header = b"\x7fELF" + bytes((2, 1, 1)) + bytes(9) + struct.pack(
|
|
"<HHIQQQIHHHHHH", 3, 183, 1, 0, 0, 64 + len(body), 0, 64, 0, 0, 64, 3, 0)
|
|
sections = (bytes(64) + struct.pack("<IIQQQQIIQQ", 0, 11, 2, 0, 64, len(dynsym), 2, 1, 8, 24)
|
|
+ struct.pack("<IIQQQQIIQQ", 0, 3, 2, 0, 64 + len(dynsym), len(strings), 0, 0, 1, 0))
|
|
return header + body + sections + salt
|
|
|
|
|
|
def make_tool(parent, name, line=VALVE_LINE, salt=b"", builtin=False, ge=False, decorated=False, fsync=False, ntsync=True):
|
|
tool = Path(parent) / name
|
|
for folder in ("files/lib/wine/aarch64-unix", "files/lib/wine/aarch64-windows", "files/bin-arm64", "files/share/wine"):
|
|
(tool / folder).mkdir(parents=True, exist_ok=True)
|
|
(tool / NTDLL).write_bytes(elf(EXPORTS, salt=salt))
|
|
(tool / WINESERVER).write_bytes(b"wineserver " + salt + (b" esync: up and running" if builtin else b"")
|
|
+ (b" fsync: up and running" if fsync else b"") + (b" /dev/ntsync" if ntsync else b""))
|
|
for rel in COPY:
|
|
(tool / rel).write_bytes(b"stock " + rel.encode() + salt)
|
|
(tool / "files/lib/wine/aarch64-unix/winevulkan.so").write_bytes(b"vulkan")
|
|
(tool / "files/lib/wine/aarch64-windows/ntdll.dll").write_bytes(b"pe")
|
|
os.symlink("wine", tool / "files/bin-arm64/msidb")
|
|
if ge:
|
|
os.symlink("bin-arm64", tool / "files/bin")
|
|
(tool / "version").write_text(line + "\n")
|
|
(tool / "proton").write_text(PROTON)
|
|
(tool / "proton").chmod(0o755)
|
|
if decorated:
|
|
(tool / "toolmanifest.vdf").write_text(MANIFEST)
|
|
(tool / "toolmanifest.vdf.droiddeck-orig").write_text(MANIFEST)
|
|
(tool / "toolmanifest.vdf.bannerlator-orig").write_text(MANIFEST)
|
|
(tool / "compatibilitytool.vdf").write_text("compat")
|
|
(tool / "droiddeck-proton-wrap").write_text("wrap")
|
|
(tool / "bannerlator-proton-wrap").write_text("wrap")
|
|
return tool
|
|
|
|
|
|
def make_pack(root, tool, pack_id, rev=1, source_match=False, stock=None, version=None, exports=None, complete=True, **extra):
|
|
tool = Path(tool)
|
|
pack_dir = Path(root) / "packs" / pack_id
|
|
(pack_dir / "files/lib/wine/aarch64-unix").mkdir(parents=True)
|
|
(pack_dir / "files/bin-arm64").mkdir(parents=True)
|
|
(pack_dir / NTDLL).write_bytes(b"patched ntdll " + pack_id.encode())
|
|
(pack_dir / WINESERVER).write_bytes(b"patched wineserver " + pack_id.encode())
|
|
line = (tool / "version").read_text().strip()
|
|
data = {
|
|
"format": 1, "id": pack_id, "flavor": "valve", "rev": rev, "version": version or line.split()[1],
|
|
"version_line": line, "stock": stock or {NTDLL: sha(tool / NTDLL), WINESERVER: sha(tool / WINESERVER)},
|
|
"exports": exports or SYNC["exports_hash"](tool / NTDLL), "source_match": source_match,
|
|
"files": {NTDLL: sha(pack_dir / NTDLL), WINESERVER: sha(pack_dir / WINESERVER)}, "copy": COPY,
|
|
"protocol": 931, "source": {"repo": "r", "ref": "t", "commit": "c", "wine_commit": "w", "sdk_image": "i"},
|
|
"patch": {"series": "valve-11", "sha256": "0" * 64},
|
|
}
|
|
data.update(extra)
|
|
(pack_dir / "pack.json").write_text(json.dumps(data))
|
|
if complete:
|
|
(pack_dir / ".complete").write_text("")
|
|
return data
|
|
|
|
|
|
def files_under(path):
|
|
return sorted(str(Path(folder, name).relative_to(path)) for folder, dirs, names in os.walk(path) for name in dirs + names)
|
|
|
|
|
|
def concurrent_worker(barrier, queue, root, tool, pack, steam):
|
|
barrier.wait()
|
|
try:
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
path = SYNC["ensure_dist"](root, tool, pack)
|
|
SYNC["reconcile"](steam, {"HOME": str(Path(root).parents[2]), "BL_SYNC_FALLBACK": "1"})
|
|
queue.put(path)
|
|
except Exception as error:
|
|
queue.put("error: %r" % error)
|
|
|
|
|
|
class SyncTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = Path(tempfile.mkdtemp())
|
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
self.home = self.tmp / "home"
|
|
self.home.mkdir()
|
|
self.root = self.home / ".local/share/droiddeck-esync"
|
|
self.tools = self.tmp / "tools"
|
|
self.tools.mkdir()
|
|
|
|
def store(self):
|
|
self.root.mkdir(parents=True, exist_ok=True)
|
|
return self.root
|
|
|
|
def env(self, **extra):
|
|
env = {"HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "BL_SYNC_FALLBACK": "1", "BL_SYNC_MIN_NOFILE": "1"}
|
|
env.update(extra)
|
|
return {name: value for name, value in env.items() if value is not None}
|
|
|
|
def select(self, tool, env, verb="waitforexitandrun"):
|
|
command = [str(Path(tool) / "proton"), verb, "game.exe"]
|
|
output = io.StringIO()
|
|
with contextlib.redirect_stderr(output):
|
|
chosen, chosen_env = SYNC["select"](command, env)
|
|
return chosen, chosen_env, output.getvalue()
|
|
|
|
def dist_of(self, pack_id, tool):
|
|
return self.root / "dist" / SYNC["dist_name"](pack_id, os.path.realpath(tool))
|
|
|
|
def quiet(self, function, *args):
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
return function(*args)
|
|
|
|
|
|
class ExportsHashTest(SyncTestCase):
|
|
def test_keeps_defined_global_and_weak_names(self):
|
|
path = self.tmp / "lib.so"
|
|
path.write_bytes(elf(("beta", "alpha", "beta"), weak=("weak",), local=("hidden",), undefined=("imported",), salt=b"x"))
|
|
self.assertEqual(SYNC["exports_hash"](path), hashlib.sha256(b"alpha\nbeta\nweak").hexdigest())
|
|
other = self.tmp / "other.so"
|
|
other.write_bytes(elf(("alpha", "beta"), weak=("weak",), salt=b"different"))
|
|
self.assertEqual(SYNC["exports_hash"](other), SYNC["exports_hash"](path))
|
|
for data in (b"not an elf", elf(("alpha",))[:80], b"\x7fELF\x01\x01" + bytes(60)):
|
|
path.write_bytes(data)
|
|
with self.assertRaises(ValueError):
|
|
SYNC["exports_hash"](path)
|
|
|
|
@unittest.skipUnless(shutil.which("cc") and shutil.which("nm"), "needs cc and nm")
|
|
def test_matches_nm_on_a_compiled_library(self):
|
|
source = self.tmp / "lib.c"
|
|
source.write_text("int alpha(void) { return 1; }\n__attribute__((weak)) int beta(void) { return 2; }\n"
|
|
"static int gamma_(void) { return 3; }\nint delta = 4;\nint use(void) { return gamma_(); }\n"
|
|
"extern int outside(void);\nint call(void) { return outside(); }\n")
|
|
library = self.tmp / "lib.so"
|
|
if subprocess.run(["cc", "-shared", "-fPIC", "-o", str(library), str(source)], capture_output=True).returncode:
|
|
self.skipTest("cc cannot build a shared library here")
|
|
listing = subprocess.run(["nm", "-D", "--defined-only", str(library)], capture_output=True, text=True, check=True).stdout
|
|
names = {fields[2].split("@")[0] for fields in (line.split() for line in listing.splitlines())
|
|
if len(fields) == 3 and fields[1].isupper()}
|
|
self.assertIn("alpha", names)
|
|
self.assertNotIn("outside", names)
|
|
self.assertEqual(SYNC["exports_hash"](library), hashlib.sha256("\n".join(sorted(names)).encode()).hexdigest())
|
|
|
|
|
|
class ShadowTest(SyncTestCase):
|
|
def test_layout_links_and_real_files(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "GE-Proton11-7", GE_LINE, ge=True, decorated=True)
|
|
os.symlink(self.tools, self.tmp / "launch")
|
|
launched = self.tmp / "launch" / "GE-Proton11-7"
|
|
pack = make_pack(root, tool, "ge-GE-Proton11-7-1789520806-abcdefabcdef-r1")
|
|
dist = Path(SYNC["ensure_dist"](root, launched, pack))
|
|
self.assertEqual(dist, self.dist_of(pack["id"], tool))
|
|
self.assertEqual(dist.name, "%s~%s" % (pack["id"], hashlib.sha256(str(tool.resolve()).encode()).hexdigest()[:8]))
|
|
for folder in REAL_DIRS:
|
|
self.assertTrue((dist / folder).is_dir() and not (dist / folder).is_symlink(), folder)
|
|
real_files = {NTDLL, WINESERVER, *COPY, RECORD}
|
|
for rel in files_under(dist):
|
|
if rel not in REAL_DIRS and rel not in real_files:
|
|
self.assertTrue((dist / rel).is_symlink(), rel)
|
|
self.assertEqual(os.readlink(dist / "files/bin"), "bin-arm64")
|
|
self.assertEqual(os.readlink(dist / "files/bin-arm64/msidb"), "wine")
|
|
for rel in ("proton", "version", "files/share", "files/lib/wine/aarch64-windows", "files/lib/wine/aarch64-unix/winevulkan.so"):
|
|
self.assertEqual(os.readlink(dist / rel), str(launched / rel))
|
|
for rel in ("dist.lock", "files/steampipe_fixups_mtime"):
|
|
self.assertEqual(os.readlink(dist / rel), str(launched / rel))
|
|
self.assertFalse((tool / rel).exists())
|
|
for name in ("toolmanifest.vdf", "compatibilitytool.vdf", "droiddeck-proton-wrap", "toolmanifest.vdf.droiddeck-orig",
|
|
"bannerlator-proton-wrap", "toolmanifest.vdf.bannerlator-orig"):
|
|
self.assertFalse(os.path.lexists(dist / name), name)
|
|
pack_dir = root / "packs" / pack["id"]
|
|
for rel in (NTDLL, WINESERVER):
|
|
self.assertEqual((dist / rel).read_bytes(), (pack_dir / rel).read_bytes())
|
|
for rel in COPY:
|
|
self.assertEqual((dist / rel).read_bytes(), (tool / rel).read_bytes())
|
|
for rel in (NTDLL, WINESERVER, *COPY):
|
|
info = os.lstat(dist / rel)
|
|
self.assertTrue(stat.S_ISREG(info.st_mode), rel)
|
|
self.assertEqual(stat.S_IMODE(info.st_mode), 0o755, rel)
|
|
self.assertEqual(info.st_nlink, 1, rel)
|
|
self.assertEqual((dist / "files/bin/wineserver").read_bytes(), (pack_dir / WINESERVER).read_bytes())
|
|
self.assertEqual((dist / "files/bin-arm64/msidb").resolve(), (dist / "files/bin-arm64/wine").resolve())
|
|
record = json.loads((dist / RECORD).read_text())
|
|
self.assertEqual(record["pack"], pack["id"])
|
|
self.assertEqual(record["stock"], str(tool.resolve()))
|
|
self.assertEqual(record["tool"], str(launched))
|
|
self.assertEqual(record["version"], GE_LINE)
|
|
self.assertEqual(sorted(record["copies"]), sorted(COPY))
|
|
for rel in COPY:
|
|
info = os.stat(tool / rel)
|
|
self.assertEqual(record["copies"][rel], [info.st_size, info.st_mtime_ns, sha(tool / rel)])
|
|
inodes = {rel: os.lstat(dist / rel).st_ino for rel in (NTDLL, WINESERVER, *COPY)}
|
|
self.assertEqual(Path(SYNC["ensure_dist"](root, tool, pack)), dist)
|
|
self.assertEqual(inodes, {rel: os.lstat(dist / rel).st_ino for rel in inodes})
|
|
self.assertEqual(os.readlink(dist / "proton"), str(launched / "proton"))
|
|
self.assertEqual([name for name in os.listdir(root / "dist") if ".tmp-" in name], [])
|
|
|
|
def test_reconcile_follows_stock_changes(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
dist = Path(SYNC["ensure_dist"](root, tool, pack))
|
|
unix_wine = "files/lib/wine/aarch64-unix/wine"
|
|
preloader = "files/lib/wine/aarch64-unix/wine-preloader"
|
|
(tool / "files/new.txt").write_text("new")
|
|
(tool / "files/lib/wine/aarch64-unix/new.so").write_text("new")
|
|
(tool / "files/lib/wine/aarch64-unix/winevulkan.so").unlink()
|
|
(dist / "files/share").unlink()
|
|
os.symlink("/nonexistent", dist / "files/share")
|
|
(dist / "files/stray").symlink_to("/nowhere")
|
|
for rel in (NTDLL, WINESERVER, "files/bin-arm64/wine"):
|
|
os.chmod(dist / rel, 0o444)
|
|
os.chmod(dist / unix_wine, 0o555)
|
|
kept = os.lstat(dist / unix_wine).st_ino
|
|
(tool / "files/bin-arm64/wine").write_bytes(b"an updated stock wine loader")
|
|
(tool / preloader).unlink()
|
|
self.assertEqual(Path(SYNC["ensure_dist"](root, tool, pack)), dist)
|
|
self.assertEqual(os.readlink(dist / "files/new.txt"), str(tool / "files/new.txt"))
|
|
self.assertEqual(os.readlink(dist / "files/lib/wine/aarch64-unix/new.so"), str(tool / "files/lib/wine/aarch64-unix/new.so"))
|
|
self.assertFalse(os.path.lexists(dist / "files/lib/wine/aarch64-unix/winevulkan.so"))
|
|
self.assertFalse(os.path.lexists(dist / "files/stray"))
|
|
self.assertEqual(os.readlink(dist / "files/share"), str(tool / "files/share"))
|
|
self.assertEqual((dist / "files/bin-arm64/wine").read_bytes(), b"an updated stock wine loader")
|
|
for rel in (NTDLL, WINESERVER, "files/bin-arm64/wine"):
|
|
self.assertEqual(stat.S_IMODE(os.lstat(dist / rel).st_mode), 0o755, rel)
|
|
self.assertEqual((dist / NTDLL).read_bytes(), (root / "packs" / pack["id"] / NTDLL).read_bytes())
|
|
self.assertEqual(os.lstat(dist / unix_wine).st_ino, kept)
|
|
self.assertTrue((dist / preloader).is_file() and not (dist / preloader).is_symlink())
|
|
record = json.loads((dist / RECORD).read_text())
|
|
self.assertEqual(record["copies"]["files/bin-arm64/wine"][2], sha(tool / "files/bin-arm64/wine"))
|
|
self.assertIn(preloader, record["copies"])
|
|
|
|
def test_tampered_pack_falls_back_to_stock(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
(root / "packs" / pack["id"] / NTDLL).write_bytes(b"tampered")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertNotIn("WINESERVER", env)
|
|
self.assertIn("unusable", said)
|
|
self.assertIn(f"pack {pack['id']} is damaged; the app fetches it again", said)
|
|
self.assertFalse((root / "packs" / pack["id"] / ".complete").exists())
|
|
self.assertEqual([name for name in os.listdir(root / "dist") if not name.endswith(".lock")], [])
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertIn("no pack for Proton Experimental (ARM64)", said)
|
|
self.assertIn(pack["stock"][NTDLL], (root / "wanted.tsv").read_text())
|
|
|
|
def test_a_missing_tool_file_does_not_condemn_the_pack(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
with mock.patch.dict(GLOBALS, {"ensure_dist": mock.Mock(side_effect=OSError("disk full"))}):
|
|
command, _, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertIn("unusable", said)
|
|
self.assertNotIn("damaged", said)
|
|
self.assertTrue((root / "packs" / pack["id"] / ".complete").exists())
|
|
|
|
def test_concurrent_ensure_dist_builds_one_dist(self):
|
|
root = self.store()
|
|
steam = self.tmp / "steam"
|
|
tool = make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
context = multiprocessing.get_context("fork")
|
|
barrier = context.Barrier(4)
|
|
queue = context.Queue()
|
|
workers = [context.Process(target=concurrent_worker, args=(barrier, queue, str(root), str(tool), pack, str(steam)))
|
|
for _ in range(4)]
|
|
with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}):
|
|
for worker in workers:
|
|
worker.start()
|
|
results = [queue.get(timeout=60) for _ in workers]
|
|
for worker in workers:
|
|
worker.join(60)
|
|
dist = self.dist_of(pack["id"], tool)
|
|
self.assertEqual(results, [str(dist)] * 4)
|
|
self.assertEqual(sorted(os.listdir(root / "dist")), sorted([dist.name, dist.name + ".lock"]))
|
|
record = json.loads((dist / RECORD).read_text())
|
|
self.assertEqual(record["pack"], pack["id"])
|
|
for rel in (NTDLL, WINESERVER):
|
|
self.assertEqual(sha(dist / rel), pack["files"][rel])
|
|
for rel in COPY:
|
|
self.assertEqual(sha(dist / rel), sha(tool / rel))
|
|
self.assertEqual((root / "tools.tsv").read_text().count("\n"), 1)
|
|
self.assertFalse([name for name in os.listdir(root) if ".tmp-" in name])
|
|
|
|
def test_build_shadow_command_line(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "proton-cachyos-11", "1790000000 cachyos-11.0-1")
|
|
pack = make_pack(root, tool, "cachyos-11-r1")
|
|
out = self.tmp / "shadow" / "dist"
|
|
for _ in range(2):
|
|
result = subprocess.run([sys.executable, str(BIN / "droiddeck-esync"), "build-shadow", str(tool),
|
|
str(root / "packs" / pack["id"]), str(out)], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout.strip(), str(out))
|
|
self.assertEqual(os.listdir(out.parent), ["dist"])
|
|
self.assertEqual(json.loads((out / RECORD).read_text())["pack"], pack["id"])
|
|
self.assertEqual(os.readlink(out / "proton"), str(tool / "proton"))
|
|
status = subprocess.run([sys.executable, str(BIN / "droiddeck-esync"), "status"], capture_output=True, text=True,
|
|
env={**os.environ, "HOME": str(self.home)}, check=True).stdout
|
|
self.assertIn("packs: 1 installed", status)
|
|
usage = subprocess.run([sys.executable, str(BIN / "droiddeck-esync"), "build-shadow"], capture_output=True, text=True)
|
|
self.assertEqual(usage.returncode, 2)
|
|
|
|
|
|
class SelectTest(SyncTestCase):
|
|
def test_store_missing_is_inert(self):
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
before = files_under(self.tmp)
|
|
env = self.env()
|
|
command, chosen_env, said = self.select(tool, env)
|
|
self.assertEqual((command, chosen_env, said), ([str(tool / "proton"), "waitforexitandrun", "game.exe"], env, ""))
|
|
command, chosen_env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1"))
|
|
self.assertEqual(chosen_env, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="0"))
|
|
self.assertIsNone(self.quiet(SYNC["reconcile"], str(self.tmp / "steam"), env))
|
|
self.assertEqual(SYNC["gc"](None), 0)
|
|
self.assertIsNone(SYNC["store_root"](env))
|
|
self.assertEqual(files_under(self.tmp), before)
|
|
|
|
def test_probe_prefixes_and_other_tools_are_left_alone(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
make_pack(root, tool, "valve-experimental-r1")
|
|
for prefix in ("", "/steam/compatdata/0", "/steam/compatdata/0/", "/steam/compatdata/0-1234"):
|
|
env = self.env(STEAM_COMPAT_DATA_PATH=prefix, PROTON_NO_NTSYNC="1")
|
|
self.assertEqual(self.select(tool, env), ([str(tool / "proton"), "waitforexitandrun", "game.exe"], env, ""))
|
|
env = self.env(PROTON_USE_ARM64="0")
|
|
self.assertEqual(self.select(tool, env)[:2], ([str(tool / "proton"), "waitforexitandrun", "game.exe"], env))
|
|
other = self.tmp / "not-proton"
|
|
other.mkdir()
|
|
self.assertEqual(self.select(other, self.env())[1], self.env())
|
|
self.assertFalse((root / "dist").exists())
|
|
self.assertFalse((root / "launches.log").exists())
|
|
|
|
def test_fallback_switch_off(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
make_pack(root, tool, "valve-experimental-r1")
|
|
for value in ("0", None, "yes"):
|
|
env = self.env(BL_SYNC_FALLBACK=value)
|
|
command, chosen_env, said = self.select(tool, env)
|
|
self.assertEqual((command[0], chosen_env), (str(tool / "proton"), SYNC["wineserver_only"](env)))
|
|
self.assertEqual(said, "droiddeck-esync: off; wineserver sync only\n")
|
|
self.assertFalse((root / "dist").exists())
|
|
self.assertFalse((root / "wanted.tsv").exists())
|
|
self.assertEqual([line.split("\t")[4] for line in (root / "launches.log").read_text().splitlines()], ["-"] * 3)
|
|
|
|
def test_pack_is_used_and_logged(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
dist = self.dist_of(pack["id"], tool)
|
|
command, env, said = self.select(tool, self.env(BL_SYNC="1"))
|
|
self.assertEqual(command, [str(dist / "proton"), "waitforexitandrun", "game.exe"])
|
|
self.assertEqual(env["WINESERVER"], str(dist / WINESERVER))
|
|
self.assertEqual(env["BL_SYNC_PACK"], pack["id"])
|
|
self.assertEqual(env["WINEESYNC"], "1")
|
|
self.assertNotIn("PROTON_NO_ESYNC", env)
|
|
self.assertTrue(said.startswith("droiddeck-esync: pack %s for Proton Experimental (ARM64) (%s, exact match); "
|
|
"droiddeck-ntsync on, droiddeck-esync on, fds " % (pack["id"], VALVE_LINE)), said)
|
|
self.assertEqual(said.count("\n"), 1)
|
|
fields = (root / "launches.log").read_text().splitlines()[-1].split("\t")
|
|
self.assertEqual(fields[1:7], ["42", "waitforexitandrun", str(tool), pack["id"], "1", "1"])
|
|
self.assertRegex(fields[7], r"^(\d+|unlimited)/(\d+|unlimited)$")
|
|
command, env, said = self.select(tool, self.env(WINEESYNC="2"))
|
|
self.assertEqual((env["WINEESYNC"], command[0]), ("2", str(dist / "proton")))
|
|
self.assertIn("droiddeck-ntsync off", said)
|
|
for _ in range(SYNC["LOG_LINES"] + 5):
|
|
SYNC["log_launch"](str(root), command, env, str(tool), None)
|
|
self.assertEqual(len((root / "launches.log").read_text().splitlines()), SYNC["LOG_LINES"])
|
|
|
|
def test_fallback_switched_off_per_game(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
cases = ({"PROTON_NO_ESYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}, {"SteamAppId": "1060210"},
|
|
{"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/414740"}, {"STEAM_COMPAT_APP_ID": "201510", "WINEESYNC": "1"},
|
|
{"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/1233880/"}, {"WINEESYNC": "0"},
|
|
{"WINEESYNC": "0", "PROTON_NO_ESYNC": "0"})
|
|
for extra in cases:
|
|
command, env, _ = self.select(tool, self.env(**extra))
|
|
self.assertEqual((env["WINEESYNC"], env["PROTON_NO_ESYNC"], env["BL_SYNC_PACK"]), ("0", "1", pack["id"]), extra)
|
|
self.assertNotIn("PROTON_NO_FSYNC", env, extra)
|
|
self.assertEqual(command[0], str(self.dist_of(pack["id"], tool) / "proton"))
|
|
for extra in ({"PROTON_NO_ESYNC": "0"}, {"PROTON_NO_ESYNC": ""}, {"STEAM_COMPAT_APP_ID": "42", "SteamAppId": "2630"},
|
|
{"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_ESYNC": "0"}, {"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_ESYNC": ""}):
|
|
env = self.select(tool, self.env(**extra))[1]
|
|
self.assertEqual((env["WINEESYNC"], env["PROTON_NO_FSYNC"]), ("1", "1"), extra)
|
|
|
|
def test_fallback_comes_before_fsync(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
make_pack(root, tool, "valve-experimental-r1")
|
|
for extra, expected in (({}, "1"), ({"WINEESYNC": "2"}, "1"), ({"PROTON_NO_FSYNC": "0"}, "0"),
|
|
({"PROTON_NO_FSYNC": ""}, ""), ({"PROTON_NO_FSYNC": "1"}, "1")):
|
|
env = self.select(tool, self.env(**extra))[1]
|
|
self.assertEqual(env.get("PROTON_NO_FSYNC"), expected, extra)
|
|
stock = self.select(tool, self.env(BL_SYNC_FALLBACK="0"))[1]
|
|
self.assertEqual((stock["PROTON_NO_FSYNC"], stock["WINEFSYNC"]), ("1", "0"))
|
|
|
|
def test_open_file_limit_threshold(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
make_pack(root, tool, "valve-experimental-r1")
|
|
cases = (((1024, 4096), None, "0"), ((1024, 4096), "4096", "1"), ((1024, 4096), "4097", "0"),
|
|
((1024, 4096), "junk", "0"), ((1024, 8192), None, "1"), ((1024, -1), "999999", "1"))
|
|
for limits, minimum, expected in cases:
|
|
with mock.patch("resource.getrlimit", return_value=limits):
|
|
command, env, said = self.select(tool, self.env(BL_SYNC_MIN_NOFILE=minimum))
|
|
self.assertEqual(env["WINEESYNC"], expected, (limits, minimum))
|
|
self.assertIn("fds 1024/unlimited", said)
|
|
|
|
def test_proton_no_ntsync_turns_emulation_off(self):
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
self.assertEqual(self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1"))[1]["BL_SYNC"], "0")
|
|
self.assertEqual(self.select(tool, self.env(PROTON_NO_NTSYNC="1"))[1]["BL_SYNC"], "0")
|
|
self.assertEqual(self.select(tool, self.env(PROTON_NO_NTSYNC="0", BL_SYNC="1"))[1]["BL_SYNC"], "1")
|
|
root = self.store()
|
|
_, env, _ = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1"))
|
|
self.assertEqual(env["BL_SYNC"], "0")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
_, env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1"))
|
|
self.assertEqual((env["BL_SYNC"], env["BL_SYNC_PACK"]), ("0", pack["id"]))
|
|
self.assertIn("droiddeck-ntsync off", said)
|
|
|
|
def test_internal_failure_launches_stock(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
make_pack(root, tool, "valve-experimental-r1")
|
|
|
|
def broken(*args, **kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
for name in ("measure", "load_packs", "ensure_dist", "find_pack"):
|
|
with mock.patch.dict(GLOBALS, {name: broken}):
|
|
command, env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1"))
|
|
self.assertEqual(command[0], str(tool / "proton"), name)
|
|
self.assertEqual(env, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="0"), name)
|
|
self.assertIn("boom", said)
|
|
|
|
def test_droiddeck_ntsync_stands_in_without_a_pack(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
command, env, said = self.select(tool, self.env(BL_SYNC="0"))
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertEqual(env, self.env(BL_SYNC="1"))
|
|
self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), said)
|
|
self.assertEqual((root / "launches.log").read_text().split("\n")[-2].split("\t")[6], "1")
|
|
command, env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1"))
|
|
self.assertEqual(env, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="0"))
|
|
self.assertTrue(said.endswith("; stock binaries\n"), said)
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
(root / "packs" / pack["id"] / NTDLL).write_bytes(b"tampered")
|
|
command, env, said = self.select(tool, self.env(BL_SYNC="0"))
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertEqual(env, self.env(BL_SYNC="1"))
|
|
self.assertIn("unusable", said)
|
|
self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), said)
|
|
|
|
def test_droiddeck_fsync_stands_in_without_a_pack(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True)
|
|
command, env, said = self.select(tool, self.env(BL_SYNC="0"))
|
|
self.assertEqual(command, [str(tool / "proton"), "waitforexitandrun", "game.exe"])
|
|
self.assertEqual(env, self.env(BL_SYNC="0", BL_FSYNC="1", WINEFSYNC="1"))
|
|
self.assertEqual(said, "droiddeck-fsync: no pack for Proton Experimental (ARM64) (%s, ntdll %s); "
|
|
"stock binaries with Proton's fsync\n" % (VALVE_LINE, sha(tool / NTDLL)[:12]))
|
|
fields = (root / "launches.log").read_text().splitlines()[-1].split("\t")
|
|
self.assertEqual((fields[4], fields[6], fields[8]), ("-", "0", "1"))
|
|
self.assertEqual(len((root / "wanted.tsv").read_text().splitlines()), 1)
|
|
for extra in ({"BL_SYNC": "1"}, {"PROTON_NO_FSYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}, {"SteamAppId": "1233880"}):
|
|
command, env, said = self.select(tool, self.env(**extra))
|
|
self.assertNotIn("BL_FSYNC", env, extra)
|
|
self.assertEqual(env["BL_SYNC"], "1", extra)
|
|
self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), (extra, said))
|
|
for extra in ({"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_FSYNC": "0"}, {"PROTON_NO_FSYNC": ""}, {"BL_SYNC": "0"}):
|
|
self.assertEqual(self.select(tool, self.env(**extra))[1]["BL_FSYNC"], "1", extra)
|
|
env = self.select(tool, self.env(PROTON_NO_FSYNC="1", PROTON_NO_NTSYNC="1"))[1]
|
|
self.assertEqual((env["BL_SYNC"], "BL_FSYNC" in env), ("0", False))
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
(root / "packs" / pack["id"] / NTDLL).write_bytes(b"tampered")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual((command[0], env["BL_FSYNC"]), (str(tool / "proton"), "1"))
|
|
self.assertIn("\ndroiddeck-fsync: pack %s unusable for Proton Experimental (ARM64) (" % pack["id"], said)
|
|
self.assertTrue(said.endswith("; stock binaries with Proton's fsync\n"), said)
|
|
|
|
def test_droiddeck_fsync_first(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True)
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
for extra in ({}, {"BL_SYNC_FALLBACK": None}, {"BL_SYNC_FALLBACK": "0"}, {"BL_FSYNC": "0"}):
|
|
command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", **extra))
|
|
self.assertEqual(command[0], str(tool / "proton"), extra)
|
|
self.assertEqual((env["BL_FSYNC"], env["BL_SYNC"], env["WINEFSYNC"]), ("1", "0", "1"), extra)
|
|
self.assertNotIn("BL_SYNC_PACK", env, extra)
|
|
self.assertEqual(said, "droiddeck-fsync: chosen for Proton Experimental (ARM64); stock binaries with Proton's fsync\n")
|
|
self.assertFalse((root / "wanted.tsv").exists())
|
|
command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", BL_SYNC="1"))
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"], env["BL_SYNC"]), (str(self.dist_of(pack["id"], tool) / "proton"), pack["id"], "1"))
|
|
self.assertNotIn("BL_FSYNC", env)
|
|
command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", BL_FSYNC="1", STEAM_COMPAT_APP_ID="414740"))
|
|
self.assertEqual((env["BL_FSYNC"], env["BL_SYNC_PACK"], env["PROTON_NO_ESYNC"]), ("0", pack["id"], "1"))
|
|
command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", BL_FSYNC="1", BL_SYNC_FALLBACK="0", PROTON_NO_FSYNC="1"))
|
|
self.assertEqual((command[0], env["BL_FSYNC"], said), (str(tool / "proton"), "0", "droiddeck-esync: off\n"))
|
|
plain = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True)
|
|
command, env, said = self.select(plain, self.env(BL_FSYNC_FIRST="1"))
|
|
self.assertEqual((command[0], said), (str(plain / "proton"), "droiddeck-esync: GE-Proton11-9 has esync built in\n"))
|
|
self.assertNotIn("BL_FSYNC", env)
|
|
|
|
def test_fsync_takes_over_when_esync_is_off_for_a_game(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True)
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
for extra in ({"PROTON_NO_ESYNC": "1"}, {"WINEESYNC": "0"}):
|
|
command, env, said = self.select(tool, self.env(**extra))
|
|
self.assertEqual((command[0], env["BL_FSYNC"]), (str(tool / "proton"), "1"), extra)
|
|
self.assertEqual(said, "droiddeck-fsync: esync is off for this game; stock binaries with Proton's fsync\n")
|
|
with mock.patch("resource.getrlimit", return_value=(1024, 4096)):
|
|
env = self.select(tool, self.env(BL_SYNC_MIN_NOFILE=None))[1]
|
|
self.assertEqual(env["BL_FSYNC"], "1")
|
|
for extra in ({"STEAM_COMPAT_APP_ID": "2630"}, {"PROTON_NO_ESYNC": "1", "PROTON_NO_FSYNC": "1"}, {"WINEESYNC": "0", "BL_SYNC": "1"}):
|
|
command, env, said = self.select(tool, self.env(**extra))
|
|
self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"]), (pack["id"], "0"), extra)
|
|
self.assertNotIn("BL_FSYNC", env, extra)
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"], env["PROTON_NO_FSYNC"]), (pack["id"], "1", "1"))
|
|
self.assertNotIn("BL_FSYNC", env)
|
|
|
|
def test_the_hash_cache_remembers_fsync(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True)
|
|
self.select(tool, self.env())
|
|
cache = (root / "hash-cache.tsv").read_text().splitlines()
|
|
rows = {line.split("\t")[0]: line.split("\t") for line in cache}
|
|
server = rows[os.path.realpath(tool / WINESERVER)]
|
|
self.assertEqual((len(server), server[7], server[9], server[10]), (11, "0", "1", "1"))
|
|
self.assertEqual(rows[os.path.realpath(tool / NTDLL)][9], "0")
|
|
(root / "hash-cache.tsv").write_text("\n".join("\t".join(row[:9]) for row in rows.values()) + "\n")
|
|
self.assertEqual(SYNC["parse_cache"]((root / "hash-cache.tsv").read_text()), {})
|
|
self.assertEqual(self.select(tool, self.env())[1]["BL_FSYNC"], "1")
|
|
|
|
def test_esync_off_games_get_droiddeck_ntsync_on_a_pack(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
for extra in ({"PROTON_NO_ESYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}, {"WINEESYNC": "0"}):
|
|
command, env, said = self.select(tool, self.env(**extra))
|
|
self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"], env["BL_SYNC"]), (pack["id"], "0", "1"), extra)
|
|
self.assertIn("droiddeck-ntsync on, droiddeck-esync off", said)
|
|
env = self.select(tool, self.env(PROTON_NO_ESYNC="1", PROTON_NO_NTSYNC="1"))[1]
|
|
self.assertEqual((env["WINEESYNC"], env["BL_SYNC"]), ("0", "0"))
|
|
env = self.select(tool, self.env(BL_SYNC="0"))[1]
|
|
self.assertEqual((env["WINEESYNC"], env["BL_SYNC"]), ("1", "0"))
|
|
|
|
def test_a_running_prefix_keeps_its_sync_choice(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True)
|
|
prefix = os.path.realpath("/compatdata/42/pfx")
|
|
command, first, _ = self.select(tool, self.env())
|
|
self.assertEqual(first["BL_FSYNC"], "1")
|
|
rows = json.loads((root / "prefixes.json").read_text())
|
|
self.assertEqual(rows[prefix]["env"]["BL_FSYNC"], "1")
|
|
self.assertIsNone(rows[prefix]["env"]["BL_SYNC_PACK"])
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
dist = self.dist_of(pack["id"], tool)
|
|
running = []
|
|
with mock.patch.dict(GLOBALS, {"server_running": lambda path: running.append(path) or True}):
|
|
command, env, said = self.select(tool, self.env(BL_SYNC="0", WINEFSYNC=None))
|
|
self.assertEqual(running, [prefix])
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertEqual(env, self.env(BL_SYNC="0", BL_FSYNC="1", WINEFSYNC="1"))
|
|
self.assertEqual(said, "droiddeck-esync: Wine is still running in this prefix; keeping its sync choice (droiddeck-fsync)\n")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"]))
|
|
self.assertNotIn("BL_FSYNC", env)
|
|
with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}):
|
|
command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1"))
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"], env["WINESERVER"]), (str(dist / "proton"), pack["id"], str(dist / WINESERVER)))
|
|
self.assertNotIn("BL_FSYNC", env)
|
|
self.assertIn("(droiddeck-esync)", said)
|
|
other = make_tool(self.tools, "Proton 11.0 (ARM64)", salt=b"11", fsync=True)
|
|
self.assertEqual(self.select(other, self.env())[1]["BL_FSYNC"], "1")
|
|
shutil.rmtree(self.root / "dist")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"]))
|
|
self.assertNotIn("keeping", said)
|
|
(root / "prefixes.json").write_text("not json")
|
|
with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}):
|
|
self.assertEqual(self.select(tool, self.env())[1]["BL_SYNC_PACK"], pack["id"])
|
|
self.assertEqual(json.loads((root / "prefixes.json").read_text())[prefix]["env"]["BL_SYNC_PACK"], pack["id"])
|
|
|
|
def test_a_running_prefix_drops_a_pack_that_no_longer_fits(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
dist = self.dist_of(pack["id"], tool)
|
|
self.assertEqual(self.select(tool, self.env())[0][0], str(dist / "proton"))
|
|
with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}):
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"]))
|
|
self.assertIn("keeping its sync choice", said)
|
|
(tool / NTDLL).write_bytes(elf(EXPORTS, salt=b"updated in place"))
|
|
(tool / "version").write_text("1791000000 experimental-11.0-20261008-arm64\n")
|
|
command, env, said = self.select(tool, self.env(), verb="run")
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"]))
|
|
self.assertIn("keeping its sync choice", said)
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertNotIn("BL_SYNC_PACK", env)
|
|
self.assertNotIn("keeping", said)
|
|
self.assertIn("no pack for Proton Experimental (ARM64)", said)
|
|
other = make_tool(self.tools, "Proton 11.0 (ARM64)", "1788504814 proton-11.0-2c-arm64", salt=b"2c")
|
|
pack = make_pack(root, other, "valve-proton-11.0-2c-arm64-r1")
|
|
dist = self.dist_of(pack["id"], other)
|
|
self.assertEqual(self.select(other, self.env())[1]["BL_SYNC_PACK"], pack["id"])
|
|
rev2 = make_pack(root, other, "valve-proton-11.0-2c-arm64-r2", rev=2)
|
|
with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}):
|
|
self.assertEqual(self.select(other, self.env(), verb="run")[1]["BL_SYNC_PACK"], pack["id"])
|
|
self.assertEqual(self.select(other, self.env())[1]["BL_SYNC_PACK"], rev2["id"])
|
|
for gone in (pack, rev2):
|
|
data = json.loads((root / "packs" / gone["id"] / "pack.json").read_text())
|
|
(root / "packs" / gone["id"] / "pack.json").write_text(json.dumps(dict(data, revoked=True)))
|
|
with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}):
|
|
self.assertEqual(self.select(other, self.env(), verb="run")[1]["BL_SYNC_PACK"], rev2["id"])
|
|
command, env, said = self.select(other, self.env())
|
|
self.assertEqual(command[0], str(other / "proton"))
|
|
self.assertNotIn("keeping", said)
|
|
|
|
def test_droiddeck_ntsync_does_not_stand_in_when_esync_is_off(self):
|
|
self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
env = self.env(BL_SYNC="0")
|
|
env.pop("BL_SYNC_FALLBACK", None)
|
|
command, chosen, said = self.select(tool, env)
|
|
self.assertEqual(chosen, SYNC["wineserver_only"](env))
|
|
self.assertEqual(said, "droiddeck-esync: off; wineserver sync only\n")
|
|
|
|
def test_the_wineserver_tab_turns_proton_sync_off(self):
|
|
self.store()
|
|
tool = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True, fsync=True)
|
|
command, env, said = self.select(tool, self.env(BL_SYNC_FALLBACK="0"))
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertEqual({name: env.get(name) for name in ("PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC", "WINEFSYNC", "BL_SYNC")},
|
|
{"PROTON_NO_ESYNC": "1", "PROTON_NO_FSYNC": "1", "PROTON_NO_NTSYNC": "1", "WINEESYNC": "0", "WINEFSYNC": "0", "BL_SYNC": "0"})
|
|
env = self.select(tool, self.env(BL_SYNC_FALLBACK="0", PROTON_NO_FSYNC="0"))[1]
|
|
self.assertEqual((env["PROTON_NO_FSYNC"], "WINEFSYNC" in env, env["PROTON_NO_ESYNC"]), ("0", False, "1"))
|
|
self.assertEqual(SYNC["describe"](SYNC["wineserver_only"]({})), "wineserver")
|
|
|
|
def test_the_ntsync_tab_needs_a_wine_with_ntsync(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3", fsync=True, ntsync=False)
|
|
command, env, said = self.select(tool, self.env(BL_SYNC="1"))
|
|
self.assertEqual((command[0], env["BL_SYNC"], env["BL_FSYNC"], env["WINEFSYNC"]), (str(tool / "proton"), "0", "1", "1"))
|
|
self.assertIn("droiddeck-ntsync: Proton 11.0 (ARM64) has no ntsync; trying droiddeck-fsync\n", said)
|
|
plain = make_tool(self.tools, "Proton 10.0 (ARM64)", "1780000000 proton-10.0-1", salt=b"p", ntsync=False)
|
|
command, env, said = self.select(plain, self.env(BL_SYNC="1"))
|
|
self.assertEqual((env["BL_SYNC"], "BL_FSYNC" in env), ("0", False))
|
|
self.assertTrue(said.endswith("; stock binaries (this Wine has no ntsync)\n"), said)
|
|
pack = make_pack(root, plain, "valve-10-r1")
|
|
command, env, said = self.select(plain, self.env(PROTON_NO_ESYNC="1"))
|
|
self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"], env.get("BL_SYNC")), (pack["id"], "0", None))
|
|
|
|
def test_built_in_esync_still_falls_back_when_esync_is_off(self):
|
|
self.store()
|
|
tool = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True, fsync=True)
|
|
for extra in ({"PROTON_NO_ESYNC": "1"}, {"WINEESYNC": "0"}, {"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_FSYNC": "0"}):
|
|
command, env, said = self.select(tool, self.env(**extra))
|
|
self.assertEqual((command[0], env["BL_FSYNC"]), (str(tool / "proton"), "1"), extra)
|
|
plain = make_tool(self.tools, "GE-Proton11-8", "1789900000 GE-Proton11-8", salt=b"g", builtin=True, ge=True)
|
|
for extra in ({"PROTON_NO_ESYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}):
|
|
command, env, said = self.select(plain, self.env(**extra))
|
|
self.assertEqual((env["BL_SYNC"], env["PROTON_NO_ESYNC"], env["WINEESYNC"]), ("1", "1", "0"), extra)
|
|
self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), (extra, said))
|
|
command, env, said = self.select(plain, self.env())
|
|
self.assertEqual(said, "droiddeck-esync: GE-Proton11-8 has esync built in\n")
|
|
|
|
def test_a_stock_fallback_replaces_the_recorded_pack(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
command, env, said = self.select(tool, self.env())
|
|
prefix = os.path.realpath("/compatdata/42/pfx")
|
|
rows = json.loads((root / "prefixes.json").read_text())
|
|
self.assertEqual(rows[prefix]["env"]["BL_SYNC_PACK"], pack["id"])
|
|
stock = [str(tool / "proton"), "waitforexitandrun", "game.exe"]
|
|
self.quiet(SYNC["record"], stock, self.env())
|
|
rows = json.loads((root / "prefixes.json").read_text())
|
|
self.assertEqual((rows[prefix]["command"], rows[prefix]["env"]["BL_SYNC_PACK"]), (stock[0], None))
|
|
|
|
def test_built_in_esync_never_uses_a_pack(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True)
|
|
make_pack(root, tool, "ge-builtin-r1")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertNotIn("WINEESYNC", env)
|
|
self.assertEqual(said, "droiddeck-esync: GE-Proton11-9 has esync built in\n")
|
|
self.assertFalse((root / "wanted.tsv").exists())
|
|
self.assertFalse((root / "dist").exists())
|
|
|
|
def test_droiddeck_proton_has_esync_built_in(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "DroidDeck-Proton 11-7.5", "1791000000 DroidDeck-Proton-11-7.5", builtin=True, ge=True, fsync=True)
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertNotIn("BL_SYNC_PACK", env)
|
|
self.assertEqual(said, "droiddeck-esync: DroidDeck-Proton 11-7.5 has esync built in\n")
|
|
self.assertFalse((root / "wanted.tsv").exists())
|
|
self.assertFalse((root / "dist").exists())
|
|
|
|
def test_wanted_rows_are_deduplicated(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "GE-Proton11-7", GE_LINE, ge=True)
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertEqual(said, "droiddeck-esync: no pack for GE-Proton11-7 (%s, ntdll %s); stock binaries with droiddeck-ntsync\n"
|
|
% (GE_LINE, sha(tool / NTDLL)[:12]))
|
|
self.assertEqual(env["BL_SYNC"], "1")
|
|
wanted = root / "wanted.tsv"
|
|
rows = [line.split("\t") for line in wanted.read_text().splitlines()]
|
|
self.assertEqual(len(rows), 1)
|
|
self.assertEqual(rows[0][:5], [sha(tool / NTDLL), sha(tool / WINESERVER), "GE-Proton11-7",
|
|
SYNC["exports_hash"](tool / NTDLL), str(tool)])
|
|
self.assertTrue(rows[0][5].isdigit())
|
|
inode = os.stat(wanted).st_ino
|
|
self.select(tool, self.env())
|
|
self.assertEqual(os.stat(wanted).st_ino, inode)
|
|
copy = self.tmp / "elsewhere" / "GE-Proton11-7"
|
|
shutil.copytree(tool, copy, symlinks=True)
|
|
self.select(copy, self.env())
|
|
rows_after = [line.split("\t") for line in wanted.read_text().splitlines()]
|
|
self.assertEqual(len(rows_after), 1)
|
|
self.assertEqual((rows_after[0][4], rows_after[0][5]), (str(copy), rows[0][5]))
|
|
other = make_tool(self.tools, "GE-Proton11-8", "1790000001 GE-Proton11-8", salt=b"8", ge=True)
|
|
self.select(other, self.env())
|
|
self.assertEqual(len(wanted.read_text().splitlines()), 2)
|
|
|
|
|
|
class MatchingTest(SyncTestCase):
|
|
def test_exact_beats_source_then_rev_then_id(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3")
|
|
other = {NTDLL: "1" * 64, WINESERVER: "2" * 64}
|
|
find = SYNC["find_pack"]
|
|
exact1 = make_pack(root, tool, "a-exact-r1", rev=1)
|
|
source9 = make_pack(root, tool, "b-source-r9", rev=9, source_match=True, stock=other)
|
|
measured = SYNC["measure"](tool)
|
|
self.assertIs(find([exact1, source9], measured), exact1)
|
|
exact2 = make_pack(root, tool, "c-exact-r2", rev=2)
|
|
self.assertIs(find([exact1, source9, exact2], measured), exact2)
|
|
exact2b = make_pack(root, tool, "d-exact-r2", rev=2)
|
|
self.assertIs(find([exact2b, exact1, exact2, source9], measured), exact2b)
|
|
source3 = make_pack(root, tool, "e-source-r3", rev=3, source_match=True, stock=other)
|
|
self.assertIs(find([source3, source9], measured), source9)
|
|
self.assertIsNone(find([make_pack(root, tool, "f-nosource", rev=5, stock=other)], measured))
|
|
self.assertIsNone(find([make_pack(root, tool, "g-exports", source_match=True, stock=other, exports="3" * 64)], measured))
|
|
self.assertIsNone(find([make_pack(root, tool, "h-version", source_match=True, stock=other, version="proton-11.0-4")], measured))
|
|
self.assertIsNone(find([dict(exact2, revoked=True)], measured))
|
|
loaded = {pack["id"] for pack in SYNC["load_packs"](root)}
|
|
self.assertEqual(loaded, {"a-exact-r1", "b-source-r9", "c-exact-r2", "d-exact-r2", "e-source-r3", "f-nosource",
|
|
"g-exports", "h-version"})
|
|
self.assertEqual(SYNC["find_pack"](SYNC["load_packs"](root), SYNC["measure"](tool))["id"], "d-exact-r2")
|
|
|
|
def test_identical_releases_prefer_the_pack_built_for_the_installed_one(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1787950357 proton-11.0-2b-arm64")
|
|
find = SYNC["find_pack"]
|
|
own = make_pack(root, tool, "valve-proton-11.0-2b-arm64-r1")
|
|
newer = make_pack(root, tool, "valve-proton-11.0-2c-arm64-r1", version="proton-11.0-2c-arm64",
|
|
version_line="1788504814 proton-11.0-2c-arm64")
|
|
older = make_pack(root, tool, "valve-proton-11.0-2a-arm64-r1", version="proton-11.0-2a-arm64",
|
|
version_line="1787000000 proton-11.0-2a-arm64")
|
|
measured = SYNC["measure"](tool)
|
|
self.assertIs(find([newer, own], measured), own)
|
|
self.assertIsNone(find([newer], measured))
|
|
self.assertIsNone(SYNC["match_kind"](newer, measured))
|
|
self.assertIs(find([newer, older], measured), older)
|
|
own2 = make_pack(root, tool, "valve-proton-11.0-2b-arm64-r2", rev=2)
|
|
older2 = make_pack(root, tool, "valve-proton-11.0-2a-arm64-r2", rev=2, version="proton-11.0-2a-arm64",
|
|
version_line="1787000000 proton-11.0-2a-arm64")
|
|
self.assertIs(find([own, older2], measured), own)
|
|
self.assertIs(find([own, older2, own2], measured), own2)
|
|
self.assertEqual(find(SYNC["load_packs"](root), measured)["id"], "valve-proton-11.0-2b-arm64-r2")
|
|
unknown = make_tool(self.tools, "Proton Hotfix", "proton-hotfix", salt=b"hotfix")
|
|
sibling = make_pack(root, unknown, "valve-hotfix-sibling-r1", version="proton-11.0-2c-arm64",
|
|
version_line="1788504814 proton-11.0-2c-arm64")
|
|
self.assertIs(find([sibling], SYNC["measure"](unknown)), sibling)
|
|
|
|
def test_exports_are_measured_only_when_needed(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3")
|
|
exact = make_pack(root, tool, "a-exact-r1")
|
|
measured = SYNC["measure"](tool)
|
|
self.assertEqual(SYNC["find_pack"]([exact], measured), exact)
|
|
self.assertNotIn("exports", measured)
|
|
other = {NTDLL: "1" * 64, WINESERVER: "2" * 64}
|
|
unrelated = make_pack(root, tool, "b-source-other", source_match=True, stock=other, version="proton-10.0-1")
|
|
self.assertIsNone(SYNC["find_pack"]([unrelated], measured))
|
|
self.assertNotIn("exports", measured)
|
|
source = make_pack(root, tool, "c-source", source_match=True, stock=other)
|
|
self.assertEqual(SYNC["find_pack"]([source], measured), source)
|
|
self.assertEqual(measured["exports"], SYNC["exports_hash"](tool / NTDLL))
|
|
|
|
def test_only_complete_valid_packs_load(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton 11.0 (ARM64)")
|
|
make_pack(root, tool, "good")
|
|
make_pack(root, tool, "incomplete", complete=False)
|
|
make_pack(root, tool, "revoked", revoked=True)
|
|
make_pack(root, tool, "badcopy", copy=["../escape"])
|
|
make_pack(root, tool, "badrev", rev="1")
|
|
make_pack(root, tool, "badhash", files={NTDLL: "x", WINESERVER: "y"})
|
|
make_pack(root, tool, "ignored")
|
|
(root / "packs/ignored/.ignored").write_text("")
|
|
make_pack(root, tool, "nofiles")
|
|
(root / "packs/nofiles" / WINESERVER).unlink()
|
|
make_pack(root, tool, "renamed")
|
|
(root / "packs/renamed").rename(root / "packs/other-name")
|
|
make_pack(root, tool, "badformat", format=True)
|
|
make_pack(root, tool, ".hidden")
|
|
(root / "packs/broken").mkdir()
|
|
(root / "packs/broken/.complete").write_text("")
|
|
(root / "packs/broken/pack.json").write_text("{")
|
|
self.assertEqual([pack["id"] for pack in SYNC["load_packs"](root)], ["good"])
|
|
|
|
|
|
class ReconcileTest(SyncTestCase):
|
|
def test_states_tables_and_prebuilt_dists(self):
|
|
root = self.store()
|
|
steam = self.tmp / "steam"
|
|
sd = self.tmp / "sd"
|
|
valve = make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)")
|
|
builtin = make_tool(sd / "steamapps/common", "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3", salt=b"b", builtin=True)
|
|
tools = steam / "compatibilitytools.d"
|
|
ge = make_tool(tools, "GE-Proton11-7", GE_LINE, salt=b"ge", ge=True, decorated=True)
|
|
make_tool(tools, "droiddeck-proton-arm64", salt=b"own")
|
|
make_tool(tools, ".hidden", salt=b"hidden")
|
|
(make_tool(tools, "incomplete", salt=b"inc") / WINESERVER).unlink()
|
|
loose = make_tool(self.tmp / "loose", "GE-Proton10-1", "1700000000 GE-Proton10-1", salt=b"loose")
|
|
gone = make_tool(self.tmp / "gone", "GE-Proton9-1", "1600000000 GE-Proton9-1", salt=b"gone")
|
|
pack = make_pack(root, valve, "valve-experimental-r1")
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
self.select(loose, self.env())
|
|
self.select(gone, self.env())
|
|
shutil.rmtree(gone)
|
|
with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(sd)}):
|
|
summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home)})
|
|
self.assertEqual(summary, "droiddeck-esync: 1 pack(s) in use, 2 wanted")
|
|
self.assertFalse((root / "dist").exists())
|
|
rows = {row[0]: row[1:] for row in (line.split("\t") for line in (root / "tools.tsv").read_text().splitlines())}
|
|
self.assertEqual(rows, {
|
|
str(valve): [VALVE_LINE, sha(valve / NTDLL), sha(valve / WINESERVER), "pack", pack["id"]],
|
|
str(builtin): ["1789000000 proton-11.0-3", sha(builtin / NTDLL), sha(builtin / WINESERVER), "builtin", "-"],
|
|
str(ge): [GE_LINE, sha(ge / NTDLL), sha(ge / WINESERVER), "wanted", "-"],
|
|
})
|
|
wanted = [line.split("\t") for line in (root / "wanted.tsv").read_text().splitlines()]
|
|
self.assertEqual(sorted(row[4] for row in wanted), sorted([str(loose), str(ge)]))
|
|
self.assertTrue((root / "hash-cache.tsv").is_file())
|
|
summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home), "BL_SYNC_FALLBACK": "1"})
|
|
self.assertEqual(summary, "droiddeck-esync: 1 pack(s) in use, 2 wanted")
|
|
dist = self.dist_of(pack["id"], valve)
|
|
self.assertEqual(json.loads((dist / RECORD).read_text())["tool"], str(valve))
|
|
make_pack(root, ge, "ge-GE-Proton11-7-r1", flavor="ge")
|
|
make_pack(root, loose, "ge-GE-Proton10-1-r1", flavor="ge")
|
|
summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home), "BL_SYNC_FALLBACK": "1"})
|
|
self.assertEqual(summary, "droiddeck-esync: 2 pack(s) in use, 0 wanted")
|
|
self.assertEqual((root / "wanted.tsv").read_text(), "")
|
|
self.assertTrue(self.dist_of("ge-GE-Proton11-7-r1", ge).is_dir())
|
|
self.assertIn("ge-GE-Proton11-7-r1", (root / "tools.tsv").read_text())
|
|
|
|
def test_droiddeck_proton_is_scanned_but_our_own_tools_are_not(self):
|
|
steam = self.tmp / "steam"
|
|
tools = steam / "compatibilitytools.d"
|
|
ours = make_tool(tools, "DroidDeck-Proton 11-7.5", "1791000000 DroidDeck-Proton-11-7.5", salt=b"dd", builtin=True, ge=True)
|
|
make_tool(tools, "droiddeck-proton-arm64", salt=b"own")
|
|
make_tool(tools, "droiddeck-proton-11-arm64", salt=b"own11")
|
|
with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}):
|
|
self.assertEqual(SYNC["find_tools"](str(steam)), [str(ours)])
|
|
|
|
def test_steam_compatibility_reports_sync(self):
|
|
steam = self.tmp / "steam"
|
|
make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)")
|
|
env = {"PATH": os.defpath, "HOME": str(self.home), "BL_SYNC_FALLBACK": "1"}
|
|
result = subprocess.run([sys.executable, str(BIN / "steam-compatibility"), str(steam)], env=env,
|
|
capture_output=True, text=True, check=True)
|
|
self.assertNotIn("droiddeck-esync", result.stdout + result.stderr)
|
|
self.assertFalse(self.root.exists())
|
|
root = self.store()
|
|
make_pack(root, steam / "steamapps/common/Proton Experimental (ARM64)", "valve-experimental-r1")
|
|
result = subprocess.run([sys.executable, str(BIN / "steam-compatibility"), str(steam)], env=env,
|
|
capture_output=True, text=True, check=True)
|
|
self.assertIn("steam-compatibility: droiddeck-esync: 1 pack(s) in use, 0 wanted\n", result.stdout)
|
|
self.assertTrue(self.dist_of("valve-experimental-r1", steam / "steamapps/common/Proton Experimental (ARM64)").is_dir())
|
|
|
|
|
|
def test_an_updated_proton_drops_its_pack_until_one_is_built_for_it(self):
|
|
root = self.store()
|
|
steam = self.tmp / "steam"
|
|
tool = make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)")
|
|
pack = make_pack(root, tool, "valve-experimental-20261001-r1")
|
|
dist = self.dist_of(pack["id"], tool)
|
|
self.assertEqual(self.select(tool, self.env())[0][0], str(dist / "proton"))
|
|
(tool / NTDLL).write_bytes(elf(EXPORTS, salt=b"20261008"))
|
|
(tool / WINESERVER).write_bytes(b"wineserver 20261008 /dev/ntsync")
|
|
line = "1791000000 experimental-11.0-20261008-arm64"
|
|
(tool / "version").write_text(line + "\n")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual(command[0], str(tool / "proton"))
|
|
self.assertNotIn(pack["id"], said)
|
|
with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}):
|
|
summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home)})
|
|
self.assertEqual(summary, "droiddeck-esync: 0 pack(s) in use, 1 wanted")
|
|
rows = {row[0]: row[1:] for row in (line.split("\t") for line in (root / "tools.tsv").read_text().splitlines())}
|
|
self.assertEqual(rows, {str(tool): [line, sha(tool / NTDLL), sha(tool / WINESERVER), "wanted", "-"]})
|
|
wanted = [row.split("\t") for row in (root / "wanted.tsv").read_text().splitlines()]
|
|
self.assertEqual([(row[0], row[1], row[2], row[4]) for row in wanted],
|
|
[(sha(tool / NTDLL), sha(tool / WINESERVER), "experimental-11.0-20261008-arm64", str(tool))])
|
|
old = time.time() - 2 * SYNC["GC_GRACE"]
|
|
os.utime(dist, (old, old))
|
|
with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: []}):
|
|
SYNC["gc"](root)
|
|
self.assertFalse(dist.exists())
|
|
new = make_pack(root, tool, "valve-experimental-20261008-r1")
|
|
command, env, said = self.select(tool, self.env())
|
|
self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(self.dist_of(new["id"], tool) / "proton"), new["id"]))
|
|
summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home)})
|
|
self.assertEqual(summary, "droiddeck-esync: 1 pack(s) in use, 0 wanted")
|
|
|
|
|
|
class GcTest(SyncTestCase):
|
|
def test_removes_stale_dists_unless_in_use(self):
|
|
root = self.store()
|
|
keep = make_tool(self.tools, "keep", salt=b"keep")
|
|
uninstalled = make_tool(self.tools, "uninstalled", salt=b"uninstalled")
|
|
updated = make_tool(self.tools, "updated", salt=b"updated")
|
|
running = make_tool(self.tools, "running", salt=b"running")
|
|
recent = make_tool(self.tools, "recent", salt=b"recent")
|
|
dists = {}
|
|
for tool in (keep, uninstalled, updated, running, recent):
|
|
pack = make_pack(root, tool, "pack-" + tool.name)
|
|
dists[tool.name] = Path(SYNC["ensure_dist"](root, tool, pack))
|
|
for name in ("uninstalled", "running", "recent"):
|
|
shutil.rmtree(root / "packs" / ("pack-" + name))
|
|
(updated / NTDLL).write_bytes(elf(EXPORTS, salt=b"new build"))
|
|
old = time.time() - 2 * SYNC["GC_GRACE"]
|
|
for name in ("keep", "uninstalled", "updated", "running"):
|
|
os.utime(dists[name], (old, old))
|
|
stale_temp = root / "dist" / ".pack-x~00000000.tmp-123"
|
|
stale_temp.mkdir()
|
|
os.utime(stale_temp, (time.time() - 2 * SYNC["TEMP_AGE"],) * 2)
|
|
fresh_temp = root / "dist" / ".pack-y~00000000.tmp-456"
|
|
fresh_temp.mkdir()
|
|
(root / "dist" / "ghost~00000000.lock").write_text("")
|
|
cmdline = b"/usr/bin/python3\0" + str(dists["running"] / "proton").encode() + b"\0waitforexitandrun\0"
|
|
with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: None}):
|
|
self.assertEqual(SYNC["gc"](root), 0)
|
|
self.assertTrue(dists["uninstalled"].exists())
|
|
with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: [b"bash\0", cmdline]}):
|
|
self.assertEqual(SYNC["gc"](root), 2)
|
|
remaining = sorted(os.listdir(root / "dist"))
|
|
self.assertEqual(remaining, sorted([dists["keep"].name, dists["keep"].name + ".lock", dists["running"].name,
|
|
dists["running"].name + ".lock", dists["recent"].name, dists["recent"].name + ".lock",
|
|
fresh_temp.name]))
|
|
with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: []}):
|
|
os.utime(dists["recent"], (old, old))
|
|
self.assertEqual(SYNC["gc"](root), 2)
|
|
self.assertEqual(sorted(os.listdir(root / "dist")), sorted([dists["keep"].name, dists["keep"].name + ".lock",
|
|
fresh_temp.name]))
|
|
|
|
def test_superseded_pack_revisions_are_collected(self):
|
|
root = self.store()
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
first = make_pack(root, tool, "valve-experimental-r1", rev=1)
|
|
first_dist = Path(SYNC["ensure_dist"](root, tool, first))
|
|
second = make_pack(root, tool, "valve-experimental-r2", rev=2)
|
|
second_dist = Path(SYNC["ensure_dist"](root, tool, second))
|
|
self.assertNotEqual(first_dist, second_dist)
|
|
old = time.time() - 2 * SYNC["GC_GRACE"]
|
|
for dist in (first_dist, second_dist):
|
|
os.utime(dist, (old, old))
|
|
with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: [str(first_dist / "proton").encode() + b"\0run\0"]}):
|
|
self.assertEqual(SYNC["gc"](root), 0)
|
|
with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: []}):
|
|
self.assertEqual(SYNC["gc"](root), 1)
|
|
self.assertEqual(sorted(os.listdir(root / "dist")), [second_dist.name, second_dist.name + ".lock"])
|
|
self.assertTrue((root / "packs" / first["id"] / ".complete").is_file())
|
|
_, env, _ = self.select(tool, self.env())
|
|
self.assertEqual(env["BL_SYNC_PACK"], second["id"])
|
|
|
|
|
|
class LauncherTest(SyncTestCase):
|
|
def test_game_env_applies_sync_keys_for_every_verb(self):
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
config = self.home / ".config/droiddeck/game-environment.json"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text(json.dumps({"version": 1, "shared": {"PROTON_NO_NTSYNC": "1", "CUSTOM": "shared"},
|
|
"games": {"42": {"WINEESYNC": "3", "BL_SYNC_FALLBACK": "1"}}}))
|
|
env = {"PATH": os.defpath, "HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "BL_SYNC": "1",
|
|
"BL_SYNC_MIN_NOFILE": "1"}
|
|
|
|
def launch(verb):
|
|
output = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(tool / "proton"), verb, "a b"],
|
|
env=env, capture_output=True, text=True, check=True).stdout
|
|
return json.loads(output)
|
|
|
|
argv0, args, seen = launch("run")
|
|
self.assertEqual((argv0, args), (str(tool / "proton"), ["run", "a b"]))
|
|
self.assertEqual((seen["CUSTOM"], seen["BL_SYNC"], seen["WINEESYNC"]), (None, "0", "3"))
|
|
self.assertEqual(launch("waitforexitandrun")[2]["CUSTOM"], "shared")
|
|
root = self.store()
|
|
pack = make_pack(root, tool, "valve-experimental-r1")
|
|
dist = self.dist_of(pack["id"], tool)
|
|
for verb in ("run", "waitforexitandrun", "getcompatpath"):
|
|
argv0, args, seen = launch(verb)
|
|
self.assertEqual((argv0, args), (str(dist / "proton"), [verb, "a b"]))
|
|
self.assertEqual((seen["WINEESYNC"], seen["BL_SYNC_PACK"], seen["WINESERVER"]), ("3", pack["id"], str(dist / WINESERVER)))
|
|
|
|
def test_failed_dist_exec_keeps_the_sync_choice(self):
|
|
tool = make_tool(self.tools, "Proton Experimental (ARM64)")
|
|
pack = make_pack(self.store(), tool, "valve-experimental-r1")
|
|
dist = self.dist_of(pack["id"], tool)
|
|
game_env = runpy.run_path(str(BIN / "droiddeck-game-env"))
|
|
command = [str(tool / "proton"), "waitforexitandrun", "game.exe"]
|
|
env = self.env(STEAM_COMPAT_APP_ID="2630", PROTON_NO_NTSYNC="1", BL_SYNC="1", WINEESYNC="1")
|
|
calls = []
|
|
|
|
def execvpe(path, args, environment):
|
|
calls.append((path, list(args), dict(environment)))
|
|
if len(calls) == 1:
|
|
raise PermissionError("denied")
|
|
raise SystemExit(0)
|
|
|
|
with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, "argv", ["droiddeck-game-env"] + command), \
|
|
mock.patch("os.execvpe", execvpe), contextlib.redirect_stderr(io.StringIO()) as said:
|
|
with self.assertRaises(SystemExit):
|
|
game_env["main"]()
|
|
self.assertEqual([call[:2] for call in calls], [(str(dist / "proton"), [str(dist / "proton")] + command[1:]),
|
|
(str(tool / "proton"), command)])
|
|
self.assertEqual((calls[0][2]["WINEESYNC"], calls[0][2]["PROTON_NO_ESYNC"], calls[0][2]["BL_SYNC_PACK"]), ("0", "1", pack["id"]))
|
|
self.assertEqual(calls[1][2], self.env(STEAM_COMPAT_APP_ID="2630", PROTON_NO_NTSYNC="1", BL_SYNC="0", WINEESYNC="1",
|
|
vblank_mode="0", GAMESCOPE_WSI_MIN_IMAGE_COUNT="8",
|
|
GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT="1"))
|
|
self.assertIn("could not start (", said.getvalue())
|
|
|
|
def test_generated_launchers_run_the_dist_proton(self):
|
|
root = self.store()
|
|
steam = self.tmp / "Steam"
|
|
tools = steam / "compatibilitytools.d"
|
|
depot = make_tool(steam / "steamapps/common", COMPAT["SOURCES"][0])
|
|
extra = make_tool(tools, "GE-Proton11-7", GE_LINE, salt=b"ge", ge=True)
|
|
(extra / "toolmanifest.vdf").write_text(MANIFEST)
|
|
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(depot))
|
|
COMPAT["adopt_extras"](str(tools))
|
|
packs = {depot: make_pack(root, depot, "valve-experimental-r1"), extra: make_pack(root, extra, "ge-GE-Proton11-7-r1", flavor="ge")}
|
|
wrappers = {depot: tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra: extra / COMPAT["EXTRA_WRAPPER"]}
|
|
env = {"PATH": os.defpath, "HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42",
|
|
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam), "BL_SYNC_FALLBACK": "1", "BL_SYNC_MIN_NOFILE": "1"}
|
|
for tool, wrapper in wrappers.items():
|
|
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/droiddeck-game-env", str(BIN / "droiddeck-game-env")))
|
|
dist = self.dist_of(packs[tool]["id"], tool)
|
|
result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"], env=env, text=True, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
argv0, args, seen = json.loads(result.stdout)
|
|
self.assertEqual((argv0, args), (str(dist / "proton"), ["waitforexitandrun", "game with spaces.exe"]))
|
|
self.assertEqual((seen["BL_SYNC_PACK"], seen["WINESERVER"], seen["WINEESYNC"]),
|
|
(packs[tool]["id"], str(dist / WINESERVER), "1"))
|
|
self.assertIn("droiddeck-esync: pack %s" % packs[tool]["id"], result.stderr)
|
|
for name in ("droiddeck-proton-wrap", "toolmanifest.vdf", "compatibilitytool.vdf", "toolmanifest.vdf.droiddeck-orig",
|
|
"bannerlator-proton-wrap", "toolmanifest.vdf.bannerlator-orig"):
|
|
self.assertFalse(os.path.lexists(dist / name), name)
|
|
result = subprocess.run([str(wrapper), "waitforexitandrun"], env={**env, "BL_SYNC_FALLBACK": "0"}, text=True, capture_output=True)
|
|
self.assertEqual(json.loads(result.stdout)[0], str(tool / "proton"), result.stderr)
|
|
|
|
def test_session_collects_sync_tables(self):
|
|
script = (BIN / "droiddeck-session").read_text()
|
|
start = script.index("collect_steam_logs() {")
|
|
function = script[start:script.index("\n}\n", start) + 3]
|
|
debug = self.tmp / "debug"
|
|
env = {"PATH": os.defpath, "HOME": str(self.home), "BL_DEBUG_DIR": str(debug)}
|
|
subprocess.run(["bash", "-c", "set -u\n" + function + "collect_steam_logs"], env=env, check=True, capture_output=True)
|
|
self.assertFalse((debug / "droiddeck-esync").exists())
|
|
root = self.store()
|
|
(root / "launches.log").write_text("launch\n")
|
|
(root / "tools.tsv").write_text("tool\n")
|
|
(root / "hash-cache.tsv").write_text("cache\n")
|
|
subprocess.run(["bash", "-c", "set -u\n" + function + "collect_steam_logs"], env=env, check=True, capture_output=True)
|
|
self.assertEqual(sorted(os.listdir(debug / "droiddeck-esync")), ["launches.log", "tools.tsv"])
|
|
|
|
|
|
class BundleTest(unittest.TestCase):
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
def release_env(self):
|
|
values = {}
|
|
for line in (self.ROOT / "tools/droiddeck-esync/release.env").read_text().splitlines():
|
|
key, _, value = line.partition("=")
|
|
values[key] = value
|
|
return values
|
|
|
|
def test_release_env_pins_a_components_bundle(self):
|
|
env = self.release_env()
|
|
self.assertEqual(set(env), {"SYNC_BUNDLE_REPO", "SYNC_BUNDLE_TAG", "SYNC_BUNDLE_ASSET", "SYNC_BUNDLE_SHA256"})
|
|
self.assertEqual(env["SYNC_BUNDLE_REPO"], "Droid-Deck/DroidDeck-Components")
|
|
self.assertEqual(env["SYNC_BUNDLE_TAG"], "droiddeck-esync-index")
|
|
self.assertRegex(env["SYNC_BUNDLE_ASSET"], r"^bundle-[0-9]{8}-[0-9]{6}\.tzst$")
|
|
self.assertRegex(env["SYNC_BUNDLE_SHA256"], r"^[0-9a-f]{64}$")
|
|
packs = (self.ROOT / "app/src/main/java/com/droiddeck/launcher/session/EsyncPacks.kt").read_text()
|
|
self.assertIn(f'const val REPO = "{env["SYNC_BUNDLE_REPO"]}"', packs)
|
|
self.assertIn('releases/download/{}/index.json"'.format(env["SYNC_BUNDLE_TAG"]), packs)
|
|
|
|
def test_builds_fetch_the_pinned_bundle(self):
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
self.skipTest("PyYAML is not installed")
|
|
apk = yaml.safe_load((self.ROOT / ".github/workflows/build.yml").read_text())
|
|
steps = {step.get("name"): step for step in apk["jobs"]["components"]["steps"]}
|
|
import json
|
|
recipes = json.loads((self.ROOT / "tools/components/recipes.json").read_text())
|
|
bundle = dict(steps["Bundle the droiddeck-esync packs"])
|
|
self.assertIn("prepare.py esync", bundle["run"])
|
|
bundle["run"] = (self.ROOT / recipes["esync"]["script"]).read_text()
|
|
self.assertNotIn("if", bundle)
|
|
self.assertIn("tools/droiddeck-esync/release.env", bundle["run"])
|
|
self.assertIn('release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"', bundle["run"])
|
|
self.assertIn('curl -fsSL --retry 5 --retry-all-errors --connect-timeout 20 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"', bundle["run"])
|
|
self.assertIn('curl -fsSL --retry 5 --retry-all-errors --connect-timeout 20 -o sync-index.json "$release_url/index.json"', bundle["run"])
|
|
self.assertIn("sha256sum -c -", bundle["run"])
|
|
self.assertNotIn("revoked.txt", bundle["run"])
|
|
stage = {"run": (self.ROOT / recipes["runtime"]["script"]).read_text()}
|
|
self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", stage["run"])
|
|
local = (self.ROOT / "tools/build_local.sh").read_text()
|
|
self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", local)
|
|
self.assertIn('"${SYNC_BUNDLE_REPO}"', local)
|
|
self.assertNotIn("revoked.txt", local)
|
|
self.assertFalse((self.ROOT / ".github/workflows/build-droiddeck-esync-packs.yml").exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|