Skip to main content

supreme_diagnostics/
lib.rs

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, // "matlist" or "tricnf"
33    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>, // actual path if found
42}
43
44#[derive(Clone, serde::Serialize, serde::Deserialize)]
45pub enum PathStatus {
46    Pass,    // case-sensitive match
47    Warn,    // case-insensitive match only
48    Fixable, // wrong path but file exists elsewhere with same filename
49    Error,   // no match found
50}
51
52pub enum DiagnosticsEvent {
53    Log(String),
54    SetStatus(String),
55    InitProgress(usize),
56    IncrementProgress,
57    Perf(String, String, u128),   // Operation, Target, Duration_ms
58    Finished(Result<(), String>), // Included here for completeness if needed, though launcher handles thread
59}
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    // Build a lowercase path index map (reusing snapshot scanning logic)
81    let mut path_index: HashMap<String, String> = HashMap::new();
82    // Build a filename index to find files by name when path is wrong
83    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            // Also index by filename for fixable path detection
113            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    // Find all files matching:
133    // Objects: matlist.txt, tricnf.txt, level_data.txt, object_classes.txt
134    // Boards: boardcnf.txt, board_mat.txt
135    let mut files_to_scan = Vec::new();
136    for path in &all_files {
137        // Check if file is inside Data directory (case-insensitive check)
138        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    // Process files in parallel
167    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            // Read file content with Windows-1252 encoding
198            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            // Extract paths - all file types use the same key = "value"; format
213            for (line_num, line) in content.lines().enumerate() {
214                let paths = extract_quoted_paths(line);
215
216                for original_path in paths {
217                    // Validate the path using the index
218                    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    // Calculate summary statistics
249    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    // Save diagnostics report
284    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    // Strip {vlores,lores,medres,hires}/{256,24bit} patterns from paths
308    let mut result = path.to_string();
309
310    // Remove resolution folder patterns like /hires/, /medres/, etc.
311    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    // Remove bit depth folder patterns like /256/, /24bit/
320    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    // Normalize the path by stripping texture resolution patterns
338    let normalized_path = strip_texture_resolution_patterns(original_path);
339    let lowercase_key = normalized_path.to_lowercase();
340
341    // Try exact match first (case-sensitive)
342    if let Some(actual_path) = path_index.get(&normalized_path.to_lowercase())
343        && actual_path.to_lowercase() == normalized_path.to_lowercase()
344    {
345        // Check if the case matches exactly
346        if actual_path == &normalized_path {
347            return (PathStatus::Pass, Some(actual_path.clone()));
348        }
349        // Case-insensitive match
350        return (PathStatus::Warn, Some(actual_path.clone()));
351    }
352
353    // Try with lowercase lookup
354    if let Some(actual_path) = path_index.get(&lowercase_key) {
355        return (PathStatus::Warn, Some(actual_path.clone()));
356    }
357
358    // Path not found - try to find by filename
359    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            // Find the best match based on proximity to current_file_path
365            let best_match = matching_paths.iter().max_by_key(|path| {
366                // Calculate common prefix length
367                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    // No match found
383    (PathStatus::Error, None)
384}