Skip to main content

modde_games/cyberpunk/
mod.rs

1//! The Cyberpunk 2077 game plugin: `REDengine` mod layout, `REDmod` deploy,
2//! and the associated scanner, save tracker, and collision classifier.
3
4pub mod collision;
5pub mod manifest;
6pub mod redmod;
7pub mod saves;
8pub mod scanner;
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13
14use smallvec::SmallVec;
15
16use crate::policies::{BareLayoutPolicy, ContentPolicy, DllOverridePolicy, StagingDllSearch};
17use crate::traits::{ContentCategory, GamePlugin, ModSafety};
18
19/// [`GamePlugin`] for Cyberpunk 2077 (`REDengine` 4).
20pub struct Cyberpunk2077;
21
22pub static CYBERPUNK2077: Cyberpunk2077 = Cyberpunk2077;
23
24/// File extensions that indicate a mod alters game logic.
25const CYBERPUNK_SAVE_BREAKING_EXT: &[&str] = &["reds", "lua", "tweak", "xl", "yaml", "yls"];
26
27/// Directories within a mod that signal save-breaking content.
28const CYBERPUNK_SAVE_BREAKING_DIRS: &[&str] = &[
29    "r6/scripts",
30    "r6/tweaks",
31    "bin/x64/plugins/cyber_engine_tweaks/mods",
32];
33
34/// Extensions that are purely cosmetic.
35const CYBERPUNK_COSMETIC_EXT: &[&str] = &["archive", "xl", "png", "jpg", "dds", "tga", "ini"];
36
37const CYBERPUNK_CONTENT_CATEGORIES: &[(&str, ContentCategory)] = &[
38    ("archive", ContentCategory::Archive),
39    ("dll", ContentCategory::Binary),
40    ("so", ContentCategory::Binary),
41    ("reds", ContentCategory::Script),
42    ("lua", ContentCategory::Script),
43    ("tweak", ContentCategory::Script),
44    ("xl", ContentCategory::Script),
45    ("yaml", ContentCategory::Config),
46    ("yls", ContentCategory::Config),
47    ("yml", ContentCategory::Config),
48    ("ini", ContentCategory::Config),
49    ("json", ContentCategory::Config),
50    ("toml", ContentCategory::Config),
51    ("xml", ContentCategory::Config),
52    ("dds", ContentCategory::Texture),
53    ("png", ContentCategory::Texture),
54    ("tga", ContentCategory::Texture),
55    ("jpg", ContentCategory::Texture),
56];
57
58const CYBERPUNK_CONTENT_POLICY: ContentPolicy = ContentPolicy {
59    save_breaking_ext: CYBERPUNK_SAVE_BREAKING_EXT,
60    cosmetic_ext: CYBERPUNK_COSMETIC_EXT,
61    save_breaking_dirs: CYBERPUNK_SAVE_BREAKING_DIRS,
62    categories: CYBERPUNK_CONTENT_CATEGORIES,
63};
64
65/// Windows system DLLs commonly hijacked by mod frameworks as proxy/hook DLLs.
66/// When present in the game's executable directory, these need Wine `n,b` overrides
67/// so Wine loads the native (mod) version instead of its built-in stub.
68const KNOWN_PROXY_DLLS: &[&str] = &[
69    "version",   // CET (Cyber Engine Tweaks), ASI loaders
70    "winmm",     // ASI loader, some mod frameworks
71    "dinput8",   // Various mod frameworks
72    "d3d11",     // ReShade, ENB
73    "dxgi",      // OptiScaler, ReShade (often handled by fgmod)
74    "winhttp",   // Some mod loaders
75    "xinput1_3", // Controller hook mods
76];
77
78const CYBERPUNK_DLL_POLICY: DllOverridePolicy = DllOverridePolicy {
79    proxy_dlls: KNOWN_PROXY_DLLS,
80    staging_search: StagingDllSearch::NestedModsBinX64,
81};
82
83const CYBERPUNK_BARE_LAYOUT_POLICY: BareLayoutPolicy = BareLayoutPolicy {
84    root_dirs: &[
85        "r6", "archive", "archives", "bin", "engine", "mods", "red4ext",
86    ],
87    root_file_exts: &[],
88    case_insensitive_dirs: false,
89};
90
91impl GamePlugin for Cyberpunk2077 {
92    fn game_id(&self) -> &'static str {
93        "cyberpunk2077"
94    }
95
96    fn display_name(&self) -> &'static str {
97        "Cyberpunk 2077"
98    }
99
100    fn mod_directory(&self, install: &Path) -> PathBuf {
101        install.join("mods")
102    }
103
104    fn deploy(&self, staging: &Path, target: &Path) -> Result<()> {
105        if !target.exists() {
106            std::fs::create_dir_all(target)
107                .with_context(|| format!("failed to create {}", target.display()))?;
108        }
109        // Symlink each mod directory from staging into game mods dir
110        for entry in std::fs::read_dir(staging)
111            .with_context(|| format!("failed to read directory: {}", staging.display()))?
112        {
113            let entry = entry?;
114            let dst = target.join(entry.file_name());
115            if dst.exists() || dst.symlink_metadata().is_ok() {
116                if dst.is_dir() {
117                    std::fs::remove_dir_all(&dst)
118                        .with_context(|| format!("failed to remove {}", dst.display()))?;
119                } else {
120                    std::fs::remove_file(&dst)
121                        .with_context(|| format!("failed to remove {}", dst.display()))?;
122                }
123            }
124            modde_core::fs::symlink(&entry.path(), &dst)?;
125        }
126        Ok(())
127    }
128
129    fn post_deploy(&self, install: &Path) -> Result<()> {
130        // Run REDmod deploy if available
131        redmod::deploy_if_available(install)
132    }
133
134    fn save_directory(&self) -> Option<PathBuf> {
135        let save_suffix = "pfx/drive_c/users/steamuser/Saved Games/CD Projekt Red/Cyberpunk 2077";
136
137        // Check Heroic prefixes (GOG / sideload)
138        let heroic_prefixes =
139            modde_core::paths::home_dir().join("Games/Heroic/Prefixes/default/Cyberpunk 2077");
140        let heroic_path = heroic_prefixes.join(save_suffix);
141        if heroic_path.exists() {
142            return Some(heroic_path);
143        }
144
145        // Steam Proton prefix
146        let compat = modde_core::paths::steam_common()
147            .parent()? // steamapps/
148            .join("compatdata/1091500")
149            .join(save_suffix);
150        if compat.exists() {
151            return Some(compat);
152        }
153        None
154    }
155
156    fn supports_save_profiles(&self) -> bool {
157        true
158    }
159
160    fn classify_mod(&self, mod_dir: &Path) -> ModSafety {
161        CYBERPUNK_CONTENT_POLICY.classify_mod(mod_dir)
162    }
163
164    fn classify_extension(&self, ext: &str) -> ContentCategory {
165        CYBERPUNK_CONTENT_POLICY.classify_extension(ext)
166    }
167
168    fn wine_dll_overrides(&self, game_dir: &Path) -> SmallVec<[String; 4]> {
169        CYBERPUNK_DLL_POLICY.from_executable_dir(&self.executable_dir(game_dir))
170    }
171
172    fn wine_dll_overrides_from_staging(&self, staging: &Path) -> SmallVec<[String; 4]> {
173        CYBERPUNK_DLL_POLICY.from_staging(staging)
174    }
175
176    fn executable_dir(&self, install: &Path) -> PathBuf {
177        install.join("bin").join("x64")
178    }
179
180    fn archive_extensions(&self) -> &[&str] {
181        &["archive"]
182    }
183
184    fn steam_app_id_u32(&self) -> Option<u32> {
185        Some(1091500)
186    }
187
188    fn nexus_game_domain(&self) -> Option<&str> {
189        Some("cyberpunk2077")
190    }
191
192    fn nexus_game_id_u32(&self) -> Option<u32> {
193        // Nexus Mods v2 GraphQL game ID for Cyberpunk 2077.
194        // Source: https://api.nexusmods.com/v1/games.json
195        Some(3333)
196    }
197
198    fn analyze_mod_archive(
199        &self,
200        extracted_dir: &Path,
201    ) -> Option<modde_core::installer::InstallMethod> {
202        // REDmod signature: top-level `info.json` + `archives/` subdir.
203        // The `archives/` dir may also be spelled `archive/` on some
204        // mods; check both.
205        let info_json = extracted_dir.join("info.json");
206        if !info_json.is_file() {
207            return None;
208        }
209        let archives = extracted_dir.join("archives");
210        let archive = extracted_dir.join("archive");
211        if archives.is_dir() || archive.is_dir() {
212            return Some(modde_core::installer::InstallMethod::REDmod {
213                manifest: PathBuf::from("info.json"),
214            });
215        }
216        None
217    }
218
219    fn recognizes_bare_layout(&self, extracted_dir: &Path) -> bool {
220        // Cyberpunk mods drop loose into one of these top-level dirs.
221        // If any of them exist at the extraction root, treat the archive
222        // as a bare extract — the deploy step will symlink into
223        // `<install>/mods/<name>/` via the REDmod loader.
224        CYBERPUNK_BARE_LAYOUT_POLICY.recognizes(extracted_dir)
225    }
226}