Added login diagnostics and tracing for login issues.

This commit is contained in:
2026-08-28 20:32:07 +02:00
parent f6e2d0923b
commit e5a1e1b781
3 changed files with 869 additions and 2 deletions

View File

@@ -0,0 +1,388 @@
"""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