diff --git a/install.sh b/install.sh
index 38cb81c..fd08b90 100755
--- a/install.sh
+++ b/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"
diff --git a/ptlinux/login_diagnostics.py b/ptlinux/login_diagnostics.py
new file mode 100644
index 0000000..264c876
--- /dev/null
+++ b/ptlinux/login_diagnostics.py
@@ -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
diff --git a/ptlinux/login_trace.py b/ptlinux/login_trace.py
new file mode 100644
index 0000000..9048c55
--- /dev/null
+++ b/ptlinux/login_trace.py
@@ -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[0-9a-f]+):',
+ re.IGNORECASE,
+)
+_COMPARE_RETURN = re.compile(
+ r'(?P[0-9a-f]{4}):Ret\s+KERNEL32\.lstrcmpA\(\) '
+ r'retval=(?P[0-9a-f]+) ret=(?P[0-9a-f]+)',
+ re.IGNORECASE,
+)
+_RELAY_VALUE = re.compile(
+ rf"^\s*{re.escape(RELAY_REGISTRY_VALUE)}\s+REG_SZ(?:\s+(?P.*))?$",
+ re.MULTILINE,
+)
+_RELAY_CALL = re.compile(
+ r'^(?P.*?Call\s+KERNEL32\.lstrcmpA)\(.*\) '
+ r'ret=(?P[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')}() "
+ 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