modde_games/bethesda/
scanner.rs1use 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
14pub 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
45const PLUGIN_EXTENSIONS: &[&str] = &["esp", "esm", "esl"];
47
48const ARCHIVE_EXTENSIONS: &[&str] = &["bsa", "ba2"];
50
51pub 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 let known_plugins = plugins_txt::read_plugins_txt(self.steam_app_id, self.game_folder_name)
100 .unwrap_or_default();
101
102 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 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 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 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, });
195 }
196
197 Ok(mods)
198 }
199
200 fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
201 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}