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