"""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)