Skip to main content

modde_games/bethesda/
scanner.rs

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