1158 lines
37 KiB
Python
1158 lines
37 KiB
Python
"""Game launch, runtime outcome, log, and crash reporting commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import asdict, replace
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
import re
|
|
import signal
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from ...command import CommandOutcome
|
|
from ...depot import validate_installed_launch_catalog
|
|
from ...diagnostics.crash_dumps import (
|
|
inspect_minidump,
|
|
prepare_minidump_attachment,
|
|
)
|
|
from ...diagnostics.native_logs import LogFileState
|
|
from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm
|
|
from ...launcher import LauncherError
|
|
from ...login_trace import LOGIN_TRACE_DISABLED_REASON
|
|
from ...paths import AppPaths
|
|
from ...settings import Settings
|
|
from ...umu import UmuBootstrapResult
|
|
from ...window_diagnostics import (
|
|
WINDOW_DIAGNOSTICS_ENV,
|
|
WINDOW_DIAGNOSTICS_SESSION_ENV,
|
|
)
|
|
|
|
|
|
def play(
|
|
api: Any,
|
|
paths: AppPaths,
|
|
game_dir: Path | None,
|
|
no_box: bool,
|
|
dry_run: bool,
|
|
skip_runtime_setup: bool,
|
|
as_json: bool,
|
|
*,
|
|
realm: str | None = None,
|
|
) -> int:
|
|
with api._play_guard(paths, dry_run) as play_lease:
|
|
return api._play_locked(
|
|
paths,
|
|
game_dir,
|
|
no_box,
|
|
dry_run,
|
|
skip_runtime_setup,
|
|
as_json,
|
|
realm=realm,
|
|
reuse_prepared=not play_lease.preparation_allowed,
|
|
before_game=play_lease.allow_shared_clients,
|
|
game_lease_fds=play_lease.inherited_fds,
|
|
)
|
|
|
|
|
|
def play_locked(
|
|
api: Any,
|
|
paths: AppPaths,
|
|
game_dir: Path | None,
|
|
no_box: bool,
|
|
dry_run: bool,
|
|
skip_runtime_setup: bool,
|
|
as_json: bool,
|
|
*,
|
|
realm: str | None = None,
|
|
reuse_prepared: bool = False,
|
|
before_game: Callable[[], None] | None = None,
|
|
game_lease_fds: Sequence[int] = (),
|
|
) -> int:
|
|
telemetry = api.current_telemetry()
|
|
launch_requested_at = time.monotonic()
|
|
window_diagnostics_active = (
|
|
telemetry is not None
|
|
and not dry_run
|
|
and bool(getattr(telemetry, "enabled", True))
|
|
)
|
|
launch_session_id = uuid4().hex if window_diagnostics_active else None
|
|
launch_session_metadata = (
|
|
{"launch_session_id": launch_session_id}
|
|
if launch_session_id is not None
|
|
else {}
|
|
)
|
|
if telemetry is not None and not dry_run:
|
|
telemetry.send_event(
|
|
"game_launch_requested",
|
|
metadata=launch_session_metadata,
|
|
context={"operation": "launch_game", "stage": "preflight"},
|
|
)
|
|
if game_dir is None and api.game_import_requires_verification(paths):
|
|
raise LauncherError(
|
|
"the imported game has not completed its full publisher verification; "
|
|
"run pristontale-eu update before playing"
|
|
)
|
|
settings = api._load_or_default_settings(paths, persist=not dry_run)
|
|
launcher_nested_in_steam_runtime = api.is_running_inside_steam_runtime()
|
|
if launcher_nested_in_steam_runtime and not dry_run:
|
|
print(
|
|
"Warning: the launcher itself is running inside Steam Runtime. Start "
|
|
"it from the desktop menu or a host terminal; nesting its UMU runtime "
|
|
"can cause black game output.",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
window_launch_metadata: dict[str, object] = {
|
|
"renderer_requested": settings.renderer,
|
|
"gamebox_enabled": not no_box,
|
|
"resizable_window_requested": settings.allow_window_resizing,
|
|
"launcher_inside_steam_runtime": launcher_nested_in_steam_runtime,
|
|
}
|
|
if (
|
|
launcher_nested_in_steam_runtime
|
|
and settings.allow_window_resizing
|
|
and not no_box
|
|
):
|
|
window_launch_metadata["gamescope_disabled_reason"] = (
|
|
"launcher_inside_steam_runtime"
|
|
)
|
|
launch_catalog = api.read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache)
|
|
if game_dir is None:
|
|
validate_installed_launch_catalog(paths, launch_catalog)
|
|
start_option = api.select_depot_start_option(
|
|
launch_catalog,
|
|
realm or settings.realm,
|
|
)
|
|
settings = runtime_settings_for_realm(settings, start_option.name)
|
|
source = api._source_game(paths, settings, game_dir)
|
|
overlay = None
|
|
depot_file_catalog = None
|
|
depot_preflight = None
|
|
if game_dir is None and not dry_run:
|
|
depot_file_catalog = api.read_current_depot_file_catalog(paths)
|
|
api._print_operation_status(
|
|
as_json,
|
|
1,
|
|
4,
|
|
(
|
|
"Reusing the active managed game view…"
|
|
if reuse_prepared
|
|
else "Preparing the managed game view…"
|
|
),
|
|
)
|
|
if reuse_prepared:
|
|
if game_dir is not None:
|
|
raise LauncherError(
|
|
"an additional client cannot replace the active managed game view; "
|
|
"close every client before using --game-dir"
|
|
)
|
|
if not paths.runtime_view.is_dir():
|
|
raise LauncherError(
|
|
"another game client is active, but the prepared runtime view is missing"
|
|
)
|
|
elif not dry_run:
|
|
if game_dir is None:
|
|
if depot_file_catalog is not None:
|
|
depot_preflight = api.preflight_managed_game(
|
|
paths,
|
|
catalog=depot_file_catalog,
|
|
)
|
|
if depot_preflight is not None and not depot_preflight.ready:
|
|
details = "; ".join(
|
|
f"{issue.path}: {issue.reason}"
|
|
for issue in depot_preflight.issues[:3]
|
|
)
|
|
raise LauncherError(
|
|
"the managed game integrity check found "
|
|
f"{depot_preflight.issue_count} missing or changed file(s)"
|
|
+ (f" ({details})" if details else "")
|
|
+ "; run `pristontale-eu update --repair` before playing"
|
|
)
|
|
overlay = api.prepare_runtime_view(paths, source)
|
|
api._print_operation_status(as_json, 2, 4, "Ensuring the compatibility runtime is available…")
|
|
umu_bootstrap = (
|
|
None
|
|
if reuse_prepared
|
|
else api._ensure_portable_umu_for_play(paths, settings, dry_run=dry_run)
|
|
)
|
|
selection = api.select_runtime(settings, paths=paths)
|
|
runner = api._runner(paths, "play", dry_run)
|
|
setup_results = []
|
|
if reuse_prepared:
|
|
api._print_operation_status(
|
|
as_json,
|
|
3,
|
|
4,
|
|
"Reusing the active Proton and DirectX runtime…",
|
|
)
|
|
if not api.game_runtime_ready(paths, selection):
|
|
raise LauncherError(
|
|
"another game client is active, but its prepared runtime no longer "
|
|
"matches the current settings; close every client and launch again"
|
|
)
|
|
elif not skip_runtime_setup:
|
|
api._print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
|
|
setup_results = api.prepare_game_runtime(paths, selection, runner)
|
|
else:
|
|
api._print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…")
|
|
api._print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
|
|
native_log_directory = paths.game_save / "logs"
|
|
native_log_baseline = api.snapshot_native_logs(
|
|
native_log_directory,
|
|
patterns=("log_*.txt",),
|
|
)
|
|
crash_directory = paths.game_save / "crash"
|
|
crash_baseline = api.snapshot_native_logs(
|
|
crash_directory,
|
|
patterns=("*.dat", "*.dmp"),
|
|
)
|
|
game_started_ns = time.time_ns()
|
|
game_started_at = time.monotonic()
|
|
launch_success_lock = threading.Lock()
|
|
# 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
|
|
launch_success_reported = False
|
|
reported_runtime_warnings: set[str] = set()
|
|
if telemetry is not None and not dry_run:
|
|
telemetry.send_event(
|
|
"game_launch_started",
|
|
metadata={
|
|
**launch_session_metadata,
|
|
**window_launch_metadata,
|
|
"launch_duration_ms": round(
|
|
(game_started_at - launch_requested_at) * 1000
|
|
),
|
|
"launch_stage": "game_start",
|
|
"runtime": selection.kind,
|
|
"realm": start_option.name,
|
|
},
|
|
context={"operation": "launch_game", "stage": "game_start"},
|
|
)
|
|
managed_asset_paths = (
|
|
[entry.path for entry in depot_file_catalog.files]
|
|
if depot_file_catalog is not None
|
|
else None
|
|
)
|
|
|
|
def observe_game_output(line: str) -> str | None:
|
|
"""Timestamp launch success when the supervisor first confirms readiness."""
|
|
|
|
nonlocal observed_readiness_signal, launch_success_reported
|
|
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:
|
|
if presentation_failure not in reported_runtime_warnings:
|
|
reported_runtime_warnings.add(presentation_failure)
|
|
telemetry.send_event(
|
|
"game_window_diagnostic",
|
|
severity="warning",
|
|
metadata={
|
|
**launch_session_metadata,
|
|
**window_launch_metadata,
|
|
"component": "gamescope_host",
|
|
"diagnostic": presentation_failure,
|
|
"runtime": selection.kind,
|
|
"realm": start_option.name,
|
|
},
|
|
context={
|
|
"operation": "launch_game",
|
|
"stage": "presentation",
|
|
},
|
|
)
|
|
window_diagnostic = api.parse_window_diagnostic(line)
|
|
if window_diagnostic is not None:
|
|
diagnostic_session_id = window_diagnostic.get("session_id")
|
|
if launch_session_id is None or diagnostic_session_id != launch_session_id:
|
|
return line
|
|
if telemetry is not None and window_diagnostics_active:
|
|
with launch_success_lock:
|
|
telemetry.send_event(
|
|
"game_window_diagnostic",
|
|
severity=api._window_diagnostic_severity(window_diagnostic),
|
|
metadata={
|
|
"runtime": selection.kind,
|
|
"realm": start_option.name,
|
|
**window_diagnostic,
|
|
},
|
|
context={
|
|
"operation": "launch_game",
|
|
"stage": "window_diagnostics",
|
|
},
|
|
)
|
|
return None
|
|
live_readiness_signal = api._game_readiness_signal(line)
|
|
if live_readiness_signal is None:
|
|
return line
|
|
with launch_success_lock:
|
|
if observed_readiness_signal is None:
|
|
observed_readiness_signal = live_readiness_signal
|
|
if telemetry is None or dry_run or launch_success_reported:
|
|
return line
|
|
ready_at = time.monotonic()
|
|
launch_success_reported = telemetry.send_event(
|
|
"game_launch_success",
|
|
metadata={
|
|
**launch_session_metadata,
|
|
**window_launch_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": observed_readiness_signal,
|
|
"runtime": selection.kind,
|
|
"realm": start_option.name,
|
|
},
|
|
context={"operation": "launch_game", "stage": "ready"},
|
|
)
|
|
return line
|
|
|
|
try:
|
|
outcome = api.run_game(
|
|
paths,
|
|
settings,
|
|
selection,
|
|
runner,
|
|
start_option=start_option,
|
|
use_gamebox=not no_box,
|
|
concurrent_prefix_launch=reuse_prepared,
|
|
check=False,
|
|
output_line_filter=observe_game_output,
|
|
extra_environment=(
|
|
{
|
|
WINDOW_DIAGNOSTICS_ENV: "1",
|
|
WINDOW_DIAGNOSTICS_SESSION_ENV: launch_session_id,
|
|
}
|
|
if launch_session_id is not None
|
|
else None
|
|
),
|
|
pass_fds=game_lease_fds,
|
|
before_start=before_game,
|
|
)
|
|
except KeyboardInterrupt:
|
|
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
|
|
current_game_log = api._current_game_log(
|
|
native_log_directory,
|
|
native_log_baseline,
|
|
game_started_ns,
|
|
managed_asset_paths=managed_asset_paths,
|
|
source_game=source if managed_asset_paths is not None else None,
|
|
)
|
|
launch_evidence = observed_readiness_signal
|
|
if launch_evidence is None and current_game_log is not None:
|
|
launch_evidence = "game_log"
|
|
if telemetry is not None and not dry_run:
|
|
api._report_game_launch_cancelled(
|
|
telemetry,
|
|
launch_requested_at,
|
|
stage="running" if launch_evidence is not None else "game_start",
|
|
metadata={
|
|
**launch_session_metadata,
|
|
**window_launch_metadata,
|
|
"runtime_seconds": round(game_runtime_seconds),
|
|
"clean_exit": False,
|
|
"launch_succeeded": launch_evidence is not None,
|
|
"game_window_ready": observed_readiness_signal is not None,
|
|
"readiness_signal": launch_evidence,
|
|
"session_duration_class": api._session_duration_class(
|
|
game_runtime_seconds
|
|
),
|
|
"runtime": selection.kind,
|
|
"realm": start_option.name,
|
|
},
|
|
)
|
|
if current_game_log is not None and not as_json:
|
|
api._print_interrupted_game_log(current_game_log)
|
|
raise api._GameLaunchInterruptionReported from None
|
|
current_game_log = api._current_game_log(
|
|
native_log_directory,
|
|
native_log_baseline,
|
|
game_started_ns,
|
|
managed_asset_paths=managed_asset_paths,
|
|
source_game=source if managed_asset_paths is not None else None,
|
|
)
|
|
current_game_crash = api._current_game_crash(
|
|
crash_directory,
|
|
crash_baseline,
|
|
game_started_ns,
|
|
)
|
|
runtime_issues = api._runtime_issue_summary(outcome.stderr)
|
|
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
|
|
runtime_issues["game_window_ready"] = game_window_ready
|
|
if game_readiness_signal is not None:
|
|
runtime_issues["game_readiness_signal"] = game_readiness_signal
|
|
recommended_comparison = recommended_runtime_comparison(
|
|
selection.kind,
|
|
runtime_issues,
|
|
)
|
|
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
|
|
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:
|
|
launch_evidence = "game_log"
|
|
elif launch_evidence is None and clean_exit:
|
|
launch_evidence = "clean_process_exit"
|
|
launch_succeeded = launch_evidence is not None
|
|
report = {
|
|
"ok": clean_exit,
|
|
"degraded": session_issue is not None,
|
|
"session_issue": session_issue,
|
|
"launch_succeeded": launch_succeeded,
|
|
"dry_run": dry_run,
|
|
"runtime": asdict(selection),
|
|
"umu_bootstrap": asdict(umu_bootstrap) if umu_bootstrap else None,
|
|
"realm": asdict(start_option),
|
|
"runtime_view": asdict(overlay) if overlay else None,
|
|
"depot_preflight": (
|
|
{
|
|
**asdict(depot_preflight),
|
|
"ready": depot_preflight.ready,
|
|
"fully_verified": depot_preflight.fully_verified,
|
|
}
|
|
if depot_preflight
|
|
else None
|
|
),
|
|
"setup_commands": [asdict(result) for result in setup_results],
|
|
"game": asdict(outcome),
|
|
"current_game_log": current_game_log,
|
|
"current_game_crash": current_game_crash,
|
|
"runtime_issues": runtime_issues,
|
|
}
|
|
game_runtime_seconds = max(0.0, time.monotonic() - game_started_at)
|
|
if telemetry is not None and not dry_run:
|
|
common_game_metadata: dict[str, object] = {
|
|
**launch_session_metadata,
|
|
**window_launch_metadata,
|
|
"runtime_seconds": round(game_runtime_seconds),
|
|
"clean_exit": clean_exit,
|
|
"degraded": session_issue is not None,
|
|
"timed_out": outcome.timed_out,
|
|
"launch_succeeded": launch_succeeded,
|
|
"game_window_ready": game_window_ready,
|
|
"readiness_signal": launch_evidence,
|
|
"crash_dump": current_game_crash is not None,
|
|
"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),
|
|
}
|
|
if not clean_exit or session_issue is not None:
|
|
common_game_metadata["failure_reason"] = game_failure_reason(
|
|
outcome,
|
|
current_game_log,
|
|
current_game_crash,
|
|
common_game_metadata,
|
|
)
|
|
failure_evidence: dict[str, object] = {
|
|
"native_game_log_present": current_game_log is not None,
|
|
}
|
|
if current_game_log is not None:
|
|
failure_evidence["native_game_log"] = game_log_telemetry_summary(
|
|
current_game_log
|
|
)
|
|
if current_game_crash is not None:
|
|
failure_evidence["crash_dump_info"] = crash_dump_telemetry_summary(
|
|
current_game_crash
|
|
)
|
|
crash_dump_attachment: dict[str, object] | None = None
|
|
if (
|
|
current_game_crash is not None
|
|
and settings.game_crash_dump_upload_enabled
|
|
and telemetry.enabled
|
|
):
|
|
crash_path = current_game_crash.get("path")
|
|
if isinstance(crash_path, str):
|
|
upload_status, crash_dump_attachment = prepare_minidump_attachment(
|
|
Path(crash_path)
|
|
)
|
|
else:
|
|
upload_status = "path_unavailable"
|
|
failure_evidence["crash_dump_upload_status"] = upload_status
|
|
if launch_succeeded:
|
|
if not launch_success_reported:
|
|
assert launch_evidence is not None
|
|
telemetry.send_event(
|
|
"game_launch_success",
|
|
metadata={
|
|
**launch_session_metadata,
|
|
**window_launch_metadata,
|
|
"detection_duration_ms": round(game_runtime_seconds * 1000),
|
|
"detected_after_exit": True,
|
|
"game_window_ready": game_window_ready,
|
|
"readiness_signal": launch_evidence,
|
|
"runtime": selection.kind,
|
|
"realm": start_option.name,
|
|
},
|
|
context={"operation": "launch_game", "stage": "post_exit"},
|
|
)
|
|
else:
|
|
telemetry.send_event(
|
|
"game_launch_failed",
|
|
severity="error",
|
|
message=(
|
|
game_crash_message(current_game_log)
|
|
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,
|
|
**failure_evidence,
|
|
"runtime_issues": runtime_issues,
|
|
"stdout": outcome.stdout,
|
|
"stderr": outcome.stderr,
|
|
},
|
|
context={"operation": "launch_game", "stage": "game_start"},
|
|
attachment=crash_dump_attachment,
|
|
)
|
|
if launch_succeeded:
|
|
exit_message = None
|
|
if current_game_crash is not None:
|
|
exit_message = game_crash_message(current_game_log)
|
|
elif outcome.timed_out:
|
|
exit_message = "Game session timed out"
|
|
elif session_issue == "direct3d_resource_exhaustion":
|
|
exit_message = (
|
|
"Game client exited after logging Direct3D resource exhaustion"
|
|
)
|
|
elif common_game_metadata.get("signal_inferred") is True:
|
|
signal_wrapper = (
|
|
"Proton" if selection.kind == "proton7" else "UMU"
|
|
)
|
|
exit_message = (
|
|
"Game session ended with status "
|
|
f"{common_game_metadata['raw_exit_code']}, consistent with "
|
|
f"{common_game_metadata['signal_name']} wrapped by {signal_wrapper}"
|
|
)
|
|
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 "warning"
|
|
if not clean_exit or session_issue is not None
|
|
else "info"
|
|
),
|
|
message=exit_message,
|
|
metadata=(
|
|
common_game_metadata
|
|
if clean_exit and session_issue is None
|
|
else {
|
|
**common_game_metadata,
|
|
**failure_evidence,
|
|
"runtime_issues": runtime_issues,
|
|
"stdout": outcome.stdout,
|
|
"stderr": outcome.stderr,
|
|
}
|
|
),
|
|
context={"operation": "launch_game", "stage": "exited"},
|
|
attachment=crash_dump_attachment,
|
|
)
|
|
if as_json or dry_run:
|
|
api._emit(report, as_json)
|
|
else:
|
|
api._print_game_outcome(
|
|
outcome,
|
|
current_game_log,
|
|
runtime_issues,
|
|
current_game_crash,
|
|
)
|
|
return 0 if outcome.returncode == 0 and current_game_crash is None else 1
|
|
|
|
|
|
def session_duration_class(duration_seconds: float) -> str:
|
|
if duration_seconds < 30:
|
|
return "under_30_seconds"
|
|
if duration_seconds < 5 * 60:
|
|
return "under_5_minutes"
|
|
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 and the retained Proton 7 Python entry point return a negative child
|
|
# status from subprocess. SystemExit encodes -SIGKILL as 247 before our
|
|
# Python gamebox sees it.
|
|
wrapped_sigkill = 256 - int(signal.SIGKILL)
|
|
if (
|
|
runtime_kind in {"umu", "ge-proton", "proton7", "proton7-container"}
|
|
and returncode == wrapped_sigkill
|
|
):
|
|
wrapper = "proton" if runtime_kind == "proton7" else "umu"
|
|
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": f"{wrapper}_python_system_exit",
|
|
}
|
|
)
|
|
return metadata
|
|
|
|
|
|
def recommended_runtime_comparison(
|
|
runtime_kind: str,
|
|
runtime_issues: dict[str, object],
|
|
) -> str | None:
|
|
"""Choose a useful one-variable comparison from launch-relevant evidence."""
|
|
|
|
if (
|
|
runtime_kind == "proton7-container"
|
|
and runtime_issues.get("host_futex_fatal_count")
|
|
):
|
|
return "proton7"
|
|
if (
|
|
runtime_kind == "proton7"
|
|
and runtime_issues.get("gstreamer_missing_libraries")
|
|
and (
|
|
runtime_issues.get("gstreamer_stream_error") is True
|
|
or runtime_issues.get("game_window_ready") is not True
|
|
)
|
|
):
|
|
return "proton7-container"
|
|
if runtime_issues.get("dbghelp_symdeferred_assertion_count"):
|
|
return "umu"
|
|
return None
|
|
|
|
|
|
def game_failure_reason(
|
|
outcome: CommandOutcome,
|
|
current_game_log: dict[str, object] | None,
|
|
current_game_crash: dict[str, object] | None,
|
|
exit_metadata: dict[str, object],
|
|
) -> str:
|
|
"""Classify the strongest evidence recorded for a failed or degraded session."""
|
|
|
|
if current_game_log and current_game_log.get("runtime_out_of_memory") is True:
|
|
return "direct3d_resource_exhaustion"
|
|
if current_game_crash is not None:
|
|
return "client_crash_dump"
|
|
if outcome.timed_out:
|
|
return "session_timeout"
|
|
if "signal" in exit_metadata:
|
|
return "process_signal"
|
|
return "nonzero_exit"
|
|
|
|
|
|
def game_log_issue_reason(
|
|
current_game_log: dict[str, object] | None,
|
|
) -> str | None:
|
|
"""Classify native-log evidence that degraded an otherwise clean process exit."""
|
|
|
|
if current_game_log and current_game_log.get("runtime_out_of_memory") is True:
|
|
return "direct3d_resource_exhaustion"
|
|
return None
|
|
|
|
|
|
def game_crash_message(current_game_log: dict[str, object] | None) -> str:
|
|
if (
|
|
current_game_log is not None
|
|
and current_game_log.get("runtime_out_of_memory") is True
|
|
):
|
|
return "Game client logged Direct3D resource exhaustion and wrote a crash dump"
|
|
return "Game client wrote a crash dump"
|
|
|
|
|
|
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(
|
|
api: Any,
|
|
paths: AppPaths,
|
|
settings: Settings,
|
|
*,
|
|
dry_run: bool,
|
|
) -> UmuBootstrapResult | None:
|
|
"""Provide a pinned UMU when the selected policy may use a container."""
|
|
|
|
if settings.runtime == "proton7":
|
|
return None
|
|
if dry_run:
|
|
return None
|
|
try:
|
|
return api.provision_umu(paths)
|
|
except (OSError, RuntimeError) as exc:
|
|
if settings.runtime != "auto":
|
|
raise LauncherError(
|
|
f"the verified portable UMU runtime helper could not be installed: {exc}"
|
|
) from exc
|
|
try:
|
|
api.select_runtime(replace(settings, runtime="proton7"), paths=paths)
|
|
except RuntimeErrorPT:
|
|
raise LauncherError(
|
|
"no usable game runtime is available and the verified portable UMU "
|
|
f"runtime helper could not be installed: {exc}"
|
|
) from exc
|
|
print(
|
|
"Warning: the Steam Runtime helper could not be installed; falling back "
|
|
f"to direct Proton 7 for this launch ({exc}).",
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
|
|
|
|
def current_game_log(
|
|
api: Any,
|
|
directory: Path,
|
|
baseline: dict[str, LogFileState],
|
|
started_ns: int,
|
|
*,
|
|
managed_asset_paths: Sequence[str] | None = None,
|
|
source_game: Path | None = None,
|
|
) -> dict[str, object] | None:
|
|
state = api.newest_current_native_log(
|
|
directory,
|
|
baseline,
|
|
started_ns,
|
|
patterns=("log_*.txt",),
|
|
)
|
|
if state is None:
|
|
return None
|
|
analysis = api.parse_current_run_log(state, baseline)
|
|
asset_references = api.classify_asset_references(
|
|
analysis,
|
|
managed_asset_paths,
|
|
source_game=source_game,
|
|
)
|
|
return {
|
|
"path": str(state.path),
|
|
"modified_at": datetime.fromtimestamp(
|
|
state.mtime_ns / 1_000_000_000
|
|
).astimezone().isoformat(),
|
|
"size": state.size,
|
|
"load_texture_to_material_failed": analysis.load_texture_to_material_failed,
|
|
"import_scene_threw": analysis.import_scene_threw,
|
|
"asset_pack_health_failed": analysis.asset_pack_health_failed,
|
|
"managed_asset_integrity_failed": asset_references.managed_integrity_failed,
|
|
"asset_references": asset_references.to_dict(),
|
|
"runtime_out_of_memory": analysis.runtime_out_of_memory,
|
|
"out_of_memory_count": len(analysis.out_of_memory_events),
|
|
"out_of_memory_effect_paths": analysis.out_of_memory_effect_paths,
|
|
"effect_creation_failures": [
|
|
event.to_dict() for event in analysis.effect_creation_failures
|
|
],
|
|
"failed_import_scenes": analysis.failed_import_scenes,
|
|
"stage_without_new_stage": analysis.stage_without_new_stage,
|
|
"tex_reclaim_count": analysis.tex_reclaim_count,
|
|
"module_code_sync_error": analysis.module_code_sync_error,
|
|
"module_code_sync_left": analysis.module_code_sync_left,
|
|
"module_code_sync_right": analysis.module_code_sync_right,
|
|
"module_code_sync_delta": analysis.module_code_sync_delta,
|
|
"banned_code_1": analysis.banned_code_1,
|
|
"banned_code_1_in_native_log": analysis.banned_code_1,
|
|
"banned_code_1_observation_scope": (
|
|
"native-game-log-only; server-provided login-screen text is not "
|
|
"written to this log"
|
|
),
|
|
"failed_file_find_count": analysis.failed_file_find_count,
|
|
"failed_file_load_count": analysis.failed_file_load_count,
|
|
"failed_file_read_count": analysis.failed_file_read_count,
|
|
"failed_asset_file_count": analysis.failed_asset_file_count,
|
|
"failed_texture_file_count": analysis.failed_texture_file_count,
|
|
"failed_effect_file_count": analysis.failed_effect_file_count,
|
|
"missing_texture_reference_count": analysis.missing_texture_reference_count,
|
|
"missing_effect_reference_count": analysis.missing_effect_reference_count,
|
|
"missing_image_reference_count": analysis.missing_image_reference_count,
|
|
"missing_pack_image_reference_count": analysis.missing_pack_image_reference_count,
|
|
"stage_without_new_stage_events": [
|
|
event.to_dict()
|
|
for event in analysis.events
|
|
if event.kind == "stage_without_new_stage"
|
|
],
|
|
"scene_imports": {
|
|
name: [scene.to_dict() for scene in imports]
|
|
for name, imports in sorted(analysis.scenes.items())
|
|
},
|
|
"failed_file_count": len(analysis.failed_files),
|
|
"failed_files": [event.to_dict() for event in analysis.failed_files[:64]],
|
|
}
|
|
|
|
|
|
def current_game_crash(
|
|
api: Any,
|
|
directory: Path,
|
|
baseline: dict[str, LogFileState],
|
|
started_ns: int,
|
|
) -> dict[str, object] | None:
|
|
state = api.newest_current_native_log(
|
|
directory,
|
|
baseline,
|
|
started_ns,
|
|
patterns=("*.dat", "*.dmp"),
|
|
)
|
|
if state is None:
|
|
return None
|
|
report: dict[str, object] = {
|
|
"path": str(state.path),
|
|
"modified_at": datetime.fromtimestamp(
|
|
state.mtime_ns / 1_000_000_000
|
|
).astimezone().isoformat(),
|
|
"size": state.size,
|
|
}
|
|
minidump = inspect_minidump(state.path)
|
|
if minidump is not None:
|
|
report["minidump"] = minidump
|
|
return report
|
|
|
|
|
|
def game_log_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
|
|
"""Keep the native-log evidence useful while bounding telemetry cardinality."""
|
|
|
|
keys = (
|
|
"modified_at",
|
|
"size",
|
|
"load_texture_to_material_failed",
|
|
"import_scene_threw",
|
|
"asset_pack_health_failed",
|
|
"managed_asset_integrity_failed",
|
|
"runtime_out_of_memory",
|
|
"out_of_memory_count",
|
|
"out_of_memory_effect_paths",
|
|
"failed_import_scenes",
|
|
"stage_without_new_stage",
|
|
"tex_reclaim_count",
|
|
"module_code_sync_error",
|
|
"module_code_sync_left",
|
|
"module_code_sync_right",
|
|
"module_code_sync_delta",
|
|
"banned_code_1",
|
|
"failed_file_find_count",
|
|
"failed_file_load_count",
|
|
"failed_file_read_count",
|
|
"failed_asset_file_count",
|
|
"failed_texture_file_count",
|
|
"failed_effect_file_count",
|
|
"missing_texture_reference_count",
|
|
"missing_effect_reference_count",
|
|
"missing_image_reference_count",
|
|
"missing_pack_image_reference_count",
|
|
)
|
|
summary = {key: report[key] for key in keys if key in report}
|
|
asset_references = report.get("asset_references")
|
|
if isinstance(asset_references, dict):
|
|
asset_summary = {
|
|
key: asset_references[key]
|
|
for key in (
|
|
"total_events",
|
|
"unique_valid_references",
|
|
"invalid_reference_events",
|
|
"catalog_available",
|
|
"managed_integrity_failed",
|
|
)
|
|
if key in asset_references
|
|
}
|
|
for source_key, output_key in (
|
|
("managed_present_paths", "managed_present_count"),
|
|
("managed_missing_paths", "managed_missing_count"),
|
|
("publisher_unmanaged_paths", "publisher_unmanaged_count"),
|
|
("unclassified_paths", "unclassified_count"),
|
|
):
|
|
paths = asset_references.get(source_key)
|
|
if isinstance(paths, (list, tuple)):
|
|
asset_summary[output_key] = len(paths)
|
|
if asset_summary:
|
|
summary["asset_reference_summary"] = asset_summary
|
|
return summary
|
|
|
|
|
|
def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
|
|
"""Exclude the local dump path while retaining its bounded signature."""
|
|
|
|
keys = ("modified_at", "size", "minidump")
|
|
return {key: report[key] for key in keys if key in report}
|
|
|
|
|
|
def runtime_issue_summary(stderr: str) -> dict[str, object]:
|
|
folded = stderr.casefold()
|
|
futex_message = "the futex facility returned an unexpected error code"
|
|
wrong_elf_message = "wrong elf class: elfclass64"
|
|
dbghelp_assertion = "assertion failed: module->module.symtype != symdeferred"
|
|
bwrap_uid_map_denied = "bwrap: setting up uid map: permission denied"
|
|
missing_libraries = sorted(
|
|
{
|
|
library
|
|
for line in folded.splitlines()
|
|
if "gstreamer-warning" in line and "failed to load plugin" in line
|
|
for library in re.findall(
|
|
r"([a-z0-9_.+-]+\.so(?:\.\d+)*): cannot open shared object file",
|
|
line,
|
|
)
|
|
}
|
|
)
|
|
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),
|
|
"gstreamer_missing_libraries": missing_libraries,
|
|
"gstreamer_stream_error": "winegstreamer error" in folded,
|
|
"gstreamer_caps_error_count": sum(
|
|
"gst_caps_is_fixed (caps)" in line and "failed" in line
|
|
for line in folded.splitlines()
|
|
),
|
|
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
|
|
"gamescope_present_queue_unsupported": (
|
|
"physical device queue doesn't support presenting on our surface" in folded
|
|
),
|
|
"gamescope_vulkan_output_failed_count": folded.count(
|
|
"vulkan_make_output failed"
|
|
),
|
|
"gamescope_memory_protection_warning_count": folded.count(
|
|
"sd_bus_call(g_dbus, message, uint64_max, null, &reply)failed with ret"
|
|
),
|
|
"gamescope_priority_fallback": "no cap_sys_nice, falling back" in folded,
|
|
"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
|
|
),
|
|
"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),
|
|
"bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded,
|
|
}
|
|
|
|
|
|
def gamescope_presentation_failure(output: str) -> str | None:
|
|
folded = output.casefold()
|
|
for diagnostic, marker in (
|
|
(
|
|
"gamescope_present_queue_unsupported",
|
|
"physical device queue doesn't support presenting on our surface",
|
|
),
|
|
("gamescope_vulkan_output_failed", "vulkan_make_output failed"),
|
|
):
|
|
if marker in folded:
|
|
return diagnostic
|
|
return None
|
|
|
|
|
|
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 window_diagnostic_severity(diagnostic: dict[str, object]) -> str:
|
|
name = diagnostic.get("diagnostic")
|
|
details = diagnostic.get("details")
|
|
if isinstance(name, str) and (
|
|
name.endswith(("_failed", "_unavailable"))
|
|
or name in {
|
|
"host_hidden_state_cleared",
|
|
"host_inactive_remap",
|
|
"host_minimize_latched",
|
|
}
|
|
):
|
|
return "warning"
|
|
if isinstance(details, dict):
|
|
if name == "host_scaling_configured" and not all(
|
|
details.get(key) is True
|
|
for key in (
|
|
"resize_increments_applied",
|
|
"scaling_enabled",
|
|
"compositing_preferred",
|
|
)
|
|
):
|
|
return "warning"
|
|
if name == "scaling_configured" and details.get("enabled") is not True:
|
|
return "warning"
|
|
return "debug"
|
|
|
|
|
|
def print_game_outcome(
|
|
outcome: CommandOutcome,
|
|
current_game_log: dict[str, object] | None,
|
|
runtime_issues: dict[str, object],
|
|
current_game_crash: dict[str, object] | None = None,
|
|
) -> None:
|
|
# Keep the human-facing stream concise. CommandRunner already relayed every
|
|
# process line live; replaying its capture here made each Proton failure appear
|
|
# twice in the dashboard.
|
|
if current_game_crash is not None:
|
|
crash_path = current_game_crash.get("path")
|
|
crash_path_text = crash_path if isinstance(crash_path, str) else "unknown"
|
|
print(
|
|
f"Game client crashed and wrote a dump: {crash_path_text}",
|
|
file=sys.stderr,
|
|
)
|
|
elif outcome.timed_out:
|
|
print("Game client timed out and was stopped.", file=sys.stderr)
|
|
elif (
|
|
outcome.returncode == 0
|
|
and game_log_issue_reason(current_game_log)
|
|
== "direct3d_resource_exhaustion"
|
|
):
|
|
print(
|
|
"Game client exited with status 0 after logging Direct3D resource "
|
|
"exhaustion.",
|
|
file=sys.stderr,
|
|
)
|
|
elif outcome.returncode == 0:
|
|
print("Game client exited normally.")
|
|
elif outcome.returncode is None:
|
|
print("Game client exited without a status.", file=sys.stderr)
|
|
else:
|
|
print(f"Game client exited with status {outcome.returncode}.", file=sys.stderr)
|
|
if current_game_log is not None:
|
|
print_interrupted_game_log(current_game_log)
|
|
wrong_elf_count = runtime_issues["gstreamer_wrong_elf_class_count"]
|
|
if isinstance(wrong_elf_count, int) and wrong_elf_count:
|
|
print(
|
|
"The repeated GStreamer wrong-ELF-class messages are plugin-scan noise; "
|
|
"they do not mean that Proton itself failed to start.",
|
|
file=sys.stderr,
|
|
)
|
|
missing_libraries = runtime_issues["gstreamer_missing_libraries"]
|
|
if isinstance(missing_libraries, list) and missing_libraries:
|
|
print(
|
|
"Media plugins could not load these shared libraries: "
|
|
+ ", ".join(str(value) for value in missing_libraries)
|
|
+ ".",
|
|
file=sys.stderr,
|
|
)
|
|
assertion_count = runtime_issues["dbghelp_symdeferred_assertion_count"]
|
|
if isinstance(assertion_count, int) and assertion_count:
|
|
print(
|
|
"Proton 7's older Wine build hit its dbghelp SymDeferred assertion; this "
|
|
"can mask the client failure that occurred immediately before it.",
|
|
file=sys.stderr,
|
|
)
|
|
if runtime_issues["bubblewrap_uid_map_permission_denied"]:
|
|
print(
|
|
"Ubuntu denied Bubblewrap's UID map before Proton started. Run "
|
|
"`pristontale-eu container-host --repair` and retry.",
|
|
file=sys.stderr,
|
|
)
|
|
recommendation = runtime_issues.get("recommended_one_variable_comparison")
|
|
if isinstance(recommendation, str):
|
|
print(
|
|
f"Recommended single-variable runtime comparison: {recommendation}.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def print_interrupted_game_log(report: dict[str, object]) -> None:
|
|
log_path = report.get("path")
|
|
log_path_text = log_path if isinstance(log_path, str) else "unknown"
|
|
print(f"\nCurrent game log: {log_path_text}", file=sys.stderr)
|
|
asset_references = report.get("asset_references")
|
|
catalog_available = (
|
|
asset_references.get("catalog_available")
|
|
if isinstance(asset_references, dict)
|
|
else False
|
|
)
|
|
if report["module_code_sync_error"]:
|
|
print(
|
|
"Detected a publisher module-integrity mismatch; the game was launched "
|
|
"with publisher-pristine code in this launcher version.",
|
|
file=sys.stderr,
|
|
)
|
|
elif report["runtime_out_of_memory"]:
|
|
effects = report.get("out_of_memory_effect_paths")
|
|
effect_text = ""
|
|
if isinstance(effects, list) and effects:
|
|
effect_text = " while creating " + ", ".join(str(path) for path in effects[:3])
|
|
print(
|
|
"Detected Direct3D resource exhaustion in the 32-bit game process"
|
|
+ effect_text
|
|
+ ". Restarting clears the process address space, but this condition can "
|
|
"recur; depot repair will not fix it.",
|
|
file=sys.stderr,
|
|
)
|
|
elif report["managed_asset_integrity_failed"]:
|
|
print(
|
|
"The game reported an asset that belongs to the publisher manifest but is "
|
|
"missing from the managed installation.",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
"Run `pristontale-eu update --repair` and restart.",
|
|
file=sys.stderr,
|
|
)
|
|
elif report["asset_pack_health_failed"] and not catalog_available:
|
|
print(
|
|
"The game reported many unresolved asset paths, but no matching cached "
|
|
"publisher manifest was available to classify them. Run "
|
|
"`pristontale-eu update` to refresh the manifest cache.",
|
|
file=sys.stderr,
|
|
)
|
|
elif report["load_texture_to_material_failed"]:
|
|
print(
|
|
"Detected a texture/material failure while loading the game world.",
|
|
file=sys.stderr,
|
|
)
|
|
elif report["import_scene_threw"] or report["failed_import_scenes"]:
|
|
print("Detected a game-world scene import failure.", file=sys.stderr)
|
|
|
|
|
|
def login_trace(
|
|
paths: AppPaths,
|
|
game_dir: Path | None,
|
|
no_box: bool,
|
|
skip_runtime_setup: bool,
|
|
timeout_seconds: float,
|
|
as_json: bool,
|
|
) -> int:
|
|
# Preserve the command name so old instructions fail safely before touching
|
|
# the prefix, preparing the runtime view, or starting Game.exe.
|
|
_ = paths, game_dir, no_box, skip_runtime_setup, timeout_seconds, as_json
|
|
raise RuntimeError(LOGIN_TRACE_DISABLED_REASON)
|