Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a53a59cef | |||
| 76bef94757 | |||
| 1a120bbf86 |
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.9.7"
|
||||
__version__ = "0.9.8"
|
||||
|
||||
198
ptlinux/cli.py
198
ptlinux/cli.py
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user