"""Credential-free diagnostics for the PristonTale EU login path.""" from __future__ import annotations from dataclasses import asdict, dataclass, replace 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 .command import CommandRunner from .identity_probe import ( IDENTITY_PROBE_PRIVACY, IdentityProbeResult, probe_client_identity, ) from .login_trace import LOGIN_TRACE_DISABLED_REASON, LOGIN_TRACE_ENABLED 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"} ) SERVER_LOGIN_RESPONSE_OWNERSHIP = ( "Banned Code 1 and Banned Code 3 are server-provided login response text; " "neither string exists in the verified V21.75.83 Game.exe/game.dll pair" ) CLIENT_IDENTITY_INPUTS = ( "Win32_Processor", "Win32_BIOS", "Win32_BaseBoard", "Win32_VideoController", "network-adapter identity", "C: volume information", "computer name", ) PUBLISHER_COMPARISON_COMMAND = "pristontale-eu wine-update" RUNTIME_COMPONENT_PRIVACY = ( "Only SHA-256 hashes of public runtime executables and DLLs plus expected DLL-" "override flags are reported. Registry contents and machine identifiers are not " "hashed or returned." ) PREFIX_COMPONENT_PATHS = ( "drive_c/windows/syswow64/d3d9.dll", "drive_c/windows/syswow64/d3dx9_43.dll", "drive_c/windows/syswow64/d3dcompiler_43.dll", "drive_c/windows/syswow64/iphlpapi.dll", "drive_c/windows/syswow64/kernel32.dll", "drive_c/windows/syswow64/ole32.dll", "drive_c/windows/syswow64/oleaut32.dll", "drive_c/windows/syswow64/wbemprox.dll", "drive_c/windows/syswow64/wbem/wbemprox.dll", ) 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 RuntimeComponentFingerprint: privacy: str runtime_wrapper_sha256: str | None proton_script_sha256: str | None proton_version_sha256: str | None proton_toolmanifest_sha256: str | None proton_wine_sha256: str | None proton_wineserver_sha256: str | None prefix_dll_sha256: dict[str, str | None] registry_overrides: dict[str, bool] runtime_marker_matches_selection: bool | 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 login_response_ownership: str client_identity_inputs: tuple[str, ...] client_identity_values_collected: bool client_identity_shape_collected: bool client_identity_ready: bool | None client_identity_privacy: str client_identity_probe: IdentityProbeResult | None login_trace_enabled: bool login_trace_disabled_reason: str | None publisher_comparison_command: str 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_configured: str runtime_requested: str runtime_override_applied: bool runtime_kind: str | None runtime_path: str | None runtime_error: str | None runtime_components: RuntimeComponentFingerprint | 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, runner: CommandRunner | None = None, runtime_override: str | None = None, ) -> LoginDiagnosticReport: """Inspect the installed client and its network route without authenticating.""" probe = probe_login_server( timeout_seconds=timeout_seconds, connector=connector, ) runtime_requested = runtime_override or settings.runtime diagnostic_settings = replace(settings, runtime=runtime_requested) selection, runtime_error = _runtime_selection(diagnostic_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 runtime_components = ( inspect_runtime_components(paths, selection, prefix) if selection is not None and prefix is not None else None ) identity_probe = ( probe_client_identity(paths, selection, runner) if selection is not None and prefix_ready and runner is not None else None ) 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, identity_probe, ) identity_preflight_ok = ( True if runner is None else identity_probe is not None and identity_probe.ready is True ) 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 and identity_preflight_ok ), 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, login_response_ownership=SERVER_LOGIN_RESPONSE_OWNERSHIP, client_identity_inputs=CLIENT_IDENTITY_INPUTS, client_identity_values_collected=False, client_identity_shape_collected=( identity_probe is not None and identity_probe.report is not None ), client_identity_ready=( identity_probe.ready if identity_probe is not None else None ), client_identity_privacy=IDENTITY_PROBE_PRIVACY, client_identity_probe=identity_probe, login_trace_enabled=LOGIN_TRACE_ENABLED, login_trace_disabled_reason=( None if LOGIN_TRACE_ENABLED else LOGIN_TRACE_DISABLED_REASON ), publisher_comparison_command=PUBLISHER_COMPARISON_COMMAND, 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_configured=settings.runtime, runtime_requested=runtime_requested, runtime_override_applied=runtime_override is not None, runtime_kind=selection.kind if selection is not None else None, runtime_path=_runtime_path(selection), runtime_error=runtime_error, runtime_components=runtime_components, 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 inspect_runtime_components( paths: AppPaths, selection: RuntimeSelection, prefix: Path, ) -> RuntimeComponentFingerprint: """Fingerprint non-private runtime components for side-by-side reports.""" proton_script_sha256: str | None = None proton_version_sha256: str | None = None proton_toolmanifest_sha256: str | None = None proton_wine_sha256: str | None = None proton_wineserver_sha256: str | None = None if selection.proton_path: proton = Path(selection.proton_path) proton_script_sha256 = _sha256_if_file(proton) proton_version_sha256 = _sha256_if_file(proton.parent / "version") proton_toolmanifest_sha256 = _sha256_if_file( proton.parent / "toolmanifest.vdf" ) for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"): wine = directory / "wine" wineserver = directory / "wineserver" if wine.is_file() and wineserver.is_file(): proton_wine_sha256 = _sha256_if_file(wine) proton_wineserver_sha256 = _sha256_if_file(wineserver) break prefix_dll_sha256 = { relative: _sha256_if_file(prefix / relative) for relative in PREFIX_COMPONENT_PATHS } runtime_marker = _read_json_object(paths.game_runtime_marker) marker_matches = ( runtime_marker.get("runtime") == asdict(selection) if runtime_marker else None ) return RuntimeComponentFingerprint( privacy=RUNTIME_COMPONENT_PRIVACY, runtime_wrapper_sha256=_sha256_if_file(Path(selection.executable)), proton_script_sha256=proton_script_sha256, proton_version_sha256=proton_version_sha256, proton_toolmanifest_sha256=proton_toolmanifest_sha256, proton_wine_sha256=proton_wine_sha256, proton_wineserver_sha256=proton_wineserver_sha256, prefix_dll_sha256=prefix_dll_sha256, registry_overrides=_runtime_registry_override_flags(prefix), runtime_marker_matches_selection=marker_matches, ) def _runtime_registry_override_flags(prefix: Path) -> dict[str, bool]: try: registry = (prefix / "user.reg").read_text( encoding="utf-8", errors="replace", ).casefold() except OSError: registry = "" return { "d3d9_native_builtin": '"d3d9"="native,builtin"' in registry, "d3dx9_43_native": _native_override_present(registry, "d3dx9_43"), "d3dcompiler_43_native": _native_override_present( registry, "d3dcompiler_43", ), } def _native_override_present(registry: str, name: str) -> bool: return any( entry in registry for entry in ( f'"{name}"="native,builtin"', f'"{name}"="native"', f'"*{name}"="native"', ) ) 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, identity_probe: IdentityProbeResult | None, ) -> 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.") if identity_probe is not None: warnings.extend(identity_probe.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: 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