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}