282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""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)
|