1use crate::error::SaveError;
4
5const BLOCK_MAGIC: u32 = 0xFEEDA1E5;
7
8const BLOCK_HEADER_SIZE: usize = 0x10;
10
11const MAX_CHUNK_SIZE: usize = 0x80000;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SaveFormat {
17 Lz4Compressed,
19 PlaintextJson,
21}
22
23#[derive(Debug, Clone, Copy)]
25struct BlockHeader {
26 compressed_size: u32,
27 decompressed_size: u32,
28}
29
30pub 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
43pub 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
57pub 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
63fn 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
96fn 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 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 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 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}