Skip to main content

modde_games/ue4/
scanner.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use anyhow::Result;
5
6use crate::traits::{
7    walk_files_relative, DiscoveredFile, DiscoveredMod, ModScanner, ModSource, ScanContext,
8};
9
10/// Data-driven scanner for UE4 pak-based mods.
11///
12/// Walks `<ProjectName>/Content/Paks/~mods` and `.../LogicMods`, grouping
13/// `.pak` / `.ucas` / `.utoc` triples by file stem. Each stem becomes one
14/// [`DiscoveredMod`] with `mod_id = "pak/<stem>"`.
15pub struct Ue4Scanner {
16    pub game_id: &'static str,
17    pub project_name: &'static str,
18}
19
20pub static STELLAR_BLADE_SCANNER: Ue4Scanner = Ue4Scanner {
21    game_id: "stellar-blade",
22    project_name: "SB",
23};
24
25/// Returns the basename stem if `rel` ends with a UE4 pak-triple extension.
26///
27/// `rel` is expected to be a relative path string; both `/` and `\` are
28/// tolerated. Matching is case-insensitive.
29fn stem_for(rel: &str) -> Option<String> {
30    let lower = rel.to_lowercase();
31    if !(lower.ends_with(".pak") || lower.ends_with(".ucas") || lower.ends_with(".utoc")) {
32        return None;
33    }
34    let path = std::path::Path::new(rel);
35    path.file_stem()
36        .and_then(|s| s.to_str())
37        .map(|s| s.to_string())
38}
39
40impl Ue4Scanner {
41    fn scan_subdir(
42        &self,
43        install: &Path,
44        subdir: &str,
45        location: &str,
46        out: &mut Vec<DiscoveredMod>,
47    ) {
48        let dir = install
49            .join(self.project_name)
50            .join("Content")
51            .join("Paks")
52            .join(subdir);
53        if !dir.is_dir() {
54            return;
55        }
56
57        // Group discovered files by file stem so a .pak + .ucas + .utoc triple
58        // collapses into a single DiscoveredMod.
59        let mut by_stem: BTreeMap<String, Vec<DiscoveredFile>> = BTreeMap::new();
60
61        let Ok(entries) = std::fs::read_dir(&dir) else {
62            return;
63        };
64        for entry in entries.flatten() {
65            let path = entry.path();
66
67            if path.is_dir() {
68                // Some packaged mods ship as a subdir containing the pak triple.
69                for f in walk_files_relative(install, &path) {
70                    if let Some(stem) = stem_for(&f.rel_path) {
71                        by_stem.entry(stem).or_default().push(f);
72                    }
73                }
74                continue;
75            }
76
77            let Ok(meta) = path.metadata() else {
78                continue;
79            };
80            let Ok(rel) = path.strip_prefix(install) else {
81                continue;
82            };
83            let rel_str = rel.to_string_lossy().to_string();
84            let Some(stem) = stem_for(&rel_str) else {
85                continue;
86            };
87            by_stem.entry(stem).or_default().push(DiscoveredFile {
88                rel_path: rel_str,
89                size: meta.len(),
90            });
91        }
92
93        for (stem, files) in by_stem {
94            out.push(DiscoveredMod {
95                mod_id: format!("pak/{stem}"),
96                display_name: stem,
97                version: None,
98                files,
99                source: ModSource::Filesystem {
100                    location: location.into(),
101                },
102                confidence: 0.9,
103            });
104        }
105    }
106}
107
108impl ModScanner for Ue4Scanner {
109    fn scan_directories(&self) -> &[&str] {
110        // Used by cache invalidation. Exact subpaths are composed at scan
111        // time because they depend on `project_name`.
112        &["Content/Paks/~mods", "Content/Paks/LogicMods"]
113    }
114
115    fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> Result<Vec<DiscoveredMod>> {
116        let mut out = Vec::new();
117        self.scan_subdir(ctx.install_dir, "~mods", "paks-mods", &mut out);
118        self.scan_subdir(ctx.install_dir, "LogicMods", "logic-mods", &mut out);
119        Ok(out)
120    }
121
122    fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
123        // Represent the mod by its `.pak` file; ucas/utoc siblings collapse
124        // into the same row (same convention as Cyberpunk's `archive/` branch).
125        let stem = mod_id.strip_prefix("pak/")?.to_lowercase();
126        Some(modde_core::scanner::ModFootprint::File(format!(
127            "{}/content/paks/~mods/{stem}.pak",
128            self.project_name.to_lowercase()
129        )))
130    }
131}