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