slpz/
lib.rs

1//! Compresses and decompresses between the slp and slpz formats.
2//!
3//! You can expect slpz files to be around 8x to 12x times smaller than slp files for regular matches.
4//! (~3Mb down to ~300Kb).
5//!
6//! Compression is done with the zstd compression library.
7//! zstd is not required on the user's computer; the library is statically linked at compile time.
8//!
9//! The slpz format is documented in the readme in the repo.
10//! Important information, such as player tags, stages, date, characters, etc. all remain uncompressed in the slpz format.
11//! This allows slp file browsers to easily parse and display this information without needing to decompress the replay.
12
13use std::path::{Path, PathBuf};
14
15#[derive(Copy, Clone, Debug, PartialEq)]
16pub enum CompError {
17    InvalidFile,
18    CompressionFailure,
19}
20
21#[derive(Copy, Clone, Debug, PartialEq)]
22pub enum DecompError {
23    InvalidFile,
24    DecompressionFailure,
25}
26
27#[derive(Copy, Clone, Debug, PartialEq)]
28pub enum TargetPathError {
29    PathNotFound,
30    PathInvalid,
31    CompressOrDecompressAmbiguous,
32    ZstdInitError,
33}
34
35impl std::fmt::Display for CompError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "{}", match self {
38            CompError::InvalidFile => "File is invalid",
39            CompError::CompressionFailure => "Compression failed",
40        })
41    }
42}
43
44impl std::fmt::Display for DecompError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(f, "{}", match self {
47            DecompError::InvalidFile => "File is invalid",
48            DecompError::DecompressionFailure => "Decompression failed",
49        })
50    }
51}
52
53impl std::fmt::Display for TargetPathError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "{}", match self {
56            TargetPathError::PathNotFound => "Replay path not found",
57            TargetPathError::PathInvalid => "Replay path invalid",
58            TargetPathError::CompressOrDecompressAmbiguous => "Not a slp or slpz file",
59            TargetPathError::ZstdInitError => "Failed to init zstd",
60        })
61    }
62}
63
64const EVENT_PAYLOADS: u8 = 0x35;
65const GAME_START: u8 = 0x36;
66const RAW_HEADER: [u8; 11] = [0x7B, 0x55, 0x03, 0x72, 0x61, 0x77, 0x5B, 0x24, 0x55, 0x23, 0x6C];
67
68pub const VERSION: u32 = 0;
69
70pub struct Compressor { ctx: zstd::bulk::Compressor<'static> }
71pub struct Decompressor { ctx: zstd::bulk::Decompressor<'static> }
72
73impl Compressor {
74    /// compression_level should be between 1..=19. The default is 3.
75    pub fn new(compression_level: i32) -> Option<Compressor> {
76        Some(Compressor {
77            ctx: zstd::bulk::Compressor::new(compression_level).ok()?,
78        })
79    }
80}
81
82impl Decompressor {
83    pub fn new() -> Option<Decompressor> {
84        Some(Decompressor { ctx: zstd::bulk::Decompressor::new().ok()? })
85    }
86}
87
88/// Compresses an slp file to an slpz file.
89pub fn compress(compressor: &mut Compressor, slp: &[u8]) -> Result<Vec<u8>, CompError> {
90    if slp.len() < 16 { return Err(CompError::InvalidFile) }
91    if slp[0..11] != RAW_HEADER { return Err(CompError::InvalidFile) }
92
93    // get metadata
94    let raw_len = u32::from_be_bytes(slp[11..15].try_into().unwrap()) as usize;
95    let metadata_offset = 15 + raw_len;
96    if slp.len() < metadata_offset as usize { return Err(CompError::InvalidFile) }
97    let metadata = &slp[metadata_offset..];
98
99    // get event sizes
100    if slp[15] != EVENT_PAYLOADS { return Err(CompError::InvalidFile) }
101    let (event_sizes, event_type_count) = event_sizes(&slp[15..]).ok_or(CompError::InvalidFile)?;
102    let event_sizes_size = 2 + event_type_count * 3;
103    let event_sizes_payload = &slp[15..][..event_sizes_size];
104
105    // get game start
106    let game_start_offset = 15 + event_sizes_size;
107    let game_start_size = event_sizes[GAME_START as usize] as usize + 1;
108    if slp.len() < game_start_offset+game_start_size { return Err(CompError::InvalidFile) }
109    if slp[game_start_offset] != GAME_START { return Err(CompError::InvalidFile) }
110    let game_start_payload = &slp[game_start_offset..][..game_start_size];
111
112    let other_events_offset = game_start_offset+game_start_size;
113    if metadata_offset < other_events_offset { return Err(CompError::InvalidFile); }
114
115    let mut slpz = Vec::with_capacity(slp.len());
116
117    // header
118    slpz.extend_from_slice(&VERSION.to_be_bytes());
119    slpz.extend_from_slice(&[0u8; 20]); // offsets filled later
120
121    // write event sizes
122    let len = slpz.len() as u32;
123    slpz[4..8].copy_from_slice(&len.to_be_bytes());
124    slpz.extend_from_slice(event_sizes_payload);
125
126    // write game start
127    let len = slpz.len() as u32;
128    slpz[8..12].copy_from_slice(&len.to_be_bytes());
129    slpz.extend_from_slice(game_start_payload);
130
131    // write metadata
132    let len = slpz.len() as u32;
133    slpz[12..16].copy_from_slice(&len.to_be_bytes());
134    slpz.extend_from_slice(metadata);
135
136    // write compressed events
137    let len = slpz.len() as u32;
138    slpz[16..20].copy_from_slice(&len.to_be_bytes());
139
140    let mut reordered_data = Vec::with_capacity(slp.len());
141    let written = reorder_events(&slp[other_events_offset..metadata_offset], &event_sizes, &mut reordered_data)?;
142    slpz[20..24].copy_from_slice(&(written as u32).to_be_bytes());
143
144    // wrap in cursor so we don't overwrite previous data
145    let mut slpz_cursor = std::io::Cursor::new(slpz);
146    slpz_cursor.set_position(len as u64);
147    compressor.ctx.compress_to_buffer(&reordered_data, &mut slpz_cursor).map_err(|_| CompError::CompressionFailure)?;
148
149    Ok(slpz_cursor.into_inner())
150}
151
152/// Decompresses an slpz file to an slp file.
153#[rustfmt::skip]
154pub fn decompress(decompressor: &mut Decompressor, slpz: &[u8]) -> Result<Vec<u8>, DecompError> {
155    if slpz.len() < 24 { return Err(DecompError::InvalidFile) }
156    let version                  = u32::from_be_bytes(slpz[0..4].try_into().unwrap());
157    let event_sizes_offset       = u32::from_be_bytes(slpz[4..8].try_into().unwrap()) as usize;
158    let game_start_offset        = u32::from_be_bytes(slpz[8..12].try_into().unwrap()) as usize;
159    let metadata_offset          = u32::from_be_bytes(slpz[12..16].try_into().unwrap()) as usize;
160    let compressed_events_offset = u32::from_be_bytes(slpz[16..20].try_into().unwrap()) as usize;
161    let decompressed_events_size = u32::from_be_bytes(slpz[20..24].try_into().unwrap()) as usize;
162
163    if slpz.len() < compressed_events_offset { return Err(DecompError::InvalidFile) }
164
165    // We do not return a custom version error here.
166    // If a file is invalid, it would raise this error instead of an InvalidFile.
167    // Unsupported version errors would be nice to check, but too many false positives.
168    if version > VERSION { return Err(DecompError::InvalidFile) }
169
170    let mut slp = Vec::with_capacity(slpz.len() * 32);
171    slp.extend_from_slice(&RAW_HEADER);
172    slp.extend_from_slice(&[0u8; 4]); // raw len. filled in later
173
174    let event_sizes_bytes = &slpz[event_sizes_offset..game_start_offset];
175    slp.extend_from_slice(event_sizes_bytes);
176    let (event_sizes, _) = event_sizes(event_sizes_bytes).ok_or(DecompError::InvalidFile)?;
177    slp.extend_from_slice(&slpz[game_start_offset..metadata_offset]);
178
179    let b = decompressor.ctx.decompress(&slpz[compressed_events_offset..], decompressed_events_size)
180        .map_err(|_| DecompError::DecompressionFailure)?;
181    unorder_events(&b, &event_sizes, &mut slp)?;
182
183    let metadata_offset_in_slp = slp.len();
184    slp.extend_from_slice(&slpz[metadata_offset..compressed_events_offset]);
185
186    slp[11..15].copy_from_slice(&(metadata_offset_in_slp as u32 - 15).to_be_bytes()); // raw len
187
188    Ok(slp)
189}
190
191/// Reorders events into byte columns.
192fn reorder_events(
193    events: &[u8],
194    event_sizes: &[u16; 256],
195    buf: &mut Vec<u8>,
196) -> Result<usize, CompError> {
197    let event_counts = event_counts(events, event_sizes)?;
198
199    // ---------------------------------------
200    // Build the offset lookup table 'reordered_event_offsets'.
201    // This is the offset of the start of the reordered data for each event in the reordered event data section.
202
203    let mut total_events = 0usize;
204    let mut reordered_event_offsets = [0u32; 256];
205
206    for i in 0..255 {
207        let size = event_sizes[i];
208        let count = event_counts[i];
209        total_events += count as usize;
210
211        let event_total_size = size as u32 * count;
212
213        // offset for next event is the end of this event.
214        reordered_event_offsets[i + 1] = reordered_event_offsets[i] + event_total_size;
215    }
216
217    let reordered_size = {
218        let last_size = event_sizes[255];
219        let last_count = event_counts[255];
220        total_events += last_count as usize;
221        let last_total_size = last_count as usize * last_size as usize;
222
223        reordered_event_offsets[255] as usize + last_total_size
224    };
225
226    if reordered_size != events.len() - total_events { return Err(CompError::InvalidFile) }
227
228    // alloc
229    let data_size = 4 + total_events + reordered_size;
230    let buf_prev = buf.len();
231    buf.resize(buf_prev + data_size, 0u8);
232    let data = &mut buf[buf_prev..];
233
234    // ---------------------------------------
235    // fill event order list and reordered data
236
237    data[0..4].copy_from_slice(&(total_events as u32).to_be_bytes());
238
239    let event_order_list_offset = 4;
240    let reordered_events_offset = event_order_list_offset + total_events;
241
242    let mut events_written = [0u32; 256];
243    let mut event_i = 0;
244    let mut i = 0;
245    while i < events.len() {
246        let event_u8 = events[i];
247        let event = event_u8 as usize;
248
249        // fill event order list
250        data[event_order_list_offset + event_i] = event_u8;
251
252        // fill reorder data
253        let event_offset = reordered_events_offset + reordered_event_offsets[event] as usize;
254        let written = events_written[event] as usize;
255        let size = event_sizes[event] as usize;
256        let stride = event_counts[event] as usize;
257
258        let write_start = event_offset + written;
259        for j in 0..size {
260            data[write_start + j * stride] = events[1 + i + j];
261        }
262
263        events_written[event] += 1;
264
265        i += 1 + size;
266        event_i += 1;
267    }
268
269    Ok(data_size)
270}
271
272/// Undoes the reordering done by 'reorder_events'.
273///
274/// Returns the number of bytes written.
275pub(crate) fn unorder_events(
276    b: &[u8],
277    event_sizes: &[u16; 256],
278    buf: &mut Vec<u8>,
279) -> Result<usize, DecompError> {
280    let total_events = u32::from_be_bytes(b[0..4].try_into().unwrap()) as usize;
281
282    let event_order_list_offset = 4;
283    let reordered_events_offset = event_order_list_offset + total_events;
284
285    let mut event_counts = [0u32; 256];
286    for i in 0..total_events {
287        let event = b[event_order_list_offset + i] as usize;
288        event_counts[event] += 1;
289    }
290
291    let mut reordered_event_offsets = [0u32; 256];
292    for i in 0..255 {
293        let size = event_sizes[i];
294        let count = event_counts[i];
295
296        let event_total_size = size as u32 * count;
297
298        // offset for next event is the end of this event.
299        reordered_event_offsets[i + 1] = reordered_event_offsets[i] + event_total_size;
300    }
301
302    let unordered_size = {
303        let last_size = event_sizes[255];
304        let last_count = event_counts[255];
305        let last_total_size = last_count as usize * last_size as usize;
306        reordered_event_offsets[255] as usize + last_total_size + total_events
307    };
308
309    let event_order_list = &b[event_order_list_offset..reordered_events_offset];
310    let events = &b[reordered_events_offset..];
311
312    if unordered_size != events.len() + total_events { return Err(DecompError::InvalidFile) }
313
314    let buf_prev = buf.len();
315    buf.resize(buf_prev + unordered_size, 0u8);
316    let data = &mut buf[buf_prev..];
317
318    let mut events_written = [0u32; 256];
319
320    let mut data_i = 0;
321    for &event_u8 in event_order_list.iter().take(total_events) {
322        let event = event_u8 as usize;
323
324        // command byte
325        data[data_i] = event_u8;
326
327        // unorder data
328        let event_offset = reordered_event_offsets[event] as usize;
329        let written = events_written[event] as usize;
330        let size = event_sizes[event] as usize;
331        let stride = event_counts[event] as usize;
332
333        let write_start = event_offset + written;
334        for j in 0..size {
335            data[1 + data_i + j] = events[write_start + j * stride];
336        }
337
338        events_written[event] += 1;
339
340        data_i += 1 + size;
341    }
342
343    Ok(unordered_size)
344}
345
346fn event_sizes(events: &[u8]) -> Option<([u16; 256], usize)> {
347    if events.is_empty() { return None }
348
349    let info_size = events[1] as usize;
350    let event_count = (info_size - 1) / 3;
351
352    if events.len() < info_size { return None }
353
354    let mut event_payload_sizes = [0; 256];
355    for i in 0..event_count {
356        let offset = i * 3 + 2;
357        let command_byte = events[offset] as usize;
358        let payload_size = u16::from_be_bytes(events[offset + 1..][..2].try_into().unwrap());
359        event_payload_sizes[command_byte] = payload_size;
360    }
361
362    Some((event_payload_sizes, event_count))
363}
364
365fn event_counts(events: &[u8], event_sizes: &[u16; 256]) -> Result<[u32; 256], CompError> {
366    let mut i = 0;
367    let mut counts = [0u32; 256];
368
369    while i < events.len() {
370        let event = events[i] as usize;
371        let event_size = event_sizes[event];
372        if event_size == 0 { return Err(CompError::InvalidFile) }
373        counts[event] += 1;
374        i += 1 + event_size as usize; // skip command byte and payload
375    }
376
377    Ok(counts)
378}
379
380#[derive(Clone, Debug)]
381pub struct Options {
382    pub keep: bool,
383    pub compress: Option<bool>,
384    pub recursive: bool,
385    pub threading: bool,
386    /// must be between 1 and 19.
387    pub level: i32,
388    pub log: bool,
389    pub output_path: Option<PathBuf>,
390}
391
392impl Default for Options {
393    fn default() -> Self { Options::DEFAULT }
394}
395
396impl Options {
397    pub const DEFAULT: Self = Options {
398        keep: true,
399        compress: None,
400        recursive: false,
401        threading: true,
402        level: 3,
403        log: true,
404        output_path: None,
405    };
406}
407
408/// Library access to slpz program functionality.
409///
410/// If Some, the sender will first send the number of targets.
411/// After that, the sender will send '1' for each target completed.
412/// If the sender cannot send, it will panic.
413///
414/// - Threaded directory compression/decompression.
415/// - Compression/decompression autodetection.
416/// - Deletion of old files.
417pub fn target_path(
418    options: &Options,
419    path: &std::path::Path,
420    sender: Option<std::sync::mpsc::Sender<usize>>,
421) -> Result<(), TargetPathError> {
422    let mut targets = Vec::new();
423    let mut should_compress = options.compress;
424
425    if path == Path::new("-") {
426        targets.push(path.to_owned());
427    } else {
428        if !matches!(path.try_exists(), Ok(true)) {
429            return Err(TargetPathError::PathNotFound)
430        }
431
432        if path.is_dir() {
433            let c = match should_compress {
434                Some(c) => c,
435                None => return Err(TargetPathError::CompressOrDecompressAmbiguous),
436            };
437            let ex = std::ffi::OsStr::new(if c { "slp" } else { "slpz" });
438            get_targets(&mut targets, path, options.recursive, ex);
439        } else if path.is_file() {
440            targets.push(path.to_path_buf());
441            if should_compress.is_none() {
442                let ex = path.extension();
443                if ex == Some(std::ffi::OsStr::new("slp")) {
444                    should_compress = Some(true);
445                } else if ex == Some(std::ffi::OsStr::new("slpz")) {
446                    should_compress = Some(false);
447                }
448            }
449        } else {
450            return Err(TargetPathError::PathInvalid);
451        }
452    }
453
454    let will_compress = match should_compress {
455        Some(n) => n,
456        None => return Err(TargetPathError::CompressOrDecompressAmbiguous),
457    };
458
459    if let Some(ref sender) = sender {
460        sender.send(targets.len()).expect("Sending failed");
461    }
462
463    if !options.threading || targets.len() < 8 {
464        if will_compress {
465            let mut compressor = Compressor::new(options.level).ok_or(TargetPathError::ZstdInitError)?;
466            for t in &targets {
467                compress_target(&mut compressor, options, t);
468                if let Some(ref sender) = sender { sender.send(1).expect("Sending failed"); }
469            }
470        } else {
471            let mut decompressor = Decompressor::new().ok_or(TargetPathError::ZstdInitError)?;
472            for t in &targets {
473                decompress_target(&mut decompressor, options, t);
474                if let Some(ref sender) = sender { sender.send(1).expect("Sending failed"); }
475            }
476        }
477    } else {
478        // split into 8 approximately equal slices (why is this so annoying?)
479        let mut slices: [&[std::path::PathBuf]; 8] = [&[]; 8];
480        let chunk = targets.len() / 8;
481        let split = (chunk + 1) * (targets.len() % 8);
482        for (i, c) in targets[..split].chunks(chunk+1).chain(targets[split..].chunks(chunk)).enumerate() {
483            slices[i] = c;
484        }
485
486        let sender_ref = sender.as_ref();
487
488        std::thread::scope(|scope| {
489            if will_compress {
490                for s in slices {
491                    scope.spawn(move || {
492                        let mut compressor = match Compressor::new(options.level) {
493                            Some(c) => c,
494                            None => {
495                                eprintln!("Error: Failed to init zstd compressor");
496                                return;
497                            }
498                        };
499                        for t in s {
500                            compress_target(&mut compressor, options, t);
501                            if let Some(sender) = sender_ref { sender.send(1).expect("Sending failed"); }
502                        }
503                    });
504                }
505            } else {
506                for s in slices {
507                    scope.spawn(move || {
508                        let mut decompressor = match Decompressor::new() {
509                            Some(d) => d,
510                            None => {
511                                eprintln!("Error: Failed to init zstd decompressor");
512                                return;
513                            }
514                        };
515                        for t in s {
516                            decompress_target(&mut decompressor, options, t);
517                            if let Some(sender) = sender_ref { sender.send(1).expect("Sending failed"); }
518                        }
519                    });
520                }
521            };
522        })
523    }
524
525    Ok(())
526}
527
528fn compress_target(c: &mut Compressor, options: &Options, t: &std::path::PathBuf) {
529    let res = if t == Path::new("-") {
530        use std::io::Read;
531        let mut b = Vec::new();
532        std::io::stdin()
533            .read_to_end(&mut b)
534            .map(|_| b)
535    } else {
536        std::fs::read(t)
537    };
538
539    let slp = match res {
540        Ok(s) => s,
541        Err(e) => {
542            eprintln!("Error compressing {}: {}", t.display(), e);
543            return;
544        }
545    };
546
547    match compress(c, &slp) {
548        Ok(slpz) => {
549            let out = match options.output_path.as_ref() {
550                Some(p) => p.clone(),
551                None => {
552                    let mut p = t.clone();
553                    if !p.set_extension("slpz") {
554                        eprintln!("Error creating new filename for {}", t.display());
555                        return;
556                    };
557                    p
558                }
559            };
560            
561            let err = if &out == Path::new("-") {
562                use std::io::Write;
563                std::io::stdout().write_all(&slpz)
564            } else {
565                std::fs::write(&out, &slpz)
566            };
567            
568            match err {
569                Ok(_) => {
570                    if options.log { println!("compressed {}", t.display()); }
571                    if !options.keep {
572                        match std::fs::remove_file(t) {
573                            Ok(_) => if options.log { println!("removed {}", t.display()) },
574                            Err(e) => {
575                                eprintln!("Error removing {}: {}", t.display(), e);
576                            }
577                        }
578                    }
579                }
580                Err(e) => {
581                    eprintln!("Error compressing {}: {}", t.display(), e);
582                }
583            }
584        }
585        Err(e) => {
586            eprintln!("Error compressing {}: {}", t.display(), e);
587        }
588    }
589}
590
591fn decompress_target(d: &mut Decompressor, options: &Options, t: &std::path::PathBuf) {
592    let res = if t == Path::new("-") {
593        use std::io::Read;
594        let mut b = Vec::new();
595        std::io::stdin()
596            .read_to_end(&mut b)
597            .map(|_| b)
598    } else {
599        std::fs::read(t)
600    };
601
602    let slpz = match res {
603        Ok(s) => s,
604        Err(e) => {
605            eprintln!("Error compressing {}: {}", t.display(), e);
606            return;
607        }
608    };
609
610    match decompress(d, &slpz) {
611        Ok(slp) => {
612            let out = match options.output_path.as_ref() {
613                Some(p) => p.clone(),
614                None => {
615                    let mut p = t.clone();
616                    if !p.set_extension("slp") {
617                        eprintln!("Error creating new filename for {}", t.display());
618                        return;
619                    };
620                    p
621                }
622            };
623            
624            let err = if &out == Path::new("-") {
625                use std::io::Write;
626                std::io::stdout().write_all(&slp)
627            } else {
628                std::fs::write(&out, &slp)
629            };
630            
631            match err {
632                Ok(_) => {
633                    if options.log { println!("decompressed {}", t.display()); }
634                    if !options.keep {
635                        match std::fs::remove_file(t) {
636                            Ok(_) => if options.log { println!("removed {}", t.display()) },
637                            Err(e) => {
638                                eprintln!("Error removing {}: {}", t.display(), e);
639                            }
640                        }
641                    }
642                }
643                Err(e) => {
644                    eprintln!("Error decompressing {}: {}", t.display(), e);
645                }
646            }
647        }
648        Err(e) => {
649            eprintln!("Error decompressing {}: {}", t.display(), e);
650        }
651    }
652}
653
654fn get_targets(
655    targets: &mut Vec<std::path::PathBuf>,
656    path: &std::path::Path,
657    rec: bool,
658    ex: &std::ffi::OsStr,
659) -> Option<()> {
660    for f in std::fs::read_dir(path).ok()? {
661        let f = match f {
662            Ok(f) => f,
663            Err(_) => continue,
664        };
665
666        let path = f.path();
667
668        if rec && path.is_dir() { get_targets(targets, &path, rec, ex); }
669        if path.is_file() && path.extension() == Some(ex) { targets.push(path)}
670    }
671
672    Some(())
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn reorder_round_trip() {
681        #[rustfmt::skip]
682        let events = [
683            3, 1, 2, 3, 4, 5,
684            1, 0, 1, 2,
685            1, 10, 11, 12,
686            2, 1,
687            2, 2,
688            3, 1, 2, 3, 4, 5,
689            1, 20, 21, 22
690        ];
691        let mut event_sizes = [0u16; 256];
692        event_sizes[..4].copy_from_slice(&[0, 3, 1, 5]);
693
694        let mut reordered = Vec::new();
695        reorder_events(&events, &event_sizes, &mut reordered).unwrap();
696        println!("{:?}", reordered);
697
698        let mut unordered = Vec::new();
699        unorder_events(&reordered, &event_sizes, &mut unordered).unwrap();
700
701        assert_eq!(events.as_slice(), &unordered);
702    }
703}