diff --git a/ptlinux/command.py b/ptlinux/command.py index 747df96..6b4c1c5 100644 --- a/ptlinux/command.py +++ b/ptlinux/command.py @@ -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: - while True: - line = stream.readline() - if not line: - return - capture.append(line) - destination.write(line) - destination.flush() + 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) diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py index 4a84117..2e67448 100644 --- a/ptlinux/overlay.py +++ b/ptlinux/overlay.py @@ -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(): - 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 + 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_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) diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 0ab16fd..bb2bc6c 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -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, diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index e5e0d60..8c3d345 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -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}") diff --git a/ptlinux/ui.py b/ptlinux/ui.py index ee3ed40..d051f22 100644 --- a/ptlinux/ui.py +++ b/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")