Skip to main content

nms_save/
decompress.rs

1//! LZ4 block decompression for NMS save files.
2
3use crate::error::SaveError;
4
5/// LZ4 block magic number (little-endian).
6const BLOCK_MAGIC: u32 = 0xFEEDA1E5;
7
8/// Size of a single block header in bytes.
9const BLOCK_HEADER_SIZE: usize = 0x10;
10
11/// Maximum decompressed size per block.
12const MAX_CHUNK_SIZE: usize = 0x80000;
13
14/// Detected format of a save file's raw bytes.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SaveFormat {
17    /// Standard NMS format (2002+): sequential LZ4 blocks with 16-byte headers.
18    Lz4Compressed,
19    /// Uncompressed JSON (first two bytes are 0x7B 0x22, i.e. `{"`).
20    PlaintextJson,
21}
22
23/// Parsed header for a single LZ4 block.
24#[derive(Debug, Clone, Copy)]
25struct BlockHeader {
26    compressed_size: u32,
27    decompressed_size: u32,
28}
29
30/// Detect whether raw save file bytes are LZ4-compressed or plaintext JSON.
31///
32/// Checks the first two bytes for `{"` (ASCII 0x7B 0x22). If found, returns
33/// [`SaveFormat::PlaintextJson`]. Otherwise returns [`SaveFormat::Lz4Compressed`].
34pub fn detect_format(data: &[u8]) -> SaveFormat {
35    if data.len() >= 2 && data[0] == 0x7B && data[1] == 0x22 {
36        SaveFormat::PlaintextJson
37    } else {
38        SaveFormat::Lz4Compressed
39    }
40}
41
42/// Decompress an entire NMS save file.
43///
44/// - If the data is plaintext JSON (starts with `{"`), returns a clone of the input.
45/// - If the data is LZ4 compressed, parses all blocks and returns concatenated
46///   decompressed bytes.
47///
48/// The returned bytes are UTF-8 JSON.
49pub fn decompress_save(data: &[u8]) -> Result<Vec<u8>, SaveError> {
50    match detect_format(data) {
51        SaveFormat::PlaintextJson => Ok(data.to_vec()),
52        SaveFormat::Lz4Compressed => decompress_blocks(data),
53    }
54}
55
56/// Convenience function: read a file from disk and decompress it.
57pub fn decompress_save_file(path: &std::path::Path) -> Result<Vec<u8>, SaveError> {
58    let data = std::fs::read(path)?;
59    decompress_save(&data)
60}
61
62/// Parse a 16-byte block header at the given byte offset.
63fn parse_block_header(data: &[u8], offset: usize) -> Result<BlockHeader, SaveError> {
64    if offset + BLOCK_HEADER_SIZE > data.len() {
65        return Err(SaveError::UnexpectedEof {
66            offset,
67            expected: BLOCK_HEADER_SIZE,
68        });
69    }
70
71    let magic = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
72    if magic != BLOCK_MAGIC {
73        return Err(SaveError::InvalidMagic {
74            offset,
75            found: magic,
76        });
77    }
78
79    let compressed_size = u32::from_le_bytes(data[offset + 4..offset + 8].try_into().unwrap());
80    let decompressed_size = u32::from_le_bytes(data[offset + 8..offset + 12].try_into().unwrap());
81
82    if decompressed_size > MAX_CHUNK_SIZE as u32 {
83        return Err(SaveError::ChunkTooLarge {
84            offset,
85            declared: decompressed_size,
86        });
87    }
88
89    Ok(BlockHeader {
90        compressed_size,
91        decompressed_size,
92    })
93}
94
95/// Read and decompress all LZ4 blocks from raw save file bytes.
96fn decompress_blocks(data: &[u8]) -> Result<Vec<u8>, SaveError> {
97    let mut output = Vec::new();
98    let mut offset: usize = 0;
99
100    while offset < data.len() {
101        let header = parse_block_header(data, offset)?;
102        offset += BLOCK_HEADER_SIZE;
103
104        let payload_end = offset + header.compressed_size as usize;
105        if payload_end > data.len() {
106            return Err(SaveError::UnexpectedEof {
107                offset,
108                expected: header.compressed_size as usize,
109            });
110        }
111
112        let compressed = &data[offset..payload_end];
113
114        let decompressed =
115            lz4_flex::block::decompress(compressed, header.decompressed_size as usize).map_err(
116                |e| SaveError::DecompressionFailed {
117                    offset: offset - BLOCK_HEADER_SIZE,
118                    message: e.to_string(),
119                },
120            )?;
121
122        output.extend_from_slice(&decompressed);
123        offset = payload_end;
124    }
125
126    Ok(output)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// Build a valid NMS-format LZ4 file from raw JSON bytes.
134    fn create_test_save(json: &[u8]) -> Vec<u8> {
135        let mut output = Vec::new();
136        for chunk in json.chunks(MAX_CHUNK_SIZE) {
137            let compressed = lz4_flex::block::compress(chunk);
138            let compressed_size = compressed.len() as u32;
139            let decompressed_size = chunk.len() as u32;
140
141            output.extend_from_slice(&BLOCK_MAGIC.to_le_bytes());
142            output.extend_from_slice(&compressed_size.to_le_bytes());
143            output.extend_from_slice(&decompressed_size.to_le_bytes());
144            output.extend_from_slice(&0u32.to_le_bytes());
145            output.extend_from_slice(&compressed);
146        }
147        output
148    }
149
150    #[test]
151    fn detect_plaintext_json() {
152        let data = br#"{"Version": 6726}"#;
153        assert_eq!(detect_format(data), SaveFormat::PlaintextJson);
154    }
155
156    #[test]
157    fn detect_lz4_compressed() {
158        let mut data = vec![0xE5, 0xA1, 0xED, 0xFE];
159        data.extend_from_slice(&[0; 12]);
160        assert_eq!(detect_format(&data), SaveFormat::Lz4Compressed);
161    }
162
163    #[test]
164    fn detect_empty_input() {
165        assert_eq!(detect_format(&[]), SaveFormat::Lz4Compressed);
166    }
167
168    #[test]
169    fn parse_block_header_valid() {
170        let mut header = Vec::new();
171        header.extend_from_slice(&BLOCK_MAGIC.to_le_bytes());
172        header.extend_from_slice(&100u32.to_le_bytes());
173        header.extend_from_slice(&200u32.to_le_bytes());
174        header.extend_from_slice(&0u32.to_le_bytes());
175
176        let bh = parse_block_header(&header, 0).unwrap();
177        assert_eq!(bh.compressed_size, 100);
178        assert_eq!(bh.decompressed_size, 200);
179    }
180
181    #[test]
182    fn parse_block_header_invalid_magic() {
183        let mut header = Vec::new();
184        header.extend_from_slice(&0xDEADBEEFu32.to_le_bytes());
185        header.extend_from_slice(&[0; 12]);
186
187        let err = parse_block_header(&header, 0).unwrap_err();
188        match err {
189            SaveError::InvalidMagic { offset, found } => {
190                assert_eq!(offset, 0);
191                assert_eq!(found, 0xDEADBEEF);
192            }
193            _ => panic!("expected InvalidMagic, got {err:?}"),
194        }
195    }
196
197    #[test]
198    fn parse_block_header_truncated() {
199        let data = [0u8; 8];
200        let err = parse_block_header(&data, 0).unwrap_err();
201        match err {
202            SaveError::UnexpectedEof { offset, expected } => {
203                assert_eq!(offset, 0);
204                assert_eq!(expected, BLOCK_HEADER_SIZE);
205            }
206            _ => panic!("expected UnexpectedEof, got {err:?}"),
207        }
208    }
209
210    #[test]
211    fn parse_block_header_chunk_too_large() {
212        let mut header = Vec::new();
213        header.extend_from_slice(&BLOCK_MAGIC.to_le_bytes());
214        header.extend_from_slice(&100u32.to_le_bytes());
215        header.extend_from_slice(&(MAX_CHUNK_SIZE as u32 + 1).to_le_bytes());
216        header.extend_from_slice(&0u32.to_le_bytes());
217
218        let err = parse_block_header(&header, 0).unwrap_err();
219        match err {
220            SaveError::ChunkTooLarge { offset, declared } => {
221                assert_eq!(offset, 0);
222                assert_eq!(declared, MAX_CHUNK_SIZE as u32 + 1);
223            }
224            _ => panic!("expected ChunkTooLarge, got {err:?}"),
225        }
226    }
227
228    #[test]
229    fn roundtrip_single_block() {
230        let json = br#"{"Version": 6726, "Platform": "PC"}"#;
231        let save = create_test_save(json);
232        let result = decompress_save(&save).unwrap();
233        assert_eq!(&result, json);
234    }
235
236    #[test]
237    fn roundtrip_multiple_blocks() {
238        let big_json = format!(r#"{{"data": "{}"}}"#, "x".repeat(MAX_CHUNK_SIZE + 1000));
239        let save = create_test_save(big_json.as_bytes());
240        let result = decompress_save(&save).unwrap();
241        assert_eq!(result, big_json.as_bytes());
242    }
243
244    #[test]
245    fn plaintext_passthrough() {
246        let json = br#"{"Version": 6726}"#;
247        let result = decompress_save(json).unwrap();
248        assert_eq!(&result, json);
249    }
250
251    #[test]
252    fn truncated_payload() {
253        let mut data = Vec::new();
254        data.extend_from_slice(&BLOCK_MAGIC.to_le_bytes());
255        data.extend_from_slice(&1000u32.to_le_bytes());
256        data.extend_from_slice(&2000u32.to_le_bytes());
257        data.extend_from_slice(&0u32.to_le_bytes());
258        data.extend_from_slice(&[0u8; 10]);
259
260        let err = decompress_save(&data).unwrap_err();
261        assert!(matches!(err, SaveError::UnexpectedEof { .. }));
262    }
263
264    #[test]
265    fn invalid_magic_at_second_block() {
266        let json = b"hello";
267        let compressed = lz4_flex::block::compress(json);
268        let mut data = Vec::new();
269
270        // Valid first block
271        data.extend_from_slice(&BLOCK_MAGIC.to_le_bytes());
272        data.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
273        data.extend_from_slice(&(json.len() as u32).to_le_bytes());
274        data.extend_from_slice(&0u32.to_le_bytes());
275        data.extend_from_slice(&compressed);
276
277        // Invalid second block
278        let bad_magic: u32 = 0xBAD;
279        data.extend_from_slice(&bad_magic.to_le_bytes());
280        data.extend_from_slice(&[0u8; 12]);
281
282        let err = decompress_save(&data).unwrap_err();
283        match err {
284            SaveError::InvalidMagic { offset, .. } => {
285                assert_eq!(offset, BLOCK_HEADER_SIZE + compressed.len());
286            }
287            _ => panic!("expected InvalidMagic, got {err:?}"),
288        }
289    }
290
291    #[test]
292    fn decompress_save_file_not_found() {
293        let err = decompress_save_file(std::path::Path::new("/nonexistent/save.hg")).unwrap_err();
294        assert!(matches!(err, SaveError::Io(_)));
295    }
296}