"""Credential-free diagnostics for the PristonTale EU login path.""" 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 import sys import time from typing import Callable, Protocol from . import __version__ 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 ( RuntimeSelection, detect_distribution_id, native_d3d_ready, resolve_proton_sync_mode, select_runtime, ) from .settings import Settings from .world_fix import ( WORLD_FIX_DISABLED_REASON, WORLD_FIX_ENABLED, WORLD_FIX_HELPER_SHA256, packaged_world_fix_helper, ) LOGIN_SERVER_HOST = "5.135.35.188" LOGIN_SERVER_PORT = 10019 DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0 VERIFIED_GAME_PROFILES = frozenset( {"v21.75.83", "verified-compatibility"} ) class _Closable(Protocol): def close(self) -> None: ... LoginConnector = Callable[[tuple[str, int], float], _Closable] def _create_login_connection(address: tuple[str, int], timeout: float) -> _Closable: return socket.create_connection(address, timeout) @dataclass(frozen=True) class LoginServerProbe: host: str port: int reachable: bool latency_ms: float | None error: str | None @dataclass(frozen=True) class LoginDiagnosticReport: preflight_ok: bool checked_at: str launcher_version: str launcher_module: str python_executable: str distribution_id: str kernel_release: str login_server: LoginServerProbe authentication_tested: bool install_directory: str game_exe: str 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 world_fix_enabled: bool world_fix_disabled_reason: str | None runtime_kind: str | None runtime_path: str | None runtime_error: str | None proton_sync_configured: str proton_sync_effective: str | None prefix: str | None prefix_ready: bool directx_ready: bool latest_game_log: str | None latest_game_log_modified_at: str | None warnings: tuple[str, ...] def to_dict(self) -> dict[str, object]: return asdict(self) def probe_login_server( *, host: str = LOGIN_SERVER_HOST, port: int = LOGIN_SERVER_PORT, timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS, connector: LoginConnector = _create_login_connection, clock: Callable[[], float] = time.monotonic, ) -> LoginServerProbe: """Test only the TCP handshake; never send credentials or protocol data.""" if timeout_seconds <= 0: raise ValueError("login diagnostic timeout must be greater than zero") started = clock() try: connection = connector((host, port), timeout_seconds) connection.close() except OSError as exc: return LoginServerProbe( host=host, port=port, reachable=False, latency_ms=None, error=f"{type(exc).__name__}: {exc}", ) return LoginServerProbe( host=host, port=port, reachable=True, latency_ms=round((clock() - started) * 1000.0, 1), error=None, ) def inspect_login_environment( paths: AppPaths, settings: Settings, *, timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS, connector: LoginConnector = _create_login_connection, ) -> LoginDiagnosticReport: """Inspect the installed client and its network route without authenticating.""" probe = probe_login_server( timeout_seconds=timeout_seconds, connector=connector, ) selection, runtime_error = _runtime_selection(settings) distribution_id = detect_distribution_id() or "unknown" kernel_release = platform.release() proton_sync_effective = ( resolve_proton_sync_mode( settings.proton_sync, distribution_id=distribution_id, kernel_release=kernel_release, ) if selection is not None and selection.kind in {"proton7", "proton7-container"} else None ) game_root = _active_game_root(paths, settings) game_exe = game_root / "Game.exe" 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 = ( WORLD_FIX_ENABLED and world_fix_helper_sha256 == WORLD_FIX_HELPER_SHA256 ) prefix = _runtime_prefix(paths, selection) prefix_ready = _prefix_ready(prefix) directx_ready = native_d3d_ready(prefix) if prefix is not None else False latest_log = latest_native_game_log(paths, settings) warnings = _diagnostic_warnings( probe, game_profile, runtime_error, 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 ), checked_at=datetime.now().astimezone().isoformat(), launcher_version=__version__, launcher_module=str(Path(__file__).resolve()), python_executable=sys.executable, distribution_id=distribution_id, kernel_release=kernel_release, login_server=probe, authentication_tested=False, install_directory=str(paths.data), game_exe=str(game_exe), 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, world_fix_enabled=WORLD_FIX_ENABLED, world_fix_disabled_reason=( None if WORLD_FIX_ENABLED else WORLD_FIX_DISABLED_REASON ), runtime_kind=selection.kind if selection is not None else None, runtime_path=_runtime_path(selection), runtime_error=runtime_error, proton_sync_configured=settings.proton_sync, proton_sync_effective=proton_sync_effective, prefix=str(prefix) if prefix is not None else None, prefix_ready=prefix_ready, directx_ready=directx_ready, latest_game_log=str(latest_log) if latest_log is not None else None, latest_game_log_modified_at=_modified_at(latest_log), warnings=warnings, ) def classify_game_profile( game_exe_sha256: str | None, game_dll_sha256: str | None, ) -> str: if game_exe_sha256 is None or game_dll_sha256 is None: return "missing" pair = {"Game.exe": game_exe_sha256, "game.dll": game_dll_sha256} if pair == CURRENT_V21_BINARY_HASHES: return "v21.75.83" if ( game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"] and game_dll_sha256 == RETIRED_POST_AUTH_GAME_DLL_SHA256 ): return "retired-post-auth-disk-patch" if ( game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"] and game_dll_sha256 == PATCHED_V21_GAME_DLL_SHA256 ): return "retired-texture-reclaim" if pair == COMPATIBILITY_BINARY_HASHES: return "verified-compatibility" 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"], RETIRED_POST_AUTH_GAME_DLL_SHA256, PATCHED_V21_GAME_DLL_SHA256, COMPATIBILITY_BINARY_HASHES["game.dll"], } if game_exe_sha256 in known_game_exe_hashes or game_dll_sha256 in known_game_dll_hashes: return "mismatched" return "unknown" def latest_native_game_log(paths: AppPaths, settings: Settings) -> Path | None: """Return the newest native log from the active managed source or runtime view.""" directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs") logs: dict[Path, Path] = {} for directory in directories: try: candidates = directory.glob("log_*.txt") for candidate in candidates: if candidate.is_file(): logs[candidate.resolve()] = candidate except OSError: continue if not logs: return None try: return max(logs.values(), key=lambda item: item.stat().st_mtime_ns) except OSError: return None def _active_game_root(paths: AppPaths, settings: Settings) -> Path: if paths.runtime_view.is_dir(): return paths.runtime_view return paths.source_game def _runtime_selection(settings: Settings) -> tuple[RuntimeSelection | None, str | None]: try: return select_runtime(settings), None except RuntimeError as exc: return None, str(exc) def _runtime_path(selection: RuntimeSelection | None) -> str | None: if selection is None: return None return selection.proton_path or selection.executable def _runtime_prefix(paths: AppPaths, selection: RuntimeSelection | None) -> Path | None: if selection is None: return None return ( paths.proton_prefix if selection.kind in {"proton7", "proton7-container"} else paths.umu_prefix ) def _prefix_ready(prefix: Path | None) -> bool: if prefix is None: return False return (prefix / "user.reg").is_file() and ( prefix / "drive_c/windows/system32/cmd.exe" ).is_file() def _diagnostic_warnings( probe: LoginServerProbe, game_profile: str, runtime_error: str | None, prefix_ready: bool, directx_ready: bool, latest_log: Path | None, runtime_matches_managed_source: bool, ) -> tuple[str, ...]: warnings: list[str] = [] if not probe.reachable: warnings.append( "The publisher login port is unreachable from this Linux host; check the " "network, firewall, VPN, or server status." ) if game_profile == "missing": warnings.append("Game.exe or game.dll is missing; install or update the game first.") elif game_profile == "mismatched": warnings.append( "Game.exe and game.dll are from different verified releases; run update --repair." ) elif game_profile == "unknown": warnings.append("Game.exe/game.dll do not match a launcher-verified client profile.") elif game_profile == "retired-texture-reclaim": warnings.append( "A retired experimental game.dll patch is active and can stall login; " "run prepare or launch the game once to restore the publisher DLL." ) elif game_profile == "retired-post-auth-disk-patch": warnings.append( "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: warnings.append("The selected game prefix is not initialized; run play once to prepare it.") elif not directx_ready: warnings.append("The native DirectX helper DLLs are not ready; run play to repair them.") if latest_log is None: warnings.append("No native game log was found yet.") 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: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _sha256_if_file(path: Path) -> str | None: try: return _sha256(path) if path.is_file() else None except OSError: return None def _modified_at(path: Path | None) -> str | None: if path is None: return None try: return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat() except OSError: return None