111 lines
4.4 KiB
Python
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
|