modde_games/traits.rs
1//! Core game-plugin abstractions: content classification, save tracking, and
2//! the [`ModScanner`] / [`GamePlugin`] interfaces every supported game implements.
3
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use anyhow::Result;
10use smallvec::SmallVec;
11
12/// Content types a game can have.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum ContentCategory {
15 Plugin, // .esp, .esm, .esl
16 Texture, // .dds, .png, .tga
17 Mesh, // .nif
18 Sound, // .wav, .xwm, .fuz
19 Script, // .pex, .psc, .reds, .lua
20 Interface, // .swf
21 Archive, // .bsa, .ba2, .archive
22 Config, // .ini, .json, .yaml, .xml
23 Binary, // .dll
24 Other,
25}
26
27impl ContentCategory {
28 /// Human-readable label for display.
29 #[must_use]
30 pub fn label(self) -> &'static str {
31 match self {
32 ContentCategory::Plugin => "plugins",
33 ContentCategory::Texture => "textures",
34 ContentCategory::Mesh => "meshes",
35 ContentCategory::Sound => "sounds",
36 ContentCategory::Script => "scripts",
37 ContentCategory::Interface => "interfaces",
38 ContentCategory::Archive => "archives",
39 ContentCategory::Config => "configs",
40 ContentCategory::Binary => "binaries",
41 ContentCategory::Other => "other",
42 }
43 }
44
45 /// Display order (lower = shown first).
46 #[must_use]
47 pub fn order(self) -> u8 {
48 match self {
49 ContentCategory::Plugin => 0,
50 ContentCategory::Script => 1,
51 ContentCategory::Binary => 2,
52 ContentCategory::Texture => 3,
53 ContentCategory::Mesh => 4,
54 ContentCategory::Sound => 5,
55 ContentCategory::Interface => 6,
56 ContentCategory::Archive => 7,
57 ContentCategory::Config => 8,
58 ContentCategory::Other => 9,
59 }
60 }
61}
62
63/// Summary of content types found in a mod.
64#[derive(Debug, Clone, Default)]
65pub struct ContentSummary {
66 pub counts: HashMap<ContentCategory, usize>,
67}
68
69impl ContentSummary {
70 /// Return counts sorted by display order, excluding zero counts.
71 #[must_use]
72 pub fn sorted_counts(&self) -> Vec<(ContentCategory, usize)> {
73 let mut entries: Vec<_> = self
74 .counts
75 .iter()
76 .filter(|(_, count)| **count > 0)
77 .map(|(cat, count)| (*cat, *count))
78 .collect();
79 entries.sort_by_key(|(cat, _)| cat.order());
80 entries
81 }
82
83 /// Format as a human-readable string like "5 textures, 2 meshes, 1 plugin".
84 #[must_use]
85 pub fn display_string(&self) -> String {
86 let parts: Vec<String> = self
87 .sorted_counts()
88 .iter()
89 .map(|(cat, count)| format!("{} {}", count, cat.label()))
90 .collect();
91 if parts.is_empty() {
92 "No files".to_string()
93 } else {
94 parts.join(", ")
95 }
96 }
97}
98
99/// Whether a mod is safe to add/remove without breaking existing saves.
100///
101/// Mods that alter game logic (scripts, gameplay tweaks, new items/quests)
102/// will corrupt or break saves if removed mid-playthrough. Cosmetic mods
103/// (textures, meshes, UI themes) can be freely toggled.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub enum ModSafety {
106 /// Alters game logic — removing this mod will break saves that depend on it.
107 /// Examples: `REDscript` mods, CET lua scripts, .tweak overrides, ESP/ESM plugins.
108 SaveBreaking,
109 /// Cosmetic only — safe to add/remove without affecting saves.
110 /// Examples: texture replacers, mesh swaps, UI reskins.
111 SaveSafe,
112 /// Cannot determine automatically (e.g. mod not installed locally, or mixed content).
113 /// Treated as `SaveBreaking` for safety when computing fingerprints.
114 Unknown,
115}
116
117impl ModSafety {
118 /// Returns `true` if this mod should be included in save fingerprints.
119 #[must_use]
120 pub fn affects_saves(self) -> bool {
121 matches!(self, ModSafety::SaveBreaking | ModSafety::Unknown)
122 }
123}
124
125/// Classes of filesystem roots a [`GamePlugin`] can advertise as
126/// deployment destinations *outside* the game install dir.
127///
128/// New variants extend the installer's routing without requiring it to
129/// know per-engine path conventions.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum DeployTargetKind {
132 /// Per-user config files the engine reads at startup. Examples:
133 /// UE4/UE5 `<Project>/Saved/Config/Windows/Engine.ini`, Bethesda
134 /// `Documents/My Games/<Game>/*.ini`, Larian's
135 /// `AppData/Local/<Game>/Player.ini`. Files in this target are
136 /// usually whole-file replacements keyed by filename.
137 UserConfig,
138 /// Per-user save directory. Reserved for save-replacing mods (rare
139 /// but real, e.g. shipped 100% completion saves).
140 UserSaves,
141 /// Anything the plugin wants to expose that doesn't fit the above.
142 /// The installer just routes files to the resolved path; semantics
143 /// are entirely the plugin's.
144 Custom,
145}
146
147/// A named alternate deployment root advertised by a [`GamePlugin`].
148///
149/// The installer pipeline keys mods to a target by `id`; the plugin
150/// resolves `id` → real path at deploy time via
151/// [`GamePlugin::resolve_deploy_target`]. Resolution is deferred so
152/// plugins can incorporate runtime context (Wine prefix, Steam
153/// `compatdata`, XDG dirs) without baking a path into a static.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct DeployTarget {
156 pub id: &'static str,
157 pub label: &'static str,
158 pub kind: DeployTargetKind,
159}
160
161/// Trait implemented by each supported game.
162pub trait GamePlugin: Send + Sync {
163 /// Unique game identifier (e.g. "skyrim-se").
164 fn game_id(&self) -> &str;
165
166 /// Human-readable display name.
167 fn display_name(&self) -> &str;
168
169 /// Attempt to detect the game's install location.
170 /// Default: delegates to `detection::find_game_install(self.game_id())`.
171 fn detect_install(&self) -> Option<PathBuf> {
172 crate::detection::find_game_install(&modde_core::GameId::from(self.game_id()))
173 }
174
175 /// Return the mod directory relative to the install path.
176 fn mod_directory(&self, install: &Path) -> PathBuf;
177
178 /// Resolve the actual deployment root for enabled mods.
179 ///
180 /// Most supported games use a directory under the install root, so the
181 /// default delegates to [`GamePlugin::mod_directory`]. Games whose mod
182 /// loader reads from a user-data path (for example Proton `AppData`) can
183 /// override this without breaking existing install-relative callers.
184 fn mod_root(&self, install: &Path) -> Result<PathBuf> {
185 Ok(self.mod_directory(install))
186 }
187
188 /// Deploy staged mods into the game's mod directory.
189 /// Default: recursive symlink farm via `modde_core::fs::deploy_symlinks`.
190 fn deploy(&self, staging: &Path, target: &Path) -> Result<()> {
191 modde_core::fs::deploy_symlinks(staging, target)
192 }
193
194 /// Deploy staged mods using the game install root as context.
195 ///
196 /// The default resolves [`GamePlugin::mod_root`] and delegates to
197 /// [`GamePlugin::deploy`]. Games that stage multi-root overlays can
198 /// override this to deploy to several install-relative destinations.
199 fn deploy_to_install(&self, staging: &Path, install: &Path) -> Result<()> {
200 let target = self.mod_root(install)?;
201 self.deploy(staging, &target)
202 }
203
204 /// Run any post-deployment steps (e.g. `REDmod` deploy).
205 fn post_deploy(&self, _install: &Path) -> Result<()> {
206 Ok(())
207 }
208
209 /// Return the save directory for this game, if known.
210 fn save_directory(&self) -> Option<PathBuf> {
211 None
212 }
213
214 /// Alternate deployment roots this game exposes to the installer
215 /// (e.g. user-config dirs for INI tweak packs). Default: none, in
216 /// which case the installer only ever stages into the game install
217 /// dir. The order is significant: when the analyzer needs to pick
218 /// a default target for a given [`DeployTargetKind`] it takes the
219 /// first one of that kind.
220 fn deploy_targets(&self) -> &'static [DeployTarget] {
221 &[]
222 }
223
224 /// Resolve a [`DeployTarget::id`] this plugin advertises to a real
225 /// filesystem path, using the live `install` dir for any path that
226 /// must be derived from it (e.g. Steam `compatdata` adjacent to
227 /// `steamapps/common/<game>`). Returns `None` if the target id is
228 /// unknown to this plugin or the path cannot be resolved on this
229 /// system (e.g. the Wine prefix doesn't exist yet).
230 fn resolve_deploy_target(&self, _id: &str, _install: &Path) -> Option<PathBuf> {
231 None
232 }
233
234 /// Whether this game participates in modde's per-profile save layer.
235 ///
236 /// Disabled games still support normal profile/mod management, but modde
237 /// must not swap saves, compute save fingerprints, or expose save commands
238 /// for them.
239 fn supports_save_profiles(&self) -> bool {
240 false
241 }
242
243 /// Classify whether a mod is save-breaking based on its installed content.
244 ///
245 /// `mod_dir` is the path to the mod's staging directory. The game plugin
246 /// inspects the files within to determine if the mod alters game logic
247 /// (scripts, plugins, tweaks) or is purely cosmetic (textures, meshes).
248 ///
249 /// Default: `Unknown` (conservative — included in fingerprints).
250 fn classify_mod(&self, _mod_dir: &Path) -> ModSafety {
251 ModSafety::Unknown
252 }
253
254 /// Scan the game directory for proxy/hook DLLs that need Wine `n,b` overrides.
255 ///
256 /// Returns DLL base names (without extension) that should be added to
257 /// `WINEDLLOVERRIDES` as `name=n,b` so Wine loads the native version
258 /// instead of its built-in stub.
259 fn wine_dll_overrides(&self, _game_dir: &Path) -> SmallVec<[String; 4]> {
260 SmallVec::new()
261 }
262
263 /// Scan the staging directory for proxy DLLs that mods deploy.
264 /// This catches DLLs that may have been deleted by other tools (e.g. fgmod)
265 /// from the game directory but are still needed.
266 fn wine_dll_overrides_from_staging(&self, _staging: &Path) -> SmallVec<[String; 4]> {
267 SmallVec::new()
268 }
269
270 /// Return the directory containing the game executable, relative to the install root.
271 /// Used to locate proxy DLLs that need Wine overrides.
272 fn executable_dir(&self, install: &Path) -> PathBuf {
273 install.to_path_buf()
274 }
275
276 // ── DRY trait methods ─────────────────────────────────────────
277 fn ini_file_names(&self) -> &[&str] {
278 &[]
279 }
280 fn archive_extensions(&self) -> &[&str] {
281 &[]
282 }
283 fn has_plugin_system(&self) -> bool {
284 false
285 }
286 fn steam_app_id_u32(&self) -> Option<u32> {
287 None
288 }
289 fn plugins_txt_folder(&self) -> Option<&str> {
290 None
291 }
292 fn nexus_game_domain(&self) -> Option<&str> {
293 None
294 }
295
296 /// Numeric Nexus game ID. Required by the GraphQL v2 API for
297 /// browse/search queries (which take `gameId: Int`, not a domain
298 /// string). Games that only speak REST can leave this `None`.
299 fn nexus_game_id_u32(&self) -> Option<u32> {
300 None
301 }
302
303 // ── Install-method detection (V8 installer pipeline) ────────
304
305 /// Claim an extracted archive as a game-specific install method.
306 ///
307 /// Runs **before** the generic probes (FOMOD, BAIN, DLL overlay) in
308 /// [`analyze`](modde_core::installer::analyze()), so a game can authoritatively
309 /// identify layouts it knows about — e.g. Cyberpunk recognizing a
310 /// `REDmod` by `info.json` + `archives/` presence, or ENB for Bethesda.
311 ///
312 /// Return `None` to fall through to the generic probes.
313 fn analyze_mod_archive(
314 &self,
315 _extracted_dir: &Path,
316 ) -> Option<modde_core::installer::InstallMethod> {
317 None
318 }
319
320 /// Decide whether an extracted archive drops cleanly into the game's
321 /// mod dir without any staging (e.g. a Skyrim archive with a
322 /// top-level `Data/` directory, or a Cyberpunk archive with `r6/`).
323 ///
324 /// Called as the last fallback by
325 /// [`analyze`](modde_core::installer::analyze()) — if this returns `true` the
326 /// plan becomes `InstallMethod::BareExtract`, otherwise the analyzer
327 /// falls through to [`InstallMethod::Unknown`](modde_core::installer::InstallMethod::Unknown) and the caller dumps
328 /// a dossier for the skill path.
329 fn recognizes_bare_layout(&self, _extracted_dir: &Path) -> bool {
330 false
331 }
332
333 /// Classify a file extension into a content category.
334 fn classify_extension(&self, ext: &str) -> ContentCategory {
335 match ext {
336 "esp" | "esm" | "esl" => ContentCategory::Plugin,
337 "dds" | "png" | "tga" | "jpg" => ContentCategory::Texture,
338 "nif" => ContentCategory::Mesh,
339 "wav" | "xwm" | "fuz" | "mp3" | "ogg" => ContentCategory::Sound,
340 "pex" | "psc" | "reds" | "lua" => ContentCategory::Script,
341 "swf" => ContentCategory::Interface,
342 "bsa" | "ba2" | "archive" => ContentCategory::Archive,
343 "ini" | "json" | "yaml" | "xml" | "toml" => ContentCategory::Config,
344 "dll" | "so" => ContentCategory::Binary,
345 _ => ContentCategory::Other,
346 }
347 }
348
349 /// Scan a mod directory and return a content summary.
350 fn summarize_content(&self, mod_dir: &Path) -> ContentSummary {
351 let mut summary = ContentSummary::default();
352 let mut stack = vec![mod_dir.to_path_buf()];
353 while let Some(dir) = stack.pop() {
354 let entries = match std::fs::read_dir(&dir) {
355 Ok(e) => e,
356 Err(_) => continue,
357 };
358 for entry in entries.flatten() {
359 let path = entry.path();
360 if path.is_dir() {
361 stack.push(path);
362 continue;
363 }
364 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
365 let cat = self.classify_extension(&ext.to_lowercase());
366 *summary.counts.entry(cat).or_insert(0) += 1;
367 }
368 }
369 }
370 summary
371 }
372}
373
374/// A detected save file or directory within a game's save directory.
375#[derive(Debug, Clone)]
376pub struct DetectedSave {
377 /// Path relative to the game's save directory.
378 pub rel_path: PathBuf,
379 /// Category: "manual", "auto", "quick", "point-of-no-return", etc.
380 /// Uses `Cow<'static, str>` because categories are almost always
381 /// static string literals, avoiding heap allocation in the common case.
382 pub category: Cow<'static, str>,
383 /// Human-readable label (e.g. custom name from `NamedSaves`).
384 pub label: Option<String>,
385 /// Last modification time.
386 pub modified: SystemTime,
387}
388
389/// Configuration for extension-based mod classification.
390///
391/// Both Bethesda and Cyberpunk games classify mods by scanning file extensions
392/// (and optionally directory paths). This struct captures the game-specific
393/// lists so the shared walker can be reused via static dispatch.
394pub struct ModClassifyConfig {
395 /// File extensions that indicate save-breaking content (lowercase, no dot).
396 pub save_breaking_ext: &'static [&'static str],
397 /// File extensions that indicate cosmetic-only content (lowercase, no dot).
398 pub cosmetic_ext: &'static [&'static str],
399 /// Directory path fragments (relative, `/`-separated) that signal save-breaking content.
400 /// Checked via `contains()` on the normalized relative path. Empty slice to skip.
401 pub save_breaking_dirs: &'static [&'static str],
402}
403
404/// Classify a mod by walking its directory and checking file extensions / directory paths
405/// against the provided configuration. Returns early on the first save-breaking indicator.
406#[must_use]
407pub fn classify_mod_by_content(mod_dir: &std::path::Path, config: &ModClassifyConfig) -> ModSafety {
408 if !mod_dir.exists() {
409 return ModSafety::Unknown;
410 }
411
412 let mut has_any_file = false;
413 let mut has_cosmetic = false;
414
415 let mut stack = vec![mod_dir.to_path_buf()];
416 while let Some(dir) = stack.pop() {
417 let entries = match std::fs::read_dir(&dir) {
418 Ok(e) => e,
419 Err(_) => continue,
420 };
421
422 for entry in entries.flatten() {
423 let path = entry.path();
424
425 if path.is_dir() {
426 if !config.save_breaking_dirs.is_empty() {
427 let rel = path.strip_prefix(mod_dir).unwrap_or(&path);
428 let rel_normalized = rel.to_string_lossy().to_lowercase().replace('\\', "/");
429 for &pattern in config.save_breaking_dirs {
430 if rel_normalized.contains(pattern) {
431 return ModSafety::SaveBreaking;
432 }
433 }
434 }
435 stack.push(path);
436 continue;
437 }
438
439 has_any_file = true;
440
441 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
442 let ext_lower = ext.to_lowercase();
443 if config.save_breaking_ext.contains(&ext_lower.as_str()) {
444 return ModSafety::SaveBreaking;
445 }
446 if config.cosmetic_ext.contains(&ext_lower.as_str()) {
447 has_cosmetic = true;
448 }
449 }
450 }
451 }
452
453 if has_cosmetic && has_any_file {
454 ModSafety::SaveSafe
455 } else {
456 ModSafety::Unknown
457 }
458}
459
460/// Game-specific save detection and classification.
461///
462/// Implemented per-game alongside `GamePlugin`. The core `SaveManager` handles
463/// the git vault; this trait tells it *what* to look for and how to describe it.
464pub trait SaveTracker: Send + Sync {
465 /// Glob patterns matching save entries in the save directory.
466 /// Typically 1–3 patterns per game; `SmallVec<[_; 2]>` avoids heap allocation.
467 fn save_patterns(&self) -> SmallVec<[String; 2]>;
468
469 /// Scan the save directory and return all detected saves with classification.
470 fn detect_saves(&self, save_dir: &Path) -> Result<Vec<DetectedSave>>;
471
472 /// Patterns to exclude from auto-capture triggers (files that exist in
473 /// the save dir but aren't actual saves, e.g. global settings).
474 /// Typically 0–2 patterns; `SmallVec<[_; 2]>` avoids heap allocation.
475 fn exclude_patterns(&self) -> SmallVec<[String; 2]> {
476 SmallVec::new()
477 }
478
479 /// Generate a human-readable commit message for a capture.
480 fn describe_capture(&self, saves: &[DetectedSave]) -> String {
481 match saves.len() {
482 0 => "capture: no new saves".into(),
483 1 => {
484 let s = &saves[0];
485 let name = s
486 .label
487 .as_deref()
488 .unwrap_or_else(|| s.rel_path.to_str().unwrap_or("unknown"));
489 format!("capture: {} [{}]", name, s.category)
490 }
491 n => format!("capture: {n} saves"),
492 }
493 }
494}
495
496// ── Mod Scanner ─────────────────────────────────────────────────
497
498/// Input handed to a [`ModScanner`]: the game install directory to scan.
499pub struct ScanContext<'a> {
500 pub install_dir: &'a Path,
501}
502
503/// A single file belonging to a [`DiscoveredMod`], with its install-relative path and size.
504#[derive(Debug, Clone)]
505pub struct DiscoveredFile {
506 pub rel_path: String,
507 pub size: u64,
508}
509
510/// Where a [`DiscoveredMod`] was found: an on-disk location or inside an archive.
511#[derive(Debug, Clone)]
512pub enum ModSource {
513 Filesystem { location: String },
514 Archive { archive_name: String },
515}
516
517/// A mod found by a [`ModScanner`], with its identity, files, source, and a
518/// `confidence` score for how certain the scanner is about the detection.
519#[derive(Debug, Clone)]
520pub struct DiscoveredMod {
521 pub mod_id: String,
522 pub display_name: String,
523 pub version: Option<String>,
524 pub files: Vec<DiscoveredFile>,
525 pub source: ModSource,
526 pub confidence: f64,
527}
528
529/// Game-specific discovery of already-installed mods.
530///
531/// Each game implements this to recognise its own mod layout (directory
532/// conventions, loose files, archives) and report what it finds. This is the
533/// central scanning interface the installer and profile importer rely on.
534pub trait ModScanner: Send + Sync {
535 /// Install-relative directories this scanner inspects for mods.
536 fn scan_directories(&self) -> &[&str];
537 /// Scan the filesystem for installed mods and return what was discovered.
538 fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> anyhow::Result<Vec<DiscoveredMod>>;
539
540 /// Inverse of [`ModScanner::scan_filesystem`]'s `mod_id` scheme: given
541 /// a `mod_id` this scanner would produce, return the filesystem footprint
542 /// that mod owns (directory subtree or single file).
543 ///
544 /// Used by `modde_core::scanner::detect_stale_duplicates` to correlate
545 /// profile rows with a Wabbajack manifest's install directives. The
546 /// default impl returns `None`, which causes the dedup path to skip
547 /// the row. Game plugins that want their filesystem-scanner rows to
548 /// participate in dedup should override this.
549 fn mod_id_footprint(&self, _mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
550 None
551 }
552}
553
554#[must_use]
555pub fn walk_files_relative(base: &Path, dir: &Path) -> Vec<DiscoveredFile> {
556 let mut result = Vec::new();
557 if let Ok(entries) = std::fs::read_dir(dir) {
558 for entry in entries.flatten() {
559 let path = entry.path();
560 if path.is_dir() {
561 result.extend(walk_files_relative(base, &path));
562 } else if let Ok(meta) = path.metadata()
563 && let Ok(rel) = path.strip_prefix(base)
564 {
565 result.push(DiscoveredFile {
566 rel_path: rel.to_string_lossy().to_string(),
567 size: meta.len(),
568 });
569 }
570 }
571 }
572 result
573}
574
575#[must_use]
576pub fn slug(s: &str) -> String {
577 s.to_lowercase()
578 .chars()
579 .map(|c| if c.is_alphanumeric() { c } else { '-' })
580 .collect::<String>()
581 .trim_matches('-')
582 .to_string()
583}