Added login diagnostics and tracing for login issues.
This commit is contained in:
388
ptlinux/login_diagnostics.py
Normal file
388
ptlinux/login_diagnostics.py
Normal 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
|
||||
Reference in New Issue
Block a user