feat: add various system and theme icons, configuration files, and support assets

This commit is contained in:
jschantli committed 2026-10-03 08:14:42 -07:00
1 parent 848dac4264
commit 54baea79f4
15893 files changed
+781426 -2

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,144 @@
"""Archive sniffing and hardened extraction (zip / 7z / rar)."""
from __future__ import annotations
import os
import shutil
import stat
import subprocess
import zipfile
from pathlib import Path
from .util import safe_relpath
MAX_UNPACKED = 32 * 1024 ** 3
class ArchiveError(Exception):
pass
def sniff(path: Path) -> str:
"""Return 'zip', '7z', 'rar' based on magic bytes (extensions lie)."""
with open(path, "rb") as fh:
head = fh.read(8)
if head[:4] in (b"PK\x03\x04", b"PK\x05\x06"):
return "zip"
if head[:6] == b"7z\xbc\xaf\x27\x1c":
return "7z"
if head[:4] == b"Rar!":
return "rar"
raise ArchiveError(f"{Path(path).name}: not a zip, 7z or rar archive")
def _validate_name(name: str) -> str:
rel = safe_relpath(name)
if rel is None:
raise ArchiveError(f"unsafe path in archive: {name!r}")
return rel
def _extract_zip(path: Path, dest: Path) -> None:
try:
zf = zipfile.ZipFile(path)
except zipfile.BadZipFile as e:
raise ArchiveError(f"corrupt zip: {e}") from e
with zf:
infos = zf.infolist()
# validate everything before writing anything
total = 0
plan = []
for info in infos:
rel = _validate_name(info.filename)
mode = info.external_attr >> 16
if stat.S_ISLNK(mode):
raise ArchiveError(f"symlink entries are not allowed: {info.filename!r}")
total += info.file_size
plan.append((info, rel))
if total > MAX_UNPACKED:
raise ArchiveError("archive unpacks to an unreasonable size")
root = os.path.realpath(dest)
for info, rel in plan:
target = os.path.join(root, rel)
if os.path.commonpath([root, os.path.realpath(target)]) != root:
raise ArchiveError(f"unsafe path in archive: {info.filename!r}")
if info.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
def _tool(*names: str) -> str | None:
for n in names:
p = shutil.which(n)
if p:
return p
return None
def _run(cmd: list[str]) -> str:
try:
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=3600)
except FileNotFoundError as e:
raise ArchiveError(f"required tool missing: {cmd[0]}") from e
if r.returncode != 0:
raise ArchiveError((r.stderr or r.stdout).strip().splitlines()[-1:][0] if (r.stderr or r.stdout).strip()
else f"{cmd[0]} failed")
return r.stdout
def _list_7z(tool: str, path: Path) -> list[str]:
out = _run([tool, "l", "-slt", "-ba", "--", str(path)])
return [line[7:] for line in out.splitlines() if line.startswith("Path = ")]
def _forbid_symlinks(dest: Path) -> None:
for dirpath, dirnames, filenames in os.walk(dest):
for n in dirnames + filenames:
p = os.path.join(dirpath, n)
if os.path.islink(p):
raise ArchiveError(f"symlink found in extracted archive: {n!r}")
def _extract_7z(path: Path, dest: Path) -> None:
tool = _tool("7z", "7zz", "7za", "7zr")
if not tool:
raise ArchiveError("7z is not installed")
for n in _list_7z(tool, path):
_validate_name(n)
_run([tool, "x", "-y", f"-o{dest}", "--", str(path)])
_forbid_symlinks(dest)
def _extract_rar(path: Path, dest: Path) -> None:
unrar = _tool("unrar")
if unrar:
names = _run([unrar, "lb", "--", str(path)]).splitlines()
for n in names:
if n.strip():
_validate_name(n)
_run([unrar, "x", "-y", "-o+", "--", str(path), str(dest) + os.sep])
_forbid_symlinks(dest)
else:
_extract_7z(path, dest) # 7z can read rar when its codec is present
def extract(archive: Path, dest: Path) -> None:
"""Extract into an existing empty directory, rejecting traversal and symlinks."""
archive, dest = Path(archive), Path(dest)
dest.mkdir(parents=True, exist_ok=True)
kind = sniff(archive)
{"zip": _extract_zip, "7z": _extract_7z, "rar": _extract_rar}[kind](archive, dest)
def copy_tree(src: Path, dest: Path) -> None:
"""Import an already-extracted folder; symlinks are skipped."""
for dirpath, dirnames, filenames in os.walk(src):
rel = Path(dirpath).relative_to(src)
(dest / rel).mkdir(parents=True, exist_ok=True)
dirnames[:] = [d for d in dirnames if not os.path.islink(os.path.join(dirpath, d))]
for f in filenames:
s = os.path.join(dirpath, f)
if not os.path.islink(s):
shutil.copy2(s, dest / rel / f)
@@ -0,0 +1,30 @@
"""settings.json — user preferences."""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from .util import atomic_write_json, read_json
LINK_MODES = ("symlink", "hardlink", "copy")
@dataclass
class Settings:
game_dir: str = "" # manual override; empty = auto-discover
link_mode: str = "symlink"
auto_deploy: bool = True # deploy on every checkbox toggle (Fluffy behaviour)
steam_user_id: str = "" # empty = first userdata dir
reframework_version: str = ""
@classmethod
def load(cls, path: Path) -> "Settings":
raw = read_json(path, {})
known = {f.name for f in fields(cls)}
s = cls(**{k: v for k, v in raw.items() if k in known}) if isinstance(raw, dict) else cls()
if s.link_mode not in LINK_MODES:
s.link_mode = "symlink"
return s
def save(self, path: Path) -> None:
atomic_write_json(path, asdict(self))
@@ -0,0 +1,485 @@
"""The link engine.
Deployment is a pure function from (enabled mods, load order) to a desired
`dest -> source` map, diffed against the last manifest and applied transactionally.
Safety rules (this writes into a ~100 GB game install):
1. Never delete anything that is not in our manifest *and* still matches what we recorded.
2. Never delete a PAK we did not create (falls out of rule 1).
3. Every destructive operation is preceded by a Plan the caller can show the user.
4. reframework/data/ is never touched.
"""
from __future__ import annotations
import os
import shutil
import uuid
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Callable
from . import paks
from .layout import PAK, Entry
from .paths import Paths
from .util import atomic_write_json, read_json
MANIFEST_VERSION = 1
ALLOWED_ROOTS = ("natives", "reframework", "pak_mods")
class DeployError(Exception):
pass
@dataclass(frozen=True)
class DeployMod:
id: str
entries: tuple[Entry, ...]
@dataclass(frozen=True)
class Item:
dest: str
src: Path
mod: str
kind: str
@dataclass
class Conflict:
key: str # dest path, or a description for PAK asset overlaps
kind: str # loose | pak-pak | pak-loose
mods: list[str] # every mod involved, ascending priority
winner: str
losers: list[str]
count: int = 1 # number of overlapping assets (PAK overlaps are aggregated)
@dataclass
class Desired:
items: dict[str, Item] = field(default_factory=dict)
conflicts: list[Conflict] = field(default_factory=list)
slots: list[paks.Slot] = field(default_factory=list)
# -- destination validation ---------------------------------------------------
def validate_dest(dest: str, baseline_names: set[str] = frozenset()) -> str | None:
"""Return an error string if `dest` is not somewhere we may write, else None."""
if not dest or dest.startswith("/") or "\\" in dest or "\0" in dest:
return f"invalid destination {dest!r}"
parts = PurePosixPath(dest).parts
if any(p in ("..", ".") for p in parts):
return f"destination escapes the game directory: {dest}"
low = [p.lower() for p in parts]
if low[0] == "reframework" and len(low) > 1 and low[1] == "data":
return f"reframework/data is never managed: {dest}"
if len(parts) == 1:
if dest in baseline_names:
return f"refusing to shadow vanilla PAK {dest}"
if paks.parse_pak_name(dest) or dest.lower().endswith(".dll"):
return None
return f"root-level file not allowed: {dest}"
if low[0] not in ALLOWED_ROOTS:
return f"destination outside natives/ or reframework/: {dest}"
return None
# -- desired state --------------------------------------------------------------
def build_desired(mods: list[DeployMod], baseline: paks.Baseline,
hashes: Callable[[Path], frozenset] = paks.pak_hashes) -> Desired:
"""`mods` is in load order, lowest priority first; later mods win contested paths."""
des = Desired()
by_key: dict[str, list[Item]] = {}
for mod in mods:
for e in mod.entries:
if e.kind == PAK:
continue
by_key.setdefault(e.dest.lower(), []).append(Item(e.dest, e.src, mod.id, e.kind))
for contenders in by_key.values():
# a mod listing the same dest twice is not a conflict with itself
winner = contenders[-1]
des.items[winner.dest] = winner
ids = list(dict.fromkeys(c.mod for c in contenders))
if len(ids) > 1:
des.conflicts.append(Conflict(winner.dest, "loose", ids, winner.mod,
[i for i in ids if i != winner.mod]))
des.slots = paks.allocate([(m.id, list(m.entries)) for m in mods], baseline)
for s in des.slots:
des.items[s.dest] = Item(s.dest, s.src, s.mod_id, PAK)
des.conflicts.extend(_pak_conflicts(des, mods, hashes))
des.conflicts.sort(key=lambda c: (c.kind, c.key.lower()))
return des
def _pak_conflicts(des: Desired, mods: list[DeployMod], hashes) -> list[Conflict]:
"""Asset overlaps involving PAKs, matched by KPKA path hash (best effort)."""
if not des.slots:
return []
order = {m.id: i for i, m in enumerate(mods)}
owners: dict[tuple[int, int], list[tuple[str, str]]] = {} # hash -> [(mod, "pak"|"loose")]
for s in des.slots:
for h in hashes(s.src):
owners.setdefault(h, []).append((s.mod_id, "pak"))
if not owners:
return []
for it in des.items.values():
if it.kind != PAK and it.dest.lower().startswith("natives/"):
h = paks.path_hash(it.dest)
if h in owners:
owners[h].append((it.mod, "loose"))
groups: dict[tuple, dict] = {}
for h, lst in owners.items():
ids = tuple(sorted(dict.fromkeys(m for m, _ in lst), key=lambda m: order.get(m, 0)))
if len(ids) < 2:
continue
loose = {m for m, k in lst if k == "loose"}
g = groups.setdefault((("pak-loose" if loose else "pak-pak"), ids), {"count": 0, "loose": set()})
g["count"] += 1
g["loose"] |= loose
out = []
for (kind, ids), g in groups.items():
# the loose file loader takes precedence over PAK contents
winner = next((m for m in reversed(ids) if m in g["loose"]), ids[-1])
out.append(Conflict(f"{g['count']} shared asset(s)", kind, list(ids), winner,
[i for i in ids if i != winner], g["count"]))
return out
# -- plan ---------------------------------------------------------------------------
@dataclass
class Action:
op: str # add | remove | forget | skip
dest: str
item: Item | None = None
entry: dict | None = None
note: str = ""
shadow: bool = False # add: an unmanaged file occupies dest and will be backed up
def describe(self) -> str:
if self.op == "add":
extra = " (backs up existing file)" if self.shadow else ""
return f"+ {self.dest}{extra}"
if self.op == "remove":
return f"- {self.dest}" + (" (restores backup)" if self.entry and self.entry.get("backup") else "")
if self.op == "forget":
return f"? {self.dest} {self.note}"
return f"! {self.dest} {self.note}"
@dataclass
class Plan:
actions: list[Action] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def changes(self) -> list[Action]:
return [a for a in self.actions if a.op in ("add", "remove")]
@property
def is_empty(self) -> bool:
return not self.actions
def describe(self) -> list[str]:
return [a.describe() for a in self.actions] + [f"warning: {w}" for w in self.warnings]
@dataclass
class ApplyResult:
added: int = 0
removed: int = 0
warnings: list[str] = field(default_factory=list)
# -- engine ---------------------------------------------------------------------------
class _Txn:
"""Undo log so a mid-apply failure leaves the game directory as it was."""
def __init__(self):
self.undo: list[Callable[[], None]] = []
self.trash: list[Path] = []
def rollback(self) -> list[str]:
errs = []
for fn in reversed(self.undo):
try:
fn()
except Exception as e: # keep unwinding
errs.append(str(e))
return errs
class Deployer:
def __init__(self, game_dir: Path, paths: Paths, link_mode: str = "symlink"):
self.game = Path(game_dir)
self.paths = paths
self.link_mode = link_mode
if not self.game.is_dir():
raise DeployError(f"game directory not found: {self.game}")
self._real_game = Path(os.path.realpath(self.game))
# -- manifest ---------------------------------------------------------
def manifest(self) -> dict:
m = read_json(self.paths.manifest_file, None)
if not isinstance(m, dict) or "entries" not in m:
return {"version": MANIFEST_VERSION, "entries": {}, "dirs": []}
m.setdefault("dirs", [])
return m
def _save_manifest(self, m: dict) -> None:
atomic_write_json(self.paths.manifest_file, m)
def _inside(self, path: Path) -> bool:
real = Path(os.path.realpath(path))
return real == self._real_game or self._real_game in real.parents
def _p(self, dest: str) -> Path:
return self.game / dest
def check(self, dest: str, entry: dict) -> str:
"""'ok' if the on-disk state still matches what we recorded, 'missing', or 'modified'."""
p = self._p(dest)
if not os.path.lexists(p):
return "missing"
try:
mode = entry["mode"]
if mode == "symlink":
return "ok" if os.path.islink(p) and os.readlink(p) == entry["src"] else "modified"
if os.path.islink(p) or not os.path.isfile(p):
return "modified"
st = os.stat(p)
if mode == "hardlink":
return "ok" if (st.st_ino, st.st_dev) == (entry.get("ino"), entry.get("dev")) else "modified"
return "ok" if (st.st_size, st.st_mtime_ns) == (entry.get("size"), entry.get("mtime_ns")) else "modified"
except (OSError, KeyError):
return "modified"
def owned_paks(self) -> set[str]:
m = self.manifest()
return {d for d, e in m["entries"].items()
if e.get("kind") == PAK and self.check(d, e) == "ok"}
def desired_from_manifest(self, exclude_kind: str | None = None) -> Desired:
"""The currently-deployed state as a Desired, optionally minus one kind."""
des = Desired()
for d, e in self.manifest()["entries"].items():
if e.get("kind") != exclude_kind:
des.items[d] = Item(d, Path(e["src"]), e.get("mod", ""), e.get("kind", ""))
return des
# -- planning -----------------------------------------------------------
def plan(self, desired: Desired) -> Plan:
plan = Plan()
m = self.manifest()
entries: dict[str, dict] = m["entries"]
baseline = paks.load_baseline(self.paths.baseline_file)
vanilla = {n for c in (baseline.chains.values() if baseline else []) for n in c.get("files", {})}
for dest, entry in sorted(entries.items()):
state = self.check(dest, entry)
want = desired.items.get(dest)
if want is None:
if state == "ok":
plan.actions.append(Action("remove", dest, entry=entry))
elif state == "missing":
plan.actions.append(Action("forget", dest, entry=entry, note="already gone"))
else:
plan.actions.append(Action("forget", dest, entry=entry, note="changed since deployment; left in place"))
plan.warnings.append(f"{dest} was modified outside the manager; leaving it alone")
elif state == "modified":
plan.actions.append(Action("skip", dest, want, entry, "changed since deployment; not overwritten"))
plan.warnings.append(f"{dest} was modified outside the manager; skipping")
elif str(want.src) != entry["src"] or want.mod != entry.get("mod"):
plan.actions.append(Action("remove", dest, entry=entry, note="replaced"))
plan.actions.append(Action("add", dest, want))
elif state == "missing":
plan.actions.append(Action("add", dest, want, note="re-link"))
for dest, item in sorted(desired.items.items()):
if dest in entries:
continue
err = validate_dest(dest, vanilla)
if err:
plan.actions.append(Action("skip", dest, item, note=err))
plan.warnings.append(err)
continue
p = self._p(dest)
shadow = os.path.lexists(p)
if shadow and (os.path.isdir(p) and not os.path.islink(p)):
plan.actions.append(Action("skip", dest, item, note="a directory is in the way"))
plan.warnings.append(f"{dest}: a directory is in the way")
continue
if shadow and (self.paths.backup_dir / dest).exists():
plan.actions.append(Action("skip", dest, item, note="a stale backup already exists"))
plan.warnings.append(f"{dest}: stale backup exists at {self.paths.backup_dir / dest}")
continue
plan.actions.append(Action("add", dest, item, shadow=shadow))
return plan
# -- apply --------------------------------------------------------------
def apply(self, plan: Plan) -> ApplyResult:
m = self.manifest()
entries: dict[str, dict] = dict(m["entries"])
dirs: list[str] = list(m["dirs"])
res = ApplyResult(warnings=list(plan.warnings))
txn = _Txn()
try:
# removals first so a replaced path is free before its new link goes in
carry: dict[str, str] = {}
for a in plan.actions:
if a.op == "remove":
replaced = a.note == "replaced"
old = entries.pop(a.dest)
self._remove(a.dest, old, txn, restore=not replaced)
if replaced and old.get("backup"):
carry[a.dest] = old["backup"] # the shadowed original stays backed up
else:
res.removed += 1
elif a.op == "forget":
e = entries.pop(a.dest, None)
if a.note == "already gone" and e and e.get("backup"):
self._restore_backup(a.dest, txn)
for a in plan.actions:
if a.op == "add":
entries[a.dest] = self._add(a, txn, dirs, carry.get(a.dest, ""))
res.added += 1
m = {"version": MANIFEST_VERSION, "entries": entries, "dirs": dirs}
self._save_manifest(m)
except BaseException as e:
errs = txn.rollback()
if errs:
res.warnings.extend(f"rollback: {x}" for x in errs)
if isinstance(e, (OSError, DeployError)):
raise DeployError(f"deploy failed and was rolled back: {e}") from e
raise
for t in txn.trash:
shutil.rmtree(t, ignore_errors=True)
self._prune_dirs(m)
return res
def undeploy_all(self) -> ApplyResult:
return self.apply(self.plan(Desired()))
# -- primitives -----------------------------------------------------------
def _remove(self, dest: str, entry: dict, txn: _Txn, restore: bool = True) -> None:
p = self._p(dest)
mode = entry["mode"]
if mode == "symlink":
target = os.readlink(p)
os.unlink(p)
txn.undo.append(lambda: os.symlink(target, p))
elif mode == "hardlink":
src = entry["src"]
os.unlink(p)
txn.undo.append(lambda: os.link(src, p))
else:
stash = self.paths.trash_dir / uuid.uuid4().hex
stash.mkdir(parents=True)
txn.trash.append(stash)
shutil.move(str(p), str(stash / "f"))
txn.undo.append(lambda: shutil.move(str(stash / "f"), str(p)))
if restore and entry.get("backup"):
self._restore_backup(dest, txn)
def _restore_backup(self, dest: str, txn: _Txn) -> None:
bak = self.paths.backup_dir / dest
p = self._p(dest)
if not os.path.lexists(bak):
return
if os.path.lexists(p):
return # something else lives there now; keep the backup
os.makedirs(p.parent, exist_ok=True)
shutil.move(str(bak), str(p))
txn.undo.append(lambda: (os.makedirs(bak.parent, exist_ok=True), shutil.move(str(p), str(bak))))
self._prune_backup_dirs(bak.parent)
def _prune_backup_dirs(self, d: Path) -> None:
root = self.paths.backup_dir
while d != root and root in d.parents:
try:
d.rmdir()
except OSError:
return
d = d.parent
def _mkdirs(self, rel_parent: str, dirs: list[str], txn: _Txn) -> None:
cur = self.game
rel = ""
for part in PurePosixPath(rel_parent).parts:
cur = cur / part
rel = f"{rel}/{part}" if rel else part
if os.path.lexists(cur):
if not os.path.isdir(cur):
raise DeployError(f"{rel} exists and is not a directory")
continue
if not self._inside(cur.parent):
raise DeployError(f"{rel} would land outside the game directory")
os.mkdir(cur)
dirs.append(rel)
txn.undo.append(lambda c=cur: os.rmdir(c))
def _add(self, a: Action, txn: _Txn, dirs: list[str], carry_backup) -> dict:
assert a.item is not None
dest, src = a.dest, a.item.src
err = validate_dest(dest)
if err:
raise DeployError(err)
p = self._p(dest)
if not os.path.isfile(src):
raise DeployError(f"source file missing: {src}")
rel_parent = str(PurePosixPath(dest).parent)
if rel_parent != ".":
self._mkdirs(rel_parent, dirs, txn)
if not self._inside(p.parent):
raise DeployError(f"{dest} would land outside the game directory")
backup = carry_backup or ""
if os.path.lexists(p):
bak = self.paths.backup_dir / dest
if os.path.lexists(bak):
raise DeployError(f"stale backup exists: {bak}")
os.makedirs(bak.parent, exist_ok=True)
shutil.move(str(p), str(bak))
txn.undo.append(lambda: shutil.move(str(bak), str(p)))
backup = dest
mode = self._link(src, p, txn)
rec = {"mod": a.item.mod, "src": str(src), "mode": mode, "kind": a.item.kind, "backup": backup}
if mode != "symlink":
st = os.stat(p)
rec.update(size=st.st_size, mtime_ns=st.st_mtime_ns, ino=st.st_ino, dev=st.st_dev)
return rec
def _link(self, src: Path, dest: Path, txn: _Txn) -> str:
mode = self.link_mode
src_abs = os.path.abspath(src)
if mode == "symlink":
os.symlink(src_abs, dest)
elif mode == "hardlink":
try:
os.link(src_abs, dest)
except OSError:
shutil.copy2(src_abs, dest)
mode = "copy"
else:
shutil.copy2(src_abs, dest)
txn.undo.append(lambda: os.unlink(dest))
return mode
def _prune_dirs(self, m: dict) -> None:
"""Remove directories we created that are now empty (deepest first)."""
keep = []
for rel in sorted(m["dirs"], key=lambda r: -r.count("/")):
p = self._p(rel)
if not os.path.isdir(p):
continue
try:
p.rmdir()
except OSError:
keep.append(rel)
if len(keep) != len(m["dirs"]):
m["dirs"] = sorted(keep)
self._save_manifest(m)
@@ -0,0 +1,44 @@
"""Desktop entry + x-scheme-handler/nxm registration (user-level only)."""
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
from pathlib import Path
from .. import APP_ID
from .util import atomic_write_text
DESKTOP_NAME = f"{APP_ID}.desktop"
def desktop_entry(exec_path: str) -> str:
return (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=MH Wilds Mod Manager\n"
"Comment=Manage mods for Monster Hunter Wilds\n"
f"Exec={shlex.quote(exec_path)} %u\n"
"Icon=applications-games\n"
"Terminal=false\n"
"Categories=Game;Utility;\n"
"MimeType=x-scheme-handler/nxm;\n"
"StartupNotify=true\n"
)
def applications_dir() -> Path:
base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share")
return base / "applications"
def register_nxm_handler(exec_path: str) -> Path:
"""Write the .desktop file and make it the default nxm:// handler. Returns the file path."""
target = applications_dir() / DESKTOP_NAME
atomic_write_text(target, desktop_entry(exec_path), 0o644)
for cmd in (["update-desktop-database", str(target.parent)],
["xdg-mime", "default", DESKTOP_NAME, "x-scheme-handler/nxm"]):
if shutil.which(cmd[0]):
subprocess.run(cmd, check=False, capture_output=True, timeout=30)
return target
@@ -0,0 +1,110 @@
"""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
@@ -0,0 +1,163 @@
"""The mod library: import, scan, rename, delete. Never touches the game directory."""
from __future__ import annotations
import os
import re
import shutil
import time
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from . import archive, layout, modinfo
from .paths import Paths
from .util import atomic_write_json, is_within, read_json, slugify
class LibraryError(Exception):
pass
@dataclass
class Mod:
id: str
name: str
version: str = ""
author: str = ""
description: str = ""
category: str = ""
screenshot: str = "" # relative to files/
imported_at: float = 0.0
source: str = "" # original archive name
dll_choice: str = "" # "", "plugin", "root", "skip"
nexus_mod_id: str = ""
nexus_file_id: str = ""
nexus_url: str = ""
root: Path = field(default=Path(), compare=False, repr=False) # mods/<id>
@property
def files_root(self) -> Path:
return self.root / "files"
@property
def screenshot_path(self) -> Path | None:
if not self.screenshot:
return None
p = self.files_root / self.screenshot
return p if p.is_file() else None
def classification(self) -> layout.Classification:
return layout.classify(self.files_root, self.dll_choice or None)
def to_meta(self) -> dict:
d = asdict(self)
d.pop("root")
return d
class Library:
def __init__(self, paths: Paths):
self.paths = paths
self.mods: dict[str, Mod] = {}
# -- loading ----------------------------------------------------------
def scan(self) -> None:
self.mods.clear()
base = self.paths.mods_dir
if not base.is_dir():
return
for d in sorted(base.iterdir()):
meta = read_json(d / "meta.json", None) if d.is_dir() and not d.name.startswith(".") else None
if not isinstance(meta, dict) or not (d / "files").is_dir():
continue
known = Mod.__dataclass_fields__.keys() - {"root"}
mod = Mod(**{k: v for k, v in meta.items() if k in known})
mod.id = d.name
mod.root = d
self.mods[mod.id] = mod
def get(self, mod_id: str) -> Mod | None:
return self.mods.get(mod_id)
def sorted_mods(self) -> list[Mod]:
return sorted(self.mods.values(), key=lambda m: m.name.lower())
# -- import -----------------------------------------------------------
def _unique_id(self, name: str) -> str:
base = slugify(name)
cand, n = base, 2
while (self.paths.mods_dir / cand).exists():
cand = f"{base}-{n}"
n += 1
return cand
def import_source(self, source: Path, nexus: dict | None = None) -> Mod:
"""Import an archive file or an extracted folder. `nexus` may prefill metadata."""
source = Path(source)
self.paths.mods_dir.mkdir(parents=True, exist_ok=True)
tmp = self.paths.mods_dir / f".incoming-{uuid.uuid4().hex[:8]}"
try:
tmp.mkdir()
try:
if source.is_dir():
archive.copy_tree(source, tmp)
elif source.suffix.lower() in (".pak", ".dll", ".lua"):
shutil.copy2(source, tmp / source.name)
else:
archive.extract(source, tmp)
except archive.ArchiveError as e:
raise LibraryError(str(e)) from e
cls = layout.classify(tmp)
if cls.empty:
raise LibraryError("Nothing recognisable to install (no natives/, reframework/, .pak or .lua files).")
info, info_dir = modinfo.read_modinfo(tmp)
nexus = nexus or {}
stem = re.sub(r"[-_ ]?\d+-\d+(-\d+)*(-\d+)?$", "", source.stem) if not source.is_dir() else source.name
name = info.name or nexus.get("name") or stem or "Unnamed mod"
mod_id = self._unique_id(name)
shot = modinfo.find_screenshot(info_dir or tmp, info.screenshot)
shot_rel = os.path.relpath(shot, tmp) if shot else ""
mod = Mod(
id=mod_id, name=name,
version=info.version or str(nexus.get("version") or ""),
author=info.author or str(nexus.get("author") or ""),
description=info.description or str(nexus.get("summary") or ""),
category=info.category,
screenshot=shot_rel, imported_at=time.time(), source=source.name,
nexus_mod_id=info.nexus_mod_id or str(nexus.get("mod_id") or ""),
nexus_file_id=str(nexus.get("file_id") or ""),
nexus_url=str(nexus.get("url") or ""),
)
dest = self.paths.mods_dir / mod_id
dest.mkdir()
os.rename(tmp, dest / "files")
mod.root = dest
self.save(mod)
self.mods[mod_id] = mod
return mod
finally:
shutil.rmtree(tmp, ignore_errors=True)
def save(self, mod: Mod) -> None:
atomic_write_json(mod.root / "meta.json", mod.to_meta())
# -- edits ------------------------------------------------------------
def update(self, mod_id: str, **fields) -> Mod:
mod = self.mods[mod_id]
for k, v in fields.items():
if k in ("name", "category", "version", "author", "description", "dll_choice", "screenshot"):
setattr(mod, k, v)
self.save(mod)
return mod
def delete(self, mod_id: str) -> None:
mod = self.mods.get(mod_id)
if mod is None:
return
if not is_within(mod.root, self.paths.mods_dir) or mod.root.resolve() == self.paths.mods_dir.resolve():
raise LibraryError("refusing to delete outside the library")
shutil.rmtree(mod.root)
del self.mods[mod_id]
def categories(self) -> list[str]:
return sorted({m.category for m in self.mods.values() if m.category}, key=str.lower)
@@ -0,0 +1,136 @@
"""REFramework setup: dinput8.dll, re2_fw_config.txt and the Proton launch option."""
from __future__ import annotations
import io
import json
import re
import urllib.request
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from . import steam
from .util import atomic_write_bytes, atomic_write_text
# The filename keeps its RE2 origin across all RE Engine games.
CONFIG_NAME = "re2_fw_config.txt"
LOOSE_KEY = "LooseFileLoader_Enabled"
RELEASES_API = "https://api.github.com/repos/praydog/REFramework-nightly/releases/latest"
ASSET_NAME = "REFramework.zip"
MAX_DOWNLOAD = 200 * 1024 * 1024
@dataclass
class Check:
label: str
ok: bool
detail: str = ""
@dataclass
class LoaderStatus:
checks: list[Check] = field(default_factory=list)
launch_options: str | None = None
suggested_launch_options: str = steam.REQUIRED_LAUNCH_OPTIONS
@property
def all_ok(self) -> bool:
return all(c.ok for c in self.checks)
# -- config file ------------------------------------------------------------------
def read_config_value(config: Path, key: str) -> str | None:
try:
text = config.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for line in text.splitlines():
k, sep, v = line.partition("=")
if sep and k.strip().lower() == key.lower():
return v.strip()
return None
def set_config_value(config: Path, key: str, value: str) -> None:
"""Key-wise patch: keep every unrelated line, create the file if absent."""
try:
text = config.read_text(encoding="utf-8")
except FileNotFoundError:
text = ""
nl = "\r\n" if "\r\n" in text else "\n"
lines = text.splitlines()
for i, line in enumerate(lines):
k, sep, _ = line.partition("=")
if sep and k.strip().lower() == key.lower():
lines[i] = f"{k.strip()}={value}"
break
else:
lines.append(f"{key}={value}")
atomic_write_text(config, nl.join(lines) + nl)
def loose_loader_enabled(game_dir: Path) -> bool:
return (read_config_value(game_dir / CONFIG_NAME, LOOSE_KEY) or "").lower() == "true"
def enable_loose_loader(game_dir: Path) -> None:
set_config_value(game_dir / CONFIG_NAME, LOOSE_KEY, "true")
# -- status -------------------------------------------------------------------------
def status(game_dir: Path, launch_options: str | None) -> LoaderStatus:
st = LoaderStatus(launch_options=launch_options,
suggested_launch_options=steam.suggest_launch_options(launch_options))
has_dll = (game_dir / "dinput8.dll").is_file()
st.checks.append(Check("REFramework (dinput8.dll) installed", has_dll,
"" if has_dll else "dinput8.dll not found in the game folder"))
loose = loose_loader_enabled(game_dir)
st.checks.append(Check(f"{LOOSE_KEY}=true in {CONFIG_NAME}", loose,
"" if loose else "Must be set before launching the game"))
lo = steam.launch_options_ok(launch_options)
st.checks.append(Check("Steam launch option overrides dinput8", lo,
"" if lo else "Proton needs WINEDLLOVERRIDES to load dinput8.dll"))
return st
# -- install ------------------------------------------------------------------------
Fetch = Callable[[str], bytes]
def _default_fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "mhwilds-mod-manager",
"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req, timeout=60) as r:
data = r.read(MAX_DOWNLOAD + 1)
if len(data) > MAX_DOWNLOAD:
raise RuntimeError("download is unexpectedly large")
return data
def install_reframework(game_dir: Path, fetch: Fetch = _default_fetch, backup_dir: Path | None = None) -> str:
"""Download the latest nightly MHWILDS.zip and extract dinput8.dll. Returns the release tag."""
release = json.loads(fetch(RELEASES_API))
asset = next((a for a in release.get("assets", []) if a.get("name") == ASSET_NAME), None)
if not asset:
raise RuntimeError(f"latest REFramework release has no {ASSET_NAME}")
blob = fetch(asset["browser_download_url"])
with zipfile.ZipFile(io.BytesIO(blob)) as zf:
member = next((n for n in zf.namelist() if n.lower() == "dinput8.dll"), None)
if member is None:
raise RuntimeError("dinput8.dll not found in the release archive")
dll = zf.read(member)
target = game_dir / "dinput8.dll"
if target.exists() and backup_dir is not None:
backup_dir.mkdir(parents=True, exist_ok=True)
(backup_dir / "dinput8.dll.prev").write_bytes(target.read_bytes())
atomic_write_bytes(target, dll, 0o755)
return str(release.get("tag_name", ""))
def setup_all(game_dir: Path) -> None:
"""Everything except the Steam launch option (that is verify-only by default)."""
enable_loose_loader(game_dir)
@@ -0,0 +1,281 @@
"""Facade tying library, profiles, deploy engine and PAK slotting together.
The UI talks only to this class; it has no GTK imports so it can be driven headlessly.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from . import loader, paks, steam
from .config import Settings
from .deploy import Conflict, DeployError, DeployMod, Deployer, Desired, Plan, ApplyResult, build_desired
from .layout import PAK, Classification
from .library import Library, Mod
from .paths import Paths
from .profiles import Profiles
from .modpack import export_modpack, import_modpack
@dataclass
class StartupReport:
game_found: bool = False
notes: list[str] = field(default_factory=list)
class Manager:
def __init__(self, paths: Paths | None = None, settings: Settings | None = None):
self.paths = paths or Paths.default()
self.paths.ensure()
self.settings = settings or Settings.load(self.paths.settings_file)
self.library = Library(self.paths)
self.profiles = Profiles(self.paths.profiles_file)
self.game_dir: Path | None = None
self.install: steam.GameInstall | None = None
self.deployer: Deployer | None = None
self.baseline: paks.Baseline | None = None
self._cls_cache: dict[str, Classification] = {}
self.last_desired: Desired = Desired()
self.reload_library()
# -- discovery ------------------------------------------------------------
def discover_game(self) -> Path | None:
if self.settings.game_dir:
p = Path(self.settings.game_dir)
self.install = None
return p if p.is_dir() else None
self.install = steam.find_app()
return self.install.game_dir if self.install else None
def bind_game(self, game_dir: Path | None) -> bool:
self.game_dir = game_dir
self.deployer = Deployer(game_dir, self.paths, self.settings.link_mode) if game_dir else None
return self.deployer is not None
def set_game_dir(self, path: str) -> None:
self.settings.game_dir = path
self.settings.save(self.paths.settings_file)
def apply_settings(self) -> None:
self.settings.save(self.paths.settings_file)
if self.deployer:
self.deployer.link_mode = self.settings.link_mode
def steam_root(self) -> Path | None:
if self.install:
return self.install.steam_root
roots = steam.steam_roots()
return roots[0] if roots else None
# -- startup ----------------------------------------------------------------
def startup(self) -> StartupReport:
rep = StartupReport()
self.reload_library()
if not self.bind_game(self.discover_game()):
return rep
rep.game_found = True
assert self.deployer and self.game_dir
self.baseline = paks.load_baseline(self.paths.baseline_file)
if self.baseline is None:
self.baseline = paks.capture_baseline(self.game_dir, self.deployer.owned_paks())
paks.save_baseline(self.paths.baseline_file, self.baseline)
note = self._handle_game_update()
try:
self.deploy()
except DeployError as e:
rep.notes.append(str(e))
if note:
rep.notes.insert(0, self._update_message(note))
return rep
def _handle_game_update(self) -> bool:
"""If Steam patched the game: drop our PAKs, re-derive the ceiling. Returns True if it happened."""
assert self.deployer and self.game_dir and self.baseline
report = paks.detect_update(self.game_dir, self.baseline, self.deployer.owned_paks())
if not report.updated:
return False
keep = self.deployer.desired_from_manifest(exclude_kind=PAK)
self.deployer.apply(self.deployer.plan(keep))
self.baseline = paks.capture_baseline(self.game_dir, self.deployer.owned_paks())
paks.save_baseline(self.paths.baseline_file, self.baseline)
return True
def _update_message(self, _: bool) -> str:
assert self.baseline
first = self.baseline.ceiling("main") + 1
if any(e.kind == PAK for m in self._enabled_mods() for e in m.entries):
return f"Game updated — mod PAKs re-slotted to {first:03d}+"
return f"Game updated — vanilla PAK ceiling is now {first - 1:03d}"
# -- library -----------------------------------------------------------------
def reload_library(self) -> None:
self.library.scan()
self._cls_cache.clear()
self.profiles.reconcile([m.id for m in self.library.sorted_mods()])
self.profiles.save()
def import_source(self, source: Path, nexus: dict | None = None) -> Mod:
mod = self.library.import_source(source, nexus)
self.profiles.reconcile(list(self.library.mods)) # new mod is appended to every profile's order
self.profiles.save()
return mod
def classification(self, mod: Mod) -> Classification:
c = self._cls_cache.get(mod.id)
if c is None:
c = self._cls_cache[mod.id] = mod.classification()
return c
def update_mod(self, mod_id: str, **fields) -> Mod:
mod = self.library.update(mod_id, **fields)
self._cls_cache.pop(mod_id, None)
return mod
def delete_mod(self, mod_id: str) -> None:
if self.profiles.is_enabled(mod_id) or any(mod_id in p["enabled"] for p in self.profiles.data.values()):
self.profiles.set_enabled(mod_id, False)
if self.deployer:
self.deploy()
self.library.delete(mod_id)
self.profiles.remove_mod(mod_id)
self.profiles.save()
self._cls_cache.pop(mod_id, None)
# -- state -------------------------------------------------------------------
def _enabled_mods(self) -> list[DeployMod]:
out = []
for mid in self.profiles.enabled_in_order():
mod = self.library.get(mid)
if mod:
out.append(DeployMod(mid, tuple(self.classification(mod).entries)))
return out
def desired(self) -> Desired:
if self.baseline is None:
raise DeployError("Game not set up yet")
self.last_desired = build_desired(self._enabled_mods(), self.baseline)
return self.last_desired
def preview(self) -> Plan:
if not self.deployer:
raise DeployError("Game not found")
return self.deployer.plan(self.desired())
def deploy(self) -> ApplyResult:
if not self.deployer:
raise DeployError("Game not found")
return self.deployer.apply(self.deployer.plan(self.desired()))
def undeploy_all(self) -> ApplyResult:
if not self.deployer:
raise DeployError("Game not found")
return self.deployer.undeploy_all()
def set_enabled(self, mod_id: str, on: bool) -> ApplyResult | None:
self.profiles.set_enabled(mod_id, on)
self.profiles.save()
return self.deploy() if self.settings.auto_deploy and self.deployer else None
def move(self, mod_id: str, new_index: int) -> ApplyResult | None:
self.profiles.move(mod_id, new_index)
self.profiles.save()
return self.deploy() if self.settings.auto_deploy and self.deployer else None
def switch_profile(self, name: str) -> ApplyResult | None:
self.profiles.switch(name)
self.profiles.save()
return self.deploy() if self.deployer else None
def export_modpack(self, dest_path: Path, profile_name: str | None = None) -> None:
profile_name = profile_name or self.profiles.active
export_modpack(self, dest_path, profile_name)
def import_modpack(self, source_path: Path, profile_name: str | None = None) -> list[str]:
profile_name = profile_name or source_path.stem
res = import_modpack(self, source_path, profile_name)
if self.deployer and self.settings.auto_deploy:
self.deploy()
return res
def conflicts(self) -> list[Conflict]:
return self.last_desired.conflicts if self.baseline else []
def conflicts_for(self, mod_id: str) -> list[Conflict]:
return [c for c in self.conflicts() if mod_id in c.mods]
def pending(self) -> bool:
"""True if what is on disk differs from what the profile asks for."""
return bool(self.deployer and self.baseline and self.preview().changes)
# -- PAK info for the UI ---------------------------------------------------------
def pak_summary(self) -> dict[str, dict]:
"""chain -> {ceiling, count, mod_slots}"""
if not self.game_dir:
return {}
scan = paks.scan_game_paks(self.game_dir)
owned = self.deployer.owned_paks() if self.deployer else set()
out = {}
for chain in paks.CHAINS:
vanilla = [n for n, i in scan[chain].items() if i["name"] not in owned]
out[chain] = {"ceiling": max(vanilla, default=0),
"baseline_ceiling": self.baseline.ceiling(chain) if self.baseline else 0,
"vanilla": len(vanilla),
"mod_slots": sorted(n for n, i in scan[chain].items() if i["name"] in owned)}
return out
# -- loader ----------------------------------------------------------------------
def launch_options(self) -> str | None:
root = self.steam_root()
lc = steam.localconfig_path(root, self.settings.steam_user_id) if root else None
return steam.read_launch_options(lc) if lc else None
def loader_status(self) -> loader.LoaderStatus:
assert self.game_dir
return loader.status(self.game_dir, self.launch_options())
def install_reframework(self) -> str:
import time
from .library import Mod
assert self.game_dir
mod_id = "reframework"
mod_dir = self.paths.mods_dir / mod_id
files_dir = mod_dir / "files"
files_dir.mkdir(parents=True, exist_ok=True)
tag = loader.install_reframework(files_dir, backup_dir=self.paths.backup_dir)
if mod_id not in self.library.mods:
mod = Mod(
id=mod_id,
name="REFramework",
version=tag,
author="praydog",
description="RE Engine Modding Framework",
category="Core",
imported_at=time.time(),
source="REFramework.zip",
dll_choice="root",
)
mod.root = mod_dir
self.library.save(mod)
self.library.mods[mod_id] = mod
self.profiles.reconcile(list(self.library.mods.keys()))
self.profiles.set_enabled(mod_id, True)
self.profiles.save()
else:
self.library.update(mod_id, version=tag, dll_choice="root")
self.deploy()
self.settings.reframework_version = tag
self.apply_settings()
loader.setup_all(self.game_dir)
return tag
def write_launch_options(self, value: str) -> Path:
root = self.steam_root()
lc = steam.localconfig_path(root, self.settings.steam_user_id) if root else None
if lc is None:
raise steam.VDFError("Steam user data not found")
return steam.write_launch_options(lc, value)
@@ -0,0 +1,83 @@
"""Fluffy-compatible modinfo.ini and screenshot discovery."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp")
@dataclass
class ModInfo:
name: str = ""
version: str = ""
author: str = ""
description: str = ""
category: str = ""
screenshot: str = "" # as written in the ini (relative)
nexus_mod_id: str = ""
def parse_modinfo(text: str) -> ModInfo:
"""Tolerant key=value parser; Fluffy files have no section header, but accept one."""
vals: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line[0] in ";#[" or "=" not in line:
continue
k, _, v = line.partition("=")
vals[k.strip().lower()] = v.strip()
return ModInfo(
name=vals.get("name", ""),
version=vals.get("version", ""),
author=vals.get("author", ""),
description=vals.get("description", "").replace("\\n", "\n"),
category=vals.get("category", ""),
screenshot=vals.get("screenshot", ""),
nexus_mod_id=vals.get("nexusid", vals.get("nexus_mod_id", "")),
)
def _read_text(path: Path) -> str:
raw = path.read_bytes()
for enc in ("utf-8-sig", "utf-16", "latin-1"):
try:
return raw.decode(enc)
except UnicodeError:
continue
return raw.decode("utf-8", "replace")
def find_modinfo(root: Path) -> Path | None:
"""modinfo.ini at the root or one directory down."""
for base in [root, *sorted(p for p in root.iterdir() if p.is_dir())]:
for p in base.iterdir():
if p.is_file() and p.name.lower() == "modinfo.ini":
return p
return None
def read_modinfo(root: Path) -> tuple[ModInfo, Path | None]:
p = find_modinfo(root)
if p is None:
return ModInfo(), None
try:
return parse_modinfo(_read_text(p)), p.parent
except OSError:
return ModInfo(), None
def find_screenshot(base: Path, hint: str = "") -> Path | None:
"""Explicit `screenshot=` wins, else screenshot.png|jpg in the mod root (Fluffy convention)."""
if not base.is_dir():
return None
files = {p.name.lower(): p for p in base.iterdir() if p.is_file()}
if hint:
p = files.get(Path(hint).name.lower())
if p:
return p
for ext in IMAGE_EXTS:
p = files.get("screenshot" + ext)
if p:
return p
return None
@@ -0,0 +1,82 @@
from __future__ import annotations
import json
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING
from .library import Mod, LibraryError
if TYPE_CHECKING:
from .manager import Manager
def export_modpack(manager: Manager, dest_path: Path, profile_name: str) -> None:
profile = manager.profiles.data.get(profile_name)
if not profile:
raise ValueError(f"Profile {profile_name} not found")
with zipfile.ZipFile(dest_path, "w", zipfile.ZIP_DEFLATED) as zf:
# Export profile metadata
zf.writestr("modpack.json", json.dumps({"name": profile_name, "profile": profile}, indent=2))
# Export mods
mods_to_export = set(profile["order"])
for mod_id in mods_to_export:
mod = manager.library.get(mod_id)
if not mod:
continue
# Write mod files
mod_root = mod.root
for dirpath, dirnames, filenames in os.walk(mod_root):
for filename in filenames:
file_path = Path(dirpath) / filename
rel_path = file_path.relative_to(mod_root)
zf.write(file_path, f"mods/{mod_id}/{rel_path}")
def import_modpack(manager: Manager, source_path: Path, new_profile_name: str) -> list[str]:
with zipfile.ZipFile(source_path, "r") as zf:
try:
with zf.open("modpack.json") as f:
data = json.load(f)
profile = data.get("profile")
except KeyError:
raise ValueError("Not a valid modpack: missing modpack.json")
if not profile:
raise ValueError("Invalid modpack.json")
# Extract mods
extracted_mods = []
for name in zf.namelist():
if name.startswith("mods/") and len(name.split("/")) > 2:
parts = name.split("/")
mod_id = parts[1]
if mod_id not in manager.library.mods:
# Not in library, we need to extract it
dest_file = manager.paths.mods_dir / Path(*parts[1:])
dest_file.parent.mkdir(parents=True, exist_ok=True)
if not name.endswith("/"):
with zf.open(name) as source, open(dest_file, "wb") as target:
shutil.copyfileobj(source, target)
if mod_id not in extracted_mods:
extracted_mods.append(mod_id)
# Reload library to discover the new mods
manager.reload_library()
# Ensure profile name is unique
final_name = new_profile_name
n = 2
while final_name in manager.profiles.data:
final_name = f"{new_profile_name} ({n})"
n += 1
manager.profiles.data[final_name] = profile
manager.profiles.switch(final_name)
manager.profiles.save()
return extracted_mods
+213
View File
@@ -0,0 +1,213 @@
"""nxm:// handling and the Nexus Mods API client."""
from __future__ import annotations
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .. import __version__
from .util import atomic_write_json, read_json
GAME_DOMAIN = "monsterhunterwilds"
API = "https://api.nexusmods.com"
_SECRET_ATTRS = {"application": "mhwilds-mod-manager", "kind": "nexus-api-key"}
class NexusError(Exception):
pass
@dataclass
class NxmLink:
game: str
mod_id: int
file_id: int
key: str = ""
expires: str = ""
@property
def is_premium_flow(self) -> bool:
return not self.key
def parse_nxm(url: str) -> NxmLink:
"""nxm://monsterhunterwilds/mods/<mod>/files/<file>?key=…&expires=… (key/expires pass through)."""
u = urllib.parse.urlparse(url.strip())
if u.scheme != "nxm":
raise NexusError(f"not an nxm:// link: {url}")
m = re.fullmatch(r"/mods/(\d+)/files/(\d+)", u.path)
if not u.netloc or not m:
raise NexusError(f"malformed nxm link: {url}")
q = urllib.parse.parse_qs(u.query)
return NxmLink(u.netloc.lower(), int(m.group(1)), int(m.group(2)),
q.get("key", [""])[0], q.get("expires", [""])[0])
# -- API key storage -----------------------------------------------------------------
def _secret_mod():
try:
import gi
gi.require_version("Secret", "1")
from gi.repository import Secret
return Secret
except (ImportError, ValueError):
return None
class KeyStore:
"""libsecret when importable, else a 0600 JSON file."""
def __init__(self, fallback_file: Path):
self.file = fallback_file
def _schema(self, Secret):
return Secret.Schema.new("ca.schantli.MHWildsModManager", Secret.SchemaFlags.NONE,
{"application": Secret.SchemaAttributeType.STRING,
"kind": Secret.SchemaAttributeType.STRING})
def get(self) -> str:
Secret = _secret_mod()
if Secret:
try:
v = Secret.password_lookup_sync(self._schema(Secret), _SECRET_ATTRS, None)
if v:
return v
except Exception:
pass
return str(read_json(self.file, {}).get("nexus_api_key", ""))
def set(self, key: str) -> None:
Secret = _secret_mod()
if Secret:
try:
if Secret.password_store_sync(self._schema(Secret), _SECRET_ATTRS, Secret.COLLECTION_DEFAULT,
"Nexus Mods API key", key, None):
if self.file.exists():
self.file.unlink()
return
except Exception:
pass
atomic_write_json(self.file, {"nexus_api_key": key}, mode=0o600)
# -- client ----------------------------------------------------------------------------
@dataclass
class RateLimits:
daily_remaining: str = ""
daily_limit: str = ""
hourly_remaining: str = ""
hourly_limit: str = ""
def summary(self) -> str:
if not (self.daily_remaining or self.hourly_remaining):
return ""
return f"API quota: {self.hourly_remaining}/{self.hourly_limit} hourly, {self.daily_remaining}/{self.daily_limit} daily"
Opener = Callable[[urllib.request.Request], "object"]
class NexusClient:
def __init__(self, api_key: str, opener: Opener | None = None):
self.api_key = api_key
self._open = opener or (lambda req: urllib.request.urlopen(req, timeout=60))
self.limits = RateLimits()
def _headers(self) -> dict[str, str]:
return {"apikey": self.api_key, "Application-Name": "MHWildsModManager",
"Application-Version": __version__, "User-Agent": f"mhwilds-mod-manager/{__version__}",
"Accept": "application/json"}
def _get_json(self, path: str, params: dict[str, str] | None = None):
if not self.api_key:
raise NexusError("No Nexus API key set (Preferences → Nexus Mods).")
url = f"{API}{path}" + (f"?{urllib.parse.urlencode(params)}" if params else "")
try:
resp = self._open(urllib.request.Request(url, headers=self._headers()))
self._note_limits(resp.headers)
return json.loads(resp.read())
except urllib.error.HTTPError as e:
self._note_limits(e.headers)
msg = {401: "Invalid Nexus API key.", 403: "Nexus refused the download (premium required or link expired).",
404: "Not found on Nexus.", 429: "Nexus rate limit reached; try again later."}.get(e.code, f"Nexus HTTP {e.code}")
raise NexusError(msg) from e
except (urllib.error.URLError, OSError) as e:
raise NexusError(f"Network error: {e}") from e
def _note_limits(self, headers) -> None:
if headers is None:
return
g = headers.get
self.limits = RateLimits(g("x-rl-daily-remaining", ""), g("x-rl-daily-limit", ""),
g("x-rl-hourly-remaining", ""), g("x-rl-hourly-limit", ""))
def download_links(self, link: NxmLink) -> list[dict]:
params = {}
if link.key: # what lets non-premium accounts download
params = {"key": link.key, "expires": link.expires}
return self._get_json(f"/v1/games/{link.game}/mods/{link.mod_id}/files/{link.file_id}/download_link.json",
params)
def mod_info(self, link: NxmLink) -> dict:
return self._get_json(f"/v1/games/{link.game}/mods/{link.mod_id}.json")
def prefill(link: NxmLink, info: dict) -> dict:
return {"name": info.get("name", ""), "version": info.get("version", ""), "author": info.get("author", ""),
"summary": info.get("summary", ""), "picture_url": info.get("picture_url", ""),
"mod_id": link.mod_id, "file_id": link.file_id,
"url": f"https://www.nexusmods.com/{link.game}/mods/{link.mod_id}"}
def download(url: str, dest_dir: Path, progress: Callable[[int, int], None] | None = None,
opener: Opener | None = None) -> Path:
"""Stream a CDN URL to dest_dir; the file name comes from the URL path."""
url = urllib.parse.quote(url, safe=":/&?=%")
u = urllib.parse.urlparse(url)
if u.scheme != "https":
raise NexusError("refusing non-HTTPS download")
name = os.path.basename(urllib.parse.unquote(u.path)) or "download.bin"
name = re.sub(r"[^\w.\- ]", "_", name).lstrip(".") or "download.bin"
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / name
part = dest.with_name(dest.name + ".part")
req = urllib.request.Request(url, headers={"User-Agent": f"mhwilds-mod-manager/{__version__}"})
try:
resp = (opener or (lambda r: urllib.request.urlopen(r, timeout=60)))(req)
total = int(resp.headers.get("Content-Length") or 0)
done = 0
with open(part, "wb") as fh:
while chunk := resp.read(1 << 20):
fh.write(chunk)
done += len(chunk)
if progress:
progress(done, total)
os.replace(part, dest)
except (urllib.error.URLError, OSError) as e:
part.unlink(missing_ok=True)
raise NexusError(f"Download failed: {e}") from e
return dest
def fetch_thumbnail(url: str, dest: Path, opener: Opener | None = None, limit: int = 8 * 1024 * 1024) -> bool:
"""Best-effort mod picture download; never raises."""
if not url.startswith("https://"):
return False
try:
req = urllib.request.Request(url, headers={"User-Agent": f"mhwilds-mod-manager/{__version__}"})
resp = (opener or (lambda r: urllib.request.urlopen(r, timeout=30)))(req)
data = resp.read(limit + 1)
if len(data) > limit or not data:
return False
dest.write_bytes(data)
return True
except (urllib.error.URLError, OSError):
return False
+217
View File
@@ -0,0 +1,217 @@
"""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>\.sub_000\.pak)?\.patch_(?P<n>\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("<I", data[: n * 4]):
k = (k * c1) & 0xFFFFFFFF
k = ((k << 15) | (k >> 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("<IIQQQQQ") # hash_lower, hash_upper, offset, csize, usize, attrs, checksum (48 B)
_pak_cache: dict[tuple[str, int, int], frozenset] = {}
def pak_hashes(path: Path) -> 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("<I", head, 8)[0]
if total > 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()
@@ -0,0 +1,65 @@
"""XDG directories and library layout."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
APP_DIRNAME = "mhwilds-mod-manager"
@dataclass(frozen=True)
class Paths:
data_dir: Path
config_dir: Path
@classmethod
def default(cls) -> "Paths":
home = Path.home()
data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
conf = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config")
return cls(data / APP_DIRNAME, conf / APP_DIRNAME)
@property
def mods_dir(self) -> Path:
return self.data_dir / "mods"
@property
def downloads_dir(self) -> Path:
return self.data_dir / "downloads"
@property
def state_dir(self) -> Path:
return self.data_dir / "state"
@property
def backup_dir(self) -> Path:
return self.state_dir / "backup"
@property
def trash_dir(self) -> Path:
return self.state_dir / "trash"
@property
def manifest_file(self) -> Path:
return self.state_dir / "deployed.json"
@property
def baseline_file(self) -> Path:
return self.state_dir / "pak-baseline.json"
@property
def settings_file(self) -> Path:
return self.config_dir / "settings.json"
@property
def profiles_file(self) -> Path:
return self.config_dir / "profiles.json"
@property
def secrets_file(self) -> Path:
return self.config_dir / "secrets.json"
def ensure(self) -> None:
for d in (self.mods_dir, self.downloads_dir, self.state_dir, self.backup_dir, self.config_dir):
d.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,103 @@
"""Named profiles: an enabled-set plus a load order (later = higher priority)."""
from __future__ import annotations
from pathlib import Path
from .util import atomic_write_json, read_json
DEFAULT = "Default"
class ProfileError(Exception):
pass
class Profiles:
def __init__(self, path: Path):
self.path = path
self.active = DEFAULT
self.data: dict[str, dict] = {DEFAULT: {"enabled": [], "order": []}}
raw = read_json(path, None)
if isinstance(raw, dict) and isinstance(raw.get("profiles"), dict) and raw["profiles"]:
self.data = {n: {"enabled": list(p.get("enabled", [])), "order": list(p.get("order", []))}
for n, p in raw["profiles"].items() if isinstance(p, dict)} or self.data
self.active = raw.get("active") if raw.get("active") in self.data else next(iter(self.data))
def save(self) -> None:
atomic_write_json(self.path, {"active": self.active, "profiles": self.data})
@property
def names(self) -> list[str]:
return list(self.data)
@property
def current(self) -> dict:
return self.data[self.active]
# -- reconciliation with the library ------------------------------------
def reconcile(self, known_ids: list[str]) -> None:
known = set(known_ids)
for p in self.data.values():
p["order"] = [i for i in dict.fromkeys(p["order"]) if i in known]
p["order"] += [i for i in known_ids if i not in p["order"]]
p["enabled"] = [i for i in dict.fromkeys(p["enabled"]) if i in known]
# -- queries ------------------------------------------------------------
def is_enabled(self, mod_id: str) -> bool:
return mod_id in self.current["enabled"]
def order(self) -> list[str]:
return list(self.current["order"])
def enabled_in_order(self) -> list[str]:
en = set(self.current["enabled"])
return [i for i in self.current["order"] if i in en]
# -- edits --------------------------------------------------------------
def set_enabled(self, mod_id: str, on: bool) -> None:
en = self.current["enabled"]
if on and mod_id not in en:
en.append(mod_id)
if mod_id not in self.current["order"]:
self.current["order"].append(mod_id)
elif not on and mod_id in en:
en.remove(mod_id)
def move(self, mod_id: str, new_index: int) -> None:
order = self.current["order"]
if mod_id not in order:
return
order.remove(mod_id)
order.insert(max(0, min(new_index, len(order))), mod_id)
def remove_mod(self, mod_id: str) -> None:
for p in self.data.values():
p["order"] = [i for i in p["order"] if i != mod_id]
p["enabled"] = [i for i in p["enabled"] if i != mod_id]
def create(self, name: str, copy_from: str | None = None) -> None:
name = name.strip()
if not name or name in self.data:
raise ProfileError("A profile with that name already exists" if name else "Name required")
src = self.data.get(copy_from or "", {"enabled": [], "order": list(self.current["order"])})
self.data[name] = {"enabled": list(src["enabled"]), "order": list(src["order"])}
def rename(self, old: str, new: str) -> None:
new = new.strip()
if old not in self.data or not new or new in self.data:
raise ProfileError("Invalid profile name")
self.data = {(new if k == old else k): v for k, v in self.data.items()}
if self.active == old:
self.active = new
def delete(self, name: str) -> None:
if len(self.data) <= 1:
raise ProfileError("Cannot delete the last profile")
self.data.pop(name, None)
if self.active == name:
self.active = next(iter(self.data))
def switch(self, name: str) -> None:
if name not in self.data:
raise ProfileError(f"No such profile: {name}")
self.active = name
+234
View File
@@ -0,0 +1,234 @@
"""Steam discovery: VDF parsing, app lookup and launch-option handling."""
from __future__ import annotations
import os
import re
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .util import atomic_write_text
APP_ID = "2246340" # Monster Hunter Wilds
REQUIRED_LAUNCH_OPTIONS = 'WINEDLLOVERRIDES="dinput8.dll=n,b" %command%'
_OVERRIDE = 'dinput8.dll=n,b'
_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*|\s+')
_ESC = {"n": "\n", "t": "\t", "\\": "\\", '"': '"'}
class VDFError(ValueError):
pass
def _unescape(s: str) -> str:
return re.sub(r"\\(.)", lambda m: _ESC.get(m.group(1), m.group(0)), s)
def _escape(s: str) -> str:
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t")
def parse_vdf(text: str) -> dict[str, Any]:
"""Parse Valve text KeyValues into nested dicts (duplicate keys: last wins)."""
root: dict[str, Any] = {}
stack = [root]
key: str | None = None
pos = 0
while pos < len(text):
m = _TOKEN.match(text, pos)
if not m:
raise VDFError(f"unexpected character at offset {pos}")
pos = m.end()
if m.group(1) is not None:
s = _unescape(m.group(1))
if key is None:
key = s
else:
stack[-1][key] = s
key = None
elif m.group(2) == "{":
if key is None:
raise VDFError("'{' without a key")
child: dict[str, Any] = {}
stack[-1][key] = child
stack.append(child)
key = None
elif m.group(2) == "}":
if len(stack) == 1:
raise VDFError("unbalanced '}'")
stack.pop()
if len(stack) != 1:
raise VDFError("unterminated block")
return root
def dump_vdf(data: dict[str, Any], depth: int = 0) -> str:
out = []
ind = "\t" * depth
for k, v in data.items():
if isinstance(v, dict):
out.append(f'{ind}"{_escape(k)}"\n{ind}{{\n{dump_vdf(v, depth + 1)}{ind}}}\n')
else:
out.append(f'{ind}"{_escape(k)}"\t\t"{_escape(str(v))}"\n')
return "".join(out)
def vdf_get(data: dict, *path: str, default: Any = None) -> Any:
"""Case-insensitive nested lookup (Steam is inconsistent about key case)."""
cur: Any = data
for part in path:
if not isinstance(cur, dict):
return default
hit = next((k for k in cur if k.lower() == part.lower()), None)
if hit is None:
return default
cur = cur[hit]
return cur
def read_vdf(path: Path) -> dict[str, Any]:
return parse_vdf(Path(path).read_text(encoding="utf-8", errors="replace"))
@dataclass
class GameInstall:
app_id: str
name: str
steam_root: Path
library: Path
game_dir: Path
build_id: str = ""
def steam_roots() -> list[Path]:
home = Path.home()
cands = [home / ".steam/steam", home / ".local/share/Steam",
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam"]
seen, out = set(), []
for c in cands:
if (c / "steamapps").is_dir():
real = c.resolve()
if real not in seen:
seen.add(real)
out.append(real)
return out
def library_folders(steam_root: Path) -> list[Path]:
libs = [Path(steam_root)]
vdf = Path(steam_root) / "steamapps/libraryfolders.vdf"
if vdf.is_file():
try:
data = vdf_get(read_vdf(vdf), "libraryfolders", default={})
except VDFError:
data = {}
for entry in data.values() if isinstance(data, dict) else []:
p = entry.get("path") if isinstance(entry, dict) else None
if p and Path(p) not in libs:
libs.append(Path(p))
return libs
def find_app(app_id: str = APP_ID, roots: list[Path] | None = None) -> GameInstall | None:
for root in roots if roots is not None else steam_roots():
for lib in library_folders(root):
acf = lib / "steamapps" / f"appmanifest_{app_id}.acf"
if not acf.is_file():
continue
try:
state = vdf_get(read_vdf(acf), "AppState", default={})
except VDFError:
continue
installdir = state.get("installdir") if isinstance(state, dict) else None
if not installdir:
continue
game_dir = lib / "steamapps/common" / installdir
if game_dir.is_dir():
return GameInstall(app_id, state.get("name", installdir), root, lib, game_dir,
state.get("buildid", ""))
return None
def userdata_ids(steam_root: Path) -> list[str]:
ud = Path(steam_root) / "userdata"
if not ud.is_dir():
return []
return sorted(p.name for p in ud.iterdir() if p.name.isdigit() and (p / "config").is_dir())
def localconfig_path(steam_root: Path, user_id: str = "") -> Path | None:
ids = userdata_ids(steam_root)
uid = user_id if user_id in ids else (ids[0] if ids else None)
return Path(steam_root) / "userdata" / uid / "config/localconfig.vdf" if uid else None
_APP_PATH = ("UserLocalConfigStore", "Software", "Valve", "Steam", "apps")
def read_launch_options(localconfig: Path, app_id: str = APP_ID) -> str | None:
try:
data = read_vdf(localconfig)
except (OSError, VDFError):
return None
return vdf_get(data, *_APP_PATH, app_id, "LaunchOptions", default="")
def steam_running() -> bool:
try:
for p in Path("/proc").iterdir():
if p.name.isdigit():
try:
if (p / "comm").read_text().strip() == "steam":
return True
except OSError:
continue
except OSError:
pass
return False
def launch_options_ok(opts: str | None) -> bool:
return bool(opts) and re.search(r"WINEDLLOVERRIDES=[\"']?[^\s\"']*dinput8(?:\.dll)?=n", opts) is not None
def suggest_launch_options(existing: str | None) -> str:
"""Return a launch-option string that includes the dinput8 override, keeping user args."""
existing = (existing or "").strip()
if not existing:
return REQUIRED_LAUNCH_OPTIONS
if launch_options_ok(existing):
return existing
m = re.search(r"WINEDLLOVERRIDES=([\"']?)([^\"'\s]*)\1", existing)
if m:
q = m.group(1)
merged = f"WINEDLLOVERRIDES={q}{m.group(2)};{_OVERRIDE}{q}"
return existing[:m.start()] + merged + existing[m.end():]
if "%command%" in existing:
return f'WINEDLLOVERRIDES="{_OVERRIDE}" ' + existing
return f'WINEDLLOVERRIDES="{_OVERRIDE}" %command% {existing}'
class SteamRunningError(RuntimeError):
pass
def write_launch_options(localconfig: Path, value: str, app_id: str = APP_ID) -> Path:
"""Opt-in write. Refuses while Steam runs (it would overwrite the file on exit). Returns backup path."""
if steam_running():
raise SteamRunningError("Steam is running; it would overwrite localconfig.vdf on exit. Close Steam first.")
data = read_vdf(localconfig)
node = data
for part in (*_APP_PATH, app_id):
hit = next((k for k in node if k.lower() == part.lower()), part)
nxt = node.setdefault(hit, {})
if not isinstance(nxt, dict):
raise VDFError(f"unexpected value at {part}")
node = nxt
node["LaunchOptions"] = value
backup = localconfig.with_name(f"{localconfig.name}.mhwmm-bak-{int(time.time())}")
shutil.copy2(localconfig, backup)
atomic_write_text(localconfig, dump_vdf(data))
return backup
@@ -0,0 +1,69 @@
"""Small filesystem helpers shared by core modules."""
from __future__ import annotations
import json
import os
import re
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any
def atomic_write_bytes(path: Path, data: bytes, mode: int | None = None) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
if mode is not None:
os.chmod(tmp, mode)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def atomic_write_text(path: Path, text: str, mode: int | None = None) -> None:
atomic_write_bytes(path, text.encode("utf-8"), mode)
def atomic_write_json(path: Path, obj: Any, mode: int | None = None) -> None:
atomic_write_text(path, json.dumps(obj, indent=2, sort_keys=True) + "\n", mode)
def read_json(path: Path, default: Any) -> Any:
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
return default
def slugify(text: str, fallback: str = "mod") -> str:
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
return slug[:48] or fallback
def safe_relpath(rel: str) -> str | None:
"""Normalise an archive/dest path. Returns None if it is absolute or escapes."""
rel = rel.replace("\\", "/")
if not rel or "\0" in rel or rel.startswith("/") or re.match(r"^[A-Za-z]:", rel):
return None
parts = [p for p in PurePosixPath(rel).parts if p not in ("", ".")]
if not parts or any(p == ".." for p in parts):
return None
return "/".join(parts)
def is_within(child: Path, parent: Path) -> bool:
try:
Path(os.path.realpath(child)).relative_to(os.path.realpath(parent))
return True
except ValueError:
return False