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

@@ -31,11 +31,10 @@ then
exit 1 exit 1
fi fi
if [ -n "${PYTHONPATH:-}" ] # Never let an unpacked older installer in the caller's current directory
then # shadow the selected installed release. Children started by the dashboard
export PYTHONPATH="${release_root}:${PYTHONPATH}" # inherit the same safe-path policy and exact package root.
else export PYTHONSAFEPATH=1
export PYTHONPATH="${release_root}" export PYTHONPATH="${release_root}"
fi
exec "$python_bin" -m ptlinux "$@" exec "$python_bin" -P -m ptlinux "$@"

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"] __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}") print(f"game.dll: {report.game_dll}")
if report.game_dll_sha256: if report.game_dll_sha256:
print(f"DLL SHA-256: {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" world_fix_status = "enabled" if report.world_fix_enabled else "retired"
print(f"World fix: {world_fix_status}") print(f"World fix: {world_fix_status}")
if report.world_fix_disabled_reason: if report.world_fix_disabled_reason:
@@ -676,6 +685,12 @@ def _play_locked(
native_log_baseline, native_log_baseline,
game_started_ns, 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 world_fix_status = None
_emit( _emit(
{ {
@@ -686,6 +701,7 @@ def _play_locked(
"setup_commands": [asdict(result) for result in setup_results], "setup_commands": [asdict(result) for result in setup_results],
"game": asdict(outcome), "game": asdict(outcome),
"current_game_log": current_game_log, "current_game_log": current_game_log,
"runtime_issues": runtime_issues,
"world_fix": world_fix_status, "world_fix": world_fix_status,
}, },
as_json, 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: def _print_interrupted_game_log(report: dict[str, object]) -> None:
print(f"\nCurrent game log: {report['path']}", file=sys.stderr) print(f"\nCurrent game log: {report['path']}", file=sys.stderr)
if report["module_code_sync_error"]: if report["module_code_sync_error"]:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections import deque
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
import json import json
@@ -16,6 +17,44 @@ from typing import Callable, Mapping, Sequence, TextIO
OutputLineFilter = Callable[[str], str | None] 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) @dataclass(frozen=True)
@@ -103,8 +142,8 @@ class CommandRunner:
outcome = CommandOutcome( outcome = CommandOutcome(
argv=arguments, argv=arguments,
returncode=completed.returncode, returncode=completed.returncode,
stdout=completed.stdout, stdout=_bounded_text(completed.stdout),
stderr=completed.stderr, stderr=_bounded_text(completed.stderr),
duration_seconds=time.monotonic() - started, duration_seconds=time.monotonic() - started,
timed_out=False, timed_out=False,
dry_run=False, dry_run=False,
@@ -113,8 +152,8 @@ class CommandRunner:
outcome = CommandOutcome( outcome = CommandOutcome(
argv=arguments, argv=arguments,
returncode=None, returncode=None,
stdout=_as_text(exc.stdout), stdout=_bounded_text(_as_text(exc.stdout)),
stderr=_as_text(exc.stderr), stderr=_bounded_text(_as_text(exc.stderr)),
duration_seconds=time.monotonic() - started, duration_seconds=time.monotonic() - started,
timed_out=True, timed_out=True,
dry_run=False, dry_run=False,
@@ -144,12 +183,12 @@ class CommandRunner:
bufsize=1, bufsize=1,
start_new_session=True, start_new_session=True,
) )
stdout: list[str] = [] stdout = BoundedTextCapture()
stderr: list[str] = [] stderr = BoundedTextCapture()
def relay( def relay(
stream: TextIO, stream: TextIO,
capture: list[str], capture: BoundedTextCapture,
destination: TextIO, destination: TextIO,
) -> None: ) -> None:
try: try:
@@ -196,8 +235,8 @@ class CommandRunner:
return CommandOutcome( return CommandOutcome(
argv=arguments, argv=arguments,
returncode=None if timed_out else returncode, returncode=None if timed_out else returncode,
stdout="".join(stdout), stdout=stdout.getvalue(),
stderr="".join(stderr), stderr=stderr.getvalue(),
duration_seconds=time.monotonic() - started, duration_seconds=time.monotonic() - started,
timed_out=timed_out, timed_out=timed_out,
dry_run=False, dry_run=False,
@@ -228,6 +267,10 @@ def _as_text(value: bytes | str | None) -> str:
return value return value
def _bounded_text(value: str) -> str:
return value[-MAX_CAPTURE_CHARACTERS:]
def _terminate_process_group( def _terminate_process_group(
process: subprocess.Popen[str], 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 PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
MAX_LINE_ENDING_RECOVERY_BYTES = 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}$") SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
@@ -453,7 +456,11 @@ def plan_update(
continue continue
except OSError as exc: except OSError as exc:
raise DepotError(f"cannot inspect {file.path}: {exc}") from 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(): if _sha1(target).casefold() != file.sha1.casefold():
downloads.append(PlannedDownload(file, "hash mismatch")) downloads.append(PlannedDownload(file, "hash mismatch"))

View File

@@ -98,6 +98,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
"Vulkan diagnostics", "Vulkan diagnostics",
), ),
] ]
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
warnings: list[str] = [] warnings: list[str] = []
if platform.machine().casefold() not in {"x86_64", "amd64"}: if platform.machine().casefold() not in {"x86_64", "amd64"}:
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.") 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( warnings.append(
"No game runtime was found: install umu-launcher or Steam Proton 7.0." "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) required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport( return DoctorReport(
ok=required_ok and not any("No game runtime" in warning for warning in warnings), 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"}: if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}:
return ( return (
"Install python, tk, wine, winetricks, umu-launcher, cabextract, " "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"}: if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return ( 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(): if (steam_root / "steamapps/common/Proton 7.0/proton").is_file():
return True return True
return False 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 dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
import hashlib import hashlib
import json
from pathlib import Path from pathlib import Path
import platform import platform
import socket import socket
@@ -17,6 +18,7 @@ from .overlay import (
COMPATIBILITY_BINARY_HASHES, COMPATIBILITY_BINARY_HASHES,
CURRENT_V21_BINARY_HASHES, CURRENT_V21_BINARY_HASHES,
PATCHED_V21_GAME_DLL_SHA256, PATCHED_V21_GAME_DLL_SHA256,
RETIRED_POST_AUTH_GAME_DLL_SHA256,
) )
from .paths import AppPaths from .paths import AppPaths
from .runtime import ( from .runtime import (
@@ -41,9 +43,6 @@ DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0
VERIFIED_GAME_PROFILES = frozenset( VERIFIED_GAME_PROFILES = frozenset(
{"v21.75.83", "verified-compatibility"} {"v21.75.83", "verified-compatibility"}
) )
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
class _Closable(Protocol): class _Closable(Protocol):
@@ -82,7 +81,13 @@ class LoginDiagnosticReport:
game_exe_sha256: str | None game_exe_sha256: str | None
game_dll: str game_dll: str
game_dll_sha256: str | None 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 game_profile: str
depot_build: int | None
depot_version: str | None
depot_completed_at: str | None
world_fix_helper: str world_fix_helper: str
world_fix_helper_sha256: str | None world_fix_helper_sha256: str | None
world_fix_helper_ready: bool world_fix_helper_ready: bool
@@ -168,7 +173,16 @@ def inspect_login_environment(
game_dll = game_root / "game.dll" game_dll = game_root / "game.dll"
game_exe_sha256 = _sha256_if_file(game_exe) game_exe_sha256 = _sha256_if_file(game_exe)
game_dll_sha256 = _sha256_if_file(game_dll) 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) 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 = packaged_world_fix_helper()
world_fix_helper_sha256 = _sha256_if_file(world_fix_helper) world_fix_helper_sha256 = _sha256_if_file(world_fix_helper)
world_fix_helper_ready = ( world_fix_helper_ready = (
@@ -186,11 +200,13 @@ def inspect_login_environment(
prefix_ready, prefix_ready,
directx_ready, directx_ready,
latest_log, latest_log,
runtime_matches_managed_source,
) )
return LoginDiagnosticReport( return LoginDiagnosticReport(
preflight_ok=( preflight_ok=(
probe.reachable probe.reachable
and game_profile in VERIFIED_GAME_PROFILES and game_profile in VERIFIED_GAME_PROFILES
and runtime_matches_managed_source
and selection is not None and selection is not None
and prefix_ready and prefix_ready
and directx_ready and directx_ready
@@ -208,7 +224,13 @@ def inspect_login_environment(
game_exe_sha256=game_exe_sha256, game_exe_sha256=game_exe_sha256,
game_dll=str(game_dll), game_dll=str(game_dll),
game_dll_sha256=game_dll_sha256, 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, 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=str(world_fix_helper),
world_fix_helper_sha256=world_fix_helper_sha256, world_fix_helper_sha256=world_fix_helper_sha256,
world_fix_helper_ready=world_fix_helper_ready, world_fix_helper_ready=world_fix_helper_ready,
@@ -331,6 +353,7 @@ def _diagnostic_warnings(
prefix_ready: bool, prefix_ready: bool,
directx_ready: bool, directx_ready: bool,
latest_log: Path | None, latest_log: Path | None,
runtime_matches_managed_source: bool,
) -> tuple[str, ...]: ) -> tuple[str, ...]:
warnings: list[str] = [] warnings: list[str] = []
if not probe.reachable: 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; " "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." "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: if runtime_error is not None:
warnings.append(runtime_error) warnings.append(runtime_error)
elif not prefix_ready: elif not prefix_ready:
@@ -367,6 +395,24 @@ def _diagnostic_warnings(
return tuple(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: def _sha256(path: Path) -> str:
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as handle: with path.open("rb") as handle:

View File

@@ -25,6 +25,13 @@ from .runtime import (
) )
from .settings import Settings 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_KEY = r"HKCU\Software\Wine\Debug"
RELAY_REGISTRY_VALUE = "RelayInclude" RELAY_REGISTRY_VALUE = "RelayInclude"
@@ -33,7 +40,7 @@ GAME_DLL_LOGIN_COMPARE_RETURN_OFFSET = 0x79F157
DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0 DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0
_LOAD_GAME_DLL = re.compile( _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, re.IGNORECASE,
) )
_COMPARE_RETURN = re.compile( _COMPARE_RETURN = re.compile(
@@ -78,6 +85,21 @@ class LoginTraceReport:
native_log_before_modified_at: str | None native_log_before_modified_at: str | None
native_log_after: str | None native_log_after: str | None
native_log_after_modified_at: 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_returncode: int | None
game_timed_out: bool game_timed_out: bool
game_duration_seconds: float game_duration_seconds: float
@@ -121,22 +143,41 @@ def run_login_trace(
raise RuntimeError("the selected Proton 7 runtime path is incomplete") raise RuntimeError("the selected Proton 7 runtime path is incomplete")
started = datetime.now().astimezone() started = datetime.now().astimezone()
started_ns = int(started.timestamp() * 1_000_000_000)
result_directory = _create_result_directory(paths, started) result_directory = _create_result_directory(paths, started)
game_exe = paths.runtime_view / "Game.exe" game_exe = paths.runtime_view / "Game.exe"
game_dll = paths.runtime_view / "game.dll" game_dll = paths.runtime_view / "game.dll"
log_before, log_before_modified = _native_log_snapshot(paths, settings) 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_before = _query_relay_filter(paths, selection, runner)
registry_restored = False registry_restored = False
trace = RelayTraceResult(None, None, (), 0) trace = RelayTraceResult(None, None, (), 0)
outcome: CommandOutcome | None = None outcome: CommandOutcome | None = None
raw_directory_path: Path | 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) paths.cache.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory( with tempfile.TemporaryDirectory(
prefix="ptlinux-login-relay-", prefix="ptlinux-login-relay-",
dir=paths.cache, dir=paths.cache,
) as raw_directory: ) as raw_directory:
raw_directory_path = Path(raw_directory) raw_path = Path(raw_directory)
raw_directory_path = raw_path
try: try:
_set_relay_filter(paths, selection, runner, RELAY_INCLUDE_FILTER) _set_relay_filter(paths, selection, runner, RELAY_INCLUDE_FILTER)
outcome = run_game( outcome = run_game(
@@ -150,14 +191,14 @@ def run_login_trace(
check=False, check=False,
extra_environment={ extra_environment={
"PROTON_LOG": "1", "PROTON_LOG": "1",
"PROTON_LOG_DIR": str(raw_directory_path), "PROTON_LOG_DIR": str(raw_path),
"SteamAppId": "0", "SteamAppId": "0",
"SteamGameId": "0", "SteamGameId": "0",
"WINEDEBUG": "+relay,+loaddll", "WINEDEBUG": "+relay,+loaddll",
}, },
output_line_filter=redact_relay_output_line, 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: finally:
registry_restored = _restore_relay_filter( registry_restored = _restore_relay_filter(
paths, paths,
@@ -168,6 +209,52 @@ def run_login_trace(
if outcome is None: if outcome is None:
raise RuntimeError("the instrumented game process did not start") 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() 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) log_after, log_after_modified = _native_log_snapshot(paths, settings)
comparison_observed = bool(trace.comparison_results) comparison_observed = bool(trace.comparison_results)
@@ -176,6 +263,9 @@ def run_login_trace(
) )
conclusion, warnings = _conclusion( conclusion, warnings = _conclusion(
trace, 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, registry_restored=registry_restored,
raw_relay_deleted=raw_relay_deleted, raw_relay_deleted=raw_relay_deleted,
timed_out=outcome.timed_out, timed_out=outcome.timed_out,
@@ -200,6 +290,21 @@ def run_login_trace(
native_log_before_modified_at=log_before_modified, native_log_before_modified_at=log_before_modified,
native_log_after=log_after, native_log_after=log_after,
native_log_after_modified_at=log_after_modified, 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_returncode=outcome.returncode,
game_timed_out=outcome.timed_out, game_timed_out=outcome.timed_out,
game_duration_seconds=round(outcome.duration_seconds, 3), game_duration_seconds=round(outcome.duration_seconds, 3),
@@ -393,15 +498,67 @@ def _native_log_snapshot(
return str(path), modified 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( def _conclusion(
trace: RelayTraceResult, trace: RelayTraceResult,
*, *,
module_code_sync_error: bool,
banned_code_1: bool,
runtime_futex_error: bool,
registry_restored: bool, registry_restored: bool,
raw_relay_deleted: bool, raw_relay_deleted: bool,
timed_out: bool, timed_out: bool,
) -> tuple[str, tuple[str, ...]]: ) -> tuple[str, tuple[str, ...]]:
warnings: list[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]: if trace.comparison_results[-1]:
conclusion = "account-id-match" conclusion = "account-id-match"
else: else:
@@ -422,6 +579,12 @@ def _conclusion(
warnings.append("The relay log did not record the loaded game.dll base address.") warnings.append("The relay log did not record the loaded game.dll base address.")
if timed_out: if timed_out:
warnings.append("The diagnostic reached its timeout and terminated the launcher process.") 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: if not registry_restored:
warnings.append("The temporary Wine relay registry value could not be restored.") warnings.append("The temporary Wine relay registry value could not be restored.")
if not raw_relay_deleted: if not raw_relay_deleted:
@@ -445,6 +608,13 @@ def _write_report(report: LoginTraceReport) -> None:
"# PristonTale EU login trace\n\n" "# PristonTale EU login trace\n\n"
f"- Started: {report.started_at}\n" f"- Started: {report.started_at}\n"
f"- Runtime: {report.runtime_kind}\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"- Account-ID comparison observed: {report.comparison_observed}\n"
f"- Exact account-ID match: {match}\n" f"- Exact account-ID match: {match}\n"
f"- Conclusion: `{report.conclusion}`\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: def _sha256_if_file(path: Path) -> str | None:
try: try:
if not path.is_file(): if not path.is_file():

View File

@@ -39,6 +39,10 @@ CURRENT_V21_BINARY_HASHES = V21_BINARY_HASHES
PATCHED_V21_GAME_DLL_SHA256 = ( PATCHED_V21_GAME_DLL_SHA256 = (
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538" "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
) )
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
class OverlayError(RuntimeError): class OverlayError(RuntimeError):
@@ -147,10 +151,28 @@ def prepare_runtime_view(
# ``use_world_fix`` is retained as a compatibility keyword. The retired # ``use_world_fix`` is retained as a compatibility keyword. The retired
# helper is never selected and no code path may replace the publisher DLL. # helper is never selected and no code path may replace the publisher DLL.
_ = use_world_fix _ = 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, paths,
source, 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}" build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{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(), created_at=datetime.now().astimezone().isoformat(),
links_created=links_created, 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 return result
@@ -271,6 +299,32 @@ def select_binary_action(
return "verified-source-pair" return "verified-source-pair"
if source_hashes == CURRENT_V21_BINARY_HASHES: if source_hashes == CURRENT_V21_BINARY_HASHES:
return "verified-v21-publisher-pair" 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" return "upstream-pair"
@@ -392,13 +446,14 @@ def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
candidate = path.resolve(strict=True) candidate = path.resolve(strict=True)
break break
if candidate is not None: if candidate is not None:
selected = candidate
allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True)) allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True))
if not any( if not any(
candidate == root or candidate.is_relative_to(root) selected == root or selected.is_relative_to(root)
for root in allowed_roots for root in allowed_roots
): ):
raise OverlayError(f"refusing to migrate game save outside managed roots: {candidate}") raise OverlayError(f"refusing to migrate game save outside managed roots: {selected}")
_reject_save_symlinks(candidate) _reject_save_symlinks(selected)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.build-{uuid.uuid4().hex}" 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}") 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( def _runtime_game_binary_sources(
paths: AppPaths, paths: AppPaths,
source: Path, source: Path,
) -> tuple[str, Path, 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 = paths.compatibility_binaries
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
source_hashes = _binary_hashes(source) source_hashes = _binary_hashes(source)
@@ -435,6 +693,7 @@ def _runtime_game_binary_sources(
action, action,
source / "Game.exe", source / "Game.exe",
source / "game.dll", source / "game.dll",
source_hashes,
) )

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@@ -33,6 +34,7 @@ WINETRICKS_CODEPAGE_PROBE = (
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"' WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
RUNTIME_DEFAULT_SYNC = "runtime-default" RUNTIME_DEFAULT_SYNC = "runtime-default"
CACHYOS_MARKER = "cachyos" CACHYOS_MARKER = "cachyos"
GAME_RUNTIME_CONFIGURATION_VERSION = 1
class RuntimeErrorPT(RuntimeError): class RuntimeErrorPT(RuntimeError):
@@ -159,6 +161,8 @@ def prepare_game_runtime(
elif selection.kind in {"proton7", "proton7-container"}: elif selection.kind in {"proton7", "proton7-container"}:
if not selection.proton_path or not selection.steam_root: if not selection.proton_path or not selection.steam_root:
raise RuntimeErrorPT("incomplete Proton 7 runtime selection") 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: if not runner.dry_run:
paths.compat_data.mkdir(parents=True, exist_ok=True) paths.compat_data.mkdir(parents=True, exist_ok=True)
proton = Path(selection.proton_path) proton = Path(selection.proton_path)
@@ -237,11 +241,13 @@ def prepare_game_runtime(
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}") raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
if not runner.dry_run: if not runner.dry_run:
_write_json( _write_json(
paths.state / "game-runtime.json", paths.game_runtime_marker,
{ {
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
"configured_at": datetime.now().astimezone().isoformat(), "configured_at": datetime.now().astimezone().isoformat(),
"native_d3d43": True, "native_d3d43": True,
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE, "dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
"proton_identity": _proton_identity(selection),
"runtime": asdict(selection), "runtime": asdict(selection),
}, },
) )
@@ -572,6 +578,69 @@ def _proton_prefix_ready(prefix: Path) -> bool:
).exists() ).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: def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"): for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
wine = directory / "wine" 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 = 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.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path) 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()