"""Portable game runtime selection and Proton/UMU preparation.""" from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime import hashlib import json import os from pathlib import Path, PurePosixPath import platform import re import shlex import shutil import sys from typing import Mapping, Sequence from .command import CommandOutcome, CommandRunner, OutputLineFilter from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption from .gamebox import build_gamebox_command from .paths import AppPaths from .settings import Settings from .umu import find_umu_launcher WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU" WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe" NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43") DXVK_D3D9_OVERRIDE = "native,builtin" DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides" WINETRICKS_CODEPAGE_PROBE = ( 'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"' ) WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"' RUNTIME_DEFAULT_SYNC = "runtime-default" CACHYOS_MARKER = "cachyos" CURRENT_UMU_DISTRIBUTIONS = frozenset( {"arch", "cachyos", "endeavouros", "garuda", "manjaro"} ) GAME_RUNTIME_CONFIGURATION_VERSION = 1 PROTON7_RELATIVE_PATH = Path("steamapps/common/Proton 7.0/proton") STEAM_LIBRARY_FOLDERS_RELATIVE_PATH = Path("steamapps/libraryfolders.vdf") STEAM_LIBRARY_FOLDERS_MAX_BYTES = 4 * 1024 * 1024 _VDF_QUOTED_PAIR = re.compile( r'"((?:\\.|[^"\\])*)"\s*"((?:\\.|[^"\\])*)"' ) class RuntimeErrorPT(RuntimeError): """Raised when a supported game runtime cannot be prepared.""" @dataclass(frozen=True) class RuntimeSelection: kind: str executable: str proton_path: str | None steam_root: str | None def select_runtime( settings: Settings, environment: Mapping[str, str] | None = None, *, paths: AppPaths | None = None, distribution_id: str | None = None, kernel_release: str | None = None, ) -> RuntimeSelection: env = os.environ if environment is None else environment umu = find_umu_launcher(paths, env) current_umu_host = prefers_current_umu_host( distribution_id=distribution_id, kernel_release=kernel_release, ) auto_container = ( settings.runtime == "auto" and bool(umu) and not current_umu_host ) if settings.runtime == "umu": if umu: return RuntimeSelection("umu", umu, None, None) raise RuntimeErrorPT("UMU was requested but umu-run was not found") if settings.proton_path and settings.runtime in { "auto", "proton7", "proton7-container", }: proton = Path(settings.proton_path).expanduser().resolve() if not proton.is_file(): raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}") steam_root = _steam_root_for_proton(proton, env) if settings.runtime == "proton7-container": if not umu: raise RuntimeErrorPT( "containerized Proton 7 was requested but umu-run was not found" ) return RuntimeSelection( "proton7-container", umu, str(proton), str(steam_root), ) if auto_container: assert umu is not None return RuntimeSelection( "proton7-container", umu, str(proton), str(steam_root), ) return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root)) # Current UMU avoids several rolling-distribution container/media failures and # remains the broad Arch-family compatibility default. Direct Proton 7 is also # known to authenticate on CachyOS, so this is a compatibility policy rather # than an assertion that Proton 7 itself causes server-side Code 1/Code 3. if settings.runtime == "auto" and current_umu_host and umu: return RuntimeSelection("umu", umu, None, None) if settings.runtime in {"auto", "proton7", "proton7-container"}: proton_result = find_steam_proton7(env) if proton_result is not None: proton, steam_root = proton_result if settings.runtime == "proton7-container" or auto_container: if not umu: raise RuntimeErrorPT( "containerized Proton 7 was requested but umu-run was not found" ) return RuntimeSelection( "proton7-container", umu, str(proton), str(steam_root), ) return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root)) if settings.runtime in {"proton7", "proton7-container"}: raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found") if settings.runtime in {"auto", "umu"}: if umu: return RuntimeSelection("umu", umu, None, None) raise RuntimeErrorPT( "no supported game runtime found; install umu-launcher or Steam Proton 7.0" ) raise RuntimeErrorPT(f"unsupported runtime selection: {settings.runtime}") def find_steam_proton7( environment: Mapping[str, str] | None = None, ) -> tuple[Path, Path] | None: """Find Proton 7 in a Steam client root or one of its registered libraries. The returned Steam root is always the client installation, even when the Proton tool itself lives in a secondary library. Proton 7 uses that client root for Steam's bundled media compatibility libraries. """ env = os.environ if environment is None else environment for steam_root in _steam_client_roots(env): for library_root in _steam_library_roots(steam_root): proton = library_root / PROTON7_RELATIVE_PATH if proton.is_file(): return proton.resolve(), steam_root return None def _steam_client_roots(environment: Mapping[str, str]) -> tuple[Path, ...]: candidates: list[Path] = [] explicit = environment.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") if explicit: candidates.append(Path(explicit).expanduser()) home_value = environment.get("HOME") if home_value: home = Path(home_value).expanduser() candidates.extend( ( home / ".local/share/Steam", home / ".steam/steam", home / ".steam/root", home / ".steam/debian-installation", home / "snap/steam/common/.local/share/Steam", home / "snap/steam/common/.steam/steam", home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", ) ) return _unique_resolved_paths(candidates) def _steam_library_roots(steam_root: Path) -> tuple[Path, ...]: registered = _read_steam_library_folders( steam_root / STEAM_LIBRARY_FOLDERS_RELATIVE_PATH ) return _unique_resolved_paths((steam_root, *registered)) def _read_steam_library_folders(path: Path) -> tuple[Path, ...]: """Read current and legacy Steam libraryfolders.vdf path records.""" try: with path.open("rb") as handle: content = handle.read(STEAM_LIBRARY_FOLDERS_MAX_BYTES + 1) except OSError: return () if len(content) > STEAM_LIBRARY_FOLDERS_MAX_BYTES: return () text = content.decode("utf-8-sig", errors="replace") result: list[Path] = [] for raw_key, raw_value in _VDF_QUOTED_PAIR.findall(text): key = _decode_vdf_string(raw_key) value = _decode_vdf_string(raw_value) # Current files use a named "path" property. Older Steam clients used # numeric top-level keys, which are distinguishable from app-size pairs # because a Linux library path must be absolute. if key.casefold() != "path" and not key.isdecimal(): continue if not value or "\x00" in value: continue library = Path(value).expanduser() if library.is_absolute(): result.append(library) return tuple(result) def _decode_vdf_string(value: str) -> str: result: list[str] = [] index = 0 while index < len(value): character = value[index] if character == "\\" and index + 1 < len(value): next_character = value[index + 1] if next_character in {'"', "\\"}: result.append(next_character) index += 2 continue result.append(character) index += 1 return "".join(result) def _unique_resolved_paths(paths: Sequence[Path]) -> tuple[Path, ...]: result: list[Path] = [] seen: set[Path] = set() for path in paths: try: resolved = path.resolve() except (OSError, RuntimeError): continue if resolved in seen: continue seen.add(resolved) result.append(resolved) return tuple(result) def prepare_game_runtime( paths: AppPaths, selection: RuntimeSelection, runner: CommandRunner, ) -> list[CommandOutcome]: if not runner.dry_run: paths.create_base_directories() results: list[CommandOutcome] = [] if selection.kind == "umu": environment = _umu_environment(paths, selection) if not native_d3d_ready(paths.umu_prefix): results.append( runner.run( [selection.executable, "winetricks", "-q", "d3dx9_43", "d3dcompiler_43"], env_overrides=environment, timeout_seconds=1800.0, ) ) elif selection.kind in {"proton7", "proton7-container"}: if not selection.proton_path or not selection.steam_root: raise RuntimeErrorPT("incomplete Proton 7 runtime selection") if not runner.dry_run and _game_runtime_ready(paths, selection): return results if not runner.dry_run: paths.compat_data.mkdir(parents=True, exist_ok=True) proton = Path(selection.proton_path) environment = _proton_environment(paths, selection) prefix_verb = "runinprefix" if _proton_prefix_ready(paths.proton_prefix) else "run" results.append( runner.run( [ sys.executable, "-m", "ptlinux.proton_compat", str(proton), prefix_verb, "cmd", "/c", "exit", ], env_overrides=environment, timeout_seconds=600.0, ) ) if not runner.dry_run: _ensure_proton_game_link(paths) results.append( runner.run( [ sys.executable, "-m", "ptlinux.proton_compat", str(proton), "runinprefix", "reg.exe", "add", DLL_OVERRIDES_KEY, "/v", "d3d9", "/t", "REG_SZ", "/d", DXVK_D3D9_OVERRIDE, "/f", ], env_overrides=environment, timeout_seconds=60.0, ) ) if not native_d3d_ready(paths.proton_prefix): wine_tools = _find_proton_wine_tools(proton) winetricks = shutil.which("winetricks") if wine_tools is None or not winetricks: raise RuntimeErrorPT("Proton Winetricks helpers are unavailable") wine, wineserver = wine_tools compatible_winetricks = prepare_proton7_winetricks( Path(winetricks), paths.cache / "compat/winetricks-proton7", dry_run=runner.dry_run, ) results.append( runner.run( [ str(compatible_winetricks), "-q", "d3dx9_43", "d3dcompiler_43", ], env_overrides={ "WINEPREFIX": str(paths.proton_prefix), "WINE": str(wine), "WINESERVER": str(wineserver), "WINEDEBUG": "-all", }, timeout_seconds=1800.0, ) ) else: raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") if not runner.dry_run: _write_json( paths.game_runtime_marker, { "configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION, "configured_at": datetime.now().astimezone().isoformat(), "native_d3d43": True, "dxvk_d3d9_override": DXVK_D3D9_OVERRIDE, "proton_identity": _proton_identity(selection), "runtime": asdict(selection), }, ) return results def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool: """Return whether another client may reuse the prepared runtime read-only.""" if selection.kind == "umu": return native_d3d_ready(paths.umu_prefix) if selection.kind in {"proton7", "proton7-container"}: return _game_runtime_ready(paths, selection) return False def run_game( paths: AppPaths, settings: Settings, selection: RuntimeSelection, runner: CommandRunner, *, start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION, use_gamebox: bool = True, concurrent_prefix_launch: bool = False, timeout_seconds: float = 12 * 60 * 60, check: bool = True, extra_environment: Mapping[str, str] | None = None, output_line_filter: OutputLineFilter | None = None, ) -> CommandOutcome: if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") native_executable, windows_executable, game_arguments = _game_start_command( paths, start_option, ) if selection.kind == "umu": game_command = [ selection.executable, native_executable, *game_arguments, ] environment = _umu_environment(paths, selection) elif selection.kind == "proton7-container": if not selection.proton_path: raise RuntimeErrorPT("Proton script path is missing") game_command = [ selection.executable, native_executable, *game_arguments, ] environment = _umu_proton7_environment( paths, selection, synchronization=resolve_proton_sync_mode(settings.proton_sync), ) else: if not selection.proton_path: raise RuntimeErrorPT("Proton script path is missing") game_command = [ sys.executable, "-m", "ptlinux.proton_compat", selection.proton_path, "runinprefix", windows_executable, *game_arguments, ] environment = _proton_environment( paths, selection, synchronization=resolve_proton_sync_mode(settings.proton_sync), ) if selection.kind in {"umu", "proton7-container"}: # Keep every UMU launch identical except for the verb, as required when # several games share one prefix. The first client owns prefix startup; # every later shared-lease client joins the active wineserver directly. environment["PROTON_VERB"] = ( "runinprefix" if concurrent_prefix_launch else "waitforexitandrun" ) if use_gamebox: game_command = build_gamebox_command(game_command, settings) if extra_environment: environment.update(extra_environment) environment["PYTHONPATH"] = _runtime_python_path( os.environ.get("PYTHONPATH"), ) return runner.run( game_command, cwd=paths.runtime_view, env_overrides=environment, timeout_seconds=timeout_seconds, check=check, stream_output=True, output_line_filter=output_line_filter, ) def _game_start_command( paths: AppPaths, option: DepotStartOption, ) -> tuple[str, str, tuple[str, ...]]: relative = PurePosixPath(option.path) if ( relative.is_absolute() or not relative.parts or any(part in {"", ".", ".."} for part in relative.parts) or relative.suffix.casefold() != ".exe" ): raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}") try: arguments = tuple(shlex.split(option.arguments)) except ValueError as exc: raise RuntimeErrorPT( f"invalid launch arguments for realm {option.name}: {exc}" ) from exc if not arguments: raise RuntimeErrorPT(f"realm {option.name} has no launch arguments") native = str(_resolve_runtime_path_case(paths.runtime_view, relative)) windows_relative = "\\".join(relative.parts) return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path: """Match Windows manifest casing against the case-sensitive runtime view.""" direct = root.joinpath(*relative.parts) if direct.exists(): return direct cursor = root for part in relative.parts: if not cursor.is_dir(): return direct try: matches = tuple( entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold() ) except OSError: return direct if not matches: return direct if len(matches) > 1: raise RuntimeErrorPT( f"realm executable path has ambiguous casing: {relative.as_posix()}" ) cursor = matches[0] return cursor def run_proton_prefix_command( paths: AppPaths, selection: RuntimeSelection, runner: CommandRunner, arguments: Sequence[str], *, check: bool = True, timeout_seconds: float = 60.0, ) -> CommandOutcome: """Run a maintenance command in the pinned Proton 7 prefix.""" if selection.kind not in {"proton7", "proton7-container"}: raise RuntimeErrorPT("the login trace requires the pinned Proton 7 runtime") if not selection.proton_path or not selection.steam_root: raise RuntimeErrorPT("incomplete Proton 7 runtime selection") return runner.run( [ sys.executable, "-m", "ptlinux.proton_compat", selection.proton_path, "runinprefix", *arguments, ], env_overrides=_proton_environment(paths, selection), timeout_seconds=timeout_seconds, check=check, ) def run_runtime_prefix_command( paths: AppPaths, selection: RuntimeSelection, runner: CommandRunner, arguments: Sequence[str], *, check: bool = True, timeout_seconds: float = 60.0, ) -> CommandOutcome: """Run a credential-free helper inside the selected game's Wine prefix.""" if not arguments: raise ValueError("runtime-prefix command arguments cannot be empty") if selection.kind == "umu": command = [selection.executable, *arguments] environment = _umu_environment(paths, selection) elif selection.kind == "proton7-container": if not selection.proton_path or not selection.steam_root: raise RuntimeErrorPT("incomplete Proton 7 runtime selection") command = [selection.executable, *arguments] environment = _umu_proton7_environment(paths, selection) elif selection.kind == "proton7": if not selection.proton_path or not selection.steam_root: raise RuntimeErrorPT("incomplete Proton 7 runtime selection") command = [ sys.executable, "-m", "ptlinux.proton_compat", selection.proton_path, "runinprefix", *arguments, ] environment = _proton_environment(paths, selection) else: raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") return runner.run( command, env_overrides=environment, timeout_seconds=timeout_seconds, check=check, ) def _runtime_python_path(existing: str | None) -> str: """Make the installed package importable after changing into runtime_view.""" package_root = str(Path(__file__).resolve().parent.parent) if not existing: return package_root return os.pathsep.join((package_root, existing)) def _umu_environment( paths: AppPaths, selection: RuntimeSelection, ) -> dict[str, str]: environment = { "WINEPREFIX": str(paths.umu_prefix), "GAMEID": "umu-pristontale-eu", "STORE": "none", "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "WINEDEBUG": "-all", } environment.update(_portable_umu_environment(paths, selection)) return environment def _umu_proton7_environment( paths: AppPaths, selection: RuntimeSelection, *, synchronization: str = RUNTIME_DEFAULT_SYNC, ) -> dict[str, str]: """Run the retained Proton 7 build inside its Steam Runtime container.""" if not selection.proton_path: raise RuntimeErrorPT("Proton script path is missing") environment = { # UMU expects the Proton compatibility-data root and manages its pfx child. # Pointing it at proton_prefix would create a nested pfx -> . compatibility # symlink; compat_data preserves the exact prefix prepared by Proton 7. "WINEPREFIX": str(paths.compat_data), "GAMEID": "umu-pristontale-eu", "STORE": "none", "PROTONPATH": str(Path(selection.proton_path).parent), "PROTON_VERB": "waitforexitandrun", "PROTONFIXES_DISABLE": "1", "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "WINEDEBUG": "-all", } environment.update(_portable_umu_environment(paths, selection)) environment.update(_proton_sync_environment(synchronization)) return environment def _portable_umu_environment( paths: AppPaths, selection: RuntimeSelection, ) -> dict[str, str]: """Keep downloads made by our zipapp inside the selected installation.""" try: selected = Path(selection.executable).expanduser().resolve() except (OSError, RuntimeError): return {} if selected != paths.managed_umu_executable.resolve(): return {} return {"UMU_FOLDERS_PATH": str(paths.runtime_tools)} def _proton_environment( paths: AppPaths, selection: RuntimeSelection, *, synchronization: str = RUNTIME_DEFAULT_SYNC, ) -> dict[str, str]: if not selection.steam_root: raise RuntimeErrorPT("Steam root is missing for Proton") environment = { "STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root, "STEAM_COMPAT_DATA_PATH": str(paths.compat_data), "STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view), "SteamAppId": "0", "SteamGameId": "0", "WINEPREFIX": str(paths.proton_prefix), "WINEDEBUG": "-all", "PROTON_LOG": "0", "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), } environment.update(_proton_sync_environment(synchronization)) return environment def detect_distribution_id() -> str: """Return the freedesktop distribution ID without invoking distro tools.""" try: value = platform.freedesktop_os_release().get("ID", "") except OSError: return "" return value.strip().casefold() def is_cachyos_host( *, distribution_id: str | None = None, kernel_release: str | None = None, ) -> bool: """Identify CachyOS narrowly for runtime compatibility policy.""" distribution = detect_distribution_id() if distribution_id is None else distribution_id kernel = platform.release() if kernel_release is None else kernel_release return ( CACHYOS_MARKER in distribution.casefold() or CACHYOS_MARKER in kernel.casefold() ) def prefers_current_umu_host( *, distribution_id: str | None = None, kernel_release: str | None = None, ) -> bool: """Use current UMU automatically on rolling Arch-family hosts.""" distribution = detect_distribution_id() if distribution_id is None else distribution_id return ( distribution.strip().casefold() in CURRENT_UMU_DISTRIBUTIONS or is_cachyos_host( distribution_id=distribution, kernel_release=kernel_release, ) ) def resolve_proton_sync_mode( configured: str, *, distribution_id: str | None = None, kernel_release: str | None = None, ) -> str: """Resolve the configured mode, applying the narrow CachyOS compatibility fallback.""" if configured not in {"auto", "fsync", "esync"}: raise ValueError(f"unsupported Proton synchronization mode: {configured}") if configured != "auto": return configured if is_cachyos_host( distribution_id=distribution_id, kernel_release=kernel_release, ): return "esync" return RUNTIME_DEFAULT_SYNC def _proton_sync_environment(synchronization: str) -> dict[str, str]: if synchronization == RUNTIME_DEFAULT_SYNC: return {} if synchronization == "esync": return { "PROTON_NO_FSYNC": "1", "WINEFSYNC": "0", "PROTON_NO_ESYNC": "0", "WINEESYNC": "1", } if synchronization == "fsync": return { "PROTON_NO_FSYNC": "0", "WINEFSYNC": "1", "PROTON_NO_ESYNC": "0", "WINEESYNC": "1", } raise ValueError(f"unsupported resolved Proton synchronization mode: {synchronization}") def _ensure_proton_game_link(paths: AppPaths) -> None: link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU" link.parent.mkdir(parents=True, exist_ok=True) if link.is_symlink(): if link.resolve() == paths.runtime_view.resolve(): return raise RuntimeErrorPT(f"Proton game link points somewhere unexpected: {link}") if link.exists(): if link.is_dir() and not any(link.iterdir()): link.rmdir() else: raise RuntimeErrorPT(f"refusing to replace existing Proton game path: {link}") link.symlink_to(paths.runtime_view, target_is_directory=True) def _steam_root_for_proton( proton: Path, environment: Mapping[str, str] | None = None, ) -> Path: steamapps = next( (parent for parent in proton.parents if parent.name.casefold() == "steamapps"), None, ) if steamapps is None: raise RuntimeErrorPT( "configured Proton path must be inside a Steam steamapps directory" ) library_root = steamapps.parent.resolve() env = os.environ if environment is None else environment for steam_root in _steam_client_roots(env): if library_root in _steam_library_roots(steam_root): return steam_root # Preserve support for an otherwise valid, manually managed Steam layout. # Without a registered client root, the library root is the best available # compatibility value and matches the launcher's historical behaviour. return library_root def native_d3d_ready(prefix: Path) -> bool: directory = prefix / "drive_c/windows/syswow64" if not all((directory / f"{name}.dll").is_file() for name in NATIVE_D3D43_DLLS): return False try: registry = (prefix / "user.reg").read_text( encoding="utf-8", errors="replace", ).casefold() except OSError: return False return all(_native_override_ready(registry, name) for name in NATIVE_D3D43_DLLS) def _native_override_ready(registry: str, name: str) -> bool: return any( entry in registry for entry in ( f'"{name}"="native,builtin"', f'"{name}"="native"', f'"*{name}"="native"', ) ) def prepare_proton7_winetricks( source: Path, destination: Path, *, dry_run: bool = False, ) -> Path: try: content = source.read_bytes() except OSError as exc: raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc probe = WINETRICKS_CODEPAGE_PROBE.encode("utf-8") replacement = WINETRICKS_PROTON7_PROBE.encode("utf-8") if probe not in content: return source if dry_run: return destination destination.parent.mkdir(parents=True, exist_ok=True) patched = content.replace( probe, replacement, 1, ) temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp") temporary.write_bytes(patched) temporary.chmod(source.stat().st_mode & 0o777) temporary.replace(destination) return destination def _proton_prefix_ready(prefix: Path) -> bool: return (prefix / "user.reg").is_file() and ( prefix / "drive_c/windows/system32/cmd.exe" ).exists() def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool: marker = _read_json(paths.game_runtime_marker) identity = _proton_identity(selection) if marker is None or identity is None: return False if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION: return False if marker.get("runtime") != asdict(selection): return False if marker.get("proton_identity") != identity: return False if marker.get("native_d3d43") is not True: return False if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE: return False if not _proton_prefix_ready(paths.proton_prefix): return False if not native_d3d_ready(paths.proton_prefix): return False if not _d3d9_override_ready(paths.proton_prefix): return False return _proton_game_link_ready(paths) def _d3d9_override_ready(prefix: Path) -> bool: try: registry = (prefix / "user.reg").read_text( encoding="utf-8", errors="replace", ).casefold() except OSError: return False return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry def _proton_game_link_ready(paths: AppPaths) -> bool: link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU" if not link.is_symlink(): return False try: return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True) except (OSError, RuntimeError): return False def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None: if not selection.proton_path: return None proton = Path(selection.proton_path) identity_paths = { "proton": proton, "version": proton.parent / "version", "toolmanifest": proton.parent / "toolmanifest.vdf", } identity: dict[str, str] = {} for name, path in identity_paths.items(): try: identity[name] = _sha256(path) except OSError: return None return identity def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None: for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"): wine = directory / "wine" wineserver = directory / "wineserver" if wine.is_file() and wineserver.is_file(): return wine, wineserver return None def _write_json(path: Path, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(path) def _read_json(path: Path) -> dict[str, object] | None: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError): return None return value if isinstance(value, dict) else None def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest()