Skip to main content

supreme_hook_config/
patch.rs

1use crate::ModParameter;
2use crate::script_engine::PatchError;
3use encoding_rs::WINDOWS_1252;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8/// A single content modification applied to a file's bytes before the game
9/// reads it.
10///
11/// Each variant carries a *list* of find/replace pairs so that a single
12/// patch can update many parts of the same file at once, e.g. renaming
13/// several level names in `language.json` when multiple custom levels are
14/// hooked in together. All pairs in one patch are matched in a single left
15/// -to-right pass over the data: they don't cascade into each other (a
16/// replacement's output is never re-scanned against the other finds in the
17/// same patch). If several `MemoryPatch` entries are applied in sequence
18/// via [`apply_all`], *those* do chain, each operating on the previous
19/// patch's output.
20#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22pub enum MemoryPatch {
23    /// Replace literal (game-encoded) byte strings. Used for e.g. swapping
24    /// several hardcoded level names for translated ones in a single
25    /// pass. Lengths need not match: we're producing a brand new temp
26    /// file, not patching in place.
27    ReplaceBytes { replacements: Vec<BytesReplacement> },
28    /// Convenience form of `ReplaceBytes` for text replacements (e.g.
29    /// localized level names in a JSON/text asset). `find`/`replace` are
30    /// ordinary Rust (UTF-8) strings, but the game's asset files are
31    /// Windows-1252 on disk, so both are transcoded to Windows-1252 bytes
32    /// before matching, otherwise any non-ASCII character (accents,
33    /// curly quotes, etc.) would never match the game's actual bytes.
34    ReplaceString {
35        replacements: Vec<StringReplacement>,
36    },
37    /// Run a Rhai script to transform the file contents. The script receives
38    /// the file contents as a UTF-8/Windows-1252-decoded `String` via the
39    /// `input` variable, plus `file_path` (the file's stable `rel_key`) and
40    /// `timestamp` (current time), and must return a `String`.
41    Script {
42        source: ScriptSource,
43        #[serde(default)]
44        parameters: Vec<ModParameter>,
45        #[serde(default)]
46        configuration: HashMap<String, serde_json::Value>,
47    },
48}
49
50/// The source of a script patch. Explicitly tagged to avoid ambiguous
51/// filesystem probing.
52#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
53#[serde(untagged, rename_all = "snake_case")]
54pub enum ScriptSource {
55    Inline { inline: String },
56    File { file: PathBuf },
57}
58
59/// One `find` -> `replace` byte pair.
60#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
61pub struct BytesReplacement {
62    pub find: Vec<u8>,
63    pub replace: Vec<u8>,
64    /// References a Rhai expression (same meaning as
65    /// `RedirectionRule::enable_if`, just at the level of one replacement
66    /// pair instead of a whole rule): this pair is only included in the
67    /// patch actually applied to the game if the parameter reads truthy.
68    /// `None` means this pair is always included.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub enable_if: Option<String>,
71}
72
73/// One `find` -> `replace` text pair, expressed as ordinary UTF-8 Rust
74/// strings and transcoded to Windows-1252 at apply time.
75#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
76pub struct StringReplacement {
77    pub find: String,
78    pub replace: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub enable_if: Option<String>,
81}
82
83impl MemoryPatch {
84    /// Applies this patch to `data`, returning the patched bytes.
85    ///
86    /// `rel_key` identifies the file being patched (game-root-relative,
87    /// lowercase, forward-slashed -- see the resolver's `rel_key`
88    /// convention) and is only actually used by [`MemoryPatch::Script`],
89    /// which exposes it to the script as `file_path`; the other variants
90    /// ignore it.
91    ///
92    /// # Errors
93    /// Returns a `PatchError` if script execution or encoding fails.
94    pub fn apply(&self, data: &[u8], rel_key: &str) -> Result<Vec<u8>, PatchError> {
95        match self {
96            MemoryPatch::ReplaceBytes { replacements } => {
97                let pairs: Vec<(&[u8], &[u8])> = replacements
98                    .iter()
99                    .map(|r| (r.find.as_slice(), r.replace.as_slice()))
100                    .collect();
101                Ok(replace_many(data, &pairs))
102            }
103            MemoryPatch::ReplaceString { replacements } => {
104                let encoded: Vec<(Vec<u8>, Vec<u8>)> = replacements
105                    .iter()
106                    .map(|r| {
107                        (
108                            encode_windows_1252(&r.find),
109                            encode_windows_1252(&r.replace),
110                        )
111                    })
112                    .collect();
113                let pairs: Vec<(&[u8], &[u8])> = encoded
114                    .iter()
115                    .map(|(find, replace)| (find.as_slice(), replace.as_slice()))
116                    .collect();
117                Ok(replace_many(data, &pairs))
118            }
119            MemoryPatch::Script {
120                source,
121                parameters,
122                configuration,
123            } => {
124                let script = match source {
125                    ScriptSource::Inline { inline } => inline.clone(),
126                    ScriptSource::File { file } => std::fs::read_to_string(file).map_err(|e| {
127                        PatchError::InvalidPatch(format!("Failed to read script file: {}", e))
128                    })?,
129                };
130
131                // Decode using Windows-1252
132                let (input, _, had_errors) = WINDOWS_1252.decode(data);
133                if had_errors {
134                    return Err(PatchError::Encoding(
135                        "Input is not valid Windows-1252".to_string(),
136                    ));
137                }
138
139                // Verify round-trip cleanly
140                let (re_encoded, _, _) = WINDOWS_1252.encode(&input);
141                if re_encoded != data {
142                    return Err(PatchError::Encoding(
143                        "Input does not round-trip cleanly through Windows-1252".to_string(),
144                    ));
145                }
146
147                let result = crate::script_engine::run_script_patch(
148                    &script,
149                    &input,
150                    rel_key,
151                    parameters,
152                    configuration,
153                )?;
154
155                let (output, _, had_errors) = WINDOWS_1252.encode(&result);
156                if had_errors {
157                    return Err(PatchError::Encoding(
158                        "Output contains characters not encodable in Windows-1252".to_string(),
159                    ));
160                }
161
162                Ok(output.into_owned())
163            }
164        }
165    }
166}
167
168/// Transcodes a Rust string to Windows-1252 bytes, the encoding the game's
169/// own asset/text files are stored in on disk.
170fn encode_windows_1252(s: &str) -> Vec<u8> {
171    let (bytes, _, _) = WINDOWS_1252.encode(s);
172    bytes.into_owned()
173}
174
175/// Applies a whole list of patches in order, each operating on the
176/// previous one's output. `rel_key` is forwarded to each patch's
177/// [`MemoryPatch::apply`] -- see there for what it's used for.
178///
179/// # Errors
180/// Returns a `PatchError` if any patch in the chain fails.
181pub fn apply_all(
182    patches: &[MemoryPatch],
183    data: &[u8],
184    rel_key: &str,
185) -> Result<Vec<u8>, PatchError> {
186    let mut acc = data.to_vec();
187    for patch in patches {
188        acc = patch.apply(&acc, rel_key)?;
189    }
190    Ok(acc)
191}
192
193/// Scans `data` once, replacing every occurrence of any `find` in `pairs`
194/// with its corresponding `replace`. At each position, pairs are tried in
195/// list order and the first one that matches wins, so if two finds could
196/// both match at the same spot (e.g. one is a prefix of the other), put
197/// the more specific one first. Matches never overlap and replacement
198/// output is never re-scanned against `pairs` within this call, so
199/// multiple simultaneous substitutions can't accidentally cascade into
200/// each other. Empty `find` entries are skipped (never matched).
201fn replace_many(data: &[u8], pairs: &[(&[u8], &[u8])]) -> Vec<u8> {
202    let pairs: Vec<_> = pairs.iter().filter(|(find, _)| !find.is_empty()).collect();
203    if pairs.is_empty() {
204        return data.to_vec();
205    }
206
207    let mut out = Vec::with_capacity(data.len());
208    let mut i = 0;
209    'outer: while i < data.len() {
210        for (find, replace) in &pairs {
211            if data[i..].starts_with(find) {
212                out.extend_from_slice(replace);
213                i += find.len();
214                continue 'outer;
215            }
216        }
217        out.push(data[i]);
218        i += 1;
219    }
220    out
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    /// Placeholder `rel_key` for tests that don't care about its value --
228    /// only `MemoryPatch::Script` (tested separately, in
229    /// `tests/script_patch.rs`) actually uses it.
230    const REL_KEY: &str = "data/test_file.txt";
231
232    fn string_patch(pairs: &[(&str, &str)]) -> MemoryPatch {
233        MemoryPatch::ReplaceString {
234            replacements: pairs
235                .iter()
236                .map(|(find, replace)| StringReplacement {
237                    find: (*find).to_string(),
238                    replace: (*replace).to_string(),
239                    enable_if: None,
240                })
241                .collect(),
242        }
243    }
244
245    #[test]
246    fn replaces_all_occurrences_of_a_single_pair() {
247        let patch = string_patch(&[("Level1", "CustomLevel")]);
248        let data = b"Level1 loaded, welcome to Level1!";
249        assert_eq!(
250            patch.apply(data, REL_KEY).unwrap(),
251            b"CustomLevel loaded, welcome to CustomLevel!"
252        );
253    }
254
255    #[test]
256    fn replaces_multiple_level_names_in_one_pass() {
257        // The motivating case: several level names in one language.json-like
258        // blob all get updated by a single patch.
259        let patch = string_patch(&[
260            ("\"Level1\"", "\"Frozen Peak\""),
261            ("\"Level2\"", "\"Sunset Valley\""),
262            ("\"Level3\"", "\"Iron Canyon\""),
263        ]);
264        let data = br#"{"levels":["Level1","Level2","Level3"]}"#;
265        assert_eq!(
266            patch.apply(data, REL_KEY).unwrap(),
267            br#"{"levels":["Frozen Peak","Sunset Valley","Iron Canyon"]}"#
268        );
269    }
270
271    #[test]
272    fn simultaneous_replacements_do_not_cascade_into_each_other() {
273        // If these were applied as two separate chained patches (A->B then
274        // B->C), the result would wrongly become "CC". Within a single
275        // patch, both pairs are matched against the *original* data.
276        let patch = string_patch(&[("A", "B"), ("B", "C")]);
277        assert_eq!(patch.apply(b"AB", REL_KEY).unwrap(), b"BC");
278    }
279
280    #[test]
281    fn earlier_pair_wins_when_finds_overlap_at_same_position() {
282        let patch = string_patch(&[("Level10", "Ten"), ("Level1", "One")]);
283        assert_eq!(patch.apply(b"Level10", REL_KEY).unwrap(), b"Ten");
284        let patch_reordered = string_patch(&[("Level1", "One"), ("Level10", "Ten")]);
285        assert_eq!(patch_reordered.apply(b"Level10", REL_KEY).unwrap(), b"One0");
286    }
287
288    #[test]
289    fn empty_find_is_skipped() {
290        let patch = string_patch(&[("", "X"), ("A", "B")]);
291        assert_eq!(patch.apply(b"AAA", REL_KEY).unwrap(), b"BBB");
292    }
293
294    #[test]
295    fn all_empty_finds_is_noop() {
296        let patch = string_patch(&[("", "X")]);
297        assert_eq!(patch.apply(b"unchanged", REL_KEY).unwrap(), b"unchanged");
298    }
299
300    #[test]
301    fn chained_separate_patches_still_apply_in_order() {
302        let patches = vec![string_patch(&[("A", "B")]), string_patch(&[("B", "C")])];
303        // Separate MemoryPatch entries in `apply_all` intentionally do
304        // chain, unlike pairs within a single patch.
305        assert_eq!(apply_all(&patches, b"AB", REL_KEY).unwrap(), b"CC");
306    }
307
308    #[test]
309    fn binary_replace_handles_non_utf8() {
310        let patch = MemoryPatch::ReplaceBytes {
311            replacements: vec![BytesReplacement {
312                find: vec![0xDE, 0xAD],
313                replace: vec![0xBE, 0xEF, 0xBE, 0xEF],
314                enable_if: None,
315            }],
316        };
317        assert_eq!(
318            patch.apply(&[0x01, 0xDE, 0xAD, 0x02], REL_KEY).unwrap(),
319            vec![0x01, 0xBE, 0xEF, 0xBE, 0xEF, 0x02]
320        );
321    }
322
323    #[test]
324    fn string_replacements_match_against_windows_1252_bytes() {
325        // "é" is 0xE9 in Windows-1252, but 0xC3 0xA9 in UTF-8. The game's
326        // on-disk asset files are Windows-1252, so the patch must find and
327        // emit that single-byte form, not the two-byte UTF-8 form Rust
328        // uses internally.
329        let patch = string_patch(&[("Piste Enneig\u{e9}e", "Sommet Gel\u{e9}")]);
330        let windows_1252_input = b"Piste Enneig\xE9e loaded";
331        assert_eq!(
332            patch.apply(windows_1252_input, REL_KEY).unwrap(),
333            b"Sommet Gel\xE9 loaded"
334        );
335    }
336}