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

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