Release 1.1.3 with game window and Gamescope lifecycle fixes
This commit is contained in:
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "1.1.2"
|
||||
__version__ = "1.1.3"
|
||||
|
||||
@@ -31,6 +31,7 @@ from ...umu import UmuBootstrapResult
|
||||
from ...window_diagnostics import (
|
||||
WINDOW_DIAGNOSTICS_ENV,
|
||||
WINDOW_DIAGNOSTICS_SESSION_ENV,
|
||||
WindowDiagnosticLog,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +83,11 @@ def play_locked(
|
||||
and bool(getattr(telemetry, "enabled", True))
|
||||
)
|
||||
launch_session_id = uuid4().hex if window_diagnostics_active else None
|
||||
window_diagnostic_log = (
|
||||
WindowDiagnosticLog(paths.logs / f"windows-{launch_session_id}.jsonl", launch_session_id)
|
||||
if launch_session_id is not None
|
||||
else None
|
||||
)
|
||||
launch_session_metadata = (
|
||||
{"launch_session_id": launch_session_id}
|
||||
if launch_session_id is not None
|
||||
@@ -218,6 +224,7 @@ def play_locked(
|
||||
# CommandRunner keeps a bounded output tail. Preserve readiness here so a
|
||||
# long, noisy session cannot evict the evidence and become a false failure.
|
||||
observed_readiness_signal: str | None = None
|
||||
observed_gamescope_exit: dict[str, object] = {}
|
||||
launch_success_reported = False
|
||||
reported_runtime_warnings: set[str] = set()
|
||||
if telemetry is not None and not dry_run:
|
||||
@@ -245,6 +252,11 @@ def play_locked(
|
||||
"""Timestamp launch success when the supervisor first confirms readiness."""
|
||||
|
||||
nonlocal observed_readiness_signal, launch_success_reported
|
||||
if "gamescope exited without a recorded game status" in line.casefold():
|
||||
# Retain supervisor evidence even if noisy child cleanup later evicts
|
||||
# this line from CommandRunner's bounded stderr capture.
|
||||
with launch_success_lock:
|
||||
observed_gamescope_exit.update(gamescope_exit_summary(line))
|
||||
presentation_failure = api._gamescope_presentation_failure(line)
|
||||
if telemetry is not None and not dry_run and presentation_failure is not None:
|
||||
with launch_success_lock:
|
||||
@@ -271,6 +283,9 @@ def play_locked(
|
||||
diagnostic_session_id = window_diagnostic.get("session_id")
|
||||
if launch_session_id is None or diagnostic_session_id != launch_session_id:
|
||||
return line
|
||||
if window_diagnostic_log is not None:
|
||||
with launch_success_lock:
|
||||
window_diagnostic_log.record(window_diagnostic)
|
||||
if telemetry is not None and window_diagnostics_active:
|
||||
with launch_success_lock:
|
||||
telemetry.send_event(
|
||||
@@ -386,6 +401,7 @@ def play_locked(
|
||||
game_started_ns,
|
||||
)
|
||||
runtime_issues = api._runtime_issue_summary(outcome.stderr)
|
||||
runtime_issues.update(observed_gamescope_exit)
|
||||
retained_readiness_signal = api._game_readiness_signal(outcome.stderr)
|
||||
game_readiness_signal = observed_readiness_signal or retained_readiness_signal
|
||||
game_window_ready = game_readiness_signal is not None
|
||||
@@ -400,7 +416,8 @@ def play_locked(
|
||||
)
|
||||
if recommended_comparison is not None:
|
||||
runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
|
||||
clean_exit = outcome.returncode == 0 and current_game_crash is None
|
||||
game_status_missing = runtime_issues.get("gamescope_child_status_missing") is True
|
||||
clean_exit = outcome.returncode == 0 and current_game_crash is None and not game_status_missing
|
||||
session_issue = game_log_issue_reason(current_game_log)
|
||||
launch_evidence = game_readiness_signal
|
||||
if launch_evidence is None and current_game_log is not None:
|
||||
@@ -432,6 +449,11 @@ def play_locked(
|
||||
"current_game_log": current_game_log,
|
||||
"current_game_crash": current_game_crash,
|
||||
"runtime_issues": runtime_issues,
|
||||
"window_diagnostics_log": (
|
||||
str(window_diagnostic_log.path)
|
||||
if window_diagnostic_log is not None and window_diagnostic_log.path.is_file()
|
||||
else None
|
||||
),
|
||||
}
|
||||
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
|
||||
if telemetry is not None and not dry_run:
|
||||
@@ -449,8 +471,16 @@ def play_locked(
|
||||
"session_duration_class": api._session_duration_class(game_runtime_seconds),
|
||||
"runtime": selection.kind,
|
||||
"realm": start_option.name,
|
||||
**api._game_exit_metadata(outcome.returncode, selection.kind),
|
||||
**api._game_exit_metadata(
|
||||
outcome.returncode, "gamescope" if game_status_missing else selection.kind,
|
||||
),
|
||||
}
|
||||
if game_status_missing:
|
||||
common_game_metadata.update({
|
||||
"game_exit_status_available": False,
|
||||
"exit_status_source": "gamescope_supervisor",
|
||||
"gamescope_exit_code": runtime_issues.get("gamescope_exit_code"),
|
||||
})
|
||||
if not clean_exit or session_issue is not None:
|
||||
common_game_metadata["failure_reason"] = game_failure_reason(
|
||||
outcome,
|
||||
@@ -509,6 +539,8 @@ def play_locked(
|
||||
if current_game_crash is not None
|
||||
else "Game session timed out"
|
||||
if outcome.timed_out
|
||||
else gamescope_exit_message(runtime_issues)
|
||||
if game_status_missing
|
||||
else f"Game client exited with status {outcome.returncode}"
|
||||
),
|
||||
metadata={
|
||||
@@ -531,6 +563,8 @@ def play_locked(
|
||||
exit_message = (
|
||||
"Game client exited after logging Direct3D resource exhaustion"
|
||||
)
|
||||
elif game_status_missing:
|
||||
exit_message = gamescope_exit_message(runtime_issues)
|
||||
elif common_game_metadata.get("signal_inferred") is True:
|
||||
signal_wrapper = (
|
||||
"Proton" if selection.kind == "proton7" else "UMU"
|
||||
@@ -580,7 +614,9 @@ def play_locked(
|
||||
runtime_issues,
|
||||
current_game_crash,
|
||||
)
|
||||
return 0 if outcome.returncode == 0 and current_game_crash is None else 1
|
||||
if not clean_exit and report["window_diagnostics_log"] is not None:
|
||||
print(f"Window diagnostics: {report['window_diagnostics_log']}", file=sys.stderr)
|
||||
return 0 if clean_exit else 1
|
||||
|
||||
|
||||
def session_duration_class(duration_seconds: float) -> str:
|
||||
@@ -686,6 +722,11 @@ def game_failure_reason(
|
||||
return "client_crash_dump"
|
||||
if outcome.timed_out:
|
||||
return "session_timeout"
|
||||
if (
|
||||
exit_metadata.get("game_exit_status_available") is False
|
||||
or "gamescope exited without a recorded game status" in outcome.stderr.casefold()
|
||||
):
|
||||
return "gamescope_game_status_missing"
|
||||
if "signal" in exit_metadata:
|
||||
return "process_signal"
|
||||
return "nonzero_exit"
|
||||
@@ -923,6 +964,20 @@ def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]
|
||||
return {key: report[key] for key in keys if key in report}
|
||||
|
||||
|
||||
def gamescope_exit_summary(stderr: str) -> dict[str, object]:
|
||||
folded = stderr.casefold()
|
||||
gamescope_status = re.search(
|
||||
r"gamescope exited without a recorded game status \(compositor status (-?\d{1,3})\)",
|
||||
folded,
|
||||
)
|
||||
return {
|
||||
"gamescope_child_status_missing": (
|
||||
"gamescope exited without a recorded game status" in folded
|
||||
),
|
||||
"gamescope_exit_code": int(gamescope_status[1]) if gamescope_status is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def runtime_issue_summary(stderr: str) -> dict[str, object]:
|
||||
folded = stderr.casefold()
|
||||
futex_message = "the futex facility returned an unexpected error code"
|
||||
@@ -965,9 +1020,7 @@ def runtime_issue_summary(stderr: str) -> dict[str, object]:
|
||||
"gamescope_forced_cleanup": (
|
||||
"gamescope did not stop gracefully; forcing cleanup" in folded
|
||||
),
|
||||
"gamescope_child_status_missing": (
|
||||
"gamescope exited without a recorded game status" in folded
|
||||
),
|
||||
**gamescope_exit_summary(stderr),
|
||||
"proton_game_drive_warning": "unable to use parent for game drive" in folded,
|
||||
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
|
||||
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion),
|
||||
@@ -995,6 +1048,9 @@ def game_window_was_ready(stderr: str) -> bool:
|
||||
|
||||
def game_readiness_signal(output: str) -> str | None:
|
||||
folded = output.casefold()
|
||||
# Older supervisors used a readiness prefix without observing a window.
|
||||
# Preserve their UI fallback without treating it as successful presentation.
|
||||
folded = folded.replace("game window ready: unmanaged ", "unmonitored game process: ")
|
||||
for signal_name, marker in (
|
||||
("window_ready", "game window ready:"),
|
||||
("rendered_frame", "rendered game frame:"),
|
||||
@@ -1035,6 +1091,14 @@ def window_diagnostic_severity(diagnostic: dict[str, object]) -> str:
|
||||
return "debug"
|
||||
|
||||
|
||||
def gamescope_exit_message(runtime_issues: dict[str, object]) -> str:
|
||||
message = "Game session ended before the game reported its exit status"
|
||||
status = runtime_issues.get("gamescope_exit_code")
|
||||
if isinstance(status, int):
|
||||
message += f" (Gamescope status {status})"
|
||||
return message
|
||||
|
||||
|
||||
def print_game_outcome(
|
||||
outcome: CommandOutcome,
|
||||
current_game_log: dict[str, object] | None,
|
||||
@@ -1063,6 +1127,8 @@ def print_game_outcome(
|
||||
"exhaustion.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif runtime_issues.get("gamescope_child_status_missing") is True:
|
||||
print(gamescope_exit_message(runtime_issues) + ".", file=sys.stderr)
|
||||
elif outcome.returncode == 0:
|
||||
print("Game client exited normally.")
|
||||
elif outcome.returncode is None:
|
||||
|
||||
53
src/ptlinux/game/arguments.py
Normal file
53
src/ptlinux/game/arguments.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Parse the publisher's Windows argument string without invoking a shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def split_windows_arguments(command_line: str) -> tuple[str, ...]:
|
||||
"""Decode MS C runtime argument quoting (the executable is supplied separately).
|
||||
|
||||
Backslashes are literal except before double quotes; single quotes and shell
|
||||
metacharacters are ordinary characters. Empty quoted arguments are retained.
|
||||
See https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments
|
||||
"""
|
||||
|
||||
if any(ord(character) < 32 and character != "\t" for character in command_line):
|
||||
raise ValueError("launch arguments contain control characters")
|
||||
arguments: list[str] = []
|
||||
index = 0
|
||||
while index < len(command_line):
|
||||
while index < len(command_line) and command_line[index] in " \t":
|
||||
index += 1
|
||||
if index == len(command_line):
|
||||
break
|
||||
argument: list[str] = []
|
||||
quoted = False
|
||||
while index < len(command_line):
|
||||
character = command_line[index]
|
||||
if character in " \t" and not quoted:
|
||||
break
|
||||
if character == "\\":
|
||||
start = index
|
||||
while index < len(command_line) and command_line[index] == "\\":
|
||||
index += 1
|
||||
count = index - start
|
||||
if index == len(command_line) or command_line[index] != '"':
|
||||
argument.append("\\" * count)
|
||||
continue
|
||||
argument.append("\\" * (count // 2))
|
||||
if count % 2:
|
||||
argument.append('"')
|
||||
index += 1
|
||||
continue
|
||||
character = '"'
|
||||
if character == '"':
|
||||
if quoted and command_line[index:index + 2] == '""':
|
||||
argument.append('"')
|
||||
index += 2
|
||||
continue
|
||||
quoted = not quoted
|
||||
else:
|
||||
argument.append(character)
|
||||
index += 1
|
||||
arguments.append("".join(argument))
|
||||
return tuple(arguments)
|
||||
121
src/ptlinux/gui_bootstrap.py
Normal file
121
src/ptlinux/gui_bootstrap.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Recover a usable GUI interpreter and report missing Tk without a Tk window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
class GuiDependencyError(RuntimeError):
|
||||
"""The current Python installation cannot provide the dashboard toolkit."""
|
||||
|
||||
|
||||
def tkinter_path() -> str | None:
|
||||
"""Check the Python package, extension, and Tcl data without opening a display."""
|
||||
|
||||
try:
|
||||
tk = import_module("tkinter")
|
||||
import_module("tkinter.ttk")
|
||||
import_module("tkinter.filedialog")
|
||||
tk.Tcl()
|
||||
return tk.__file__ or "available"
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
return None
|
||||
except Exception as exc:
|
||||
# TclError is only available after a successful tkinter import.
|
||||
if type(exc).__name__ == "TclError":
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def recover_gui_python(arguments: Sequence[str]) -> bool:
|
||||
"""Use the distribution's Python if a PATH/venv Python has no usable Tk."""
|
||||
|
||||
if tkinter_path() is not None:
|
||||
return True
|
||||
if os.environ.get("PTLINUX_PYTHON"):
|
||||
return False
|
||||
candidate = Path("/usr/bin/python3")
|
||||
if not candidate.is_file() or os.environ.get("PTLINUX_GUI_PYTHON_RETRIED") == "1":
|
||||
return False
|
||||
# A venv may share the same binary but have different package search paths.
|
||||
if str(candidate) == sys.executable and sys.prefix == sys.base_prefix:
|
||||
return False
|
||||
probe = (
|
||||
"import sys; assert sys.version_info >= (3, 11); "
|
||||
"import tkinter; from tkinter import ttk, filedialog; tkinter.Tcl()"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(candidate), "-I", "-B", "-c", probe],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, timeout=5, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
environment = os.environ.copy()
|
||||
# Preserve the selected release when restarting from python -m or a
|
||||
# desktop entry. Ignore Python-home settings belonging to the failed SDK.
|
||||
environment.pop("PYTHONHOME", None)
|
||||
environment["PYTHONPATH"] = str(Path(__file__).resolve().parent.parent)
|
||||
environment["PYTHONSAFEPATH"] = "1"
|
||||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
environment["PTLINUX_GUI_PYTHON_RETRIED"] = "1"
|
||||
os.execve(str(candidate), [str(candidate), "-B", "-P", "-m", "ptlinux", *arguments], environment)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def missing_tk_message() -> str:
|
||||
from .system_identity import detect_distribution_id
|
||||
|
||||
distribution = detect_distribution_id()
|
||||
if distribution in {"ubuntu", "debian", "linuxmint", "pop", "elementary", "zorin"}:
|
||||
repair = "Install python3-tk with your package manager (sudo apt install python3-tk)."
|
||||
elif distribution in {"fedora", "nobara"}:
|
||||
repair = "Install python3-tkinter with your package manager."
|
||||
elif distribution in {"arch", "cachyos", "endeavouros", "garuda", "manjaro"}:
|
||||
repair = "Install tk with your package manager."
|
||||
elif distribution == "steamos":
|
||||
repair = "Use a Python installation that includes tkinter and Tcl/Tk."
|
||||
else:
|
||||
repair = "Install the Tk package matching your Python version with your distribution's package manager."
|
||||
return (
|
||||
f"Python Tk is unavailable for {sys.executable} (Python {sys.version_info.major}.{sys.version_info.minor}). "
|
||||
f"{repair} If you selected a custom Python, install its matching Tk support or "
|
||||
"start with PTLINUX_PYTHON=/usr/bin/python3. "
|
||||
"The pristontale-eu command-line interface remains available."
|
||||
)
|
||||
|
||||
|
||||
def show_gui_dependency_error(message: str) -> None:
|
||||
"""Give desktop launches an actionable message even when Tk cannot import."""
|
||||
|
||||
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
||||
return
|
||||
for name, arguments in (
|
||||
("kdialog", ["--title", "PristonTale EU", "--error", message]),
|
||||
("zenity", ["--error", "--title=PristonTale EU", "--text", message]),
|
||||
("notify-send", ["--urgency=critical", "PristonTale EU", message]),
|
||||
):
|
||||
executable = shutil.which(name)
|
||||
if executable is None:
|
||||
continue
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[executable, *arguments], stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=60, check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
return
|
||||
except OSError:
|
||||
continue
|
||||
@@ -2605,7 +2605,7 @@ def run_game_window(
|
||||
raise
|
||||
if mapper is None:
|
||||
print(
|
||||
f"Game window ready: unmanaged {window_title}",
|
||||
f"Game window monitoring unavailable; process started: {window_title}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -336,8 +336,10 @@ def _supervised_command(
|
||||
str(command[0]),
|
||||
"--backend",
|
||||
backend,
|
||||
"--keep-alive",
|
||||
]
|
||||
# The child records its status before exiting. Leave Gamescope's normal
|
||||
# child-exit shutdown enabled so a reporting failure (for example ENOSPC)
|
||||
# cannot strand an empty compositor with no status for us to observe.
|
||||
if backend == "sdl" or force_composition:
|
||||
result.append("--force-composition")
|
||||
result.extend(map(str, command[1:]))
|
||||
@@ -611,20 +613,19 @@ def _wait_for_optimized_gamescope(
|
||||
child_status = (
|
||||
_read_child_status(status_file) if status_file is not None else None
|
||||
)
|
||||
if status_file is not None and child_status is None:
|
||||
if child_status is None:
|
||||
# A compositor can exit with 0 when its host window closes,
|
||||
# even though its nested game was killed. Only the game's
|
||||
# recorded status can establish a clean game exit.
|
||||
print(
|
||||
"Gamescope exited without a recorded game status; "
|
||||
"the game session was interrupted.",
|
||||
"Gamescope exited without a recorded game status "
|
||||
f"(compositor status {gamescope_status}); "
|
||||
"the game's exit result is unknown.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return _process_exit_status(gamescope_status) or 1
|
||||
return _process_exit_status(
|
||||
gamescope_status if child_status is None else child_status
|
||||
)
|
||||
return _process_exit_status(child_status)
|
||||
time.sleep(CHILD_STATUS_POLL_SECONDS)
|
||||
|
||||
|
||||
@@ -880,104 +881,104 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
diagnostics.close(process_started=False, backend=backend)
|
||||
raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc
|
||||
|
||||
if kde_wayland_xwayland_fallback:
|
||||
print(
|
||||
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||
"SDL host window through XWayland to avoid KWin presentation, "
|
||||
"fullscreen, and fractional-scaling failures.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif cinnamon_wayland_xwayland_fallback:
|
||||
print(
|
||||
"Cinnamon Wayland compatibility mode active: Gamescope is using "
|
||||
"its resizable SDL host through XWayland so Muffin provides the "
|
||||
"native minimize, maximize, and close controls.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if backend == "wayland" and not force_composition:
|
||||
message = (
|
||||
"Gamescope resize optimization active: native Wayland direct "
|
||||
"presentation keeps live host-window changes off the game surface."
|
||||
)
|
||||
if using_bundled_gamescope:
|
||||
message += (
|
||||
" Launcher-local lazy-output, live-resize, letterbox backing, "
|
||||
"decoration-input, and login-window visibility fixes are active."
|
||||
)
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
elif backend == "wayland" and kde_session:
|
||||
print(
|
||||
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||
"composed native fallback because no XWayland display is available.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif backend == "wayland":
|
||||
print(
|
||||
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
|
||||
"backing. Forced composition prevents flicker, but live resizing may "
|
||||
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif mapper is not None:
|
||||
try:
|
||||
if diagnostic_sink is None:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
)
|
||||
else:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
diagnostics=diagnostic_sink,
|
||||
)
|
||||
except Exception as exc:
|
||||
increments = None
|
||||
diagnostics.record(
|
||||
"host_scaling_configuration_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
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 "
|
||||
"composited and size changes use "
|
||||
f"{increments[0]}x{increments[1]} pixel steps.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif process.poll() is None:
|
||||
diagnostics.record("host_scaling_unavailable")
|
||||
print(
|
||||
"Warning: Gamescope's host window could not be resize-tuned; "
|
||||
"continuing normally.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
diagnostics.record("host_x11_monitor_unavailable")
|
||||
print(
|
||||
"Gamescope is using its SDL presentation fallback; host X11 resize "
|
||||
"tuning is unavailable.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
if kde_wayland_xwayland_fallback:
|
||||
print(
|
||||
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||
"SDL host window through XWayland to avoid KWin presentation, "
|
||||
"fullscreen, and fractional-scaling failures.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif cinnamon_wayland_xwayland_fallback:
|
||||
print(
|
||||
"Cinnamon Wayland compatibility mode active: Gamescope is using "
|
||||
"its resizable SDL host through XWayland so Muffin provides the "
|
||||
"native minimize, maximize, and close controls.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if backend == "wayland" and not force_composition:
|
||||
message = (
|
||||
"Gamescope resize optimization active: native Wayland direct "
|
||||
"presentation keeps live host-window changes off the game surface."
|
||||
)
|
||||
if using_bundled_gamescope:
|
||||
message += (
|
||||
" Launcher-local lazy-output, live-resize, letterbox backing, "
|
||||
"decoration-input, and login-window visibility fixes are active."
|
||||
)
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
elif backend == "wayland" and kde_session:
|
||||
print(
|
||||
"KDE Wayland compatibility mode active: Gamescope is using its "
|
||||
"composed native fallback because no XWayland display is available.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif backend == "wayland":
|
||||
print(
|
||||
"Warning: this Gamescope build lacks safe direct Wayland letterbox "
|
||||
"backing. Forced composition prevents flicker, but live resizing may "
|
||||
"pause; install Gamescope 3.16.28 or newer for direct presentation.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif mapper is not None:
|
||||
try:
|
||||
if diagnostic_sink is None:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
)
|
||||
else:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
diagnostics=diagnostic_sink,
|
||||
)
|
||||
except Exception as exc:
|
||||
increments = None
|
||||
diagnostics.record(
|
||||
"host_scaling_configuration_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
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 "
|
||||
"composited and size changes use "
|
||||
f"{increments[0]}x{increments[1]} pixel steps.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif process.poll() is None:
|
||||
diagnostics.record("host_scaling_unavailable")
|
||||
print(
|
||||
"Warning: Gamescope's host window could not be resize-tuned; "
|
||||
"continuing normally.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
diagnostics.record("host_x11_monitor_unavailable")
|
||||
print(
|
||||
"Gamescope is using its SDL presentation fallback; host X11 resize "
|
||||
"tuning is unavailable.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return _wait_for_optimized_gamescope(
|
||||
process,
|
||||
status_file,
|
||||
|
||||
@@ -208,6 +208,10 @@ def start_operation_process(
|
||||
)
|
||||
events.put(("game_started", game_client_id))
|
||||
if game_client_id is not None and line.startswith(
|
||||
("Game window monitoring unavailable; process started:", "Game window ready: unmanaged ")
|
||||
):
|
||||
events.put(("game_window_unmonitored", game_client_id))
|
||||
elif game_client_id is not None and line.startswith(
|
||||
("Game window ready:", "Rendered game frame:")
|
||||
):
|
||||
events.put(("game_window_ready", game_client_id))
|
||||
|
||||
@@ -1012,6 +1012,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
ready: bool,
|
||||
returncode: int | None = None,
|
||||
cancelled: bool = False,
|
||||
window_unmonitored: bool = False,
|
||||
) -> None:
|
||||
nonlocal active_game_launch_id
|
||||
nonlocal operation_active, game_window_active
|
||||
@@ -1037,6 +1038,9 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
status.set("Ready for another account.")
|
||||
progress_text.set("Click Play to open another game window.")
|
||||
dashboard_chrome.minimize()
|
||||
elif window_unmonitored:
|
||||
status.set(f"Client {client_id} started; window readiness could not be checked.")
|
||||
progress_text.set("Select the game window to continue, or see the activity log for details.")
|
||||
elif cancelled:
|
||||
root.lift()
|
||||
status.set("Game launch cancelled.")
|
||||
@@ -1166,6 +1170,19 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
game_window_active = True
|
||||
release_game_launch_controls(value, ready=True)
|
||||
|
||||
def handle_game_window_unmonitored_event(value: object) -> None:
|
||||
nonlocal game_window_active
|
||||
if not (
|
||||
isinstance(value, int)
|
||||
and not game_launch_supervisors.is_cancel_requested(value)
|
||||
):
|
||||
return
|
||||
if game_clients.mark_running(value):
|
||||
game_window_active = True
|
||||
# Release the modal preloader without claiming a visible window or
|
||||
# minimizing the dashboard before the user can inspect startup errors.
|
||||
release_game_launch_controls(value, ready=False, window_unmonitored=True)
|
||||
|
||||
def handle_game_done_event(value: object) -> None:
|
||||
nonlocal game_window_active
|
||||
if not (
|
||||
@@ -1366,6 +1383,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
"game_operation_status": handle_game_operation_status_event,
|
||||
"game_started": handle_game_started_event,
|
||||
"game_window_ready": handle_game_window_ready_event,
|
||||
"game_window_unmonitored": handle_game_window_unmonitored_event,
|
||||
"game_done": handle_game_done_event,
|
||||
"broadcast": handle_broadcast_event,
|
||||
"server_status_check": handle_server_status_event,
|
||||
|
||||
@@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping, Sequence
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
@@ -33,6 +34,46 @@ def window_diagnostics_enabled(
|
||||
return env.get(WINDOW_DIAGNOSTICS_ENV, "").strip().casefold() in _TRUE_VALUES
|
||||
|
||||
|
||||
class WindowDiagnosticLog:
|
||||
"""Retain bounded supervisor evidence independently of remote telemetry."""
|
||||
|
||||
def __init__(self, path: Path, session_id: str) -> None:
|
||||
self.path = path
|
||||
self.session_id = session_id
|
||||
self._samples: Counter[str] = Counter()
|
||||
self._summaries: set[str] = set()
|
||||
self._failed = False
|
||||
|
||||
def record(self, payload: dict[str, object]) -> None:
|
||||
if self._failed or payload.get("session_id") != self.session_id:
|
||||
return
|
||||
component = payload.get("component")
|
||||
if not isinstance(component, str) or component not in WINDOW_DIAGNOSTIC_COMPONENTS:
|
||||
return
|
||||
if payload.get("diagnostic") == "session_summary":
|
||||
if component in self._summaries:
|
||||
return
|
||||
self._summaries.add(component)
|
||||
elif self._samples[component] >= WINDOW_DIAGNOSTIC_EVENT_LIMIT:
|
||||
return
|
||||
else:
|
||||
self._samples[component] += 1
|
||||
try:
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
if len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor = os.open(
|
||||
self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600,
|
||||
)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
stream.write(encoded + "\n")
|
||||
except (OSError, TypeError, ValueError):
|
||||
# Logging must never interrupt the game or repeatedly fill stderr.
|
||||
self._failed = True
|
||||
print("Warning: window diagnostics could not be saved locally.", file=sys.stderr)
|
||||
|
||||
|
||||
class WindowDiagnosticRecorder:
|
||||
"""Emit a few samples while retaining aggregate counts for session shutdown."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user