diff --git a/packaging/pristontale-eu b/packaging/pristontale-eu index 574356f..9012f58 100755 --- a/packaging/pristontale-eu +++ b/packaging/pristontale-eu @@ -31,11 +31,10 @@ then exit 1 fi -if [ -n "${PYTHONPATH:-}" ] -then - export PYTHONPATH="${release_root}:${PYTHONPATH}" -else - export PYTHONPATH="${release_root}" -fi +# Never let an unpacked older installer in the caller's current directory +# shadow the selected installed release. Children started by the dashboard +# inherit the same safe-path policy and exact package root. +export PYTHONSAFEPATH=1 +export PYTHONPATH="${release_root}" -exec "$python_bin" -m ptlinux "$@" +exec "$python_bin" -P -m ptlinux "$@" diff --git a/ptlinux/__init__.py b/ptlinux/__init__.py index 0f49a83..14db81f 100644 --- a/ptlinux/__init__.py +++ b/ptlinux/__init__.py @@ -4,4 +4,4 @@ from .paths import AppPaths __all__ = ["AppPaths", "__version__"] -__version__ = "0.5.11" +__version__ = "0.5.13" diff --git a/ptlinux/cli.py b/ptlinux/cli.py index 2e93e4a..82d0a37 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -353,6 +353,15 @@ def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) - print(f"game.dll: {report.game_dll}") if report.game_dll_sha256: print(f"DLL SHA-256: {report.game_dll_sha256}") + print( + "Managed source: " + + ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS") + ) + if report.depot_build is not None: + print( + f"Depot: build {report.depot_build} " + f"({report.depot_version or 'unknown version'})" + ) world_fix_status = "enabled" if report.world_fix_enabled else "retired" print(f"World fix: {world_fix_status}") if report.world_fix_disabled_reason: @@ -676,6 +685,12 @@ def _play_locked( native_log_baseline, game_started_ns, ) + runtime_issues = _runtime_issue_summary(outcome.stderr) + if ( + selection.kind == "proton7-container" + and runtime_issues["host_futex_fatal_count"] + ): + runtime_issues["recommended_one_variable_comparison"] = "proton7" world_fix_status = None _emit( { @@ -686,6 +701,7 @@ def _play_locked( "setup_commands": [asdict(result) for result in setup_results], "game": asdict(outcome), "current_game_log": current_game_log, + "runtime_issues": runtime_issues, "world_fix": world_fix_status, }, as_json, @@ -736,6 +752,19 @@ def _current_game_log( } +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" + return { + "host_futex_fatal_count": folded.count(futex_message), + "gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message), + "gstreamer_stream_error": "winegstreamer error" in folded, + "mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded, + "steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded, + } + + def _print_interrupted_game_log(report: dict[str, object]) -> None: print(f"\nCurrent game log: {report['path']}", file=sys.stderr) if report["module_code_sync_error"]: diff --git a/ptlinux/command.py b/ptlinux/command.py index 6b4c1c5..4118237 100644 --- a/ptlinux/command.py +++ b/ptlinux/command.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import deque from dataclasses import asdict, dataclass from datetime import datetime import json @@ -16,6 +17,44 @@ from typing import Callable, Mapping, Sequence, TextIO OutputLineFilter = Callable[[str], str | None] +MAX_CAPTURE_CHARACTERS = 1024 * 1024 + + +class BoundedTextCapture: + """Retain the newest subprocess output without unbounded memory growth.""" + + def __init__(self, max_characters: int = MAX_CAPTURE_CHARACTERS) -> None: + if max_characters <= 0: + raise ValueError("capture size must be positive") + self._max_characters = max_characters + self._parts: deque[str] = deque() + self._characters = 0 + self._lock = threading.Lock() + + def append(self, value: str) -> None: + if not value: + return + with self._lock: + if len(value) >= self._max_characters: + self._parts.clear() + self._parts.append(value[-self._max_characters:]) + self._characters = self._max_characters + return + self._parts.append(value) + self._characters += len(value) + while self._characters > self._max_characters: + overflow = self._characters - self._max_characters + oldest = self._parts[0] + if len(oldest) <= overflow: + self._parts.popleft() + self._characters -= len(oldest) + else: + self._parts[0] = oldest[overflow:] + self._characters -= overflow + + def getvalue(self) -> str: + with self._lock: + return "".join(self._parts) @dataclass(frozen=True) @@ -103,8 +142,8 @@ class CommandRunner: outcome = CommandOutcome( argv=arguments, returncode=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, + stdout=_bounded_text(completed.stdout), + stderr=_bounded_text(completed.stderr), duration_seconds=time.monotonic() - started, timed_out=False, dry_run=False, @@ -113,8 +152,8 @@ class CommandRunner: outcome = CommandOutcome( argv=arguments, returncode=None, - stdout=_as_text(exc.stdout), - stderr=_as_text(exc.stderr), + stdout=_bounded_text(_as_text(exc.stdout)), + stderr=_bounded_text(_as_text(exc.stderr)), duration_seconds=time.monotonic() - started, timed_out=True, dry_run=False, @@ -144,12 +183,12 @@ class CommandRunner: bufsize=1, start_new_session=True, ) - stdout: list[str] = [] - stderr: list[str] = [] + stdout = BoundedTextCapture() + stderr = BoundedTextCapture() def relay( stream: TextIO, - capture: list[str], + capture: BoundedTextCapture, destination: TextIO, ) -> None: try: @@ -196,8 +235,8 @@ class CommandRunner: return CommandOutcome( argv=arguments, returncode=None if timed_out else returncode, - stdout="".join(stdout), - stderr="".join(stderr), + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), duration_seconds=time.monotonic() - started, timed_out=timed_out, dry_run=False, @@ -228,6 +267,10 @@ def _as_text(value: bytes | str | None) -> str: return value +def _bounded_text(value: str) -> str: + return value[-MAX_CAPTURE_CHARACTERS:] + + def _terminate_process_group( process: subprocess.Popen[str], *, diff --git a/ptlinux/depot.py b/ptlinux/depot.py index f2bfc84..7ba87e7 100644 --- a/ptlinux/depot.py +++ b/ptlinux/depot.py @@ -38,6 +38,9 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0) PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 +ALWAYS_VERIFY_PATHS = frozenset( + {"game.exe", "game.dll", "gamese.exe", "gamese.dll"} +) SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) @@ -453,7 +456,11 @@ def plan_update( continue except OSError as exc: raise DepotError(f"cannot inspect {file.path}: {exc}") from exc - if repair or file.build == snapshot.build: + if ( + repair + or file.build == snapshot.build + or file.path.casefold() in ALWAYS_VERIFY_PATHS + ): if _sha1(target).casefold() != file.sha1.casefold(): downloads.append(PlannedDownload(file, "hash mismatch")) diff --git a/ptlinux/doctor.py b/ptlinux/doctor.py index 2c18dd5..a4980aa 100644 --- a/ptlinux/doctor.py +++ b/ptlinux/doctor.py @@ -98,6 +98,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: "Vulkan diagnostics", ), ] + tools.extend(_platform_optional_tools(distribution, architecture=platform.machine())) warnings: list[str] = [] if platform.machine().casefold() not in {"x86_64", "amd64"}: warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.") @@ -107,6 +108,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: warnings.append( "No game runtime was found: install umu-launcher or Steam Proton 7.0." ) + warnings.extend(_arch_runtime_warnings(distribution, platform.machine())) required_ok = all(item.path is not None for item in tools if item.required) return DoctorReport( ok=required_ok and not any("No game runtime" in warning for warning in warnings), @@ -158,7 +160,7 @@ def _package_hint(distribution: str) -> str | None: if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}: return ( "Install python, tk, wine, winetricks, umu-launcher, cabextract, " - "openssl, and the matching lib32 Vulkan driver with Pacman." + "openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman." ) if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}: return ( @@ -182,3 +184,55 @@ def _has_steam_proton7(environment: Mapping[str, str]) -> bool: if (steam_root / "steamapps/common/Proton 7.0/proton").is_file(): return True return False + + +def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]: + if architecture.casefold() not in {"x86_64", "amd64"}: + return [] + if distribution in {"arch", "cachyos", "endeavouros", "manjaro", "garuda"}: + return [ + _optional_tool_status( + "lib32-vulkan", + "/usr/lib32/libvulkan.so.1", + "32-bit Vulkan runtime compatibility layer", + ), + _optional_tool_status( + "lib32-gstreamer", + "/usr/lib32/gstreamer-1.0", + "32-bit gstreamer runtime compatibility modules", + ), + ] + return [] + + +def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]: + if distribution not in {"arch", "cachyos", "endeavouros", "manjaro", "garuda"}: + return [] + if architecture.casefold() not in {"x86_64", "amd64"}: + return [ + "Non-x86_64 Linux hosts are not a supported runtime architecture for this game." + ] + warnings: list[str] = [] + if not _path_exists("/usr/lib32/libvulkan.so.1"): + warnings.append( + "Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit " + "Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK." + ) + if not _path_exists("/usr/lib32/gstreamer-1.0"): + warnings.append( + "Install lib32-gstreamer and lib32-gst-plugins-base if the login video or " + "other media does not play. Missing media plugins are not evidence of an " + "authentication failure." + ) + return warnings + + +def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus: + if _path_exists(path): + return ToolStatus(name, path, required=False, purpose=purpose) + return ToolStatus(name, None, required=False, purpose=purpose) + + +def _path_exists(path: str) -> bool: + entry = Path(path) + return entry.is_file() or entry.is_dir() diff --git a/ptlinux/login_diagnostics.py b/ptlinux/login_diagnostics.py index a2e423b..c56b734 100644 --- a/ptlinux/login_diagnostics.py +++ b/ptlinux/login_diagnostics.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime import hashlib +import json from pathlib import Path import platform import socket @@ -17,6 +18,7 @@ from .overlay import ( COMPATIBILITY_BINARY_HASHES, CURRENT_V21_BINARY_HASHES, PATCHED_V21_GAME_DLL_SHA256, + RETIRED_POST_AUTH_GAME_DLL_SHA256, ) from .paths import AppPaths from .runtime import ( @@ -41,9 +43,6 @@ DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0 VERIFIED_GAME_PROFILES = frozenset( {"v21.75.83", "verified-compatibility"} ) -RETIRED_POST_AUTH_GAME_DLL_SHA256 = ( - "93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd" -) class _Closable(Protocol): @@ -82,7 +81,13 @@ class LoginDiagnosticReport: game_exe_sha256: str | None game_dll: str game_dll_sha256: str | None + source_game_exe_sha256: str | None + source_game_dll_sha256: str | None + runtime_matches_managed_source: bool game_profile: str + depot_build: int | None + depot_version: str | None + depot_completed_at: str | None world_fix_helper: str world_fix_helper_sha256: str | None world_fix_helper_ready: bool @@ -168,7 +173,16 @@ def inspect_login_environment( game_dll = game_root / "game.dll" game_exe_sha256 = _sha256_if_file(game_exe) game_dll_sha256 = _sha256_if_file(game_dll) + source_game_exe_sha256 = _sha256_if_file(paths.source_game / "Game.exe") + source_game_dll_sha256 = _sha256_if_file(paths.source_game / "game.dll") + runtime_matches_managed_source = ( + game_exe_sha256 is not None + and game_dll_sha256 is not None + and game_exe_sha256 == source_game_exe_sha256 + and game_dll_sha256 == source_game_dll_sha256 + ) game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256) + depot_state = _read_json_object(paths.native_depot_state) world_fix_helper = packaged_world_fix_helper() world_fix_helper_sha256 = _sha256_if_file(world_fix_helper) world_fix_helper_ready = ( @@ -186,11 +200,13 @@ def inspect_login_environment( prefix_ready, directx_ready, latest_log, + runtime_matches_managed_source, ) return LoginDiagnosticReport( preflight_ok=( probe.reachable and game_profile in VERIFIED_GAME_PROFILES + and runtime_matches_managed_source and selection is not None and prefix_ready and directx_ready @@ -208,7 +224,13 @@ def inspect_login_environment( game_exe_sha256=game_exe_sha256, game_dll=str(game_dll), game_dll_sha256=game_dll_sha256, + source_game_exe_sha256=source_game_exe_sha256, + source_game_dll_sha256=source_game_dll_sha256, + runtime_matches_managed_source=runtime_matches_managed_source, game_profile=game_profile, + depot_build=_integer_or_none(depot_state.get("build")), + depot_version=_string_or_none(depot_state.get("version")), + depot_completed_at=_string_or_none(depot_state.get("completed_at")), world_fix_helper=str(world_fix_helper), world_fix_helper_sha256=world_fix_helper_sha256, world_fix_helper_ready=world_fix_helper_ready, @@ -331,6 +353,7 @@ def _diagnostic_warnings( prefix_ready: bool, directx_ready: bool, latest_log: Path | None, + runtime_matches_managed_source: bool, ) -> tuple[str, ...]: warnings: list[str] = [] if not probe.reachable: @@ -356,6 +379,11 @@ def _diagnostic_warnings( "The retired 0.5.8 disk-patched game.dll is active and can stall login; " "run prepare or launch with the current launcher to restore the publisher DLL." ) + if not runtime_matches_managed_source: + warnings.append( + "The runtime Game.exe/game.dll pair does not match the managed updater " + "source; run prepare after update --repair before testing login again." + ) if runtime_error is not None: warnings.append(runtime_error) elif not prefix_ready: @@ -367,6 +395,24 @@ def _diagnostic_warnings( return tuple(warnings) +def _read_json_object(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return {} + return value if isinstance(value, dict) else {} + + +def _integer_or_none(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def _string_or_none(value: object) -> str | None: + return value if isinstance(value, str) else None + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: diff --git a/ptlinux/login_trace.py b/ptlinux/login_trace.py index 9048c55..6fe6079 100644 --- a/ptlinux/login_trace.py +++ b/ptlinux/login_trace.py @@ -25,6 +25,13 @@ from .runtime import ( ) from .settings import Settings +from ptdiag.logs import ( + LogFileState, + newest_current_native_log, + parse_current_run_log, + snapshot_native_logs, +) + RELAY_REGISTRY_KEY = r"HKCU\Software\Wine\Debug" RELAY_REGISTRY_VALUE = "RelayInclude" @@ -33,7 +40,7 @@ GAME_DLL_LOGIN_COMPARE_RETURN_OFFSET = 0x79F157 DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0 _LOAD_GAME_DLL = re.compile( - r'Loaded L"[^"\r\n]*(?:\\|/)game\.dll" at (?P[0-9a-f]+):', + r'Loaded L"[^"\r\n]*[\\/]game\.dll" at (?P[0-9a-f]+):', re.IGNORECASE, ) _COMPARE_RETURN = re.compile( @@ -78,6 +85,21 @@ class LoginTraceReport: native_log_before_modified_at: str | None native_log_after: str | None native_log_after_modified_at: str | None + current_game_log: str | None + current_game_log_modified_at: str | None + failed_file_count: int + failed_import_scenes: tuple[str, ...] + load_texture_to_material_failed: bool + import_scene_threw: bool + stage_without_new_stage: bool + module_code_sync_error: bool + module_code_sync_left: int | None + module_code_sync_right: int | None + module_code_sync_delta: int | None + banned_code_1: bool + runtime_futex_error: bool + gstreamer_wrong_elf_class: bool + gstreamer_stream_error: bool game_returncode: int | None game_timed_out: bool game_duration_seconds: float @@ -121,22 +143,41 @@ def run_login_trace( raise RuntimeError("the selected Proton 7 runtime path is incomplete") started = datetime.now().astimezone() + started_ns = int(started.timestamp() * 1_000_000_000) result_directory = _create_result_directory(paths, started) game_exe = paths.runtime_view / "Game.exe" game_dll = paths.runtime_view / "game.dll" log_before, log_before_modified = _native_log_snapshot(paths, settings) + native_log_directory = paths.game_save / "logs" + native_log_baseline = snapshot_native_logs( + native_log_directory, + patterns=("log_*.txt",), + ) registry_before = _query_relay_filter(paths, selection, runner) registry_restored = False trace = RelayTraceResult(None, None, (), 0) outcome: CommandOutcome | None = None raw_directory_path: Path | None = None + current_log_path = None + current_log_modified_at = None + current_log_failure_count = 0 + current_failed_import_scenes: list[str] = [] + current_load_texture_to_material_failed = False + current_import_scene_threw = False + current_stage_without_new_stage = False + current_module_code_sync_error = False + current_module_code_sync_left = None + current_module_code_sync_right = None + current_module_code_sync_delta = None + current_banned_code_1 = False paths.cache.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory( prefix="ptlinux-login-relay-", dir=paths.cache, ) as raw_directory: - raw_directory_path = Path(raw_directory) + raw_path = Path(raw_directory) + raw_directory_path = raw_path try: _set_relay_filter(paths, selection, runner, RELAY_INCLUDE_FILTER) outcome = run_game( @@ -150,14 +191,14 @@ def run_login_trace( check=False, extra_environment={ "PROTON_LOG": "1", - "PROTON_LOG_DIR": str(raw_directory_path), + "PROTON_LOG_DIR": str(raw_path), "SteamAppId": "0", "SteamGameId": "0", "WINEDEBUG": "+relay,+loaddll", }, output_line_filter=redact_relay_output_line, ) - trace = parse_relay_logs(raw_directory_path.rglob("*.log")) + trace = parse_relay_logs(raw_path.rglob("*.log")) finally: registry_restored = _restore_relay_filter( paths, @@ -168,6 +209,52 @@ def run_login_trace( if outcome is None: raise RuntimeError("the instrumented game process did not start") + current_game_log = _current_game_log( + native_log_directory, + native_log_baseline, + started_ns, + ) + if current_game_log is not None: + current_log_path = _string_or_none(current_game_log.get("path")) + current_log_modified_at = _string_or_none( + current_game_log.get("modified_at") + ) + current_log_failure_count = _integer_or_zero( + current_game_log.get("failed_file_count") + ) + failed_scenes = current_game_log.get("failed_import_scenes") + if isinstance(failed_scenes, (list, tuple)): + current_failed_import_scenes = [ + scene for scene in failed_scenes if isinstance(scene, str) + ] + current_load_texture_to_material_failed = bool( + current_game_log.get("load_texture_to_material_failed") + ) + current_import_scene_threw = bool(current_game_log.get("import_scene_threw")) + current_stage_without_new_stage = bool( + current_game_log.get("stage_without_new_stage") + ) + current_module_code_sync_error = bool( + current_game_log.get("module_code_sync_error") + ) + current_module_code_sync_left = _integer_or_none( + current_game_log.get("module_code_sync_left") + ) + current_module_code_sync_right = _integer_or_none( + current_game_log.get("module_code_sync_right") + ) + current_module_code_sync_delta = _integer_or_none( + current_game_log.get("module_code_sync_delta") + ) + current_banned_code_1 = bool(current_game_log.get("banned_code_1")) + runtime_futex_error = "futex facility returned an unexpected error code" in ( + outcome.stderr.casefold() + ) + gstreamer_wrong_elf_class = ( + "gstreamer-warning" in outcome.stderr.casefold() + and "wrong elf class" in outcome.stderr.casefold() + ) + gstreamer_stream_error = "winegstreamer error" in outcome.stderr.casefold() raw_relay_deleted = raw_directory_path is not None and not raw_directory_path.exists() log_after, log_after_modified = _native_log_snapshot(paths, settings) comparison_observed = bool(trace.comparison_results) @@ -176,6 +263,9 @@ def run_login_trace( ) conclusion, warnings = _conclusion( trace, + module_code_sync_error=current_module_code_sync_error, + banned_code_1=current_banned_code_1, + runtime_futex_error=runtime_futex_error, registry_restored=registry_restored, raw_relay_deleted=raw_relay_deleted, timed_out=outcome.timed_out, @@ -200,6 +290,21 @@ def run_login_trace( native_log_before_modified_at=log_before_modified, native_log_after=log_after, native_log_after_modified_at=log_after_modified, + current_game_log=current_log_path, + current_game_log_modified_at=current_log_modified_at, + failed_file_count=current_log_failure_count, + failed_import_scenes=tuple(current_failed_import_scenes), + load_texture_to_material_failed=current_load_texture_to_material_failed, + import_scene_threw=current_import_scene_threw, + stage_without_new_stage=current_stage_without_new_stage, + module_code_sync_error=current_module_code_sync_error, + module_code_sync_left=current_module_code_sync_left, + module_code_sync_right=current_module_code_sync_right, + module_code_sync_delta=current_module_code_sync_delta, + banned_code_1=current_banned_code_1, + runtime_futex_error=runtime_futex_error, + gstreamer_wrong_elf_class=gstreamer_wrong_elf_class, + gstreamer_stream_error=gstreamer_stream_error, game_returncode=outcome.returncode, game_timed_out=outcome.timed_out, game_duration_seconds=round(outcome.duration_seconds, 3), @@ -393,15 +498,67 @@ def _native_log_snapshot( return str(path), modified +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(), + "failed_file_count": len(analysis.failed_files), + "failed_import_scenes": tuple(analysis.failed_import_scenes), + "load_texture_to_material_failed": analysis.load_texture_to_material_failed, + "import_scene_threw": analysis.import_scene_threw, + "stage_without_new_stage": analysis.stage_without_new_stage, + "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, + } + + def _conclusion( trace: RelayTraceResult, *, + module_code_sync_error: bool, + banned_code_1: bool, + runtime_futex_error: bool, registry_restored: bool, raw_relay_deleted: bool, timed_out: bool, ) -> tuple[str, tuple[str, ...]]: warnings: list[str] = [] - if trace.comparison_results: + if module_code_sync_error: + conclusion = "module-code-sync-error" + warnings.append( + "The current-run native game log recorded a module-code integrity " + "mismatch. Do not retry this account with an instrumented or modified " + "game module." + ) + elif banned_code_1: + conclusion = "banned-code-1" + warnings.append( + "The current-run native game log recorded Banned Code 1. Confirm the " + "same account with the unmodified publisher launcher before changing " + "the local runtime again." + ) + elif trace.comparison_results: if trace.comparison_results[-1]: conclusion = "account-id-match" else: @@ -422,6 +579,12 @@ def _conclusion( warnings.append("The relay log did not record the loaded game.dll base address.") if timed_out: warnings.append("The diagnostic reached its timeout and terminated the launcher process.") + if runtime_futex_error: + warnings.append( + "A host-side process emitted glibc's fatal futex message. This is not a " + "game module-code result; compare the same prefix with direct Proton 7 " + "outside UMU before attributing the login failure to authentication." + ) if not registry_restored: warnings.append("The temporary Wine relay registry value could not be restored.") if not raw_relay_deleted: @@ -445,6 +608,13 @@ def _write_report(report: LoginTraceReport) -> None: "# PristonTale EU login trace\n\n" f"- Started: {report.started_at}\n" f"- Runtime: {report.runtime_kind}\n" + f"- Game.exe SHA-256: `{report.game_exe_sha256 or 'missing'}`\n" + f"- game.dll SHA-256: `{report.game_dll_sha256 or 'missing'}`\n" + f"- Current native log: {report.current_game_log or 'not found'}\n" + f"- Module-code mismatch: {report.module_code_sync_error}\n" + f"- Banned Code 1 in native log: {report.banned_code_1}\n" + f"- Host futex fatal message: {report.runtime_futex_error}\n" + f"- GStreamer wrong-ELF warnings: {report.gstreamer_wrong_elf_class}\n" f"- Account-ID comparison observed: {report.comparison_observed}\n" f"- Exact account-ID match: {match}\n" f"- Conclusion: `{report.conclusion}`\n" @@ -455,6 +625,21 @@ def _write_report(report: LoginTraceReport) -> None: ) +def _string_or_none(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _integer_or_none(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int): + return None + return value + + +def _integer_or_zero(value: object) -> int: + result = _integer_or_none(value) + return result if result is not None else 0 + + def _sha256_if_file(path: Path) -> str | None: try: if not path.is_file(): diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py index 807759a..ad56d19 100644 --- a/ptlinux/overlay.py +++ b/ptlinux/overlay.py @@ -39,6 +39,10 @@ CURRENT_V21_BINARY_HASHES = V21_BINARY_HASHES PATCHED_V21_GAME_DLL_SHA256 = ( "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538" ) +RETIRED_POST_AUTH_GAME_DLL_SHA256 = ( + "93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd" +) +RUNTIME_VIEW_CONFIGURATION_VERSION = 1 class OverlayError(RuntimeError): @@ -147,10 +151,28 @@ def prepare_runtime_view( # ``use_world_fix`` is retained as a compatibility keyword. The retired # helper is never selected and no code path may replace the publisher DLL. _ = use_world_fix - binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources( + ( + binary_action, + game_exe_source, + game_dll_source, + source_binary_hashes, + ) = _runtime_game_binary_profile( paths, source, ) + reused = _reuse_runtime_view( + paths, + source, + inspection, + action, + game_save, + binary_action, + game_exe_source, + game_dll_source, + source_binary_hashes, + ) + if reused is not None: + return reused build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}" backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}" @@ -223,7 +245,13 @@ def prepare_runtime_view( created_at=datetime.now().astimezone().isoformat(), links_created=links_created, ) - _write_json(paths.runtime_manifest, asdict(result)) + _write_json( + paths.runtime_manifest, + { + "configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION, + **asdict(result), + }, + ) return result @@ -271,6 +299,32 @@ def select_binary_action( return "verified-source-pair" if source_hashes == CURRENT_V21_BINARY_HASHES: return "verified-v21-publisher-pair" + game_exe_sha256 = source_hashes.get("Game.exe") + game_dll_sha256 = source_hashes.get("game.dll") + if game_dll_sha256 in { + PATCHED_V21_GAME_DLL_SHA256, + RETIRED_POST_AUTH_GAME_DLL_SHA256, + }: + raise OverlayError( + "a retired experimental game.dll is installed; run update --repair " + "before launching" + ) + known_game_exe_hashes = { + CURRENT_V21_BINARY_HASHES["Game.exe"], + COMPATIBILITY_BINARY_HASHES["Game.exe"], + } + known_game_dll_hashes = { + CURRENT_V21_BINARY_HASHES["game.dll"], + COMPATIBILITY_BINARY_HASHES["game.dll"], + } + if ( + game_exe_sha256 in known_game_exe_hashes + or game_dll_sha256 in known_game_dll_hashes + ): + raise OverlayError( + "Game.exe and game.dll are from different verified releases; run " + "update --repair before launching" + ) return "upstream-pair" @@ -392,13 +446,14 @@ def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path: candidate = path.resolve(strict=True) break if candidate is not None: + selected = candidate allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True)) if not any( - candidate == root or candidate.is_relative_to(root) + selected == root or selected.is_relative_to(root) for root in allowed_roots ): - raise OverlayError(f"refusing to migrate game save outside managed roots: {candidate}") - _reject_save_symlinks(candidate) + raise OverlayError(f"refusing to migrate game save outside managed roots: {selected}") + _reject_save_symlinks(selected) target.parent.mkdir(parents=True, exist_ok=True) staging = target.parent / f".{target.name}.build-{uuid.uuid4().hex}" @@ -423,10 +478,213 @@ def _reject_save_symlinks(root: Path) -> None: raise OverlayError(f"persistent game save contains a symbolic link: {path}") +def _reuse_runtime_view( + paths: AppPaths, + source: Path, + inspection: SceneInspection, + scene_action: str, + game_save: Path, + binary_action: str, + game_exe_source: Path, + game_dll_source: Path, + source_binary_hashes: Mapping[str, str], +) -> OverlayResult | None: + result = _read_overlay_result(paths.runtime_manifest) + if result is None: + return None + if result.source_game != str(source): + return None + if result.runtime_view != str(paths.runtime_view): + return None + if result.source_scene_sha256 != inspection.file_sha256: + return None + if result.scene_action != scene_action or result.binary_action != binary_action: + return None + if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"): + return None + if result.game_dll_sha256 != source_binary_hashes.get("game.dll"): + return None + if not _runtime_layout_ready( + paths, + source, + game_save, + game_exe_source, + game_dll_source, + result.links_created, + ): + return None + runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE + try: + if _sha256(runtime_scene) != result.runtime_scene_sha256: + return None + except OSError: + return None + return result + + +def _runtime_layout_ready( + paths: AppPaths, + source: Path, + game_save: Path, + game_exe_source: Path, + game_dll_source: Path, + expected_links: int, +) -> bool: + runtime = paths.runtime_view + if runtime.is_symlink() or not runtime.is_dir(): + return False + top_level_exceptions = {"game", "save"} + top_level_special = {"game", "save"} + for name, binary_source in ( + ("Game.exe", game_exe_source), + ("game.dll", game_dll_source), + ): + if binary_source != source / name: + top_level_exceptions.add(name) + top_level_special.add(name) + top_level_ready, top_level_links = _linked_level_ready( + source, + runtime, + except_names=top_level_exceptions, + special_names=top_level_special, + ) + if not top_level_ready: + return False + if not _symlink_points_to(runtime / "save", game_save): + return False + if game_exe_source != source / "Game.exe" and not _symlink_points_to( + runtime / "Game.exe", + game_exe_source, + ): + return False + if game_dll_source != source / "game.dll" and not _symlink_points_to( + runtime / "game.dll", + game_dll_source, + ): + return False + + game_source = source / "game" + game_target = runtime / "game" + if game_target.is_symlink() or not game_target.is_dir(): + return False + game_ready, game_links = _linked_level_ready( + game_source, + game_target, + except_names={"maps"}, + special_names={"maps"}, + ) + if not game_ready: + return False + + maps_source = game_source / "maps" + maps_target = game_target / "maps" + if maps_target.is_symlink() or not maps_target.is_dir(): + return False + maps_ready, maps_links = _linked_level_ready( + maps_source, + maps_target, + except_names={"WhisperingVale.rovug"}, + special_names={"WhisperingVale.rovug"}, + ) + if not maps_ready: + return False + runtime_scene = maps_target / "WhisperingVale.rovug" + if runtime_scene.is_symlink() or not runtime_scene.is_file(): + return False + actual_links = top_level_links + game_links + maps_links + 1 + if game_exe_source != source / "Game.exe": + actual_links += 1 + if game_dll_source != source / "game.dll": + actual_links += 1 + return actual_links == expected_links + + +def _linked_level_ready( + source: Path, + target: Path, + *, + except_names: set[str], + special_names: set[str], +) -> tuple[bool, int]: + try: + source_children = { + child.name: child + for child in source.iterdir() + if child.name not in except_names + } + target_names = {child.name for child in target.iterdir()} + except OSError: + return False, 0 + if target_names != set(source_children) | special_names: + return False, 0 + for name, child in source_children.items(): + if not _symlink_points_to(target / name, child): + return False, 0 + return True, len(source_children) + + +def _symlink_points_to(link: Path, expected_target: Path) -> bool: + if not link.is_symlink(): + return False + try: + return link.resolve(strict=True) == expected_target.resolve(strict=True) + except (OSError, RuntimeError): + return False + + +def _read_overlay_result(path: Path) -> OverlayResult | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + if not isinstance(value, dict): + return None + if value.get("configuration_version") != RUNTIME_VIEW_CONFIGURATION_VERSION: + return None + string_fields = ( + "source_game", + "runtime_view", + "source_scene_sha256", + "runtime_scene_sha256", + "scene_action", + "binary_action", + "game_exe_sha256", + "game_dll_sha256", + "created_at", + ) + if not all(isinstance(value.get(name), str) for name in string_fields): + return None + links_created = value.get("links_created") + if isinstance(links_created, bool) or not isinstance(links_created, int): + return None + if links_created < 0: + return None + return OverlayResult( + source_game=value["source_game"], + runtime_view=value["runtime_view"], + source_scene_sha256=value["source_scene_sha256"], + runtime_scene_sha256=value["runtime_scene_sha256"], + scene_action=value["scene_action"], + binary_action=value["binary_action"], + game_exe_sha256=value["game_exe_sha256"], + game_dll_sha256=value["game_dll_sha256"], + created_at=value["created_at"], + links_created=links_created, + ) + + def _runtime_game_binary_sources( paths: AppPaths, source: Path, ) -> tuple[str, Path, Path]: + action, game_exe, game_dll, _ = _runtime_game_binary_profile(paths, source) + return action, game_exe, game_dll + + +def _runtime_game_binary_profile( + paths: AppPaths, + source: Path, +) -> tuple[str, Path, Path, dict[str, str]]: cache = paths.compatibility_binaries cache_hashes = _binary_hashes(cache) if cache.is_dir() else None source_hashes = _binary_hashes(source) @@ -435,6 +693,7 @@ def _runtime_game_binary_sources( action, source / "Game.exe", source / "game.dll", + source_hashes, ) diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 741c695..c5ead23 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime +import hashlib import json import os from pathlib import Path @@ -33,6 +34,7 @@ WINETRICKS_CODEPAGE_PROBE = ( WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"' RUNTIME_DEFAULT_SYNC = "runtime-default" CACHYOS_MARKER = "cachyos" +GAME_RUNTIME_CONFIGURATION_VERSION = 1 class RuntimeErrorPT(RuntimeError): @@ -159,6 +161,8 @@ def prepare_game_runtime( elif selection.kind in {"proton7", "proton7-container"}: if not selection.proton_path or not selection.steam_root: raise RuntimeErrorPT("incomplete Proton 7 runtime selection") + if not runner.dry_run and _game_runtime_ready(paths, selection): + return results if not runner.dry_run: paths.compat_data.mkdir(parents=True, exist_ok=True) proton = Path(selection.proton_path) @@ -237,11 +241,13 @@ def prepare_game_runtime( raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") if not runner.dry_run: _write_json( - paths.state / "game-runtime.json", + paths.game_runtime_marker, { + "configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION, "configured_at": datetime.now().astimezone().isoformat(), "native_d3d43": True, "dxvk_d3d9_override": DXVK_D3D9_OVERRIDE, + "proton_identity": _proton_identity(selection), "runtime": asdict(selection), }, ) @@ -572,6 +578,69 @@ def _proton_prefix_ready(prefix: Path) -> bool: ).exists() +def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool: + marker = _read_json(paths.game_runtime_marker) + identity = _proton_identity(selection) + if marker is None or identity is None: + return False + if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION: + return False + if marker.get("runtime") != asdict(selection): + return False + if marker.get("proton_identity") != identity: + return False + if marker.get("native_d3d43") is not True: + return False + if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE: + return False + if not _proton_prefix_ready(paths.proton_prefix): + return False + if not native_d3d_ready(paths.proton_prefix): + return False + if not _d3d9_override_ready(paths.proton_prefix): + return False + return _proton_game_link_ready(paths) + + +def _d3d9_override_ready(prefix: Path) -> bool: + try: + registry = (prefix / "user.reg").read_text( + encoding="utf-8", + errors="replace", + ).casefold() + except OSError: + return False + return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry + + +def _proton_game_link_ready(paths: AppPaths) -> bool: + link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU" + if not link.is_symlink(): + return False + try: + return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True) + except (OSError, RuntimeError): + return False + + +def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None: + if not selection.proton_path: + return None + proton = Path(selection.proton_path) + identity_paths = { + "proton": proton, + "version": proton.parent / "version", + "toolmanifest": proton.parent / "toolmanifest.vdf", + } + identity: dict[str, str] = {} + for name, path in identity_paths.items(): + try: + identity[name] = _sha256(path) + except OSError: + return None + return identity + + def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None: for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"): wine = directory / "wine" @@ -586,3 +655,19 @@ def _write_json(path: Path, value: object) -> None: temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(path) + + +def _read_json(path: Path) -> dict[str, object] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest()