Skip to main content

nms_save/
mapping.rs

1//! Key deobfuscation for NMS save file JSON.
2//!
3//! NMS save files (format 2002+) have obfuscated JSON keys (e.g., `"F2P"` instead
4//! of `"Version"`). This module loads mapping files and recursively replaces
5//! obfuscated keys with their readable equivalents.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use crate::error::SaveError;
11
12/// A single entry in the mapping JSON file.
13#[derive(Debug, serde::Deserialize)]
14struct MappingEntry {
15    #[serde(rename = "Key")]
16    key: String,
17    #[serde(rename = "Value")]
18    value: String,
19}
20
21/// Top-level structure of a mapping JSON file.
22#[derive(Debug, serde::Deserialize)]
23struct MappingFile {
24    #[serde(rename = "libMBIN_version")]
25    version: String,
26    #[serde(rename = "Mapping")]
27    mapping: Vec<MappingEntry>,
28}
29
30/// Bidirectional key mapping for NMS save file deobfuscation.
31///
32/// Maps obfuscated 3-character keys (e.g., `"F2P"`) to readable names
33/// (e.g., `"Version"`).
34#[derive(Debug, Clone)]
35pub struct KeyMapping {
36    /// Version string from the primary mapping file.
37    pub version: String,
38    /// Obfuscated key -> readable name.
39    entries: HashMap<String, String>,
40    /// Readable-name fixups (e.g., "MultiTools" -> "Multitools").
41    fixups: HashMap<String, String>,
42}
43
44impl KeyMapping {
45    /// Load the bundled (compiled-in) mapping.
46    ///
47    /// Merges all three mapping sources:
48    /// - `mapping_mbincompiler.json` (primary)
49    /// - `mapping_legacy.json` (older keys)
50    /// - `mapping_savewizard.json` (name fixups)
51    pub fn bundled() -> Self {
52        let primary_json = include_str!("../data/mapping_mbincompiler.json");
53        let legacy_json = include_str!("../data/mapping_legacy.json");
54        let savewizard_json = include_str!("../data/mapping_savewizard.json");
55
56        let mut mapping = Self::from_json(primary_json)
57            .expect("bundled mapping_mbincompiler.json should be valid");
58
59        let legacy: MappingFile =
60            serde_json::from_str(legacy_json).expect("bundled mapping_legacy.json should be valid");
61        for entry in legacy.mapping {
62            mapping.entries.entry(entry.key).or_insert(entry.value);
63        }
64
65        let savewizard: MappingFile = serde_json::from_str(savewizard_json)
66            .expect("bundled mapping_savewizard.json should be valid");
67        for entry in savewizard.mapping {
68            mapping.fixups.insert(entry.key, entry.value);
69        }
70
71        mapping
72    }
73
74    /// Load a mapping from a JSON string (single mapping file).
75    pub fn from_json(json: &str) -> Result<Self, SaveError> {
76        let file: MappingFile =
77            serde_json::from_str(json).map_err(|e| SaveError::MappingParseError {
78                message: e.to_string(),
79            })?;
80
81        let mut entries = HashMap::with_capacity(file.mapping.len());
82        for entry in file.mapping {
83            entries.entry(entry.key).or_insert(entry.value);
84        }
85
86        Ok(Self {
87            version: file.version,
88            entries,
89            fixups: HashMap::new(),
90        })
91    }
92
93    /// Load a mapping from a file on disk.
94    pub fn from_file(path: &Path) -> Result<Self, SaveError> {
95        let json = std::fs::read_to_string(path)?;
96        Self::from_json(&json)
97    }
98
99    /// Look up the readable name for an obfuscated key.
100    pub fn get(&self, obfuscated: &str) -> Option<&str> {
101        self.entries.get(obfuscated).map(|s| s.as_str())
102    }
103
104    /// Return the number of mapping entries.
105    pub fn len(&self) -> usize {
106        self.entries.len()
107    }
108
109    /// Return whether the mapping is empty.
110    pub fn is_empty(&self) -> bool {
111        self.entries.is_empty()
112    }
113
114    /// Deobfuscate all keys in a [`serde_json::Value`] tree in place.
115    ///
116    /// Walks the tree recursively. For each JSON object, replaces obfuscated
117    /// keys with their readable equivalents. Unknown keys are preserved as-is.
118    /// After key replacement, applies any fixups (e.g., case corrections).
119    pub fn deobfuscate(&self, value: &mut serde_json::Value) {
120        self.walk(value);
121    }
122
123    fn walk(&self, value: &mut serde_json::Value) {
124        match value {
125            serde_json::Value::Object(map) => {
126                let entries: Vec<(String, serde_json::Value)> = std::mem::take(map)
127                    .into_iter()
128                    .map(|(k, mut v)| {
129                        self.walk(&mut v);
130                        let mut new_key = self.entries.get(&k).cloned().unwrap_or(k);
131                        if let Some(fixed) = self.fixups.get(&new_key) {
132                            new_key = fixed.clone();
133                        }
134                        (new_key, v)
135                    })
136                    .collect();
137                *map = serde_json::Map::from_iter(entries);
138            }
139            serde_json::Value::Array(arr) => {
140                for item in arr.iter_mut() {
141                    self.walk(item);
142                }
143            }
144            _ => {}
145        }
146    }
147}
148
149/// Detect whether a parsed JSON value has obfuscated keys.
150///
151/// Checks the top-level object for known obfuscated keys (`"F2P"`, `"6f="`, `"8>q"`)
152/// vs the plaintext `"Version"` key.
153pub fn is_obfuscated(json: &serde_json::Value) -> bool {
154    match json.as_object() {
155        Some(map) => {
156            if map.contains_key("F2P") {
157                return true;
158            }
159            if map.contains_key("Version") {
160                return false;
161            }
162            map.contains_key("6f=") || map.contains_key("8>q")
163        }
164        None => false,
165    }
166}
167
168/// Deobfuscate JSON bytes: parse, detect obfuscation, apply mapping if needed.
169///
170/// Returns the (potentially deobfuscated) parsed JSON value.
171pub fn deobfuscate_json(
172    json_bytes: &[u8],
173    mapping: &KeyMapping,
174) -> Result<serde_json::Value, SaveError> {
175    let mut value: serde_json::Value =
176        serde_json::from_slice(json_bytes).map_err(|e| SaveError::JsonParseError {
177            message: e.to_string(),
178        })?;
179
180    if is_obfuscated(&value) {
181        mapping.deobfuscate(&mut value);
182    }
183
184    Ok(value)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use serde_json::json;
191
192    #[test]
193    fn load_bundled_mapping() {
194        let mapping = KeyMapping::bundled();
195        assert!(
196            mapping.len() > 1300,
197            "expected 1300+ entries, got {}",
198            mapping.len()
199        );
200        assert_eq!(mapping.version, "6.11.0.1");
201    }
202
203    #[test]
204    fn lookup_known_keys() {
205        let mapping = KeyMapping::bundled();
206        assert_eq!(mapping.get("F2P"), Some("Version"));
207        assert_eq!(mapping.get("6f="), Some("PlayerStateData"));
208        assert_eq!(mapping.get("8>q"), Some("Platform"));
209    }
210
211    #[test]
212    fn lookup_unknown_key() {
213        let mapping = KeyMapping::bundled();
214        assert_eq!(mapping.get("ZZZNOTAKEY"), None);
215    }
216
217    #[test]
218    fn legacy_keys_merged() {
219        let mapping = KeyMapping::bundled();
220        assert_eq!(mapping.get("5Ta"), Some("AllowFriendBases"));
221    }
222
223    #[test]
224    fn deobfuscate_simple() {
225        let mapping = KeyMapping::bundled();
226        let mut value = json!({"F2P": 6726});
227        mapping.deobfuscate(&mut value);
228        assert_eq!(value, json!({"Version": 6726}));
229    }
230
231    #[test]
232    fn deobfuscate_nested() {
233        let mapping = KeyMapping::bundled();
234        assert_eq!(mapping.get("Pk4"), Some("SaveName"));
235        let mut value = json!({"6f=": {"Pk4": "MySave"}});
236        mapping.deobfuscate(&mut value);
237        assert_eq!(value, json!({"PlayerStateData": {"SaveName": "MySave"}}));
238    }
239
240    #[test]
241    fn deobfuscate_array() {
242        let mapping = KeyMapping::bundled();
243        let mut value = json!({
244            "F2P": 6726,
245            "items": [
246                {"F2P": 1},
247                {"F2P": 2}
248            ]
249        });
250        mapping.deobfuscate(&mut value);
251        assert_eq!(value["Version"], 6726);
252        assert_eq!(value["items"][0]["Version"], 1);
253        assert_eq!(value["items"][1]["Version"], 2);
254    }
255
256    #[test]
257    fn deobfuscate_preserves_unknown_keys() {
258        let mapping = KeyMapping::bundled();
259        let mut value = json!({"F2P": 6726, "UnknownKey123": "hello"});
260        mapping.deobfuscate(&mut value);
261        assert_eq!(value["Version"], 6726);
262        assert_eq!(value["UnknownKey123"], "hello");
263    }
264
265    #[test]
266    fn deobfuscate_already_plaintext() {
267        let mapping = KeyMapping::bundled();
268        let mut value = json!({"Version": 6726, "PlayerStateData": {"SaveName": "MySave"}});
269        mapping.deobfuscate(&mut value);
270        assert_eq!(value["Version"], 6726);
271        assert_eq!(value["PlayerStateData"]["SaveName"], "MySave");
272    }
273
274    #[test]
275    fn savewizard_fixup() {
276        let mapping = KeyMapping::bundled();
277        let mut value = json!({"MultiTools": [1, 2, 3]});
278        mapping.deobfuscate(&mut value);
279        assert!(
280            value.get("Multitools").is_some(),
281            "savewizard fixup should rename MultiTools to Multitools"
282        );
283        assert!(value.get("MultiTools").is_none());
284    }
285
286    #[test]
287    fn is_obfuscated_with_f2p() {
288        let value = json!({"F2P": 6726, "6f=": {}});
289        assert!(is_obfuscated(&value));
290    }
291
292    #[test]
293    fn is_obfuscated_with_version() {
294        let value = json!({"Version": 6726, "PlayerStateData": {}});
295        assert!(!is_obfuscated(&value));
296    }
297
298    #[test]
299    fn is_obfuscated_no_version_key() {
300        let value = json!({"8>q": "PC"});
301        assert!(is_obfuscated(&value));
302    }
303
304    #[test]
305    fn is_obfuscated_non_object() {
306        let value = json!([1, 2, 3]);
307        assert!(!is_obfuscated(&value));
308    }
309
310    #[test]
311    fn is_obfuscated_empty_object() {
312        let value = json!({});
313        assert!(!is_obfuscated(&value));
314    }
315
316    #[test]
317    fn deobfuscate_json_bytes() {
318        let mapping = KeyMapping::bundled();
319        let json_bytes = br#"{"F2P": 6726}"#;
320        let value = deobfuscate_json(json_bytes, &mapping).unwrap();
321        assert_eq!(value["Version"], 6726);
322    }
323
324    #[test]
325    fn deobfuscate_json_already_plaintext() {
326        let mapping = KeyMapping::bundled();
327        let json_bytes = br#"{"Version": 6726}"#;
328        let value = deobfuscate_json(json_bytes, &mapping).unwrap();
329        assert_eq!(value["Version"], 6726);
330    }
331
332    #[test]
333    fn deobfuscate_json_invalid_json() {
334        let mapping = KeyMapping::bundled();
335        let json_bytes = b"not json";
336        let err = deobfuscate_json(json_bytes, &mapping).unwrap_err();
337        assert!(matches!(err, SaveError::JsonParseError { .. }));
338    }
339
340    #[test]
341    fn from_json_valid() {
342        let json = r#"{"libMBIN_version":"1.0.0","Mapping":[{"Key":"abc","Value":"Alpha"}]}"#;
343        let mapping = KeyMapping::from_json(json).unwrap();
344        assert_eq!(mapping.version, "1.0.0");
345        assert_eq!(mapping.get("abc"), Some("Alpha"));
346        assert_eq!(mapping.len(), 1);
347    }
348
349    #[test]
350    fn from_json_invalid() {
351        let json = "not valid json";
352        let err = KeyMapping::from_json(json).unwrap_err();
353        assert!(matches!(err, SaveError::MappingParseError { .. }));
354    }
355
356    #[test]
357    fn from_json_empty_mapping() {
358        let json = r#"{"libMBIN_version":"1.0.0","Mapping":[]}"#;
359        let mapping = KeyMapping::from_json(json).unwrap();
360        assert!(mapping.is_empty());
361    }
362}