"""Vanilla PAK baseline, mod slot allocation and title-update detection. Wilds has two parallel patch chains. Mods must sit *above* the vanilla ceiling of their chain, and the ceiling rises with every title update, so slots are derived rather than fixed. """ from __future__ import annotations import os import re import struct import time from dataclasses import dataclass, field from pathlib import Path from .layout import PAK, Entry from .util import atomic_write_json, read_json PAK_RE = re.compile(r"^re_chunk_000\.pak(?P\.sub_000\.pak)?\.patch_(?P\d{3})\.pak$") CHAINS = ("main", "sub") _BASES = {"main": "re_chunk_000.pak", "sub": "re_chunk_000.pak.sub_000.pak"} def pak_name(chain: str, n: int) -> str: return f"{_BASES[chain]}.patch_{n:03d}.pak" def parse_pak_name(name: str) -> tuple[str, int] | None: m = PAK_RE.match(name) if not m: return None return ("sub" if m.group("sub") else "main"), int(m.group("n")) def scan_game_paks(game_dir: Path) -> dict[str, dict[int, dict]]: """chain -> patch number -> {name,size,mtime_ns}. Only real patch files are considered.""" out: dict[str, dict[int, dict]] = {c: {} for c in CHAINS} try: it = list(os.scandir(game_dir)) except OSError: return out for e in it: parsed = parse_pak_name(e.name) if parsed is None: continue try: st = e.stat() except OSError: continue out[parsed[0]][parsed[1]] = {"name": e.name, "size": st.st_size, "mtime_ns": st.st_mtime_ns} return out # -- baseline --------------------------------------------------------------- @dataclass class Baseline: chains: dict[str, dict] = field(default_factory=dict) # chain -> {"ceiling": int, "files": {name: {...}}} captured: float = 0.0 def ceiling(self, chain: str) -> int: return int(self.chains.get(chain, {}).get("ceiling", 0)) def to_json(self) -> dict: return {"version": 1, "captured": self.captured, "chains": self.chains} @classmethod def from_json(cls, raw) -> "Baseline | None": if not isinstance(raw, dict) or not isinstance(raw.get("chains"), dict): return None return cls(raw["chains"], float(raw.get("captured", 0))) def capture_baseline(game_dir: Path, owned: set[str] = frozenset()) -> Baseline: """Snapshot vanilla PAKs. `owned` = file names we created; they are not vanilla.""" scan = scan_game_paks(game_dir) chains = {} for chain in CHAINS: files = {i["name"]: {"size": i["size"], "mtime_ns": i["mtime_ns"]} for n, i in scan[chain].items() if i["name"] not in owned} nums = [n for n, i in scan[chain].items() if i["name"] not in owned] chains[chain] = {"ceiling": max(nums, default=0), "files": files} return Baseline(chains, time.time()) def load_baseline(path: Path) -> Baseline | None: return Baseline.from_json(read_json(path, None)) def save_baseline(path: Path, baseline: Baseline) -> None: atomic_write_json(path, baseline.to_json()) # -- slot allocation -------------------------------------------------------- @dataclass(frozen=True) class Slot: mod_id: str src: Path chain: str number: int @property def dest(self) -> str: # Deploy to pak_mods/ so REFramework's IntegrityCheckBypass can load them # natively (fixing Linux/Proton issues and silent patch failures). return f"pak_mods/{self.chain}_{self.number:03d}_{self.mod_id}.pak" def _pak_sort_key(p: Path): parsed = parse_pak_name(p.name) return (parsed[1] if parsed else 10 ** 6, p.name.lower()) def allocate(mods: list[tuple[str, list[Entry]]], baseline: Baseline) -> list[Slot]: """Assign contiguous slots above each chain's ceiling, following load order.""" nxt = {c: baseline.ceiling(c) + 1 for c in CHAINS} slots: list[Slot] = [] for mod_id, entries in mods: for chain in CHAINS: paks = sorted((e.src for e in entries if e.kind == PAK and e.chain == chain), key=_pak_sort_key) for src in paks: slots.append(Slot(mod_id, src, chain, nxt[chain])) nxt[chain] += 1 return slots # -- title update detection ------------------------------------------------- @dataclass class UpdateReport: updated: bool = False reasons: list[str] = field(default_factory=list) def detect_update(game_dir: Path, baseline: Baseline, owned: set[str]) -> UpdateReport: """A PAK we do not own above the ceiling, or a changed/missing vanilla PAK, means Steam patched.""" rep = UpdateReport() scan = scan_game_paks(game_dir) for chain in CHAINS: ceiling = baseline.ceiling(chain) for n, info in sorted(scan[chain].items()): if n > ceiling and info["name"] not in owned: rep.reasons.append(f"new PAK {info['name']}") present = {i["name"]: i for i in scan[chain].values()} for name, rec in baseline.chains.get(chain, {}).get("files", {}).items(): cur = present.get(name) if cur is None: rep.reasons.append(f"vanilla PAK {name} disappeared") elif cur["size"] != rec["size"]: rep.reasons.append(f"vanilla PAK {name} changed size") rep.updated = bool(rep.reasons) return rep # -- PAK content hashes (best-effort conflict detection) --------------------- def murmur3_32(data: bytes, seed: int = 0) -> int: c1, c2 = 0xCC9E2D51, 0x1B873593 h = seed & 0xFFFFFFFF n = len(data) // 4 for (k,) in struct.iter_unpack("> 17)) & 0xFFFFFFFF k = (k * c2) & 0xFFFFFFFF h ^= k h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF h = (h * 5 + 0xE6546B64) & 0xFFFFFFFF tail = data[n * 4:] k = 0 for i, b in enumerate(tail): k |= b << (8 * i) if tail: k = (k * c1) & 0xFFFFFFFF k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF k = (k * c2) & 0xFFFFFFFF h ^= k h ^= len(data) h ^= h >> 16 h = (h * 0x85EBCA6B) & 0xFFFFFFFF h ^= h >> 13 h = (h * 0xC2B2AE35) & 0xFFFFFFFF h ^= h >> 16 return h def path_hash(game_path: str) -> tuple[int, int]: """RE Engine KPKA entry key: murmur3 of the lower/upper-cased UTF-16LE path, seed 0xFFFFFFFF.""" p = game_path.replace("\\", "/") return (murmur3_32(p.lower().encode("utf-16-le"), 0xFFFFFFFF), murmur3_32(p.upper().encode("utf-16-le"), 0xFFFFFFFF)) _ENTRY = struct.Struct(" frozenset[tuple[int, int]]: """Set of (lower, upper) path hashes inside a KPKA archive; empty if unreadable.""" try: st = os.stat(path) key = (str(path), st.st_size, st.st_mtime_ns) if key in _pak_cache: return _pak_cache[key] with open(path, "rb") as fh: head = fh.read(16) if len(head) < 16 or head[:4] != b"KPKA": return frozenset() total = struct.unpack_from(" 5_000_000 or 16 + total * _ENTRY.size > st.st_size: return frozenset() table = fh.read(total * _ENTRY.size) result = frozenset((lo, up) for lo, up, *_ in _ENTRY.iter_unpack(table)) _pak_cache[key] = result return result except (OSError, struct.error): return frozenset()