"""Classify an extracted mod tree into deployment entries. Mods are packaged inconsistently, so the payload root is discovered, not assumed. """ from __future__ import annotations import os import re from dataclasses import dataclass, field from pathlib import Path LOOSE, REFRAMEWORK, PAK, ROOT = "loose", "reframework", "pak", "root" _JUNK = {".ini", ".txt", ".md", ".png", ".jpg", ".jpeg", ".webp", ".pdf", ".url", ".rtf"} _SUB_CHAIN = re.compile(r"\.sub_000\.", re.I) PAK_DEPTH = 2 @dataclass(frozen=True) class Entry: kind: str src: Path dest: str # posix path relative to the game dir; "" for PAKs (slot assigned later) chain: str = "" # PAKs only: "main" or "sub" @dataclass class Classification: entries: list[Entry] = field(default_factory=list) ambiguous: list[Path] = field(default_factory=list) # root-level .dll needing a user decision notes: list[str] = field(default_factory=list) @property def empty(self) -> bool: return not self.entries and not self.ambiguous def pak_chain(name: str) -> str: return "sub" if _SUB_CHAIN.search(name) else "main" def _effective_root(root: Path) -> Path: """Descend through single wrapper directories (Mod/Mod/natives/...).""" while True: kids = [p for p in root.iterdir() if not p.is_symlink()] dirs = [p for p in kids if p.is_dir() and not p.name.startswith("__MACOSX")] files = [p for p in kids if p.is_file()] if len(dirs) == 1 and dirs[0].name.lower() not in ("natives", "reframework", "stm") \ and all(f.suffix.lower() in _JUNK for f in files): root = dirs[0] else: return root def _tree(base: Path, prefix: str, kind: str, out: Classification, skip_data: bool = False) -> None: for dirpath, dirnames, filenames in os.walk(base): rel_dir = Path(dirpath).relative_to(base) if skip_data and not rel_dir.parts: if any(d.lower() == "data" for d in dirnames): out.notes.append("Ignored reframework/data (user settings are never managed)") dirnames[:] = [d for d in dirnames if d.lower() != "data"] dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath, d))) for f in sorted(filenames): p = Path(dirpath) / f if p.is_symlink(): continue out.entries.append(Entry(kind, p, "/".join([prefix, *rel_dir.parts, f]))) def classify(root: Path, dll_choice: str | None = None) -> Classification: """dll_choice for root-level .dll files: 'plugin' | 'root' | 'skip' | None (=> ambiguous).""" root = Path(root) out = Classification() top = _effective_root(root) def walk(d: Path, depth: int) -> None: for e in sorted(d.iterdir(), key=lambda p: p.name.lower()): if e.is_symlink(): continue low = e.name.lower() if e.is_dir(): if low.startswith("__macosx"): continue if low == "natives": _tree(e, "natives", LOOSE, out) elif low == "reframework": _tree(e, "reframework", REFRAMEWORK, out, skip_data=True) elif low == "stm": _tree(e, "natives/STM", LOOSE, out) elif depth < PAK_DEPTH: walk(e, depth + 1) elif e.is_file(): if low.endswith(".pak") and depth <= PAK_DEPTH: out.entries.append(Entry(PAK, e, "", pak_chain(e.name))) elif depth == 0 and low.endswith(".lua"): out.entries.append(Entry(REFRAMEWORK, e, f"reframework/autorun/{e.name}")) elif depth == 0 and low.endswith(".dll"): if dll_choice == "plugin": out.entries.append(Entry(REFRAMEWORK, e, f"reframework/plugins/{e.name}")) elif dll_choice == "root": out.entries.append(Entry(ROOT, e, e.name)) elif dll_choice != "skip": if "reframework" in low or "d2d" in low: out.entries.append(Entry(REFRAMEWORK, e, f"reframework/plugins/{e.name}")) else: out.ambiguous.append(e) walk(top, 0) # the PAK order inside one mod follows its own patch numbers, then name return out