Skip to main content

modde_games/cyberpunk/
scanner.rs

1//! Mod scanner for Cyberpunk 2077.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6
7use super::manifest::RedModManifest;
8use crate::scanner_patterns::{DirectoryModRule, SingleFileModRule};
9use crate::traits::{DiscoveredMod, ModScanner, ModSource, ScanContext, walk_files_relative};
10
11/// [`ModScanner`] that discovers installed Cyberpunk 2077 mods.
12pub struct CyberpunkScanner;
13
14pub static CYBERPUNK_SCANNER: CyberpunkScanner = CyberpunkScanner;
15
16/// Directories scanned for Cyberpunk 2077 mods, relative to install root.
17const SCAN_DIRS: &[&str] = &[
18    "bin/x64/plugins/cyber_engine_tweaks/mods",
19    "r6/scripts",
20    "r6/tweaks",
21    "archive/pc/mod",
22    "mods",
23];
24
25const CET_RULE: DirectoryModRule = DirectoryModRule {
26    rel_dir: "bin/x64/plugins/cyber_engine_tweaks/mods",
27    mod_id_prefix: "cet",
28    source_location: "cet",
29    confidence: 0.7,
30    marker_file: Some("init.lua"),
31    marker_confidence: Some(0.95),
32};
33
34const REDSCRIPT_RULE: DirectoryModRule = DirectoryModRule {
35    rel_dir: "r6/scripts",
36    mod_id_prefix: "reds",
37    source_location: "r6/scripts",
38    confidence: 0.9,
39    marker_file: None,
40    marker_confidence: None,
41};
42
43const TWEAKXL_RULE: DirectoryModRule = DirectoryModRule {
44    rel_dir: "r6/tweaks",
45    mod_id_prefix: "tweak",
46    source_location: "r6/tweaks",
47    confidence: 0.9,
48    marker_file: None,
49    marker_confidence: None,
50};
51
52const ARCHIVE_RULE: SingleFileModRule = SingleFileModRule {
53    rel_dir: "archive/pc/mod",
54    extension: "archive",
55    ignored_prefixes: &[],
56    mod_id_prefix: "archive",
57    source_location: "archive/pc/mod",
58    confidence: 0.85,
59};
60
61impl ModScanner for CyberpunkScanner {
62    fn scan_directories(&self) -> &[&str] {
63        SCAN_DIRS
64    }
65
66    fn scan_filesystem(&self, ctx: &ScanContext<'_>) -> Result<Vec<DiscoveredMod>> {
67        let install = ctx.install_dir;
68        let mut mods = Vec::new();
69
70        CET_RULE.scan(install, &mut mods)?;
71        REDSCRIPT_RULE.scan(install, &mut mods)?;
72        TWEAKXL_RULE.scan(install, &mut mods)?;
73        ARCHIVE_RULE.scan(install, &mut mods)?;
74        scan_redmod_mods(install, &mut mods)?;
75
76        Ok(mods)
77    }
78
79    /// Inverse of the scheme used in the `scan_*_mods` helpers below.
80    /// Must stay in sync with them — if a new scan pass is added (or a
81    /// prefix changes), this function needs the matching branch.
82    ///
83    /// Directory footprints are lowercased and terminated with a
84    /// trailing `/`; file footprints are lowercased and match the on-disk
85    /// layout exactly. Both conventions match what
86    /// `modde_core::scanner::detect_stale_duplicates` expects.
87    fn mod_id_footprint(&self, mod_id: &str) -> Option<modde_core::scanner::ModFootprint> {
88        use modde_core::scanner::ModFootprint;
89        if let Some(name) = mod_id.strip_prefix("cet/") {
90            Some(ModFootprint::Directory(format!(
91                "bin/x64/plugins/cyber_engine_tweaks/mods/{}/",
92                name.to_lowercase()
93            )))
94        } else if let Some(name) = mod_id.strip_prefix("reds/") {
95            Some(ModFootprint::Directory(format!(
96                "r6/scripts/{}/",
97                name.to_lowercase()
98            )))
99        } else if let Some(name) = mod_id.strip_prefix("tweak/") {
100            Some(ModFootprint::Directory(format!(
101                "r6/tweaks/{}/",
102                name.to_lowercase()
103            )))
104        } else if let Some(name) = mod_id.strip_prefix("redmod/") {
105            Some(ModFootprint::Directory(format!(
106                "mods/{}/",
107                name.to_lowercase()
108            )))
109        } else {
110            mod_id.strip_prefix("archive/").map(|stem| {
111                ModFootprint::File(format!("archive/pc/mod/{}.archive", stem.to_lowercase()))
112            })
113        }
114    }
115}
116
117/// `REDmod` mods: each subdirectory of `mods/` is one mod (parse `info.json`).
118fn scan_redmod_mods(install: &Path, out: &mut Vec<DiscoveredMod>) -> Result<()> {
119    let mods_dir = install.join("mods");
120    if !mods_dir.is_dir() {
121        return Ok(());
122    }
123
124    for entry in std::fs::read_dir(&mods_dir)
125        .with_context(|| format!("failed to read directory: {}", mods_dir.display()))?
126        .flatten()
127    {
128        if !entry.path().is_dir() {
129            continue;
130        }
131
132        let dir_name = entry.file_name().to_string_lossy().to_string();
133        let info_json = entry.path().join("info.json");
134
135        let (name, version) = if info_json.exists() {
136            match std::fs::read_to_string(&info_json)
137                .ok()
138                .and_then(|s| RedModManifest::parse(&s).ok())
139            {
140                Some(manifest) => (manifest.name, manifest.version),
141                None => (dir_name.clone(), None),
142            }
143        } else {
144            (dir_name.clone(), None)
145        };
146
147        let files = walk_files_relative(install, &entry.path());
148        if files.is_empty() {
149            continue;
150        }
151
152        out.push(DiscoveredMod {
153            mod_id: format!("redmod/{dir_name}"),
154            display_name: name,
155            version,
156            files,
157            source: ModSource::Filesystem {
158                location: "mods".into(),
159            },
160            confidence: if info_json.exists() { 0.95 } else { 0.8 },
161        });
162    }
163    Ok(())
164}