"""Portable game runtime selection and Proton/UMU preparation.""" from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime import json import os from pathlib import Path import shutil import sys from typing import Mapping from .command import CommandOutcome, CommandRunner from .gamebox import build_gamebox_command from .paths import AppPaths from .settings import Settings WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\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%"' 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, ) -> RuntimeSelection: env = os.environ if environment is None else environment if settings.proton_path: 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) return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root)) if settings.runtime in {"auto", "proton7"}: proton_result = find_steam_proton7(env) if proton_result is not None: proton, steam_root = proton_result return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root)) if settings.runtime == "proton7": raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found") if settings.runtime in {"auto", "umu"}: umu = shutil.which("umu-run") 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: env = os.environ if environment is None else environment home_value = env.get("HOME") if not home_value: return None home = Path(home_value).expanduser() roots: list[Path] = [] explicit = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") if explicit: roots.append(Path(explicit).expanduser()) roots.extend( ( home / ".local/share/Steam", home / ".steam/steam", home / "snap/steam/common/.local/share/Steam", home / ".var/app/com.valvesoftware.Steam/.local/share/Steam", ) ) seen: set[Path] = set() for root in roots: resolved = root.resolve() if resolved in seen: continue seen.add(resolved) proton = resolved / "steamapps/common/Proton 7.0/proton" if proton.is_file(): return proton, resolved return None 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) 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 == "proton7": if not selection.proton_path or not selection.steam_root: raise RuntimeErrorPT("incomplete Proton 7 runtime selection") 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.state / "game-runtime.json", { "configured_at": datetime.now().astimezone().isoformat(), "native_d3d43": True, "dxvk_d3d9_override": DXVK_D3D9_OVERRIDE, "runtime": asdict(selection), }, ) return results def run_game( paths: AppPaths, settings: Settings, selection: RuntimeSelection, runner: CommandRunner, *, use_gamebox: bool = True, ) -> CommandOutcome: if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") if selection.kind == "umu": game_command = [ selection.executable, str(paths.runtime_view / "Game.exe"), "PTEU_REBORN", ] environment = _umu_environment(paths) 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_GAME_EXE, "PTEU_REBORN", ] environment = _proton_environment(paths, selection) if use_gamebox: game_command = build_gamebox_command(game_command, settings) environment["PYTHONPATH"] = _runtime_python_path( os.environ.get("PYTHONPATH"), ) return runner.run( game_command, cwd=paths.runtime_view, env_overrides=environment, timeout_seconds=12 * 60 * 60, stream_output=True, ) 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) -> dict[str, str]: return { "WINEPREFIX": str(paths.umu_prefix), "GAMEID": "umu-pristontale-eu", "STORE": "none", "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "WINEDEBUG": "-all", } def _proton_environment( paths: AppPaths, selection: RuntimeSelection, ) -> dict[str, str]: if not selection.steam_root: raise RuntimeErrorPT("Steam root is missing for Proton") return { "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), } 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) -> Path: parts = proton.parts try: index = parts.index("steamapps") except ValueError as exc: raise RuntimeErrorPT( "configured Proton path must be inside a Steam steamapps directory" ) from exc return Path(*parts[:index]) 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_text(encoding="utf-8") except OSError as exc: raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc if WINETRICKS_CODEPAGE_PROBE not in content: return source if dry_run: return destination destination.parent.mkdir(parents=True, exist_ok=True) patched = content.replace( WINETRICKS_CODEPAGE_PROBE, WINETRICKS_PROTON7_PROBE, 1, ) temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp") temporary.write_text(patched, encoding="utf-8") 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 _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)