3 Commits

Author SHA1 Message Date
2a53a59cef Released a new version, 0.9.8. 2026-09-08 22:51:19 +02:00
76bef94757 Fixed more issues with Cinnamon window resizing and popups. 2026-09-08 22:46:53 +02:00
1a120bbf86 Fixed issues with telemetry. 2026-09-08 22:46:20 +02:00
15 changed files with 571 additions and 165 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux
Version 0.9.7 installs, updates, repairs, and launches PristonTale EU through a
Version 0.9.8 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-v0-9-7.tar.gz.sig \
pristontale-eu-linux-installer-v0-9-7.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-9-7.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-9-7.tar.gz
cd pristontale-eu-linux-installer-v0-9-7
-signature pristontale-eu-linux-installer-v0-9-8.tar.gz.sig \
pristontale-eu-linux-installer-v0-9-8.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-9-8.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-9-8.tar.gz
cd pristontale-eu-linux-installer-v0-9-8
./install.sh
```

View File

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

View File

@@ -9,7 +9,9 @@ from datetime import datetime
import json
from pathlib import Path
import re
import signal
import sys
import threading
import time
from typing import Callable, Sequence
@@ -1379,6 +1381,8 @@ def _play_locked(
)
game_started_ns = time.time_ns()
game_started_at = time.monotonic()
launch_success_lock = threading.Lock()
launch_success_reported = False
if telemetry is not None and not dry_run:
telemetry.send_event(
"game_launch_started",
@@ -1399,6 +1403,36 @@ def _play_locked(
if depot_file_catalog is not None
else None
)
def observe_game_output(line: str) -> str:
"""Timestamp launch success when the supervisor first confirms readiness."""
nonlocal launch_success_reported
live_readiness_signal = _game_readiness_signal(line)
if telemetry is None or dry_run or live_readiness_signal is None:
return line
with launch_success_lock:
if launch_success_reported:
return line
ready_at = time.monotonic()
launch_success_reported = telemetry.send_event(
"game_launch_success",
metadata={
"launch_duration_ms": round(
(ready_at - launch_requested_at) * 1000
),
"game_start_duration_ms": round(
(ready_at - game_started_at) * 1000
),
"game_window_ready": True,
"readiness_signal": live_readiness_signal,
"runtime": selection.kind,
"realm": start_option.name,
},
context={"operation": "launch_game", "stage": "ready"},
)
return line
try:
outcome = run_game(
paths,
@@ -1409,6 +1443,7 @@ def _play_locked(
use_gamebox=not no_box,
concurrent_prefix_launch=reuse_prepared,
check=False,
output_line_filter=observe_game_output,
pass_fds=game_lease_fds,
)
except KeyboardInterrupt:
@@ -1449,6 +1484,7 @@ def _play_locked(
game_started_ns,
)
runtime_issues = _runtime_issue_summary(outcome.stderr)
game_window_ready = runtime_issues["game_window_ready"] is True
if (
selection.kind == "proton7-container"
and runtime_issues["host_futex_fatal_count"]
@@ -1458,8 +1494,13 @@ def _play_locked(
runtime_issues["recommended_one_variable_comparison"] = "proton7-container"
elif runtime_issues["dbghelp_symdeferred_assertion_count"]:
runtime_issues["recommended_one_variable_comparison"] = "umu"
clean_exit = outcome.returncode == 0 and current_game_crash is None
launch_succeeded = (
clean_exit or game_window_ready or current_game_log is not None
)
report = {
"ok": outcome.returncode == 0 and current_game_crash is None,
"ok": clean_exit,
"launch_succeeded": launch_succeeded,
"dry_run": dry_run,
"runtime": asdict(selection),
"umu_bootstrap": asdict(umu_bootstrap) if umu_bootstrap else None,
@@ -1481,22 +1522,40 @@ def _play_locked(
"runtime_issues": runtime_issues,
}
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
clean_exit = outcome.returncode == 0 and current_game_crash is None
if telemetry is not None and not dry_run:
common_game_metadata = {
common_game_metadata: dict[str, object] = {
"runtime_seconds": round(game_runtime_seconds),
"exit_code": outcome.returncode,
"clean_exit": clean_exit,
"timed_out": outcome.timed_out,
"launch_succeeded": launch_succeeded,
"game_window_ready": game_window_ready,
"crash_dump": current_game_crash is not None,
"session_duration_class": _session_duration_class(game_runtime_seconds),
"runtime": selection.kind,
"realm": start_option.name,
**_game_exit_metadata(outcome.returncode, selection.kind),
}
if clean_exit:
telemetry.send_event(
"game_launch_success",
metadata=common_game_metadata,
context={"operation": "launch_game", "stage": "completed"},
)
if launch_succeeded:
if not launch_success_reported:
readiness_signal = _game_readiness_signal(outcome.stderr)
if readiness_signal is None:
readiness_signal = (
"game_log"
if current_game_log is not None
else "clean_process_exit"
)
telemetry.send_event(
"game_launch_success",
metadata={
"detection_duration_ms": round(game_runtime_seconds * 1000),
"detected_after_exit": True,
"game_window_ready": game_window_ready,
"readiness_signal": readiness_signal,
"runtime": selection.kind,
"realm": start_option.name,
},
context={"operation": "launch_game", "stage": "post_exit"},
)
else:
telemetry.send_event(
"game_launch_failed",
@@ -1504,17 +1563,57 @@ def _play_locked(
message=(
"Game client wrote a crash dump"
if current_game_crash is not None
else "Game session timed out"
if outcome.timed_out
else f"Game client exited with status {outcome.returncode}"
),
metadata={**common_game_metadata, "runtime_issues": runtime_issues},
context={"operation": "launch_game", "stage": "running"},
metadata={
**common_game_metadata,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
},
context={"operation": "launch_game", "stage": "game_start"},
)
if launch_succeeded:
exit_message = None
if current_game_crash is not None:
exit_message = "Game client wrote a crash dump"
elif outcome.timed_out:
exit_message = "Game session timed out"
elif common_game_metadata.get("signal_inferred") is True:
exit_message = (
"Game session ended with status "
f"{common_game_metadata['raw_exit_code']}, consistent with "
f"{common_game_metadata['signal_name']} wrapped by UMU"
)
elif "signal" in common_game_metadata:
exit_message = (
"Game session was terminated by "
f"{common_game_metadata['signal_name']}"
)
elif outcome.returncode not in {0, None}:
exit_message = f"Game session ended with status {outcome.returncode}"
telemetry.send_event(
"game_exited",
severity=(
"error"
if current_game_crash is not None
else "info" if clean_exit else "warning"
),
message=exit_message,
metadata=(
common_game_metadata
if clean_exit
else {
**common_game_metadata,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
}
),
context={"operation": "launch_game", "stage": "exited"},
)
telemetry.send_event(
"game_exited",
severity="info" if clean_exit else "warning",
metadata=common_game_metadata,
context={"operation": "launch_game", "stage": "exited"},
)
if as_json or dry_run:
_emit(report, as_json)
else:
@@ -1535,6 +1634,49 @@ def _session_duration_class(duration_seconds: float) -> str:
return "normal"
def _game_exit_metadata(
returncode: int | None,
runtime_kind: str,
) -> dict[str, object]:
metadata: dict[str, object] = {"exit_code": returncode}
if returncode is None:
return metadata
if returncode < 0:
signal_number = -returncode
metadata.update(
{
"exit_code": 128 + min(signal_number, 127),
"raw_exit_code": returncode,
"signal": signal_number,
"signal_name": _signal_name(signal_number),
}
)
return metadata
# UMU returns Popen's negative child status from a Python main function.
# SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it.
wrapped_sigkill = 256 - int(signal.SIGKILL)
if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill:
metadata.update(
{
"exit_code": 128 + int(signal.SIGKILL),
"raw_exit_code": returncode,
"signal": int(signal.SIGKILL),
"signal_name": signal.SIGKILL.name,
"signal_inferred": True,
"signal_inference": "umu_python_system_exit",
}
)
return metadata
def _signal_name(signal_number: int) -> str:
try:
return signal.Signals(signal_number).name
except ValueError:
return f"signal {signal_number}"
def _ensure_portable_umu_for_play(
paths: AppPaths,
settings: Settings,
@@ -1684,6 +1826,7 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]:
}
)
return {
"game_window_ready": _game_window_was_ready(folded),
"host_futex_fatal_count": folded.count(futex_message),
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
"gstreamer_missing_library_count": len(missing_libraries),
@@ -1696,6 +1839,25 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]:
}
def _game_window_was_ready(stderr: str) -> bool:
return _game_readiness_signal(stderr) is not None
def _game_readiness_signal(output: str) -> str | None:
folded = output.casefold()
for signal_name, marker in (
("window_ready", "game window ready:"),
("rendered_frame", "rendered game frame:"),
(
"window_supervision",
"game window resizing is disabled to prevent repeated direct3d device resets",
),
):
if marker in folded:
return signal_name
return None
def _print_game_outcome(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,

View File

@@ -110,6 +110,7 @@ class CommandRunner:
output_line_filter: OutputLineFilter | None = None,
pass_fds: Sequence[int] = (),
telemetry_stage: str | None = None,
report_failure_telemetry: bool = True,
) -> CommandOutcome:
arguments = [str(value) for value in argv]
if not arguments:
@@ -179,7 +180,9 @@ class CommandRunner:
dry_run=False,
)
self._record(outcome, cwd, overrides)
if outcome.timed_out or outcome.returncode != 0:
if report_failure_telemetry and (
outcome.timed_out or outcome.returncode != 0
):
telemetry = current_telemetry()
if telemetry is not None:
telemetry.report_subprocess_failure(

View File

@@ -5,11 +5,13 @@ from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import os
from pathlib import Path
import select
import subprocess
import sys
import time
from collections.abc import Collection, Iterable, Sequence
from collections.abc import Collection, Iterable, Mapping, Sequence
from .desktop_identity import (
APPLICATION_NAME,
@@ -44,12 +46,15 @@ _X_PROPERTY_CHANGE_MASK = 1 << 22
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
_X_WM_STATE_WITHDRAWN = 0
_X_WM_STATE_NORMAL = 1
_X_WM_STATE_ICONIC = 3
_NET_WM_STATE_REMOVE = 0
_NET_WM_STATE_SOURCE_APPLICATION = 1
_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2
WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0
GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS = 0.25
GAMESCOPE_HOST_FOCUS_STATE_ENV = "PTLINUX_GAMESCOPE_HOST_FOCUS_STATE"
GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS = 0.6
_P_MIN_SIZE = 1 << 4
_P_MAX_SIZE = 1 << 5
_P_RESIZE_INC = 1 << 6
@@ -64,6 +69,14 @@ _MOTIF_DECOR_MAXIMIZE = 1 << 6
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
def _process_exit_status(returncode: int) -> int:
"""Convert Popen's negative signal code before passing it through SystemExit."""
if returncode < 0:
return 128 + min(-returncode, 127)
return min(returncode, 255)
def _ignore_x11_error(_display: object, _event: object) -> int:
"""Keep a stale X11 window race from terminating the mapper process."""
return 0
@@ -555,21 +568,14 @@ class _X11:
and updated_normal_hints != normal_hints
)
motif_changed = motif_hints is not None and updated_motif_hints != motif_hints
allowed_actions = self._long_property(window, b"_NET_WM_ALLOWED_ACTIONS", 64)
allowed_actions_with_scaling = self._allowed_actions_with_scaling(
allowed_actions,
)
allowed_actions_changed = (
allowed_actions is not None
and allowed_actions_with_scaling is not None
and allowed_actions_with_scaling != allowed_actions
)
if (
not normal_changed
and not motif_changed
and not allowed_actions_changed
):
return not confirm or self.window_allows_scaling(window)
if not normal_changed and not motif_changed:
if not confirm:
return True
# ``_NET_WM_ALLOWED_ACTIONS`` belongs to the window manager. Wait for
# its answer even when the client hints were already resizable; Muffin
# can publish the property shortly after the SDL window is discovered.
self.library.XSync(self.display, 0)
return self._wait_for_scaling_actions(window)
confirmed = False
try:
@@ -587,13 +593,6 @@ class _X11:
b"_MOTIF_WM_HINTS",
updated_motif_hints,
)
if allowed_actions_changed and allowed_actions_with_scaling is not None:
self._replace_long_property(
window,
b"_NET_WM_ALLOWED_ACTIONS",
b"_ATOM",
allowed_actions_with_scaling,
)
if not confirm:
self.library.XFlush(self.display)
confirmed = True
@@ -624,16 +623,6 @@ class _X11:
)
except Exception as exc:
restore_errors.append(exc)
if allowed_actions_changed and allowed_actions is not None:
try:
self._replace_long_property(
window,
b"_NET_WM_ALLOWED_ACTIONS",
b"_ATOM",
allowed_actions,
)
except Exception as exc:
restore_errors.append(exc)
try:
self.library.XSync(self.display, 0)
except Exception as exc:
@@ -665,36 +654,6 @@ class _X11:
vertical,
}.issubset(actions)
def _allowed_actions_with_scaling(
self,
actions: tuple[int, ...] | None,
) -> tuple[int, ...] | None:
if actions is None:
return None
resize = self._intern_atom(
b"_NET_WM_ACTION_RESIZE",
only_if_exists=True,
)
horizontal = self._intern_atom(
b"_NET_WM_ACTION_MAXIMIZE_HORZ",
only_if_exists=True,
)
vertical = self._intern_atom(
b"_NET_WM_ACTION_MAXIMIZE_VERT",
only_if_exists=True,
)
if resize == 0 or horizontal == 0 or vertical == 0:
return actions
updated_actions = list(actions)
known_actions = set(actions)
for atom in (resize, horizontal, vertical):
if atom not in known_actions:
updated_actions.append(atom)
known_actions.add(atom)
return tuple(updated_actions)
def _wait_for_scaling_actions(self, window: int) -> bool:
deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS
while not self.window_allows_scaling(window):
@@ -1004,6 +963,14 @@ class _X11:
active = self._long_property(root, b"_NET_ACTIVE_WINDOW", 1)
return bool(active and active[0] == window)
def minimize_window(self, window: int) -> bool:
"""Ask the host window manager to keep a remapped client minimized."""
screen = self.library.XDefaultScreen(self.display)
accepted = self.library.XIconifyWindow(self.display, window, screen) != 0
self.library.XSync(self.display, 0)
return accepted
def activate_window(self, window: int) -> bool:
"""Ask the window manager to restore and activate one client."""
@@ -1174,6 +1141,14 @@ class _X11:
self.library.XOpenDisplay.restype = ctypes.c_void_p
self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p]
self.library.XDefaultRootWindow.restype = ctypes.c_ulong
self.library.XDefaultScreen.argtypes = [ctypes.c_void_p]
self.library.XDefaultScreen.restype = ctypes.c_int
self.library.XIconifyWindow.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_int,
]
self.library.XIconifyWindow.restype = ctypes.c_int
self.library.XQueryTree.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
@@ -1457,6 +1432,8 @@ def _repair_game_window_focus(mapper: _X11, window: int) -> bool:
def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool:
"""Restore nested focus without asking the desktop to activate Gamescope."""
if not _gamescope_host_focus_is_allowed():
return False
try:
if mapper.window_state(window) == _X_WM_STATE_ICONIC:
return False
@@ -1477,6 +1454,31 @@ def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool:
return False
def _gamescope_host_focus_is_allowed(
*,
environment: Mapping[str, str] | None = None,
now: float | None = None,
) -> bool:
"""Honor the outer X11 window's current focus without trusting stale state."""
env = os.environ if environment is None else environment
state_path = env.get(GAMESCOPE_HOST_FOCUS_STATE_ENV, "").strip()
if not state_path:
# Direct invocations and native Wayland Gamescope have no X11 host-state
# bridge. Retain their existing nested focus behavior.
return True
try:
fields = Path(state_path).read_text(encoding="ascii").split()
if len(fields) != 2 or fields[0] != "1":
return False
published_at = float(fields[1])
except (OSError, UnicodeError, ValueError):
return False
observed_at = time.monotonic() if now is None else now
age = observed_at - published_at
return 0.0 <= age <= GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS
def _resolve_gamescope_game_window(
mapper: _X11,
window: int,
@@ -1851,7 +1853,8 @@ def run_game_window(
file=sys.stderr,
flush=True,
)
_focus_game_window(mapper, game_window)
if _gamescope_host_focus_is_allowed():
_focus_game_window(mapper, game_window)
print(
"Game presentation is isolated; Gamescope handles "
"aspect-preserving outer-window resizing.",
@@ -1944,13 +1947,15 @@ def main(argv: Sequence[str] | None = None) -> int:
if command and command[0] == "--":
command = command[1:]
try:
return run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
allow_window_resizing=arguments.allow_window_resizing,
gamescope_scaling=arguments.gamescope_scaling,
suppress_wm_ping=arguments.suppress_wm_ping,
return _process_exit_status(
run_game_window(
command,
arguments.window_title,
borderless=arguments.borderless,
allow_window_resizing=arguments.allow_window_resizing,
gamescope_scaling=arguments.gamescope_scaling,
suppress_wm_ping=arguments.suppress_wm_ping,
)
)
except (GameboxRuntimeError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)

View File

@@ -14,7 +14,13 @@ import tempfile
import time
from collections.abc import Mapping, Sequence
from .gamebox_runtime import GameboxRuntimeError, _X11
from .gamebox_runtime import (
GAMESCOPE_HOST_FOCUS_STATE_ENV,
GameboxRuntimeError,
_X11,
_X_WM_STATE_ICONIC,
_X_WM_STATE_NORMAL,
)
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0
WINDOW_DISCOVERY_POLL_SECONDS = 0.05
@@ -22,6 +28,8 @@ RESIZE_STEPS_PER_GAME_CANVAS = 20
MIN_RESIZE_INCREMENT = 8
MAX_RESIZE_INCREMENT = 96
CHILD_STATUS_POLL_SECONDS = 0.05
HOST_FOCUS_HEARTBEAT_SECONDS = 0.2
HOST_REICONIFY_INTERVAL_SECONDS = 0.2
GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0
WAYLAND_BACKING_FIX_VERSION = (3, 16, 28)
BUNDLED_GAMESCOPE_VERSION = (3, 16, 20)
@@ -43,6 +51,101 @@ class GamescopeRuntimeError(RuntimeError):
"""Raised when the Gamescope process cannot be started."""
class _HostFocusPublisher:
"""Publish the host WM's focus decision to the nested game supervisor."""
def __init__(self, file_descriptor: int, path: Path) -> None:
self.file_descriptor = file_descriptor
self.path = path
self._last_active: bool | None = None
self._last_published_at = 0.0
self._last_reiconify_at = 0.0
self._seen_normal_state = False
self._minimized_latched = False
self._publish(False, force=True)
@classmethod
def create(cls) -> _HostFocusPublisher:
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
try:
file_descriptor, path = tempfile.mkstemp(
prefix="ptlinux-gamescope-focus-",
dir=runtime_directory,
)
except OSError:
file_descriptor, path = tempfile.mkstemp(
prefix="ptlinux-gamescope-focus-",
)
try:
return cls(file_descriptor, Path(path))
except BaseException:
os.close(file_descriptor)
Path(path).unlink(missing_ok=True)
raise
def update(self, mapper: _X11, window: int) -> None:
now = time.monotonic()
try:
state = mapper.window_state(window)
active = state == _X_WM_STATE_NORMAL and mapper.window_is_active(window)
except Exception:
state = None
active = False
if state == _X_WM_STATE_ICONIC and self._seen_normal_state:
self._minimized_latched = True
elif state == _X_WM_STATE_NORMAL:
self._seen_normal_state = True
if self._minimized_latched:
if active:
# A normal, active window was explicitly restored by the
# desktop. Resume nested focus repair for it.
self._minimized_latched = False
elif now - self._last_reiconify_at >= HOST_REICONIFY_INTERVAL_SECONDS:
# Gamescope's SDL visibility handoff uses XMapRaised. If it
# remaps an inactive window after a user minimize, preserve
# the user's state instead of letting it cover other apps.
try:
mapper.minimize_window(window)
except Exception:
pass
self._last_reiconify_at = now
active = False
self._publish(active, now=now)
def _publish(
self,
active: bool,
*,
force: bool = False,
now: float | None = None,
) -> None:
published_at = time.monotonic() if now is None else now
if (
not force
and active == self._last_active
and published_at - self._last_published_at < HOST_FOCUS_HEARTBEAT_SECONDS
):
return
payload = f"{int(active)} {published_at:.9f}\n".encode("ascii")
try:
os.lseek(self.file_descriptor, 0, os.SEEK_SET)
# Truncating first makes an interrupted update fail closed in the
# nested reader instead of leaving a stale active value behind.
os.ftruncate(self.file_descriptor, 0)
os.write(self.file_descriptor, payload)
except OSError:
self._last_active = None
return
self._last_active = active
self._last_published_at = published_at
def close(self) -> None:
try:
os.close(self.file_descriptor)
finally:
self.path.unlink(missing_ok=True)
def resize_increments(width: int, height: int) -> tuple[int, int]:
"""Return coarse-enough size steps to avoid a swapchain rebuild storm."""
@@ -247,12 +350,26 @@ def _process_exit_status(returncode: int) -> int:
def _wait_for_optimized_gamescope(
process: subprocess.Popen[bytes],
status_file: Path,
status_file: Path | None,
*,
host_focus_publisher: _HostFocusPublisher | None = None,
host_mapper: _X11 | None = None,
host_window: int | None = None,
) -> int:
"""Return the real child status and bypass old Gamescope shutdown bugs."""
"""Monitor host focus and preserve the nested child's real exit status."""
while True:
child_status = _read_child_status(status_file)
if host_focus_publisher is not None and host_mapper is not None:
if host_window is None:
try:
host_window = host_mapper.find_window_by_pid(process.pid)
except Exception:
host_window = None
if host_window is not None:
host_focus_publisher.update(host_mapper, host_window)
child_status = (
_read_child_status(status_file) if status_file is not None else None
)
if child_status is not None:
if process.poll() is None:
process.kill()
@@ -260,7 +377,9 @@ def _wait_for_optimized_gamescope(
return _process_exit_status(child_status)
gamescope_status = process.poll()
if gamescope_status is not None:
child_status = _read_child_status(status_file)
child_status = (
_read_child_status(status_file) if status_file is not None else None
)
return _process_exit_status(
gamescope_status if child_status is None else child_status
)
@@ -283,6 +402,8 @@ def _tune_process_window(
process: subprocess.Popen[bytes],
width: int,
height: int,
*,
host_focus_publisher: _HostFocusPublisher | None = None,
) -> tuple[int, int] | None:
"""Stabilize resizing once the SDL host window becomes available."""
@@ -290,15 +411,19 @@ def _tune_process_window(
while process.poll() is None and time.monotonic() < deadline:
window = mapper.find_window_by_pid(process.pid)
if window is not None:
if host_focus_publisher is not None:
host_focus_publisher.update(mapper, window)
increments = resize_increments(width, height)
# Some X11 window managers keep the maximize decoration hidden until
# both the size constraints and allowed actions advertise resizing.
scaling_enabled = mapper.enable_window_scaling(window)
increments_applied = mapper.set_resize_increments(
window,
(width, height),
increments,
)
# Apply every client-owned hint first, then wait for the window
# manager to publish resize and maximize actions from those hints.
scaling_enabled = mapper.enable_window_scaling(window)
if host_focus_publisher is not None:
host_focus_publisher.update(mapper, window)
compositing_preferred = mapper.prefer_composited_presentation(window)
if scaling_enabled and increments_applied and compositing_preferred:
return increments
@@ -325,6 +450,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
) = _select_wayland_gamescope(selected_command[0])
mapper = _open_host_x11() if backend == "sdl" else None
status_file: Path | None = None
host_focus_publisher: _HostFocusPublisher | None = None
try:
candidate_status_file = _create_child_status_file()
try:
@@ -361,6 +487,23 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
# SDL otherwise requests compositor bypass, which can switch near
# screen-filling sizes and visibly flicker while Gamescope is scaling.
environment["SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"] = "0"
# If host X11 monitoring cannot start, make the nested focus repair
# fail closed. An absent bridge retains legacy behavior for direct
# gamebox invocations and native Wayland Gamescope only.
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = "unavailable"
if mapper is not None:
try:
publisher = _HostFocusPublisher.create()
except OSError as exc:
print(
f"Warning: Gamescope host-focus monitoring is unavailable: "
f"{exc}; nested focus repair will remain disabled.",
file=sys.stderr,
flush=True,
)
else:
host_focus_publisher = publisher
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = str(publisher.path)
if using_bundled_gamescope:
# The game asks its X11 WM to iconify the first client while entering
# the login screen. Our scoped Gamescope patch keeps that nested surface
@@ -371,6 +514,8 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
except OSError as exc:
if mapper is not None:
mapper.close()
if host_focus_publisher is not None:
host_focus_publisher.close()
if status_file is not None:
status_file.unlink(missing_ok=True)
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
@@ -396,23 +541,21 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
)
elif mapper is not None:
try:
try:
increments = _tune_process_window(
mapper,
process,
width,
height,
)
except Exception as exc:
increments = None
print(
f"Warning: Gamescope resize tuning failed: {exc}; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
finally:
mapper.close()
increments = _tune_process_window(
mapper,
process,
width,
height,
host_focus_publisher=host_focus_publisher,
)
except Exception as exc:
increments = None
print(
f"Warning: Gamescope resize tuning failed: {exc}; "
"continuing normally.",
file=sys.stderr,
flush=True,
)
if increments is not None:
print(
"Gamescope resize optimization active: the host window remains "
@@ -436,9 +579,12 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
flush=True,
)
try:
if status_file is not None:
return _wait_for_optimized_gamescope(process, status_file)
return _process_exit_status(int(process.wait()))
return _wait_for_optimized_gamescope(
process,
status_file,
host_focus_publisher=host_focus_publisher,
host_mapper=mapper,
)
except BaseException:
if process.poll() is None:
process.terminate()
@@ -449,6 +595,10 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
process.wait()
raise
finally:
if mapper is not None:
mapper.close()
if host_focus_publisher is not None:
host_focus_publisher.close()
if status_file is not None:
status_file.unlink(missing_ok=True)

View File

@@ -509,6 +509,9 @@ def run_game(
output_line_filter=output_line_filter,
pass_fds=pass_fds,
telemetry_stage="game_start",
# Play distinguishes a failed launch from a nonzero status returned after
# a visible game session. Its game-specific event retains that context.
report_failure_telemetry=False,
)

View File

@@ -34,6 +34,8 @@ DEFAULT_TELEMETRY_ENDPOINT = "https://pristontale.eu/api/launcher/telemetry"
TELEMETRY_ENDPOINT_VARIABLE = "PTLINUX_TELEMETRY_ENDPOINT"
TELEMETRY_KEY_VARIABLE = "PTLINUX_TELEMETRY_KEY"
TELEMETRY_DEBUG_VARIABLE = "PTLINUX_TELEMETRY_DEBUG"
TELEMETRY_PROCESS_ROLE_VARIABLE = "PTLINUX_TELEMETRY_PROCESS_ROLE"
TELEMETRY_OPERATION_PROCESS_ROLE = "operation"
MAX_REQUEST_BYTES = 64 * 1024
MAX_MESSAGE_BYTES = 10 * 1024
@@ -263,9 +265,14 @@ class TelemetryService:
self._session_metadata = dict(session_metadata or {})
self._monotonic = monotonic
self._started_at = monotonic()
self._reports_launcher_lifecycle = (
self._environment.get(TELEMETRY_PROCESS_ROLE_VARIABLE, "").strip().casefold()
!= TELEMETRY_OPERATION_PROCESS_ROLE
)
self._enabled = bool(enabled)
self._active = False
self._accepting = False
self._closing = False
self._ready = False
self._first_run = False
self._installation_id: str | None = None
@@ -322,6 +329,7 @@ class TelemetryService:
self._base_payload = self._collect_common_payload(first_seen)
self._stop.clear()
self._accepting = True
self._closing = False
self._active = True
worker = threading.Thread(
target=self._worker_loop,
@@ -338,15 +346,16 @@ class TelemetryService:
self._debug_log(f"initialization failed: {type(exc).__name__}")
return
self.send_event(
"launcher_started",
metadata={"cold_start": True, **self._session_metadata},
)
if self._first_run:
if self._reports_launcher_lifecycle:
self.send_event(
"first_run_started",
metadata={"install_type": self._install_type()},
"launcher_started",
metadata={"cold_start": True, **self._session_metadata},
)
if self._first_run:
self.send_event(
"first_run_started",
metadata={"install_type": self._install_type()},
)
self._start_environment_collection()
def set_enabled(self, enabled: bool) -> None:
@@ -368,6 +377,8 @@ class TelemetryService:
if self._ready or not self._active:
return
self._ready = True
if not self._reports_launcher_lifecycle:
return
duration_ms = round((self._monotonic() - self._started_at) * 1000)
self.send_event(
"launcher_ready",
@@ -674,11 +685,12 @@ class TelemetryService:
self._send_repeated_error(state)
def shutdown(self, timeout_seconds: float = SHUTDOWN_WAIT_SECONDS) -> None:
"""Queue the closing event and give the daemon worker a bounded flush."""
"""Finish this process role and give the daemon worker a bounded flush."""
with self._state_lock:
if not self._active:
if not self._active or self._closing:
return
self._closing = True
deadline = time.monotonic() + max(0.0, timeout_seconds)
environment_worker = self._environment_worker
if isinstance(environment_worker, threading.Thread) and (
@@ -692,17 +704,18 @@ class TelemetryService:
)
self._flush_repeated_errors()
runtime_seconds = max(0, round(self._monotonic() - self._started_at))
if self._first_run and not self._ready:
if self._reports_launcher_lifecycle:
if self._first_run and not self._ready:
self.send_event(
"first_run_failed",
severity="error",
message="Launcher did not reach the ready state",
metadata={"duration_ms": runtime_seconds * 1000},
)
self.send_event(
"first_run_failed",
severity="error",
message="Launcher did not reach the ready state",
metadata={"duration_ms": runtime_seconds * 1000},
"launcher_closed",
metadata={"runtime_seconds": runtime_seconds, **self._session_metadata},
)
self.send_event(
"launcher_closed",
metadata={"runtime_seconds": runtime_seconds, **self._session_metadata},
)
with self._state_lock:
self._accepting = False
self._stop.set()
@@ -719,6 +732,7 @@ class TelemetryService:
with self._state_lock:
self._enabled = False
self._accepting = False
self._closing = True
self._stop.set()
self._repeated_errors.clear()
while True:
@@ -795,6 +809,11 @@ class TelemetryService:
self._base_payload[key] = {**existing, **value}
else:
self._base_payload[key] = value
if not self._reports_launcher_lifecycle:
# Dashboard operation subprocesses enrich their own events with the
# detected environment, while the dashboard remains the sole source
# of launcher lifecycle and automatic compatibility events.
return
runtime = sanitized.get("runtime")
dependencies = sanitized.get("dependencies")
@@ -816,14 +835,38 @@ class TelemetryService:
context={"operation": "launcher", "stage": "environment_detection"},
)
missing_required = dependency_details.get("missing_required")
if dependency_details.get("healthy") is False:
generic_dependency_failures = (
[
str(value)
for value in missing_required
if isinstance(value, str) and value
]
if isinstance(missing_required, list)
else []
)
if (
dependency_details.get("container_ready") is False
and "container_host" not in generic_dependency_failures
):
generic_dependency_failures.append("container_host")
if (
dependency_details.get("healthy") is False
and generic_dependency_failures
):
self.send_event(
"dependency_check_failed",
severity="warning",
message="One or more launcher dependencies are unavailable",
metadata={
"dependencies": (
missing_required if isinstance(missing_required, list) else []
"dependencies": generic_dependency_failures,
"required_shared_libraries_present": dependency_details.get(
"required_shared_libraries_present"
),
"vulkan_64bit_available": dependency_details.get(
"vulkan_64bit_available"
),
"vulkan_32bit_available": dependency_details.get(
"vulkan_32bit_available"
),
},
context={"operation": "launcher", "stage": "dependency_check"},
@@ -846,7 +889,17 @@ class TelemetryService:
self.send_event(
"vulkan_32bit_check_failed",
severity="warning",
metadata={"dependency": "vulkan_32bit", "check": "32bit_vulkan"},
metadata={
"dependency": "vulkan_32bit",
"check": "32bit_vulkan",
"loader_available": runtime_details.get(
"vulkan_32bit_loader_available"
),
"icd_available": runtime_details.get(
"vulkan_32bit_icd_available"
),
"detection": runtime_details.get("vulkan_32bit_detection"),
},
context={"operation": "launcher", "stage": "vulkan_check"},
)

View File

@@ -29,8 +29,11 @@ _VULKAN_MANIFEST_DIRECTORIES = (
)
_LIBRARY_ROOTS = (
Path("/lib"),
Path("/lib32"),
Path("/usr/lib"),
Path("/usr/lib32"),
Path("/usr/local/lib"),
Path("/usr/local/lib32"),
)
_PCI_DATABASES = (
Path("/usr/share/hwdata/pci.ids"),
@@ -198,13 +201,12 @@ def _vulkan_info(
if api_version and _VERSION_PATTERN.fullmatch(api_version):
api_versions.append(api_version)
library = _short_string(icd.get("library_path"))
architecture = _manifest_library_architecture(
architectures = _manifest_library_architectures(
manifest,
library,
library_roots,
)
if architecture is not None:
manifest_architectures.add(architecture)
manifest_architectures.update(architectures)
vulkaninfo = shutil.which("vulkaninfo", path=environment.get("PATH", ""))
probe = (
@@ -240,6 +242,11 @@ def _vulkan_info(
"vulkan_available": available_64bit,
"vulkan_64bit": available_64bit,
"vulkan_32bit": available_32bit,
"vulkan_64bit_loader_available": 64 in loader_architectures,
"vulkan_64bit_icd_available": 64 in manifest_architectures,
"vulkan_64bit_probe_succeeded": functional_64bit,
"vulkan_32bit_loader_available": 32 in loader_architectures,
"vulkan_32bit_icd_available": 32 in manifest_architectures,
"vulkan_64bit_detection": (
"vulkaninfo" if functional_64bit else "loader_and_icd"
),
@@ -860,17 +867,18 @@ def _vulkan_loader_architectures(library_roots: Sequence[Path]) -> set[int]:
return architectures
def _manifest_library_architecture(
def _manifest_library_architectures(
manifest: Path,
library: str | None,
library_roots: Sequence[Path],
) -> int | None:
) -> set[int]:
architectures: set[int] = set()
if library:
path = Path(library)
if path.is_absolute():
architecture = _elf_architecture(path)
if architecture is not None:
return architecture
architectures.add(architecture)
else:
candidates = [manifest.parent / path]
for root in library_roots:
@@ -878,13 +886,15 @@ def _manifest_library_architecture(
for candidate in candidates:
architecture = _elf_architecture(candidate)
if architecture is not None:
return architecture
architectures.add(architecture)
if architectures:
return architectures
lowered = manifest.name.casefold()
if any(token in lowered for token in ("i386", "i486", "i586", "i686", "x86_32")):
return 32
return {32}
if any(token in lowered for token in ("x86_64", "amd64")):
return 64
return None
return {64}
return set()
def _library_candidates(root: Path, prefix: str) -> tuple[Path, ...]:

View File

@@ -36,6 +36,7 @@ from .viewmodels.dashboard import (
game_client_activity_text,
interrupt_game_supervisor,
launcher_child_command,
launcher_child_environment,
launcher_restart_spec,
launcher_update_presentation,
news_card_columns,
@@ -131,6 +132,7 @@ __all__ = [
"interrupt_game_supervisor",
"launch_external_url",
"launcher_child_command",
"launcher_child_environment",
"launcher_restart_spec",
"launcher_update_presentation",
"news_card_columns",

View File

@@ -123,6 +123,7 @@ from .viewmodels.dashboard import (
game_client_activity_text,
interrupt_game_supervisor,
launcher_child_command,
launcher_child_environment,
launcher_restart_spec,
launcher_update_presentation,
news_card_columns,
@@ -1064,7 +1065,8 @@ def run_dashboard(paths: AppPaths) -> None:
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
)
env=launcher_child_environment(),
)
except OSError as process_error:
publish_output(f"Could not start operation: {process_error}\n")
if game_client_id is None:

View File

@@ -14,6 +14,10 @@ from typing import TYPE_CHECKING, Callable, Mapping
from ...paths import AppPaths
from ...self_update import LauncherUpdateCheck
from ...telemetry import (
TELEMETRY_OPERATION_PROCESS_ROLE,
TELEMETRY_PROCESS_ROLE_VARIABLE,
)
if TYPE_CHECKING:
@@ -465,6 +469,18 @@ def launcher_child_command(*arguments: str) -> list[str]:
return [sys.executable, "-B", "-m", "ptlinux", *arguments]
def launcher_child_environment(
environment: Mapping[str, str] | None = None,
) -> dict[str, str]:
"""Mark an internal CLI process as part of the current dashboard session."""
child_environment = dict(os.environ if environment is None else environment)
child_environment[TELEMETRY_PROCESS_ROLE_VARIABLE] = (
TELEMETRY_OPERATION_PROCESS_ROLE
)
return child_environment
def dashboard_poll_interval(
*,
operation_active: bool,

View File

@@ -1,6 +1,6 @@
[project]
name = "PTEU-Linux-Launcher"
version = "0.9.7"
version = "0.9.8"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11"
dependencies = []

View File

@@ -1,11 +1,11 @@
{
"archive_sha256": "d74025d04cee7dd5acbd331e58f794913604d351ec0ccb849059ffd774f9f03a",
"archive_size": 1440607,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.9.7/pristontale-eu-linux-update-v0.9.7.tar.gz",
"archive_sha256": "4be27ca9320f4996d517b6259ac17c8bb668e918bfba7c844fed908e2ab67381",
"archive_size": 1444186,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.9.8/pristontale-eu-linux-update-v0.9.8.tar.gz",
"channel": "stable",
"minimum_updater_version": "0.5.0",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.9.7",
"published_at": "2026-09-07T20:21:15Z",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.9.8",
"published_at": "2026-09-08T20:49:27Z",
"schema": 1,
"version": "0.9.7"
"version": "0.9.8"
}

Binary file not shown.