More fixes to support arch distros.
This commit is contained in:
@@ -7,11 +7,15 @@ from datetime import datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Mapping, Sequence, TextIO
|
from typing import Callable, Mapping, Sequence, TextIO
|
||||||
|
|
||||||
|
|
||||||
|
OutputLineFilter = Callable[[str], str | None]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -53,6 +57,7 @@ class CommandRunner:
|
|||||||
timeout_seconds: float = 300.0,
|
timeout_seconds: float = 300.0,
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
stream_output: bool = False,
|
stream_output: bool = False,
|
||||||
|
output_line_filter: OutputLineFilter | None = None,
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
arguments = [str(value) for value in argv]
|
arguments = [str(value) for value in argv]
|
||||||
if not arguments:
|
if not arguments:
|
||||||
@@ -81,6 +86,7 @@ class CommandRunner:
|
|||||||
environment=environment,
|
environment=environment,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
started=started,
|
started=started,
|
||||||
|
output_line_filter=output_line_filter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -126,6 +132,7 @@ class CommandRunner:
|
|||||||
environment: Mapping[str, str],
|
environment: Mapping[str, str],
|
||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
started: float,
|
started: float,
|
||||||
|
output_line_filter: OutputLineFilter | None,
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
arguments,
|
arguments,
|
||||||
@@ -135,6 +142,7 @@ class CommandRunner:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
stdout: list[str] = []
|
stdout: list[str] = []
|
||||||
stderr: list[str] = []
|
stderr: list[str] = []
|
||||||
@@ -144,13 +152,21 @@ class CommandRunner:
|
|||||||
capture: list[str],
|
capture: list[str],
|
||||||
destination: TextIO,
|
destination: TextIO,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
try:
|
||||||
while True:
|
while True:
|
||||||
line = stream.readline()
|
line = stream.readline()
|
||||||
if not line:
|
if not line:
|
||||||
return
|
return
|
||||||
|
if output_line_filter is not None:
|
||||||
|
line = output_line_filter(line)
|
||||||
|
if line is None:
|
||||||
|
continue
|
||||||
capture.append(line)
|
capture.append(line)
|
||||||
destination.write(line)
|
destination.write(line)
|
||||||
destination.flush()
|
destination.flush()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
# Shutdown can close a pipe while its relay thread is leaving readline().
|
||||||
|
return
|
||||||
|
|
||||||
assert process.stdout is not None
|
assert process.stdout is not None
|
||||||
assert process.stderr is not None
|
assert process.stderr is not None
|
||||||
@@ -171,12 +187,12 @@ class CommandRunner:
|
|||||||
returncode = process.wait(timeout=timeout_seconds)
|
returncode = process.wait(timeout=timeout_seconds)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
process.kill()
|
returncode = _terminate_process_group(process)
|
||||||
returncode = process.wait()
|
except BaseException:
|
||||||
stdout_thread.join()
|
_terminate_process_group(process)
|
||||||
stderr_thread.join()
|
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||||
process.stdout.close()
|
raise
|
||||||
process.stderr.close()
|
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||||
return CommandOutcome(
|
return CommandOutcome(
|
||||||
argv=arguments,
|
argv=arguments,
|
||||||
returncode=None if timed_out else returncode,
|
returncode=None if timed_out else returncode,
|
||||||
@@ -210,3 +226,49 @@ def _as_text(value: bytes | str | None) -> str:
|
|||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
return value.decode("utf-8", errors="replace")
|
return value.decode("utf-8", errors="replace")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process_group(
|
||||||
|
process: subprocess.Popen[str],
|
||||||
|
*,
|
||||||
|
grace_seconds: float = 5.0,
|
||||||
|
) -> int | None:
|
||||||
|
"""Stop the complete child session, allowing Proton a short clean exit."""
|
||||||
|
|
||||||
|
if process.poll() is not None:
|
||||||
|
return process.returncode
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return process.poll()
|
||||||
|
try:
|
||||||
|
return process.wait(timeout=grace_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return process.poll()
|
||||||
|
try:
|
||||||
|
return process.wait(timeout=grace_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return process.poll()
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_stream_relays(
|
||||||
|
process: subprocess.Popen[str],
|
||||||
|
stdout_thread: threading.Thread,
|
||||||
|
stderr_thread: threading.Thread,
|
||||||
|
) -> None:
|
||||||
|
"""Bound relay cleanup so a second interrupt can never be required."""
|
||||||
|
|
||||||
|
stdout_thread.join(timeout=2.0)
|
||||||
|
stderr_thread.join(timeout=2.0)
|
||||||
|
for stream in (process.stdout, process.stderr):
|
||||||
|
if stream is not None:
|
||||||
|
try:
|
||||||
|
stream.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
stdout_thread.join(timeout=0.25)
|
||||||
|
stderr_thread.join(timeout=0.25)
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ from ptdiag.assbin import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
from .v21_texture_reclaim import TextureReclaimPatchError, build_texture_reclaim_patch
|
from .v21_post_auth_reclaim import (
|
||||||
|
PATCHED_SHA256 as POST_AUTH_RECLAIM_GAME_DLL_SHA256,
|
||||||
|
PostAuthReclaimPatchError,
|
||||||
|
build_post_auth_reclaim_patch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||||
@@ -71,17 +75,11 @@ class CompatibilityCacheResult:
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimePatchCacheResult:
|
|
||||||
source: str
|
|
||||||
destination: str
|
|
||||||
game_dll_sha256: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_runtime_view(
|
def prepare_runtime_view(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
source_game: Path,
|
source_game: Path,
|
||||||
|
*,
|
||||||
|
use_world_fix: bool = True,
|
||||||
) -> OverlayResult:
|
) -> OverlayResult:
|
||||||
"""Build a symlink view and keep the launcher-managed source read-only."""
|
"""Build a symlink view and keep the launcher-managed source read-only."""
|
||||||
|
|
||||||
@@ -91,9 +89,11 @@ def prepare_runtime_view(
|
|||||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||||
inspection = inspect_wrapped_assbin(source_scene)
|
inspection = inspect_wrapped_assbin(source_scene)
|
||||||
action = _scene_action(inspection)
|
action = _scene_action(inspection)
|
||||||
|
game_save = _prepare_persistent_save(paths, source)
|
||||||
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
|
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
|
||||||
paths,
|
paths,
|
||||||
source,
|
source,
|
||||||
|
use_world_fix=use_world_fix,
|
||||||
)
|
)
|
||||||
|
|
||||||
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
|
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
|
||||||
@@ -101,7 +101,7 @@ def prepare_runtime_view(
|
|||||||
links_created = 0
|
links_created = 0
|
||||||
try:
|
try:
|
||||||
build.mkdir(parents=True, exist_ok=False)
|
build.mkdir(parents=True, exist_ok=False)
|
||||||
top_level_exceptions = {"game"}
|
top_level_exceptions = {"game", "save"}
|
||||||
for name, binary_source in (
|
for name, binary_source in (
|
||||||
("Game.exe", game_exe_source),
|
("Game.exe", game_exe_source),
|
||||||
("game.dll", game_dll_source),
|
("game.dll", game_dll_source),
|
||||||
@@ -113,6 +113,8 @@ def prepare_runtime_view(
|
|||||||
build,
|
build,
|
||||||
except_names=top_level_exceptions,
|
except_names=top_level_exceptions,
|
||||||
)
|
)
|
||||||
|
(build / "save").symlink_to(game_save, target_is_directory=True)
|
||||||
|
links_created += 1
|
||||||
if game_exe_source != source / "Game.exe":
|
if game_exe_source != source / "Game.exe":
|
||||||
(build / "Game.exe").symlink_to(game_exe_source)
|
(build / "Game.exe").symlink_to(game_exe_source)
|
||||||
links_created += 1
|
links_created += 1
|
||||||
@@ -202,41 +204,14 @@ def cache_compatibility_binaries(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def cache_runtime_patch(
|
|
||||||
paths: AppPaths,
|
|
||||||
source_dll: Path,
|
|
||||||
) -> RuntimePatchCacheResult:
|
|
||||||
source = source_dll.expanduser().resolve(strict=True)
|
|
||||||
if _sha256(source) != PATCHED_V21_GAME_DLL_SHA256:
|
|
||||||
raise OverlayError("runtime patch does not match the verified V21 profile")
|
|
||||||
target = paths.runtime_patch_dll
|
|
||||||
if target.exists() or target.is_symlink():
|
|
||||||
if target.is_file() and _sha256(target) == PATCHED_V21_GAME_DLL_SHA256:
|
|
||||||
return _runtime_patch_result(paths, source)
|
|
||||||
raise OverlayError(f"runtime patch cache has an unknown profile: {target}")
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
|
||||||
try:
|
|
||||||
shutil.copy2(source, temporary)
|
|
||||||
if _sha256(temporary) != PATCHED_V21_GAME_DLL_SHA256:
|
|
||||||
raise OverlayError("copied runtime patch failed hash verification")
|
|
||||||
temporary.replace(target)
|
|
||||||
except BaseException:
|
|
||||||
temporary.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
result = _runtime_patch_result(paths, source)
|
|
||||||
_write_json(paths.runtime_patch_manifest, asdict(result))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def select_binary_action(
|
def select_binary_action(
|
||||||
cache_hashes: dict[str, str] | None,
|
cache_hashes: dict[str, str] | None,
|
||||||
source_hashes: dict[str, str],
|
source_hashes: dict[str, str],
|
||||||
runtime_patch_hash: str | None = None,
|
runtime_patch_hash: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if runtime_patch_hash is not None:
|
if runtime_patch_hash is not None:
|
||||||
if runtime_patch_hash != PATCHED_V21_GAME_DLL_SHA256:
|
if runtime_patch_hash != POST_AUTH_RECLAIM_GAME_DLL_SHA256:
|
||||||
raise OverlayError("runtime patch cache has an unknown binary profile")
|
raise OverlayError("post-authentication runtime patch has an unknown profile")
|
||||||
if cache_hashes is not None:
|
if cache_hashes is not None:
|
||||||
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
|
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
|
||||||
raise OverlayError("compatibility cache has an unknown binary profile")
|
raise OverlayError("compatibility cache has an unknown binary profile")
|
||||||
@@ -244,8 +219,10 @@ def select_binary_action(
|
|||||||
return "verified-source-pair"
|
return "verified-source-pair"
|
||||||
if runtime_patch_hash is not None:
|
if runtime_patch_hash is not None:
|
||||||
if source_hashes != CURRENT_V21_BINARY_HASHES:
|
if source_hashes != CURRENT_V21_BINARY_HASHES:
|
||||||
raise OverlayError("V21 runtime patch does not match the installed client profile")
|
raise OverlayError(
|
||||||
return "verified-v21-texture-reclaim"
|
"post-authentication runtime patch does not match the installed client profile"
|
||||||
|
)
|
||||||
|
return "verified-v21-post-auth-world-fix"
|
||||||
return "upstream-pair"
|
return "upstream-pair"
|
||||||
|
|
||||||
|
|
||||||
@@ -349,23 +326,87 @@ def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def _runtime_game_binary_sources(paths: AppPaths, source: Path) -> tuple[str, Path, Path]:
|
def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
|
||||||
|
"""Migrate game-created settings and logs before replacing the runtime view."""
|
||||||
|
|
||||||
|
target = paths.game_save
|
||||||
|
if target.is_symlink():
|
||||||
|
raise OverlayError(f"persistent game save must not be a symbolic link: {target}")
|
||||||
|
if target.exists():
|
||||||
|
if not target.is_dir():
|
||||||
|
raise OverlayError(f"persistent game save is not a directory: {target}")
|
||||||
|
_reject_save_symlinks(target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
candidate = None
|
||||||
|
for path in (paths.runtime_view / "save", source / "save"):
|
||||||
|
if path.is_dir():
|
||||||
|
candidate = path.resolve(strict=True)
|
||||||
|
break
|
||||||
|
if candidate is not None:
|
||||||
|
allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True))
|
||||||
|
if not any(
|
||||||
|
candidate == root or candidate.is_relative_to(root)
|
||||||
|
for root in allowed_roots
|
||||||
|
):
|
||||||
|
raise OverlayError(f"refusing to migrate game save outside managed roots: {candidate}")
|
||||||
|
_reject_save_symlinks(candidate)
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
staging = target.parent / f".{target.name}.build-{uuid.uuid4().hex}"
|
||||||
|
try:
|
||||||
|
if candidate is None:
|
||||||
|
staging.mkdir(parents=False, exist_ok=False)
|
||||||
|
else:
|
||||||
|
shutil.copytree(candidate, staging, symlinks=True)
|
||||||
|
staging.replace(target)
|
||||||
|
except BaseException:
|
||||||
|
_remove_runtime_tree(staging)
|
||||||
|
raise
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_save_symlinks(root: Path) -> None:
|
||||||
|
for directory, directory_names, file_names in os.walk(root, followlinks=False):
|
||||||
|
parent = Path(directory)
|
||||||
|
for name in (*directory_names, *file_names):
|
||||||
|
path = parent / name
|
||||||
|
if path.is_symlink():
|
||||||
|
raise OverlayError(f"persistent game save contains a symbolic link: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_game_binary_sources(
|
||||||
|
paths: AppPaths,
|
||||||
|
source: Path,
|
||||||
|
*,
|
||||||
|
use_world_fix: bool = True,
|
||||||
|
) -> tuple[str, Path, Path]:
|
||||||
cache = paths.compatibility_binaries
|
cache = paths.compatibility_binaries
|
||||||
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||||
source_hashes = _binary_hashes(source)
|
source_hashes = _binary_hashes(source)
|
||||||
patch = paths.runtime_patch_dll
|
patch = paths.runtime_patch_dll
|
||||||
if source_hashes == CURRENT_V21_BINARY_HASHES and not patch.exists():
|
patch_hash = None
|
||||||
|
if source_hashes == CURRENT_V21_BINARY_HASHES and use_world_fix:
|
||||||
|
if patch.exists() or patch.is_symlink():
|
||||||
|
if not patch.is_file() or patch.is_symlink():
|
||||||
|
raise OverlayError(f"runtime patch cache is not a regular file: {patch}")
|
||||||
|
patch_hash = _sha256(patch)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
result = build_texture_reclaim_patch(source / "game.dll", patch)
|
result = build_post_auth_reclaim_patch(source / "game.dll", patch)
|
||||||
except TextureReclaimPatchError as exc:
|
except PostAuthReclaimPatchError as exc:
|
||||||
raise OverlayError(f"could not build the verified V21 runtime patch: {exc}") from exc
|
raise OverlayError(
|
||||||
_write_json(patch.parent / "patch.json", {"ok": True, **asdict(result)})
|
f"could not build the verified post-authentication world fix: {exc}"
|
||||||
patch_hash = _sha256(patch) if patch.is_file() else None
|
) from exc
|
||||||
|
patch_hash = result.destination_sha256
|
||||||
|
payload = {"ok": True, **asdict(result)}
|
||||||
|
_write_json(patch.parent / "patch.json", payload)
|
||||||
|
_write_json(paths.runtime_patch_manifest, payload)
|
||||||
action = select_binary_action(cache_hashes, source_hashes, patch_hash)
|
action = select_binary_action(cache_hashes, source_hashes, patch_hash)
|
||||||
return (
|
return (
|
||||||
action,
|
action,
|
||||||
source / "Game.exe",
|
source / "Game.exe",
|
||||||
patch if action == "verified-v21-texture-reclaim" else source / "game.dll",
|
patch if action == "verified-v21-post-auth-world-fix" else source / "game.dll",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -387,15 +428,6 @@ def _compatibility_result(paths: AppPaths, source: Path) -> CompatibilityCacheRe
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _runtime_patch_result(paths: AppPaths, source: Path) -> RuntimePatchCacheResult:
|
|
||||||
return RuntimePatchCacheResult(
|
|
||||||
source=str(source),
|
|
||||||
destination=str(paths.runtime_patch_dll),
|
|
||||||
game_dll_sha256=PATCHED_V21_GAME_DLL_SHA256,
|
|
||||||
created_at=datetime.now().astimezone().isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_runtime_tree(path: Path) -> None:
|
def _remove_runtime_tree(path: Path) -> None:
|
||||||
if path.is_symlink() or path.is_file():
|
if path.is_symlink() or path.is_file():
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from pathlib import Path
|
|||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from typing import Mapping
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
from .command import CommandOutcome, CommandRunner
|
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||||
from .gamebox import build_gamebox_command
|
from .gamebox import build_gamebox_command
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
@@ -47,21 +47,52 @@ def select_runtime(
|
|||||||
environment: Mapping[str, str] | None = None,
|
environment: Mapping[str, str] | None = None,
|
||||||
) -> RuntimeSelection:
|
) -> RuntimeSelection:
|
||||||
env = os.environ if environment is None else environment
|
env = os.environ if environment is None else environment
|
||||||
|
umu = shutil.which("umu-run", path=env.get("PATH"))
|
||||||
if settings.proton_path:
|
if settings.proton_path:
|
||||||
proton = Path(settings.proton_path).expanduser().resolve()
|
proton = Path(settings.proton_path).expanduser().resolve()
|
||||||
if not proton.is_file():
|
if not proton.is_file():
|
||||||
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
|
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
|
||||||
steam_root = _steam_root_for_proton(proton)
|
steam_root = _steam_root_for_proton(proton)
|
||||||
|
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 settings.runtime == "auto" and umu:
|
||||||
|
return RuntimeSelection(
|
||||||
|
"proton7-container",
|
||||||
|
umu,
|
||||||
|
str(proton),
|
||||||
|
str(steam_root),
|
||||||
|
)
|
||||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||||
if settings.runtime in {"auto", "proton7"}:
|
if settings.runtime in {"auto", "proton7", "proton7-container"}:
|
||||||
proton_result = find_steam_proton7(env)
|
proton_result = find_steam_proton7(env)
|
||||||
if proton_result is not None:
|
if proton_result is not None:
|
||||||
proton, steam_root = proton_result
|
proton, steam_root = proton_result
|
||||||
|
if settings.runtime == "proton7-container" or (
|
||||||
|
settings.runtime == "auto" and umu
|
||||||
|
):
|
||||||
|
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))
|
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||||
if settings.runtime == "proton7":
|
if settings.runtime in {"proton7", "proton7-container"}:
|
||||||
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
|
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
|
||||||
if settings.runtime in {"auto", "umu"}:
|
if settings.runtime in {"auto", "umu"}:
|
||||||
umu = shutil.which("umu-run")
|
|
||||||
if umu:
|
if umu:
|
||||||
return RuntimeSelection("umu", umu, None, None)
|
return RuntimeSelection("umu", umu, None, None)
|
||||||
raise RuntimeErrorPT(
|
raise RuntimeErrorPT(
|
||||||
@@ -120,7 +151,7 @@ def prepare_game_runtime(
|
|||||||
timeout_seconds=1800.0,
|
timeout_seconds=1800.0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif selection.kind == "proton7":
|
elif selection.kind in {"proton7", "proton7-container"}:
|
||||||
if not selection.proton_path or not selection.steam_root:
|
if not selection.proton_path or not selection.steam_root:
|
||||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||||
if not runner.dry_run:
|
if not runner.dry_run:
|
||||||
@@ -219,6 +250,10 @@ def run_game(
|
|||||||
runner: CommandRunner,
|
runner: CommandRunner,
|
||||||
*,
|
*,
|
||||||
use_gamebox: bool = True,
|
use_gamebox: bool = True,
|
||||||
|
timeout_seconds: float = 12 * 60 * 60,
|
||||||
|
check: bool = True,
|
||||||
|
extra_environment: Mapping[str, str] | None = None,
|
||||||
|
output_line_filter: OutputLineFilter | None = None,
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||||
@@ -229,6 +264,19 @@ def run_game(
|
|||||||
"PTEU_REBORN",
|
"PTEU_REBORN",
|
||||||
]
|
]
|
||||||
environment = _umu_environment(paths)
|
environment = _umu_environment(paths)
|
||||||
|
elif selection.kind == "proton7-container":
|
||||||
|
if not selection.proton_path:
|
||||||
|
raise RuntimeErrorPT("Proton script path is missing")
|
||||||
|
game_command = [
|
||||||
|
selection.executable,
|
||||||
|
str(paths.runtime_view / "Game.exe"),
|
||||||
|
"PTEU_REBORN",
|
||||||
|
]
|
||||||
|
environment = _umu_proton7_environment(
|
||||||
|
paths,
|
||||||
|
selection,
|
||||||
|
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if not selection.proton_path:
|
if not selection.proton_path:
|
||||||
raise RuntimeErrorPT("Proton script path is missing")
|
raise RuntimeErrorPT("Proton script path is missing")
|
||||||
@@ -248,6 +296,8 @@ def run_game(
|
|||||||
)
|
)
|
||||||
if use_gamebox:
|
if use_gamebox:
|
||||||
game_command = build_gamebox_command(game_command, settings)
|
game_command = build_gamebox_command(game_command, settings)
|
||||||
|
if extra_environment:
|
||||||
|
environment.update(extra_environment)
|
||||||
environment["PYTHONPATH"] = _runtime_python_path(
|
environment["PYTHONPATH"] = _runtime_python_path(
|
||||||
os.environ.get("PYTHONPATH"),
|
os.environ.get("PYTHONPATH"),
|
||||||
)
|
)
|
||||||
@@ -255,8 +305,40 @@ def run_game(
|
|||||||
game_command,
|
game_command,
|
||||||
cwd=paths.runtime_view,
|
cwd=paths.runtime_view,
|
||||||
env_overrides=environment,
|
env_overrides=environment,
|
||||||
timeout_seconds=12 * 60 * 60,
|
timeout_seconds=timeout_seconds,
|
||||||
|
check=check,
|
||||||
stream_output=True,
|
stream_output=True,
|
||||||
|
output_line_filter=output_line_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,6 +360,33 @@ def _umu_environment(paths: AppPaths) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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(_proton_sync_environment(synchronization))
|
||||||
|
return environment
|
||||||
|
|
||||||
|
|
||||||
def _proton_environment(
|
def _proton_environment(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
selection: RuntimeSelection,
|
selection: RuntimeSelection,
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ def _validate_release_directory(release: Path) -> str:
|
|||||||
Path("ptlinux") / PUBLIC_KEY_FILENAME,
|
Path("ptlinux") / PUBLIC_KEY_FILENAME,
|
||||||
Path("ptdiag/__init__.py"),
|
Path("ptdiag/__init__.py"),
|
||||||
Path("ptdiag/assbin.py"),
|
Path("ptdiag/assbin.py"),
|
||||||
|
Path("ptdiag/logs.py"),
|
||||||
):
|
):
|
||||||
if not (release / required).is_file():
|
if not (release / required).is_file():
|
||||||
raise LauncherUpdateError(f"launcher release is missing {required}")
|
raise LauncherUpdateError(f"launcher release is missing {required}")
|
||||||
|
|||||||
164
ptlinux/ui.py
164
ptlinux/ui.py
@@ -1350,7 +1350,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
).pack(anchor="w", pady=(1, 0))
|
).pack(anchor="w", pady=(1, 0))
|
||||||
tk.Label(
|
tk.Label(
|
||||||
header_copy,
|
header_copy,
|
||||||
text="Tune your display, rendering, and compatibility preferences.",
|
text="Tune the game window, runtime, and compatibility preferences.",
|
||||||
background="#24170f",
|
background="#24170f",
|
||||||
foreground="#c9baaa",
|
foreground="#c9baaa",
|
||||||
font=("Sans", 9),
|
font=("Sans", 9),
|
||||||
@@ -1361,55 +1361,12 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
content.columnconfigure(0, weight=1, uniform="settings-card")
|
content.columnconfigure(0, weight=1, uniform="settings-card")
|
||||||
content.columnconfigure(1, weight=1, uniform="settings-card")
|
content.columnconfigure(1, weight=1, uniform="settings-card")
|
||||||
|
|
||||||
width_value = tk.StringVar(value=str(current_settings.width))
|
|
||||||
height_value = tk.StringVar(value=str(current_settings.height))
|
|
||||||
output_width_value = tk.StringVar(value=str(current_settings.output_width))
|
|
||||||
output_height_value = tk.StringVar(value=str(current_settings.output_height))
|
|
||||||
refresh_rate_value = tk.StringVar(value=str(current_settings.refresh_rate))
|
|
||||||
fullscreen_value = tk.BooleanVar(value=current_settings.fullscreen)
|
|
||||||
borderless_value = tk.BooleanVar(value=current_settings.borderless)
|
borderless_value = tk.BooleanVar(value=current_settings.borderless)
|
||||||
scaler_value = tk.StringVar(value=current_settings.scaler)
|
|
||||||
filter_value = tk.StringVar(value=current_settings.filter)
|
|
||||||
runtime_value = tk.StringVar(value=current_settings.runtime)
|
runtime_value = tk.StringVar(value=current_settings.runtime)
|
||||||
proton_sync_value = tk.StringVar(value=current_settings.proton_sync)
|
proton_sync_value = tk.StringVar(value=current_settings.proton_sync)
|
||||||
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
|
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
|
||||||
game_source_value = tk.StringVar(value=current_settings.game_source or "")
|
game_source_value = tk.StringVar(value=current_settings.game_source or "")
|
||||||
|
|
||||||
def add_dimensions(
|
|
||||||
parent: ttk.LabelFrame,
|
|
||||||
row: int,
|
|
||||||
label: str,
|
|
||||||
description: str,
|
|
||||||
width_variable: tk.StringVar,
|
|
||||||
height_variable: tk.StringVar,
|
|
||||||
) -> None:
|
|
||||||
ttk.Label(parent, text=label, style="SettingsText.TLabel").grid(
|
|
||||||
row=row,
|
|
||||||
column=0,
|
|
||||||
sticky="w",
|
|
||||||
)
|
|
||||||
ttk.Label(parent, text=description, style="SettingsHint.TLabel").grid(
|
|
||||||
row=row,
|
|
||||||
column=1,
|
|
||||||
sticky="e",
|
|
||||||
)
|
|
||||||
fields = ttk.Frame(parent, style="SettingsCard.TFrame")
|
|
||||||
fields.grid(row=row + 1, column=0, columnspan=2, sticky="ew", pady=(3, 9))
|
|
||||||
ttk.Label(fields, text="Width", style="SettingsHint.TLabel").pack(side="left")
|
|
||||||
ttk.Entry(
|
|
||||||
fields,
|
|
||||||
textvariable=width_variable,
|
|
||||||
width=8,
|
|
||||||
style="Settings.TEntry",
|
|
||||||
).pack(side="left", padx=(5, 12))
|
|
||||||
ttk.Label(fields, text="Height", style="SettingsHint.TLabel").pack(side="left")
|
|
||||||
ttk.Entry(
|
|
||||||
fields,
|
|
||||||
textvariable=height_variable,
|
|
||||||
width=8,
|
|
||||||
style="Settings.TEntry",
|
|
||||||
).pack(side="left", padx=(5, 0))
|
|
||||||
|
|
||||||
def add_choice(
|
def add_choice(
|
||||||
parent: ttk.LabelFrame,
|
parent: ttk.LabelFrame,
|
||||||
row: int,
|
row: int,
|
||||||
@@ -1492,25 +1449,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
settings_window.destroy()
|
settings_window.destroy()
|
||||||
settings_window = None
|
settings_window = None
|
||||||
|
|
||||||
def integer_value(variable: tk.StringVar, name: str) -> int:
|
|
||||||
value = variable.get().strip()
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError as error:
|
|
||||||
raise ValueError(f"{name} must be a whole number") from error
|
|
||||||
|
|
||||||
def save_current_settings() -> None:
|
def save_current_settings() -> None:
|
||||||
try:
|
try:
|
||||||
updated_settings = Settings(
|
updated_settings = Settings(
|
||||||
width=integer_value(width_value, "Client width"),
|
width=current_settings.width,
|
||||||
height=integer_value(height_value, "Client height"),
|
height=current_settings.height,
|
||||||
output_width=integer_value(output_width_value, "Output width"),
|
output_width=current_settings.output_width,
|
||||||
output_height=integer_value(output_height_value, "Output height"),
|
output_height=current_settings.output_height,
|
||||||
refresh_rate=integer_value(refresh_rate_value, "Refresh rate"),
|
refresh_rate=current_settings.refresh_rate,
|
||||||
fullscreen=fullscreen_value.get(),
|
fullscreen=current_settings.fullscreen,
|
||||||
borderless=borderless_value.get(),
|
borderless=borderless_value.get(),
|
||||||
scaler=scaler_value.get(),
|
scaler=current_settings.scaler,
|
||||||
filter=filter_value.get(),
|
filter=current_settings.filter,
|
||||||
runtime=runtime_value.get(),
|
runtime=runtime_value.get(),
|
||||||
proton_sync=proton_sync_value.get(),
|
proton_sync=proton_sync_value.get(),
|
||||||
proton_path=proton_path_value.get().strip() or None,
|
proton_path=proton_path_value.get().strip() or None,
|
||||||
@@ -1527,7 +1477,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
|
|
||||||
display_card = ttk.LabelFrame(
|
display_card = ttk.LabelFrame(
|
||||||
content,
|
content,
|
||||||
text="DISPLAY",
|
text="GAME WINDOW",
|
||||||
style="SettingsCard.TLabelframe",
|
style="SettingsCard.TLabelframe",
|
||||||
padding=14,
|
padding=14,
|
||||||
)
|
)
|
||||||
@@ -1536,66 +1486,25 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
display_card.columnconfigure(1, weight=1)
|
display_card.columnconfigure(1, weight=1)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
display_card,
|
display_card,
|
||||||
text="Choose the game canvas and the Gamescope output it is presented on.",
|
text=(
|
||||||
|
"The launcher uses the proven client resolution and can remove the "
|
||||||
|
"new game window's host decorations."
|
||||||
|
),
|
||||||
style="SettingsHint.TLabel",
|
style="SettingsHint.TLabel",
|
||||||
wraplength=360,
|
wraplength=360,
|
||||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||||
add_dimensions(
|
|
||||||
display_card,
|
|
||||||
1,
|
|
||||||
"Game resolution",
|
|
||||||
"Internal client size",
|
|
||||||
width_value,
|
|
||||||
height_value,
|
|
||||||
)
|
|
||||||
add_dimensions(
|
|
||||||
display_card,
|
|
||||||
3,
|
|
||||||
"Output resolution",
|
|
||||||
"Gamescope desktop",
|
|
||||||
output_width_value,
|
|
||||||
output_height_value,
|
|
||||||
)
|
|
||||||
ttk.Label(display_card, text="Refresh rate", style="SettingsText.TLabel").grid(
|
|
||||||
row=5,
|
|
||||||
column=0,
|
|
||||||
sticky="w",
|
|
||||||
)
|
|
||||||
ttk.Label(
|
|
||||||
display_card,
|
|
||||||
text="Output refresh",
|
|
||||||
style="SettingsHint.TLabel",
|
|
||||||
).grid(row=5, column=1, sticky="e")
|
|
||||||
refresh_fields = ttk.Frame(display_card, style="SettingsCard.TFrame")
|
|
||||||
refresh_fields.grid(row=6, column=0, columnspan=2, sticky="w", pady=(3, 8))
|
|
||||||
ttk.Entry(
|
|
||||||
refresh_fields,
|
|
||||||
textvariable=refresh_rate_value,
|
|
||||||
width=8,
|
|
||||||
style="Settings.TEntry",
|
|
||||||
).pack(side="left")
|
|
||||||
ttk.Label(refresh_fields, text="Hz", style="SettingsHint.TLabel").pack(
|
|
||||||
side="left",
|
|
||||||
padx=(5, 0),
|
|
||||||
)
|
|
||||||
presentation = ttk.Frame(display_card, style="SettingsCard.TFrame")
|
presentation = ttk.Frame(display_card, style="SettingsCard.TFrame")
|
||||||
presentation.grid(row=7, column=0, columnspan=2, sticky="w")
|
presentation.grid(row=1, column=0, columnspan=2, sticky="w")
|
||||||
ttk.Checkbutton(
|
|
||||||
presentation,
|
|
||||||
text="Fullscreen",
|
|
||||||
variable=fullscreen_value,
|
|
||||||
style="Settings.TCheckbutton",
|
|
||||||
).pack(side="left")
|
|
||||||
ttk.Checkbutton(
|
ttk.Checkbutton(
|
||||||
presentation,
|
presentation,
|
||||||
text="Borderless",
|
text="Borderless",
|
||||||
variable=borderless_value,
|
variable=borderless_value,
|
||||||
style="Settings.TCheckbutton",
|
style="Settings.TCheckbutton",
|
||||||
).pack(side="left", padx=(18, 0))
|
).pack(side="left")
|
||||||
|
|
||||||
rendering_card = ttk.LabelFrame(
|
rendering_card = ttk.LabelFrame(
|
||||||
content,
|
content,
|
||||||
text="RENDERING & RUNTIME",
|
text="RUNTIME",
|
||||||
style="SettingsCard.TLabelframe",
|
style="SettingsCard.TLabelframe",
|
||||||
padding=14,
|
padding=14,
|
||||||
)
|
)
|
||||||
@@ -1604,44 +1513,21 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
rendering_card.columnconfigure(1, weight=1)
|
rendering_card.columnconfigure(1, weight=1)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
rendering_card,
|
rendering_card,
|
||||||
text="Control how the client is scaled and which compatible runtime launches it.",
|
text="Choose which compatibility runtime launches the proven game setup.",
|
||||||
style="SettingsHint.TLabel",
|
style="SettingsHint.TLabel",
|
||||||
wraplength=360,
|
wraplength=360,
|
||||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||||
add_choice(
|
add_choice(
|
||||||
rendering_card,
|
rendering_card,
|
||||||
1,
|
1,
|
||||||
"Scaling mode",
|
"Runtime",
|
||||||
"Gamescope layout",
|
"Compatibility layer",
|
||||||
scaler_value,
|
runtime_value,
|
||||||
("auto", "integer", "fit", "fill", "stretch"),
|
("auto", "proton7-container", "proton7", "umu"),
|
||||||
)
|
)
|
||||||
add_choice(
|
add_choice(
|
||||||
rendering_card,
|
rendering_card,
|
||||||
3,
|
3,
|
||||||
"Image filter",
|
|
||||||
"Upscaling filter",
|
|
||||||
filter_value,
|
|
||||||
("linear", "nearest", "fsr", "nis", "pixel"),
|
|
||||||
)
|
|
||||||
ttk.Separator(rendering_card).grid(
|
|
||||||
row=5,
|
|
||||||
column=0,
|
|
||||||
columnspan=2,
|
|
||||||
sticky="ew",
|
|
||||||
pady=(2, 9),
|
|
||||||
)
|
|
||||||
add_choice(
|
|
||||||
rendering_card,
|
|
||||||
6,
|
|
||||||
"Runtime",
|
|
||||||
"Compatibility layer",
|
|
||||||
runtime_value,
|
|
||||||
("auto", "proton7", "umu"),
|
|
||||||
)
|
|
||||||
add_choice(
|
|
||||||
rendering_card,
|
|
||||||
8,
|
|
||||||
"Proton synchronization",
|
"Proton synchronization",
|
||||||
"Auto uses esync on CachyOS",
|
"Auto uses esync on CachyOS",
|
||||||
proton_sync_value,
|
proton_sync_value,
|
||||||
@@ -1672,8 +1558,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
add_path(
|
add_path(
|
||||||
overrides_card,
|
overrides_card,
|
||||||
3,
|
3,
|
||||||
"Game source",
|
"Import source",
|
||||||
"Import from this existing game folder",
|
"Seed a new managed installation only",
|
||||||
game_source_value,
|
game_source_value,
|
||||||
choose_game_source,
|
choose_game_source,
|
||||||
)
|
)
|
||||||
@@ -1683,7 +1569,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
|||||||
footer.columnconfigure(0, weight=1)
|
footer.columnconfigure(0, weight=1)
|
||||||
ttk.Label(
|
ttk.Label(
|
||||||
footer,
|
footer,
|
||||||
text="Settings are stored locally and used the next time you press Play.",
|
text="Only the settings shown here affect the next launcher operation.",
|
||||||
style="SettingsFooter.TLabel",
|
style="SettingsFooter.TLabel",
|
||||||
).grid(row=0, column=0, sticky="w")
|
).grid(row=0, column=0, sticky="w")
|
||||||
buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame")
|
buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame")
|
||||||
|
|||||||
Reference in New Issue
Block a user