389 lines
11 KiB
Python
389 lines
11 KiB
Python
"""Credential-free diagnostics for the PristonTale EU login path."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
import hashlib
|
|
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,
|
|
)
|
|
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_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"}
|
|
)
|
|
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
|
|
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
|
|
)
|
|
|
|
|
|
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
|
|
game_profile: str
|
|
world_fix_helper: str
|
|
world_fix_helper_sha256: str | None
|
|
world_fix_helper_ready: bool
|
|
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)
|
|
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
|
|
world_fix_helper = packaged_world_fix_helper()
|
|
world_fix_helper_sha256 = _sha256_if_file(world_fix_helper)
|
|
world_fix_helper_ready = 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,
|
|
world_fix_required=game_profile == "v21.75.83",
|
|
world_fix_helper_ready=world_fix_helper_ready,
|
|
)
|
|
return LoginDiagnosticReport(
|
|
preflight_ok=(
|
|
probe.reachable
|
|
and game_profile in VERIFIED_GAME_PROFILES
|
|
and selection is not None
|
|
and prefix_ready
|
|
and directx_ready
|
|
and (game_profile != "v21.75.83" or world_fix_helper_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,
|
|
game_profile=game_profile,
|
|
world_fix_helper=str(world_fix_helper),
|
|
world_fix_helper_sha256=world_fix_helper_sha256,
|
|
world_fix_helper_ready=world_fix_helper_ready,
|
|
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,
|
|
*,
|
|
world_fix_required: bool,
|
|
world_fix_helper_ready: 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 world_fix_required and not world_fix_helper_ready:
|
|
warnings.append(
|
|
"The verified post-login world-fix helper is missing or failed integrity "
|
|
"verification; reinstall the launcher."
|
|
)
|
|
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 _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
|