diff --git a/ptlinux/cli.py b/ptlinux/cli.py index ee3b197..f189511 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -9,8 +9,16 @@ from datetime import datetime import json from pathlib import Path import sys +import time from typing import Sequence +from ptdiag.logs import ( + LogFileState, + newest_current_native_log, + parse_current_run_log, + snapshot_native_logs, +) + from . import __version__ from .command import CommandError, CommandRunner from .depot import SyncProgress, sync_managed_game @@ -26,7 +34,8 @@ from .launcher import ( ) from .locking import launcher_operation_lock from .login_diagnostics import inspect_login_environment -from .overlay import cache_compatibility_binaries, cache_runtime_patch, prepare_runtime_view +from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, run_login_trace +from .overlay import cache_compatibility_binaries, prepare_runtime_view from .paths import AppPaths from .runtime import ( prepare_game_runtime, @@ -122,20 +131,32 @@ def build_parser() -> argparse.ArgumentParser: help="cache one exact verified Game.exe/game.dll compatibility pair", ) compatibility.add_argument("source", type=Path) - runtime_patch = subparsers.add_parser( - "cache-runtime-patch", - help="cache the exact verified V21 texture-reclaim game.dll", - ) - runtime_patch.add_argument("source", type=Path) - prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix") prepare.add_argument("--game-dir", type=Path) - play = subparsers.add_parser("play", help="launch the game in the clean Gamescope box") + play = subparsers.add_parser("play", help="launch the game in the managed Proton window") play.add_argument("--game-dir", type=Path) - play.add_argument("--no-box", action="store_true", help="launch without Gamescope") + play.add_argument("--no-box", action="store_true", help="launch without X11 window styling") + play.add_argument( + "--publisher-dll", + action="store_true", + help="diagnostic fallback: disable the post-authentication world fix", + ) play.add_argument("--dry-run", action="store_true") play.add_argument("--skip-runtime-setup", action="store_true") + login_trace = subparsers.add_parser( + "login-trace", + help="trace the post-authentication account check without retaining credentials", + ) + login_trace.add_argument("--game-dir", type=Path) + login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling") + login_trace.add_argument("--skip-runtime-setup", action="store_true") + login_trace.add_argument( + "--timeout", + type=float, + default=DEFAULT_TRACE_TIMEOUT_SECONDS, + help="maximum game runtime in seconds (default: 600)", + ) settings = subparsers.add_parser("settings", help="show or initialize settings") settings.add_argument("--init", action="store_true", help="write conservative defaults") @@ -229,10 +250,6 @@ def main(argv: Sequence[str] | None = None) -> int: result = cache_compatibility_binaries(paths, arguments.source) _emit({"ok": True, "compatibility": asdict(result)}, arguments.json) return 0 - if command == "cache-runtime-patch": - result = cache_runtime_patch(paths, arguments.source) - _emit({"ok": True, "runtime_patch": asdict(result)}, arguments.json) - return 0 if command == "prepare": return _prepare(paths, arguments.game_dir, arguments.json) if command == "play": @@ -240,10 +257,20 @@ def main(argv: Sequence[str] | None = None) -> int: paths, arguments.game_dir, arguments.no_box, + arguments.publisher_dll, arguments.dry_run, arguments.skip_runtime_setup, arguments.json, ) + if command == "login-trace": + return _login_trace( + paths, + arguments.game_dir, + arguments.no_box, + arguments.skip_runtime_setup, + arguments.timeout, + arguments.json, + ) if command == "settings": return _settings(paths, arguments.init, arguments.json) if command == "launcher-update": @@ -256,6 +283,13 @@ def main(argv: Sequence[str] | None = None) -> int: arguments.json, ) parser.error(f"unknown command: {command}") + except KeyboardInterrupt: + message = "Interrupted; PristonTale and its compatibility runtime were stopped." + if arguments.json: + _print_json({"ok": False, "error": message, "interrupted": True}) + else: + print(f"\n{message}", file=sys.stderr) + return 130 except (ValueError, OSError, RuntimeError) as exc: if arguments.json: _print_json({"ok": False, "error": str(exc)}) @@ -561,6 +595,28 @@ def _play( paths: AppPaths, game_dir: Path | None, no_box: bool, + publisher_dll: bool, + dry_run: bool, + skip_runtime_setup: bool, + as_json: bool, +) -> int: + with _operation_guard(paths, "play", dry_run): + return _play_locked( + paths, + game_dir, + no_box, + publisher_dll, + dry_run, + skip_runtime_setup, + as_json, + ) + + +def _play_locked( + paths: AppPaths, + game_dir: Path | None, + no_box: bool, + publisher_dll: bool, dry_run: bool, skip_runtime_setup: bool, as_json: bool, @@ -570,7 +626,11 @@ def _play( overlay = None _print_operation_status(as_json, 1, 4, "Preparing the managed game view…") if not dry_run: - overlay = prepare_runtime_view(paths, source) + overlay = prepare_runtime_view( + paths, + source, + use_world_fix=not publisher_dll, + ) _print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…") selection = select_runtime(settings) runner = _runner(paths, "play", dry_run) @@ -579,12 +639,33 @@ def _play( _print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…") setup_results = prepare_game_runtime(paths, selection, runner) _print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…") - outcome = run_game( - paths, - settings, - selection, - runner, - use_gamebox=not no_box, + native_log_directory = paths.game_save / "logs" + native_log_baseline = snapshot_native_logs( + native_log_directory, + patterns=("log_*.txt",), + ) + game_started_ns = time.time_ns() + try: + outcome = run_game( + paths, + settings, + selection, + runner, + use_gamebox=not no_box, + ) + except KeyboardInterrupt: + current_game_log = _current_game_log( + native_log_directory, + native_log_baseline, + game_started_ns, + ) + if current_game_log is not None and not as_json: + _print_interrupted_game_log(current_game_log) + raise + current_game_log = _current_game_log( + native_log_directory, + native_log_baseline, + game_started_ns, ) _emit( { @@ -594,12 +675,90 @@ def _play( "runtime_view": asdict(overlay) if overlay else None, "setup_commands": [asdict(result) for result in setup_results], "game": asdict(outcome), + "current_game_log": current_game_log, }, as_json, ) return 0 if outcome.returncode == 0 else 1 +def _current_game_log( + directory: Path, + baseline: dict[str, LogFileState], + started_ns: int, +) -> dict[str, object] | None: + state = newest_current_native_log( + directory, + baseline, + started_ns, + patterns=("log_*.txt",), + ) + if state is None: + return None + analysis = parse_current_run_log(state, baseline) + 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, + "failed_import_scenes": analysis.failed_import_scenes, + "stage_without_new_stage": analysis.stage_without_new_stage, + "failed_file_count": len(analysis.failed_files), + } + + +def _print_interrupted_game_log(report: dict[str, object]) -> None: + print(f"\nCurrent game log: {report['path']}", file=sys.stderr) + if 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: + settings = _load_or_default_settings(paths, persist=True) + source = _source_game(paths, settings, game_dir) + runner = _runner(paths, "login-trace", False) + with _operation_guard(paths, "login-trace", False): + _print_operation_status(as_json, 1, 4, "Preparing the managed game view…") + prepare_runtime_view(paths, source) + _print_operation_status(as_json, 2, 4, "Selecting the configured Proton runtime…") + selection = select_runtime(settings) + if not skip_runtime_setup: + _print_operation_status(as_json, 3, 4, "Checking Proton and DirectX components…") + prepare_game_runtime(paths, selection, runner) + _print_operation_status(as_json, 4, 4, "Starting the privacy-safe login trace…") + if not as_json: + print( + "Enter the account name with its registered capitalization, press Login, " + "leave the approval screen visible for five seconds, then close the game.", + flush=True, + ) + report = run_login_trace( + paths, + settings, + selection, + runner, + use_gamebox=not no_box, + timeout_seconds=timeout_seconds, + ) + _emit({"ok": True, "login_trace": report.to_dict()}, as_json) + return 0 + + def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int: if initialize: save_settings(paths, Settings()) @@ -665,14 +824,18 @@ def _launcher_update( def _source_game( paths: AppPaths, - settings: Settings, + _settings: Settings, argument: Path | None, ) -> Path: + """Select the managed game unless an explicit diagnostic path was supplied. + + The persisted ``game_source`` setting is an import seed only. It must not + redirect normal Play, Prepare, or Login Trace operations away from the + launcher-managed tree. + """ + if argument is not None: return argument.expanduser().resolve() - source_override = settings.source_override - if source_override is not None: - return source_override return paths.source_game diff --git a/ptlinux/locking.py b/ptlinux/locking.py index 38404b6..54c17e3 100644 --- a/ptlinux/locking.py +++ b/ptlinux/locking.py @@ -24,7 +24,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]: handle.seek(0) owner = handle.read().strip() or "unknown operation" raise OperationBusyError( - f"launcher files are busy ({owner}); close the updater and try again" + f"launcher files are busy ({owner}); wait for it to finish and try again" ) from exc handle.seek(0) handle.truncate()