Skip to main content

modde_games/tools/
reshade.rs

1//! `ReShade` — shader injection for Wine/Proton games.
2//!
3//! `ReShade` works by placing a proxy DLL (typically `dxgi.dll` or `d3d11.dll`)
4//! in the game directory. Wine needs `WINEDLLOVERRIDES` set to load the native
5//! version instead of its built-in stub.
6
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use smallvec::{SmallVec, smallvec};
11use tracing::info;
12
13use super::{
14    AppliedFiles, GameTool, ToolApplyPreview, ToolAvailability, ToolCategory, ToolConfig,
15    ToolGameContext,
16};
17
18pub static RESHADE: ReShade = ReShade;
19
20pub struct ReShade;
21
22impl GameTool for ReShade {
23    fn tool_id(&self) -> &'static str {
24        "reshade"
25    }
26
27    fn display_name(&self) -> &'static str {
28        "ReShade"
29    }
30
31    fn category(&self) -> ToolCategory {
32        ToolCategory::PostProcess
33    }
34
35    fn description(&self) -> &'static str {
36        "Wine/Proton ReShade deployment through proxy DLLs and shader directories."
37    }
38
39    fn settings_schema(&self) -> Vec<super::ToolSettingSpec> {
40        vec![
41            super::ToolSettingSpec::path(
42                "source_dir",
43                "Source directory",
44                "Directory containing ReShade DLLs, ReShade.ini, and shader folders.",
45            )
46            .section("Source"),
47            super::ToolSettingSpec::select(
48                "dll_name",
49                "Proxy DLL",
50                "DLL name copied into the executable directory.",
51                &["dxgi.dll", "d3d11.dll", "dinput8.dll"],
52            )
53            .section("Deployment"),
54            super::ToolSettingSpec::read_only(
55                "derived_executable_dir",
56                "Executable directory",
57                "Derived from the selected game's metadata.",
58            )
59            .section("Detected Game"),
60        ]
61    }
62
63    fn detect_available(&self) -> ToolAvailability {
64        // ReShade DLLs must be provided by the user (they're Windows binaries).
65        // Check if a source path is configured.
66        ToolAvailability::Available {
67            version: Some("user-provided".into()),
68        }
69    }
70
71    fn env_vars(&self, _config: &ToolConfig) -> SmallVec<[(String, String); 4]> {
72        SmallVec::new()
73    }
74
75    fn wine_dll_overrides(&self, config: &ToolConfig) -> SmallVec<[String; 4]> {
76        let dll_name = config.get_str("dll_name").unwrap_or("dxgi");
77        smallvec![dll_name.to_string()]
78    }
79
80    fn apply(&self, game_dir: &Path, config: &ToolConfig) -> Result<AppliedFiles> {
81        self.apply_for(game_dir, None, config)
82    }
83
84    fn apply_for(
85        &self,
86        game_dir: &Path,
87        context: Option<&ToolGameContext>,
88        config: &ToolConfig,
89    ) -> Result<AppliedFiles> {
90        let source_dir = config
91            .get_str("source_dir")
92            .map(PathBuf::from)
93            .context("reshade: 'source_dir' setting is required (path to ReShade DLLs)")?;
94
95        let dll_name = config.get_str("dll_name").unwrap_or("dxgi.dll");
96        let target_dir = context
97            .and_then(|context| context.executable_dir.clone())
98            .unwrap_or_else(|| {
99                let exe_subdir = config.get_str("exe_subdir").unwrap_or("");
100                if exe_subdir.is_empty() {
101                    game_dir.to_path_buf()
102                } else {
103                    game_dir.join(exe_subdir)
104                }
105            });
106
107        std::fs::create_dir_all(&target_dir)
108            .with_context(|| format!("failed to create {}", target_dir.display()))?;
109
110        let mut applied = AppliedFiles::default();
111
112        // Copy the main DLL
113        let src_dll = source_dir.join(dll_name);
114        if src_dll.exists() {
115            let dest = target_dir.join(dll_name);
116            std::fs::copy(&src_dll, &dest)
117                .with_context(|| format!("failed to copy ReShade DLL to {}", dest.display()))?;
118            let rel = dest.strip_prefix(game_dir).unwrap_or(&dest).to_path_buf();
119            applied.files.push(rel);
120            info!(dll = %dll_name, "applied ReShade DLL");
121        }
122
123        // Copy ReShade.ini if present
124        let ini = source_dir.join("ReShade.ini");
125        if ini.exists() {
126            let dest = target_dir.join("ReShade.ini");
127            std::fs::copy(&ini, &dest)
128                .with_context(|| format!("failed to copy ReShade.ini to {}", dest.display()))?;
129            let rel = dest.strip_prefix(game_dir).unwrap_or(&dest).to_path_buf();
130            applied.files.push(rel);
131        }
132
133        // Copy shader directories if present
134        for subdir in &["reshade-shaders", "reshade-presets"] {
135            let src = source_dir.join(subdir);
136            if src.is_dir() {
137                let dest = target_dir.join(subdir);
138                copy_dir_recursive(&src, &dest, game_dir, &mut applied)?;
139            }
140        }
141
142        Ok(applied)
143    }
144
145    fn preview_apply_for(
146        &self,
147        game_dir: &Path,
148        context: Option<&ToolGameContext>,
149        config: &ToolConfig,
150    ) -> Result<ToolApplyPreview> {
151        let Some(source_dir) = config
152            .get_str("source_dir")
153            .filter(|value| !value.trim().is_empty())
154            .map(PathBuf::from)
155        else {
156            return Ok(missing_preview(
157                "reshade: source directory is not configured",
158            ));
159        };
160        if !source_dir.is_dir() {
161            return Ok(missing_preview(format!(
162                "reshade: source directory does not exist: {}",
163                source_dir.display()
164            )));
165        }
166
167        let dll_name = config.get_str("dll_name").unwrap_or("dxgi.dll");
168        let target_dir = reshade_target_dir(game_dir, context, config);
169        let mut preview = ToolApplyPreview::default();
170
171        let src_dll = source_dir.join(dll_name);
172        if src_dll.is_file() {
173            preview_source_file(game_dir, &src_dll, &target_dir.join(dll_name), &mut preview)?;
174        } else {
175            preview.missing_inputs.push(format!(
176                "reshade: source DLL not found: {}",
177                src_dll.display()
178            ));
179        }
180
181        let ini = source_dir.join("ReShade.ini");
182        if ini.is_file() {
183            preview_source_file(
184                game_dir,
185                &ini,
186                &target_dir.join("ReShade.ini"),
187                &mut preview,
188            )?;
189        }
190
191        for subdir in &["reshade-shaders", "reshade-presets"] {
192            let src = source_dir.join(subdir);
193            if src.is_dir() {
194                preview_dir_recursive(game_dir, &src, &target_dir.join(subdir), &mut preview)?;
195            }
196        }
197
198        Ok(preview)
199    }
200
201    fn default_config(&self) -> ToolConfig {
202        let mut config = ToolConfig::new("reshade");
203        config.set("dll_name", serde_json::json!("dxgi.dll"));
204        config
205    }
206}
207
208fn reshade_target_dir(
209    game_dir: &Path,
210    context: Option<&ToolGameContext>,
211    config: &ToolConfig,
212) -> PathBuf {
213    context
214        .and_then(|context| context.executable_dir.clone())
215        .unwrap_or_else(|| {
216            let exe_subdir = config.get_str("exe_subdir").unwrap_or("");
217            if exe_subdir.is_empty() {
218                game_dir.to_path_buf()
219            } else {
220                game_dir.join(exe_subdir)
221            }
222        })
223}
224
225fn missing_preview(message: impl Into<String>) -> ToolApplyPreview {
226    ToolApplyPreview {
227        missing_inputs: vec![message.into()],
228        ..ToolApplyPreview::default()
229    }
230}
231
232fn preview_source_file(
233    game_dir: &Path,
234    src: &Path,
235    dest: &Path,
236    preview: &mut ToolApplyPreview,
237) -> Result<()> {
238    let expected =
239        std::fs::read(src).with_context(|| format!("failed to read {}", src.display()))?;
240    preview_bytes(game_dir, dest, &expected, preview);
241    Ok(())
242}
243
244fn preview_bytes(game_dir: &Path, dest: &Path, expected: &[u8], preview: &mut ToolApplyPreview) {
245    let changed = std::fs::read(dest).map_or(true, |current| current != expected);
246    let rel = dest.strip_prefix(game_dir).unwrap_or(dest).to_path_buf();
247    preview.record_file(rel, changed);
248}
249
250fn preview_dir_recursive(
251    game_dir: &Path,
252    src: &Path,
253    dest: &Path,
254    preview: &mut ToolApplyPreview,
255) -> Result<()> {
256    for entry in std::fs::read_dir(src)
257        .with_context(|| format!("failed to read directory: {}", src.display()))?
258        .flatten()
259    {
260        let ty = entry.file_type()?;
261        let src_path = entry.path();
262        let dest_path = dest.join(entry.file_name());
263
264        if ty.is_dir() {
265            preview_dir_recursive(game_dir, &src_path, &dest_path, preview)?;
266        } else {
267            preview_source_file(game_dir, &src_path, &dest_path, preview)?;
268        }
269    }
270    Ok(())
271}
272
273/// Recursively copy a directory, recording all files in [`AppliedFiles`].
274fn copy_dir_recursive(
275    src: &Path,
276    dest: &Path,
277    game_dir: &Path,
278    applied: &mut AppliedFiles,
279) -> Result<()> {
280    std::fs::create_dir_all(dest)
281        .with_context(|| format!("failed to create {}", dest.display()))?;
282
283    for entry in std::fs::read_dir(src)
284        .with_context(|| format!("failed to read directory: {}", src.display()))?
285        .flatten()
286    {
287        let ty = entry.file_type()?;
288        let src_path = entry.path();
289        let dest_path = dest.join(entry.file_name());
290
291        if ty.is_dir() {
292            copy_dir_recursive(&src_path, &dest_path, game_dir, applied)?;
293        } else {
294            std::fs::copy(&src_path, &dest_path)
295                .with_context(|| format!("failed to copy {}", dest_path.display()))?;
296            let rel = dest_path
297                .strip_prefix(game_dir)
298                .unwrap_or(&dest_path)
299                .to_path_buf();
300            applied.files.push(rel);
301        }
302    }
303
304    Ok(())
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn preview_reports_changed_when_destination_is_missing() {
313        let source = tempfile::tempdir().expect("source");
314        let game = tempfile::tempdir().expect("game");
315        std::fs::write(source.path().join("dxgi.dll"), b"reshade").expect("source dll");
316        let mut config = ReShade.default_config();
317        config.set(
318            "source_dir",
319            serde_json::json!(source.path().display().to_string()),
320        );
321
322        let preview = ReShade
323            .preview_apply_for(game.path(), None, &config)
324            .expect("preview");
325
326        assert_eq!(preview.changed_files, vec![PathBuf::from("dxgi.dll")]);
327        assert!(preview.unchanged_files.is_empty());
328        assert!(preview.missing_inputs.is_empty());
329    }
330
331    #[test]
332    fn preview_reports_unchanged_when_destination_matches() {
333        let source = tempfile::tempdir().expect("source");
334        let game = tempfile::tempdir().expect("game");
335        std::fs::write(source.path().join("dxgi.dll"), b"reshade").expect("source dll");
336        std::fs::write(game.path().join("dxgi.dll"), b"reshade").expect("dest dll");
337        let mut config = ReShade.default_config();
338        config.set(
339            "source_dir",
340            serde_json::json!(source.path().display().to_string()),
341        );
342
343        let preview = ReShade
344            .preview_apply_for(game.path(), None, &config)
345            .expect("preview");
346
347        assert!(preview.changed_files.is_empty());
348        assert_eq!(preview.unchanged_files, vec![PathBuf::from("dxgi.dll")]);
349        assert!(preview.missing_inputs.is_empty());
350    }
351}