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