Skip to main content

supreme_hook_config/
mod_config.rs

1//! The per-mod *authored* shape: [`ModConfig`] (one mod's `mod.json`,
2//! metadata, conditional/templated rules, and the parameters it exposes),
3//! plus the `${param_id}` substitution/gating logic that turns it into the
4//! plain [`crate::hook_config::RedirectionRule`]s
5//! [`crate::hook_config::HookConfig::load_from_mods_dir`] merges across
6//! mods.
7
8use crate::error::HookConfigError;
9use crate::hook_config::{RedirectionRule, RuleTarget};
10use crate::patch::{MemoryPatch, ScriptSource, StringReplacement};
11use crate::script_engine;
12use crate::ui::ModUi;
13use serde::{Deserialize, Serialize};
14use std::collections::{HashMap, HashSet};
15use std::fs::read_to_string;
16use std::path::{Path, PathBuf};
17
18/// The on-disk shape of a single mod's `mod.json`: metadata, the mod's
19/// authored (possibly conditional/templated) rules, the parameters it
20/// exposes for configuration, and default values for some or all of them.
21/// Holds no *per-user* values, though, those live separately in
22/// [`crate::mods_state::ModConfigurations`], keyed by mod directory name, layered on top of
23/// these defaults at resolution time (see
24/// [`ModConfig::effective_configuration`]). That split means re-installing
25/// or updating a mod (replacing this whole file, defaults included) can
26/// never take a person's own overrides for it along with it.
27///
28/// This is distinct from [`crate::hook_config::HookConfig`], which is the *merged, resolved*
29/// shape the proxy DLL actually consumes, see [`ModConfig::resolved_rules`]
30/// for how one becomes the other, given a mod's current overrides.
31#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
32pub struct ModConfig {
33    /// Mandatory, also what's shown in the launcher's mod list.
34    pub name: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub author: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub license: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub version: Option<String>,
43
44    #[serde(default)]
45    pub rules: Vec<RedirectionRule>,
46    #[serde(default)]
47    pub parameters: Vec<ModParameter>,
48    /// Default value for some or all of `parameters`, keyed by
49    /// [`ModParameter::id`]. A parameter with no entry here, and no
50    /// override in [`crate::mods_state::ModConfigurations`], reads as fully unset: off for
51    /// gating, empty string for `${param_id}` interpolation. Author-owned
52    /// and distributed alongside `rules`, unlike the per-user overrides
53    /// in [`crate::mods_state::ModConfigurations`].
54    #[serde(default)]
55    pub configuration: HashMap<String, serde_json::Value>,
56    /// Optional launcher-UI schema, buttons shown for this mod and the
57    /// popups they open, rendered generically by the Mods page (see
58    /// [`crate::ui::ModUi`]). `None` for mods without a `ui` section:
59    /// they keep the plain parameter editor only, and every pre-`ui`
60    /// `mod.json` stays fully valid unchanged.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub ui: Option<ModUi>,
63}
64
65/// One configurable setting a mod exposes to the launcher's Mods page.
66#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
67pub struct ModParameter {
68    /// Stable identifier, referenced from [`RedirectionRule::enable_if`],
69    /// `${param_id}` interpolation placeholders, and as a key into both
70    /// this mod's own [`ModConfig::configuration`] defaults and its entry
71    /// in [`crate::mods_state::ModConfigurations`].
72    pub id: String,
73    /// Display name shown in the launcher UI.
74    pub name: String,
75    /// Shown as a tooltip/help text next to the control in the launcher UI.
76    pub help_text: String,
77    #[serde(flatten)]
78    pub kind: ParameterKind,
79    /// Optional Rhai expression that must evaluate to `true` for this
80    /// parameter to be editable in the launcher UI. When `false` (or when
81    /// evaluation fails), the parameter falls back to its default value
82    /// from [`ModConfig::configuration`] (or the type-appropriate zero
83    /// value if no default exists). Other parameters' `enable_if`
84    /// expressions and rule `enable_if` expressions may still reference
85    /// this parameter, gating only affects the UI control, not the
86    /// parameter's availability in the evaluation scope.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub enable_if: Option<String>,
89    /// hide this parameter from the launcher's parameter editors
90    /// entirely. Hidden parameters keep EVERY other behavior (validation,
91    /// `enable_if` scope, `${...}` interpolation, configuration merge,
92    /// launcher-written overrides), they are just not user-facing
93    /// controls. The online-tournament mod declares its
94    /// `tournament_env` / `tournament_difficulty` /
95    /// `tournament_room_id` this way: the launcher sets them when the
96    /// player creates or joins a room; a user has no business editing them.
97    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
98    pub hidden: bool,
99    /// Optional name of a shared validation rule set (see
100    /// [`crate::validation`]) the launcher enforces live while the user
101    /// edits this parameter: invalid input renders an error stroke and a
102    /// warning icon whose hover carries the reason. Known tags:
103    /// `player_name`, `room_name`; unknown tags fail
104    /// [`ModConfig::validate`]. Only meaningful on text parameters.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub validation: Option<String>,
107}
108
109/// The type of control a [`ModParameter`] is edited with, and the data
110/// that type needs. Serializes flat alongside `id`/`name`/`help_text` via
111/// `#[serde(flatten)]` on [`ModParameter::kind`], tagged by a `"type"`
112/// field, e.g.:
113///
114/// ```json
115/// { "id": "Fov_Boost", "name": "FOV boost", "help_text": "...",
116///   "type": "number", "min": 0.0, "max": 20.0, "step": 0.5 }
117/// ```
118#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
119#[serde(tag = "type", rename_all = "snake_case")]
120pub enum ParameterKind {
121    /// A checkbox. May gate a [`RedirectionRule::enable_if`].
122    Boolean,
123    /// A dropdown among `choices`. May gate a [`RedirectionRule::enable_if`]
124    /// (empty-string selection = off). "(None)" is *not* implicit, a
125    /// dropdown only has a "no selection" state if `""` is itself one of
126    /// `choices`; otherwise every choice is non-empty and the parameter is
127    /// always "on" for gating purposes.
128    Dropdown { choices: Vec<String> },
129    /// A numeric slider between `min` and `max`, with an optional step.
130    /// Cannot gate a rule, only usable via `${param_id}` interpolation.
131    Number {
132        min: f32,
133        max: f32,
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        step: Option<f32>,
136    },
137    /// A free-text field. Cannot gate a rule, only usable via
138    /// `${param_id}` interpolation.
139    Text,
140}
141
142impl ModParameter {
143    /// Structural validation, independent of any other parameter or rule,
144    /// called from [`ModConfig::validate`] so a malformed `mod.json`
145    /// surfaces as a clear per-mod error at load time.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error describing the problem if this parameter's data is
150    /// internally inconsistent (e.g. a dropdown with no choices, or a
151    /// number range with `min > max`).
152    pub fn validate(&self) -> Result<(), String> {
153        match &self.kind {
154            ParameterKind::Dropdown { choices } if choices.is_empty() => Err(format!(
155                "parameter '{}': dropdown needs at least one choice",
156                self.id
157            )),
158            ParameterKind::Number { min, max, .. } if min > max => {
159                Err(format!("parameter '{}': min must be <= max", self.id))
160            }
161            _ => Ok(()),
162        }
163    }
164}
165
166impl ModConfig {
167    /// Loads a `ModConfig` from a single JSON file on disk (one mod's
168    /// `mod.json`).
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if the file cannot be read or does not contain
173    /// valid JSON matching the expected shape.
174    pub fn load_from_path(path: &Path) -> Result<Self, HookConfigError> {
175        let bytes = std::fs::read(path).map_err(|source| HookConfigError::Read {
176            path: path.to_path_buf(),
177            source,
178        })?;
179        Ok(serde_json::from_slice(&bytes)?)
180    }
181
182    /// Like [`Self::load_from_path`], but also returns warnings about
183    /// unknown keys in the JSON file. Keys prefixed with `_` are
184    /// considered intentional private metadata and are never warned about.
185    /// Unknown keys at any nesting level are reported with their path
186    /// (e.g. `"unknown key 'future_field' at parameters[1]"`).
187    pub fn load_from_path_with_warnings(
188        path: &Path,
189    ) -> Result<(Self, Vec<String>), HookConfigError> {
190        let bytes = std::fs::read(path).map_err(|source| HookConfigError::Read {
191            path: path.to_path_buf(),
192            source,
193        })?;
194
195        // Parse once as raw Value for key comparison.
196        let original: serde_json::Value = serde_json::from_slice(&bytes)?;
197
198        // Parse into typed struct (silently ignores unknown keys).
199        let config: ModConfig = serde_json::from_slice(&bytes)?;
200
201        // Re-serialize to get the "known keys" view.
202        let reserialized =
203            serde_json::to_value(&config).expect("ModConfig serialization should never fail");
204
205        let warnings = collect_unknown_keys(&original, &reserialized, "");
206
207        Ok((config, warnings))
208    }
209
210    /// Serializes and writes this `ModConfig` to a JSON file on disk,
211    /// creating parent directories as needed.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if parent directories can't be created, the file
216    /// can't be written, or serialization fails.
217    pub fn write_to_path(&self, path: &Path) -> Result<(), HookConfigError> {
218        if let Some(parent) = path.parent() {
219            std::fs::create_dir_all(parent).map_err(|source| HookConfigError::Write {
220                path: path.to_path_buf(),
221                source,
222            })?;
223        }
224        let json = serde_json::to_vec_pretty(self)?;
225        std::fs::write(path, json).map_err(|source| HookConfigError::Write {
226            path: path.to_path_buf(),
227            source,
228        })
229    }
230
231    /// Structural validation of the whole mod: mandatory `name`, each
232    /// parameter's own validity (see [`ModParameter::validate`]), and
233    /// every rule's `enable_if` (if any), evaluated as Rhai expressions.
234    ///
235    /// # Errors
236    ///
237    /// Returns every problem found (not just the first), so a mod author
238    /// or the launcher's error display for a broken mod, can see the
239    /// whole picture at once.
240    pub fn validate(&self) -> Result<(), Vec<String>> {
241        let mut errors = Vec::new();
242
243        if self.name.trim().is_empty() {
244            errors.push("'name' is mandatory".to_string());
245        }
246
247        for param in &self.parameters {
248            if let Err(e) = param.validate() {
249                errors.push(e);
250            }
251            if let Some(tag) = &param.validation {
252                if !crate::validation::is_known_tag(tag) {
253                    errors.push(format!(
254                        "parameter '{}': unknown validation tag `{tag}` (expected one of: {})",
255                        param.id,
256                        crate::validation::KNOWN_TAGS.join(", ")
257                    ));
258                } else if !matches!(param.kind, ParameterKind::Text) {
259                    errors.push(format!(
260                        "parameter '{}': carries a validation tag, which only text parameters enforce",
261                        param.id
262                    ));
263                }
264            }
265            // Check for circular dependencies among parameter enable_if expressions.
266            self.check_parameter_enable_if_cycles(&mut errors);
267            // Validate parameter-level enable_if using the same machinery
268            // as rule-level enable_if.
269            self.check_enable_if(&param.enable_if, &mut errors);
270        }
271
272        for rule in &self.rules {
273            self.check_enable_if(&rule.enable_if, &mut errors);
274            // Managed targets : one remote URL can only name one
275            // file, so the glob must be literal; the URLs must be real
276            // http(s) URLs; a poll interval, when present, must be at least
277            // one second (0 would spin the refresh engine).
278            if let Some(RuleTarget::Managed {
279                local,
280                remote,
281                poll_interval_secs,
282                push_url,
283            }) = &rule.target
284            {
285                if !rule.is_literal_glob() {
286                    errors.push(format!(
287                        "Rule `{}`: a managed (struct) target requires a literal glob; \
288                         a wildcard pattern cannot be named by a single remote URL",
289                        rule.glob_pattern
290                    ));
291                }
292                if local.as_os_str().is_empty() {
293                    errors.push(format!(
294                        "Rule `{}`: managed target's `local` path is empty",
295                        rule.glob_pattern
296                    ));
297                }
298                // The URL check only applies to fully-authored URLs: an
299                // uninterpolated `${...}` placeholder (e.g. the launcher-
300                // written `${tournament_server_url}`) is checked after
301                // interpolation, the shipped-package test asserts the
302                // resolved form.
303                if !remote.contains("${")
304                    && !remote.starts_with("http://")
305                    && !remote.starts_with("https://")
306                {
307                    errors.push(format!(
308                        "Rule `{}`: managed target's `remote` must be an http(s) URL, got `{remote}`",
309                        rule.glob_pattern
310                    ));
311                }
312                // Same rule for the optional push URL : absent =
313                // fetch-only; present = where game writes are POSTed.
314                if let Some(push_url) = push_url
315                    && !push_url.contains("${")
316                    && !push_url.starts_with("http://")
317                    && !push_url.starts_with("https://")
318                {
319                    errors.push(format!(
320                        "Rule `{}`: managed target's `push_url` must be an http(s) URL, \
321                         got `{push_url}`",
322                        rule.glob_pattern
323                    ));
324                }
325                if poll_interval_secs.is_some_and(|s| s == 0) {
326                    errors.push(format!(
327                        "Rule `{}`: managed target's `poll_interval_secs` must be at least 1 \
328                         (omit it for no polling)",
329                        rule.glob_pattern
330                    ));
331                }
332            }
333            for patch in &rule.patches {
334                match patch {
335                    MemoryPatch::ReplaceBytes { replacements } => {
336                        for r in replacements {
337                            self.check_enable_if(&r.enable_if, &mut errors);
338                        }
339                    }
340                    MemoryPatch::ReplaceString { replacements } => {
341                        for r in replacements {
342                            self.check_enable_if(&r.enable_if, &mut errors);
343                        }
344                    }
345                    MemoryPatch::Script { source, .. } => {
346                        let script_result = match source {
347                            ScriptSource::Inline { inline } => {
348                                script_engine::validate_script(inline, &self.parameters)
349                            }
350                            ScriptSource::File { file } => match read_to_string(file) {
351                                Ok(s) => script_engine::validate_script(&s, &self.parameters),
352                                Err(e) => Err(script_engine::ScriptError::Runtime {
353                                    message: format!(
354                                        "Failed to read script file {}: {}",
355                                        file.display(),
356                                        e
357                                    ),
358                                }),
359                            },
360                        };
361                        if let Err(e) = script_result {
362                            errors.push(format!("Script patch validation failed: {}", e));
363                        }
364                    }
365                }
366            }
367        }
368
369        let mut seen_ids = HashSet::new();
370        for param in &self.parameters {
371            if !seen_ids.insert(param.id.as_str()) {
372                errors.push(format!(
373                    "Duplicate parameter id `{}`; every parameter id must be unique",
374                    param.id
375                ));
376            }
377        }
378
379        // Same collect-everything convention for the optional `ui`
380        // section: its problems surface as per-mod validation errors,
381        // never as parse failures (unknown kinds are deliberately kept
382        // parseable by the `Unknown` catch-alls so this check can name
383        // them).
384        if let Some(ui) = &self.ui
385            && let Err(ui_errors) = ui.validate()
386        {
387            errors.extend(ui_errors);
388        }
389
390        if errors.is_empty() {
391            Ok(())
392        } else {
393            Err(errors)
394        }
395    }
396
397    /// Checks one `enable_if` expression, appending a message to `errors`
398    /// in case of problem. Shared by [`Self::validate`] for both call sites,
399    /// a rule's `enable_if` and a replacement's `enable_if` mean exactly
400    /// the same thing, just at different granularity.
401    fn check_enable_if(&self, enable_if: &Option<String>, errors: &mut Vec<String>) {
402        let Some(expr) = enable_if else { return };
403        if let Err(e) = script_engine::validate_expression(expr, &self.parameters) {
404            errors.push(format!("enable_if expression '{}' is invalid: {}", expr, e));
405        }
406    }
407
408    /// Detects cycles in parameter `enable_if` dependencies. A parameter's
409    /// `enable_if` expression may reference other parameters; those
410    /// referenced parameters may themselves have `enable_if` expressions.
411    /// This must form a DAG, a cycle like A→B→A would make evaluation
412    /// order undefined and could cause infinite loops in a naive
413    /// recursive evaluator.
414    ///
415    /// We extract variable references from each parameter's `enable_if`
416    /// by compiling it and inspecting which declared parameter ids appear
417    /// as identifiers in the expression. This is conservative: we only
418    /// flag references to *declared* parameters, ignoring literals,
419    /// operators, and Rhai builtins.
420    fn check_parameter_enable_if_cycles(&self, errors: &mut Vec<String>) {
421        // Build adjacency list: param_id -> set of param_ids its enable_if references.
422        let param_ids: HashSet<&str> = self.parameters.iter().map(|p| p.id.as_str()).collect();
423        let mut deps: HashMap<&str, Vec<&str>> = HashMap::new();
424
425        for param in &self.parameters {
426            let Some(expr) = &param.enable_if else {
427                continue;
428            };
429            // Extract referenced parameter ids by checking which declared
430            // ids appear as substrings in the expression. This is simpler
431            // than parsing the Rhai AST and sufficient since parameter ids
432            // are user-chosen identifiers that won't collide with Rhai
433            // keywords or operators in practice.
434            let referenced: Vec<&str> = param_ids
435                .iter()
436                .filter(|&&id| id != param.id.as_str() && expr.contains(id))
437                .copied()
438                .collect();
439            if !referenced.is_empty() {
440                deps.insert(param.id.as_str(), referenced);
441            }
442        }
443
444        // DFS-based cycle detection with three-color marking.
445        #[derive(Clone, Copy, PartialEq)]
446        enum Color {
447            White,
448            Gray,
449            Black,
450        }
451
452        let mut colors: HashMap<&str, Color> = HashMap::new();
453        for param in &self.parameters {
454            colors.insert(param.id.as_str(), Color::White);
455        }
456
457        fn dfs<'a>(
458            node: &'a str,
459            deps: &HashMap<&'a str, Vec<&'a str>>,
460            colors: &mut HashMap<&'a str, Color>,
461            path: &mut Vec<&'a str>,
462        ) -> Option<Vec<&'a str>> {
463            colors.insert(node, Color::Gray);
464            path.push(node);
465
466            if let Some(neighbors) = deps.get(node) {
467                for &neighbor in neighbors {
468                    match colors.get(neighbor).copied() {
469                        Some(Color::Gray) => {
470                            // Found a cycle, extract just the cyclic portion.
471                            let cycle_start = path.iter().position(|&n| n == neighbor).unwrap();
472                            let mut cycle = path[cycle_start..].to_vec();
473                            cycle.push(neighbor);
474                            return Some(cycle);
475                        }
476                        Some(Color::White) => {
477                            if let Some(cycle) = dfs(neighbor, deps, colors, path) {
478                                return Some(cycle);
479                            }
480                        }
481                        _ => {} // Black = already fully explored, no cycle through here.
482                    }
483                }
484            }
485
486            path.pop();
487            colors.insert(node, Color::Black);
488            None
489        }
490
491        for param in &self.parameters {
492            if colors.get(param.id.as_str()) == Some(&Color::White) {
493                let mut path = Vec::new();
494                if let Some(cycle) = dfs(param.id.as_str(), &deps, &mut colors, &mut path) {
495                    let cycle_str = cycle
496                        .iter()
497                        .map(|s| s.to_string())
498                        .collect::<Vec<_>>()
499                        .join(" → ");
500                    errors.push(format!(
501                        "circular enable_if dependency detected: {}",
502                        cycle_str
503                    ));
504                    return; // One cycle report is enough; more would be redundant.
505                }
506            }
507        }
508    }
509
510    /// This mod's parameter values for resolution purposes: its own
511    /// `configuration` defaults, with `overrides` (typically a person's
512    /// entry from [`crate::mods_state::ModConfigurations`]) layered on top,
513    /// then any parameter whose `enable_if` evaluates to `false` is forced
514    /// back to its default value (from `self.configuration`) or the
515    /// type-appropriate zero value if no default exists. This ensures
516    /// disabled parameters never retain stale overrides that could affect
517    /// other parameters' `enable_if` evaluations or rule interpolation.
518    ///
519    /// Parameters are resolved in dependency order (parameters with no
520    /// `enable_if` or whose dependencies are already resolved first), so
521    /// a chain like A→B→C correctly cascades: if A is disabled, B sees
522    /// A's default, which may disable B, which then also falls back.
523    #[must_use]
524    pub fn effective_configuration(
525        &self,
526        overrides: &HashMap<String, serde_json::Value>,
527    ) -> HashMap<String, serde_json::Value> {
528        let mut merged = self.configuration.clone();
529        merged.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
530
531        // Topological sort of parameters by enable_if dependencies.
532        // Parameters without enable_if come first, then those depending
533        // only on already-resolved parameters, etc.
534        let param_map: HashMap<&str, &ModParameter> =
535            self.parameters.iter().map(|p| (p.id.as_str(), p)).collect();
536
537        let param_ids: HashSet<&str> = param_map.keys().copied().collect();
538        let mut resolved: HashSet<&str> = HashSet::new();
539        let mut ordered: Vec<&str> = Vec::with_capacity(self.parameters.len());
540
541        // Simple iterative topological sort: repeatedly pick any
542        // unresolved parameter whose dependencies are all resolved.
543        while ordered.len() < self.parameters.len() {
544            let mut progress = false;
545            for param in &self.parameters {
546                if resolved.contains(param.id.as_str()) {
547                    continue;
548                }
549                let deps_satisfied = match &param.enable_if {
550                    None => true,
551                    Some(expr) => param_ids
552                        .iter()
553                        .filter(|&&id| id != param.id.as_str() && expr.contains(id))
554                        .all(|&id| resolved.contains(id)),
555                };
556                if deps_satisfied {
557                    ordered.push(param.id.as_str());
558                    resolved.insert(param.id.as_str());
559                    progress = true;
560                }
561            }
562            if !progress {
563                // Remaining parameters have unresolvable deps (shouldn't
564                // happen if cycle detection passed, but be safe). Process
565                // them in declaration order as a fallback.
566                for param in &self.parameters {
567                    if !resolved.contains(param.id.as_str()) {
568                        ordered.push(param.id.as_str());
569                        resolved.insert(param.id.as_str());
570                    }
571                }
572                break;
573            }
574        }
575
576        // Now evaluate enable_if in dependency order, forcing disabled
577        // parameters to their default values.
578        for &id in &ordered {
579            let Some(param) = param_map.get(id) else {
580                continue;
581            };
582            let Some(expr) = &param.enable_if else {
583                continue;
584            };
585
586            let enabled = script_engine::evaluate_bool_expression(expr, &self.parameters, &merged)
587                .unwrap_or(false);
588
589            if !enabled {
590                // Force this parameter to its default (or type zero value).
591                let default_value =
592                    self.configuration
593                        .get(id)
594                        .cloned()
595                        .unwrap_or_else(|| match param.kind {
596                            ParameterKind::Boolean => serde_json::json!(false),
597                            ParameterKind::Dropdown { .. } | ParameterKind::Text => {
598                                serde_json::json!("")
599                            }
600                            ParameterKind::Number { .. } => serde_json::json!(0.0),
601                        });
602                merged.insert(id.to_string(), default_value);
603            }
604        }
605
606        merged
607    }
608
609    /// The rules the DLL should actually apply for this mod, given
610    /// `overrides` (see [`Self::effective_configuration`]): rules gated
611    /// off by their `enable_if` are dropped outright; within a surviving
612    /// rule, individual replacements inside `patches` are *also* gated by
613    /// their own `enable_if` the same way, independent of the rule's own
614    /// gating (a rule can be unconditional while only some of its
615    /// replacements are conditional, see the "Fast_Start" example this
616    /// was built for). Every surviving rule and replacement's `${param_id}`
617    /// placeholders (in `glob_pattern`, `target`, and any
618    /// `MemoryPatch::ReplaceString` `find`/`replace` text) are interpolated
619    /// via Rhai's native string interpolation. `MemoryPatch::ReplaceBytes`
620    /// text is never interpolated, its `find`/`replace` are raw bytes,
621    /// not text, so there's no placeholder syntax to look for, but it's
622    /// still gated by its own `enable_if`.
623    ///
624    /// By the time a rule leaves this function there's no templating or
625    /// conditionality left in it at all, this is the shape
626    /// [`crate::hook_config::HookConfig::load_from_mods_dir`] merges across mods, and the
627    /// shape the proxy DLL consumes.
628    #[must_use]
629    pub fn resolved_rules(
630        &self,
631        overrides: &HashMap<String, serde_json::Value>,
632    ) -> Vec<RedirectionRule> {
633        let configuration = self.effective_configuration(overrides);
634        self.rules
635            .iter()
636            .filter(|rule| match &rule.enable_if {
637                None => true,
638                Some(expr) => {
639                    script_engine::evaluate_bool_expression(expr, &self.parameters, &configuration)
640                        .unwrap_or(false)
641                }
642            })
643            .map(|rule| substitute_rule(rule, &self.parameters, &configuration))
644            .collect()
645    }
646}
647
648fn substitute_rule(
649    rule: &RedirectionRule,
650    parameters: &[ModParameter],
651    configuration: &HashMap<String, serde_json::Value>,
652) -> RedirectionRule {
653    RedirectionRule {
654        glob_pattern: substitute_placeholders(&rule.glob_pattern, parameters, configuration, true),
655        target: rule.target.as_ref().map(|t| match t {
656            RuleTarget::Path(p) => RuleTarget::Path(PathBuf::from(substitute_placeholders(
657                &p.to_string_lossy(),
658                parameters,
659                configuration,
660                false,
661            ))),
662            RuleTarget::Managed {
663                local,
664                remote,
665                poll_interval_secs,
666                push_url,
667            } => RuleTarget::Managed {
668                local: PathBuf::from(substitute_placeholders(
669                    &local.to_string_lossy(),
670                    parameters,
671                    configuration,
672                    false,
673                )),
674                remote: substitute_placeholders(remote, parameters, configuration, false),
675                poll_interval_secs: *poll_interval_secs,
676                push_url: push_url
677                    .as_ref()
678                    .map(|u| substitute_placeholders(u, parameters, configuration, false)),
679            },
680        }),
681        mode: rule.mode,
682        patches: rule
683            .patches
684            .iter()
685            .map(|p| substitute_patch(p, parameters, configuration))
686            .collect(),
687        enable_if: rule.enable_if.clone(),
688    }
689}
690
691fn substitute_patch(
692    patch: &MemoryPatch,
693    parameters: &[ModParameter],
694    configuration: &HashMap<String, serde_json::Value>,
695) -> MemoryPatch {
696    match patch {
697        MemoryPatch::ReplaceBytes { replacements } => MemoryPatch::ReplaceBytes {
698            replacements: replacements
699                .iter()
700                .filter(|r| match &r.enable_if {
701                    None => true,
702                    Some(expr) => {
703                        script_engine::evaluate_bool_expression(expr, parameters, configuration)
704                            .unwrap_or(false)
705                    }
706                })
707                .cloned()
708                .collect(),
709        },
710        MemoryPatch::ReplaceString { replacements } => MemoryPatch::ReplaceString {
711            replacements: replacements
712                .iter()
713                .filter(|r| match &r.enable_if {
714                    None => true,
715                    Some(expr) => {
716                        script_engine::evaluate_bool_expression(expr, parameters, configuration)
717                            .unwrap_or(false)
718                    }
719                })
720                .map(|r| StringReplacement {
721                    find: substitute_placeholders(&r.find, parameters, configuration, false),
722                    replace: substitute_placeholders(&r.replace, parameters, configuration, false),
723                    enable_if: r.enable_if.clone(),
724                })
725                .collect(),
726        },
727        MemoryPatch::Script { source, .. } => MemoryPatch::Script {
728            source: source.clone(),
729            parameters: parameters.to_vec(),
730            configuration: configuration.clone(),
731        },
732    }
733}
734
735/// Renders a configured parameter value as a plain string, used when
736/// pre-escaping substituted values for glob context (see
737/// [`substitute_placeholders`]).
738fn stringify_configured_value(value: &serde_json::Value) -> String {
739    match value {
740        serde_json::Value::Bool(b) => b.to_string(),
741        serde_json::Value::Number(n) => n.to_string(),
742        serde_json::Value::String(s) => s.clone(),
743        _ => String::new(),
744    }
745}
746
747/// Interpolates `${...}` expressions in `text` using Rhai's native string
748/// interpolation, with each parameter's value from `configuration`
749/// available as a variable in the evaluation scope. The expression inside
750/// `${...}` can be a bare parameter id (e.g. `${Board_Style}`) or any
751/// valid Rhai expression (e.g. `${Board_Style + "_v2"}`).
752///
753/// When `escape_for_glob` is true, glob-special characters in configured
754/// values are escaped first (see [`escape_glob_special_chars`]) so they
755/// are matched literally by [`glob::Pattern`] rather than reinterpreted
756/// as wildcard syntax.
757///
758/// If interpolation fails (e.g. an unknown variable, or a malformed
759/// `${...}` expression), `text` is returned unchanged, this should be
760/// caught by [`ModConfig::validate`] at load time, not silently
761/// misbehave here.
762fn substitute_placeholders(
763    text: &str,
764    parameters: &[ModParameter],
765    configuration: &HashMap<String, serde_json::Value>,
766    escape_for_glob: bool,
767) -> String {
768    if escape_for_glob {
769        // Pre-escape glob-special characters in all configured values so
770        // they are matched literally by `glob::Pattern` after interpolation.
771        let escaped_config: HashMap<String, serde_json::Value> = configuration
772            .iter()
773            .map(|(k, v)| {
774                let escaped = escape_glob_special_chars(&stringify_configured_value(v));
775                (k.clone(), serde_json::Value::String(escaped))
776            })
777            .collect();
778        script_engine::interpolate_string(text, parameters, &escaped_config)
779            .unwrap_or_else(|_| text.to_string())
780    } else {
781        script_engine::interpolate_string(text, parameters, configuration)
782            .unwrap_or_else(|_| text.to_string())
783    }
784}
785
786/// Recursively compares two JSON values structurally, collecting keys
787/// present in `original` but absent from `reserialized` at every level.
788/// Keys starting with `_` are intentionally ignored (reserved for mod
789/// authors' private metadata).
790///
791/// `path` tracks the current location for human-readable warning messages
792/// (e.g. `"parameters[1]"`, `"rules[0].patches"`).
793fn collect_unknown_keys(
794    original: &serde_json::Value,
795    reserialized: &serde_json::Value,
796    path: &str,
797) -> Vec<String> {
798    let mut warnings = Vec::new();
799
800    match (original, reserialized) {
801        (serde_json::Value::Object(orig_map), serde_json::Value::Object(reser_map)) => {
802            for (key, orig_val) in orig_map {
803                if key.starts_with('_') {
804                    continue; // Intentionally private/ignored by convention.
805                }
806                match reser_map.get(key) {
807                    Some(reser_val) => {
808                        // Key exists in both, recurse into the value.
809                        let child_path = if path.is_empty() {
810                            key.clone()
811                        } else {
812                            format!("{path}.{key}")
813                        };
814                        warnings.extend(collect_unknown_keys(orig_val, reser_val, &child_path));
815                    }
816                    None => {
817                        // Key in original but not in reserialized → unknown.
818                        let location = if path.is_empty() {
819                            "root".to_string()
820                        } else {
821                            path.to_string()
822                        };
823                        warnings.push(format!(
824                            "This mod may require a newer launcher version (unknown key `{key}` at {location})"
825                        ));
826                    }
827                }
828            }
829        }
830        (serde_json::Value::Array(orig_arr), serde_json::Value::Array(reser_arr)) => {
831            // Compare paired elements by index. Extra elements in original
832            // beyond reserialized length shouldn't happen with round-trip,
833            // but handle gracefully.
834            for (i, orig_elem) in orig_arr.iter().enumerate() {
835                let child_path = format!("{path}[{i}]");
836                if let Some(reser_elem) = reser_arr.get(i) {
837                    warnings.extend(collect_unknown_keys(orig_elem, reser_elem, &child_path));
838                }
839            }
840        }
841        // Leaf values or type mismatches, nothing to compare.
842        _ => {}
843    }
844
845    warnings
846}
847
848/// Escapes glob-special characters (`*`, `?`, `[`, `]`) in a substituted
849/// value so it's matched as literal text by [`glob::Pattern`] rather than
850/// reinterpreted as wildcard syntax, each special character is wrapped in
851/// its own one-character class (e.g. `*` becomes `[*]`), the standard
852/// technique for embedding a literal character in glob syntax.
853///
854/// This does *not*, and structurally can't, prevent a literal `/` or `\`
855/// in a substituted value from acting as a path separator once it lands in
856/// the pattern, `glob`'s separator handling treats both natively (see the
857/// note on [`crate::hook_config::MATCH_OPTIONS`]). Parameter values meant to fill a single
858/// path segment (like a dropdown choice used in a `target` or
859/// `glob_pattern`) simply shouldn't contain separators.
860fn escape_glob_special_chars(value: &str) -> String {
861    let mut out = String::with_capacity(value.len());
862    for c in value.chars() {
863        match c {
864            '*' | '?' | '[' | ']' => {
865                out.push('[');
866                out.push(c);
867                out.push(']');
868            }
869            _ => out.push(c),
870        }
871    }
872    out
873}