More fixes to support arch distros.
This commit is contained in:
@@ -7,11 +7,15 @@ from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Mapping, Sequence, TextIO
|
||||
from typing import Callable, Mapping, Sequence, TextIO
|
||||
|
||||
|
||||
OutputLineFilter = Callable[[str], str | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,6 +57,7 @@ class CommandRunner:
|
||||
timeout_seconds: float = 300.0,
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
) -> CommandOutcome:
|
||||
arguments = [str(value) for value in argv]
|
||||
if not arguments:
|
||||
@@ -81,6 +86,7 @@ class CommandRunner:
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
started=started,
|
||||
output_line_filter=output_line_filter,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -126,6 +132,7 @@ class CommandRunner:
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: float,
|
||||
started: float,
|
||||
output_line_filter: OutputLineFilter | None,
|
||||
) -> CommandOutcome:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
@@ -135,6 +142,7 @@ class CommandRunner:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
stdout: list[str] = []
|
||||
stderr: list[str] = []
|
||||
@@ -144,13 +152,21 @@ class CommandRunner:
|
||||
capture: list[str],
|
||||
destination: TextIO,
|
||||
) -> None:
|
||||
try:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
if output_line_filter is not None:
|
||||
line = output_line_filter(line)
|
||||
if line is None:
|
||||
continue
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
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.stderr is not None
|
||||
@@ -171,12 +187,12 @@ class CommandRunner:
|
||||
returncode = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
process.kill()
|
||||
returncode = process.wait()
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
returncode = _terminate_process_group(process)
|
||||
except BaseException:
|
||||
_terminate_process_group(process)
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
raise
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
return CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None if timed_out else returncode,
|
||||
@@ -210,3 +226,49 @@ def _as_text(value: bytes | str | None) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
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 .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")
|
||||
@@ -71,17 +75,11 @@ class CompatibilityCacheResult:
|
||||
created_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePatchCacheResult:
|
||||
source: str
|
||||
destination: str
|
||||
game_dll_sha256: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def prepare_runtime_view(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
*,
|
||||
use_world_fix: bool = True,
|
||||
) -> OverlayResult:
|
||||
"""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
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
action = _scene_action(inspection)
|
||||
game_save = _prepare_persistent_save(paths, source)
|
||||
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
|
||||
paths,
|
||||
source,
|
||||
use_world_fix=use_world_fix,
|
||||
)
|
||||
|
||||
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
|
||||
@@ -101,7 +101,7 @@ def prepare_runtime_view(
|
||||
links_created = 0
|
||||
try:
|
||||
build.mkdir(parents=True, exist_ok=False)
|
||||
top_level_exceptions = {"game"}
|
||||
top_level_exceptions = {"game", "save"}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
@@ -113,6 +113,8 @@ def prepare_runtime_view(
|
||||
build,
|
||||
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":
|
||||
(build / "Game.exe").symlink_to(game_exe_source)
|
||||
links_created += 1
|
||||
@@ -202,41 +204,14 @@ def cache_compatibility_binaries(
|
||||
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(
|
||||
cache_hashes: dict[str, str] | None,
|
||||
source_hashes: dict[str, str],
|
||||
runtime_patch_hash: str | None = None,
|
||||
) -> str:
|
||||
if runtime_patch_hash is not None:
|
||||
if runtime_patch_hash != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch cache has an unknown binary profile")
|
||||
if runtime_patch_hash != POST_AUTH_RECLAIM_GAME_DLL_SHA256:
|
||||
raise OverlayError("post-authentication runtime patch has an unknown profile")
|
||||
if cache_hashes is not None:
|
||||
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
|
||||
raise OverlayError("compatibility cache has an unknown binary profile")
|
||||
@@ -244,8 +219,10 @@ def select_binary_action(
|
||||
return "verified-source-pair"
|
||||
if runtime_patch_hash is not None:
|
||||
if source_hashes != CURRENT_V21_BINARY_HASHES:
|
||||
raise OverlayError("V21 runtime patch does not match the installed client profile")
|
||||
return "verified-v21-texture-reclaim"
|
||||
raise OverlayError(
|
||||
"post-authentication runtime patch does not match the installed client profile"
|
||||
)
|
||||
return "verified-v21-post-auth-world-fix"
|
||||
return "upstream-pair"
|
||||
|
||||
|
||||
@@ -349,23 +326,87 @@ def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
|
||||
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_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||
source_hashes = _binary_hashes(source)
|
||||
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:
|
||||
result = build_texture_reclaim_patch(source / "game.dll", patch)
|
||||
except TextureReclaimPatchError as exc:
|
||||
raise OverlayError(f"could not build the verified V21 runtime patch: {exc}") from exc
|
||||
_write_json(patch.parent / "patch.json", {"ok": True, **asdict(result)})
|
||||
patch_hash = _sha256(patch) if patch.is_file() else None
|
||||
result = build_post_auth_reclaim_patch(source / "game.dll", patch)
|
||||
except PostAuthReclaimPatchError as exc:
|
||||
raise OverlayError(
|
||||
f"could not build the verified post-authentication world fix: {exc}"
|
||||
) 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)
|
||||
return (
|
||||
action,
|
||||
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:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@@ -10,9 +10,9 @@ from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
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 .paths import AppPaths
|
||||
from .settings import Settings
|
||||
@@ -47,21 +47,52 @@ def select_runtime(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> RuntimeSelection:
|
||||
env = os.environ if environment is None else environment
|
||||
umu = shutil.which("umu-run", path=env.get("PATH"))
|
||||
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)
|
||||
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))
|
||||
if settings.runtime in {"auto", "proton7"}:
|
||||
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 (
|
||||
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))
|
||||
if settings.runtime == "proton7":
|
||||
if settings.runtime in {"proton7", "proton7-container"}:
|
||||
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(
|
||||
@@ -120,7 +151,7 @@ def prepare_game_runtime(
|
||||
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:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
if not runner.dry_run:
|
||||
@@ -219,6 +250,10 @@ def run_game(
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
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:
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
@@ -229,6 +264,19 @@ def run_game(
|
||||
"PTEU_REBORN",
|
||||
]
|
||||
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:
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
@@ -248,6 +296,8 @@ def run_game(
|
||||
)
|
||||
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"),
|
||||
)
|
||||
@@ -255,8 +305,40 @@ def run_game(
|
||||
game_command,
|
||||
cwd=paths.runtime_view,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=12 * 60 * 60,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
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(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
|
||||
@@ -639,6 +639,7 @@ def _validate_release_directory(release: Path) -> str:
|
||||
Path("ptlinux") / PUBLIC_KEY_FILENAME,
|
||||
Path("ptdiag/__init__.py"),
|
||||
Path("ptdiag/assbin.py"),
|
||||
Path("ptdiag/logs.py"),
|
||||
):
|
||||
if not (release / required).is_file():
|
||||
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))
|
||||
tk.Label(
|
||||
header_copy,
|
||||
text="Tune your display, rendering, and compatibility preferences.",
|
||||
text="Tune the game window, runtime, and compatibility preferences.",
|
||||
background="#24170f",
|
||||
foreground="#c9baaa",
|
||||
font=("Sans", 9),
|
||||
@@ -1361,55 +1361,12 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
content.columnconfigure(0, 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)
|
||||
scaler_value = tk.StringVar(value=current_settings.scaler)
|
||||
filter_value = tk.StringVar(value=current_settings.filter)
|
||||
runtime_value = tk.StringVar(value=current_settings.runtime)
|
||||
proton_sync_value = tk.StringVar(value=current_settings.proton_sync)
|
||||
proton_path_value = tk.StringVar(value=current_settings.proton_path 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(
|
||||
parent: ttk.LabelFrame,
|
||||
row: int,
|
||||
@@ -1492,25 +1449,18 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
settings_window.destroy()
|
||||
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:
|
||||
try:
|
||||
updated_settings = Settings(
|
||||
width=integer_value(width_value, "Client width"),
|
||||
height=integer_value(height_value, "Client height"),
|
||||
output_width=integer_value(output_width_value, "Output width"),
|
||||
output_height=integer_value(output_height_value, "Output height"),
|
||||
refresh_rate=integer_value(refresh_rate_value, "Refresh rate"),
|
||||
fullscreen=fullscreen_value.get(),
|
||||
width=current_settings.width,
|
||||
height=current_settings.height,
|
||||
output_width=current_settings.output_width,
|
||||
output_height=current_settings.output_height,
|
||||
refresh_rate=current_settings.refresh_rate,
|
||||
fullscreen=current_settings.fullscreen,
|
||||
borderless=borderless_value.get(),
|
||||
scaler=scaler_value.get(),
|
||||
filter=filter_value.get(),
|
||||
scaler=current_settings.scaler,
|
||||
filter=current_settings.filter,
|
||||
runtime=runtime_value.get(),
|
||||
proton_sync=proton_sync_value.get(),
|
||||
proton_path=proton_path_value.get().strip() or None,
|
||||
@@ -1527,7 +1477,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
|
||||
display_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="DISPLAY",
|
||||
text="GAME WINDOW",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
@@ -1536,66 +1486,25 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
display_card.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
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",
|
||||
wraplength=360,
|
||||
).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.grid(row=7, column=0, columnspan=2, sticky="w")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Fullscreen",
|
||||
variable=fullscreen_value,
|
||||
style="Settings.TCheckbutton",
|
||||
).pack(side="left")
|
||||
presentation.grid(row=1, column=0, columnspan=2, sticky="w")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Borderless",
|
||||
variable=borderless_value,
|
||||
style="Settings.TCheckbutton",
|
||||
).pack(side="left", padx=(18, 0))
|
||||
).pack(side="left")
|
||||
|
||||
rendering_card = ttk.LabelFrame(
|
||||
content,
|
||||
text="RENDERING & RUNTIME",
|
||||
text="RUNTIME",
|
||||
style="SettingsCard.TLabelframe",
|
||||
padding=14,
|
||||
)
|
||||
@@ -1604,44 +1513,21 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
rendering_card.columnconfigure(1, weight=1)
|
||||
ttk.Label(
|
||||
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",
|
||||
wraplength=360,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 11))
|
||||
add_choice(
|
||||
rendering_card,
|
||||
1,
|
||||
"Scaling mode",
|
||||
"Gamescope layout",
|
||||
scaler_value,
|
||||
("auto", "integer", "fit", "fill", "stretch"),
|
||||
"Runtime",
|
||||
"Compatibility layer",
|
||||
runtime_value,
|
||||
("auto", "proton7-container", "proton7", "umu"),
|
||||
)
|
||||
add_choice(
|
||||
rendering_card,
|
||||
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",
|
||||
"Auto uses esync on CachyOS",
|
||||
proton_sync_value,
|
||||
@@ -1672,8 +1558,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
add_path(
|
||||
overrides_card,
|
||||
3,
|
||||
"Game source",
|
||||
"Import from this existing game folder",
|
||||
"Import source",
|
||||
"Seed a new managed installation only",
|
||||
game_source_value,
|
||||
choose_game_source,
|
||||
)
|
||||
@@ -1683,7 +1569,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
footer.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
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",
|
||||
).grid(row=0, column=0, sticky="w")
|
||||
buttons_row = ttk.Frame(footer, style="SettingsFooter.TFrame")
|
||||
|
||||
Reference in New Issue
Block a user