Files
pristontale-eu-linux/src/ptlinux/application/commands/play.py

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)