Skip to main content

supreme_hook_config/
hook_config.rs

1//! The merged, resolved redirection config the proxy DLL actually
2//! consumes: [`HookConfig`] (a flat list of [`RedirectionRule`]s) and
3//! [`ResolvedAction`], the outcome of resolving one requested path against
4//! it. Distinct from [`crate::mod_config::ModConfig`], which is the
5//! per-mod *authored* (possibly conditional/templated) shape, see
6//! [`ModConfig::resolved_rules`](crate::mod_config::ModConfig::resolved_rules)
7//! for how one becomes the other.
8
9use crate::error::HookConfigError;
10use crate::mod_config::ModConfig;
11use crate::mods_state::MOD_FILE_NAME;
12use crate::patch::MemoryPatch;
13use glob::{MatchOptions, Pattern};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18/// Options passed to [`glob::Pattern::matches_with`] for every rule
19/// lookup: case-insensitive (Windows paths), and `*` bounded to a single
20/// path component while a bare `**` component still matches recursively
21/// (the crate special-cases that regardless of `require_literal_separator`).
22/// On Windows, `glob`'s separator handling (via `std::path::is_separator`)
23/// already treats both `/` and `\` as separators natively, so there's no
24/// need to normalize paths ourselves before matching.
25pub(crate) const MATCH_OPTIONS: MatchOptions = MatchOptions {
26    case_sensitive: false,
27    require_literal_separator: true,
28    require_literal_leading_dot: false,
29};
30
31/// The full set of redirection rules the proxy DLL should apply for a
32/// single game session, already merged across every enabled mod. See
33/// [`HookConfig::load_from_mods_dir`].
34#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
35pub struct HookConfig {
36    pub rules: Vec<RedirectionRule>,
37}
38
39/// Where a rule's replacement content lives.
40///
41/// A rule's `target` is either a plain local path (the classic string
42/// form, a plain redirect) or a struct describing a **remote-backed
43/// managed file**: the mod declares *what* the file is and *where its
44/// fresh content comes from*, and the proxy DLL's generic managed-file
45/// engine takes over from there, no per-mod special-casing inside the
46/// DLL.
47///
48/// ```json
49/// "target": ".supreme/mods/mymod/data/file.txt"              // classic
50/// "target": {                                                // managed
51///     "local":  ".supreme/mods/mymod/high_times/file.txt",
52///     "remote": "https://host/ssbz/index.php?action=room_high_times_file&id=${room}",
53///     "poll_interval_secs": 10,
54///     "push_url": "https://host/ssbz/index.php?action=room_high_times_file&id=${room}"
55/// }
56/// ```
57#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
58#[serde(untagged)]
59pub enum RuleTarget {
60    /// Plain local redirect target, the original, classic form. Relative
61    /// paths are interpreted against the game root (joined at resolver
62    /// load), exactly as before.
63    Path(PathBuf),
64    /// Remote-backed managed file. `local` is the physical file the
65    /// redirect serves (and the only thing the redirect machinery ever
66    /// touches); `remote` is the URL whose body IS the file content (a
67    /// plain HTTP GET, response bytes used verbatim); `poll_interval_secs`
68    /// is the auto-refresh cadence once the game has accessed the file at
69    /// least once, `None`/absent means no polling (fetch only on access);
70    /// `push_url` is where every game write of the file is POSTed to
71    /// `None`/absent means the file is fetch-only (writes stay
72    /// local and are never uploaded).
73    ///
74    /// All three strings (`local`, `remote`, `push_url`) go through the
75    /// same `${...}` interpolation as every other rule string, so a mod can
76    /// wire selection-driven values (`${tournament_room_id}`,
77    /// `${url_encode(tournament_player)}`, …) into the URLs. `remote` and
78    /// `push_url` routinely name the same endpoint (GET = fetch the
79    /// canonical content, POST = push a game-written file), the two
80    /// properties exist so a mod author can split the directions across
81    /// different URLs when their backend wants that.
82    Managed {
83        local: PathBuf,
84        remote: String,
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        poll_interval_secs: Option<u64>,
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        push_url: Option<String>,
89    },
90}
91
92impl RuleTarget {
93    /// The local path this target resolves to (the redirect machinery's
94    /// only touch point). For a managed target that is the `local` field.
95    #[must_use]
96    pub fn path(&self) -> &Path {
97        match self {
98            RuleTarget::Path(p) => p,
99            RuleTarget::Managed { local, .. } => local,
100        }
101    }
102
103    /// The managed half of this target, if it is one: `(remote URL,
104    /// poll interval in seconds, push URL)`.
105    #[must_use]
106    pub fn managed(&self) -> Option<(&str, Option<u64>, Option<&str>)> {
107        match self {
108            RuleTarget::Path(_) => None,
109            RuleTarget::Managed {
110                remote,
111                poll_interval_secs,
112                push_url,
113                ..
114            } => Some((remote, *poll_interval_secs, push_url.as_deref())),
115        }
116    }
117}
118
119/// A single glob-based redirection rule.
120#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
121pub struct RedirectionRule {
122    /// Glob pattern matched against the file path the game requests,
123    /// relative to the game's root directory (e.g. `data/levels/*.dat`).
124    pub glob_pattern: String,
125    /// The replacement asset for files matching the glob, relative to the
126    /// game's root directory unless absolute, either a plain local path
127    /// ([`RuleTarget::Path`], the classic string form) or a remote-backed
128    /// managed file ([`RuleTarget::Managed`], struct form; the local path
129    /// plus where fresh content comes from and how often to refresh it).
130    /// Omittable, `None` means no replacement is configured yet (e.g. a
131    /// rule scaffolded ahead of the asset being dropped in), and is
132    /// treated exactly like "the replacement doesn't exist": `GracefulFallback`
133    /// mode passes through to the original, `HardError` mode blocks the
134    /// operation. When present, its meaning depends on the glob's shape,
135    /// see [`RedirectionRule::is_literal_glob`]:
136    /// - **Wildcard glob** (contains `*`, `?`, or `[`, e.g.
137    ///   `data/levels/*.dat` or `mods_source/**`): the target's local path
138    ///   is a *directory*. The portion of the matched path below the
139    ///   glob's fixed prefix is joined onto it, preserving relative
140    ///   structure.
141    /// - **Literal glob** (no wildcard at all, e.g.
142    ///   `Saved_Data/high_times.txt`): the rule can only ever match that
143    ///   one exact path, so the target's local path is the replacement
144    ///   *file* itself, used as-is, nothing is joined onto it. A managed
145    ///   target additionally requires a literal glob (one remote URL can
146    ///   only name one file), enforced by [`ModConfig::validate`].
147    #[serde(default)]
148    pub target: Option<RuleTarget>,
149    pub mode: RedirectionMode,
150    /// Content modifications applied to the replacement file's bytes
151    /// before they're written to the temp redirection target. Empty by
152    /// default: most rules only redirect, they don't also patch bytes.
153    #[serde(default)]
154    pub patches: Vec<MemoryPatch>,
155    /// References a [`crate::mod_config::ModParameter::id`] within the same mod. When
156    /// present, this rule only participates in [`ModConfig::resolved_rules`]
157    /// if that parameter's current value is "on", a `true` boolean, or a
158    /// non-empty dropdown selection (see [`ModConfig`] for the full
159    /// picture). `None` means the rule is unconditionally active.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub enable_if: Option<String>,
162}
163
164impl RedirectionRule {
165    /// True if `glob_pattern` contains no wildcard characters (`*`, `?`,
166    /// `[`), meaning it can only ever match exactly one path. Such rules
167    /// treat `target` as the replacement file directly rather than a
168    /// directory to join a matched suffix into, see the field docs on
169    /// [`RedirectionRule::target`].
170    #[must_use]
171    pub fn is_literal_glob(&self) -> bool {
172        !self.glob_pattern.contains(['*', '?', '['])
173    }
174}
175
176#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
177#[serde(rename_all = "snake_case")]
178pub enum RedirectionMode {
179    /// If the redirected file doesn't exist, silently open the original.
180    GracefulFallback,
181    /// If the redirected file doesn't exist, fail the open with a
182    /// "file not found" style error instead of touching the original.
183    HardError,
184}
185
186/// What the hook should do for a given requested path, once a rule has
187/// been matched (or not) and the existence of the replacement asset has
188/// been checked.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum ResolvedAction {
191    /// No rule matched this path; pass the call straight through.
192    PassThrough,
193    /// A rule matched and the replacement asset exists at this path;
194    /// the caller should stage a patched temp file and redirect there.
195    Redirect { replacement_path: PathBuf },
196    /// A rule matched in `GracefulFallback` mode but the replacement
197    /// asset is missing; pass through to the original file.
198    FallbackToOriginal,
199    /// A rule matched in `HardError` mode but the replacement asset is
200    /// missing; the caller should fail the open outright.
201    HardErrorMissingFile,
202}
203
204impl HookConfig {
205    /// Loads a `HookConfig` from a single JSON file on disk (a plain
206    /// merged/resolved rule set, for a single mod's authored `mod.json`,
207    /// use [`ModConfig::load_from_path`] instead).
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if the file cannot be read or does not contain
212    /// valid JSON matching the expected shape.
213    pub fn load_from_path(path: &Path) -> Result<Self, HookConfigError> {
214        let bytes = std::fs::read(path).map_err(|source| HookConfigError::Read {
215            path: path.to_path_buf(),
216            source,
217        })?;
218        Ok(serde_json::from_slice(&bytes)?)
219    }
220
221    /// Serializes and writes this `HookConfig` to a JSON file on disk,
222    /// creating parent directories as needed.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if parent directories can't be created, the file
227    /// can't be written, or serialization fails.
228    pub fn write_to_path(&self, path: &Path) -> Result<(), HookConfigError> {
229        if let Some(parent) = path.parent() {
230            std::fs::create_dir_all(parent).map_err(|source| HookConfigError::Write {
231                path: path.to_path_buf(),
232                source,
233            })?;
234        }
235        let json = serde_json::to_vec_pretty(self)?;
236        std::fs::write(path, json).map_err(|source| HookConfigError::Write {
237            path: path.to_path_buf(),
238            source,
239        })
240    }
241
242    /// Loads and merges every enabled mod's rules from a mods directory
243    /// laid out as described on
244    /// [`crate::mods_state::DEFAULT_MODS_DIR_NAME`].
245    ///
246    /// Each mod's rules are filtered and interpolated through
247    /// [`ModConfig::resolved_rules`] before merging, by the time a rule
248    /// reaches this function's result, any `enable_if` gating and
249    /// `${param_id}` interpolation have already been resolved against that
250    /// mod's own `configuration`. Mods are then merged by simple
251    /// concatenation of their (already-resolved) rule lists, in
252    /// [`crate::mods_state::ModsPreset::priority_order`] order (of
253    /// [`crate::mods_state::ModsState::active`]), the same order the
254    /// launcher's mod list displays, since for the active configuration
255    /// display order and merge priority are the same list (unlike a
256    /// saved, not-currently-applied preset in
257    /// [`crate::mods_state::ModsState::presets`], which freezes its own
258    /// order separately until it's applied, see
259    /// [`crate::mods_state::ModsState::apply_preset`]). Since rule
260    /// resolver scans that concatenated list in order: patch-only matches
261    /// accumulate across mods, whereas a usable redirect starts a new
262    /// stage at the redirected path and scans the entire list from the
263    /// beginning. Thus the first successful conflicting redirect wins at
264    /// its current stage, but later mods can still patch the same path or
265    /// continue a chain at a redirected path. A mod's rules stay
266    /// contiguous and in their authored order.
267    ///
268    /// A mod in the active priority order whose `mod.json` is missing,
269    /// fails to parse, or fails [`ModConfig::validate`] is skipped
270    /// entirely (none of its rules are applied) rather than failing the
271    /// whole load, one broken or since-removed mod shouldn't take every
272    /// other enabled mod down with it. Validation failing here matches
273    /// what the launcher's own mod list shows as a broken/red entry, so a
274    /// mod that a person can see is broken never has some of its rules
275    /// quietly applied anyway. Skipped mods are returned alongside the
276    /// merged config so the caller can log them; this crate takes no
277    /// logging dependency of its own.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if `mods.json` can't be read (including if
282    /// it's missing, unlike
283    /// [`load_mods_state`](crate::mods_state::load_mods_state), this
284    /// function requires it to exist) or doesn't parse.
285    pub fn load_from_mods_dir(
286        mods_dir: &Path,
287    ) -> Result<(Self, Vec<(String, HookConfigError)>), HookConfigError> {
288        let state_path = mods_dir.join(crate::mods_state::MODS_STATE_FILE_NAME);
289        let bytes = std::fs::read(&state_path).map_err(|source| HookConfigError::Read {
290            path: state_path.clone(),
291            source,
292        })?;
293        let state: crate::mods_state::ModsState = serde_json::from_slice(&bytes)?;
294        let mod_names: Vec<String> = state
295            .active
296            .priority_order()
297            .into_iter()
298            .map(str::to_string)
299            .collect();
300        let configurations = state.active.configuration;
301        let empty_configuration = HashMap::new();
302
303        let mut merged = HookConfig::default();
304        let mut skipped = Vec::new();
305
306        for mod_name in mod_names {
307            let cfg_path = mods_dir.join(&mod_name).join(MOD_FILE_NAME);
308            match ModConfig::load_from_path(&cfg_path) {
309                Ok(mod_config) => match mod_config.validate() {
310                    Ok(()) => {
311                        let configuration = configurations
312                            .get(&mod_name)
313                            .unwrap_or(&empty_configuration);
314                        merged
315                            .rules
316                            .extend(mod_config.resolved_rules(configuration));
317                    }
318                    Err(errors) => skipped.push((
319                        mod_name,
320                        HookConfigError::Invalid {
321                            path: cfg_path,
322                            reasons: errors.join("; "),
323                        },
324                    )),
325                },
326                Err(e) => skipped.push((mod_name, e)),
327            }
328        }
329
330        Ok((merged, skipped))
331    }
332
333    /// Finds the first rule whose glob matches `requested_path`.
334    ///
335    /// `requested_path` should be relative to the game's root directory
336    /// (callers are responsible for stripping the game root and
337    /// normalizing separators before calling this).
338    #[must_use]
339    pub fn find_matching_rule(&self, requested_path: &str) -> Option<&RedirectionRule> {
340        let normalized = requested_path.replace('\\', "/");
341        self.rules.iter().find(|rule| {
342            Pattern::new(&rule.glob_pattern)
343                .is_ok_and(|pattern| pattern.matches_with(&normalized, MATCH_OPTIONS))
344        })
345    }
346
347    /// Full resolution: given a requested path (relative to the game
348    /// root) and a way to check whether a candidate replacement file
349    /// exists, decides what the hook should do.
350    ///
351    /// For a wildcard rule, the path *below* the rule's fixed
352    /// (non-wildcard) prefix is preserved when joining onto `target`, not
353    /// just the file's basename. This matters for directory-tree rules
354    /// such as `mods_source/**`: a request for
355    /// `mods_source/textures/snow/ice.dds` resolves under `target` as
356    /// `.../textures/snow/ice.dds`, keeping the same relative layout,
357    /// rather than flattening every match into a single directory. For a
358    /// flat, single-component pattern like `data/levels/*.dat` this still
359    /// reduces to just the file's basename, since there's no subdirectory
360    /// portion to keep.
361    ///
362    /// For a literal (non-wildcard) rule, `target` is used directly with
363    /// nothing joined onto it, see [`RedirectionRule::is_literal_glob`].
364    ///
365    /// If `target` is omitted (`None`), this behaves exactly as if a
366    /// configured replacement simply didn't exist on disk: `file_exists`
367    /// is never called, and the outcome is `FallbackToOriginal` or
368    /// `HardErrorMissingFile` per the rule's `mode`.
369    ///
370    /// `file_exists` is injected rather than calling `std::fs` directly so
371    /// this stays testable without touching a real filesystem, and so the
372    /// proxy DLL can plug in whatever existence check makes sense for its
373    /// context (e.g. going through the real `NtQueryAttributesFile`
374    /// trampoline instead of `std::fs`, to avoid recursing into itself).
375    #[must_use]
376    pub fn resolve(
377        &self,
378        requested_path: &str,
379        file_exists: impl FnOnce(&Path) -> bool,
380    ) -> ResolvedAction {
381        let normalized = requested_path.replace('\\', "/");
382        let Some(rule) = self.find_matching_rule(&normalized) else {
383            return ResolvedAction::PassThrough;
384        };
385
386        let missing = || match rule.mode {
387            RedirectionMode::GracefulFallback => ResolvedAction::FallbackToOriginal,
388            RedirectionMode::HardError => ResolvedAction::HardErrorMissingFile,
389        };
390
391        let Some(target) = &rule.target else {
392            return missing();
393        };
394
395        let replacement_path = if rule.is_literal_glob() {
396            target.path().to_path_buf()
397        } else {
398            let suffix = matched_suffix(&rule.glob_pattern, &normalized);
399            target.path().join(suffix)
400        };
401
402        log::debug!("Probing for replacement: {}", replacement_path.display());
403
404        if file_exists(&replacement_path) {
405            ResolvedAction::Redirect { replacement_path }
406        } else {
407            missing()
408        }
409    }
410}
411
412/// Returns the portion of `requested_path` that falls *below* the rule's
413/// fixed (non-wildcard) directory prefix, so directory-tree rules can
414/// preserve relative structure instead of collapsing every match down to
415/// its basename.
416///
417/// The "fixed prefix" is everything in `glob_pattern` up to (and
418/// including) the last path separator before the first wildcard
419/// character (`*`, `?`, or `[`). For `data/levels/*.dat` that's
420/// `data/levels/`; for `mods_source/**` that's `mods_source/`. Note this
421/// is plain string bookkeeping, not glob matching, the `glob` crate only
422/// answers yes/no on a match and doesn't expose which part of the path a
423/// wildcard consumed, so there's nothing to reuse from it here.
424///
425/// Only meaningful for wildcard rules, callers should check
426/// [`RedirectionRule::is_literal_glob`] first, since a literal glob has no
427/// wildcard prefix boundary to compute here.
428pub(crate) fn matched_suffix<'a>(glob_pattern: &str, requested_path: &'a str) -> &'a str {
429    let normalized_pattern = glob_pattern.replace('\\', "/");
430    let wildcard_idx = normalized_pattern
431        .find(['*', '?', '['])
432        .unwrap_or(normalized_pattern.len());
433    let prefix_end = normalized_pattern[..wildcard_idx]
434        .rfind('/')
435        .map_or(0, |i| i + 1);
436    let prefix_lower = normalized_pattern[..prefix_end].to_lowercase();
437
438    // Case-insensitive prefix check, to match `find_matching_rule`'s
439    // contract (see `matching_is_case_insensitive`), rule authors write
440    // patterns in whatever case reads naturally (`Data/Levels/...`), while
441    // `rel_key`s built from live NT paths are always lowercased. A plain
442    // byte-prefix check fails on that mismatch alone and used to fall back
443    // to the bare basename, silently discarding the matched subdirectory
444    // structure for every rule whose pattern wasn't authored all-lowercase.
445    //
446    // Compare against lowercased copies to find the prefix length, then
447    // slice the *original* `requested_path` at that byte offset so the
448    // returned suffix keeps whatever casing the caller's rel_key actually
449    // has. `to_lowercase()` is byte-length-preserving for the ASCII
450    // separators/drive-letters/extensions these paths are built from, so
451    // the offset found in the lowercased copy lines up with the original;
452    // `requested_path.get(..)` still guards against a non-ASCII edge case
453    // landing the offset off a UTF-8 boundary, falling back to the
454    // basename exactly as before rather than panicking or mis-slicing.
455    let requested_lower = requested_path.to_lowercase();
456    let prefix_matches = requested_lower
457        .get(..prefix_end)
458        .is_some_and(|head| head == prefix_lower);
459
460    prefix_matches
461        .then(|| requested_path.get(prefix_end..))
462        .flatten()
463        .unwrap_or_else(|| {
464            Path::new(requested_path)
465                .file_name()
466                .and_then(|f| f.to_str())
467                .unwrap_or(requested_path)
468        })
469}