diff --git a/packaging/pristontale-eu b/packaging/pristontale-eu
index 574356f..9012f58 100755
--- a/packaging/pristontale-eu
+++ b/packaging/pristontale-eu
@@ -31,11 +31,10 @@ then
exit 1
fi
-if [ -n "${PYTHONPATH:-}" ]
-then
- export PYTHONPATH="${release_root}:${PYTHONPATH}"
-else
- export PYTHONPATH="${release_root}"
-fi
+# Never let an unpacked older installer in the caller's current directory
+# shadow the selected installed release. Children started by the dashboard
+# inherit the same safe-path policy and exact package root.
+export PYTHONSAFEPATH=1
+export PYTHONPATH="${release_root}"
-exec "$python_bin" -m ptlinux "$@"
+exec "$python_bin" -P -m ptlinux "$@"
diff --git a/ptlinux/__init__.py b/ptlinux/__init__.py
index 0f49a83..14db81f 100644
--- a/ptlinux/__init__.py
+++ b/ptlinux/__init__.py
@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
-__version__ = "0.5.11"
+__version__ = "0.5.13"
diff --git a/ptlinux/cli.py b/ptlinux/cli.py
index 2e93e4a..82d0a37 100644
--- a/ptlinux/cli.py
+++ b/ptlinux/cli.py
@@ -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"]:
diff --git a/ptlinux/command.py b/ptlinux/command.py
index 6b4c1c5..4118237 100644
--- a/ptlinux/command.py
+++ b/ptlinux/command.py
@@ -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],
*,
diff --git a/ptlinux/depot.py b/ptlinux/depot.py
index f2bfc84..7ba87e7 100644
--- a/ptlinux/depot.py
+++ b/ptlinux/depot.py
@@ -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"))
diff --git a/ptlinux/doctor.py b/ptlinux/doctor.py
index 2c18dd5..a4980aa 100644
--- a/ptlinux/doctor.py
+++ b/ptlinux/doctor.py
@@ -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()
diff --git a/ptlinux/login_diagnostics.py b/ptlinux/login_diagnostics.py
index a2e423b..c56b734 100644
--- a/ptlinux/login_diagnostics.py
+++ b/ptlinux/login_diagnostics.py
@@ -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:
diff --git a/ptlinux/login_trace.py b/ptlinux/login_trace.py
index 9048c55..6fe6079 100644
--- a/ptlinux/login_trace.py
+++ b/ptlinux/login_trace.py
@@ -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[0-9a-f]+):',
+ r'Loaded L"[^"\r\n]*[\\/]game\.dll" at (?P[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():
diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py
index 807759a..ad56d19 100644
--- a/ptlinux/overlay.py
+++ b/ptlinux/overlay.py
@@ -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,
)
diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py
index 741c695..c5ead23 100644
--- a/ptlinux/runtime.py
+++ b/ptlinux/runtime.py
@@ -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()