Files

111 lines
4.4 KiB
Python

"""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