2 Commits

13 changed files with 389 additions and 110 deletions

View File

@@ -1,6 +1,6 @@
# 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
included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v1-0-8.tar.gz.sig \
pristontale-eu-linux-installer-v1-0-8.tar.gz
sha256sum -c pristontale-eu-linux-installer-v1-0-8.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v1-0-8.tar.gz
cd pristontale-eu-linux-installer-v1-0-8
-signature pristontale-eu-linux-installer-v1-1-1.tar.gz.sig \
pristontale-eu-linux-installer-v1-1-1.tar.gz
sha256sum -c pristontale-eu-linux-installer-v1-1-1.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v1-1-1.tar.gz
cd pristontale-eu-linux-installer-v1-1-1
./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
installs the `pristontale-eu` command in `~/.local/bin`.
Steam Proton 7.0 is discovered in both the default Steam library and secondary
libraries registered with the Steam client. If Proton uses an unregistered
manual layout, open **Settings → Optional Overrides** and select the exact
`Proton 7.0/proton` script.
GE-Proton through the managed UMU helper is the default runtime for the current
game, with modern WoW64 and a separate compatibility prefix. Existing settings
switch to GE-Proton once on startup; users can choose another runtime afterwards.
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

View File

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

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__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,
)
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")

View File

@@ -861,11 +861,9 @@ def _source_game(
def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
if paths.settings_file.is_file():
return load_settings(paths)
if persist:
return ensure_settings(paths)
return Settings()
return load_settings(paths)
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)
if "Stage without new stage" in raw:
if "stage without new stage" in raw.casefold():
analysis.stage_without_new_stage = True
analysis.events.append(
LogEvent("stage_without_new_stage", line_number, raw, timestamp)

View File

@@ -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,6 +1082,7 @@ class _X11:
target_size = self.window_size(window)
if borderless:
self._remove_decorations(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:
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,
)
# Borderless clients need the same active-window-only input repair.
return _maintain_game_window_focus(
mapper,
process,
game_window,
**(
{"diagnostics": diagnostic_sink}
if diagnostic_sink is not None
else {}
),
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 {}),
)
return int(process.wait())
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:

View File

@@ -18,7 +18,9 @@ from .update_resolver import SUPPORTED_CHANNELS
SETTINGS_SCHEMA = 1
DEFAULT_GAME_RUNTIME = "ge-proton"
_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"})
_RETIRED_SETTINGS = frozenset(
{
@@ -43,7 +45,7 @@ class Settings:
game_crash_dump_upload_enabled: bool = False
borderless: bool = False
suppress_unresponsive_warnings: bool = True
runtime: str = "auto"
runtime: str = DEFAULT_GAME_RUNTIME
proton_sync: str = "auto"
proton_path: str | None = None
renderer: str = "auto"
@@ -188,7 +190,7 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
"suppress_unresponsive_warnings",
True,
),
runtime=_string(value, "runtime", "auto"),
runtime=_string(value, "runtime", DEFAULT_GAME_RUNTIME),
proton_sync=_string(value, "proton_sync", "auto"),
proton_path=proton_path,
renderer=renderer,
@@ -201,6 +203,12 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
update_channel=_string(value, "update_channel", "stable"),
)
_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
@@ -237,6 +245,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
value.get("schema") != SETTINGS_SCHEMA
or not _SETTINGS_FIELD_NAMES.issubset(value)
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 _legacy_renderer_assignment(value.get("proton_path")) is not None
)
@@ -279,6 +288,7 @@ def _write_settings(
payload.update(asdict(settings))
payload["schema"] = SETTINGS_SCHEMA
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
payload[_GE_PROTON_DEFAULT_MIGRATION_KEY] = True
temporary = paths.settings_file.with_name(
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
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]:
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(

View File

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

View File

@@ -26,8 +26,8 @@ from ..viewmodels.downloader import (
RUNTIME_LABELS: dict[str, str] = {
"Automatic (recommended)": "auto",
"GE-Proton — Modern WoW64 via UMU": "ge-proton",
"GE-Proton — Modern WoW64 (recommended)": "ge-proton",
"Automatic": "auto",
"Proton 7 — Steam Runtime container": "proton7-container",
"Proton 7 — Direct": "proton7",
"UMU Launcher": "umu",
@@ -431,7 +431,7 @@ def open_settings_dialog(
rendering_card.columnconfigure(1, weight=1)
rendering_hint = ttk.Label(
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",
wraplength=360,
)

View File

@@ -2,16 +2,16 @@
"application": "PristonTale-EU",
"archive": {
"layout": "flat",
"name": "PristonTale-EU-1.1.0.tar.gz",
"sha256": "b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c",
"size": 1550967,
"url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.0/PristonTale-EU-1.1.0.tar.gz?sha256=b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c"
"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": "b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c",
"archive_size": 1550967,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.1.0/PristonTale-EU-1.1.0.tar.gz?sha256=b0bb10bdbe0d0e01ce04c8cdb2307c45f0cedb7e2c081794b6efe68d6aa4ac5c",
"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": "be7891f06617674864b035645137441321db03b4",
"commit": "770bbb8a88ecc0a847649aee1af2e9500951bcd6",
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
@@ -36,7 +36,7 @@
},
{
"path": "ptlinux/__init__.py",
"sha256": "d533461854917e3343b9f600b5949421eadedb9a0a550a8adf0444b81023d333",
"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",
@@ -91,8 +91,8 @@
},
{
"path": "ptlinux/cli.py",
"sha256": "ae8f3740fa997d7e00d26bc5fec68f582e70a051cfb00cd88c8a2eaeb547b4b0",
"size": 23871
"sha256": "e142e0f33a7f1f8f88b8ad431d1b28b24dab3f74d367ed1912e8106ba6aab229",
"size": 23816
},
{
"path": "ptlinux/command.py",
@@ -131,8 +131,8 @@
},
{
"path": "ptlinux/diagnostics/native_logs.py",
"sha256": "3c07a45e89f69b2607288d7fd98f20e0679ee35599de03a3d4e3838ef93e91ba",
"size": 18769
"sha256": "1a7044630052311e7f74ddd716b82a60383d4404493970db35f6741019510ecb",
"size": 18780
},
{
"path": "ptlinux/doctor.py",
@@ -406,8 +406,8 @@
},
{
"path": "ptlinux/presentation/game_window.py",
"sha256": "217ec82c0d35edf9f3e4346096930460c8f4de6c9b97f98512ef19afcf1eb9ba",
"size": 71882
"sha256": "a605537bb8b3a052369510fdf7709ac5bfde917be651169470f79b59887ea58f",
"size": 80275
},
{
"path": "ptlinux/presentation/gamescope.py",
@@ -446,8 +446,8 @@
},
{
"path": "ptlinux/settings.py",
"sha256": "8d62b262bb43eff664b9f6a58bf68a88fec0a8e05b6b9e97cbb47509079c2115",
"size": 12059
"sha256": "7fd8ecdcbc76feb9bef1f9c840e0e5f73f159c1d370d04e2f3455917a7bc3cce",
"size": 12727
},
{
"path": "ptlinux/system_identity.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",
@@ -656,8 +656,8 @@
},
{
"path": "ptlinux/ui/dialogs/settings_dialog.py",
"sha256": "20a8e461799fa2104e5a09a6e4ec2e8c027caa0688726c57c9ed45e872c1971f",
"size": 25299
"sha256": "36ffa11821ec1ee3c95774071736082828c58c1f37874f00e66441d2ba0f76fa",
"size": 25294
},
{
"path": "ptlinux/ui/viewmodels/__init__.py",
@@ -706,21 +706,21 @@
},
{
"path": "release.json",
"sha256": "5dfcea2e0eb6ee4d79dba982d79e1a4d5efe1ce0579537d2a0cfab990153636a",
"sha256": "f95fab56f77e0dff21b6f0129625de84f0dff413671126921b37afe8f629e181",
"size": 218
}
],
"minimum_updater_version": "0.9.9",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.0",
"published_at": "2026-09-19T10:49:03Z",
"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.0\n\n- Improved GE-Proton/UMU compatibility, launch arguments, and renderer selection.\n- Isolated runtime prefixes and strengthened compatibility checks for game updates.\n- Fixed missing-Tk startup recovery and SteamOS dependency warnings.\n- Improved CDN retries, mirror failover, and interrupted download recovery.\n- Fixed Gamescope shutdown handling and optimized window resizing detection.\n- Expanded launch diagnostics and regression coverage.\n\n---\n\nVersion: `1.1.0` \nChannel: `stable` \nTag: `v1.1.0` \nSource commit: `be7891f06617674864b035645137441321db03b4`\n",
"release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.1.0",
"summary": "Improved GE-Proton/UMU compatibility, launch arguments, and renderer selection.",
"title": "PristonTale EU Launcher 1.1.0"
"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.0",
"version": "1.1.0"
"tag": "v1.1.2",
"version": "1.1.2"
}

Binary file not shown.