1 Commits

Author SHA1 Message Date
531480b195 Fixed issues with X11 focus, improved Direct3D telemetry. 2026-10-24 00:23:50 +02:00
7 changed files with 348 additions and 82 deletions

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"] __all__ = ["AppPaths", "__version__"]
__version__ = "1.1.1" __version__ = "1.1.2"

View File

@@ -21,6 +21,7 @@ from ...diagnostics.crash_dumps import (
prepare_minidump_attachment, prepare_minidump_attachment,
) )
from ...diagnostics.native_logs import LogFileState 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 ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm
from ...launcher import LauncherError from ...launcher import LauncherError
from ...login_trace import LOGIN_TRACE_DISABLED_REASON from ...login_trace import LOGIN_TRACE_DISABLED_REASON
@@ -394,6 +395,8 @@ def play_locked(
recommended_comparison = recommended_runtime_comparison( recommended_comparison = recommended_runtime_comparison(
selection.kind, selection.kind,
runtime_issues, runtime_issues,
current_game_log=current_game_log,
realm=start_option.name,
) )
if recommended_comparison is not None: if recommended_comparison is not None:
runtime_issues["recommended_one_variable_comparison"] = recommended_comparison runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
@@ -632,9 +635,24 @@ def game_exit_metadata(
def recommended_runtime_comparison( def recommended_runtime_comparison(
runtime_kind: str, runtime_kind: str,
runtime_issues: dict[str, object], runtime_issues: dict[str, object],
*,
current_game_log: dict[str, object] | None = None,
realm: str | None = None,
) -> str | None: ) -> str | None:
"""Choose a useful one-variable comparison from launch-relevant evidence.""" """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 ( if (
runtime_kind == "proton7-container" runtime_kind == "proton7-container"
and runtime_issues.get("host_futex_fatal_count") and runtime_issues.get("host_futex_fatal_count")

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import ctypes import ctypes
import ctypes.util import ctypes.util
import math
import os import os
from pathlib import Path from pathlib import Path
import select import select
@@ -301,24 +302,40 @@ class _X11:
self._restore_error_handler() self._restore_error_handler()
def root_window_ids(self) -> set[int]: 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( def find_first_window(
self, self,
title_fragment: str, title_fragment: str,
*, *,
excluded_window_ids: Collection[int], excluded_window_ids: Collection[int],
expected_pid: int | None = None,
) -> tuple[int, str] | None: ) -> tuple[int, str] | None:
windows: list[tuple[int, str]] = [] 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) title = self._window_title(window)
if title is None and frame != window:
title = self._window_title(frame)
if title is not None: if title is not None:
windows.append((window, title)) windows.append((window, title))
selected = _select_game_window(windows, title_fragment, excluded_window_ids) return _select_game_window(windows, title_fragment, excluded_window_ids)
if selected is None:
return None def window_pid(self, window: int) -> int | None:
window, title = selected """Read the client's process identity within this X server."""
return self._client_window(window), title
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: def find_window_by_pid(self, expected_pid: int) -> int | None:
"""Find a top-level host window created by one native process.""" """Find a top-level host window created by one native process."""
@@ -1057,6 +1074,7 @@ class _X11:
window: int, window: int,
*, *,
borderless: bool, borderless: bool,
raise_window: bool = True,
) -> tuple[int, int] | None: ) -> tuple[int, int] | None:
# Capture the real client size for both decorated and borderless windows. # Capture the real client size for both decorated and borderless windows.
# Wine/DXVK can otherwise retain a desktop-sized render surface inside a # Wine/DXVK can otherwise retain a desktop-sized render surface inside a
@@ -1064,6 +1082,7 @@ class _X11:
target_size = self.window_size(window) target_size = self.window_size(window)
if borderless: if borderless:
self._remove_decorations(window) self._remove_decorations(window)
if raise_window:
self.library.XMapRaised(self.display, window) self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display) self.library.XFlush(self.display)
return target_size return target_size
@@ -1133,6 +1152,8 @@ class _X11:
self.library.XSync(self.display, 0) self.library.XSync(self.display, 0)
def window_is_focused(self, window: int) -> bool: 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() focused = ctypes.c_ulong()
revert_to = ctypes.c_int() revert_to = ctypes.c_int()
if self.library.XGetInputFocus( if self.library.XGetInputFocus(
@@ -1141,7 +1162,33 @@ class _X11:
ctypes.byref(revert_to), ctypes.byref(revert_to),
) != 1: ) != 1:
return False 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: def window_is_active(self, window: int) -> bool:
"""Return whether the desktop already considers this client active.""" """Return whether the desktop already considers this client active."""
@@ -1167,10 +1214,22 @@ class _X11:
) )
if active_window == 0: if active_window == 0:
return False 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( return self._send_root_client_message(
window, window,
active_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( def _send_root_client_message(
@@ -1495,7 +1554,14 @@ def _prepare_game_window(
*, *,
borderless: bool, borderless: bool,
suppress_wm_ping: bool, suppress_wm_ping: bool,
raise_window: bool = True,
) -> None: ) -> None:
if not raise_window:
try:
if mapper.window_state(window) != _X_WM_STATE_NORMAL:
return
except Exception:
return
if suppress_wm_ping: if suppress_wm_ping:
try: try:
ping_suppressed = mapper.suppress_wm_ping(window) ping_suppressed = mapper.suppress_wm_ping(window)
@@ -1520,7 +1586,11 @@ def _prepare_game_window(
file=sys.stderr, file=sys.stderr,
flush=True, 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 forced_windowed = False
try: try:
forced_windowed = mapper.force_windowed_state(window) 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.""" """Let the dashboard process its ready event before focusing the game."""
time.sleep(WINDOW_FOCUS_DELAY_SECONDS) time.sleep(WINDOW_FOCUS_DELAY_SECONDS)
activation_requested = False
activation_submitted = False
outcome = "not_activated"
try: 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: except Exception as exc:
outcome = "error"
print( print(
f"Warning: could not focus the game window ({exc}); continuing normally", f"Warning: could not focus the game window ({exc}); continuing normally",
file=sys.stderr, file=sys.stderr,
flush=True, 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( def _repair_game_window_focus(
@@ -1632,7 +1751,7 @@ def _repair_game_window_focus(
active=active, active=active,
focused=focused, focused=focused,
) )
if state == _X_WM_STATE_ICONIC: if state != _X_WM_STATE_NORMAL:
return False return False
if not active: if not active:
return False return False
@@ -1641,6 +1760,9 @@ def _repair_game_window_focus(
except Exception: except Exception:
return False return False
try: 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) mapper.restore_input_focus(window)
repaired = mapper.window_is_focused(window) repaired = mapper.window_is_focused(window)
if diagnostics is not None: if diagnostics is not None:
@@ -1697,13 +1819,15 @@ def _repair_gamescope_game_window_focus(
wm_state=_wm_state_name(state), wm_state=_wm_state_name(state),
focused=focused, focused=focused,
) )
if state == _X_WM_STATE_ICONIC: if state != _X_WM_STATE_NORMAL:
return False return False
if focused: if focused:
return True return True
except Exception: except Exception:
return False return False
try: try:
if not _gamescope_host_focus_is_allowed() or mapper.window_state(window) != _X_WM_STATE_NORMAL:
return False
mapper.restore_input_focus(window) mapper.restore_input_focus(window)
repaired = mapper.window_is_focused(window) repaired = mapper.window_is_focused(window)
if diagnostics is not None: if diagnostics is not None:
@@ -1769,6 +1893,9 @@ def _gamescope_host_focus_status(
return {"allowed": False, "bridge": "unreadable"} return {"allowed": False, "bridge": "unreadable"}
observed_at = time.monotonic() if now is None else now observed_at = time.monotonic() if now is None else now
age = observed_at - published_at 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) age_ms = round(age * 1000)
if age < 0.0: if age < 0.0:
return {"allowed": False, "bridge": "future", "age_ms": age_ms} return {"allowed": False, "bridge": "future", "age_ms": age_ms}
@@ -1919,10 +2046,22 @@ def _maintain_game_window_scaling(
process: subprocess.Popen[bytes], process: subprocess.Popen[bytes],
window: int, window: int,
*, *,
window_title: str = WINDOW_TITLE,
existing_window_ids: Collection[int] = (),
expected_pid: int | None = None,
diagnostics: WindowDiagnosticRecorder | None = None, diagnostics: WindowDiagnosticRecorder | None = None,
) -> int: ) -> int:
"""Keep host scaling hints across Wine maximize/restore transitions.""" """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: try:
mapper.select_scaling_hint_events(window) mapper.select_scaling_hint_events(window)
except Exception as exc: except Exception as exc:
@@ -1938,7 +2077,7 @@ def _maintain_game_window_scaling(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
return int(process.wait()) return keep_focus_monitoring()
scaling_enabled = ( scaling_enabled = (
_enable_game_window_scaling(mapper, window) _enable_game_window_scaling(mapper, window)
@@ -1950,7 +2089,7 @@ def _maintain_game_window_scaling(
) )
) )
if not scaling_enabled: if not scaling_enabled:
return int(process.wait()) return keep_focus_monitoring()
repair_failures = 0 repair_failures = 0
next_scaling_repair_at = 0.0 next_scaling_repair_at = 0.0
@@ -1965,7 +2104,7 @@ def _maintain_game_window_scaling(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
break return keep_focus_monitoring()
now = time.monotonic() now = time.monotonic()
if now >= next_focus_repair_at: if now >= next_focus_repair_at:
if diagnostics is None: if diagnostics is None:
@@ -2005,7 +2144,7 @@ def _maintain_game_window_scaling(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
break return keep_focus_monitoring()
else: else:
repair_failures = 0 repair_failures = 0
if hints_changed and diagnostics is not None: if hints_changed and diagnostics is not None:
@@ -2018,34 +2157,100 @@ def _maintain_game_window_scaling(
return int(process.wait()) 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( def _maintain_game_window_focus(
mapper: _X11, mapper: _X11,
process: subprocess.Popen[bytes], process: subprocess.Popen[bytes],
window: int, window: int,
*, *,
window_title: str = WINDOW_TITLE,
existing_window_ids: Collection[int] = (),
expected_pid: int | None = None,
diagnostics: WindowDiagnosticRecorder | None = None, diagnostics: WindowDiagnosticRecorder | None = None,
) -> int: ) -> int:
"""Keep the keyboard-focus repair while retaining Wine's fixed-size hints.""" """Keep keyboard-focus repair without making further window-size changes."""
print( print(
"Game window resizing is disabled to prevent repeated Direct3D " "Game window focus monitoring active.",
"device resets.",
file=sys.stderr, file=sys.stderr,
flush=True, 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 next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS
while process.poll() is None: while process.poll() is None:
time.sleep(WINDOW_SCALING_EVENT_TIMEOUT_SECONDS) time.sleep(WINDOW_SCALING_EVENT_TIMEOUT_SECONDS)
now = time.monotonic() now = time.monotonic()
if now < next_focus_repair_at: if now < next_focus_repair_at:
continue continue
if diagnostics is None: resolved_window = _resolve_direct_game_window(
_repair_game_window_focus(mapper, window) mapper,
else: 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( _repair_game_window_focus(
mapper, mapper,
window, window,
diagnostics=diagnostics, **({"diagnostics": diagnostics} if diagnostics is not None else {}),
) )
next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS
return int(process.wait()) return int(process.wait())
@@ -2120,7 +2325,8 @@ def _guard_game_window_startup(
) -> bool: ) -> bool:
"""Recover Wine's login-screen window transition without hiding the client.""" """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: try:
initial_borderless = ( initial_borderless = (
mapper.window_is_borderless(window) if borderless else None 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), **_sample_window_diagnostic_details(diagnostics, mapper, window),
) )
recovery_count = 0 recovery_count = 0
next_focus_repair_at = started_at + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS
while process.poll() is None: while process.poll() is None:
if time.monotonic() >= deadline: now = time.monotonic()
if now >= deadline:
return True return True
try: try:
state = mapper.window_state(window) state = mapper.window_state(window)
@@ -2193,6 +2401,17 @@ def _guard_game_window_startup(
flush=True, flush=True,
) )
return False 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 ( if (
not entered_screen_filling_mode not entered_screen_filling_mode
and not login_style_reset and not login_style_reset
@@ -2200,13 +2419,18 @@ def _guard_game_window_startup(
time.sleep(WINDOW_STARTUP_POLL_SECONDS) time.sleep(WINDOW_STARTUP_POLL_SECONDS)
continue continue
try:
if state != _X_WM_STATE_NORMAL or not mapper.window_is_active(window):
return False
except Exception:
return False
recovery_count += 1 recovery_count += 1
if entered_screen_filling_mode: if entered_screen_filling_mode:
reason = "entered fullscreen/maximized mode" reason = "entered fullscreen/maximized mode"
else: else:
reason = "changed its window layout" reason = "changed its window layout"
print( print(
f"Game startup window {reason}; restoring and refocusing it.", f"Game startup window {reason}; restoring its window layout.",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
@@ -2219,16 +2443,16 @@ def _guard_game_window_startup(
**_sample_window_diagnostic_details(diagnostics, mapper, window), **_sample_window_diagnostic_details(diagnostics, mapper, window),
) )
try: try:
mapper.activate_window(window) if mapper.window_state(window) != _X_WM_STATE_NORMAL or not mapper.window_is_active(window):
time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS) return False
_prepare_game_window( _prepare_game_window(
mapper, mapper,
window, window,
borderless=borderless, borderless=borderless,
suppress_wm_ping=False, suppress_wm_ping=False,
raise_window=False,
) )
mapper.activate_window(window) _repair_game_window_focus(mapper, window)
mapper.focus_window(window)
if borderless: if borderless:
borderless_was_applied = ( borderless_was_applied = (
mapper.window_is_borderless(window) is True mapper.window_is_borderless(window) is True
@@ -2322,6 +2546,7 @@ def run_game_window(
finally: finally:
diagnostics.close(process_started=True, managed=False) diagnostics.close(process_started=True, managed=False)
game_window: int | None = None game_window: int | None = None
game_window_pid: int | None = None
game_title: str | None = None game_title: str | None = None
game_window_created_at: float | None = None game_window_created_at: float | None = None
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
@@ -2336,6 +2561,10 @@ def run_game_window(
if found is not None: if found is not None:
window, title = found window, title = found
game_window = window game_window = window
try:
game_window_pid = mapper.window_pid(window)
except Exception:
game_window_pid = None
game_title = title game_title = title
game_window_created_at = now game_window_created_at = now
_apply_game_window_identity(mapper, window) _apply_game_window_identity(mapper, window)
@@ -2381,7 +2610,12 @@ def run_game_window(
), ),
) )
if _gamescope_host_focus_is_allowed(): 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( print(
"Game presentation is isolated; Gamescope handles " "Game presentation is isolated; Gamescope handles "
"aspect-preserving outer-window resizing.", "aspect-preserving outer-window resizing.",
@@ -2405,6 +2639,7 @@ def run_game_window(
game_window, game_window,
borderless=borderless, borderless=borderless,
suppress_wm_ping=suppress_wm_ping, suppress_wm_ping=suppress_wm_ping,
raise_window=False,
) )
print( print(
f"Game window ready: {game_title or window_title}", f"Game window ready: {game_title or window_title}",
@@ -2422,7 +2657,11 @@ def run_game_window(
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( startup_stable = _guard_game_window_startup(
mapper, mapper,
process, process,
@@ -2444,31 +2683,32 @@ def run_game_window(
mapper, mapper,
process, process,
game_window, game_window,
window_title=window_title,
existing_window_ids=existing_window_ids,
expected_pid=game_window_pid,
**( **(
{"diagnostics": diagnostic_sink} {"diagnostics": diagnostic_sink}
if diagnostic_sink is not None if diagnostic_sink is not None
else {} else {}
), ),
) )
if not borderless: if allow_window_resizing and not startup_stable and not borderless:
if allow_window_resizing and not startup_stable:
print( print(
"Warning: live window resizing was not enabled because the " "Warning: live window resizing was not enabled because the "
"startup window did not stabilize.", "startup window did not stabilize.",
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
# Borderless clients need the same active-window-only input repair.
return _maintain_game_window_focus( return _maintain_game_window_focus(
mapper, mapper,
process, process,
game_window, game_window,
**( window_title=window_title,
{"diagnostics": diagnostic_sink} existing_window_ids=existing_window_ids,
if diagnostic_sink is not None expected_pid=game_window_pid,
else {} **({"diagnostics": diagnostic_sink} if diagnostic_sink is not None else {}),
),
) )
return int(process.wait())
time.sleep(0.1) time.sleep(0.1)
if process.poll() is None: if process.poll() is None:
print( print(
@@ -2482,6 +2722,7 @@ def run_game_window(
game_window, game_window,
borderless=borderless, borderless=borderless,
suppress_wm_ping=suppress_wm_ping, suppress_wm_ping=suppress_wm_ping,
raise_window=False,
) )
return int(process.wait()) return int(process.wait())
finally: finally:

View File

@@ -380,12 +380,19 @@ def normalized_error_message(message: str) -> str:
def _resource_usage() -> dict[str, object]: 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, get_usage=resource.getrusage,
usage_target=resource.RUSAGE_SELF, usage_target=resource.RUSAGE_SELF,
platform_name=sys.platform, platform_name=sys.platform,
is_finite=math.isfinite, 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( def error_fingerprint(

View File

@@ -224,7 +224,7 @@ def resource_usage(
platform_name: str = sys.platform, platform_name: str = sys.platform,
is_finite: Callable[[float], bool] = math.isfinite, is_finite: Callable[[float], bool] = math.isfinite,
) -> dict[str, object]: ) -> 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: try:
usage = get_usage(usage_target) usage = get_usage(usage_target)

View File

@@ -2,16 +2,16 @@
"application": "PristonTale-EU", "application": "PristonTale-EU",
"archive": { "archive": {
"layout": "flat", "layout": "flat",
"name": "PristonTale-EU-1.1.1.tar.gz", "name": "PristonTale-EU-1.1.2.tar.gz",
"sha256": "f3825038f83c1348a6eec790e94f5f244fe5ce246d39e2d32f65bb58395542e0", "sha256": "5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281",
"size": 1551152, "size": 1553283,
"url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.1/PristonTale-EU-1.1.1.tar.gz?sha256=f3825038f83c1348a6eec790e94f5f244fe5ce246d39e2d32f65bb58395542e0" "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_sha256": "5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281",
"archive_size": 1551152, "archive_size": 1553283,
"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_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", "channel": "stable",
"commit": "d75c345d36b5916ce58fdf5fd9dbdf4e6d006471", "commit": "770bbb8a88ecc0a847649aee1af2e9500951bcd6",
"compatibility": { "compatibility": {
"config_schema": 1, "config_schema": 1,
"manifest_schema": 1, "manifest_schema": 1,
@@ -36,7 +36,7 @@
}, },
{ {
"path": "ptlinux/__init__.py", "path": "ptlinux/__init__.py",
"sha256": "1ec358b790abcebfcba6a44510323648e9a11a6b4e1f2b6b666e288c10fe4c96", "sha256": "1bb085ba401a36b95964349f6415c075a18f2ecce2b08aeecfb59bab472848aa",
"size": 151 "size": 151
}, },
{ {
@@ -71,8 +71,8 @@
}, },
{ {
"path": "ptlinux/application/commands/play.py", "path": "ptlinux/application/commands/play.py",
"sha256": "3328a0dff4335c3e20a89a5984d889bee1137f509f3605a979f4d8aee595efe4", "sha256": "13b252a82bc2b28d22e7d2fefc24e739e20e913c667e14a1cb11198b9d880aae",
"size": 37609 "size": 38393
}, },
{ {
"path": "ptlinux/application/commands/settings.py", "path": "ptlinux/application/commands/settings.py",
@@ -406,8 +406,8 @@
}, },
{ {
"path": "ptlinux/presentation/game_window.py", "path": "ptlinux/presentation/game_window.py",
"sha256": "217ec82c0d35edf9f3e4346096930460c8f4de6c9b97f98512ef19afcf1eb9ba", "sha256": "a605537bb8b3a052369510fdf7709ac5bfde917be651169470f79b59887ea58f",
"size": 71882 "size": 80275
}, },
{ {
"path": "ptlinux/presentation/gamescope.py", "path": "ptlinux/presentation/gamescope.py",
@@ -456,8 +456,8 @@
}, },
{ {
"path": "ptlinux/telemetry/__init__.py", "path": "ptlinux/telemetry/__init__.py",
"sha256": "fb79ca2ececb07eef4a78f3306b817caa300ddf3da9c71973d660349b9ce644f", "sha256": "bef4c247d5bbf2e2eb9c14e89e914d4a433040c400a27303150038077a9aee22",
"size": 12635 "size": 12941
}, },
{ {
"path": "ptlinux/telemetry/dependencies.py", "path": "ptlinux/telemetry/dependencies.py",
@@ -466,8 +466,8 @@
}, },
{ {
"path": "ptlinux/telemetry/environment.py", "path": "ptlinux/telemetry/environment.py",
"sha256": "bac7a3a023f4530d306edcd366481cfbc867823c57d59734ebb043dcdb74f6eb", "sha256": "f8ccee2d33283e935e474a66aa845141d395299cc14fea0f631304a9d9207e9a",
"size": 7333 "size": 7341
}, },
{ {
"path": "ptlinux/telemetry/errors.py", "path": "ptlinux/telemetry/errors.py",
@@ -706,21 +706,21 @@
}, },
{ {
"path": "release.json", "path": "release.json",
"sha256": "1480e6705168b79b038871832ee1558f1e79e8bdac2fd285c558950cc1fcbae9", "sha256": "f95fab56f77e0dff21b6f0129625de84f0dff413671126921b37afe8f629e181",
"size": 218 "size": 218
} }
], ],
"minimum_updater_version": "0.9.9", "minimum_updater_version": "0.9.9",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.1", "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.2",
"published_at": "2026-10-20T21:42:30Z", "published_at": "2026-10-23T19:34:51Z",
"release": { "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", "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.1", "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.2",
"summary": "Made GE-Proton with modern WoW64 the default runtime for Babel.", "summary": "Improved GE-Proton keyboard focus on X11, including delayed startup and login-window changes.",
"title": "PristonTale EU Launcher 1.1.1" "title": "PristonTale EU Launcher 1.1.2"
}, },
"schema": 1, "schema": 1,
"status": "active", "status": "active",
"tag": "v1.1.1", "tag": "v1.1.2",
"version": "1.1.1" "version": "1.1.2"
} }

Binary file not shown.