Skip to main content

supreme_fs/game_storage/
hooked_ops.rs

1//! Local file operations with the same shape as [`super::local_ops`]'s
2//! `read_text`/`write_text`, but hook-aware: when this `GameStorage` was
3//! built with [`super::GameStorage::with_hooks`] or
4//! [`super::GameStorage::with_hook_resolver`], every path is resolved
5//! through the active mod redirection rules first, exactly as the game
6//! process's own hooked file I/O would resolve it. Instances built with
7//! [`super::GameStorage::new`] (no resolver) fall through to the literal
8//! path under the game root, byte-for-byte the same as before this existed.
9//!
10//! This is what lets a launcher page (a boards list, a renderer picker,
11//! or any other known game file or directory) stay in sync with what a mod
12//! has redirected, injected, or hidden, and write edits back to wherever
13//! the game would actually read them from, without the launcher ever
14//! injecting into the game process.
15
16use super::GameStorage;
17use crate::WINDOWS_1252;
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use supreme_hook_config::ResolvedAction;
22
23impl GameStorage {
24    /// Resolves `path` (relative to the game root) to the physical file a
25    /// READ should come from: the mod's replacement if a rule matches and
26    /// it exists, the original otherwise. With no resolver attached, this
27    /// is always just `root_path.join(path)`.
28    ///
29    /// # Errors
30    ///
31    /// Returns a `NotFound` error if a matched rule is in `hard_error` mode
32    /// and its replacement is missing, the same outcome the game process
33    /// would see for the same path, rather than silently falling back to
34    /// the original.
35    fn resolve_read_path(&self, path: &Path) -> io::Result<PathBuf> {
36        let Some(resolver) = &self.hook_resolver else {
37            return Ok(self.root_path.join(path));
38        };
39        let rel_key = rel_key_for(path);
40
41        match resolver.resolve_path(&rel_key) {
42            ResolvedAction::PassThrough | ResolvedAction::FallbackToOriginal => {
43                Ok(self.root_path.join(path))
44            }
45            ResolvedAction::Redirect { replacement_path } => Ok(replacement_path),
46            ResolvedAction::HardErrorMissingFile => Err(io::Error::new(
47                io::ErrorKind::NotFound,
48                format!(
49                    "'{rel_key}' is redirected by a mod in hard-error mode, and its replacement is missing"
50                ),
51            )),
52        }
53    }
54
55    /// Resolves `path` (relative to the game root) to the physical file a
56    /// WRITE should land on: a mod's own `target` if a rule matches (seeded
57    /// with the original file's contents first, so untouched fields
58    /// survive a partial rewrite), or the original file otherwise. With no
59    /// resolver attached, this is always just `root_path.join(path)`.
60    ///
61    /// # Errors
62    ///
63    /// Returns a `NotFound` error if a matched rule is in `hard_error` mode
64    /// and has no usable target, mirrors the game process being blocked
65    /// from creating the file at all for the same path.
66    fn resolve_write_path(&self, path: &Path) -> io::Result<PathBuf> {
67        let Some(resolver) = &self.hook_resolver else {
68            return Ok(self.root_path.join(path));
69        };
70        let rel_key = rel_key_for(path);
71
72        match resolver.resolve_path_for_write(&rel_key) {
73            ResolvedAction::PassThrough | ResolvedAction::FallbackToOriginal => {
74                Ok(self.root_path.join(path))
75            }
76            ResolvedAction::Redirect { replacement_path } => Ok(replacement_path),
77            ResolvedAction::HardErrorMissingFile => Err(io::Error::new(
78                io::ErrorKind::NotFound,
79                format!(
80                    "'{rel_key}' is redirected by a mod in hard-error mode with no usable target"
81                ),
82            )),
83        }
84    }
85
86    /// Read a game-relative file's raw bytes, hook-aware.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if the resolved file cannot be read, or (in
91    /// hook-aware mode) if a matched rule blocks the read, see
92    /// [`GameStorage::resolve_read_path`].
93    pub fn read_bytes(&self, path: &Path) -> io::Result<Vec<u8>> {
94        let resolved = self.resolve_read_path(path)?;
95        fs::read(resolved)
96    }
97
98    /// Write a game-relative file's raw bytes, hook-aware: transparently
99    /// forwarded to wherever the active redirection rules say this path
100    /// should be written, same as `read_bytes` reads it from.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the resolved location cannot be written, or (in
105    /// hook-aware mode) if a matched rule blocks the write, see
106    /// [`GameStorage::resolve_write_path`].
107    pub fn write_bytes(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
108        let resolved = self.resolve_write_path(path)?;
109        fs::write(resolved, contents)
110    }
111
112    /// Read text file with Windows-1252 encoding (game-specific), hook-aware.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the resolved file cannot be read, or (in
117    /// hook-aware mode) if a matched rule blocks the read.
118    pub fn read_text(&self, path: &Path) -> io::Result<String> {
119        let bytes = self.read_bytes(path)?;
120        let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
121        Ok(decoded.into_owned())
122    }
123
124    /// Write text file with Windows-1252 encoding (game-specific),
125    /// hook-aware: transparently forwarded per the active redirection
126    /// rules, same as [`GameStorage::write_bytes`].
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if the resolved location cannot be written, or (in
131    /// hook-aware mode) if a matched rule blocks the write.
132    pub fn write_text(&self, path: &Path, content: &str) -> io::Result<()> {
133        let (encoded, _, _) = WINDOWS_1252.encode(content);
134        self.write_bytes(path, &encoded)
135    }
136
137    /// True if `path` (relative to the game root) resolves to a file that
138    /// actually exists, the mod's replacement if one is active and
139    /// present, the original otherwise. Never errors: a blocked
140    /// (`hard_error`) resolution is reported as "does not exist", matching
141    /// what the game process would experience trying to open it.
142    #[must_use]
143    pub fn exists(&self, path: &Path) -> bool {
144        self.resolve_read_path(path)
145            .map(|resolved| resolved.exists())
146            .unwrap_or(false)
147    }
148
149    /// Removes a game-relative file, hook-aware: if a mod redirect (static
150    /// replacement, or a file previously written into a mod's `target`) is
151    /// active for this path, that file is removed instead of the original.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the resolved file cannot be removed, or (in
156    /// hook-aware mode) if a matched rule is in `hard_error` mode with no
157    /// hooked file present, the delete is blocked, same as the game
158    /// process would see.
159    pub fn remove_file(&self, path: &Path) -> io::Result<()> {
160        let Some(resolver) = &self.hook_resolver else {
161            return fs::remove_file(self.root_path.join(path));
162        };
163        let rel_key = rel_key_for(path);
164
165        match resolver.resolve_path_for_delete(&rel_key) {
166            ResolvedAction::PassThrough | ResolvedAction::FallbackToOriginal => {
167                fs::remove_file(self.root_path.join(path))
168            }
169            ResolvedAction::Redirect { replacement_path } => fs::remove_file(replacement_path),
170            ResolvedAction::HardErrorMissingFile => Err(io::Error::new(
171                io::ErrorKind::NotFound,
172                format!("'{rel_key}' is redirected by a mod in hard-error mode; nothing to delete"),
173            )),
174        }
175    }
176
177    /// Lists the file names directly under `rel_dir` (relative to the game
178    /// root, non-recursive) as the game process would actually see them,
179    /// hook-aware: real on-disk files are kept unless a matched mod rule
180    /// hides/replaces them (per
181    /// [`supreme_hook_config::HookResolver::should_hide_original_entry`]),
182    /// and any file a mod injects into `rel_dir` is added (per
183    /// [`supreme_hook_config::HookResolver::collect_mod_files_in_dir`]),
184    /// the same two decisions the game process's own directory-listing hook
185    /// makes. With no resolver attached, this is just the plain names of
186    /// the files physically present in `rel_dir`.
187    ///
188    /// A missing `rel_dir` is not an error: it's treated the same as an
189    /// empty one, since a mod can still inject files into a directory that
190    /// doesn't exist on disk at all.
191    ///
192    /// Result is sorted and deduplicated, so callers get a stable order
193    /// regardless of the OS's own directory-listing order or of a name
194    /// appearing both physically and (redundantly) in a mod's injection
195    /// list.
196    ///
197    /// # Errors
198    ///
199    /// Returns an error if `rel_dir` exists but cannot be read.
200    pub fn list_files(&self, rel_dir: &Path) -> io::Result<Vec<String>> {
201        let full_dir = self.root_path.join(rel_dir);
202
203        let mut names: Vec<String> = match fs::read_dir(&full_dir) {
204            Ok(entries) => entries
205                .filter_map(Result::ok)
206                .filter(|entry| entry.file_type().is_ok_and(|ft| ft.is_file()))
207                .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
208                .collect(),
209            Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(),
210            Err(e) => return Err(e),
211        };
212
213        if let Some(resolver) = &self.hook_resolver {
214            let rel_dir_key = rel_key_for(rel_dir).to_lowercase();
215            names.retain(|name| {
216                let rel_key = if rel_dir_key.is_empty() {
217                    name.to_lowercase()
218                } else {
219                    format!("{rel_dir_key}/{}", name.to_lowercase())
220                };
221                !resolver.should_hide_original_entry(&rel_key)
222            });
223            names.extend(resolver.collect_mod_files_in_dir(&rel_dir_key));
224        }
225
226        names.sort();
227        names.dedup();
228        Ok(names)
229    }
230}
231
232/// Converts a game-relative `Path` into the forward-slash `rel_key` string
233/// [`supreme_hook_config`] operates on, joining components explicitly
234/// rather than trusting `Path`'s own separator, `GameStorage` callers are
235/// expected to pass already-relative paths built with `/`-separated
236/// literals (as every existing call site and test in this crate already
237/// does), but this stays correct even if a path were built with
238/// platform-native separators on a non-Unix host.
239fn rel_key_for(path: &Path) -> String {
240    path.components()
241        .map(|c| c.as_os_str().to_string_lossy())
242        .collect::<Vec<_>>()
243        .join("/")
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::game_storage::GameStorage;
250    use std::fs;
251    use supreme_hook_config::{
252        MOD_FILE_NAME, ModConfig, ModsPreset, ModsState, RedirectionMode, RedirectionRule,
253        RuleTarget, save_mods_state,
254    };
255    use tempfile::tempdir;
256
257    /// Lays out `<root>/.supreme/mods/mods.json` + one mod's `mod.json`
258    /// with a single literal-glob rule redirecting `rel_glob` to
259    /// `target_rel_path` (relative to `root`), and returns a hook-aware
260    /// `GameStorage` over it.
261    fn make_hooked_storage(
262        root: &Path,
263        rel_glob: &str,
264        target_rel_path: &str,
265        mode: RedirectionMode,
266    ) -> GameStorage {
267        let mods_dir = root.join(".supreme/mods");
268        let mod_dir = mods_dir.join("test_mod");
269        fs::create_dir_all(&mod_dir).unwrap();
270        save_mods_state(
271            &mods_dir,
272            &ModsState {
273                active: ModsPreset {
274                    order: vec!["test_mod".to_string()],
275                    enabled: vec!["test_mod".to_string()],
276                    configuration: std::collections::HashMap::new(),
277                },
278                presets: std::collections::HashMap::new(),
279            },
280        )
281        .unwrap();
282
283        let config = ModConfig {
284            name: "Test mod".to_string(),
285            rules: vec![RedirectionRule {
286                glob_pattern: rel_glob.to_string(),
287                target: Some(RuleTarget::Path(PathBuf::from(target_rel_path))),
288                mode,
289                patches: Vec::new(),
290                enable_if: None,
291            }],
292            ..ModConfig::default()
293        };
294        fs::write(
295            mod_dir.join(MOD_FILE_NAME),
296            serde_json::to_string(&config).unwrap(),
297        )
298        .unwrap();
299
300        let (storage, skipped) =
301            GameStorage::with_hooks(root, &mods_dir, root.join(".supreme/tmp"));
302        assert!(skipped.is_empty(), "test mod should load cleanly");
303        storage
304    }
305
306    #[test]
307    fn without_hooks_read_write_behave_exactly_as_before() {
308        let dir = tempdir().unwrap();
309        let storage = GameStorage::new(dir.path());
310
311        storage
312            .write_text(Path::new("boards.txt"), "original list")
313            .unwrap();
314        assert_eq!(
315            storage.read_text(Path::new("boards.txt")).unwrap(),
316            "original list"
317        );
318        assert_eq!(
319            fs::read_to_string(dir.path().join("boards.txt")).unwrap(),
320            "original list"
321        );
322    }
323
324    #[test]
325    fn read_text_returns_mod_replacement_when_rule_matches() {
326        let dir = tempdir().unwrap();
327        fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
328        fs::write(
329            dir.path().join("modded_boards.txt"),
330            "vanilla boards\nnew snowboard",
331        )
332        .unwrap();
333
334        let storage = make_hooked_storage(
335            dir.path(),
336            "boards.txt",
337            "modded_boards.txt",
338            RedirectionMode::GracefulFallback,
339        );
340
341        assert_eq!(
342            storage.read_text(Path::new("boards.txt")).unwrap(),
343            "vanilla boards\nnew snowboard"
344        );
345    }
346
347    #[test]
348    fn read_text_falls_through_untouched_for_non_matching_paths() {
349        let dir = tempdir().unwrap();
350        fs::write(dir.path().join("other.txt"), "not redirected").unwrap();
351
352        let storage = make_hooked_storage(
353            dir.path(),
354            "boards.txt",
355            "modded_boards.txt",
356            RedirectionMode::GracefulFallback,
357        );
358
359        assert_eq!(
360            storage.read_text(Path::new("other.txt")).unwrap(),
361            "not redirected"
362        );
363    }
364
365    #[test]
366    fn write_text_is_transparently_forwarded_to_the_mod_target_not_the_original() {
367        let dir = tempdir().unwrap();
368        fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
369
370        let storage = make_hooked_storage(
371            dir.path(),
372            "boards.txt",
373            "modded_boards.txt",
374            RedirectionMode::GracefulFallback,
375        );
376
377        storage
378            .write_text(Path::new("boards.txt"), "vanilla boards\nedited board")
379            .unwrap();
380
381        // The edit landed on the mod's target file...
382        assert_eq!(
383            fs::read_to_string(dir.path().join("modded_boards.txt")).unwrap(),
384            "vanilla boards\nedited board"
385        );
386        // ...and the original game file was never touched.
387        assert_eq!(
388            fs::read_to_string(dir.path().join("boards.txt")).unwrap(),
389            "vanilla boards"
390        );
391        // Reading it back reflects the edit, same as the game would see.
392        assert_eq!(
393            storage.read_text(Path::new("boards.txt")).unwrap(),
394            "vanilla boards\nedited board"
395        );
396    }
397
398    #[test]
399    fn write_text_seeds_target_from_original_on_first_write() {
400        let dir = tempdir().unwrap();
401        fs::write(dir.path().join("boards.txt"), "board a\nboard b").unwrap();
402
403        let storage = make_hooked_storage(
404            dir.path(),
405            "boards.txt",
406            "modded_boards.txt",
407            RedirectionMode::GracefulFallback,
408        );
409
410        // First write seeds the target from the original before landing
411        // there, the target must not exist yet for this to be meaningful.
412        assert!(!dir.path().join("modded_boards.txt").exists());
413        storage
414            .write_text(Path::new("boards.txt"), "board a\nboard b\nboard c")
415            .unwrap();
416
417        assert_eq!(
418            fs::read_to_string(dir.path().join("modded_boards.txt")).unwrap(),
419            "board a\nboard b\nboard c"
420        );
421    }
422
423    #[test]
424    fn hard_error_mode_blocks_read_when_replacement_is_missing() {
425        let dir = tempdir().unwrap();
426        fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
427        // Note: modded_boards.txt is intentionally never created.
428
429        let storage = make_hooked_storage(
430            dir.path(),
431            "boards.txt",
432            "modded_boards.txt",
433            RedirectionMode::HardError,
434        );
435
436        let result = storage.read_text(Path::new("boards.txt"));
437        assert!(result.is_err());
438        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
439    }
440
441    #[test]
442    fn exists_reflects_mod_replacement() {
443        let dir = tempdir().unwrap();
444        fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
445
446        let storage = make_hooked_storage(
447            dir.path(),
448            "boards.txt",
449            "modded_boards.txt",
450            RedirectionMode::HardError,
451        );
452
453        // hard_error + missing replacement => reported as not existing.
454        assert!(!storage.exists(Path::new("boards.txt")));
455
456        fs::write(dir.path().join("modded_boards.txt"), "modded").unwrap();
457        assert!(storage.exists(Path::new("boards.txt")));
458    }
459
460    #[test]
461    fn remove_file_removes_the_mod_target_not_the_original() {
462        let dir = tempdir().unwrap();
463        fs::write(dir.path().join("boards.txt"), "vanilla boards").unwrap();
464        fs::write(dir.path().join("modded_boards.txt"), "modded").unwrap();
465
466        let storage = make_hooked_storage(
467            dir.path(),
468            "boards.txt",
469            "modded_boards.txt",
470            RedirectionMode::GracefulFallback,
471        );
472
473        storage.remove_file(Path::new("boards.txt")).unwrap();
474
475        assert!(!dir.path().join("modded_boards.txt").exists());
476        assert!(
477            dir.path().join("boards.txt").exists(),
478            "original file must survive a redirected delete"
479        );
480    }
481
482    #[test]
483    fn rel_key_for_joins_components_with_forward_slashes() {
484        assert_eq!(
485            rel_key_for(Path::new("data/levels/level1.dat")),
486            "data/levels/level1.dat"
487        );
488        assert_eq!(rel_key_for(Path::new("boards.txt")), "boards.txt");
489    }
490
491    // --- list_files ---
492
493    /// Lays out `<root>/.supreme/mods/mods.json` + one mod's `mod.json`
494    /// with a single directory-scanning wildcard rule redirecting
495    /// everything under `rel_glob_dir` to `target_dir` (relative to
496    /// `root`), and returns a hook-aware `GameStorage` over it. Unlike
497    /// `make_hooked_storage` above, `target_dir` is a directory to scan
498    /// (via `HookResolver::collect_mod_files_in_dir`), not a single
499    /// replacement file.
500    fn make_hooked_storage_with_dir_rule(
501        root: &Path,
502        rel_glob_dir: &str,
503        target_dir: &str,
504        mode: RedirectionMode,
505    ) -> GameStorage {
506        let mods_dir = root.join(".supreme/mods");
507        let mod_dir = mods_dir.join("test_mod");
508        fs::create_dir_all(&mod_dir).unwrap();
509        save_mods_state(
510            &mods_dir,
511            &ModsState {
512                active: ModsPreset {
513                    order: vec!["test_mod".to_string()],
514                    enabled: vec!["test_mod".to_string()],
515                    configuration: std::collections::HashMap::new(),
516                },
517                presets: std::collections::HashMap::new(),
518            },
519        )
520        .unwrap();
521
522        let config = ModConfig {
523            name: "Test mod".to_string(),
524            rules: vec![RedirectionRule {
525                glob_pattern: format!("{rel_glob_dir}/**"),
526                target: Some(RuleTarget::Path(PathBuf::from(target_dir))),
527                mode,
528                patches: Vec::new(),
529                enable_if: None,
530            }],
531            ..ModConfig::default()
532        };
533        fs::write(
534            mod_dir.join(MOD_FILE_NAME),
535            serde_json::to_string(&config).unwrap(),
536        )
537        .unwrap();
538
539        let (storage, skipped) =
540            GameStorage::with_hooks(root, &mods_dir, root.join(".supreme/tmp"));
541        assert!(skipped.is_empty(), "test mod should load cleanly");
542        storage
543    }
544
545    #[test]
546    fn list_files_without_hooks_lists_real_files_only() {
547        let dir = tempdir().unwrap();
548        fs::create_dir_all(dir.path().join("renderers")).unwrap();
549        fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"").unwrap();
550        fs::write(dir.path().join("renderers/srDD_DirectX6.dll"), b"").unwrap();
551
552        let storage = GameStorage::new(dir.path());
553        let mut files = storage.list_files(Path::new("renderers")).unwrap();
554        files.sort();
555        assert_eq!(files, vec!["srDD_DirectX6.dll", "srDD_OpenGL.dll"]);
556    }
557
558    #[test]
559    fn list_files_on_missing_directory_returns_empty_ok() {
560        let dir = tempdir().unwrap();
561        let storage = GameStorage::new(dir.path());
562        assert_eq!(
563            storage.list_files(Path::new("does_not_exist")).unwrap(),
564            Vec::<String>::new()
565        );
566    }
567
568    #[test]
569    fn list_files_hides_original_under_hard_error_with_no_replacement() {
570        // hard_error hides the moment the rule matches, whether or not a
571        // replacement exists yet (same contract as a direct read/write,
572        // see `HookResolver::should_hide_original_entry`). With no target
573        // file ever created, the literal-glob "inject the target's own
574        // name" branch in `collect_mod_files_in_dir` never fires either
575        // (it requires the target to exist), so the renderer genuinely
576        // disappears from the listing rather than reappearing under a
577        // different name.
578        let dir = tempdir().unwrap();
579        fs::create_dir_all(dir.path().join("renderers")).unwrap();
580        fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
581        // modded_opengl.dll intentionally never created.
582
583        let storage = make_hooked_storage(
584            dir.path(),
585            "renderers/srDD_OpenGL.dll",
586            "modded_opengl.dll",
587            RedirectionMode::HardError,
588        );
589
590        assert_eq!(
591            storage.list_files(Path::new("renderers")).unwrap(),
592            Vec::<String>::new(),
593        );
594    }
595
596    #[test]
597    fn list_files_shows_a_same_named_replacement_exactly_once() {
598        // The realistic modding convention: a literal-glob rule's target
599        // is named the same as what it replaces. The original is hidden
600        // (a real replacement exists) and the replacement is injected
601        // under that same name -- net effect: the renderer is still
602        // listed exactly once, now backed by the mod's file instead of
603        // the vanilla one.
604        let dir = tempdir().unwrap();
605        fs::create_dir_all(dir.path().join("renderers")).unwrap();
606        fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
607        fs::create_dir_all(dir.path().join("mod_target")).unwrap();
608        fs::write(dir.path().join("mod_target/srDD_OpenGL.dll"), b"modded").unwrap();
609
610        let storage = make_hooked_storage(
611            dir.path(),
612            "renderers/srDD_OpenGL.dll",
613            "mod_target/srDD_OpenGL.dll",
614            RedirectionMode::GracefulFallback,
615        );
616
617        assert_eq!(
618            storage.list_files(Path::new("renderers")).unwrap(),
619            vec!["srDD_OpenGL.dll"],
620        );
621    }
622
623    #[test]
624    fn list_files_keeps_original_visible_when_graceful_fallback_replacement_is_missing() {
625        let dir = tempdir().unwrap();
626        fs::create_dir_all(dir.path().join("renderers")).unwrap();
627        fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
628        // modded_opengl.dll intentionally never created.
629
630        let storage = make_hooked_storage(
631            dir.path(),
632            "renderers/srDD_OpenGL.dll",
633            "modded_opengl.dll",
634            RedirectionMode::GracefulFallback,
635        );
636
637        assert_eq!(
638            storage.list_files(Path::new("renderers")).unwrap(),
639            vec!["srDD_OpenGL.dll"]
640        );
641    }
642
643    #[test]
644    fn list_files_includes_files_a_mod_injects_into_the_directory() {
645        let dir = tempdir().unwrap();
646        fs::create_dir_all(dir.path().join("renderers")).unwrap();
647        fs::write(dir.path().join("renderers/srDD_OpenGL.dll"), b"vanilla").unwrap();
648        fs::create_dir_all(dir.path().join("mod_renderers")).unwrap();
649        fs::write(
650            dir.path().join("mod_renderers/srDD_Vulkan.dll"),
651            b"injected by mod",
652        )
653        .unwrap();
654
655        let storage = make_hooked_storage_with_dir_rule(
656            dir.path(),
657            "renderers",
658            "mod_renderers",
659            RedirectionMode::GracefulFallback,
660        );
661
662        assert_eq!(
663            storage.list_files(Path::new("renderers")).unwrap(),
664            vec!["srDD_OpenGL.dll", "srDD_Vulkan.dll"],
665            "a mod-injected file must show up in the listing alongside the \
666             untouched original"
667        );
668    }
669}