Improvements to try resolve the issues with login checksums.
This commit is contained in:
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.5.11"
|
||||
__version__ = "0.5.13"
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user