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) = ¶m.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(¶m.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) = ¶m.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 ¶m.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) = ¶m.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}