modde_games/bethesda/
scanner.rs1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::traits::{DiscoveredFile, DiscoveredMod, ModScanner, ModSource, ScanContext};
6use super::plugins_txt;
7
8pub 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
35const PLUGIN_EXTENSIONS: &[&str] = &["esp", "esm", "esl"];
37
38const 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 let known_plugins = plugins_txt::read_plugins_txt(self.steam_app_id, self.game_folder_name)
56 .unwrap_or_default();
57
58 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 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 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 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, });
148 }
149
150 Ok(mods)
151 }
152
153 fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
154 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}