Skip to main content

modde_games/cyberpunk/
scanner.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::traits::{DiscoveredFile, DiscoveredMod, ModScanner, ModSource, ScanContext, walk_files_relative};
6use super::manifest::RedModManifest;
7
8pub struct CyberpunkScanner;
9
10pub static CYBERPUNK_SCANNER: CyberpunkScanner = CyberpunkScanner;
11
12/// Directories scanned for Cyberpunk 2077 mods, relative to install root.
13const SCAN_DIRS: &[&str] = &[
14    "bin/x64/plugins/cyber_engine_tweaks/mods",
15    "r6/scripts",
16    "r6/tweaks",
17    "archive/pc/mod",
18    "mods",
19];
20
21impl ModScanner for CyberpunkScanner {
22    fn scan_directories(&self) -> &[&str] {
23        SCAN_DIRS
24    }
25
26    fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> Result<Vec<DiscoveredMod>> {
27        let install = ctx.install_dir;
28        let mut mods = Vec::new();
29
30        scan_cet_mods(install, &mut mods)?;
31        scan_redscript_mods(install, &mut mods)?;
32        scan_tweakxl_mods(install, &mut mods)?;
33        scan_archive_mods(install, &mut mods)?;
34        scan_redmod_mods(install, &mut mods)?;
35
36        Ok(mods)
37    }
38
39    /// Inverse of the scheme used in the `scan_*_mods` helpers below.
40    /// Must stay in sync with them — if a new scan pass is added (or a
41    /// prefix changes), this function needs the matching branch.
42    ///
43    /// Directory footprints are lowercased and terminated with a
44    /// trailing `/`; file footprints are lowercased and match the on-disk
45    /// layout exactly. Both conventions match what
46    /// `modde_core::scanner::detect_stale_duplicates` expects.
47    fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
48        use modde_core::scanner::ModFootprint;
49        if let Some(name) = mod_id.strip_prefix("cet/") {
50            Some(ModFootprint::Directory(format!(
51                "bin/x64/plugins/cyber_engine_tweaks/mods/{}/",
52                name.to_lowercase()
53            )))
54        } else if let Some(name) = mod_id.strip_prefix("reds/") {
55            Some(ModFootprint::Directory(format!(
56                "r6/scripts/{}/",
57                name.to_lowercase()
58            )))
59        } else if let Some(name) = mod_id.strip_prefix("tweak/") {
60            Some(ModFootprint::Directory(format!(
61                "r6/tweaks/{}/",
62                name.to_lowercase()
63            )))
64        } else if let Some(name) = mod_id.strip_prefix("redmod/") {
65            Some(ModFootprint::Directory(format!("mods/{}/", name.to_lowercase())))
66        } else if let Some(stem) = mod_id.strip_prefix("archive/") {
67            Some(ModFootprint::File(format!(
68                "archive/pc/mod/{}.archive",
69                stem.to_lowercase()
70            )))
71        } else {
72            None
73        }
74    }
75}
76
77/// Cyber Engine Tweaks mods: each subdirectory of `.../cyber_engine_tweaks/mods/` is one mod.
78fn scan_cet_mods(install: &Path, out: &mut Vec<DiscoveredMod>) -> Result<()> {
79    let cet_dir = install.join("bin/x64/plugins/cyber_engine_tweaks/mods");
80    if !cet_dir.is_dir() {
81        return Ok(());
82    }
83
84    for entry in std::fs::read_dir(&cet_dir)?.flatten() {
85        if !entry.path().is_dir() {
86            continue;
87        }
88        let name = entry.file_name().to_string_lossy().to_string();
89        let has_init = entry.path().join("init.lua").exists();
90        let files = walk_files_relative(install, &entry.path());
91
92        if files.is_empty() {
93            continue;
94        }
95
96        out.push(DiscoveredMod {
97            mod_id: format!("cet/{name}"),
98            display_name: name,
99            version: None,
100            files,
101            source: ModSource::Filesystem {
102                location: "cet".into(),
103            },
104            confidence: if has_init { 0.95 } else { 0.7 },
105        });
106    }
107    Ok(())
108}
109
110/// REDscript mods: each subdirectory of `r6/scripts/` is one mod.
111fn scan_redscript_mods(install: &Path, out: &mut Vec<DiscoveredMod>) -> Result<()> {
112    let scripts_dir = install.join("r6/scripts");
113    if !scripts_dir.is_dir() {
114        return Ok(());
115    }
116
117    for entry in std::fs::read_dir(&scripts_dir)?.flatten() {
118        if !entry.path().is_dir() {
119            continue;
120        }
121        let name = entry.file_name().to_string_lossy().to_string();
122        let files = walk_files_relative(install, &entry.path());
123
124        if files.is_empty() {
125            continue;
126        }
127
128        out.push(DiscoveredMod {
129            mod_id: format!("reds/{name}"),
130            display_name: name,
131            version: None,
132            files,
133            source: ModSource::Filesystem {
134                location: "r6/scripts".into(),
135            },
136            confidence: 0.9,
137        });
138    }
139    Ok(())
140}
141
142/// TweakXL mods: each subdirectory of `r6/tweaks/` is one mod.
143fn scan_tweakxl_mods(install: &Path, out: &mut Vec<DiscoveredMod>) -> Result<()> {
144    let tweaks_dir = install.join("r6/tweaks");
145    if !tweaks_dir.is_dir() {
146        return Ok(());
147    }
148
149    for entry in std::fs::read_dir(&tweaks_dir)?.flatten() {
150        if !entry.path().is_dir() {
151            continue;
152        }
153        let name = entry.file_name().to_string_lossy().to_string();
154        let files = walk_files_relative(install, &entry.path());
155
156        if files.is_empty() {
157            continue;
158        }
159
160        out.push(DiscoveredMod {
161            mod_id: format!("tweak/{name}"),
162            display_name: name,
163            version: None,
164            files,
165            source: ModSource::Filesystem {
166                location: "r6/tweaks".into(),
167            },
168            confidence: 0.9,
169        });
170    }
171    Ok(())
172}
173
174/// Archive mods: each `.archive` file in `archive/pc/mod/` is one mod.
175fn scan_archive_mods(install: &Path, out: &mut Vec<DiscoveredMod>) -> Result<()> {
176    let archive_dir = install.join("archive/pc/mod");
177    if !archive_dir.is_dir() {
178        return Ok(());
179    }
180
181    for entry in std::fs::read_dir(&archive_dir)?.flatten() {
182        let path = entry.path();
183        if path.is_dir() || path.extension().and_then(|e| e.to_str()) != Some("archive") {
184            continue;
185        }
186
187        let stem = path
188            .file_stem()
189            .and_then(|s| s.to_str())
190            .unwrap_or("unknown");
191        let size = path.metadata().map(|m| m.len()).unwrap_or(0);
192        let rel = path
193            .strip_prefix(install)
194            .unwrap_or(&path)
195            .to_string_lossy()
196            .replace('\\', "/");
197
198        out.push(DiscoveredMod {
199            mod_id: format!("archive/{stem}"),
200            display_name: stem.to_string(),
201            version: None,
202            files: vec![DiscoveredFile { rel_path: rel, size }],
203            source: ModSource::Filesystem {
204                location: "archive/pc/mod".into(),
205            },
206            confidence: 0.85,
207        });
208    }
209    Ok(())
210}
211
212/// REDmod mods: each subdirectory of `mods/` is one mod (parse `info.json`).
213fn scan_redmod_mods(install: &Path, out: &mut Vec<DiscoveredMod>) -> Result<()> {
214    let mods_dir = install.join("mods");
215    if !mods_dir.is_dir() {
216        return Ok(());
217    }
218
219    for entry in std::fs::read_dir(&mods_dir)?.flatten() {
220        if !entry.path().is_dir() {
221            continue;
222        }
223
224        let dir_name = entry.file_name().to_string_lossy().to_string();
225        let info_json = entry.path().join("info.json");
226
227        let (name, version) = if info_json.exists() {
228            match std::fs::read_to_string(&info_json).ok().and_then(|s| RedModManifest::parse(&s).ok()) {
229                Some(manifest) => (manifest.name, manifest.version),
230                None => (dir_name.clone(), None),
231            }
232        } else {
233            (dir_name.clone(), None)
234        };
235
236        let files = walk_files_relative(install, &entry.path());
237        if files.is_empty() {
238            continue;
239        }
240
241        out.push(DiscoveredMod {
242            mod_id: format!("redmod/{dir_name}"),
243            display_name: name,
244            version,
245            files,
246            source: ModSource::Filesystem {
247                location: "mods".into(),
248            },
249            confidence: if info_json.exists() { 0.95 } else { 0.8 },
250        });
251    }
252    Ok(())
253}