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