Init
This commit is contained in:
390
ptlinux/runtime.py
Normal file
390
ptlinux/runtime.py
Normal file
@@ -0,0 +1,390 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user