Improvements to try resolve the issues with login checksums.

This commit is contained in:
2026-08-28 22:54:46 +02:00
parent 06672589ad
commit 1e1f76a000
10 changed files with 740 additions and 33 deletions

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "0.5.11"
__version__ = "0.5.13"

View File

@@ -353,6 +353,15 @@ def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) -
print(f"game.dll: {report.game_dll}")
if report.game_dll_sha256:
print(f"DLL SHA-256: {report.game_dll_sha256}")
print(
"Managed source: "
+ ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS")
)
if report.depot_build is not None:
print(
f"Depot: build {report.depot_build} "
f"({report.depot_version or 'unknown version'})"
)
world_fix_status = "enabled" if report.world_fix_enabled else "retired"
print(f"World fix: {world_fix_status}")
if report.world_fix_disabled_reason:
@@ -676,6 +685,12 @@ def _play_locked(
native_log_baseline,
game_started_ns,
)
runtime_issues = _runtime_issue_summary(outcome.stderr)
if (
selection.kind == "proton7-container"
and runtime_issues["host_futex_fatal_count"]
):
runtime_issues["recommended_one_variable_comparison"] = "proton7"
world_fix_status = None
_emit(
{
@@ -686,6 +701,7 @@ def _play_locked(
"setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome),
"current_game_log": current_game_log,
"runtime_issues": runtime_issues,
"world_fix": world_fix_status,
},
as_json,
@@ -736,6 +752,19 @@ def _current_game_log(
}
def _runtime_issue_summary(stderr: str) -> dict[str, object]:
folded = stderr.casefold()
futex_message = "the futex facility returned an unexpected error code"
wrong_elf_message = "wrong elf class: elfclass64"
return {
"host_futex_fatal_count": folded.count(futex_message),
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
"gstreamer_stream_error": "winegstreamer error" in folded,
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
}
def _print_interrupted_game_log(report: dict[str, object]) -> None:
print(f"\nCurrent game log: {report['path']}", file=sys.stderr)
if report["module_code_sync_error"]:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections import deque
from dataclasses import asdict, dataclass
from datetime import datetime
import json
@@ -16,6 +17,44 @@ from typing import Callable, Mapping, Sequence, TextIO
OutputLineFilter = Callable[[str], str | None]
MAX_CAPTURE_CHARACTERS = 1024 * 1024
class BoundedTextCapture:
"""Retain the newest subprocess output without unbounded memory growth."""
def __init__(self, max_characters: int = MAX_CAPTURE_CHARACTERS) -> None:
if max_characters <= 0:
raise ValueError("capture size must be positive")
self._max_characters = max_characters
self._parts: deque[str] = deque()
self._characters = 0
self._lock = threading.Lock()
def append(self, value: str) -> None:
if not value:
return
with self._lock:
if len(value) >= self._max_characters:
self._parts.clear()
self._parts.append(value[-self._max_characters:])
self._characters = self._max_characters
return
self._parts.append(value)
self._characters += len(value)
while self._characters > self._max_characters:
overflow = self._characters - self._max_characters
oldest = self._parts[0]
if len(oldest) <= overflow:
self._parts.popleft()
self._characters -= len(oldest)
else:
self._parts[0] = oldest[overflow:]
self._characters -= overflow
def getvalue(self) -> str:
with self._lock:
return "".join(self._parts)
@dataclass(frozen=True)
@@ -103,8 +142,8 @@ class CommandRunner:
outcome = CommandOutcome(
argv=arguments,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
stdout=_bounded_text(completed.stdout),
stderr=_bounded_text(completed.stderr),
duration_seconds=time.monotonic() - started,
timed_out=False,
dry_run=False,
@@ -113,8 +152,8 @@ class CommandRunner:
outcome = CommandOutcome(
argv=arguments,
returncode=None,
stdout=_as_text(exc.stdout),
stderr=_as_text(exc.stderr),
stdout=_bounded_text(_as_text(exc.stdout)),
stderr=_bounded_text(_as_text(exc.stderr)),
duration_seconds=time.monotonic() - started,
timed_out=True,
dry_run=False,
@@ -144,12 +183,12 @@ class CommandRunner:
bufsize=1,
start_new_session=True,
)
stdout: list[str] = []
stderr: list[str] = []
stdout = BoundedTextCapture()
stderr = BoundedTextCapture()
def relay(
stream: TextIO,
capture: list[str],
capture: BoundedTextCapture,
destination: TextIO,
) -> None:
try:
@@ -196,8 +235,8 @@ class CommandRunner:
return CommandOutcome(
argv=arguments,
returncode=None if timed_out else returncode,
stdout="".join(stdout),
stderr="".join(stderr),
stdout=stdout.getvalue(),
stderr=stderr.getvalue(),
duration_seconds=time.monotonic() - started,
timed_out=timed_out,
dry_run=False,
@@ -228,6 +267,10 @@ def _as_text(value: bytes | str | None) -> str:
return value
def _bounded_text(value: str) -> str:
return value[-MAX_CAPTURE_CHARACTERS:]
def _terminate_process_group(
process: subprocess.Popen[str],
*,

View File

@@ -38,6 +38,9 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
ALWAYS_VERIFY_PATHS = frozenset(
{"game.exe", "game.dll", "gamese.exe", "gamese.dll"}
)
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
@@ -453,7 +456,11 @@ def plan_update(
continue
except OSError as exc:
raise DepotError(f"cannot inspect {file.path}: {exc}") from exc
if repair or file.build == snapshot.build:
if (
repair
or file.build == snapshot.build
or file.path.casefold() in ALWAYS_VERIFY_PATHS
):
if _sha1(target).casefold() != file.sha1.casefold():
downloads.append(PlannedDownload(file, "hash mismatch"))

View File

@@ -98,6 +98,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
"Vulkan diagnostics",
),
]
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
@@ -107,6 +108,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
)
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
@@ -158,7 +160,7 @@ def _package_hint(distribution: str) -> str | None:
if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}:
return (
"Install python, tk, wine, winetricks, umu-launcher, cabextract, "
"openssl, and the matching lib32 Vulkan driver with Pacman."
"openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
)
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return (
@@ -182,3 +184,55 @@ def _has_steam_proton7(environment: Mapping[str, str]) -> bool:
if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
return True
return False
def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
if architecture.casefold() not in {"x86_64", "amd64"}:
return []
if distribution in {"arch", "cachyos", "endeavouros", "manjaro", "garuda"}:
return [
_optional_tool_status(
"lib32-vulkan",
"/usr/lib32/libvulkan.so.1",
"32-bit Vulkan runtime compatibility layer",
),
_optional_tool_status(
"lib32-gstreamer",
"/usr/lib32/gstreamer-1.0",
"32-bit gstreamer runtime compatibility modules",
),
]
return []
def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
if distribution not in {"arch", "cachyos", "endeavouros", "manjaro", "garuda"}:
return []
if architecture.casefold() not in {"x86_64", "amd64"}:
return [
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
]
warnings: list[str] = []
if not _path_exists("/usr/lib32/libvulkan.so.1"):
warnings.append(
"Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit "
"Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK."
)
if not _path_exists("/usr/lib32/gstreamer-1.0"):
warnings.append(
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
"other media does not play. Missing media plugins are not evidence of an "
"authentication failure."
)
return warnings
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
if _path_exists(path):
return ToolStatus(name, path, required=False, purpose=purpose)
return ToolStatus(name, None, required=False, purpose=purpose)
def _path_exists(path: str) -> bool:
entry = Path(path)
return entry.is_file() or entry.is_dir()

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
from pathlib import Path
import platform
import socket
@@ -17,6 +18,7 @@ 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 (
@@ -41,9 +43,6 @@ 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):
@@ -82,7 +81,13 @@ class LoginDiagnosticReport:
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
@@ -168,7 +173,16 @@ def inspect_login_environment(
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 = (
@@ -186,11 +200,13 @@ def inspect_login_environment(
prefix_ready,
directx_ready,
latest_log,
runtime_matches_managed_source,
)
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
@@ -208,7 +224,13 @@ def inspect_login_environment(
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,
@@ -331,6 +353,7 @@ def _diagnostic_warnings(
prefix_ready: bool,
directx_ready: bool,
latest_log: Path | None,
runtime_matches_managed_source: bool,
) -> tuple[str, ...]:
warnings: list[str] = []
if not probe.reachable:
@@ -356,6 +379,11 @@ def _diagnostic_warnings(
"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:
@@ -367,6 +395,24 @@ def _diagnostic_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:

View File

@@ -25,6 +25,13 @@ from .runtime import (
)
from .settings import Settings
from ptdiag.logs import (
LogFileState,
newest_current_native_log,
parse_current_run_log,
snapshot_native_logs,
)
RELAY_REGISTRY_KEY = r"HKCU\Software\Wine\Debug"
RELAY_REGISTRY_VALUE = "RelayInclude"
@@ -33,7 +40,7 @@ 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]+):',
r'Loaded L"[^"\r\n]*[\\/]game\.dll" at (?P<base>[0-9a-f]+):',
re.IGNORECASE,
)
_COMPARE_RETURN = re.compile(
@@ -78,6 +85,21 @@ class LoginTraceReport:
native_log_before_modified_at: str | None
native_log_after: str | None
native_log_after_modified_at: str | None
current_game_log: str | None
current_game_log_modified_at: str | None
failed_file_count: int
failed_import_scenes: tuple[str, ...]
load_texture_to_material_failed: bool
import_scene_threw: bool
stage_without_new_stage: bool
module_code_sync_error: bool
module_code_sync_left: int | None
module_code_sync_right: int | None
module_code_sync_delta: int | None
banned_code_1: bool
runtime_futex_error: bool
gstreamer_wrong_elf_class: bool
gstreamer_stream_error: bool
game_returncode: int | None
game_timed_out: bool
game_duration_seconds: float
@@ -121,22 +143,41 @@ def run_login_trace(
raise RuntimeError("the selected Proton 7 runtime path is incomplete")
started = datetime.now().astimezone()
started_ns = int(started.timestamp() * 1_000_000_000)
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)
native_log_directory = paths.game_save / "logs"
native_log_baseline = snapshot_native_logs(
native_log_directory,
patterns=("log_*.txt",),
)
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
current_log_path = None
current_log_modified_at = None
current_log_failure_count = 0
current_failed_import_scenes: list[str] = []
current_load_texture_to_material_failed = False
current_import_scene_threw = False
current_stage_without_new_stage = False
current_module_code_sync_error = False
current_module_code_sync_left = None
current_module_code_sync_right = None
current_module_code_sync_delta = None
current_banned_code_1 = False
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)
raw_path = Path(raw_directory)
raw_directory_path = raw_path
try:
_set_relay_filter(paths, selection, runner, RELAY_INCLUDE_FILTER)
outcome = run_game(
@@ -150,14 +191,14 @@ def run_login_trace(
check=False,
extra_environment={
"PROTON_LOG": "1",
"PROTON_LOG_DIR": str(raw_directory_path),
"PROTON_LOG_DIR": str(raw_path),
"SteamAppId": "0",
"SteamGameId": "0",
"WINEDEBUG": "+relay,+loaddll",
},
output_line_filter=redact_relay_output_line,
)
trace = parse_relay_logs(raw_directory_path.rglob("*.log"))
trace = parse_relay_logs(raw_path.rglob("*.log"))
finally:
registry_restored = _restore_relay_filter(
paths,
@@ -168,6 +209,52 @@ def run_login_trace(
if outcome is None:
raise RuntimeError("the instrumented game process did not start")
current_game_log = _current_game_log(
native_log_directory,
native_log_baseline,
started_ns,
)
if current_game_log is not None:
current_log_path = _string_or_none(current_game_log.get("path"))
current_log_modified_at = _string_or_none(
current_game_log.get("modified_at")
)
current_log_failure_count = _integer_or_zero(
current_game_log.get("failed_file_count")
)
failed_scenes = current_game_log.get("failed_import_scenes")
if isinstance(failed_scenes, (list, tuple)):
current_failed_import_scenes = [
scene for scene in failed_scenes if isinstance(scene, str)
]
current_load_texture_to_material_failed = bool(
current_game_log.get("load_texture_to_material_failed")
)
current_import_scene_threw = bool(current_game_log.get("import_scene_threw"))
current_stage_without_new_stage = bool(
current_game_log.get("stage_without_new_stage")
)
current_module_code_sync_error = bool(
current_game_log.get("module_code_sync_error")
)
current_module_code_sync_left = _integer_or_none(
current_game_log.get("module_code_sync_left")
)
current_module_code_sync_right = _integer_or_none(
current_game_log.get("module_code_sync_right")
)
current_module_code_sync_delta = _integer_or_none(
current_game_log.get("module_code_sync_delta")
)
current_banned_code_1 = bool(current_game_log.get("banned_code_1"))
runtime_futex_error = "futex facility returned an unexpected error code" in (
outcome.stderr.casefold()
)
gstreamer_wrong_elf_class = (
"gstreamer-warning" in outcome.stderr.casefold()
and "wrong elf class" in outcome.stderr.casefold()
)
gstreamer_stream_error = "winegstreamer error" in outcome.stderr.casefold()
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)
@@ -176,6 +263,9 @@ def run_login_trace(
)
conclusion, warnings = _conclusion(
trace,
module_code_sync_error=current_module_code_sync_error,
banned_code_1=current_banned_code_1,
runtime_futex_error=runtime_futex_error,
registry_restored=registry_restored,
raw_relay_deleted=raw_relay_deleted,
timed_out=outcome.timed_out,
@@ -200,6 +290,21 @@ def run_login_trace(
native_log_before_modified_at=log_before_modified,
native_log_after=log_after,
native_log_after_modified_at=log_after_modified,
current_game_log=current_log_path,
current_game_log_modified_at=current_log_modified_at,
failed_file_count=current_log_failure_count,
failed_import_scenes=tuple(current_failed_import_scenes),
load_texture_to_material_failed=current_load_texture_to_material_failed,
import_scene_threw=current_import_scene_threw,
stage_without_new_stage=current_stage_without_new_stage,
module_code_sync_error=current_module_code_sync_error,
module_code_sync_left=current_module_code_sync_left,
module_code_sync_right=current_module_code_sync_right,
module_code_sync_delta=current_module_code_sync_delta,
banned_code_1=current_banned_code_1,
runtime_futex_error=runtime_futex_error,
gstreamer_wrong_elf_class=gstreamer_wrong_elf_class,
gstreamer_stream_error=gstreamer_stream_error,
game_returncode=outcome.returncode,
game_timed_out=outcome.timed_out,
game_duration_seconds=round(outcome.duration_seconds, 3),
@@ -393,15 +498,67 @@ def _native_log_snapshot(
return str(path), modified
def _current_game_log(
directory: Path,
baseline: dict[str, LogFileState],
started_ns: int,
) -> dict[str, object] | None:
state = newest_current_native_log(
directory,
baseline,
started_ns,
patterns=("log_*.txt",),
)
if state is None:
return None
analysis = parse_current_run_log(
state,
baseline,
)
return {
"path": str(state.path),
"modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000
).astimezone().isoformat(),
"failed_file_count": len(analysis.failed_files),
"failed_import_scenes": tuple(analysis.failed_import_scenes),
"load_texture_to_material_failed": analysis.load_texture_to_material_failed,
"import_scene_threw": analysis.import_scene_threw,
"stage_without_new_stage": analysis.stage_without_new_stage,
"module_code_sync_error": analysis.module_code_sync_error,
"module_code_sync_left": analysis.module_code_sync_left,
"module_code_sync_right": analysis.module_code_sync_right,
"module_code_sync_delta": analysis.module_code_sync_delta,
"banned_code_1": analysis.banned_code_1,
}
def _conclusion(
trace: RelayTraceResult,
*,
module_code_sync_error: bool,
banned_code_1: bool,
runtime_futex_error: bool,
registry_restored: bool,
raw_relay_deleted: bool,
timed_out: bool,
) -> tuple[str, tuple[str, ...]]:
warnings: list[str] = []
if trace.comparison_results:
if module_code_sync_error:
conclusion = "module-code-sync-error"
warnings.append(
"The current-run native game log recorded a module-code integrity "
"mismatch. Do not retry this account with an instrumented or modified "
"game module."
)
elif banned_code_1:
conclusion = "banned-code-1"
warnings.append(
"The current-run native game log recorded Banned Code 1. Confirm the "
"same account with the unmodified publisher launcher before changing "
"the local runtime again."
)
elif trace.comparison_results:
if trace.comparison_results[-1]:
conclusion = "account-id-match"
else:
@@ -422,6 +579,12 @@ def _conclusion(
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 runtime_futex_error:
warnings.append(
"A host-side process emitted glibc's fatal futex message. This is not a "
"game module-code result; compare the same prefix with direct Proton 7 "
"outside UMU before attributing the login failure to authentication."
)
if not registry_restored:
warnings.append("The temporary Wine relay registry value could not be restored.")
if not raw_relay_deleted:
@@ -445,6 +608,13 @@ def _write_report(report: LoginTraceReport) -> None:
"# PristonTale EU login trace\n\n"
f"- Started: {report.started_at}\n"
f"- Runtime: {report.runtime_kind}\n"
f"- Game.exe SHA-256: `{report.game_exe_sha256 or 'missing'}`\n"
f"- game.dll SHA-256: `{report.game_dll_sha256 or 'missing'}`\n"
f"- Current native log: {report.current_game_log or 'not found'}\n"
f"- Module-code mismatch: {report.module_code_sync_error}\n"
f"- Banned Code 1 in native log: {report.banned_code_1}\n"
f"- Host futex fatal message: {report.runtime_futex_error}\n"
f"- GStreamer wrong-ELF warnings: {report.gstreamer_wrong_elf_class}\n"
f"- Account-ID comparison observed: {report.comparison_observed}\n"
f"- Exact account-ID match: {match}\n"
f"- Conclusion: `{report.conclusion}`\n"
@@ -455,6 +625,21 @@ def _write_report(report: LoginTraceReport) -> None:
)
def _string_or_none(value: object) -> str | None:
return value if isinstance(value, str) else None
def _integer_or_none(value: object) -> int | None:
if isinstance(value, bool) or not isinstance(value, int):
return None
return value
def _integer_or_zero(value: object) -> int:
result = _integer_or_none(value)
return result if result is not None else 0
def _sha256_if_file(path: Path) -> str | None:
try:
if not path.is_file():

View File

@@ -39,6 +39,10 @@ CURRENT_V21_BINARY_HASHES = V21_BINARY_HASHES
PATCHED_V21_GAME_DLL_SHA256 = (
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
)
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
class OverlayError(RuntimeError):
@@ -147,10 +151,28 @@ def prepare_runtime_view(
# ``use_world_fix`` is retained as a compatibility keyword. The retired
# helper is never selected and no code path may replace the publisher DLL.
_ = use_world_fix
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
(
binary_action,
game_exe_source,
game_dll_source,
source_binary_hashes,
) = _runtime_game_binary_profile(
paths,
source,
)
reused = _reuse_runtime_view(
paths,
source,
inspection,
action,
game_save,
binary_action,
game_exe_source,
game_dll_source,
source_binary_hashes,
)
if reused is not None:
return reused
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
@@ -223,7 +245,13 @@ def prepare_runtime_view(
created_at=datetime.now().astimezone().isoformat(),
links_created=links_created,
)
_write_json(paths.runtime_manifest, asdict(result))
_write_json(
paths.runtime_manifest,
{
"configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION,
**asdict(result),
},
)
return result
@@ -271,6 +299,32 @@ def select_binary_action(
return "verified-source-pair"
if source_hashes == CURRENT_V21_BINARY_HASHES:
return "verified-v21-publisher-pair"
game_exe_sha256 = source_hashes.get("Game.exe")
game_dll_sha256 = source_hashes.get("game.dll")
if game_dll_sha256 in {
PATCHED_V21_GAME_DLL_SHA256,
RETIRED_POST_AUTH_GAME_DLL_SHA256,
}:
raise OverlayError(
"a retired experimental game.dll is installed; run update --repair "
"before launching"
)
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"],
COMPATIBILITY_BINARY_HASHES["game.dll"],
}
if (
game_exe_sha256 in known_game_exe_hashes
or game_dll_sha256 in known_game_dll_hashes
):
raise OverlayError(
"Game.exe and game.dll are from different verified releases; run "
"update --repair before launching"
)
return "upstream-pair"
@@ -392,13 +446,14 @@ def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
candidate = path.resolve(strict=True)
break
if candidate is not None:
selected = candidate
allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True))
if not any(
candidate == root or candidate.is_relative_to(root)
selected == root or selected.is_relative_to(root)
for root in allowed_roots
):
raise OverlayError(f"refusing to migrate game save outside managed roots: {candidate}")
_reject_save_symlinks(candidate)
raise OverlayError(f"refusing to migrate game save outside managed roots: {selected}")
_reject_save_symlinks(selected)
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.build-{uuid.uuid4().hex}"
@@ -423,10 +478,213 @@ def _reject_save_symlinks(root: Path) -> None:
raise OverlayError(f"persistent game save contains a symbolic link: {path}")
def _reuse_runtime_view(
paths: AppPaths,
source: Path,
inspection: SceneInspection,
scene_action: str,
game_save: Path,
binary_action: str,
game_exe_source: Path,
game_dll_source: Path,
source_binary_hashes: Mapping[str, str],
) -> OverlayResult | None:
result = _read_overlay_result(paths.runtime_manifest)
if result is None:
return None
if result.source_game != str(source):
return None
if result.runtime_view != str(paths.runtime_view):
return None
if result.source_scene_sha256 != inspection.file_sha256:
return None
if result.scene_action != scene_action or result.binary_action != binary_action:
return None
if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
return None
if result.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
if not _runtime_layout_ready(
paths,
source,
game_save,
game_exe_source,
game_dll_source,
result.links_created,
):
return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
try:
if _sha256(runtime_scene) != result.runtime_scene_sha256:
return None
except OSError:
return None
return result
def _runtime_layout_ready(
paths: AppPaths,
source: Path,
game_save: Path,
game_exe_source: Path,
game_dll_source: Path,
expected_links: int,
) -> bool:
runtime = paths.runtime_view
if runtime.is_symlink() or not runtime.is_dir():
return False
top_level_exceptions = {"game", "save"}
top_level_special = {"game", "save"}
for name, binary_source in (
("Game.exe", game_exe_source),
("game.dll", game_dll_source),
):
if binary_source != source / name:
top_level_exceptions.add(name)
top_level_special.add(name)
top_level_ready, top_level_links = _linked_level_ready(
source,
runtime,
except_names=top_level_exceptions,
special_names=top_level_special,
)
if not top_level_ready:
return False
if not _symlink_points_to(runtime / "save", game_save):
return False
if game_exe_source != source / "Game.exe" and not _symlink_points_to(
runtime / "Game.exe",
game_exe_source,
):
return False
if game_dll_source != source / "game.dll" and not _symlink_points_to(
runtime / "game.dll",
game_dll_source,
):
return False
game_source = source / "game"
game_target = runtime / "game"
if game_target.is_symlink() or not game_target.is_dir():
return False
game_ready, game_links = _linked_level_ready(
game_source,
game_target,
except_names={"maps"},
special_names={"maps"},
)
if not game_ready:
return False
maps_source = game_source / "maps"
maps_target = game_target / "maps"
if maps_target.is_symlink() or not maps_target.is_dir():
return False
maps_ready, maps_links = _linked_level_ready(
maps_source,
maps_target,
except_names={"WhisperingVale.rovug"},
special_names={"WhisperingVale.rovug"},
)
if not maps_ready:
return False
runtime_scene = maps_target / "WhisperingVale.rovug"
if runtime_scene.is_symlink() or not runtime_scene.is_file():
return False
actual_links = top_level_links + game_links + maps_links + 1
if game_exe_source != source / "Game.exe":
actual_links += 1
if game_dll_source != source / "game.dll":
actual_links += 1
return actual_links == expected_links
def _linked_level_ready(
source: Path,
target: Path,
*,
except_names: set[str],
special_names: set[str],
) -> tuple[bool, int]:
try:
source_children = {
child.name: child
for child in source.iterdir()
if child.name not in except_names
}
target_names = {child.name for child in target.iterdir()}
except OSError:
return False, 0
if target_names != set(source_children) | special_names:
return False, 0
for name, child in source_children.items():
if not _symlink_points_to(target / name, child):
return False, 0
return True, len(source_children)
def _symlink_points_to(link: Path, expected_target: Path) -> bool:
if not link.is_symlink():
return False
try:
return link.resolve(strict=True) == expected_target.resolve(strict=True)
except (OSError, RuntimeError):
return False
def _read_overlay_result(path: Path) -> OverlayResult | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict):
return None
if value.get("configuration_version") != RUNTIME_VIEW_CONFIGURATION_VERSION:
return None
string_fields = (
"source_game",
"runtime_view",
"source_scene_sha256",
"runtime_scene_sha256",
"scene_action",
"binary_action",
"game_exe_sha256",
"game_dll_sha256",
"created_at",
)
if not all(isinstance(value.get(name), str) for name in string_fields):
return None
links_created = value.get("links_created")
if isinstance(links_created, bool) or not isinstance(links_created, int):
return None
if links_created < 0:
return None
return OverlayResult(
source_game=value["source_game"],
runtime_view=value["runtime_view"],
source_scene_sha256=value["source_scene_sha256"],
runtime_scene_sha256=value["runtime_scene_sha256"],
scene_action=value["scene_action"],
binary_action=value["binary_action"],
game_exe_sha256=value["game_exe_sha256"],
game_dll_sha256=value["game_dll_sha256"],
created_at=value["created_at"],
links_created=links_created,
)
def _runtime_game_binary_sources(
paths: AppPaths,
source: Path,
) -> tuple[str, Path, Path]:
action, game_exe, game_dll, _ = _runtime_game_binary_profile(paths, source)
return action, game_exe, game_dll
def _runtime_game_binary_profile(
paths: AppPaths,
source: Path,
) -> tuple[str, Path, Path, dict[str, str]]:
cache = paths.compatibility_binaries
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
source_hashes = _binary_hashes(source)
@@ -435,6 +693,7 @@ def _runtime_game_binary_sources(
action,
source / "Game.exe",
source / "game.dll",
source_hashes,
)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
@@ -33,6 +34,7 @@ WINETRICKS_CODEPAGE_PROBE = (
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
RUNTIME_DEFAULT_SYNC = "runtime-default"
CACHYOS_MARKER = "cachyos"
GAME_RUNTIME_CONFIGURATION_VERSION = 1
class RuntimeErrorPT(RuntimeError):
@@ -159,6 +161,8 @@ def prepare_game_runtime(
elif selection.kind in {"proton7", "proton7-container"}:
if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
if not runner.dry_run and _game_runtime_ready(paths, selection):
return results
if not runner.dry_run:
paths.compat_data.mkdir(parents=True, exist_ok=True)
proton = Path(selection.proton_path)
@@ -237,11 +241,13 @@ def prepare_game_runtime(
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
if not runner.dry_run:
_write_json(
paths.state / "game-runtime.json",
paths.game_runtime_marker,
{
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
"configured_at": datetime.now().astimezone().isoformat(),
"native_d3d43": True,
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
"proton_identity": _proton_identity(selection),
"runtime": asdict(selection),
},
)
@@ -572,6 +578,69 @@ def _proton_prefix_ready(prefix: Path) -> bool:
).exists()
def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
marker = _read_json(paths.game_runtime_marker)
identity = _proton_identity(selection)
if marker is None or identity is None:
return False
if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION:
return False
if marker.get("runtime") != asdict(selection):
return False
if marker.get("proton_identity") != identity:
return False
if marker.get("native_d3d43") is not True:
return False
if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE:
return False
if not _proton_prefix_ready(paths.proton_prefix):
return False
if not native_d3d_ready(paths.proton_prefix):
return False
if not _d3d9_override_ready(paths.proton_prefix):
return False
return _proton_game_link_ready(paths)
def _d3d9_override_ready(prefix: Path) -> bool:
try:
registry = (prefix / "user.reg").read_text(
encoding="utf-8",
errors="replace",
).casefold()
except OSError:
return False
return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry
def _proton_game_link_ready(paths: AppPaths) -> bool:
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
if not link.is_symlink():
return False
try:
return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True)
except (OSError, RuntimeError):
return False
def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None:
if not selection.proton_path:
return None
proton = Path(selection.proton_path)
identity_paths = {
"proton": proton,
"version": proton.parent / "version",
"toolmanifest": proton.parent / "toolmanifest.vdf",
}
identity: dict[str, str] = {}
for name, path in identity_paths.items():
try:
identity[name] = _sha256(path)
except OSError:
return None
return identity
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
wine = directory / "wine"
@@ -586,3 +655,19 @@ def _write_json(path: Path, value: object) -> None:
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def _read_json(path: Path) -> dict[str, object] | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) 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()