modde_games/ue4/
scanner.rs1use 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
10pub 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
25fn 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 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 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 &["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 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}