Skip to main content

modde_games/bethesda/
scanner.rs

1//! Filesystem scanners that discover installed Bethesda mods, pairing plugin
2//! files with companion archives. [`BethesdaScanner`] uses `plugins.txt` for
3//! authoritative load order, while [`BethesdaArchiveScanner`] handles titles
4//! like Fallout 76 that load loose `.ba2` archives without a local load order.
5
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use super::plugins_txt;
11use crate::scanner_patterns::SingleFileModRule;
12use crate::traits::{DiscoveredFile, DiscoveredMod, ModScanner, ModSource, ScanContext};
13
14/// Data-driven Bethesda mod scanner.
15pub struct BethesdaScanner {
16    pub game_id: &'static str,
17    pub steam_app_id: u32,
18    pub game_folder_name: &'static str,
19}
20
21pub static SKYRIM_SCANNER: BethesdaScanner = BethesdaScanner {
22    game_id: "skyrim-se",
23    steam_app_id: plugins_txt::SKYRIM_SE_APP_ID,
24    game_folder_name: "Skyrim Special Edition",
25};
26
27pub static FALLOUT4_SCANNER: BethesdaScanner = BethesdaScanner {
28    game_id: "fallout4",
29    steam_app_id: plugins_txt::FALLOUT4_APP_ID,
30    game_folder_name: "Fallout4",
31};
32
33pub static FALLOUT76_SCANNER: BethesdaArchiveScanner = BethesdaArchiveScanner {
34    game_id: "fallout76",
35};
36
37pub static STARFIELD_SCANNER: BethesdaScanner = BethesdaScanner {
38    game_id: "starfield",
39    steam_app_id: plugins_txt::STARFIELD_APP_ID,
40    game_folder_name: "Starfield",
41};
42
43const BETHESDA_SCAN_DIRS: &[&str] = &["Data"];
44
45/// Plugin file extensions that Bethesda games use.
46const PLUGIN_EXTENSIONS: &[&str] = &["esp", "esm", "esl"];
47
48/// Archive extensions that may accompany a plugin.
49const ARCHIVE_EXTENSIONS: &[&str] = &["bsa", "ba2"];
50
51/// Scanner for Bethesda titles that load loose archive mods without a local
52/// plugins.txt-style load order. Fallout 76 mods are typically `.ba2` files
53/// dropped in `Data/` and enabled from `Fallout76Custom.ini`.
54pub struct BethesdaArchiveScanner {
55    pub game_id: &'static str,
56}
57
58impl ModScanner for BethesdaArchiveScanner {
59    fn scan_directories(&self) -> &[&str] {
60        BETHESDA_SCAN_DIRS
61    }
62
63    fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> Result<Vec<DiscoveredMod>> {
64        let mut mods = Vec::new();
65        SingleFileModRule {
66            rel_dir: "Data",
67            extension: "ba2",
68            ignored_prefixes: &["SeventySix"],
69            mod_id_prefix: "archive",
70            source_location: "Data",
71            confidence: 0.8,
72        }
73        .scan(ctx.install_dir, &mut mods)?;
74        Ok(mods)
75    }
76
77    fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
78        let stem = mod_id.strip_prefix("archive/")?.to_lowercase();
79        Some(modde_core::scanner::ModFootprint::File(format!(
80            "data/{stem}.ba2"
81        )))
82    }
83}
84
85impl ModScanner for BethesdaScanner {
86    fn scan_directories(&self) -> &[&str] {
87        BETHESDA_SCAN_DIRS
88    }
89
90    fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> Result<Vec<DiscoveredMod>> {
91        let data_dir = ctx.install_dir.join("Data");
92        if !data_dir.is_dir() {
93            return Ok(Vec::new());
94        }
95
96        let mut mods = Vec::new();
97
98        // Try to read plugins.txt for load order and enabled status.
99        let known_plugins = plugins_txt::read_plugins_txt(self.steam_app_id, self.game_folder_name)
100            .unwrap_or_default();
101
102        // Scan plugins listed in plugins.txt first (these are authoritative).
103        let mut seen_stems: std::collections::HashSet<String> = std::collections::HashSet::new();
104
105        for plugin_entry in &known_plugins {
106            let plugin_path = data_dir.join(&plugin_entry.name);
107            if !plugin_path.exists() {
108                continue;
109            }
110
111            let stem = std::path::Path::new(&plugin_entry.name)
112                .file_stem()
113                .and_then(|s| s.to_str())
114                .unwrap_or(&plugin_entry.name)
115                .to_string();
116
117            seen_stems.insert(stem.to_lowercase());
118
119            let mut files = vec![make_data_file(ctx.install_dir, &plugin_path)];
120
121            // Look for companion archives (same stem).
122            for ext in ARCHIVE_EXTENSIONS {
123                let archive_path = data_dir.join(format!("{stem}.{ext}"));
124                if archive_path.exists() {
125                    files.push(make_data_file(ctx.install_dir, &archive_path));
126                }
127                // Bethesda also uses " - Textures" suffix for texture BSAs.
128                let tex_path = data_dir.join(format!("{stem} - Textures.{ext}"));
129                if tex_path.exists() {
130                    files.push(make_data_file(ctx.install_dir, &tex_path));
131                }
132            }
133
134            mods.push(DiscoveredMod {
135                mod_id: format!("plugin/{}", plugin_entry.name),
136                display_name: plugin_entry.name.clone(),
137                version: None,
138                files,
139                source: ModSource::Filesystem {
140                    location: "Data".into(),
141                },
142                confidence: 0.95,
143            });
144        }
145
146        // Also scan for plugins NOT in plugins.txt (disabled or unmanaged).
147        for entry in std::fs::read_dir(&data_dir)
148            .with_context(|| format!("failed to read directory: {}", data_dir.display()))?
149            .flatten()
150        {
151            let path = entry.path();
152            if path.is_dir() {
153                continue;
154            }
155
156            let ext = path
157                .extension()
158                .and_then(|e| e.to_str())
159                .unwrap_or("")
160                .to_lowercase();
161
162            if !PLUGIN_EXTENSIONS.contains(&ext.as_str()) {
163                continue;
164            }
165
166            let stem = path
167                .file_stem()
168                .and_then(|s| s.to_str())
169                .unwrap_or("")
170                .to_string();
171
172            if seen_stems.contains(&stem.to_lowercase()) {
173                continue;
174            }
175
176            let mut files = vec![make_data_file(ctx.install_dir, &path)];
177
178            for archive_ext in ARCHIVE_EXTENSIONS {
179                let archive_path = data_dir.join(format!("{stem}.{archive_ext}"));
180                if archive_path.exists() {
181                    files.push(make_data_file(ctx.install_dir, &archive_path));
182                }
183            }
184
185            mods.push(DiscoveredMod {
186                mod_id: format!("plugin/{stem}.{ext}"),
187                display_name: format!("{stem}.{ext}"),
188                version: None,
189                files,
190                source: ModSource::Filesystem {
191                    location: "Data".into(),
192                },
193                confidence: 0.8, // Lower confidence since not in plugins.txt
194            });
195        }
196
197        Ok(mods)
198    }
199
200    fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
201        // Inverse of the `plugin/<filename>` scheme produced by `scan_filesystem`.
202        // Footprint is Data-relative because MO2 Bethesda mod folders mirror
203        // `Data/` (not the game install root), so the manifest paths we compare
204        // against are Data-relative after `strip_mo2_prefix` in
205        // `detect_stale_duplicates`.
206        let filename = mod_id.strip_prefix("plugin/")?.to_lowercase();
207        Some(modde_core::scanner::ModFootprint::File(filename))
208    }
209}
210
211fn make_data_file(install_root: &Path, file_path: &Path) -> DiscoveredFile {
212    let rel = file_path
213        .strip_prefix(install_root)
214        .unwrap_or(file_path)
215        .to_string_lossy()
216        .replace('\\', "/");
217    let size = file_path.metadata().map_or(0, |m| m.len());
218    DiscoveredFile {
219        rel_path: rel,
220        size,
221    }
222}