diff --git a/src/ptlinux/__init__.py b/src/ptlinux/__init__.py index a9dbb3e..cb922a9 100644 --- a/src/ptlinux/__init__.py +++ b/src/ptlinux/__init__.py @@ -4,4 +4,4 @@ from .paths import AppPaths __all__ = ["AppPaths", "__version__"] -__version__ = "1.1.1" +__version__ = "1.1.2" diff --git a/src/ptlinux/application/commands/play.py b/src/ptlinux/application/commands/play.py index 50c26b0..ff175c4 100644 --- a/src/ptlinux/application/commands/play.py +++ b/src/ptlinux/application/commands/play.py @@ -21,6 +21,7 @@ from ...diagnostics.crash_dumps import ( prepare_minidump_attachment, ) from ...diagnostics.native_logs import LogFileState +from ...game.depot.realms import PTR_BABEL_REALM_NAME, launcher_realm_name from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm from ...launcher import LauncherError from ...login_trace import LOGIN_TRACE_DISABLED_REASON @@ -394,6 +395,8 @@ def play_locked( recommended_comparison = recommended_runtime_comparison( selection.kind, runtime_issues, + current_game_log=current_game_log, + realm=start_option.name, ) if recommended_comparison is not None: runtime_issues["recommended_one_variable_comparison"] = recommended_comparison @@ -632,9 +635,24 @@ def game_exit_metadata( def recommended_runtime_comparison( runtime_kind: str, runtime_issues: dict[str, object], + *, + current_game_log: dict[str, object] | None = None, + realm: str | None = None, ) -> str | None: """Choose a useful one-variable comparison from launch-relevant evidence.""" + if realm and launcher_realm_name(realm).casefold() == PTR_BABEL_REALM_NAME.casefold(): + # PTR pins Proton 7 for its client-specific compatibility requirements. + return None + if current_game_log and current_game_log.get("runtime_out_of_memory") is True: + # GE enables modern WoW64 in an isolated prefix. Compare it before + # chasing media-plugin scan warnings; the exact exhausted resource is + # not identified by the game's E_OUTOFMEMORY error alone. + return ( + "ge-proton" + if runtime_kind in {"proton7", "proton7-container", "umu"} + else None + ) if ( runtime_kind == "proton7-container" and runtime_issues.get("host_futex_fatal_count") diff --git a/src/ptlinux/presentation/game_window.py b/src/ptlinux/presentation/game_window.py index 4461642..479ba3a 100644 --- a/src/ptlinux/presentation/game_window.py +++ b/src/ptlinux/presentation/game_window.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import ctypes import ctypes.util +import math import os from pathlib import Path import select @@ -301,24 +302,40 @@ class _X11: self._restore_error_handler() def root_window_ids(self) -> set[int]: - return set(self._root_windows()) + # Keep client IDs as well as frames: decorations can be reparented while + # another account starts, and a new frame must not make an old game new. + frames = self._root_windows() + return set(frames) | {self._client_window(frame) for frame in frames} def find_first_window( self, title_fragment: str, *, excluded_window_ids: Collection[int], + expected_pid: int | None = None, ) -> tuple[int, str] | None: windows: list[tuple[int, str]] = [] - for window in self._root_windows(): + for frame in self._root_windows(): + if frame in excluded_window_ids: + continue + window = self._client_window(frame) + if window in excluded_window_ids: + continue + if expected_pid is not None and self.window_pid(window) != expected_pid: + continue + # Reparenting WMs need not copy a client's title onto its outer frame. title = self._window_title(window) + if title is None and frame != window: + title = self._window_title(frame) if title is not None: windows.append((window, title)) - selected = _select_game_window(windows, title_fragment, excluded_window_ids) - if selected is None: - return None - window, title = selected - return self._client_window(window), title + return _select_game_window(windows, title_fragment, excluded_window_ids) + + def window_pid(self, window: int) -> int | None: + """Read the client's process identity within this X server.""" + + values = self._long_property(window, b"_NET_WM_PID", 1) + return values[0] if values and values[0] > 0 else None def find_window_by_pid(self, expected_pid: int) -> int | None: """Find a top-level host window created by one native process.""" @@ -1057,6 +1074,7 @@ class _X11: window: int, *, borderless: bool, + raise_window: bool = True, ) -> tuple[int, int] | None: # Capture the real client size for both decorated and borderless windows. # Wine/DXVK can otherwise retain a desktop-sized render surface inside a @@ -1064,7 +1082,8 @@ class _X11: target_size = self.window_size(window) if borderless: self._remove_decorations(window) - self.library.XMapRaised(self.display, window) + if raise_window: + self.library.XMapRaised(self.display, window) self.library.XFlush(self.display) return target_size @@ -1133,6 +1152,8 @@ class _X11: self.library.XSync(self.display, 0) def window_is_focused(self, window: int) -> bool: + """Accept focus on the client or one of Wine's child input windows.""" + focused = ctypes.c_ulong() revert_to = ctypes.c_int() if self.library.XGetInputFocus( @@ -1141,7 +1162,33 @@ class _X11: ctypes.byref(revert_to), ) != 1: return False - return int(focused.value) == window + candidate = int(focused.value) + visited: set[int] = set() + # None (0) and PointerRoot (1) are special focus values, not clients. + while candidate not in {0, 1} and candidate not in visited and len(visited) < 64: + if candidate == window: + return True + visited.add(candidate) + root = ctypes.c_ulong() + parent = ctypes.c_ulong() + children = ctypes.POINTER(ctypes.c_ulong)() + child_count = ctypes.c_uint() + try: + status = self.library.XQueryTree( + self.display, + candidate, + ctypes.byref(root), + ctypes.byref(parent), + ctypes.byref(children), + ctypes.byref(child_count), + ) + if status == 0 or candidate == root.value: + return False + candidate = int(parent.value) + finally: + if children: + self.library.XFree(ctypes.cast(children, ctypes.c_void_p)) + return False def window_is_active(self, window: int) -> bool: """Return whether the desktop already considers this client active.""" @@ -1167,10 +1214,22 @@ class _X11: ) if active_window == 0: return False + # Wine may keep its last input timestamp on a separate user-time window. + # Use this client's timestamp; borrowing another application's timestamp + # would incorrectly bypass the desktop's focus-stealing prevention. + user_time_window = self._long_property(window, b"_NET_WM_USER_TIME_WINDOW", 1) + time_window = user_time_window[0] if user_time_window and user_time_window[0] else window + user_time = self._long_property( + time_window, + b"_NET_WM_USER_TIME", + 1, + ) + if not user_time and time_window != window: + user_time = self._long_property(window, b"_NET_WM_USER_TIME", 1) return self._send_root_client_message( window, active_window, - (1, _X_CURRENT_TIME, 0, 0, 0), + (1, user_time[0] if user_time else _X_CURRENT_TIME, 0, 0, 0), ) def _send_root_client_message( @@ -1495,7 +1554,14 @@ def _prepare_game_window( *, borderless: bool, suppress_wm_ping: bool, + raise_window: bool = True, ) -> None: + if not raise_window: + try: + if mapper.window_state(window) != _X_WM_STATE_NORMAL: + return + except Exception: + return if suppress_wm_ping: try: ping_suppressed = mapper.suppress_wm_ping(window) @@ -1520,7 +1586,11 @@ def _prepare_game_window( file=sys.stderr, flush=True, ) - target_size = mapper.map_window(window, borderless=borderless) + target_size = mapper.map_window( + window, + borderless=borderless, + **({"raise_window": False} if not raise_window else {}), + ) forced_windowed = False try: forced_windowed = mapper.force_windowed_state(window) @@ -1596,18 +1666,67 @@ def _apply_game_window_identity(mapper: _X11, window: int) -> None: ) -def _focus_game_window(mapper: _X11, window: int) -> None: +def _focus_game_window( + mapper: _X11, + window: int, + *, + activate: bool = True, + diagnostics: WindowDiagnosticRecorder | None = None, +) -> None: """Let the dashboard process its ready event before focusing the game.""" time.sleep(WINDOW_FOCUS_DELAY_SECONDS) + activation_requested = False + activation_submitted = False + outcome = "not_activated" try: - mapper.focus_window(window) + if activate: + if mapper.window_state(window) != _X_WM_STATE_NORMAL: + outcome = "not_mapped" + return + if not mapper.window_is_active(window): + activation_requested = True + activation_submitted = mapper.activate_window(window) + if not activation_submitted: + outcome = "request_failed" + return + # XSendEvent success only means the request was sent. The desktop + # may refuse it, or take longer than a fixed sleep to activate us. + deadline = time.monotonic() + WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS + while True: + if mapper.window_state(window) != _X_WM_STATE_NORMAL: + outcome = "not_mapped" + return + if mapper.window_is_active(window): + break + remaining = deadline - time.monotonic() + if remaining <= 0: + return + time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining)) + # Recheck ownership after waiting and preserve an already-focused + # Wine child. Never override a declined activation with XMapRaised. + focused = _repair_game_window_focus(mapper, window) + else: + # Host focus may have changed during the dashboard handoff delay. + focused = _repair_gamescope_game_window_focus(mapper, window) + outcome = "focused" if focused else "focus_not_owned" except Exception as exc: + outcome = "error" print( f"Warning: could not focus the game window ({exc}); continuing normally", file=sys.stderr, flush=True, ) + finally: + if diagnostics is not None: + diagnostics.record( + "focus_handoff", + window_id=window, + activation_requested=activation_requested, + activation_submitted=activation_submitted, + outcome=outcome, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) def _repair_game_window_focus( @@ -1632,7 +1751,7 @@ def _repair_game_window_focus( active=active, focused=focused, ) - if state == _X_WM_STATE_ICONIC: + if state != _X_WM_STATE_NORMAL: return False if not active: return False @@ -1641,6 +1760,9 @@ def _repair_game_window_focus( except Exception: return False try: + # Diagnostic/property reads above can outlast an Alt-Tab or minimize. + if mapper.window_state(window) != _X_WM_STATE_NORMAL or not mapper.window_is_active(window): + return False mapper.restore_input_focus(window) repaired = mapper.window_is_focused(window) if diagnostics is not None: @@ -1697,13 +1819,15 @@ def _repair_gamescope_game_window_focus( wm_state=_wm_state_name(state), focused=focused, ) - if state == _X_WM_STATE_ICONIC: + if state != _X_WM_STATE_NORMAL: return False if focused: return True except Exception: return False try: + if not _gamescope_host_focus_is_allowed() or mapper.window_state(window) != _X_WM_STATE_NORMAL: + return False mapper.restore_input_focus(window) repaired = mapper.window_is_focused(window) if diagnostics is not None: @@ -1769,6 +1893,9 @@ def _gamescope_host_focus_status( return {"allowed": False, "bridge": "unreadable"} observed_at = time.monotonic() if now is None else now age = observed_at - published_at + # float() accepts NaN/infinity; round() would terminate the supervisor. + if not math.isfinite(published_at) or not math.isfinite(age * 1000): + return {"allowed": False, "bridge": "invalid"} age_ms = round(age * 1000) if age < 0.0: return {"allowed": False, "bridge": "future", "age_ms": age_ms} @@ -1919,10 +2046,22 @@ def _maintain_game_window_scaling( process: subprocess.Popen[bytes], window: int, *, + window_title: str = WINDOW_TITLE, + existing_window_ids: Collection[int] = (), + expected_pid: int | None = None, diagnostics: WindowDiagnosticRecorder | None = None, ) -> int: """Keep host scaling hints across Wine maximize/restore transitions.""" + def keep_focus_monitoring() -> int: + return _maintain_game_window_focus( + mapper, process, window, + window_title=window_title, + existing_window_ids=existing_window_ids, + expected_pid=expected_pid, + **({"diagnostics": diagnostics} if diagnostics is not None else {}), + ) + try: mapper.select_scaling_hint_events(window) except Exception as exc: @@ -1938,7 +2077,7 @@ def _maintain_game_window_scaling( file=sys.stderr, flush=True, ) - return int(process.wait()) + return keep_focus_monitoring() scaling_enabled = ( _enable_game_window_scaling(mapper, window) @@ -1950,7 +2089,7 @@ def _maintain_game_window_scaling( ) ) if not scaling_enabled: - return int(process.wait()) + return keep_focus_monitoring() repair_failures = 0 next_scaling_repair_at = 0.0 @@ -1965,7 +2104,7 @@ def _maintain_game_window_scaling( file=sys.stderr, flush=True, ) - break + return keep_focus_monitoring() now = time.monotonic() if now >= next_focus_repair_at: if diagnostics is None: @@ -2005,7 +2144,7 @@ def _maintain_game_window_scaling( file=sys.stderr, flush=True, ) - break + return keep_focus_monitoring() else: repair_failures = 0 if hints_changed and diagnostics is not None: @@ -2018,34 +2157,100 @@ def _maintain_game_window_scaling( return int(process.wait()) +def _resolve_direct_game_window( + mapper: _X11, + window: int, + *, + window_title: str, + excluded_window_ids: Collection[int], + expected_pid: int | None, + diagnostics: WindowDiagnosticRecorder | None = None, +) -> int | None: + """Follow a replacement from the same game without restoring minimized clients.""" + + try: + state = mapper.window_state(window) + if ( + state is not None + and state != _X_WM_STATE_WITHDRAWN + and (expected_pid is None or mapper.window_pid(window) == expected_pid) + ): + return window + # A shared host display can contain several accounts with identical + # titles. Without the original client's PID, a replacement is ambiguous. + if expected_pid is None: + return None + found = mapper.find_first_window( + window_title, + excluded_window_ids=set(excluded_window_ids) | {window}, + expected_pid=expected_pid, + ) + if found is None: + return None + replacement, title = found + except Exception: + return None + _apply_game_window_identity(mapper, replacement) + if diagnostics is not None: + diagnostics.record( + "game_window_replaced", + previous_window_id=window, + window_id=replacement, + **_sample_window_diagnostic_details(diagnostics, mapper, replacement), + ) + print( + f"Game window changed; continuing focus monitoring: {title}", + file=sys.stderr, + flush=True, + ) + return replacement + + def _maintain_game_window_focus( mapper: _X11, process: subprocess.Popen[bytes], window: int, *, + window_title: str = WINDOW_TITLE, + existing_window_ids: Collection[int] = (), + expected_pid: int | None = None, diagnostics: WindowDiagnosticRecorder | None = None, ) -> int: - """Keep the keyboard-focus repair while retaining Wine's fixed-size hints.""" + """Keep keyboard-focus repair without making further window-size changes.""" print( - "Game window resizing is disabled to prevent repeated Direct3D " - "device resets.", + "Game window focus monitoring active.", file=sys.stderr, flush=True, ) + if expected_pid is None: + try: + expected_pid = mapper.window_pid(window) + except Exception: + pass + retired_window_ids = set(existing_window_ids) next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS while process.poll() is None: time.sleep(WINDOW_SCALING_EVENT_TIMEOUT_SECONDS) now = time.monotonic() if now < next_focus_repair_at: continue - if diagnostics is None: - _repair_game_window_focus(mapper, window) - else: + resolved_window = _resolve_direct_game_window( + mapper, + window, + window_title=window_title, + excluded_window_ids=retired_window_ids, + expected_pid=expected_pid, + diagnostics=diagnostics, + ) + if resolved_window is not None: + if resolved_window != window: + retired_window_ids.add(window) + window = resolved_window _repair_game_window_focus( mapper, window, - diagnostics=diagnostics, + **({"diagnostics": diagnostics} if diagnostics is not None else {}), ) next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS return int(process.wait()) @@ -2120,7 +2325,8 @@ def _guard_game_window_startup( ) -> bool: """Recover Wine's login-screen window transition without hiding the client.""" - deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS + started_at = time.monotonic() + deadline = started_at + WINDOW_STARTUP_GUARD_SECONDS try: initial_borderless = ( mapper.window_is_borderless(window) if borderless else None @@ -2150,8 +2356,10 @@ def _guard_game_window_startup( **_sample_window_diagnostic_details(diagnostics, mapper, window), ) recovery_count = 0 + next_focus_repair_at = started_at + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS while process.poll() is None: - if time.monotonic() >= deadline: + now = time.monotonic() + if now >= deadline: return True try: state = mapper.window_state(window) @@ -2193,6 +2401,17 @@ def _guard_game_window_startup( flush=True, ) return False + if state is None or state == _X_WM_STATE_WITHDRAWN: + # Let the focus monitor look for the replacement without waiting + # out the startup guard on a window which no longer exists. + return False + if now >= next_focus_repair_at: + _repair_game_window_focus( + mapper, + window, + **({"diagnostics": diagnostics} if diagnostics is not None else {}), + ) + next_focus_repair_at = now + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS if ( not entered_screen_filling_mode and not login_style_reset @@ -2200,13 +2419,18 @@ def _guard_game_window_startup( time.sleep(WINDOW_STARTUP_POLL_SECONDS) continue + try: + if state != _X_WM_STATE_NORMAL or not mapper.window_is_active(window): + return False + except Exception: + return False recovery_count += 1 if entered_screen_filling_mode: reason = "entered fullscreen/maximized mode" else: reason = "changed its window layout" print( - f"Game startup window {reason}; restoring and refocusing it.", + f"Game startup window {reason}; restoring its window layout.", file=sys.stderr, flush=True, ) @@ -2219,16 +2443,16 @@ def _guard_game_window_startup( **_sample_window_diagnostic_details(diagnostics, mapper, window), ) try: - mapper.activate_window(window) - time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS) + if mapper.window_state(window) != _X_WM_STATE_NORMAL or not mapper.window_is_active(window): + return False _prepare_game_window( mapper, window, borderless=borderless, suppress_wm_ping=False, + raise_window=False, ) - mapper.activate_window(window) - mapper.focus_window(window) + _repair_game_window_focus(mapper, window) if borderless: borderless_was_applied = ( mapper.window_is_borderless(window) is True @@ -2322,6 +2546,7 @@ def run_game_window( finally: diagnostics.close(process_started=True, managed=False) game_window: int | None = None + game_window_pid: int | None = None game_title: str | None = None game_window_created_at: float | None = None deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS @@ -2336,6 +2561,10 @@ def run_game_window( if found is not None: window, title = found game_window = window + try: + game_window_pid = mapper.window_pid(window) + except Exception: + game_window_pid = None game_title = title game_window_created_at = now _apply_game_window_identity(mapper, window) @@ -2381,7 +2610,12 @@ def run_game_window( ), ) if _gamescope_host_focus_is_allowed(): - _focus_game_window(mapper, game_window) + _focus_game_window( + mapper, + game_window, + activate=False, + **({"diagnostics": diagnostic_sink} if diagnostic_sink is not None else {}), + ) print( "Game presentation is isolated; Gamescope handles " "aspect-preserving outer-window resizing.", @@ -2405,6 +2639,7 @@ def run_game_window( game_window, borderless=borderless, suppress_wm_ping=suppress_wm_ping, + raise_window=False, ) print( f"Game window ready: {game_title or window_title}", @@ -2422,7 +2657,11 @@ def run_game_window( game_window, ), ) - _focus_game_window(mapper, game_window) + _focus_game_window( + mapper, + game_window, + **({"diagnostics": diagnostic_sink} if diagnostic_sink is not None else {}), + ) startup_stable = _guard_game_window_startup( mapper, process, @@ -2444,31 +2683,32 @@ def run_game_window( mapper, process, game_window, + window_title=window_title, + existing_window_ids=existing_window_ids, + expected_pid=game_window_pid, **( {"diagnostics": diagnostic_sink} if diagnostic_sink is not None else {} ), ) - if not borderless: - if allow_window_resizing and not startup_stable: - print( - "Warning: live window resizing was not enabled because the " - "startup window did not stabilize.", - file=sys.stderr, - flush=True, - ) - return _maintain_game_window_focus( - mapper, - process, - game_window, - **( - {"diagnostics": diagnostic_sink} - if diagnostic_sink is not None - else {} - ), + if allow_window_resizing and not startup_stable and not borderless: + print( + "Warning: live window resizing was not enabled because the " + "startup window did not stabilize.", + file=sys.stderr, + flush=True, ) - return int(process.wait()) + # Borderless clients need the same active-window-only input repair. + return _maintain_game_window_focus( + mapper, + process, + game_window, + window_title=window_title, + existing_window_ids=existing_window_ids, + expected_pid=game_window_pid, + **({"diagnostics": diagnostic_sink} if diagnostic_sink is not None else {}), + ) time.sleep(0.1) if process.poll() is None: print( @@ -2482,6 +2722,7 @@ def run_game_window( game_window, borderless=borderless, suppress_wm_ping=suppress_wm_ping, + raise_window=False, ) return int(process.wait()) finally: diff --git a/src/ptlinux/telemetry/__init__.py b/src/ptlinux/telemetry/__init__.py index 2591dd6..bdfc558 100644 --- a/src/ptlinux/telemetry/__init__.py +++ b/src/ptlinux/telemetry/__init__.py @@ -380,12 +380,19 @@ def normalized_error_message(message: str) -> str: def _resource_usage() -> dict[str, object]: - return _environment_resource_usage( + """Report launcher-lifetime CPU time and peak RSS, excluding the game.""" + + snapshot = _environment_resource_usage( get_usage=resource.getrusage, usage_target=resource.RUSAGE_SELF, platform_name=sys.platform, is_finite=math.isfinite, ) + if snapshot: + # Keep the existing numeric fields for telemetry consumers, but make + # their scope explicit even when attached to a game_exited event. + snapshot["resource_usage_scope"] = "launcher_process" + return snapshot def error_fingerprint( diff --git a/src/ptlinux/telemetry/environment.py b/src/ptlinux/telemetry/environment.py index 7c797fc..b0eea91 100644 --- a/src/ptlinux/telemetry/environment.py +++ b/src/ptlinux/telemetry/environment.py @@ -224,7 +224,7 @@ def resource_usage( platform_name: str = sys.platform, is_finite: Callable[[float], bool] = math.isfinite, ) -> dict[str, object]: - """Read one bounded process-usage snapshot on the Linux launcher host.""" + """Read cumulative CPU time and peak resident memory for the selected process.""" try: usage = get_usage(usage_target) diff --git a/updates/stable.json b/updates/stable.json index 3c34e71..94e5f28 100644 --- a/updates/stable.json +++ b/updates/stable.json @@ -2,16 +2,16 @@ "application": "PristonTale-EU", "archive": { "layout": "flat", - "name": "PristonTale-EU-1.1.1.tar.gz", - "sha256": "f3825038f83c1348a6eec790e94f5f244fe5ce246d39e2d32f65bb58395542e0", - "size": 1551152, - "url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.1/PristonTale-EU-1.1.1.tar.gz?sha256=f3825038f83c1348a6eec790e94f5f244fe5ce246d39e2d32f65bb58395542e0" + "name": "PristonTale-EU-1.1.2.tar.gz", + "sha256": "5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281", + "size": 1553283, + "url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.2/PristonTale-EU-1.1.2.tar.gz?sha256=5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281" }, - "archive_sha256": "f3825038f83c1348a6eec790e94f5f244fe5ce246d39e2d32f65bb58395542e0", - "archive_size": 1551152, - "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.1/PristonTale-EU-1.1.1.tar.gz?sha256=f3825038f83c1348a6eec790e94f5f244fe5ce246d39e2d32f65bb58395542e0", + "archive_sha256": "5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281", + "archive_size": 1553283, + "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.2/PristonTale-EU-1.1.2.tar.gz?sha256=5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281", "channel": "stable", - "commit": "d75c345d36b5916ce58fdf5fd9dbdf4e6d006471", + "commit": "770bbb8a88ecc0a847649aee1af2e9500951bcd6", "compatibility": { "config_schema": 1, "manifest_schema": 1, @@ -36,7 +36,7 @@ }, { "path": "ptlinux/__init__.py", - "sha256": "1ec358b790abcebfcba6a44510323648e9a11a6b4e1f2b6b666e288c10fe4c96", + "sha256": "1bb085ba401a36b95964349f6415c075a18f2ecce2b08aeecfb59bab472848aa", "size": 151 }, { @@ -71,8 +71,8 @@ }, { "path": "ptlinux/application/commands/play.py", - "sha256": "3328a0dff4335c3e20a89a5984d889bee1137f509f3605a979f4d8aee595efe4", - "size": 37609 + "sha256": "13b252a82bc2b28d22e7d2fefc24e739e20e913c667e14a1cb11198b9d880aae", + "size": 38393 }, { "path": "ptlinux/application/commands/settings.py", @@ -406,8 +406,8 @@ }, { "path": "ptlinux/presentation/game_window.py", - "sha256": "217ec82c0d35edf9f3e4346096930460c8f4de6c9b97f98512ef19afcf1eb9ba", - "size": 71882 + "sha256": "a605537bb8b3a052369510fdf7709ac5bfde917be651169470f79b59887ea58f", + "size": 80275 }, { "path": "ptlinux/presentation/gamescope.py", @@ -456,8 +456,8 @@ }, { "path": "ptlinux/telemetry/__init__.py", - "sha256": "fb79ca2ececb07eef4a78f3306b817caa300ddf3da9c71973d660349b9ce644f", - "size": 12635 + "sha256": "bef4c247d5bbf2e2eb9c14e89e914d4a433040c400a27303150038077a9aee22", + "size": 12941 }, { "path": "ptlinux/telemetry/dependencies.py", @@ -466,8 +466,8 @@ }, { "path": "ptlinux/telemetry/environment.py", - "sha256": "bac7a3a023f4530d306edcd366481cfbc867823c57d59734ebb043dcdb74f6eb", - "size": 7333 + "sha256": "f8ccee2d33283e935e474a66aa845141d395299cc14fea0f631304a9d9207e9a", + "size": 7341 }, { "path": "ptlinux/telemetry/errors.py", @@ -706,21 +706,21 @@ }, { "path": "release.json", - "sha256": "1480e6705168b79b038871832ee1558f1e79e8bdac2fd285c558950cc1fcbae9", + "sha256": "f95fab56f77e0dff21b6f0129625de84f0dff413671126921b37afe8f629e181", "size": 218 } ], "minimum_updater_version": "0.9.9", - "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.1", - "published_at": "2026-10-20T21:42:30Z", + "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.2", + "published_at": "2026-10-23T19:34:51Z", "release": { - "notes_markdown": "# PristonTale EU Launcher 1.1.1\n\n- Made GE-Proton with modern WoW64 the default runtime for Babel.\n- Automatically migrate existing runtime settings once, while preserving later manual choices and PTR's Proton 7 compatibility.\n- Persist settings migrations during command-line launches and updates.\n- Improved detection of collision warnings in game crash diagnostics.\n\nThe first launch may download GE-Proton and prepare its separate compatibility prefix.\n\n---\n\nVersion: `1.1.1` \nChannel: `stable` \nTag: `v1.1.1` \nSource commit: `d75c345d36b5916ce58fdf5fd9dbdf4e6d006471`\n", - "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.1", - "summary": "Made GE-Proton with modern WoW64 the default runtime for Babel.", - "title": "PristonTale EU Launcher 1.1.1" + "notes_markdown": "# PristonTale EU Launcher 1.1.2\n\n- Improved GE-Proton keyboard focus on X11, including delayed startup and login-window changes.\n- Kept focus recovery active for fixed-size and borderless windows while respecting Alt-Tab and minimization.\n- Prevented replacement-window tracking from attaching to another game account.\n- Added GE-Proton comparison advice for Direct3D resource exhaustion while preserving PTR runtime requirements.\n- Clarified that reported launcher memory and CPU usage exclude the game process.\n\nThe reported Zorin/NVIDIA freeze still needs a gameplay retest with GE-Proton and these focus fixes.\n\n---\n\nVersion: `1.1.2` \nChannel: `stable` \nTag: `v1.1.2` \nSource commit: `770bbb8a88ecc0a847649aee1af2e9500951bcd6`\n", + "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.2", + "summary": "Improved GE-Proton keyboard focus on X11, including delayed startup and login-window changes.", + "title": "PristonTale EU Launcher 1.1.2" }, "schema": 1, "status": "active", - "tag": "v1.1.1", - "version": "1.1.1" + "tag": "v1.1.2", + "version": "1.1.2" } diff --git a/updates/stable.json.sig b/updates/stable.json.sig index b7eef8e..fe64388 100644 Binary files a/updates/stable.json.sig and b/updates/stable.json.sig differ