Added asset checks and fixes for missing data in game client.

This commit is contained in:
2026-09-05 21:52:41 +02:00
parent cccce05a99
commit f0d631be66
7 changed files with 1011 additions and 211 deletions

View File

@@ -82,6 +82,8 @@ ALWAYS_VERIFY_PATHS = frozenset(
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
DEPOT_LAUNCH_CATALOG_SCHEMA = 1
DEPOT_FILE_CATALOG_SCHEMA = 1
MAX_PREFLIGHT_ISSUES = 32
class DepotError(RuntimeError):
@@ -424,6 +426,64 @@ class DepotLaunchCatalog:
default_start_option: str
@dataclass(frozen=True)
class DepotFileCatalogEntry:
"""One manifest file plus the identity recorded after verified installation."""
path: str
size: int
sha1: str
device: int
inode: int
mtime_ns: int
ctime_ns: int
verified: bool
@dataclass(frozen=True)
class DepotFileCatalog:
"""Cached manifest used for fast, network-free launch preflight checks."""
build: int
files: tuple[DepotFileCatalogEntry, ...]
created_at: str
@dataclass(frozen=True)
class DepotIntegrityIssue:
path: str
reason: str
@dataclass(frozen=True)
class DepotPreflightResult:
"""Outcome of validating cached file identities before starting the game."""
build: int
files_total: int
verified_files: int
unverified_files: int
changed_files_hashed: int
issue_count: int
issues: tuple[DepotIntegrityIssue, ...]
duration_ms: int
@property
def ready(self) -> bool:
return self.issue_count == 0
@property
def fully_verified(self) -> bool:
return self.ready and self.unverified_files == 0
@dataclass(frozen=True)
class _ScannedGameEntry:
path: Path
state: os.stat_result
kind: str
def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog:
return DepotLaunchCatalog(
build=snapshot.build,
@@ -508,6 +568,206 @@ def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None:
)
def cache_depot_file_catalog(
path: Path,
game_directory: Path,
snapshot: DepotSnapshot,
*,
verified_paths: Iterable[str],
previous: DepotFileCatalog | None = None,
) -> DepotFileCatalog:
"""Record identities without rehashing files already verified by the updater."""
root = _prepare_game_root(game_directory, create=False)
scanned_files = _scan_game_tree(root)
explicitly_verified = {verified_path.casefold() for verified_path in verified_paths}
previous_files = (
{entry.path.casefold(): entry for entry in previous.files}
if previous is not None
else {}
)
entries: list[DepotFileCatalogEntry] = []
for file in snapshot.files:
scanned = scanned_files.get(file.path)
if scanned is None:
raise DepotError(f"cannot cache missing depot file identity: {file.path}")
state = scanned.state
if scanned.kind != "file" or state.st_size != file.size:
raise DepotError(f"cannot cache invalid depot file identity: {file.path}")
prior = previous_files.get(file.path.casefold())
verified = file.path.casefold() in explicitly_verified
if (
not verified
and prior is not None
and prior.verified
and prior.size == file.size
and prior.sha1 == file.sha1
and _catalog_identity_matches(prior, state)
):
verified = True
entries.append(_catalog_entry(file, state, verified=verified))
catalog = DepotFileCatalog(
build=snapshot.build,
files=tuple(entries),
created_at=datetime.now().astimezone().isoformat(),
)
_write_json(
path,
_depot_file_catalog_payload(catalog),
compact=True,
)
return catalog
def read_cached_depot_file_catalog(path: Path) -> DepotFileCatalog | None:
"""Read and strictly validate the local manifest/identity cache."""
try:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
return None
if value.get("schema") != DEPOT_FILE_CATALOG_SCHEMA:
return None
build = _positive_int(value.get("build"), "cached depot file build")
created_at = _string(value.get("created_at"), "cached depot file timestamp")
raw_files = value.get("files")
if not isinstance(raw_files, list):
return None
entries: list[DepotFileCatalogEntry] = []
seen: set[str] = set()
for raw in raw_files:
entry = _parse_depot_file_catalog_entry(raw)
if entry.path.casefold() in seen:
return None
seen.add(entry.path.casefold())
entries.append(entry)
except (OSError, UnicodeError, json.JSONDecodeError, DepotError):
return None
return DepotFileCatalog(build=build, files=tuple(entries), created_at=created_at)
def read_current_depot_file_catalog(paths: AppPaths) -> DepotFileCatalog | None:
"""Return the file catalog only when it matches the last completed sync."""
catalog = read_cached_depot_file_catalog(paths.depot_file_catalog)
if catalog is None:
return None
if _completed_depot_build(paths.native_depot_state) != catalog.build:
return None
return catalog
def preflight_managed_game(
paths: AppPaths,
*,
catalog: DepotFileCatalog | None = None,
) -> DepotPreflightResult | None:
"""Check all managed paths cheaply and hash only files whose identity changed."""
catalog = catalog or read_current_depot_file_catalog(paths)
if catalog is None:
return None
if _completed_depot_build(paths.native_depot_state) != catalog.build:
return None
started = time.perf_counter()
root = _prepare_game_root(paths.source_game, create=False)
if not root.is_dir():
return DepotPreflightResult(
build=catalog.build,
files_total=len(catalog.files),
verified_files=0,
unverified_files=sum(not entry.verified for entry in catalog.files),
changed_files_hashed=0,
issue_count=1,
issues=(DepotIntegrityIssue(".", "managed game directory is missing"),),
duration_ms=_elapsed_milliseconds(started),
)
try:
scanned_files = _scan_game_tree(root)
except DepotError as exc:
return DepotPreflightResult(
build=catalog.build,
files_total=len(catalog.files),
verified_files=0,
unverified_files=sum(not entry.verified for entry in catalog.files),
changed_files_hashed=0,
issue_count=1,
issues=(DepotIntegrityIssue(".", str(exc)),),
duration_ms=_elapsed_milliseconds(started),
)
verified_files = 0
unverified_files = 0
changed_files_hashed = 0
issue_count = 0
issues: list[DepotIntegrityIssue] = []
updated_entries: list[DepotFileCatalogEntry] = []
cache_changed = False
for entry in catalog.files:
reason = None
state: os.stat_result | None = None
scanned = scanned_files.get(entry.path)
if scanned is None:
reason = "missing"
elif scanned.kind != "file":
reason = scanned.kind
elif scanned.state.st_size != entry.size:
reason = "size mismatch"
else:
state = scanned.state
updated_entry = entry
if reason is None and state is not None:
if not entry.verified:
unverified_files += 1
elif _catalog_identity_matches(entry, state):
verified_files += 1
else:
changed_files_hashed += 1
try:
assert scanned is not None
matches = _sha1(scanned.path).casefold() == entry.sha1
except DepotError as exc:
reason = f"cannot verify changed file: {exc}"
else:
if matches:
verified_files += 1
updated_entry = _catalog_entry_from_cached(entry, state)
cache_changed = True
else:
reason = "hash mismatch"
if reason is not None:
issue_count += 1
if len(issues) < MAX_PREFLIGHT_ISSUES:
issues.append(DepotIntegrityIssue(entry.path, reason))
updated_entries.append(updated_entry)
if cache_changed:
updated_catalog = DepotFileCatalog(
build=catalog.build,
files=tuple(updated_entries),
created_at=datetime.now().astimezone().isoformat(),
)
_write_json(
paths.depot_file_catalog,
_depot_file_catalog_payload(updated_catalog),
compact=True,
)
return DepotPreflightResult(
build=catalog.build,
files_total=len(catalog.files),
verified_files=verified_files,
unverified_files=unverified_files,
changed_files_hashed=changed_files_hashed,
issue_count=issue_count,
issues=tuple(issues),
duration_ms=_elapsed_milliseconds(started),
)
@dataclass(frozen=True)
class PlannedDownload:
file: DepotFile
@@ -522,6 +782,7 @@ class UpdatePlan:
deletes: tuple[str, ...]
files_total: int
bytes_total: int
hash_verified_paths: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -564,6 +825,8 @@ class DepotSyncResult:
repair: bool
dry_run: bool
completed_at: str
files_planned: int = 0
bytes_planned: int = 0
@dataclass(frozen=True)
@@ -955,6 +1218,7 @@ def plan_update(
root = _prepare_game_root(game_directory, create=False)
downloads: list[PlannedDownload] = []
hash_verified_paths: list[str] = []
for file in snapshot.files:
target = _safe_target(root, file.path)
if target.is_symlink():
@@ -976,6 +1240,8 @@ def plan_update(
):
if _sha1(target).casefold() != file.sha1.casefold():
downloads.append(PlannedDownload(file, "hash mismatch"))
else:
hash_verified_paths.append(file.path)
deletes: list[str] = []
for relative_path in snapshot.deleted_files:
@@ -992,6 +1258,7 @@ def plan_update(
deletes=tuple(deletes),
files_total=len(snapshot.files),
bytes_total=sum(item.file.size for item in downloads),
hash_verified_paths=tuple(hash_verified_paths),
)
@@ -1009,6 +1276,7 @@ def sync_managed_game(
paths.create_base_directories()
depot_client = client or DepotClient()
previous_file_catalog = read_current_depot_file_catalog(paths)
_emit_progress(progress, "manifest", 0, 0, 0, 0, None)
snapshot = depot_client.fetch_snapshot()
quarantine = QuarantineResult(0, None)
@@ -1059,6 +1327,17 @@ def sync_managed_game(
created=created,
quarantine=quarantine,
)
verified_paths = {
*plan.hash_verified_paths,
*(download.file.path for download in plan.downloads),
}
cache_depot_file_catalog(
paths.depot_file_catalog,
root,
snapshot,
verified_paths=verified_paths,
previous=previous_file_catalog,
)
_write_json(paths.native_depot_state, asdict(result))
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
_emit_progress(
@@ -1422,6 +1701,162 @@ def _apply_create_if_missing(
return created
def _catalog_entry(
file: DepotFile,
state: os.stat_result,
*,
verified: bool,
) -> DepotFileCatalogEntry:
return DepotFileCatalogEntry(
path=file.path,
size=file.size,
sha1=file.sha1.casefold(),
device=state.st_dev,
inode=state.st_ino,
mtime_ns=state.st_mtime_ns,
ctime_ns=state.st_ctime_ns,
verified=verified,
)
def _scan_game_tree(root: Path) -> dict[str, _ScannedGameEntry]:
"""Inventory files without following symlinks or resolving every manifest path."""
entries: dict[str, _ScannedGameEntry] = {}
def scan(directory: Path, relative_parent: PurePosixPath) -> None:
children = _scan_directory(directory)
for child in children:
relative = relative_parent / child.name
key = relative.as_posix()
try:
state = child.stat(follow_symlinks=False)
except OSError as exc:
raise DepotError(f"cannot inspect managed game path {key}: {exc}") from exc
path = Path(child.path)
if stat.S_ISLNK(state.st_mode):
entries[key] = _ScannedGameEntry(path, state, "symbolic link")
elif stat.S_ISDIR(state.st_mode):
scan(path, relative)
elif stat.S_ISREG(state.st_mode):
entries[key] = _ScannedGameEntry(path, state, "file")
else:
entries[key] = _ScannedGameEntry(path, state, "not a regular file")
scan(root, PurePosixPath())
return entries
def _scan_directory(directory: Path) -> list[os.DirEntry[str]]:
try:
with os.scandir(directory) as iterator:
return list(iterator)
except OSError as exc:
raise DepotError(f"cannot scan managed game directory {directory}: {exc}") from exc
def _catalog_entry_from_cached(
entry: DepotFileCatalogEntry,
state: os.stat_result,
) -> DepotFileCatalogEntry:
return DepotFileCatalogEntry(
path=entry.path,
size=entry.size,
sha1=entry.sha1,
device=state.st_dev,
inode=state.st_ino,
mtime_ns=state.st_mtime_ns,
ctime_ns=state.st_ctime_ns,
verified=True,
)
def _catalog_identity_matches(
entry: DepotFileCatalogEntry,
state: os.stat_result,
) -> bool:
return (
entry.device == state.st_dev
and entry.inode == state.st_ino
and entry.size == state.st_size
and entry.mtime_ns == state.st_mtime_ns
and entry.ctime_ns == state.st_ctime_ns
)
def _depot_file_catalog_payload(catalog: DepotFileCatalog) -> dict[str, object]:
"""Use compact rows because this cache contains tens of thousands of files."""
return {
"schema": DEPOT_FILE_CATALOG_SCHEMA,
"build": catalog.build,
"created_at": catalog.created_at,
"files": [
[
entry.path,
entry.size,
entry.sha1,
entry.device,
entry.inode,
entry.mtime_ns,
entry.ctime_ns,
entry.verified,
]
for entry in catalog.files
],
}
def _parse_depot_file_catalog_entry(value: object) -> DepotFileCatalogEntry:
if not isinstance(value, list) or len(value) != 8:
raise DepotError("cached depot file entry must be an eight-value row")
path = _normalise_relative_path(value[0], "cached depot file path")
size = _non_negative_int(value[1], f"cached depot file size for {path}")
sha1 = _string(value[2], f"cached depot file hash for {path}").casefold()
if not SHA1_PATTERN.fullmatch(sha1):
raise DepotError(f"invalid cached SHA-1 hash for {path}")
identity: dict[str, int] = {}
for field_name, field_value in zip(
("device", "inode", "mtime_ns", "ctime_ns"),
value[3:7],
strict=True,
):
identity[field_name] = _non_negative_int(
field_value,
f"cached depot file {field_name} for {path}",
)
verified = value[7]
if not isinstance(verified, bool):
raise DepotError(f"cached depot verification flag is invalid for {path}")
return DepotFileCatalogEntry(
path=path,
size=size,
sha1=sha1,
device=identity["device"],
inode=identity["inode"],
mtime_ns=identity["mtime_ns"],
ctime_ns=identity["ctime_ns"],
verified=verified,
)
def _completed_depot_build(path: Path) -> int | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict) or value.get("dry_run") is not False:
return None
build = value.get("build")
if isinstance(build, bool) or not isinstance(build, int) or build <= 0:
return None
return build
def _elapsed_milliseconds(started: float) -> int:
return max(0, round((time.perf_counter() - started) * 1000))
def _sync_result(
snapshot: DepotSnapshot,
plan: UpdatePlan,
@@ -1441,6 +1876,8 @@ def _sync_result(
build=snapshot.build,
version=snapshot.version,
files_total=plan.files_total,
files_planned=len(plan.downloads),
bytes_planned=plan.bytes_total,
files_downloaded=files_downloaded,
bytes_downloaded=bytes_downloaded,
files_deleted=files_deleted,
@@ -1919,14 +2356,16 @@ def _headers() -> dict[str, str]:
return {"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"}
def _write_json(path: Path, value: object) -> None:
def _write_json(path: Path, value: object, *, compact: bool = False) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(
json.dumps(value, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
payload = (
json.dumps(value, separators=(",", ":"), sort_keys=True)
if compact
else json.dumps(value, indent=2, sort_keys=True)
)
temporary.write_text(payload + "\n", encoding="utf-8")
temporary.replace(path)
except BaseException:
temporary.unlink(missing_ok=True)