Skip to main content

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}