Added login diagnostics and tracing for login issues.
This commit is contained in:
11
install.sh
11
install.sh
@@ -3,7 +3,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="${PTLINUX_VERSION:-0.5.8}"
|
||||
VERSION="${PTLINUX_VERSION:-0.5.9}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
then
|
||||
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2
|
||||
@@ -138,6 +138,7 @@ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-world-fix.exe" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
|
||||
@@ -191,11 +192,17 @@ then
|
||||
exit 1
|
||||
fi
|
||||
install -d -m 0755 "$STAGING_RELEASE"
|
||||
install -d -m 0755 "${STAGING_RELEASE}/ptlinux" "${STAGING_RELEASE}/ptdiag"
|
||||
install -d -m 0755 \
|
||||
"${STAGING_RELEASE}/ptlinux" \
|
||||
"${STAGING_RELEASE}/ptlinux/native" \
|
||||
"${STAGING_RELEASE}/ptdiag"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/"
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/ptlinux/native/pt-world-fix.exe" \
|
||||
"${STAGING_RELEASE}/ptlinux/native/pt-world-fix.exe"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py"
|
||||
|
||||
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
|
||||
472
ptlinux/login_trace.py
Normal file
472
ptlinux/login_trace.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""Privacy-safe instrumentation for the post-authentication login transition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Iterable
|
||||
|
||||
from .command import CommandOutcome, CommandRunner
|
||||
from .login_diagnostics import latest_native_game_log
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
RuntimeSelection,
|
||||
detect_distribution_id,
|
||||
resolve_proton_sync_mode,
|
||||
run_game,
|
||||
run_proton_prefix_command,
|
||||
)
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
RELAY_REGISTRY_KEY = r"HKCU\Software\Wine\Debug"
|
||||
RELAY_REGISTRY_VALUE = "RelayInclude"
|
||||
RELAY_INCLUDE_FILTER = "KERNEL32.lstrcmpA"
|
||||
GAME_DLL_LOGIN_COMPARE_RETURN_OFFSET = 0x79F157
|
||||
DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0
|
||||
|
||||
_LOAD_GAME_DLL = re.compile(
|
||||
r'Loaded L"[^"\r\n]*(?:\\|/)game\.dll" at (?P<base>[0-9a-f]+):',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMPARE_RETURN = re.compile(
|
||||
r'(?P<thread>[0-9a-f]{4}):Ret\s+KERNEL32\.lstrcmpA\(\) '
|
||||
r'retval=(?P<result>[0-9a-f]+) ret=(?P<return_address>[0-9a-f]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RELAY_VALUE = re.compile(
|
||||
rf"^\s*{re.escape(RELAY_REGISTRY_VALUE)}\s+REG_SZ(?:\s+(?P<value>.*))?$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_RELAY_CALL = re.compile(
|
||||
r'^(?P<prefix>.*?Call\s+KERNEL32\.lstrcmpA)\(.*\) '
|
||||
r'ret=(?P<return_address>[0-9a-f]+)\s*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelayTraceResult:
|
||||
game_dll_base: int | None
|
||||
target_return_address: int | None
|
||||
comparison_results: tuple[bool, ...]
|
||||
log_files: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginTraceReport:
|
||||
started_at: str
|
||||
finished_at: str
|
||||
result_directory: str
|
||||
summary_file: str
|
||||
report_file: str
|
||||
distribution_id: str
|
||||
kernel_release: str
|
||||
runtime_kind: str
|
||||
runtime_path: str | None
|
||||
proton_sync_effective: str | None
|
||||
game_exe_sha256: str | None
|
||||
game_dll_sha256: str | None
|
||||
native_log_before: str | None
|
||||
native_log_before_modified_at: str | None
|
||||
native_log_after: str | None
|
||||
native_log_after_modified_at: str | None
|
||||
game_returncode: int | None
|
||||
game_timed_out: bool
|
||||
game_duration_seconds: float
|
||||
registry_restored: bool
|
||||
raw_relay_deleted: bool
|
||||
relay_log_files: int
|
||||
game_dll_base: str | None
|
||||
target_return_address: str | None
|
||||
comparison_observed: bool
|
||||
account_id_exact_match: bool | None
|
||||
comparison_attempts: int
|
||||
conclusion: str
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RegistryValue:
|
||||
existed: bool
|
||||
value: str | None
|
||||
|
||||
|
||||
def run_login_trace(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
use_gamebox: bool = True,
|
||||
timeout_seconds: float = DEFAULT_TRACE_TIMEOUT_SECONDS,
|
||||
) -> LoginTraceReport:
|
||||
"""Launch one instrumented login attempt and retain only a redacted summary."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("login trace timeout must be greater than zero")
|
||||
if selection.kind not in {"proton7", "proton7-container"}:
|
||||
raise RuntimeError("login tracing currently requires the pinned Proton 7 runtime")
|
||||
if not selection.proton_path:
|
||||
raise RuntimeError("the selected Proton 7 runtime path is incomplete")
|
||||
|
||||
started = datetime.now().astimezone()
|
||||
result_directory = _create_result_directory(paths, started)
|
||||
game_exe = paths.runtime_view / "Game.exe"
|
||||
game_dll = paths.runtime_view / "game.dll"
|
||||
log_before, log_before_modified = _native_log_snapshot(paths, settings)
|
||||
registry_before = _query_relay_filter(paths, selection, runner)
|
||||
registry_restored = False
|
||||
trace = RelayTraceResult(None, None, (), 0)
|
||||
outcome: CommandOutcome | None = None
|
||||
raw_directory_path: Path | None = None
|
||||
|
||||
paths.cache.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="ptlinux-login-relay-",
|
||||
dir=paths.cache,
|
||||
) as raw_directory:
|
||||
raw_directory_path = Path(raw_directory)
|
||||
try:
|
||||
_set_relay_filter(paths, selection, runner, RELAY_INCLUDE_FILTER)
|
||||
outcome = run_game(
|
||||
paths,
|
||||
settings,
|
||||
selection,
|
||||
runner,
|
||||
use_gamebox=use_gamebox,
|
||||
use_world_fix=False,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=False,
|
||||
extra_environment={
|
||||
"PROTON_LOG": "1",
|
||||
"PROTON_LOG_DIR": str(raw_directory_path),
|
||||
"SteamAppId": "0",
|
||||
"SteamGameId": "0",
|
||||
"WINEDEBUG": "+relay,+loaddll",
|
||||
},
|
||||
output_line_filter=redact_relay_output_line,
|
||||
)
|
||||
trace = parse_relay_logs(raw_directory_path.rglob("*.log"))
|
||||
finally:
|
||||
registry_restored = _restore_relay_filter(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
registry_before,
|
||||
)
|
||||
|
||||
if outcome is None:
|
||||
raise RuntimeError("the instrumented game process did not start")
|
||||
raw_relay_deleted = raw_directory_path is not None and not raw_directory_path.exists()
|
||||
log_after, log_after_modified = _native_log_snapshot(paths, settings)
|
||||
comparison_observed = bool(trace.comparison_results)
|
||||
account_id_exact_match = (
|
||||
trace.comparison_results[-1] if comparison_observed else None
|
||||
)
|
||||
conclusion, warnings = _conclusion(
|
||||
trace,
|
||||
registry_restored=registry_restored,
|
||||
raw_relay_deleted=raw_relay_deleted,
|
||||
timed_out=outcome.timed_out,
|
||||
)
|
||||
finished = datetime.now().astimezone()
|
||||
summary_file = result_directory / "summary.json"
|
||||
report_file = result_directory / "report.md"
|
||||
report = LoginTraceReport(
|
||||
started_at=started.isoformat(),
|
||||
finished_at=finished.isoformat(),
|
||||
result_directory=str(result_directory),
|
||||
summary_file=str(summary_file),
|
||||
report_file=str(report_file),
|
||||
distribution_id=detect_distribution_id() or "unknown",
|
||||
kernel_release=platform.release(),
|
||||
runtime_kind=selection.kind,
|
||||
runtime_path=selection.proton_path,
|
||||
proton_sync_effective=resolve_proton_sync_mode(settings.proton_sync),
|
||||
game_exe_sha256=_sha256_if_file(game_exe),
|
||||
game_dll_sha256=_sha256_if_file(game_dll),
|
||||
native_log_before=log_before,
|
||||
native_log_before_modified_at=log_before_modified,
|
||||
native_log_after=log_after,
|
||||
native_log_after_modified_at=log_after_modified,
|
||||
game_returncode=outcome.returncode,
|
||||
game_timed_out=outcome.timed_out,
|
||||
game_duration_seconds=round(outcome.duration_seconds, 3),
|
||||
registry_restored=registry_restored,
|
||||
raw_relay_deleted=raw_relay_deleted,
|
||||
relay_log_files=trace.log_files,
|
||||
game_dll_base=_hex_or_none(trace.game_dll_base),
|
||||
target_return_address=_hex_or_none(trace.target_return_address),
|
||||
comparison_observed=comparison_observed,
|
||||
account_id_exact_match=account_id_exact_match,
|
||||
comparison_attempts=len(trace.comparison_results),
|
||||
conclusion=conclusion,
|
||||
warnings=warnings,
|
||||
)
|
||||
_write_report(report)
|
||||
return report
|
||||
|
||||
|
||||
def parse_relay_logs(log_paths: Iterable[Path]) -> RelayTraceResult:
|
||||
"""Extract only module addresses and equality results from raw Proton logs."""
|
||||
|
||||
game_dll_base: int | None = None
|
||||
returns: list[tuple[int, int]] = []
|
||||
log_files = 0
|
||||
for path in log_paths:
|
||||
try:
|
||||
handle = path.open("r", encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
log_files += 1
|
||||
with handle:
|
||||
for line in handle:
|
||||
if game_dll_base is None:
|
||||
loaded = _LOAD_GAME_DLL.search(line)
|
||||
if loaded is not None:
|
||||
game_dll_base = int(loaded.group("base"), 16)
|
||||
returned = _COMPARE_RETURN.search(line)
|
||||
if returned is not None:
|
||||
returns.append(
|
||||
(
|
||||
int(returned.group("return_address"), 16),
|
||||
int(returned.group("result"), 16),
|
||||
)
|
||||
)
|
||||
target_return_address = (
|
||||
game_dll_base + GAME_DLL_LOGIN_COMPARE_RETURN_OFFSET
|
||||
if game_dll_base is not None
|
||||
else None
|
||||
)
|
||||
comparison_results = tuple(
|
||||
result == 0
|
||||
for return_address, result in returns
|
||||
if target_return_address is not None and return_address == target_return_address
|
||||
)
|
||||
return RelayTraceResult(
|
||||
game_dll_base=game_dll_base,
|
||||
target_return_address=target_return_address,
|
||||
comparison_results=comparison_results,
|
||||
log_files=log_files,
|
||||
)
|
||||
|
||||
|
||||
def redact_relay_output_line(line: str) -> str | None:
|
||||
"""Remove strings from a relay call before console or command-log output."""
|
||||
|
||||
match = _RELAY_CALL.match(line.rstrip("\r\n"))
|
||||
if match is None:
|
||||
return line
|
||||
return (
|
||||
f"{match.group('prefix')}(<redacted>) "
|
||||
f"ret={match.group('return_address')}\n"
|
||||
)
|
||||
|
||||
|
||||
def _query_relay_filter(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
) -> _RegistryValue:
|
||||
outcome = run_proton_prefix_command(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
[
|
||||
"reg.exe",
|
||||
"query",
|
||||
RELAY_REGISTRY_KEY,
|
||||
"/v",
|
||||
RELAY_REGISTRY_VALUE,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
match = _RELAY_VALUE.search(outcome.stdout)
|
||||
if match is None:
|
||||
return _RegistryValue(False, None)
|
||||
return _RegistryValue(True, match.group("value") or "")
|
||||
|
||||
|
||||
def _set_relay_filter(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
value: str,
|
||||
) -> None:
|
||||
run_proton_prefix_command(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
[
|
||||
"reg.exe",
|
||||
"add",
|
||||
RELAY_REGISTRY_KEY,
|
||||
"/v",
|
||||
RELAY_REGISTRY_VALUE,
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
value,
|
||||
"/f",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _restore_relay_filter(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
previous: _RegistryValue,
|
||||
) -> bool:
|
||||
if previous.existed:
|
||||
outcome = run_proton_prefix_command(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
[
|
||||
"reg.exe",
|
||||
"add",
|
||||
RELAY_REGISTRY_KEY,
|
||||
"/v",
|
||||
RELAY_REGISTRY_VALUE,
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
previous.value or "",
|
||||
"/f",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
outcome = run_proton_prefix_command(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
[
|
||||
"reg.exe",
|
||||
"delete",
|
||||
RELAY_REGISTRY_KEY,
|
||||
"/v",
|
||||
RELAY_REGISTRY_VALUE,
|
||||
"/f",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
return not outcome.timed_out and outcome.returncode == 0
|
||||
|
||||
|
||||
def _create_result_directory(paths: AppPaths, started: datetime) -> Path:
|
||||
root = paths.state / "login-traces"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{started.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
|
||||
result = root / stem
|
||||
suffix = 1
|
||||
while result.exists():
|
||||
result = root / f"{stem}-{suffix}"
|
||||
suffix += 1
|
||||
result.mkdir(mode=0o700)
|
||||
return result
|
||||
|
||||
|
||||
def _native_log_snapshot(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
) -> tuple[str | None, str | None]:
|
||||
path = latest_native_game_log(paths, settings)
|
||||
if path is None:
|
||||
return None, None
|
||||
try:
|
||||
modified = datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat()
|
||||
except OSError:
|
||||
modified = None
|
||||
return str(path), modified
|
||||
|
||||
|
||||
def _conclusion(
|
||||
trace: RelayTraceResult,
|
||||
*,
|
||||
registry_restored: bool,
|
||||
raw_relay_deleted: bool,
|
||||
timed_out: bool,
|
||||
) -> tuple[str, tuple[str, ...]]:
|
||||
warnings: list[str] = []
|
||||
if trace.comparison_results:
|
||||
if trace.comparison_results[-1]:
|
||||
conclusion = "account-id-match"
|
||||
else:
|
||||
conclusion = "account-id-mismatch"
|
||||
warnings.append(
|
||||
"The authenticated reply was discarded because its account ID did not "
|
||||
"exactly match the ID entered in the client."
|
||||
)
|
||||
else:
|
||||
conclusion = "comparison-not-observed"
|
||||
warnings.append(
|
||||
"The post-authentication account comparison was not observed; confirm that "
|
||||
"Login was pressed before closing the game."
|
||||
)
|
||||
if trace.log_files == 0:
|
||||
warnings.append("Proton did not create a relay log for this run.")
|
||||
elif trace.game_dll_base is None:
|
||||
warnings.append("The relay log did not record the loaded game.dll base address.")
|
||||
if timed_out:
|
||||
warnings.append("The diagnostic reached its timeout and terminated the launcher process.")
|
||||
if not registry_restored:
|
||||
warnings.append("The temporary Wine relay registry value could not be restored.")
|
||||
if not raw_relay_deleted:
|
||||
warnings.append("The private raw relay directory could not be removed.")
|
||||
return conclusion, tuple(warnings)
|
||||
|
||||
|
||||
def _write_report(report: LoginTraceReport) -> None:
|
||||
summary_path = Path(report.summary_file)
|
||||
report_path = Path(report.report_file)
|
||||
summary_path.write_text(
|
||||
json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
match = (
|
||||
"not observed"
|
||||
if report.account_id_exact_match is None
|
||||
else "yes" if report.account_id_exact_match else "no"
|
||||
)
|
||||
report_path.write_text(
|
||||
"# PristonTale EU login trace\n\n"
|
||||
f"- Started: {report.started_at}\n"
|
||||
f"- Runtime: {report.runtime_kind}\n"
|
||||
f"- Account-ID comparison observed: {report.comparison_observed}\n"
|
||||
f"- Exact account-ID match: {match}\n"
|
||||
f"- Conclusion: `{report.conclusion}`\n"
|
||||
f"- Registry restored: {report.registry_restored}\n"
|
||||
f"- Raw relay data deleted: {report.raw_relay_deleted}\n\n"
|
||||
"No account names, passwords, or packet payloads are retained in this report.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _sha256_if_file(path: Path) -> str | None:
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
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()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _hex_or_none(value: int | None) -> str | None:
|
||||
return f"0x{value:08x}" if value is not None else None
|
||||
Reference in New Issue
Block a user