From 13c8edb3d58ad8d10ed111982e07d07fba3f2f53 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 10 Nov 2026 06:24:03 -0500 Subject: [PATCH] fix(game-env): repair foreign-architecture system DLLs in ARM64 prefixes (#2318) Co-authored-by: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> --- .../launcher/session/SessionFiles.kt | 1 + .../overlay/usr/local/bin/droiddeck-game-env | 122 ++++++++-- .../usr/local/bin/droiddeck-wincomponents | 45 +++- .../overlay/usr/local/bin/droiddeck-wine-pe | 94 ++++++++ tools/tests/test_game_environment.py | 209 ++++++++++++++++++ tools/tests/test_wincomponents.py | 132 ++++++++++- 6 files changed, 579 insertions(+), 24 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index 3cefd44c0..f3b8478a2 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -43,6 +43,7 @@ object SessionFiles { "usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu", "usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory", "usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents", + "usr/local/bin/droiddeck-wine-pe" to "usr/local/bin/droiddeck-wine-pe", "usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install", "usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync", "usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat", diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index a2e130b5a..5ed1f7f11 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -333,12 +333,12 @@ def engine_fixes(exe, args): return env, extra, notes -def win_components(compat, app_id): +def win_components(compat, app_id, proton=None, env=None): if not compat: return "" try: module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents")) - names, notes = module["apply"](compat.rstrip("/"), app_id) + names, notes = module["apply"](compat.rstrip("/"), app_id, proton=proton, env=env or os.environ) for note in notes: print("droiddeck-game-env: " + note, file=sys.stderr) return module["overrides_value"](names) @@ -859,6 +859,60 @@ WINE_DLL_DIRS = ("/lib/wine/i386-windows", "/lib/wine/x86_64-windows", "/lib/win "/lib/wine/fakedlls", "/lib64/wine", "/lib64/wine/fakedlls", "/lib64/wine/x86_64-windows") +FOREIGN_SUFFIX = ".foreign-arch" +PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe")) +pe_machine = PE["pe_machine"] + + +def repair_foreign_system_dlls(prefix, proton, env=None): + dist = Path(proton).parent / "files/lib/wine" + windows = Path(prefix) / "pfx/drive_c/windows" + machines_by_folder = PE["proton_pe_machines"](proton, env or {}) + if machines_by_folder is None: + return [] + repaired = [] + for folder, (libdir, machines) in machines_by_folder.items(): + builtins = dist / libdir + try: + builtin_names = {name.lower(): name for name in os.listdir(builtins)} + except OSError: + continue + try: + names = sorted(os.listdir(windows / folder)) + except OSError: + continue + for name in names: + path = windows / folder / name + builtin_name = builtin_names.get(name.lower()) + source = builtins / builtin_name if builtin_name else builtins / name + if path.is_symlink() or not name.lower().endswith(".dll") or not source.is_file() or not path.is_file(): + continue + machine = pe_machine(path) + if machine is None or PE["machine_compatible"](path, machines, machine): + continue + link = windows / folder / (name + ".bl-new") + backup = windows / folder / (name + FOREIGN_SUFFIX) + serial = 1 + while os.path.lexists(backup): + backup = windows / folder / (name + FOREIGN_SUFFIX + ".%d" % serial) + serial += 1 + try: + os.symlink(os.path.relpath(source, path.parent), link) + os.replace(path, backup) + try: + os.replace(link, path) + except OSError: + if not os.path.lexists(path) and os.path.lexists(backup): + os.replace(backup, path) + raise + except OSError: + if link.is_symlink(): + link.unlink() + continue + repaired.append("%s/%s (machine 0x%x)" % (folder, name, machine)) + return repaired + + def is_wine_dll_link(path): try: return os.path.islink(path) and os.path.dirname(os.readlink(path)).endswith(WINE_DLL_DIRS) @@ -1156,6 +1210,42 @@ def main(): env = game_gpu(os.environ.copy()) mic_enabled = env.get("BL_MIC_ENABLED") debug_dir = env.get("BL_DEBUG_DIR", "") + prefix_path = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") + app_id = game_id(env) + components = "" + waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun") + config = {} + launch_config = None + launch_config_valid = True + effective_env = env + if waiting: + path = Path.home() / ".config/droiddeck/game-environment.json" + try: + with path.open(encoding="utf-8") as source: + config = json.load(source) + launch_config = config + except FileNotFoundError: + launch_config = {"version": 1} + except (OSError, ValueError, TypeError, AttributeError): + launch_config_valid = False + if launch_config_valid: + try: + effective_env = apply_config(env, launch_config, app_id) + except (OSError, ValueError, TypeError, AttributeError): + launch_config_valid = False + if not launch_config_valid: + print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) + if waiting: + components = win_components(prefix_path, app_id, command[0], effective_env) + if prefix_path and command: + try: + fixed = repair_foreign_system_dlls(prefix_path, command[0], effective_env) + except OSError as error: + fixed = [] + print("droiddeck-game-env: prefix system DLL check skipped (%s)" % error, file=sys.stderr) + if fixed: + print("droiddeck-game-env: replaced foreign-architecture system DLLs with this Proton's own: %s" % ", ".join(fixed), + file=sys.stderr) scripts = mark_install_scripts(command, env) if scripts and install_scripts_done(scripts, env): print("droiddeck-game-env: install scripts already run in this prefix; evaluator skipped", file=sys.stderr) @@ -1164,8 +1254,6 @@ def main(): digest = scripts_digest(scripts) if scripts else None except OSError: digest = None - app_id = game_id(env) - waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun") diagnostic_app_id = app_id if waiting and os.environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") == "1" else None dirt_prefix = None if app_id: @@ -1183,7 +1271,6 @@ def main(): for note in notes: print("droiddeck-game-env: " + note, file=sys.stderr) command += extra - components = win_components(env.get("STEAM_COMPAT_DATA_PATH", ""), app_id) if components: own = fixes.get("WINEDLLOVERRIDES") fixes["WINEDLLOVERRIDES"] = components + ";" + own if own else components @@ -1191,22 +1278,21 @@ def main(): if dirt_prefix: own = fixes.get("WINEDLLOVERRIDES") fixes["WINEDLLOVERRIDES"] = own + ";ws2_32=n,b" if own else "ws2_32=n,b" - path = Path.home() / ".config/droiddeck/game-environment.json" - config = {} - try: - with path.open(encoding="utf-8") as source: - config = json.load(source) - if waiting: + if waiting: + if launch_config_valid: if "vblank_mode" in config.get("shared", {}): fixes.pop("vblank_mode", None) - env = apply_config(env, config, app_id, fixes) - else: + env = apply_config(env, launch_config, app_id, fixes) + else: + path = Path.home() / ".config/droiddeck/game-environment.json" + try: + with path.open(encoding="utf-8") as source: + config = json.load(source) env = apply_config(env, sync_config(config, app_id), app_id) - except FileNotFoundError: - if waiting: - env = apply_config(env, {"version": 1}, app_id, fixes) - except (OSError, ValueError, TypeError, AttributeError): - print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) + except FileNotFoundError: + pass + except (OSError, ValueError, TypeError, AttributeError): + print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) if waiting: before_debug = env env = apply_game_debug_logs(env, config, debug_dir) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents index 393f97102..dde2d6e6b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wincomponents @@ -2,11 +2,17 @@ import json import os import re +import runpy import shutil import sys import time from pathlib import Path +PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe")) +PE_MACHINES = PE["PE_MACHINES"] +pe_machine = PE["pe_machine"] +machine_compatible = PE["machine_compatible"] + STORE = Path("/opt/droiddeck/wincomponents") SELECTION = Path.home() / ".config/droiddeck/wincomponents.json" PENDING = Path.home() / ".config/droiddeck/wincomponents-pending.json" @@ -473,6 +479,15 @@ def save_state(compat, state): os.replace(temp, compat / STATE) +def foreign_component(rel, path, machines): + parts = Path(rel).parts + folder = parts[2] if len(parts) > 2 and parts[:2] == ("drive_c", "windows") else None + if not machines or folder not in PE_MACHINES or not rel.lower().endswith(".dll"): + return False + machine = pe_machine(path) + return machine is not None and not machine_compatible(path, machines[folder][1], machine) + + def pending_for(app_id, pending=PENDING): try: with open(pending, encoding="utf-8") as source: @@ -514,7 +529,8 @@ def wait_for_pending(app_id, pending=PENDING, store=STORE, timeout=PENDING_WAIT, return "wincomp: %s waited %dms for %s" % (app_id, waited, ",".join(asked)) -def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending): +def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending, + proton=None, env=None): compat = Path(compat_path) pfx = compat / "pfx" waited = wait(app_id, pending, store) if wait else None @@ -527,6 +543,9 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING installed = [] values = [] held = set() + skipped = set() + placed_stems = set() + machines = PE["proton_pe_machines"](proton, env) if proton else None saved_overrides = state.get("overrides") if isinstance(state.get("overrides"), dict) else {} saved_requires = state.get("requires") if isinstance(state.get("requires"), dict) else {} recorded = [c for c in state.get("components", []) if isinstance(c, str) and valid_id(c)] @@ -566,9 +585,17 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING kept_requires[cid] = list(requires) values += [(cid, value) for value in registry] for rel, src in files: - if place(src, pfx / rel, None if rel in state["files"] else compat / BACKUP / rel): + foreign = foreign_component(rel, src, machines) + if foreign: + skipped.add(Path(rel).stem.lower()) + changed = False + else: + changed = place(src, pfx / rel, None if rel in state["files"] else compat / BACKUP / rel) + placed_stems.add(Path(rel).stem.lower()) + if changed: copied += 1 - keep[rel] = cid + if not foreign: + keep[rel] = cid removed = restored = 0 for rel, cid in state["files"].items(): if rel in keep: @@ -579,6 +606,10 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING path = existing(pfx / rel) saved = compat / BACKUP / rel if saved.is_file(): + if foreign_component(rel, saved, machines): + if path is not None and not path.is_symlink(): + path.unlink() + continue target = path or (pfx / rel) os.replace(saved, target) restored += 1 @@ -590,6 +621,14 @@ def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING (compat / "config_info").unlink() except FileNotFoundError: pass + def override_name(entry): + name = entry.partition("=")[0].strip().lower() + return name[:-4] if name.endswith(".dll") else name + + dropped = skipped - placed_stems + overrides = [entry for entry in overrides if override_name(entry) not in dropped] + for cid in kept_overrides: + kept_overrides[cid] = [entry for entry in kept_overrides[cid] if override_name(entry) not in dropped] compat.mkdir(parents=True, exist_ok=True) new_state = {"files": keep, "components": installed + sorted(held), "overrides": kept_overrides, "requires": kept_requires, "registry": state.get("registry", {})} diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe new file mode 100755 index 000000000..7034bf301 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-wine-pe @@ -0,0 +1,94 @@ +#!/usr/bin/python3 +from pathlib import Path + + +PE_MACHINES = { + "system32": ("aarch64-windows", frozenset((0xAA64, 0xA641))), + "syswow64": ("i386-windows", frozenset((0x14C,))), +} + + +def pe_machine(path): + try: + with open(path, "rb") as image: + head = image.read(64) + if head[:2] != b"MZ" or len(head) < 64: + return None + image.seek(int.from_bytes(head[60:64], "little")) + header = image.read(6) + except OSError: + return None + if header[:4] != b"PE\0\0" or len(header) < 6: + return None + return int.from_bytes(header[4:6], "little") + + +def pe_has_arm64ec_metadata(path): + try: + with open(path, "rb") as image: + image.seek(60) + raw = image.read(4) + if len(raw) != 4: + return False + pe_offset = int.from_bytes(raw, "little") + image.seek(pe_offset) + if image.read(4) != b"PE\0\0": + return False + coff = image.read(20) + if len(coff) != 20: + return False + section_count = int.from_bytes(coff[2:4], "little") + if section_count > 96: + return False + optional_size = int.from_bytes(coff[16:18], "little") + optional_offset = pe_offset + 24 + image.seek(optional_offset) + optional = image.read(optional_size) + if len(optional) != optional_size or int.from_bytes(optional[:2], "little") != 0x20B: + return False + directory_count = int.from_bytes(optional[108:112], "little") + directory_offset = 112 + 10 * 8 + if directory_count <= 10 or directory_offset + 8 > len(optional): + return False + load_config_rva = int.from_bytes(optional[directory_offset:directory_offset + 4], "little") + load_config_size = int.from_bytes(optional[directory_offset + 4:directory_offset + 8], "little") + if not load_config_rva or load_config_size < 0xD0: + return False + section_offset = optional_offset + optional_size + for index in range(section_count): + image.seek(section_offset + index * 40) + section = image.read(40) + if len(section) != 40: + return False + virtual_address = int.from_bytes(section[12:16], "little") + raw_size = int.from_bytes(section[16:20], "little") + raw_offset = int.from_bytes(section[20:24], "little") + relative = load_config_rva - virtual_address + if 0 <= relative <= raw_size - 0xD0: + image.seek(raw_offset + relative) + load_config = image.read(0xD0) + if len(load_config) != 0xD0 or int.from_bytes(load_config[:4], "little") < 0xD0: + return False + return int.from_bytes(load_config[0xC8:0xD0], "little") != 0 + except (OSError, OverflowError, ValueError): + return False + return False + + +def machine_compatible(path, machines, machine=None): + machine = pe_machine(path) if machine is None else machine + if machine == 0x8664 and pe_has_arm64ec_metadata(path): + return 0xA641 in machines + return machine in machines + + +def proton_pe_machines(proton, env=None): + dist = Path(proton).parent / "files/lib/wine" + if (dist / "aarch64-unix").is_dir() and (env or {}).get("PROTON_USE_ARM64", "1") == "1": + return PE_MACHINES + if (dist / "x86_64-unix").is_dir(): + return { + "system32": ("x86_64-windows", frozenset((0x8664,))), + "syswow64": PE_MACHINES["syswow64"], + } + return None diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 48d08a0b5..ce902817a 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -708,6 +708,54 @@ class ReadConfigMappingTest(unittest.TestCase): 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')): @@ -2585,6 +2633,167 @@ class UbisoftCompositionTest(unittest.TestCase): 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_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() diff --git a/tools/tests/test_wincomponents.py b/tools/tests/test_wincomponents.py index 51c555b9b..e6ad95e2d 100644 --- a/tools/tests/test_wincomponents.py +++ b/tools/tests/test_wincomponents.py @@ -35,8 +35,134 @@ class WinComponentsTest(unittest.TestCase): def pick(self, *ids): self.selection.write_text(json.dumps({"version": 1, "games": {"2571976725": list(ids)}})) - def apply(self): - return MODULE["apply"](self.compat, "2571976725", self.selection, self.store) + def apply(self, proton=None, env=None): + return MODULE["apply"](self.compat, "2571976725", self.selection, self.store, proton=proton, env=env) + + def proton(self, arm=True): + root = Path(self.tmp.name) / ("arm-proton" if arm else "x64-proton") + (root / "files/lib/wine" / ("aarch64-unix" if arm else "x86_64-unix")).mkdir(parents=True) + return root / "proton" + + def pe_image(self, 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(self): + 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) + + def test_foreign_component_dll_is_skipped_and_override_dropped(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, []) + self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists()) + names, notes = self.apply(proton) + self.assertEqual(names, []) + self.assertEqual(notes, []) + + def test_component_final_arm64ec_dll_is_placed_and_keeps_its_override(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.arm64ec_pe_image()}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664) + + def test_compatible_copy_keeps_override_when_other_architecture_copy_is_skipped(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], { + "system32/ucrtbase.dll": self.pe_image(0x8664), + "syswow64/ucrtbase.dll": self.pe_image(0x14C), + }) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists()) + self.assertEqual(MODULE["pe_machine"](self.pfx / "syswow64/ucrtbase.dll"), 0x14C) + + def test_existing_foreign_component_dll_is_removed_for_repair(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + self.pfx.joinpath("system32").mkdir(parents=True) + self.pfx.joinpath("system32/ucrtbase.dll").write_bytes(self.pe_image(0x8664)) + self.compat.joinpath(".droiddeck-wincomponents.json").write_text(json.dumps({ + "files": {"drive_c/windows/system32/ucrtbase.dll": "vc"}, "components": ["vc"]})) + self.apply(proton) + self.assertFalse((self.pfx / "system32/ucrtbase.dll").exists()) + self.assertEqual([], GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton))) + self.assertEqual([], GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton))) + + def test_foreign_component_restore_does_not_override_repair_with_native_override(self): + proton = self.proton() + builtin = Path(self.tmp.name) / "arm-proton/files/lib/wine/aarch64-windows/ucrtbase.dll" + builtin.parent.mkdir(parents=True, exist_ok=True) + builtin.write_bytes(self.pe_image(0xAA64)) + system32 = self.pfx / "system32" + system32.mkdir(parents=True) + dll = system32 / "ucrtbase.dll" + dll.write_bytes(self.pe_image(0x8664)) + backup = self.compat / ".droiddeck-wincomponents-backup/drive_c/windows/system32/ucrtbase.dll" + backup.parent.mkdir(parents=True) + backup.write_bytes(self.pe_image(0x8664)) + self.compat.joinpath(".droiddeck-wincomponents.json").write_text(json.dumps({ + "files": {"drive_c/windows/system32/ucrtbase.dll": "vc"}, "components": ["vc"]})) + self.compat.joinpath("pfx/user.reg").parent.mkdir(parents=True, exist_ok=True) + self.compat.joinpath("pfx/user.reg").write_text( + 'WINE REGISTRY Version 2\n\n[Software\\\\Wine\\\\DllOverrides] 1\n"ucrtbase"="native,builtin"\n') + fixed = GAME_ENV["repair_foreign_system_dlls"](str(self.compat), str(proton)) + self.assertEqual(["system32/ucrtbase.dll (machine 0x8664)"], fixed) + self.apply(proton) + self.assertTrue(dll.is_symlink()) + self.assertEqual(0xAA64, MODULE["pe_machine"](dll)) + + def test_matching_component_dll_is_placed(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0xAA64)}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0xAA64) + + def test_x64_proton_places_x64_component_dll(self): + proton = self.proton(arm=False) + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + names, _ = self.apply(proton) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664) + + def test_arm_proton_with_x64_selection_places_x64_component_dll(self): + proton = self.proton() + self.component("vc", ["ucrtbase"], {"system32/ucrtbase.dll": self.pe_image(0x8664)}) + self.pick("vc") + names, _ = self.apply(proton, {"PROTON_USE_ARM64": "0"}) + self.assertEqual(names, ["ucrtbase"]) + self.assertEqual(MODULE["pe_machine"](self.pfx / "system32/ucrtbase.dll"), 0x8664) def test_places_files_before_the_prefix_exists_and_returns_overrides(self): self.component("openal", ["openal32"], {"syswow64/OpenAL32.dll": b"x86", "system32/OpenAL32.dll": b"x64"}) @@ -235,7 +361,7 @@ class WinComponentsTest(unittest.TestCase): self.pick("openal") globals_ = GAME_ENV["win_components"].__globals__ real = runpy.run_path(str(BIN / "droiddeck-wincomponents")) - real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None) + real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None, None, None) with mock.patch.dict(globals_, {"runpy": mock.Mock(run_path=lambda _path: real)}): self.assertEqual(GAME_ENV["win_components"](str(self.compat) + "/", "2571976725"), "openal32=n,b") self.assertEqual(GAME_ENV["win_components"]("", "2571976725"), "")