2 Commits

13 changed files with 389 additions and 110 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 1.0.8 installs, updates, repairs, and launches PristonTale EU through a Version 1.1.1 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not native Linux dashboard. Game files come directly from the publisher and are not
included in this archive. included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text ```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \ openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v1-0-8.tar.gz.sig \ -signature pristontale-eu-linux-installer-v1-1-1.tar.gz.sig \
pristontale-eu-linux-installer-v1-0-8.tar.gz pristontale-eu-linux-installer-v1-1-1.tar.gz
sha256sum -c pristontale-eu-linux-installer-v1-0-8.tar.gz.sha256 sha256sum -c pristontale-eu-linux-installer-v1-1-1.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v1-0-8.tar.gz tar -xzf pristontale-eu-linux-installer-v1-1-1.tar.gz
cd pristontale-eu-linux-installer-v1-0-8 cd pristontale-eu-linux-installer-v1-1-1
./install.sh ./install.sh
``` ```
@@ -34,10 +34,15 @@ Existing installations at the previous default location are preserved during
upgrades. The installer adds **PristonTale EU** to the application menu and upgrades. The installer adds **PristonTale EU** to the application menu and
installs the `pristontale-eu` command in `~/.local/bin`. installs the `pristontale-eu` command in `~/.local/bin`.
Steam Proton 7.0 is discovered in both the default Steam library and secondary GE-Proton through the managed UMU helper is the default runtime for the current
libraries registered with the Steam client. If Proton uses an unregistered game, with modern WoW64 and a separate compatibility prefix. Existing settings
manual layout, open **Settings → Optional Overrides** and select the exact switch to GE-Proton once on startup; users can choose another runtime afterwards.
`Proton 7.0/proton` script. Renderer preferences and saved Proton paths are retained. PTR continues to use
Proton 7.0. Proton 7.0 is discovered in both
the default Steam library and secondary libraries registered with Steam. For an
unregistered manual layout, select its exact `proton` script under
**Settings → Optional Overrides**. Use the renderer setting to choose DXVK or
WineD3D; environment assignments do not belong in the Proton script path.
## Use ## Use

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "PTEU-Linux-Launcher" name = "PTEU-Linux-Launcher"
version = "1.0.8" version = "1.1.1"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"] __all__ = ["AppPaths", "__version__"]
__version__ = "1.1.0" __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

@@ -861,11 +861,9 @@ def _source_game(
def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings: def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
if paths.settings_file.is_file():
return load_settings(paths)
if persist: if persist:
return ensure_settings(paths) return ensure_settings(paths)
return Settings() return load_settings(paths)
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner: def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:

View File

@@ -443,7 +443,7 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
): ):
analysis.out_of_memory_effect_paths.append(value) analysis.out_of_memory_effect_paths.append(value)
if "Stage without new stage" in raw: if "stage without new stage" in raw.casefold():
analysis.stage_without_new_stage = True analysis.stage_without_new_stage = True
analysis.events.append( analysis.events.append(
LogEvent("stage_without_new_stage", line_number, raw, timestamp) LogEvent("stage_without_new_stage", line_number, raw, timestamp)

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,7 +1082,8 @@ 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)
self.library.XMapRaised(self.display, window) if raise_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,
)
return _maintain_game_window_focus(
mapper,
process,
game_window,
**(
{"diagnostics": diagnostic_sink}
if diagnostic_sink is not None
else {}
),
) )
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) 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

@@ -18,7 +18,9 @@ from .update_resolver import SUPPORTED_CHANNELS
SETTINGS_SCHEMA = 1 SETTINGS_SCHEMA = 1
DEFAULT_GAME_RUNTIME = "ge-proton"
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated" _TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
_GE_PROTON_DEFAULT_MIGRATION_KEY = "ge_proton_default_migrated"
UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"}) UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"})
_RETIRED_SETTINGS = frozenset( _RETIRED_SETTINGS = frozenset(
{ {
@@ -43,7 +45,7 @@ class Settings:
game_crash_dump_upload_enabled: bool = False game_crash_dump_upload_enabled: bool = False
borderless: bool = False borderless: bool = False
suppress_unresponsive_warnings: bool = True suppress_unresponsive_warnings: bool = True
runtime: str = "auto" runtime: str = DEFAULT_GAME_RUNTIME
proton_sync: str = "auto" proton_sync: str = "auto"
proton_path: str | None = None proton_path: str | None = None
renderer: str = "auto" renderer: str = "auto"
@@ -188,7 +190,7 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
"suppress_unresponsive_warnings", "suppress_unresponsive_warnings",
True, True,
), ),
runtime=_string(value, "runtime", "auto"), runtime=_string(value, "runtime", DEFAULT_GAME_RUNTIME),
proton_sync=_string(value, "proton_sync", "auto"), proton_sync=_string(value, "proton_sync", "auto"),
proton_path=proton_path, proton_path=proton_path,
renderer=renderer, renderer=renderer,
@@ -201,6 +203,12 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
update_channel=_string(value, "update_channel", "stable"), update_channel=_string(value, "update_channel", "stable"),
) )
_validate(settings) _validate(settings)
# Move existing installations to the modern WoW64 profile once. Validate the
# saved runtime first so migration cannot hide malformed settings. New saves
# carry the marker, allowing users to choose another runtime afterwards.
# Keep the optional Proton path for PTR and future explicit legacy choices.
if not _boolean(value, _GE_PROTON_DEFAULT_MIGRATION_KEY, False):
return replace(settings, runtime=DEFAULT_GAME_RUNTIME)
return settings return settings
@@ -237,6 +245,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
value.get("schema") != SETTINGS_SCHEMA value.get("schema") != SETTINGS_SCHEMA
or not _SETTINGS_FIELD_NAMES.issubset(value) or not _SETTINGS_FIELD_NAMES.issubset(value)
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
or value.get(_GE_PROTON_DEFAULT_MIGRATION_KEY) is not True
or bool(_RETIRED_SETTINGS.intersection(value)) or bool(_RETIRED_SETTINGS.intersection(value))
or _legacy_renderer_assignment(value.get("proton_path")) is not None or _legacy_renderer_assignment(value.get("proton_path")) is not None
) )
@@ -279,6 +288,7 @@ def _write_settings(
payload.update(asdict(settings)) payload.update(asdict(settings))
payload["schema"] = SETTINGS_SCHEMA payload["schema"] = SETTINGS_SCHEMA
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
payload[_GE_PROTON_DEFAULT_MIGRATION_KEY] = True
temporary = paths.settings_file.with_name( temporary = paths.settings_file.with_name(
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}." f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
f"{secrets.token_hex(8)}.tmp" f"{secrets.token_hex(8)}.tmp"

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

@@ -26,8 +26,8 @@ from ..viewmodels.downloader import (
RUNTIME_LABELS: dict[str, str] = { RUNTIME_LABELS: dict[str, str] = {
"Automatic (recommended)": "auto", "GE-Proton — Modern WoW64 (recommended)": "ge-proton",
"GE-Proton — Modern WoW64 via UMU": "ge-proton", "Automatic": "auto",
"Proton 7 — Steam Runtime container": "proton7-container", "Proton 7 — Steam Runtime container": "proton7-container",
"Proton 7 — Direct": "proton7", "Proton 7 — Direct": "proton7",
"UMU Launcher": "umu", "UMU Launcher": "umu",
@@ -431,7 +431,7 @@ def open_settings_dialog(
rendering_card.columnconfigure(1, weight=1) rendering_card.columnconfigure(1, weight=1)
rendering_hint = ttk.Label( rendering_hint = ttk.Label(
rendering_card, rendering_card,
text="Automatic uses GE-Proton with modern WoW64 for Babel when UMU is available.", text="GE-Proton with modern WoW64 is recommended for Babel. PTR Babel uses Proton 7.",
style="SettingsHint.TLabel", style="SettingsHint.TLabel",
wraplength=360, wraplength=360,
) )

View File

@@ -2,16 +2,16 @@
"application": "PristonTale-EU", "application": "PristonTale-EU",
"archive": { "archive": {
"layout": "flat", "layout": "flat",
"name": "PristonTale-EU-1.1.0.tar.gz", "name": "PristonTale-EU-1.1.2.tar.gz",
"sha256": "b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c", "sha256": "5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281",
"size": 1550967, "size": 1553283,
"url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.0/PristonTale-EU-1.1.0.tar.gz?sha256=b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c" "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": "b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c", "archive_sha256": "5e2ced0bee8bb82a8731b5e6acfa10abba3b86190a56ff5c58d2c98248f27281",
"archive_size": 1550967, "archive_size": 1553283,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.0/PristonTale-EU-1.1.0.tar.gz?sha256=b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c", "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": "be7891f06617674864b035645137441321db03b4", "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": "d533461854917e3343b9f600b5949421eadedb9a0a550a8adf0444b81023d333", "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",
@@ -91,8 +91,8 @@
}, },
{ {
"path": "ptlinux/cli.py", "path": "ptlinux/cli.py",
"sha256": "ae8f3740fa997d7e00d26bc5fec68f582e70a051cfb00cd88c8a2eaeb547b4b0", "sha256": "e142e0f33a7f1f8f88b8ad431d1b28b24dab3f74d367ed1912e8106ba6aab229",
"size": 23871 "size": 23816
}, },
{ {
"path": "ptlinux/command.py", "path": "ptlinux/command.py",
@@ -131,8 +131,8 @@
}, },
{ {
"path": "ptlinux/diagnostics/native_logs.py", "path": "ptlinux/diagnostics/native_logs.py",
"sha256": "3c07a45e89f69b2607288d7fd98f20e0679ee35599de03a3d4e3838ef93e91ba", "sha256": "1a7044630052311e7f74ddd716b82a60383d4404493970db35f6741019510ecb",
"size": 18769 "size": 18780
}, },
{ {
"path": "ptlinux/doctor.py", "path": "ptlinux/doctor.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",
@@ -446,8 +446,8 @@
}, },
{ {
"path": "ptlinux/settings.py", "path": "ptlinux/settings.py",
"sha256": "8d62b262bb43eff664b9f6a58bf68a88fec0a8e05b6b9e97cbb47509079c2115", "sha256": "7fd8ecdcbc76feb9bef1f9c840e0e5f73f159c1d370d04e2f3455917a7bc3cce",
"size": 12059 "size": 12727
}, },
{ {
"path": "ptlinux/system_identity.py", "path": "ptlinux/system_identity.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",
@@ -656,8 +656,8 @@
}, },
{ {
"path": "ptlinux/ui/dialogs/settings_dialog.py", "path": "ptlinux/ui/dialogs/settings_dialog.py",
"sha256": "20a8e461799fa2104e5a09a6e4ec2e8c027caa0688726c57c9ed45e872c1971f", "sha256": "36ffa11821ec1ee3c95774071736082828c58c1f37874f00e66441d2ba0f76fa",
"size": 25299 "size": 25294
}, },
{ {
"path": "ptlinux/ui/viewmodels/__init__.py", "path": "ptlinux/ui/viewmodels/__init__.py",
@@ -706,21 +706,21 @@
}, },
{ {
"path": "release.json", "path": "release.json",
"sha256": "5dfcea2e0eb6ee4d79dba982d79e1a4d5efe1ce0579537d2a0cfab990153636a", "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.0", "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.2",
"published_at": "2026-09-19T10:49:03Z", "published_at": "2026-10-23T19:34:51Z",
"release": { "release": {
"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", "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.0", "release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.2",
"summary": "Improved GE-Proton/UMU compatibility, launch arguments, and renderer selection.", "summary": "Improved GE-Proton keyboard focus on X11, including delayed startup and login-window changes.",
"title": "PristonTale EU Launcher 1.1.0" "title": "PristonTale EU Launcher 1.1.2"
}, },
"schema": 1, "schema": 1,
"status": "active", "status": "active",
"tag": "v1.1.0", "tag": "v1.1.2",
"version": "1.1.0" "version": "1.1.2"
} }

Binary file not shown.