nms_save/lib.rs
1//! Raw binary save file parser for No Man's Sky.
2//!
3//! Reads `save.hg` files directly from disk:
4//!
5//! 1. Detect format (plaintext JSON vs LZ4 compressed)
6//! 2. Parse sequential LZ4 blocks (magic `0xFEEDA1E5`), decompress, concatenate
7//! 3. Deobfuscate JSON keys using MBINCompiler's `mapping.json`
8//! 4. Deserialize into typed Rust structs via serde
9//!
10//! Also handles metadata verification (`mf_save.hg`) via XXTEA + SHA-256.
11
12pub mod convert;
13pub mod decompress;
14pub mod error;
15pub mod locate;
16pub mod mapping;
17pub mod metadata;
18pub mod model;
19pub mod xxtea;
20
21pub use decompress::{SaveFormat, decompress_save, decompress_save_file, detect_format};
22pub use error::SaveError;
23pub use mapping::{KeyMapping, deobfuscate_json, is_obfuscated};
24pub use metadata::{SaveMetadata, StorageSlot, read_metadata, verify_sha256};
25pub use model::SaveRoot;
26
27/// Parse deobfuscated save file JSON bytes into a [`SaveRoot`] struct.
28///
29/// The input must be valid UTF-8 JSON with plaintext (deobfuscated) keys.
30pub fn parse_save(json: &[u8]) -> Result<SaveRoot, SaveError> {
31 serde_json::from_slice(json).map_err(|e| SaveError::JsonParseError {
32 message: e.to_string(),
33 })
34}
35
36/// Parse a save file from disk, handling the full pipeline.
37///
38/// Runs the complete parsing pipeline:
39/// 1. Read raw bytes from disk
40/// 2. Decompress LZ4 blocks (passes through plaintext JSON unchanged)
41/// 3. Check for obfuscated keys and deobfuscate if needed
42/// 4. Deserialize into [`SaveRoot`]
43///
44/// This is the high-level entry point for reading NMS save files.
45/// For already-decompressed, already-deobfuscated JSON bytes, use [`parse_save`] instead.
46pub fn parse_save_file(path: &std::path::Path) -> Result<SaveRoot, SaveError> {
47 let raw = std::fs::read(path)?;
48 let decompressed = decompress_save(&raw)?;
49
50 // NMS saves can contain raw non-UTF-8 bytes in some string values
51 // (e.g., item hashes, binary IDs). Sanitize to valid UTF-8 before
52 // JSON parsing by replacing invalid sequences with U+FFFD.
53 let json_bytes = sanitize_for_json(&decompressed);
54
55 // Quick byte-level check: obfuscated saves start with {"F2P" (the obfuscated
56 // "Version" key).
57 if is_obfuscated_bytes(json_bytes.as_bytes()) {
58 let mapping = KeyMapping::bundled();
59 let value = deobfuscate_json(json_bytes.as_bytes(), &mapping)?;
60 serde_json::from_value(value).map_err(|e| SaveError::JsonParseError {
61 message: e.to_string(),
62 })
63 } else {
64 parse_save(json_bytes.as_bytes())
65 }
66}
67
68/// Sanitize decompressed save bytes for JSON parsing.
69///
70/// 1. Strips trailing null bytes (NMS LZ4 blocks are padded with nulls)
71/// 2. Replaces invalid UTF-8 sequences with U+FFFD (NMS saves can contain
72/// raw non-UTF-8 bytes in some string values like item hashes)
73fn sanitize_for_json(data: &[u8]) -> std::borrow::Cow<'_, str> {
74 let trimmed = match data.iter().rposition(|&b| b != 0) {
75 Some(pos) => &data[..=pos],
76 None => data,
77 };
78 String::from_utf8_lossy(trimmed)
79}
80
81/// Check if decompressed save bytes have obfuscated keys.
82///
83/// Scans the first bytes (skipping whitespace) for the `{"F2P"` pattern,
84/// which is the obfuscated form of the `"Version"` key and always appears
85/// first in obfuscated NMS saves. Works on raw bytes without requiring
86/// valid UTF-8.
87fn is_obfuscated_bytes(data: &[u8]) -> bool {
88 let marker = b"{\"F2P\"";
89 let trimmed = data
90 .iter()
91 .position(|&b| !b.is_ascii_whitespace())
92 .map(|pos| &data[pos..])
93 .unwrap_or(data);
94 trimmed.starts_with(marker)
95}