Skip to main content

nms_save/
metadata.rs

1//! Metadata file (`mf_save*.hg`) decryption and parsing.
2
3use crate::error::SaveError;
4use crate::xxtea::{derive_key, xxtea_decrypt};
5
6/// Meta magic sentinel -- first u32 after successful XXTEA decryption.
7const META_MAGIC: u32 = 0xEEEEEEBE;
8
9/// Valid meta file lengths.
10const META_LENGTH_VANILLA: usize = 0x68; // 104 bytes, format 2001
11const META_LENGTH_WAYPOINT: usize = 0x168; // 360 bytes, format 2002
12const META_LENGTH_WORLDS_PART_I: usize = 0x180; // 384 bytes, format 2003
13const META_LENGTH_WORLDS_PART_II: usize = 0x1B0; // 432 bytes, format 2004
14
15/// XXTEA rounds for vanilla-length meta files.
16const ROUNDS_VANILLA: usize = 8;
17
18/// XXTEA rounds for all other meta file lengths.
19const ROUNDS_DEFAULT: usize = 6;
20
21/// Meta format version constants.
22const META_FORMAT_VANILLA: u32 = 0x7D0; // 2000 -- NOT SUPPORTED
23const META_FORMAT_FOUNDATION: u32 = 0x7D1; // 2001
24
25/// All valid metadata file lengths.
26const VALID_META_LENGTHS: [usize; 4] = [
27    META_LENGTH_VANILLA,
28    META_LENGTH_WAYPOINT,
29    META_LENGTH_WORLDS_PART_I,
30    META_LENGTH_WORLDS_PART_II,
31];
32
33// ---------------------------------------------------------------------------
34// StorageSlot
35// ---------------------------------------------------------------------------
36
37/// Storage slot enum values used for XXTEA key derivation.
38///
39/// Each value corresponds to a file index. The numeric value is XOR'd
40/// with a constant during key derivation.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[repr(u8)]
43pub enum StorageSlot {
44    UserSettings = 0,
45    AccountData = 1,
46    PlayerState1 = 2,
47    PlayerState2 = 3,
48    PlayerState3 = 4,
49    PlayerState4 = 5,
50    PlayerState5 = 6,
51    PlayerState6 = 7,
52    PlayerState7 = 8,
53    PlayerState8 = 9,
54    PlayerState9 = 10,
55    PlayerState10 = 11,
56    PlayerState11 = 12,
57    PlayerState12 = 13,
58    PlayerState13 = 14,
59    PlayerState14 = 15,
60    PlayerState15 = 16,
61    PlayerState16 = 17,
62    PlayerState17 = 18,
63    PlayerState18 = 19,
64    PlayerState19 = 20,
65    PlayerState20 = 21,
66    PlayerState21 = 22,
67    PlayerState22 = 23,
68    PlayerState23 = 24,
69    PlayerState24 = 25,
70    PlayerState25 = 26,
71    PlayerState26 = 27,
72    PlayerState27 = 28,
73    PlayerState28 = 29,
74    PlayerState29 = 30,
75    PlayerState30 = 31,
76}
77
78impl StorageSlot {
79    /// All valid slot values.
80    pub const ALL: [StorageSlot; 32] = [
81        Self::UserSettings,
82        Self::AccountData,
83        Self::PlayerState1,
84        Self::PlayerState2,
85        Self::PlayerState3,
86        Self::PlayerState4,
87        Self::PlayerState5,
88        Self::PlayerState6,
89        Self::PlayerState7,
90        Self::PlayerState8,
91        Self::PlayerState9,
92        Self::PlayerState10,
93        Self::PlayerState11,
94        Self::PlayerState12,
95        Self::PlayerState13,
96        Self::PlayerState14,
97        Self::PlayerState15,
98        Self::PlayerState16,
99        Self::PlayerState17,
100        Self::PlayerState18,
101        Self::PlayerState19,
102        Self::PlayerState20,
103        Self::PlayerState21,
104        Self::PlayerState22,
105        Self::PlayerState23,
106        Self::PlayerState24,
107        Self::PlayerState25,
108        Self::PlayerState26,
109        Self::PlayerState27,
110        Self::PlayerState28,
111        Self::PlayerState29,
112        Self::PlayerState30,
113    ];
114
115    /// Whether this slot represents account-level data (not a save slot).
116    pub fn is_account(&self) -> bool {
117        matches!(self, Self::UserSettings | Self::AccountData)
118    }
119}
120
121// ---------------------------------------------------------------------------
122// SaveMetadata
123// ---------------------------------------------------------------------------
124
125/// Decrypted and parsed metadata from an `mf_save*.hg` file.
126#[derive(Debug, Clone)]
127pub struct SaveMetadata {
128    /// Format version: 0x7D1 (2001), 0x7D2 (2002), 0x7D3 (2003), 0x7D4 (2004).
129    pub format_version: u32,
130    /// Decompressed JSON size in bytes.
131    pub decompressed_size: u32,
132    /// Total compressed data size in bytes.
133    pub compressed_size: u32,
134    /// Profile hash (0 if none).
135    pub profile_hash: u32,
136    /// SpookyHash V2 keys (format 2001 only, None for 2002+).
137    pub spooky_hash: Option<[u64; 2]>,
138    /// SHA-256 of the raw storage file (format 2001 only, None for 2002+).
139    pub sha256_hash: Option<[u8; 32]>,
140    /// The storage slot that successfully decrypted this metadata.
141    pub decrypted_with_slot: StorageSlot,
142}
143
144// ---------------------------------------------------------------------------
145// Public API
146// ---------------------------------------------------------------------------
147
148/// Decrypt and parse a metadata file.
149///
150/// `data` is the raw bytes of the `mf_save*.hg` file.
151/// `slot` is the expected storage slot for this file.
152///
153/// The function first tries decryption with the given `slot`. If the magic
154/// sentinel is not found, it tries all other valid slots (in case the file
155/// was manually moved between save directories).
156pub fn read_metadata(data: &[u8], slot: StorageSlot) -> Result<SaveMetadata, SaveError> {
157    if !VALID_META_LENGTHS.contains(&data.len()) {
158        return Err(SaveError::InvalidMetaLength { length: data.len() });
159    }
160
161    let iterations = if data.len() == META_LENGTH_VANILLA {
162        ROUNDS_VANILLA
163    } else {
164        ROUNDS_DEFAULT
165    };
166
167    let u32_count = data.len() / 4;
168    let words: Vec<u32> = (0..u32_count)
169        .map(|i| u32::from_le_bytes(data[i * 4..(i + 1) * 4].try_into().unwrap()))
170        .collect();
171
172    // Try expected slot first, then all others of the same kind.
173    let is_account = slot.is_account();
174    let slots_to_try: Vec<StorageSlot> = std::iter::once(slot)
175        .chain(
176            StorageSlot::ALL
177                .iter()
178                .copied()
179                .filter(|&s| s != slot && s.is_account() == is_account),
180        )
181        .collect();
182
183    for try_slot in &slots_to_try {
184        let mut attempt = words.clone();
185        let key = derive_key(*try_slot);
186        xxtea_decrypt(&mut attempt, &key, iterations);
187
188        if attempt[0] == META_MAGIC {
189            return parse_decrypted_metadata(&attempt, *try_slot);
190        }
191    }
192
193    Err(SaveError::MetaDecryptionFailed)
194}
195
196/// Verify the SHA-256 hash stored in metadata against the raw save file bytes.
197///
198/// Only applicable for format 2001 (Foundation through Prisms). For format 2002+,
199/// SHA-256 is not used and this function returns `true`.
200pub fn verify_sha256(metadata: &SaveMetadata, raw_save_bytes: &[u8]) -> bool {
201    use sha2::{Digest, Sha256};
202
203    match metadata.sha256_hash {
204        Some(expected) => {
205            let mut hasher = Sha256::new();
206            hasher.update(raw_save_bytes);
207            let actual: [u8; 32] = hasher.finalize().into();
208            actual == expected
209        }
210        None => true,
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Internal
216// ---------------------------------------------------------------------------
217
218/// Parse fields from a successfully decrypted metadata u32 array.
219fn parse_decrypted_metadata(words: &[u32], slot: StorageSlot) -> Result<SaveMetadata, SaveError> {
220    let format_version = words[1];
221
222    if format_version == META_FORMAT_VANILLA {
223        return Err(SaveError::UnsupportedMetaFormat {
224            version: format_version,
225        });
226    }
227
228    let spooky_hash = if format_version == META_FORMAT_FOUNDATION {
229        let h0 = (words[2] as u64) | ((words[3] as u64) << 32);
230        let h1 = (words[4] as u64) | ((words[5] as u64) << 32);
231        Some([h0, h1])
232    } else {
233        None
234    };
235
236    let sha256_hash = if format_version == META_FORMAT_FOUNDATION {
237        let mut hash = [0u8; 32];
238        for i in 0..8 {
239            hash[i * 4..(i + 1) * 4].copy_from_slice(&words[6 + i].to_le_bytes());
240        }
241        Some(hash)
242    } else {
243        None
244    };
245
246    let decompressed_size = words[14];
247    let compressed_size = words[15];
248    let profile_hash = words[16];
249
250    Ok(SaveMetadata {
251        format_version,
252        decompressed_size,
253        compressed_size,
254        profile_hash,
255        spooky_hash,
256        sha256_hash,
257        decrypted_with_slot: slot,
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::xxtea::{META_ENCRYPTION_KEY, xxtea_encrypt};
265
266    #[test]
267    fn invalid_meta_length() {
268        let data = vec![0u8; 50];
269        let err = read_metadata(&data, StorageSlot::PlayerState1).unwrap_err();
270        match err {
271            SaveError::InvalidMetaLength { length } => assert_eq!(length, 50),
272            _ => panic!("expected InvalidMetaLength, got {err:?}"),
273        }
274    }
275
276    #[test]
277    fn valid_meta_lengths_reach_decryption() {
278        for &len in &[0x68, 0x168, 0x180, 0x1B0] {
279            let data = vec![0u8; len];
280            let err = read_metadata(&data, StorageSlot::PlayerState1).unwrap_err();
281            assert!(
282                matches!(err, SaveError::MetaDecryptionFailed),
283                "length {len:#x} should reach decryption stage, got {err:?}"
284            );
285        }
286    }
287
288    #[test]
289    fn parse_decrypted_metadata_format_2002() {
290        let mut words = vec![0u32; 26];
291        words[0] = META_MAGIC;
292        words[1] = 0x7D2; // Frontiers
293        words[14] = 1_000_000;
294        words[15] = 500_000;
295        words[16] = 0x12345678;
296
297        let meta = parse_decrypted_metadata(&words, StorageSlot::PlayerState1).unwrap();
298        assert_eq!(meta.format_version, 0x7D2);
299        assert_eq!(meta.decompressed_size, 1_000_000);
300        assert_eq!(meta.compressed_size, 500_000);
301        assert_eq!(meta.profile_hash, 0x12345678);
302        assert!(meta.spooky_hash.is_none());
303        assert!(meta.sha256_hash.is_none());
304    }
305
306    #[test]
307    fn parse_decrypted_metadata_format_2001() {
308        let mut words = vec![0u32; 26];
309        words[0] = META_MAGIC;
310        words[1] = META_FORMAT_FOUNDATION;
311        words[2] = 0xAABBCCDD;
312        words[3] = 0x11223344;
313        words[4] = 0x55667788;
314        words[5] = 0x99AABBCC;
315        for i in 6..14 {
316            words[i] = (i as u32) * 0x01010101;
317        }
318        words[14] = 2_000_000;
319        words[15] = 800_000;
320        words[16] = 0;
321
322        let meta = parse_decrypted_metadata(&words, StorageSlot::PlayerState1).unwrap();
323        assert_eq!(meta.format_version, 0x7D1);
324        let spooky = meta.spooky_hash.unwrap();
325        assert_eq!(spooky[0], 0x11223344_AABBCCDD_u64);
326        assert_eq!(spooky[1], 0x99AABBCC_55667788_u64);
327        assert!(meta.sha256_hash.is_some());
328    }
329
330    #[test]
331    fn unsupported_vanilla_format() {
332        let mut words = vec![0u32; 26];
333        words[0] = META_MAGIC;
334        words[1] = META_FORMAT_VANILLA;
335        let err = parse_decrypted_metadata(&words, StorageSlot::PlayerState1).unwrap_err();
336        match err {
337            SaveError::UnsupportedMetaFormat { version } => assert_eq!(version, 0x7D0),
338            _ => panic!("expected UnsupportedMetaFormat, got {err:?}"),
339        }
340    }
341
342    #[test]
343    fn storage_slot_is_account() {
344        assert!(StorageSlot::UserSettings.is_account());
345        assert!(StorageSlot::AccountData.is_account());
346        assert!(!StorageSlot::PlayerState1.is_account());
347        assert!(!StorageSlot::PlayerState30.is_account());
348    }
349
350    #[test]
351    fn storage_slot_all_has_32_entries() {
352        assert_eq!(StorageSlot::ALL.len(), 32);
353    }
354
355    #[test]
356    fn read_metadata_with_synthetic_encrypted_data() {
357        // Build plaintext metadata, encrypt it, then verify read_metadata decrypts it.
358        let slot = StorageSlot::PlayerState1;
359        let iterations = ROUNDS_DEFAULT;
360
361        // Build a 360-byte (META_LENGTH_WAYPOINT) metadata: 90 u32s
362        let u32_count = META_LENGTH_WAYPOINT / 4;
363        let mut words = vec![0u32; u32_count];
364        words[0] = META_MAGIC;
365        words[1] = 0x7D2; // format 2002
366        words[14] = 5_000_000;
367        words[15] = 2_000_000;
368        words[16] = 0xDEADBEEF;
369
370        // Encrypt
371        let key = derive_key(slot);
372        let mut encrypted = words.clone();
373        xxtea_encrypt(&mut encrypted, &key, iterations);
374
375        // Convert to bytes (little-endian)
376        let data: Vec<u8> = encrypted.iter().flat_map(|w| w.to_le_bytes()).collect();
377        assert_eq!(data.len(), META_LENGTH_WAYPOINT);
378
379        // Decrypt and parse
380        let meta = read_metadata(&data, slot).unwrap();
381        assert_eq!(meta.format_version, 0x7D2);
382        assert_eq!(meta.decompressed_size, 5_000_000);
383        assert_eq!(meta.compressed_size, 2_000_000);
384        assert_eq!(meta.profile_hash, 0xDEADBEEF);
385        assert_eq!(meta.decrypted_with_slot, slot);
386    }
387
388    #[test]
389    fn read_metadata_tries_other_slots() {
390        // Encrypt with PlayerState5, but pass PlayerState1 as expected slot.
391        let actual_slot = StorageSlot::PlayerState5;
392        let wrong_slot = StorageSlot::PlayerState1;
393        let iterations = ROUNDS_DEFAULT;
394
395        let u32_count = META_LENGTH_WAYPOINT / 4;
396        let mut words = vec![0u32; u32_count];
397        words[0] = META_MAGIC;
398        words[1] = 0x7D3;
399        words[14] = 100;
400        words[15] = 50;
401
402        let key = derive_key(actual_slot);
403        let mut encrypted = words.clone();
404        xxtea_encrypt(&mut encrypted, &key, iterations);
405
406        let data: Vec<u8> = encrypted.iter().flat_map(|w| w.to_le_bytes()).collect();
407
408        // Should succeed by trying all slots
409        let meta = read_metadata(&data, wrong_slot).unwrap();
410        assert_eq!(meta.decrypted_with_slot, actual_slot);
411    }
412
413    #[test]
414    fn read_metadata_vanilla_length_uses_8_rounds() {
415        let slot = StorageSlot::PlayerState1;
416        let iterations = ROUNDS_VANILLA;
417
418        let u32_count = META_LENGTH_VANILLA / 4;
419        let mut words = vec![0u32; u32_count];
420        words[0] = META_MAGIC;
421        words[1] = META_FORMAT_FOUNDATION; // 2001
422        words[14] = 999;
423        words[15] = 500;
424
425        let key = derive_key(slot);
426        let mut encrypted = words.clone();
427        xxtea_encrypt(&mut encrypted, &key, iterations);
428
429        let data: Vec<u8> = encrypted.iter().flat_map(|w| w.to_le_bytes()).collect();
430        assert_eq!(data.len(), META_LENGTH_VANILLA);
431
432        let meta = read_metadata(&data, slot).unwrap();
433        assert_eq!(meta.format_version, 0x7D1);
434        assert_eq!(meta.decompressed_size, 999);
435    }
436
437    #[test]
438    fn verify_sha256_no_hash_returns_true() {
439        let meta = SaveMetadata {
440            format_version: 0x7D2,
441            decompressed_size: 0,
442            compressed_size: 0,
443            profile_hash: 0,
444            spooky_hash: None,
445            sha256_hash: None,
446            decrypted_with_slot: StorageSlot::PlayerState1,
447        };
448        assert!(verify_sha256(&meta, b"anything"));
449    }
450
451    #[test]
452    fn verify_sha256_correct_hash() {
453        use sha2::{Digest, Sha256};
454
455        let data = b"test data for hashing";
456        let hash: [u8; 32] = Sha256::digest(data).into();
457
458        let meta = SaveMetadata {
459            format_version: 0x7D1,
460            decompressed_size: 0,
461            compressed_size: 0,
462            profile_hash: 0,
463            spooky_hash: None,
464            sha256_hash: Some(hash),
465            decrypted_with_slot: StorageSlot::PlayerState1,
466        };
467        assert!(verify_sha256(&meta, data));
468    }
469
470    #[test]
471    fn verify_sha256_wrong_hash() {
472        let meta = SaveMetadata {
473            format_version: 0x7D1,
474            decompressed_size: 0,
475            compressed_size: 0,
476            profile_hash: 0,
477            spooky_hash: None,
478            sha256_hash: Some([0xFF; 32]),
479            decrypted_with_slot: StorageSlot::PlayerState1,
480        };
481        assert!(!verify_sha256(&meta, b"test data"));
482    }
483
484    #[test]
485    fn meta_encryption_key_values() {
486        // Verify the key matches "NAESEVADNAYRTNRG"
487        assert_eq!(META_ENCRYPTION_KEY[0], 0x5345414E);
488        assert_eq!(META_ENCRYPTION_KEY[1], 0x44415645);
489        assert_eq!(META_ENCRYPTION_KEY[2], 0x5259414E);
490        assert_eq!(META_ENCRYPTION_KEY[3], 0x47524E54);
491    }
492}