Files
pristontale-eu-linux/ptlinux/login_diagnostics.py

626 lines
19 KiB
Python

"""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