diff --git a/src/ptlinux/__init__.py b/src/ptlinux/__init__.py index 1ae9750..c2f1fcd 100644 --- a/src/ptlinux/__init__.py +++ b/src/ptlinux/__init__.py @@ -4,4 +4,4 @@ from .paths import AppPaths __all__ = ["AppPaths", "__version__"] -__version__ = "1.0.8" +__version__ = "1.1.0" diff --git a/src/ptlinux/__main__.py b/src/ptlinux/__main__.py index ccac227..5b710d3 100644 --- a/src/ptlinux/__main__.py +++ b/src/ptlinux/__main__.py @@ -8,6 +8,9 @@ def main() -> int: arguments = sys.argv[1:] if not arguments or arguments == ["gui"]: + from .gui_bootstrap import ( + GuiDependencyError, missing_tk_message, recover_gui_python, show_gui_dependency_error, + ) from .container_host import ( container_host_error, repair_container_host_after_update, @@ -20,10 +23,13 @@ def main() -> int: telemetry_owner = False try: paths = AppPaths.from_environment() + gui_ready = recover_gui_python(arguments) telemetry, telemetry_owner = initialize_shared_telemetry( paths, session_metadata={"interface": "gui", "command": "gui"}, ) + if not gui_ready: + raise GuiDependencyError(missing_tk_message()) # Older installed releases cannot run a bootstrap step that only exists # in their update payload. Their automatic restart enters here instead. provision_umu_for_startup(paths) @@ -42,6 +48,16 @@ def main() -> int: run_dashboard(paths) except KeyboardInterrupt: return 130 + except GuiDependencyError as exc: + if telemetry is not None: + telemetry.send_event( + "dependency_check_failed", severity="error", message=str(exc), + metadata={"command": "gui", "dependencies": ["python-tk"]}, + context={"operation": "launcher", "stage": "dependency_check"}, + ) + print(f"Error: {exc}", file=sys.stderr) + show_gui_dependency_error(str(exc)) + return 1 except (ValueError, OSError, RuntimeError) as exc: if telemetry is not None: telemetry.report_exception( diff --git a/src/ptlinux/application/commands/play.py b/src/ptlinux/application/commands/play.py index 4772238..50c26b0 100644 --- a/src/ptlinux/application/commands/play.py +++ b/src/ptlinux/application/commands/play.py @@ -15,6 +15,7 @@ from typing import Any from uuid import uuid4 from ...command import CommandOutcome +from ...depot import validate_installed_launch_catalog from ...diagnostics.crash_dumps import ( inspect_minidump, prepare_minidump_attachment, @@ -107,6 +108,7 @@ def play_locked( flush=True, ) window_launch_metadata: dict[str, object] = { + "renderer_requested": settings.renderer, "gamebox_enabled": not no_box, "resizable_window_requested": settings.allow_window_resizing, "launcher_inside_steam_runtime": launcher_nested_in_steam_runtime, @@ -119,8 +121,11 @@ def play_locked( window_launch_metadata["gamescope_disabled_reason"] = ( "launcher_inside_steam_runtime" ) + launch_catalog = api.read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache) + if game_dir is None: + validate_installed_launch_catalog(paths, launch_catalog) start_option = api.select_depot_start_option( - api.read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache), + launch_catalog, realm or settings.realm, ) settings = runtime_settings_for_realm(settings, start_option.name) @@ -229,8 +234,6 @@ def play_locked( }, context={"operation": "launch_game", "stage": "game_start"}, ) - if before_game is not None: - before_game() managed_asset_paths = ( [entry.path for entry in depot_file_catalog.files] if depot_file_catalog is not None @@ -332,6 +335,7 @@ def play_locked( else None ), pass_fds=game_lease_fds, + before_start=before_game, ) except KeyboardInterrupt: game_runtime_seconds = max(0.0, time.monotonic() - game_started_at) @@ -608,7 +612,7 @@ def game_exit_metadata( # Python gamebox sees it. wrapped_sigkill = 256 - int(signal.SIGKILL) if ( - runtime_kind in {"umu", "proton7", "proton7-container"} + runtime_kind in {"umu", "ge-proton", "proton7", "proton7-container"} and returncode == wrapped_sigkill ): wrapper = "proton" if runtime_kind == "proton7" else "umu" @@ -925,6 +929,10 @@ def runtime_issue_summary(stderr: str) -> dict[str, object]: "gstreamer_missing_library_count": len(missing_libraries), "gstreamer_missing_libraries": missing_libraries, "gstreamer_stream_error": "winegstreamer error" in folded, + "gstreamer_caps_error_count": sum( + "gst_caps_is_fixed (caps)" in line and "failed" in line + for line in folded.splitlines() + ), "mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded, "gamescope_present_queue_unsupported": ( "physical device queue doesn't support presenting on our surface" in folded @@ -932,6 +940,17 @@ def runtime_issue_summary(stderr: str) -> dict[str, object]: "gamescope_vulkan_output_failed_count": folded.count( "vulkan_make_output failed" ), + "gamescope_memory_protection_warning_count": folded.count( + "sd_bus_call(g_dbus, message, uint64_max, null, &reply)failed with ret" + ), + "gamescope_priority_fallback": "no cap_sys_nice, falling back" in folded, + "gamescope_forced_cleanup": ( + "gamescope did not stop gracefully; forcing cleanup" in folded + ), + "gamescope_child_status_missing": ( + "gamescope exited without a recorded game status" in folded + ), + "proton_game_drive_warning": "unable to use parent for game drive" in folded, "steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded, "dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion), "bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded, diff --git a/src/ptlinux/cli.py b/src/ptlinux/cli.py index a6e0299..d9a7c1a 100644 --- a/src/ptlinux/cli.py +++ b/src/ptlinux/cli.py @@ -154,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser: ) login_diagnostics.add_argument( "--runtime", - choices=("configured", "auto", "proton7", "proton7-container", "umu"), + choices=("configured", "auto", "proton7", "proton7-container", "umu", "ge-proton"), default="configured", help=( "probe one runtime without changing settings or starting the game " @@ -906,7 +906,7 @@ def _operation_guard( def _managed_game_process_is_active(paths: AppPaths) -> bool: return managed_game_process_is_active( paths.runtime_view, - (paths.proton_prefix, paths.umu_prefix), + (paths.proton_prefix, paths.umu_prefix, paths.ge_proton_prefix), ) diff --git a/src/ptlinux/dashboard_debug.py b/src/ptlinux/dashboard_debug.py index 922485b..043d641 100644 --- a/src/ptlinux/dashboard_debug.py +++ b/src/ptlinux/dashboard_debug.py @@ -95,7 +95,9 @@ def inspect_runtime_debug( selected_kind = selection.kind if selection is not None else None prefix_kind = selected_kind or prepared_kind prefix = None - if prefix_kind == "umu": + if prefix_kind == "ge-proton": + prefix = str(paths.ge_proton_prefix) + elif prefix_kind == "umu": prefix = str(paths.umu_prefix) elif prefix_kind in {"proton7", "proton7-container"}: prefix = str(paths.proton_prefix) diff --git a/src/ptlinux/depot.py b/src/ptlinux/depot.py index 39fce99..9122da1 100644 --- a/src/ptlinux/depot.py +++ b/src/ptlinux/depot.py @@ -270,6 +270,24 @@ def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None: return None +def validate_installed_launch_catalog( + paths: AppPaths, + catalog: DepotLaunchCatalog | None, +) -> None: + """Prevent a background update check from supplying arguments for another build.""" + + installed_build = _completed_depot_build(paths.native_depot_state) + if installed_build is None: + return # Legacy/external installations do not have a native depot receipt. + if catalog is None or catalog.build != installed_build: + advertised_build = str(catalog.build) if catalog is not None else "missing" + raise DepotError( + f"launch options build {advertised_build} does not match installed game " + f"build {installed_build}; run `pristontale-eu update` before playing " + "to refresh the game and its publisher launch parameters together" + ) + + def cache_depot_file_catalog( path: Path, game_directory: Path, diff --git a/src/ptlinux/doctor.py b/src/ptlinux/doctor.py index f922c34..540f3aa 100644 --- a/src/ptlinux/doctor.py +++ b/src/ptlinux/doctor.py @@ -3,17 +3,19 @@ from __future__ import annotations from dataclasses import asdict, dataclass -from importlib.util import find_spec import os from pathlib import Path import platform import shutil +import sys from typing import Mapping from .container_host import ContainerHostReport, inspect_container_host from .launcher import find_wine_tools +from .gui_bootstrap import tkinter_path from .paths import AppPaths -from .runtime import find_steam_proton7 +from .runtime import find_steam_proton7, game_runtime_prefix, native_d3d_ready, select_runtime +from .settings import load_settings from .umu import managed_umu_ready @@ -92,6 +94,8 @@ def inspect_host( environment: Mapping[str, str] | None = None, *, vulkan_support: Mapping[str, object] | None = None, + game_runtime: Mapping[str, object] | None = None, + runtime_prefix: Path | None = None, ) -> DoctorReport: env = os.environ if environment is None else environment os_release = _read_os_release() @@ -100,8 +104,14 @@ def inspect_host( architecture = platform.machine() wine_tools = find_wine_tools() umu_path = _umu_path(env) + runtime_kind, runtime_prefix = _selected_game_runtime(env, game_runtime, runtime_prefix) + legacy_setup_required = runtime_kind in {"proton7", "proton7-container"} and ( + runtime_prefix is None or not native_d3d_ready(runtime_prefix) + ) container_host = inspect_container_host(environment=env) - container_required = umu_path is not None + container_required = runtime_kind in {"umu", "ge-proton", "proton7-container"} or ( + runtime_kind is None and umu_path is not None + ) detected_vulkan = ( dict(vulkan_support) if vulkan_support is not None @@ -110,7 +120,7 @@ def inspect_host( tools = [ ToolStatus( "python3", - shutil.which("python3"), + sys.executable, True, "native installer and command-line interface", ), @@ -122,38 +132,38 @@ def inspect_host( ), ToolStatus( "wine", - str(wine_tools.wine) if wine_tools else None, - True, - "legacy updater support and game compatibility utilities", + shutil.which("wine", path=env.get("PATH")) or (str(wine_tools.wine) if wine_tools else None), + False, + "optional legacy Windows updater; Proton supplies game Wine", ), ToolStatus( "winetricks", - str(wine_tools.winetricks) if wine_tools else shutil.which("winetricks"), - True, - "native D3D helper DLLs for the Proton game prefix", + shutil.which("winetricks", path=env.get("PATH")), + legacy_setup_required, + "initial DirectX setup for Proton 7; UMU/GE supplies its own Winetricks", ), ToolStatus( "gamescope", - shutil.which("gamescope"), + shutil.which("gamescope", path=env.get("PATH")), False, "optional fixed-surface scaling for a stable resizable game window", ), ToolStatus( "cabextract", - shutil.which("cabextract"), - True, - "Microsoft runtime extraction through Winetricks", + shutil.which("cabextract", path=env.get("PATH")), + legacy_setup_required, + "initial host Winetricks extraction for Proton 7", ), ToolStatus( "openssl", - shutil.which("openssl"), + shutil.which("openssl", path=env.get("PATH")), True, "signed native-launcher update verification", ), ToolStatus( "umu-run", umu_path, - distribution in ARCH_FAMILY_DISTRIBUTIONS, + container_required or distribution in ARCH_FAMILY_DISTRIBUTIONS, "Steam Runtime container; managed automatically when needed", ), ToolStatus( @@ -164,13 +174,20 @@ def inspect_host( ), ToolStatus( "vulkaninfo", - shutil.which("vulkaninfo"), + shutil.which("vulkaninfo", path=env.get("PATH")), False, "Vulkan diagnostics", ), ] if architecture.casefold() in {"x86_64", "amd64"}: tools.extend(_vulkan_tool_statuses(detected_vulkan, distribution)) + if runtime_kind == "ge-proton": + # The GE profile requests modern WoW64: its 32-bit Windows client + # uses the 64-bit Unix Vulkan driver. + tools = [ + ToolStatus(item.name, item.path, False, "optional for GE-Proton WoW64") + if item.name == "vulkan-32bit" else item for item in tools + ] tools.extend(_platform_optional_tools(distribution, architecture=architecture)) warnings: list[str] = [] if architecture.casefold() not in {"x86_64", "amd64"}: @@ -226,37 +243,49 @@ def _read_os_release() -> dict[str, str]: def _python_tk_path() -> str | None: + return tkinter_path() + + +def _selected_game_runtime( + environment: Mapping[str, str], + runtime: Mapping[str, object] | None, + prefix: Path | None, +) -> tuple[str | None, Path | None]: + if runtime and runtime.get("kind") in {"umu", "ge-proton", "proton7", "proton7-container"}: + return str(runtime["kind"]), prefix try: - specification = find_spec("_tkinter") - except (ImportError, ValueError): - return None - if specification is None: - return None - return specification.origin or "available" + paths = AppPaths.from_environment(environment) + selection = select_runtime(load_settings(paths), environment, paths=paths) + return selection.kind, game_runtime_prefix(paths, selection) + except (OSError, ValueError, RuntimeError): + return None, None def _package_hint(distribution: str) -> str | None: if distribution in {"ubuntu", "debian", "linuxmint", "pop"}: return ( - "Install Python/Tk, Wine (64- and 32-bit), Winetricks, " - "Cabextract, OpenSSL, Bubblewrap, and both 64- and 32-bit Vulkan " - "drivers with APT. Ubuntu also needs its AppArmor tools." + "Install the missing required components with APT: python3-tk, openssl, " + "bubblewrap and the matching Vulkan drivers. Host winetricks/cabextract " + "are needed only for initial Proton 7 setup. Ubuntu also needs its AppArmor tools." ) if distribution in {"fedora", "nobara"}: return ( - "Install python3-tkinter, wine, winetricks, bubblewrap, cabextract, openssl, " - "and the matching 32-bit Vulkan driver with DNF." + "Install missing required components with DNF: python3-tkinter, bubblewrap, " + "openssl and matching Vulkan drivers; winetricks/cabextract for initial Proton 7 setup." ) if distribution in ARCH_FAMILY_DISTRIBUTIONS: return ( - "Install python, tk, wine, winetricks, umu-launcher, bubblewrap, cabextract, " - "openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman." + "Install missing required components with Pacman: python, tk, umu-launcher, " + "bubblewrap, openssl and matching Vulkan drivers; winetricks/cabextract " + "for initial Proton 7 setup." ) if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}: return ( - "Install Python/Tk, Wine, Winetricks, Bubblewrap, Cabextract, OpenSSL, and " - "32-bit Vulkan support with Zypper." + "Install missing required components with Zypper: Python/Tk, Bubblewrap, " + "OpenSSL and Vulkan drivers; Winetricks/Cabextract for initial Proton 7 setup." ) + if distribution == "steamos": + return "Use the managed UMU runtime on SteamOS; host Wine and Winetricks are optional." return None diff --git a/src/ptlinux/game/depot/catalogs.py b/src/ptlinux/game/depot/catalogs.py index 334fa65..a09ba41 100644 --- a/src/ptlinux/game/depot/catalogs.py +++ b/src/ptlinux/game/depot/catalogs.py @@ -42,7 +42,7 @@ def select_depot_start_option( catalog: DepotLaunchCatalog | None, preferred_realm: str | None = None, ) -> DepotStartOption: - """Choose a published realm, falling back to Babel when possible.""" + """Choose a published realm, falling back to the publisher's default.""" current = launcher_launch_catalog( catalog @@ -60,9 +60,6 @@ def select_depot_start_option( for option in current.start_options: if option.name.casefold() in preferred_names: return option - for option in current.start_options: - if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold(): - return option for option in current.start_options: if option.name.casefold() == current.default_start_option.casefold(): return option diff --git a/src/ptlinux/game/depot/manifest.py b/src/ptlinux/game/depot/manifest.py index 7066c88..ba61d05 100644 --- a/src/ptlinux/game/depot/manifest.py +++ b/src/ptlinux/game/depot/manifest.py @@ -165,11 +165,15 @@ def parse_start_options( raise DepotError( f"StartOptions path is not an executable for {name}: {path}" ) - arguments = start_option_text( - item.get("Arguments"), - f"StartOptions arguments for {name}", - 2048, - ) + arguments = item.get("Arguments") + if arguments is None: + arguments = "" + if ( + not isinstance(arguments, str) + or len(arguments) > 2048 + or any(ord(character) < 32 and character != "\t" for character in arguments) + ): + raise DepotError(f"invalid StartOptions arguments for {name}") options.append(DepotStartOption(name=name, path=path, arguments=arguments)) if default_value is None: diff --git a/src/ptlinux/game/depot/transfer.py b/src/ptlinux/game/depot/transfer.py index 56c6fa8..c37d303 100644 --- a/src/ptlinux/game/depot/transfer.py +++ b/src/ptlinux/game/depot/transfer.py @@ -376,6 +376,8 @@ class DepotHttpClient: self._candidate_servers: tuple[str, ...] = () self._download_observer = download_observer self._bandwidth_limiter: BandwidthLimiter | None = None + self._mirror_lock = threading.Lock() + self._mirror_backoff_until: dict[str, float] = {} @property def download_observer(self) -> DownloadObserver | None: @@ -447,7 +449,7 @@ class DepotHttpClient: last_error: _RetryableDownloadError | None = None attempts = len(self._retry_delays) + 1 for attempt_index in range(attempts): - attempt_server = servers[attempt_index % len(servers)] + attempt_server = self._download_server(servers, attempt_index) try: self._download_file_once( attempt_server, @@ -459,6 +461,8 @@ class DepotHttpClient: return except _RetryableDownloadError as exc: last_error = exc + with self._mirror_lock: + self._mirror_backoff_until[attempt_server] = self._policy.clock() + 60.0 self._notify_download( DownloadTelemetry( kind="retry", @@ -486,6 +490,20 @@ class DepotHttpClient: f"{last_error}{resume_hint}" ) from last_error + def _download_server(self, servers: tuple[str, ...], attempt: int) -> str: + """Share recent mirror failures across workers and subsequent files.""" + + offset = attempt % len(servers) + ordered = (*servers[offset:], *servers[:offset]) + now = self._policy.clock() + with self._mirror_lock: + for server in ordered: + if self._mirror_backoff_until.get(server, 0.0) <= now: + return server + # Exhausted mirrors still get bounded retries; a cooldown must never + # make an otherwise recoverable download fail without trying. + return ordered[0] + def _download_file_once( self, server: str, @@ -663,11 +681,15 @@ class DepotHttpClient: ) mode = "ab" if append else "wb" + # HTTPResponse.read(n) can lose bytes it buffered internally when a + # socket times out before n bytes arrive. read1 persists each available + # block, so the next mirror can resume from the actual saved offset. + read_chunk = getattr(response, "read1", response.read) try: with temporary.open(mode) as handle: while True: try: - chunk = response.read(self._policy.read_size()) + chunk = read_chunk(self._policy.read_size()) except (OSError, HTTPException) as exc: raise _RetryableDownloadError( f"could not read {file.path} from {server}: {exc}" @@ -740,16 +762,34 @@ class DepotHttpClient: pass def _get_json(self, url: str) -> Mapping[str, object]: + # Manifests also have regional failover. One local retry recovers a + # transient stall without spending the entire file retry budget here. + delays = self._retry_delays[:1] + for attempt in range(len(delays) + 1): + try: + return self._get_json_once(url) + except _RetryableDownloadError: + if attempt >= len(delays): + raise + self._sleeper(delays[attempt]) + raise AssertionError("manifest retry loop exhausted") + + def _get_json_once(self, url: str) -> Mapping[str, object]: request = Request(url, headers=self._policy.headers(), method="GET") try: response = self._opener(request, timeout=self.timeout_seconds) + except HTTPError as exc: + status = exc.code + exc.close() + error_type = _RetryableDownloadError if status in {408, 429} or 500 <= status < 600 else DepotError + raise error_type(f"HTTP {status} from {url}") from exc except (OSError, HTTPException) as exc: - raise DepotError(f"request failed for {url}: {exc}") from exc + raise _RetryableDownloadError(f"request failed for {url}: {exc}") from exc payload = b"" try: payload = response.read() except (OSError, HTTPException) as exc: - raise DepotError(f"could not read {url}: {exc}") from exc + raise _RetryableDownloadError(f"could not read {url}: {exc}") from exc finally: try: response.close() @@ -884,7 +924,10 @@ def content_range( def request_headers(version: str) -> dict[str, str]: - return {"User-Agent": f"PT-Linux/{version} (+https://pristontale.eu/)"} + return { + "User-Agent": f"PT-Linux/{version} (+https://pristontale.eu/)", + "Accept-Encoding": "identity", + } __all__ = [ diff --git a/src/ptlinux/game/ptr_compat.py b/src/ptlinux/game/ptr_compat.py index f314b6d..f681056 100644 --- a/src/ptlinux/game/ptr_compat.py +++ b/src/ptlinux/game/ptr_compat.py @@ -31,6 +31,8 @@ class PtrCompatibilityError(RuntimeError): def stage_ptr_bcrypt_compatibility( runtime_view: Path, proton_script: str | Path | None, + *, + read_only: bool = False, ) -> tuple[Path, Path]: """Stage the verified proxy and Proton 7's original 32-bit BCrypt DLL.""" @@ -66,6 +68,13 @@ def stage_ptr_bcrypt_compatibility( original_target = runtime_view / PTR_BCRYPT_ORIGINAL_RUNTIME_FILENAME proxy_target = runtime_view / PTR_BCRYPT_RUNTIME_FILENAME + if read_only: + for target, expected in ((original_target, original_bytes), (proxy_target, proxy_bytes)): + if target.is_symlink() or _read_file(target, "prepared PTR compatibility DLL") != expected: + raise PtrCompatibilityError( + "PTR compatibility files differ from the active runtime; close every client and launch again" + ) + return proxy_target, original_target # Install the forwarding target first, so a partial write can never expose a # proxy whose forwarded exports have nowhere to resolve. _write_runtime_file(original_target, original_bytes) @@ -81,10 +90,12 @@ def ptr_bcrypt_dll_overrides(current: str | None) -> str: entry = entry.strip() if not entry: continue - name, separator, _value = entry.partition("=") - if separator and name.strip().lstrip("*").casefold() == "bcrypt": + names, separator, value = entry.partition("=") + if not separator: continue - overrides.append(entry) + retained = [name.strip() for name in names.split(",") if name.strip() and name.strip().lstrip("*").casefold() != "bcrypt"] + if retained: + overrides.append(f"{','.join(retained)}={value}") overrides.append("bcrypt=n") return ";".join(overrides) diff --git a/src/ptlinux/game/runtime.py b/src/ptlinux/game/runtime.py index 3f96a74..7fa6e8e 100644 --- a/src/ptlinux/game/runtime.py +++ b/src/ptlinux/game/runtime.py @@ -10,10 +10,9 @@ import os from pathlib import Path, PurePosixPath import platform import re -import shlex import shutil import sys -from typing import Mapping, Sequence +from typing import Callable, Mapping, Sequence from ..command import CommandOutcome, CommandRunner, OutputLineFilter from ..container_host import container_host_error, inspect_container_host @@ -25,12 +24,15 @@ from ..gamebox import ( read_game_canvas_size, ) from ..paths import AppPaths -from ..settings import Settings +from ..settings import Settings, validate_proton_path_setting from ..system_identity import ( detect_distribution_id as _detect_distribution_id, is_running_inside_steam_runtime as _is_running_inside_steam_runtime, ) from ..umu import find_umu_launcher +from .arguments import split_windows_arguments +from .depot.manifest import normalize_relative_path +from .depot.models import DepotError from .depot.realms import ( PTR_BABEL_REALM_NAME, launcher_realm_name, @@ -122,16 +124,22 @@ def select_runtime( and bool(umu) and not current_umu_host ) - if settings.runtime == "umu": + if settings.runtime in {"umu", "ge-proton"}: if umu: - return RuntimeSelection("umu", umu, None, None) + return RuntimeSelection(settings.runtime, umu, None, None) raise RuntimeErrorPT("UMU was requested but umu-run was not found") if settings.proton_path and settings.runtime in { "auto", "proton7", "proton7-container", }: - proton = Path(settings.proton_path).expanduser().resolve() + try: + validate_proton_path_setting(settings.proton_path) + except ValueError as exc: + raise RuntimeErrorPT(str(exc)) from exc + proton = Path(settings.proton_path.strip().strip('"')).expanduser().resolve() + if proton.is_dir(): + proton /= "proton" if not proton.is_file(): raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}") steam_root = _steam_root_for_proton(proton, env) @@ -155,12 +163,11 @@ def select_runtime( str(steam_root), ) return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root)) - # Current UMU avoids several rolling-distribution container/media failures and - # remains the broad Arch-family compatibility default. Direct Proton 7 is also - # known to authenticate on CachyOS, so this is a compatibility policy rather - # than an assertion that Proton 7 itself causes server-side Code 1/Code 3. - if settings.runtime == "auto" and current_umu_host and umu: - return RuntimeSelection("umu", umu, None, None) + # Match the publisher's Linux launcher for current clients. GE's modern + # WoW64 runs the 32-bit client with 64-bit Unix libraries. A separate prefix + # keeps returning to the explicitly selected legacy runtimes possible. + if settings.runtime == "auto" and umu: + return RuntimeSelection("ge-proton", umu, None, None) if settings.runtime in {"auto", "proton7", "proton7-container"}: proton_result = find_steam_proton7(env) if proton_result is not None: @@ -304,9 +311,9 @@ def prepare_game_runtime( if not runner.dry_run: paths.create_base_directories() results: list[CommandOutcome] = [] - if selection.kind == "umu": + if selection.kind in {"umu", "ge-proton"}: environment = _umu_environment(paths, selection) - if not native_d3d_ready(paths.umu_prefix): + if not native_d3d_ready(game_runtime_prefix(paths, selection)): results.append( runner.run( [selection.executable, "winetricks", "-q", "d3dx9_43", "d3dcompiler_43"], @@ -400,6 +407,11 @@ def prepare_game_runtime( else: raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") if not runner.dry_run: + if not native_d3d_ready(game_runtime_prefix(paths, selection)): + raise RuntimeErrorPT( + "DirectX setup completed without usable native d3dx9_43 and " + "d3dcompiler_43 components; retry runtime setup before playing" + ) _write_json( paths.game_runtime_marker, { @@ -417,8 +429,14 @@ def prepare_game_runtime( def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool: """Return whether another client may reuse the prepared runtime read-only.""" - if selection.kind == "umu": - return native_d3d_ready(paths.umu_prefix) + if selection.kind in {"umu", "ge-proton"}: + marker = _read_json(paths.game_runtime_marker) + return bool( + marker + and marker.get("configuration_version") == GAME_RUNTIME_CONFIGURATION_VERSION + and marker.get("runtime") == asdict(selection) + and native_d3d_ready(game_runtime_prefix(paths, selection)) + ) if selection.kind in {"proton7", "proton7-container"}: return _game_runtime_ready(paths, selection) return False @@ -450,6 +468,7 @@ def run_game( extra_environment: Mapping[str, str] | None = None, output_line_filter: OutputLineFilter | None = None, pass_fds: Sequence[int] = (), + before_start: Callable[[], None] | None = None, ) -> CommandOutcome: _require_container_host(selection, runner) if not paths.runtime_view.is_dir() and not runner.dry_run: @@ -466,16 +485,19 @@ def run_game( stage_ptr_bcrypt_compatibility( paths.runtime_view, selection.proton_path, + read_only=concurrent_prefix_launch, ) except PtrCompatibilityError as exc: raise RuntimeErrorPT(str(exc)) from exc - if selection.kind == "umu": + if selection.kind in {"umu", "ge-proton"}: game_command = [ selection.executable, native_executable, *game_arguments, ] environment = _umu_environment(paths, selection) + if settings.proton_sync != "auto": + environment.update(_proton_sync_environment(settings.proton_sync)) # Build 22.80.90 can consume an uninitialized heap value as a vector # length under current Wine. Proton's scoped heap-zero mode prevents the # resulting std::length_error without changing publisher files. @@ -510,7 +532,7 @@ def run_game( selection, synchronization=resolve_proton_sync_mode(settings.proton_sync), ) - if selection.kind in {"umu", "proton7-container"}: + if selection.kind in {"umu", "ge-proton", "proton7-container"}: # Keep every UMU launch identical except for the verb, as required when # several games share one prefix. The first client owns prefix startup; # every later shared-lease client joins the active wineserver directly. @@ -519,6 +541,15 @@ def run_game( ) if extra_environment: environment.update(extra_environment) + if settings.renderer != "auto": + environment["PROTON_USE_WINED3D"] = "1" if settings.renderer == "wined3d" else "0" + renderer = settings.renderer + if environment.get("PROTON_USE_WINED3D", os.environ.get("PROTON_USE_WINED3D")) == "1": + renderer = "wined3d" + environment["WINEDLLOVERRIDES"] = _game_dll_overrides( + environment.get("WINEDLLOVERRIDES", os.environ.get("WINEDLLOVERRIDES", "")), + renderer, + ) if ptr_babel: existing_overrides = environment.get( "WINEDLLOVERRIDES", @@ -568,6 +599,15 @@ def run_game( environment["PYTHONPATH"] = _runtime_python_path( os.environ.get("PYTHONPATH"), ) + if not runner.dry_run: + _verify_shared_launch_environment( + paths, selection, environment, ptr_babel=ptr_babel, + concurrent=concurrent_prefix_launch, + ) + # All runtime-view writes and validation must finish while the first + # client's exclusive preparation lease is still held. + if before_start is not None: + before_start() return runner.run( game_command, cwd=paths.runtime_view, @@ -584,26 +624,58 @@ def run_game( ) +def _verify_shared_launch_environment( + paths: AppPaths, + selection: RuntimeSelection, + environment: Mapping[str, str], + *, + ptr_babel: bool, + concurrent: bool, +) -> None: + effective = {**os.environ, **environment} + # Hash compatibility options without recording potentially private values. + # Per-client window diagnostics and PROTON_VERB intentionally differ. + options = { + key: value for key, value in effective.items() + if key != "PROTON_VERB" and ( + key.startswith(("PROTON_", "WINE", "DXVK_", "VKD3D_", "STEAM_COMPAT_", "UMU_")) + or key in {"PROTONPATH", "GAMEID", "STORE"} + ) + } + profile = { + "runtime": asdict(selection), "ptr_babel": ptr_babel, + "environment_sha256": hashlib.sha256( + json.dumps(options, sort_keys=True).encode("utf-8") + ).hexdigest(), + } + marker = paths.state / "game-launch.json" + if concurrent: + if _read_json(marker) != profile: + raise RuntimeErrorPT( + "the active client's runtime options differ or cannot be verified; " + "close every client before changing renderer, synchronization, runtime, " + "or PTR compatibility settings" + ) + else: + _write_json(marker, profile) + + def _game_start_command( paths: AppPaths, option: DepotStartOption, ) -> tuple[str, str, tuple[str, ...]]: - relative = PurePosixPath(option.path) - if ( - relative.is_absolute() - or not relative.parts - or any(part in {"", ".", ".."} for part in relative.parts) - or relative.suffix.casefold() != ".exe" - ): + try: + relative = PurePosixPath(normalize_relative_path(option.path, "realm executable")) + except DepotError as exc: + raise RuntimeErrorPT(str(exc)) from exc + if relative.suffix.casefold() != ".exe": raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}") try: - arguments = tuple(shlex.split(option.arguments)) + arguments = split_windows_arguments(option.arguments) except ValueError as exc: raise RuntimeErrorPT( f"invalid launch arguments for realm {option.name}: {exc}" ) from exc - if not arguments: - raise RuntimeErrorPT(f"realm {option.name} has no launch arguments") native = str(_resolve_runtime_path_case(paths.runtime_view, relative)) windows_relative = "\\".join(relative.parts) return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments @@ -639,7 +711,10 @@ def _validate_start_option_runtime_files( runtime_view: Path, option: DepotStartOption, ) -> None: - for relative in required_start_option_files(option): + for relative in ( + PurePosixPath(normalize_relative_path(option.path, "realm executable")), + *required_start_option_files(option), + ): path = _resolve_runtime_path_case(runtime_view, relative) if not path.is_file(): raise RuntimeErrorPT( @@ -662,7 +737,7 @@ def run_runtime_prefix_command( if not arguments: raise ValueError("runtime-prefix command arguments cannot be empty") _require_container_host(selection, runner) - if selection.kind == "umu": + if selection.kind in {"umu", "ge-proton"}: command = [selection.executable, *arguments] environment = _umu_environment(paths, selection) elif selection.kind == "proton7-container": @@ -684,6 +759,9 @@ def run_runtime_prefix_command( environment = _proton_environment(paths, selection) else: raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") + if selection.kind in {"umu", "ge-proton", "proton7-container"}: + # Helpers join the existing wineserver instead of waiting for game exit. + environment["PROTON_VERB"] = "runinprefix" return runner.run( command, env_overrides=environment, @@ -697,7 +775,7 @@ def _require_container_host( selection: RuntimeSelection, runner: CommandRunner, ) -> None: - if runner.dry_run or selection.kind not in {"umu", "proton7-container"}: + if runner.dry_run or selection.kind not in {"umu", "ge-proton", "proton7-container"}: return report = inspect_container_host() if not report.ready: @@ -717,16 +795,49 @@ def _umu_environment( selection: RuntimeSelection, ) -> dict[str, str]: environment = { - "WINEPREFIX": str(paths.umu_prefix), + "WINEPREFIX": str(game_runtime_prefix(paths, selection)), "GAMEID": "umu-pristontale-eu", "STORE": "none", "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "WINEDEBUG": "-all", } - environment.update(_portable_umu_environment(paths, selection)) + if selection.kind == "ge-proton": + environment.update({"PROTONPATH": "GE-Proton", "PROTON_USE_WOW64": "1"}) + environment.update(_umu_launcher_environment(paths, selection)) return environment +def game_runtime_prefix(paths: AppPaths, selection: RuntimeSelection) -> Path: + if selection.kind == "ge-proton": + return paths.ge_proton_prefix + if selection.kind == "umu": + return paths.umu_prefix + return paths.proton_prefix + + +def _game_dll_overrides(existing: str, renderer: str) -> str: + """Keep native helpers selected even when Proton or a parent sets overrides.""" + + required = {name: "n,b" for name in NATIVE_D3D43_DLLS} + if renderer in {"wined3d", "dxvk"}: + # runinprefix does not rebuild d3d9.dll; a builtin override also makes + # WineD3D work with the DXVK DLL retained in the Proton 7 prefix. + required["d3d9"] = "b" if renderer == "wined3d" else "n,b" + entries: list[str] = [] + for entry in existing.split(";"): + names, separator, mode = entry.partition("=") + if not separator: + continue + retained = [ + name.strip() for name in names.split(",") + if name.strip() and name.strip().lstrip("*").casefold() not in required + ] + if retained: + entries.append(f"{','.join(retained)}={mode}") + entries.extend(f"{name}={mode}" for name, mode in required.items()) + return ";".join(entries) + + def _umu_proton7_environment( paths: AppPaths, selection: RuntimeSelection, @@ -750,24 +861,29 @@ def _umu_proton7_environment( "DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache), "WINEDEBUG": "-all", } - environment.update(_portable_umu_environment(paths, selection)) + environment.update(_umu_launcher_environment(paths, selection)) environment.update(_proton_sync_environment(synchronization)) return environment -def _portable_umu_environment( +def _umu_launcher_environment( paths: AppPaths, selection: RuntimeSelection, ) -> dict[str, str]: - """Keep downloads made by our zipapp inside the selected installation.""" + """Separate Proton's Python imports and managed downloads from the launcher.""" + # The installed wrapper and IDE use safe-path mode for launcher imports. + # Proton imports bundled siblings (filelock, utilities, protonfixes), so it + # needs its script directory on sys.path. An empty value disables the + # inherited flag; "0" would still enable it. Launcher wrappers use -P. + environment = {"PYTHONSAFEPATH": ""} try: selected = Path(selection.executable).expanduser().resolve() except (OSError, RuntimeError): - return {} - if selected != paths.managed_umu_executable.resolve(): - return {} - return {"UMU_FOLDERS_PATH": str(paths.runtime_tools)} + return environment + if selected == paths.managed_umu_executable.resolve(): + environment["UMU_FOLDERS_PATH"] = str(paths.runtime_tools) + return environment def _proton_environment( diff --git a/src/ptlinux/game/runtime_view.py b/src/ptlinux/game/runtime_view.py index 6b05970..2069ad4 100644 --- a/src/ptlinux/game/runtime_view.py +++ b/src/ptlinux/game/runtime_view.py @@ -143,9 +143,7 @@ def inspect_runtime_compatibility( if cached is not None: return cached source_scene = source / WHISPERING_VALE_RELATIVE - inspection = inspect_wrapped_assbin(source_scene) - source_scene_sha256 = inspection.file_sha256 - scene_action = _scene_action(inspection) + source_scene_sha256, scene_action = _runtime_scene_profile(source_scene) binary_action = _inspect_runtime_binary_action(paths, source) except (OSError, ValueError, OverlayError) as exc: reason = str(exc).strip() or type(exc).__name__ @@ -190,8 +188,7 @@ def prepare_runtime_view( return reused input_identities = _runtime_input_identities(paths, source) source_scene = source / WHISPERING_VALE_RELATIVE - inspection = inspect_wrapped_assbin(source_scene) - action = _scene_action(inspection) + source_scene_sha256, action = _runtime_scene_profile(source_scene) ( binary_action, game_exe_source, @@ -269,7 +266,7 @@ def prepare_runtime_view( result = OverlayResult( source_game=str(source), runtime_view=str(paths.runtime_view), - source_scene_sha256=inspection.file_sha256, + source_scene_sha256=source_scene_sha256, runtime_scene_sha256=_sha256(paths.runtime_view / WHISPERING_VALE_RELATIVE), scene_action=action, binary_action=binary_action, @@ -369,7 +366,24 @@ def select_binary_action( return "upstream-pair" +def _runtime_scene_profile(source: Path) -> tuple[str, str]: + """Keep a publisher format update independent of our legacy texture decoder.""" + + try: + inspection = inspect_wrapped_assbin(source) + except ValueError: + file_sha256 = _sha256(source) + if file_sha256 == KNOWN_SOURCE_SHA256: + raise + # Managed files have already passed depot integrity checks. A new format + # is not a reason to alter it or apply the old texture-index workaround. + return file_sha256, "upstream-unmodified-copy" + return inspection.file_sha256, _scene_action(inspection) + + def _scene_action(inspection: SceneInspection) -> str: + if inspection.file_sha256 != KNOWN_SOURCE_SHA256: + return "upstream-unmodified-copy" selected = [] for index in TEXTURE_INDICES: if index >= len(inspection.embedded_textures): @@ -391,6 +405,8 @@ def select_scene_action( return "normalize-known-profile" if depths == [8, 8] and colors == [2, 2]: return "upstream-compatible-copy" + if file_sha256 != KNOWN_SOURCE_SHA256: + return "upstream-unmodified-copy" raise OverlayError( "WhisperingVale.rovug is not a supported release profile; refusing to " f"modify unknown textures (sha256={file_sha256}, " @@ -870,13 +886,13 @@ def _runtime_layout_ready( runtime = paths.runtime_view if runtime.is_symlink() or not runtime.is_dir(): return False - ptr_compatibility_files = { - name - for name in PTR_BCRYPT_RUNTIME_FILENAMES - if not (runtime / name).is_symlink() and (runtime / name).is_file() - } - top_level_exceptions = {"game", "game.ini", "save", *ptr_compatibility_files} - top_level_special = {"game", "game.ini", "save", *ptr_compatibility_files} + # A prior PTR session materializes Proton-specific BCrypt DLLs here. + # Rebuild under the exclusive preparation lease so a later stable realm + # cannot accidentally load those DLLs. Shared clients never prepare a view. + if any(not (runtime / name).is_symlink() and (runtime / name).exists() for name in PTR_BCRYPT_RUNTIME_FILENAMES): + return False + top_level_exceptions = {"game", "game.ini", "save"} + top_level_special = {"game", "game.ini", "save"} for name, binary_source in ( ("Game.exe", game_exe_source), ("game.dll", game_dll_source), @@ -919,9 +935,6 @@ def _runtime_layout_ready( actual_links += 1 if game_dll_source != source / "game.dll": actual_links += 1 - actual_links += sum( - 1 for name in ptr_compatibility_files if (source / name).exists() - ) return actual_links == expected_links diff --git a/src/ptlinux/gamebox.py b/src/ptlinux/gamebox.py index 59e7054..ce6449e 100644 --- a/src/ptlinux/gamebox.py +++ b/src/ptlinux/gamebox.py @@ -111,6 +111,7 @@ def build_gamebox_command( ) command = [ sys.executable, + "-P", "-m", "ptlinux.gamebox_runtime", "--window-title", @@ -136,6 +137,7 @@ def build_gamebox_command( raise ValueError("game size is outside the supported Gamescope range") gamescope_command = [ sys.executable, + "-P", "-m", "ptlinux.gamescope_runtime", "--width", diff --git a/src/ptlinux/launcher_updates/service.py b/src/ptlinux/launcher_updates/service.py index 192915b..f750d57 100644 --- a/src/ptlinux/launcher_updates/service.py +++ b/src/ptlinux/launcher_updates/service.py @@ -1304,7 +1304,7 @@ def _launcher_mutation_lock(paths: AppPaths, operation: str) -> Iterator[None]: operation, active_game_probe=lambda: managed_game_process_is_active( paths.runtime_view, - (paths.proton_prefix, paths.umu_prefix), + (paths.proton_prefix, paths.umu_prefix, paths.ge_proton_prefix), ), ): with launcher_operation_lock(paths.launcher_update_lock, operation): diff --git a/src/ptlinux/locking.py b/src/ptlinux/locking.py index 5916a60..0f29817 100644 --- a/src/ptlinux/locking.py +++ b/src/ptlinux/locking.py @@ -183,15 +183,8 @@ def managed_game_process_is_active( for process in processes: if not process.name.isdecimal(): continue - try: - name = (process / "comm").read_text( - encoding="utf-8", - errors="replace", - ).strip().casefold() - except OSError: - continue - if not name.endswith(".exe"): - continue + # Linux truncates comm to 15 bytes; future executable names may lose + # their .exe suffix. Use the full command and dedicated prefix instead. environment = _read_bounded(process / "environ", MAX_PROCESS_ENVIRONMENT_BYTES) if environment is None: continue @@ -211,9 +204,14 @@ def managed_game_process_is_active( command = _read_bounded(process / "cmdline", MAX_PROCESS_COMMAND_BYTES) if command is None: continue - normalized_command = os.fsdecode(command).casefold().replace("\\", "/") - if runtime_marker in normalized_command: - return True + for argument in command.split(b"\0"): + candidate = os.fsdecode(argument).strip('"').casefold().replace("\\", "/") + if not candidate.endswith(".exe"): + continue + if candidate.startswith((runtime_marker, "z:" + runtime_marker)): + return True + if candidate.startswith("c:/program files (x86)/pristontale eu/"): + return True return False diff --git a/src/ptlinux/login_diagnostics.py b/src/ptlinux/login_diagnostics.py index ca958d5..79ccc15 100644 --- a/src/ptlinux/login_diagnostics.py +++ b/src/ptlinux/login_diagnostics.py @@ -495,6 +495,8 @@ def _runtime_path(selection: RuntimeSelection | None) -> str | None: def _runtime_prefix(paths: AppPaths, selection: RuntimeSelection | None) -> Path | None: if selection is None: return None + if selection.kind == "ge-proton": + return paths.ge_proton_prefix return ( paths.proton_prefix if selection.kind in {"proton7", "proton7-container"} diff --git a/src/ptlinux/paths.py b/src/ptlinux/paths.py index 710dbc7..95a75a1 100644 --- a/src/ptlinux/paths.py +++ b/src/ptlinux/paths.py @@ -287,6 +287,12 @@ class AppPaths: def umu_prefix(self) -> Path: return self.data / "umu-prefix" + @property + def ge_proton_prefix(self) -> Path: + """Keep modern WoW64 separate from the retained UMU and Proton 7 prefixes.""" + + return self.data / "ge-proton-prefix" + @property def dxvk_cache(self) -> Path: return self.cache / "dxvk-state" diff --git a/src/ptlinux/presentation/gamescope.py b/src/ptlinux/presentation/gamescope.py index 2bd39ec..ba45938 100644 --- a/src/ptlinux/presentation/gamescope.py +++ b/src/ptlinux/presentation/gamescope.py @@ -32,6 +32,7 @@ RESIZE_STEPS_PER_GAME_CANVAS = 20 MIN_RESIZE_INCREMENT = 8 MAX_RESIZE_INCREMENT = 96 CHILD_STATUS_POLL_SECONDS = 0.2 +GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS = 2.0 HOST_FOCUS_HEARTBEAT_SECONDS = 0.2 HOST_REICONIFY_INTERVAL_SECONDS = 0.2 GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0 @@ -46,7 +47,7 @@ BUNDLED_GAMESCOPE_OS_VERSION = "26.04" KEEP_NESTED_WINDOWS_VISIBLE_ENV = "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE" HASH_READ_SIZE = 256 * 1024 _GAMESCOPE_VERSION_PATTERN = re.compile( - r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)", + r"\bgamescope(?:-[a-z0-9_.+-]+)?\s+version\s+(\d+)\.(\d+)\.(\d+)", re.IGNORECASE, ) @@ -348,6 +349,7 @@ def _supervised_command( raise ValueError("Gamescope command has no nested command") result[separator + 1:separator + 1] = [ sys.executable, + "-P", "-m", "ptlinux.gamescope_child_runtime", "--status-file", @@ -560,6 +562,25 @@ def _process_exit_status(returncode: int) -> int: return min(returncode, 255) +def _stop_gamescope(process: subprocess.Popen[bytes]) -> None: + """Let Gamescope close its displays before forcing a stuck compositor down.""" + + if process.poll() is not None: + process.wait() + return + process.terminate() + try: + process.wait(timeout=GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + print( + "Warning: Gamescope did not stop gracefully; forcing cleanup.", + file=sys.stderr, + flush=True, + ) + process.kill() + process.wait() + + def _wait_for_optimized_gamescope( process: subprocess.Popen[bytes], status_file: Path | None, @@ -583,15 +604,24 @@ def _wait_for_optimized_gamescope( _read_child_status(status_file) if status_file is not None else None ) if child_status is not None: - if process.poll() is None: - process.kill() - process.wait() + _stop_gamescope(process) return _process_exit_status(child_status) gamescope_status = process.poll() if gamescope_status is not None: child_status = ( _read_child_status(status_file) if status_file is not None else None ) + if status_file is not None and child_status is None: + # A compositor can exit with 0 when its host window closes, + # even though its nested game was killed. Only the game's + # recorded status can establish a clean game exit. + print( + "Gamescope exited without a recorded game status; " + "the game session was interrupted.", + file=sys.stderr, + flush=True, + ) + return _process_exit_status(gamescope_status) or 1 return _process_exit_status( gamescope_status if child_status is None else child_status ) @@ -955,13 +985,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: host_mapper=mapper, ) except BaseException: - if process.poll() is None: - process.terminate() - try: - process.wait(timeout=2.0) - except subprocess.TimeoutExpired: - process.kill() - process.wait() + _stop_gamescope(process) raise finally: host_summary = ( diff --git a/src/ptlinux/settings.py b/src/ptlinux/settings.py index 8bae34f..147a953 100644 --- a/src/ptlinux/settings.py +++ b/src/ptlinux/settings.py @@ -7,6 +7,7 @@ import json import math import os from pathlib import Path +import re import secrets import threading from typing import Any @@ -45,6 +46,7 @@ class Settings: runtime: str = "auto" proton_sync: str = "auto" proton_path: str | None = None + renderer: str = "auto" game_source: str | None = None download_speed_limit_mbps: float | None = None low_bandwidth_mode: bool = False @@ -78,6 +80,7 @@ def save_settings(paths: AppPaths, settings: Settings) -> Settings: """ _validate(settings) + validate_proton_path_setting(settings.proton_path) paths.config.mkdir(parents=True, exist_ok=True) with launcher_operation_lock(paths.settings_lock, "settings-save"): existing = _read_settings_object(paths.settings_file) @@ -102,6 +105,7 @@ def update_settings(paths: AppPaths, **changes: Any) -> Settings: ) updated = replace(current, **changes) _validate(updated) + validate_proton_path_setting(updated.proton_path) _write_settings(paths, updated, existing) return updated @@ -155,6 +159,15 @@ def _read_settings_object(path: Path) -> dict[str, object] | None: def _settings_from_object(value: dict[str, object]) -> Settings: + proton_path = _optional_string(value, "proton_path") + renderer = _string(value, "renderer", "auto") + # Older settings exposed only a path field. Recover the exact renderer + # assignment seen in telemetry without interpreting arbitrary shell input. + legacy_renderer = _legacy_renderer_assignment(proton_path) + if legacy_renderer is not None: + proton_path = None + if renderer == "auto": + renderer = legacy_renderer settings = Settings( realm=_string(value, "realm", "Babel").strip(), ui_scale=_string(value, "ui_scale", "auto"), @@ -177,7 +190,8 @@ def _settings_from_object(value: dict[str, object]) -> Settings: ), runtime=_string(value, "runtime", "auto"), proton_sync=_string(value, "proton_sync", "auto"), - proton_path=_optional_string(value, "proton_path"), + proton_path=proton_path, + renderer=renderer, game_source=_optional_string(value, "game_source"), download_speed_limit_mbps=_optional_positive_number( value, @@ -224,9 +238,36 @@ def _settings_need_migration(value: dict[str, object]) -> bool: or not _SETTINGS_FIELD_NAMES.issubset(value) or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True or bool(_RETIRED_SETTINGS.intersection(value)) + or _legacy_renderer_assignment(value.get("proton_path")) is not None ) +def _legacy_renderer_assignment(value: object) -> str | None: + if not isinstance(value, str): + return None + return { + "PROTON_USE_WINED3D=1": "wined3d", + "PROTON_USE_WINED3D=0": "dxvk", + }.get(value.strip()) + + +def validate_proton_path_setting(value: str | None) -> None: + """Reject launch options in a filesystem field, without requiring a mounted disk.""" + + if value is None: + return + if not value.strip() or any(ord(character) < 32 for character in value): + raise ValueError("Proton script must be a filesystem path") + if re.match(r"(?:env\s+)?[A-Za-z_][A-Za-z0-9_]*=", value.strip()): + raise ValueError( + "Proton script must be a filesystem path. Choose WineD3D or DXVK " + "under Renderer; put other environment variables in the launcher " + "process environment. Leave Proton script blank for automatic detection." + ) + if "%command%" in value: + raise ValueError("Proton script accepts a path, not Steam launch options") + + def _write_settings( paths: AppPaths, settings: Settings, @@ -278,8 +319,10 @@ def _validate(settings: Settings) -> None: raise ValueError("realm must be a non-empty display name") if settings.ui_scale not in UI_SCALE_CHOICES: raise ValueError(f"unsupported launcher UI scale: {settings.ui_scale}") - if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}: + if settings.runtime not in {"auto", "proton7", "proton7-container", "umu", "ge-proton"}: raise ValueError(f"unsupported runtime selection: {settings.runtime}") + if settings.renderer not in {"auto", "dxvk", "wined3d"}: + raise ValueError(f"unsupported renderer: {settings.renderer}") if settings.proton_sync not in {"auto", "fsync", "esync"}: raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}") if settings.update_channel not in SUPPORTED_CHANNELS: diff --git a/src/ptlinux/telemetry/dependencies.py b/src/ptlinux/telemetry/dependencies.py index 8e1d2a8..3f8736c 100644 --- a/src/ptlinux/telemetry/dependencies.py +++ b/src/ptlinux/telemetry/dependencies.py @@ -27,7 +27,9 @@ def _dependency_health( try: from ..doctor import inspect_host - report = inspect_host(environment, vulkan_support=runtime) + report = inspect_host( + environment, vulkan_support=runtime, game_runtime=runtime, runtime_prefix=prefix, + ) for item in report.tools: available = item.path is not None tools[item.name] = available @@ -55,7 +57,7 @@ def _dependency_health( ) is not None shared_libraries = ( vulkan_64bit and vulkan_32bit - if platform.machine().casefold() in {"x86_64", "amd64"} + if platform.machine().casefold() in {"x86_64", "amd64"} and runtime.get("kind") != "ge-proton" else vulkan_64bit ) healthy = report_ok diff --git a/src/ptlinux/telemetry/runtime.py b/src/ptlinux/telemetry/runtime.py index 3614a0e..5cbf64d 100644 --- a/src/ptlinux/telemetry/runtime.py +++ b/src/ptlinux/telemetry/runtime.py @@ -67,6 +67,9 @@ def _runtime_info( proton_name = _proton_name(runtime_marker.get("proton_path")) if proton_name: result["proton_version"] = proton_name + if runtime_kind == "ge-proton": + result["proton_channel"] = "GE-Proton" + result["wow64_requested"] = True if prefix is not None: prefix_architecture = _wine_prefix_architecture(prefix) @@ -107,8 +110,16 @@ def _selected_prefix( ) -> Path | None: runtime = _object_field(marker, "runtime") kind = _short_string(runtime.get("kind")) if runtime else None - preferred = paths.umu_prefix if kind == "umu" else paths.proton_prefix - for candidate in (preferred, paths.proton_prefix, paths.umu_prefix): + preferred = ( + paths.ge_proton_prefix if kind == "ge-proton" + else paths.umu_prefix if kind == "umu" + else paths.proton_prefix + ) + candidates = ( + (preferred,) if kind in {"umu", "ge-proton", "proton7", "proton7-container"} + else (preferred, paths.umu_prefix, paths.ge_proton_prefix) + ) + for candidate in candidates: if (candidate / "system.reg").is_file() or ( candidate / "drive_c/windows" ).is_dir(): @@ -136,6 +147,10 @@ def _wine_command( executable = _short_string(runtime.get("executable")) if executable and Path(executable).name.casefold() in {"wine", "wine64"}: return executable + if runtime.get("kind") in {"umu", "ge-proton", "proton7", "proton7-container"}: + # Host Wine is unrelated to the Wine bundled with the selected Proton. + # An unknown bundled version is more useful than a false host version. + return None return shutil.which("wine", path=environment.get("PATH", "")) @@ -144,8 +159,8 @@ def _wine_vendor( runtime: Mapping[str, object] | None, paths: AppPaths, ) -> str: - if runtime_kind in {"proton7", "proton7-container", "umu"}: - return "proton" if runtime_kind != "umu" else "umu" + if runtime_kind in {"proton7", "proton7-container", "umu", "ge-proton"}: + return "umu" if runtime_kind in {"umu", "ge-proton"} else "proton" if runtime: executable = _short_string(runtime.get("executable")) if executable: diff --git a/src/ptlinux/ui/dashboard.py b/src/ptlinux/ui/dashboard.py index 2fab61a..d227cba 100644 --- a/src/ptlinux/ui/dashboard.py +++ b/src/ptlinux/ui/dashboard.py @@ -187,17 +187,19 @@ def launch_external_url(url: str) -> None: def run_dashboard(paths: AppPaths) -> None: + from ..gui_bootstrap import GuiDependencyError, missing_tk_message + try: import tkinter as tk from tkinter import filedialog, ttk - except ImportError as exc: - raise DashboardError( - "Python Tk is missing. Install your distribution's python3-tk package " - "or use the pristontale-eu command-line interface." - ) from exc + except (ImportError, OSError) as exc: + raise GuiDependencyError(missing_tk_message()) from exc paths.create_base_directories() - root = tk.Tk(className=APPLICATION_WM_CLASS) + try: + root = tk.Tk(className=APPLICATION_WM_CLASS) + except tk.TclError as exc: + raise DashboardError(f"Could not open the launcher window: {exc}") from exc root.title(f"{APPLICATION_NAME} — Linux Launcher") root.iconname(APPLICATION_NAME) window_icon: PhotoImage | None = None diff --git a/src/ptlinux/ui/dialogs/settings_dialog.py b/src/ptlinux/ui/dialogs/settings_dialog.py index 7db0637..092ba4b 100644 --- a/src/ptlinux/ui/dialogs/settings_dialog.py +++ b/src/ptlinux/ui/dialogs/settings_dialog.py @@ -27,10 +27,16 @@ from ..viewmodels.downloader import ( RUNTIME_LABELS: dict[str, str] = { "Automatic (recommended)": "auto", + "GE-Proton — Modern WoW64 via UMU": "ge-proton", "Proton 7 — Steam Runtime container": "proton7-container", "Proton 7 — Direct": "proton7", "UMU Launcher": "umu", } +RENDERER_LABELS: dict[str, str] = { + "Automatic (DXVK by default)": "auto", + "DXVK (Vulkan)": "dxvk", + "WineD3D (OpenGL)": "wined3d", +} PROTON_SYNC_LABELS: dict[str, str] = { "Automatic (recommended)": "auto", "Fsync": "fsync", @@ -198,6 +204,7 @@ def open_settings_dialog( value=setting_label(current_settings.proton_sync, PROTON_SYNC_LABELS) ) proton_path_value = tk.StringVar(value=current_settings.proton_path or "") + renderer_value = tk.StringVar(value=setting_label(current_settings.renderer, RENDERER_LABELS)) game_source_value = tk.StringVar(value=current_settings.game_source or "") download_speed_value = tk.StringVar( value=download_speed_setting_label( @@ -333,6 +340,7 @@ def open_settings_dialog( PROTON_SYNC_LABELS, ), proton_path=proton_path_value.get().strip() or None, + renderer=setting_value(renderer_value.get(), RENDERER_LABELS), game_source=game_source_value.get().strip() or None, download_speed_limit_mbps=download_speed_limit, low_bandwidth_mode=low_bandwidth_value.get(), @@ -423,7 +431,7 @@ def open_settings_dialog( rendering_card.columnconfigure(1, weight=1) rendering_hint = ttk.Label( rendering_card, - text="Choose which compatibility runtime launches the proven game setup.", + text="Automatic uses GE-Proton with modern WoW64 for Babel when UMU is available.", style="SettingsHint.TLabel", wraplength=360, ) @@ -446,6 +454,15 @@ def open_settings_dialog( proton_sync_value, tuple(PROTON_SYNC_LABELS), ) + add_choice( + rendering_card, + 5, + "settings_selector_renderer", + "Renderer", + "Direct3D graphics translation", + renderer_value, + tuple(RENDERER_LABELS), + ) downloads_card = ttk.LabelFrame( content, @@ -652,7 +669,7 @@ def open_settings_dialog( 1, "settings_proton_path", "Proton script", - "Use a specific Proton executable", + "Proton 7 script or folder; environment options belong under Renderer", proton_path_value, choose_proton_path, ) diff --git a/updates/stable.json b/updates/stable.json index fbded08..6599d56 100644 --- a/updates/stable.json +++ b/updates/stable.json @@ -2,16 +2,16 @@ "application": "PristonTale-EU", "archive": { "layout": "flat", - "name": "PristonTale-EU-1.0.8.tar.gz", - "sha256": "f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4", - "size": 1544812, - "url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.8/PristonTale-EU-1.0.8.tar.gz?sha256=f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4" + "name": "PristonTale-EU-1.1.0.tar.gz", + "sha256": "b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c", + "size": 1550967, + "url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.0/PristonTale-EU-1.1.0.tar.gz?sha256=b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c" }, - "archive_sha256": "f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4", - "archive_size": 1544812, - "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.8/PristonTale-EU-1.0.8.tar.gz?sha256=f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4", + "archive_sha256": "b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c", + "archive_size": 1550967, + "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.0/PristonTale-EU-1.1.0.tar.gz?sha256=b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c", "channel": "stable", - "commit": "6a508476b90955705cdec106ba2151bd53d1f824", + "commit": "be7891f06617674864b035645137441321db03b4", "compatibility": { "config_schema": 1, "manifest_schema": 1, @@ -36,13 +36,13 @@ }, { "path": "ptlinux/__init__.py", - "sha256": "f8dcaf56174e23070242e4df9a06d6eb0e7a9b8d221ffaa6465db96227bb2976", + "sha256": "d533461854917e3343b9f600b5949421eadedb9a0a550a8adf0444b81023d333", "size": 151 }, { "path": "ptlinux/__main__.py", - "sha256": "558713c62425bdf35f77642b6aaf1d0c7f0686982c05a9e0102b362697d66718", - "size": 2112 + "sha256": "9608795f7d41b21a5a283f09e716686572ba47bc004cf515758b68fb172e3a86", + "size": 2747 }, { "path": "ptlinux/application/__init__.py", @@ -71,8 +71,8 @@ }, { "path": "ptlinux/application/commands/play.py", - "sha256": "d4160bad360c7ba4299930b1040565468458ec4f88320220da0e2d29cf64b233", - "size": 36758 + "sha256": "3328a0dff4335c3e20a89a5984d889bee1137f509f3605a979f4d8aee595efe4", + "size": 37609 }, { "path": "ptlinux/application/commands/settings.py", @@ -91,8 +91,8 @@ }, { "path": "ptlinux/cli.py", - "sha256": "52df55cd676b7b186d6c7336a1299432d33ff3811c6102b220b04111ea3323a6", - "size": 23834 + "sha256": "ae8f3740fa997d7e00d26bc5fec68f582e70a051cfb00cd88c8a2eaeb547b4b0", + "size": 23871 }, { "path": "ptlinux/command.py", @@ -106,13 +106,13 @@ }, { "path": "ptlinux/dashboard_debug.py", - "sha256": "cbbd9d78c7c51da6ed007ef758fdcf62642d489dd71377db30092ee54598cf8a", - "size": 6738 + "sha256": "44514bdbdd2a2df74f357dc4e67e6c164f4c8183b40a8be1629b1a6b62482f7f", + "size": 6811 }, { "path": "ptlinux/depot.py", - "sha256": "6df7d74b8a682cb35b612a7b5e7e53de3a3debbb379321f472b7b7a8c0086e6a", - "size": 38043 + "sha256": "607a6a8d22d3f3ed691b52465cf15b1d9fd236aa9f422b211ae683e3f7fd7cd6", + "size": 38792 }, { "path": "ptlinux/desktop_identity.py", @@ -136,8 +136,8 @@ }, { "path": "ptlinux/doctor.py", - "sha256": "03c00c8072f234b6c45e53e65ee9974e3f76321169c263ab9e1ce97535ba9c65", - "size": 11591 + "sha256": "36ee40ea827bbf3d765fbb2cb6ba38fc03fdb9f60a938b0ea53793b303e168db", + "size": 13270 }, { "path": "ptlinux/download.py", @@ -149,6 +149,11 @@ "sha256": "10c4e85aea19bffdf99b7b43e24dc6ff709c48b780f1a45017bd989c8f1266b7", "size": 77 }, + { + "path": "ptlinux/game/arguments.py", + "sha256": "6c95c2edc870f011568ebd60d7058c400f1e1c552206b69d810cf3c153d6842b", + "size": 1705 + }, { "path": "ptlinux/game/compatibility/__init__.py", "sha256": "4ef61334709782fddf119d90eec1d355936d3626ad589f7d5a2d382d45047e94", @@ -166,8 +171,8 @@ }, { "path": "ptlinux/game/depot/catalogs.py", - "sha256": "83da15647115b167c18fe24ae624382d6969170323057f8b0042f38e273abf54", - "size": 5563 + "sha256": "b78df7d92578a6f1b97940472ec5a58d1e076df4d57fcc5b7bd8a2605ef300e3", + "size": 5437 }, { "path": "ptlinux/game/depot/filesystem.py", @@ -181,8 +186,8 @@ }, { "path": "ptlinux/game/depot/manifest.py", - "sha256": "02840956502511a933ac2862b3d80597fad555b704c32666496dbcf7fdae0b27", - "size": 8482 + "sha256": "b0a5053c1b77d414dafae9cf06c4aa6eeff4d6caa22ea194b18ec652356281f8", + "size": 8667 }, { "path": "ptlinux/game/depot/models.py", @@ -196,28 +201,28 @@ }, { "path": "ptlinux/game/depot/transfer.py", - "sha256": "8b9c1d1e0d86f816d40d4aaf42bcb9e013d32aaad94680ba4864372ac0bb687e", - "size": 26835 + "sha256": "1bd67a4b7cce3b1ae57ae89cf8f257d69aec385fcea779bf3ee0744a0eb360a8", + "size": 28644 }, { "path": "ptlinux/game/ptr_compat.py", - "sha256": "5851efd7d5dde72a961a60cb79cd2aff5a8153635d44d4b7951e2f29b0a92d1e", - "size": 4876 + "sha256": "84fd44b39e2b4f2973fa947a5177f99a2fde566579ce3e86f0912d3e68c37000", + "size": 5404 }, { "path": "ptlinux/game/runtime.py", - "sha256": "cdeba4551aab6c54f2704d2aabc964a61b54a2cf9006567cf94a0a6aaf281061", - "size": 32421 + "sha256": "f7c3c1908face855a09367ca4bac070c6372e00140cbde3d7b4adca8b8deff68", + "size": 37119 }, { "path": "ptlinux/game/runtime_view.py", - "sha256": "dff2cf7b7e9ef3e7bd33b864b13fe4777f47af09be6d18951559b169df092cb5", - "size": 37325 + "sha256": "3d51661ba57786828e6094c284caa13ad10e42ef376c9e886756e587339e4b19", + "size": 38008 }, { "path": "ptlinux/gamebox.py", - "sha256": "ea5878e59c27abbc4909fa0c0c215a36c28f5467e2166f125b30269314c9a2e4", - "size": 4603 + "sha256": "aa7232c57f4ddcd43b6b075c065641279053f20b90700fc3f768418799f83e24", + "size": 4619 }, { "path": "ptlinux/gamebox_runtime.py", @@ -234,6 +239,11 @@ "sha256": "4d32fa6cbea2cf4c3b6be0a8179c40e812aa3afdb0939628518c468daa2f4d03", "size": 280 }, + { + "path": "ptlinux/gui_bootstrap.py", + "sha256": "1b775c745e1701b2541ef56c75a55515c329a9021a0bbca0728f26769c653ee1", + "size": 4296 + }, { "path": "ptlinux/identity_probe.py", "sha256": "5d4685887da4ec27ef1ae20b0a819a2305e40c5b92bd4c3425fc3ea51413382f", @@ -301,8 +311,8 @@ }, { "path": "ptlinux/launcher_updates/service.py", - "sha256": "8b3b1f928291accc839a05362b2270bf8e6274a42215d56d59bc76bdabd6b180", - "size": 48406 + "sha256": "181508d77cd9e545d359a6c2aad92c08dcc9a3997bb8bc0a2ae03e8d2276e3bf", + "size": 48430 }, { "path": "ptlinux/launcher_updates/staging.py", @@ -311,13 +321,13 @@ }, { "path": "ptlinux/locking.py", - "sha256": "725eebf03b821c110c817af80007567e7e21c4f657fbeaeee4f3b91e88a48992", - "size": 6856 + "sha256": "dcd63520c161e334fc836fb72c4b8fff2199e6dd040e33b623b60dd94eaf1d15", + "size": 7020 }, { "path": "ptlinux/login_diagnostics.py", - "sha256": "96fd67f95ade529b72b9456058317d3ea8f57553b1417052ab6c3cfacde1177f", - "size": 18398 + "sha256": "5a3375c1affaa953a98c25fffc549c09e7737b689ae933f8dbc955ca2d9c3d85", + "size": 18465 }, { "path": "ptlinux/login_trace.py", @@ -386,8 +396,8 @@ }, { "path": "ptlinux/paths.py", - "sha256": "06289b8ba2a47ae664f764c6be8c1b32d08d6fcf06ba860f65f27daf78287587", - "size": 8455 + "sha256": "0c050a33eeb8f590681591e51595a3d82dc9b0263322e8690d2c2c3d1c84223b", + "size": 8625 }, { "path": "ptlinux/presentation/__init__.py", @@ -401,8 +411,8 @@ }, { "path": "ptlinux/presentation/gamescope.py", - "sha256": "c02fc6e5cd7a0081b5e7f6a3b7f3abc42ad54d19f5959032e97d1fc54110b53b", - "size": 37369 + "sha256": "93c1e9774930cc2ce2ea8b2b0a583f1525b19a6e33a12432febfd2f426f109ea", + "size": 38328 }, { "path": "ptlinux/presentation/gamescope_child.py", @@ -436,8 +446,8 @@ }, { "path": "ptlinux/settings.py", - "sha256": "af7b1de17c69d499a3a91e22f2f2e2e6035ea79131cfc04800656468b762c870", - "size": 10363 + "sha256": "8d62b262bb43eff664b9f6a58bf68a88fec0a8e05b6b9e97cbb47509079c2115", + "size": 12059 }, { "path": "ptlinux/system_identity.py", @@ -451,8 +461,8 @@ }, { "path": "ptlinux/telemetry/dependencies.py", - "sha256": "725f215a9ecd24a397b8c49ef099bdb0f7444fbf93b6b6b9cc390f0ba38a6e59", - "size": 2568 + "sha256": "7657fedfa015771567fe55df03663256d7bde0a166a680cdaa7d6afa6e7199a1", + "size": 2660 }, { "path": "ptlinux/telemetry/environment.py", @@ -496,8 +506,8 @@ }, { "path": "ptlinux/telemetry/runtime.py", - "sha256": "f0ca545a029817ab4175ae9a6a4cf3bbc2e4f66488949df90b9eb36164159b9f", - "size": 14686 + "sha256": "43e5b477f9308548a5f6e7a00f2f921a51967b4b3e8e9ff742606571bd723abd", + "size": 15249 }, { "path": "ptlinux/telemetry/service.py", @@ -621,8 +631,8 @@ }, { "path": "ptlinux/ui/dashboard.py", - "sha256": "5d391b1e8f5afa0daf704d8e8222a7001cb338db19c7b6b6c41c49e8b6a79d5c", - "size": 54316 + "sha256": "660b718f5695143ee10259b703f5a8ec1b1db8c37ab7ecfd37c5e6505af35f85", + "size": 54400 }, { "path": "ptlinux/ui/dialogs/__init__.py", @@ -646,8 +656,8 @@ }, { "path": "ptlinux/ui/dialogs/settings_dialog.py", - "sha256": "700e514977c584cdb8b499491700a0689e766b28fb3f3d1b0a5b7c719051ca0e", - "size": 24739 + "sha256": "20a8e461799fa2104e5a09a6e4ec2e8c027caa0688726c57c9ed45e872c1971f", + "size": 25299 }, { "path": "ptlinux/ui/viewmodels/__init__.py", @@ -696,21 +706,21 @@ }, { "path": "release.json", - "sha256": "37426179c48b341bb457d376adaa415af3b28a009df574b0c0bb014a4e21d5fe", + "sha256": "5dfcea2e0eb6ee4d79dba982d79e1a4d5efe1ce0579537d2a0cfab990153636a", "size": 218 } ], "minimum_updater_version": "0.9.9", - "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.8", - "published_at": "2026-09-18T19:36:12Z", + "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.0", + "published_at": "2026-09-19T10:49:03Z", "release": { - "notes_markdown": "# PristonTale EU Launcher 1.0.8\n\nPrevents the Build 22.80.90 startup crash under UMU-Proton 10 and packages the\ncurrent launcher, diagnostics, telemetry, and native compatibility updates.\n\n## Changes\n\n- Enable Proton's heap-zero compatibility mode for UMU game launches to prevent\n the reproduced `std::length_error(\"vector too long\")` startup failure.\n- Improve crash-dump stack analysis and privacy-safe asset-failure telemetry.\n- Improve Direct3D resource-exhaustion handling and current runtime packaging.\n\n---\n\nVersion: `1.0.8` \nChannel: `stable` \nTag: `v1.0.8` \nSource commit: `6a508476b90955705cdec106ba2151bd53d1f824`\n", - "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.8", - "summary": "Prevents the Build 22.80.90 startup crash under UMU-Proton 10 and packages the", - "title": "PristonTale EU Launcher 1.0.8" + "notes_markdown": "# PristonTale EU Launcher 1.1.0\n\n- Improved GE-Proton/UMU compatibility, launch arguments, and renderer selection.\n- Isolated runtime prefixes and strengthened compatibility checks for game updates.\n- Fixed missing-Tk startup recovery and SteamOS dependency warnings.\n- Improved CDN retries, mirror failover, and interrupted download recovery.\n- Fixed Gamescope shutdown handling and optimized window resizing detection.\n- Expanded launch diagnostics and regression coverage.\n\n---\n\nVersion: `1.1.0` \nChannel: `stable` \nTag: `v1.1.0` \nSource commit: `be7891f06617674864b035645137441321db03b4`\n", + "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.0", + "summary": "Improved GE-Proton/UMU compatibility, launch arguments, and renderer selection.", + "title": "PristonTale EU Launcher 1.1.0" }, "schema": 1, "status": "active", - "tag": "v1.0.8", - "version": "1.0.8" + "tag": "v1.1.0", + "version": "1.1.0" } diff --git a/updates/stable.json.sig b/updates/stable.json.sig index 6996384..31a5db5 100644 Binary files a/updates/stable.json.sig and b/updates/stable.json.sig differ