Files
pristontale-eu-linux/ptlinux/runtime.py
2026-08-27 17:50:36 +02:00

391 lines
11 KiB
Python

"""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)