More fixes to support arch distros.

This commit is contained in:
2026-08-28 20:15:32 +02:00
parent d2c66b6316
commit b37b5291ae
5 changed files with 309 additions and 219 deletions

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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