1use rayon::prelude::*;
2use std::collections::HashMap;
3use std::fs;
4use std::io;
5use std::sync::mpsc::Sender;
6use std::time::{Instant, SystemTime, UNIX_EPOCH};
7
8use supreme_fs::DiscoverMode;
9use supreme_fs::GameStorage;
10use supreme_parsing::{decode_bytes, extract_quoted_paths};
11
12#[derive(Clone, serde::Serialize, serde::Deserialize)]
13pub struct PathDiagnostics {
14 pub scan_timestamp: u64,
15 pub total_files_scanned: usize,
16 pub total_paths_found: usize,
17 pub summary: PathSummary,
18 pub files: Vec<FilePathReport>,
19}
20
21#[derive(Clone, serde::Serialize, serde::Deserialize)]
22pub struct PathSummary {
23 pub pass_count: usize,
24 pub warn_count: usize,
25 pub fixable_count: usize,
26 pub error_count: usize,
27}
28
29#[derive(Clone, serde::Serialize, serde::Deserialize)]
30pub struct FilePathReport {
31 pub file_path: String,
32 pub file_type: String, pub paths: Vec<PathValidation>,
34}
35
36#[derive(Clone, serde::Serialize, serde::Deserialize)]
37pub struct PathValidation {
38 pub line_number: usize,
39 pub original_path: String,
40 pub status: PathStatus,
41 pub resolved_path: Option<String>, }
43
44#[derive(Clone, serde::Serialize, serde::Deserialize)]
45pub enum PathStatus {
46 Pass, Warn, Fixable, Error, }
51
52pub enum DiagnosticsEvent {
53 Log(String),
54 SetStatus(String),
55 InitProgress(usize),
56 IncrementProgress,
57 Perf(String, String, u128), Finished(Result<(), String>), }
60
61pub fn scan_paths(
62 storage: &GameStorage,
63 tx: Sender<DiagnosticsEvent>,
64) -> io::Result<PathDiagnostics> {
65 let start_total = Instant::now();
66 let root_path = storage.game_path();
67 let data_dir = root_path.join("data");
68
69 if !data_dir.exists() {
70 return Err(io::Error::new(
71 io::ErrorKind::NotFound,
72 "Data directory not found",
73 ));
74 }
75
76 let _ = tx.send(DiagnosticsEvent::SetStatus(
77 "Building file index...".to_string(),
78 ));
79
80 let mut path_index: HashMap<String, String> = HashMap::new();
82 let mut filename_index: HashMap<String, Vec<String>> = HashMap::new();
84
85 let all_files = match storage.discover(DiscoverMode::Files) {
86 Ok(files) => files,
87 Err(e) => {
88 let _ = tx.send(DiagnosticsEvent::Finished(Err(format!(
89 "Failed to discover files: {e}",
90 ))));
91 return Err(e);
92 }
93 };
94
95 if all_files.is_empty() {
96 let _ = tx.send(DiagnosticsEvent::Finished(
97 Err("No files found".to_string()),
98 ));
99 return Err(io::Error::new(
100 io::ErrorKind::NotFound,
101 "No files found in game directory",
102 ));
103 }
104
105 for file_path in &all_files {
106 if let Ok(relative_path) = file_path.strip_prefix(root_path) {
107 let path_str = relative_path.to_string_lossy().replace('\\', "/");
108 let normalized_path = strip_texture_resolution_patterns(&path_str);
109 let lowercase_key = normalized_path.to_lowercase();
110 path_index.insert(lowercase_key, path_str.clone());
111
112 if let Some(filename) = file_path.file_name().and_then(|f| f.to_str()) {
114 let filename_lower = filename.to_lowercase();
115 filename_index
116 .entry(filename_lower)
117 .or_default()
118 .push(path_str.clone());
119 }
120 }
121 }
122
123 let _ = tx.send(DiagnosticsEvent::Log(format!(
124 "Built index with {} files.",
125 path_index.len()
126 )));
127
128 let _ = tx.send(DiagnosticsEvent::SetStatus(
129 "Finding files to scan...".to_string(),
130 ));
131
132 let mut files_to_scan = Vec::new();
136 for path in &all_files {
137 let is_in_data = path.strip_prefix(root_path).is_ok_and(|rel| {
139 rel.components()
140 .next()
141 .is_some_and(|c| c.as_os_str().eq_ignore_ascii_case("data"))
142 });
143
144 if is_in_data && let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
145 let filename_lower = filename.to_lowercase();
146 if filename_lower == "matlist.txt"
147 || filename_lower == "tricnf.txt"
148 || filename_lower == "level_data.txt"
149 || filename_lower == "object_classes.txt"
150 || filename_lower == "board_types.txt"
151 || filename_lower == "boardcnf.txt"
152 || filename_lower == "board_mat.txt"
153 {
154 files_to_scan.push(path.clone());
155 }
156 }
157 }
158
159 let total_files = files_to_scan.len();
160 let _ = tx.send(DiagnosticsEvent::Log(format!(
161 "Found {total_files} files to scan.",
162 )));
163 let _ = tx.send(DiagnosticsEvent::InitProgress(total_files));
164 let _ = tx.send(DiagnosticsEvent::SetStatus("Scanning files...".to_string()));
165
166 let file_reports: Vec<FilePathReport> = files_to_scan
168 .par_iter()
169 .filter_map(|file_path| {
170 let tx = tx.clone();
171
172 let filename = file_path.file_name()?.to_str()?;
173 let filename_lower = filename.to_lowercase();
174 let file_type = if filename_lower == "matlist.txt" {
175 "matlist"
176 } else if filename_lower == "tricnf.txt" {
177 "tricnf"
178 } else if filename_lower == "level_data.txt" {
179 "level_data"
180 } else if filename_lower == "object_classes.txt" {
181 "object_classes"
182 } else if filename_lower == "boardcnf.txt" {
183 "boardcnf"
184 } else if filename_lower == "board_mat.txt" {
185 "board_mat"
186 } else {
187 return None;
188 };
189
190 let relative_path = file_path
191 .strip_prefix(root_path)
192 .unwrap_or(file_path)
193 .to_string_lossy()
194 .to_string()
195 .replace('\\', "/");
196
197 let bytes = match fs::read(file_path) {
199 Ok(b) => b,
200 Err(e) => {
201 let _ = tx.send(DiagnosticsEvent::Log(format!(
202 "Error reading {relative_path}: {e}",
203 )));
204 return None;
205 }
206 };
207
208 let content = decode_bytes(&bytes);
209
210 let mut path_validations = Vec::new();
211
212 for (line_num, line) in content.lines().enumerate() {
214 let paths = extract_quoted_paths(line);
215
216 for original_path in paths {
217 let (status, resolved_path) = validate_path_with_index(
219 &path_index,
220 &filename_index,
221 &original_path,
222 &relative_path,
223 );
224
225 path_validations.push(PathValidation {
226 line_number: line_num + 1,
227 original_path: original_path.clone(),
228 status,
229 resolved_path,
230 });
231 }
232 }
233
234 let _ = tx.send(DiagnosticsEvent::IncrementProgress);
235
236 if path_validations.is_empty() {
237 None
238 } else {
239 Some(FilePathReport {
240 file_path: relative_path.clone(),
241 file_type: file_type.to_string(),
242 paths: path_validations,
243 })
244 }
245 })
246 .collect();
247
248 let mut pass_count = 0;
250 let mut warn_count = 0;
251 let mut fixable_count = 0;
252 let mut error_count = 0;
253 let mut total_paths = 0;
254
255 for report in &file_reports {
256 for validation in &report.paths {
257 total_paths += 1;
258 match validation.status {
259 PathStatus::Pass => pass_count += 1,
260 PathStatus::Warn => warn_count += 1,
261 PathStatus::Fixable => fixable_count += 1,
262 PathStatus::Error => error_count += 1,
263 }
264 }
265 }
266
267 let diagnostics = PathDiagnostics {
268 scan_timestamp: SystemTime::now()
269 .duration_since(UNIX_EPOCH)
270 .unwrap_or_default()
271 .as_secs(),
272 total_files_scanned: file_reports.len(),
273 total_paths_found: total_paths,
274 summary: PathSummary {
275 pass_count,
276 warn_count,
277 fixable_count,
278 error_count,
279 },
280 files: file_reports,
281 };
282
283 if let Err(e) = storage.write_report("path_diagnostics", &diagnostics) {
285 let _ = tx.send(DiagnosticsEvent::Log(format!(
286 "Failed to save diagnostics report: {e}"
287 )));
288 }
289
290 let _ = tx.send(DiagnosticsEvent::Log(format!(
291 "Scan complete: {pass_count} pass, {warn_count} warn, {fixable_count} fixable, {error_count} error",
292 )));
293 let _ = tx.send(DiagnosticsEvent::Log(format!(
294 "Report saved to {}",
295 storage.get_report_path("path_diagnostics").display()
296 )));
297 let _ = tx.send(DiagnosticsEvent::Perf(
298 "PathScanTotal".to_string(),
299 "All".to_string(),
300 start_total.elapsed().as_millis(),
301 ));
302
303 Ok(diagnostics)
304}
305
306fn strip_texture_resolution_patterns(path: &str) -> String {
307 let mut result = path.to_string();
309
310 let resolution_folders = ["vlores", "lores", "medres", "hires"];
312 for folder in &resolution_folders {
313 let pattern = format!("/{folder}/");
314 if let Some(pos) = result.find(&pattern) {
315 result = format!("{}{}", &result[..=pos], &result[pos + pattern.len()..]);
316 }
317 }
318
319 let bit_depth_folders = ["256", "24bit"];
321 for folder in &bit_depth_folders {
322 let pattern = format!("/{folder}/");
323 if let Some(pos) = result.find(&pattern) {
324 result = format!("{}{}", &result[..=pos], &result[pos + pattern.len()..]);
325 }
326 }
327
328 result
329}
330
331fn validate_path_with_index(
332 path_index: &HashMap<String, String>,
333 filename_index: &HashMap<String, Vec<String>>,
334 original_path: &str,
335 current_file_path: &str,
336) -> (PathStatus, Option<String>) {
337 let normalized_path = strip_texture_resolution_patterns(original_path);
339 let lowercase_key = normalized_path.to_lowercase();
340
341 if let Some(actual_path) = path_index.get(&normalized_path.to_lowercase())
343 && actual_path.to_lowercase() == normalized_path.to_lowercase()
344 {
345 if actual_path == &normalized_path {
347 return (PathStatus::Pass, Some(actual_path.clone()));
348 }
349 return (PathStatus::Warn, Some(actual_path.clone()));
351 }
352
353 if let Some(actual_path) = path_index.get(&lowercase_key) {
355 return (PathStatus::Warn, Some(actual_path.clone()));
356 }
357
358 if let Some(filename) = original_path.split('/').next_back() {
360 let filename_lower = filename.to_lowercase();
361 if let Some(matching_paths) = filename_index.get(&filename_lower)
362 && !matching_paths.is_empty()
363 {
364 let best_match = matching_paths.iter().max_by_key(|path| {
366 let path_components = path.split('/');
368 let current_components = current_file_path.split('/');
369 path_components
370 .zip(current_components)
371 .take_while(|(a, b)| a == b)
372 .count()
373 });
374
375 if let Some(best) = best_match {
376 let stripped = strip_texture_resolution_patterns(best);
377 return (PathStatus::Fixable, Some(stripped));
378 }
379 }
380 }
381
382 (PathStatus::Error, None)
384}