Skip to main content

barotrauma_compress/
lib.rs

1// Copyright 2025 Michael Ripley
2// This file is part of barotrauma-compress.
3// barotrauma-compress is licensed under the AGPL-3.0 license (see LICENSE file for details).
4
5//! Compress and decompress barotrauma save files.
6
7use flate2::Compression;
8use flate2::bufread::GzDecoder;
9use flate2::write::GzEncoder;
10use std::fs::File;
11use std::io::{BufReader, BufWriter, Read, Write};
12use std::mem::MaybeUninit;
13use std::path::{Path, PathBuf};
14use std::{fs, io};
15use zerocopy::IntoBytes;
16
17/// Some arbitrary guess as to how long a really long filename might be, in utf16 code units.
18/// It's fine if we're wrong, as the buffer is growable.
19const INITIAL_FILENAME_BUFFER_SIZE: usize = 256;
20
21// source: https://docs.rs/debug_print/1.0.0/src/debug_print/lib.rs.html#49-52
22// licensed under MIT OR Apache-2.0
23macro_rules! debug_println {
24    ($($arg:tt)*) => (#[cfg(debug_assertions)] println!($($arg)*));
25}
26
27/// Given `file_path`, a path to a .save file, decompress the save into a new directory next to the save file.
28/// This will fail if the output directory already exists.
29pub fn decompress<P: AsRef<Path>>(file_path: P) -> Result<(), String> {
30    // open the save file
31    let file = File::open(&file_path).map_err(|e| format!("Could not open save file: {}", e))?;
32    let gzip_input = BufReader::new(file);
33    // the filesystem buffering is handled by this BufReader, so we can make small reads from the underlying stream
34    debug_println!("fs read buffer size = {}", gzip_input.capacity());
35    let mut input = GzDecoder::new(gzip_input);
36
37    // create the output directory
38    let directory_path: PathBuf = file_path
39        .as_ref()
40        .parent()
41        .ok_or("Could not get parent directory of save file")?
42        .join(
43            file_path
44                .as_ref()
45                .file_stem()
46                .ok_or("Could not remove extension from save file")?,
47        );
48    debug_println!("directory_path = {}", directory_path.display());
49    fs::create_dir(&directory_path).map_err(|e| {
50        format!(
51            "Could not create target directory \"{}\": {}",
52            directory_path.display(),
53            e
54        )
55    })?;
56
57    // SAFETY: buffer must not be read before it is written. This is done as the very first thing in the following loop,
58    // so this is fine.
59    let mut length_buffer: [u8; 4] = unsafe {
60        #[allow(clippy::uninit_assumed_init, invalid_value)]
61        MaybeUninit::uninit().assume_init()
62    };
63
64    // this buffer is specifically for holding u16-aligned filenames
65    let mut filename_buffer = Vec::<u16>::with_capacity(INITIAL_FILENAME_BUFFER_SIZE);
66
67    loop {
68        // read the filename length
69        if input.read_exact(&mut length_buffer).is_err() {
70            // as the filename length prefix is the first token in a chunk, its absence isn't a problem:
71            // it just means we have no chunks left and are therefore done decompressing
72            break;
73        }
74
75        let filename_length: usize = u32::from_le_bytes(length_buffer) as usize;
76        debug_println!("filename_size = {}", filename_length * 2);
77
78        // grow the u16 filename buffer if required
79        if filename_buffer.capacity() < filename_length {
80            filename_buffer.reserve(filename_length - filename_buffer.capacity());
81        }
82
83        // size the underlying slice to the necessary size without zeroing
84        // SAFETY: all data must be written before it is read. We immediately do a `read_exact` call to perform said write.
85        unsafe {
86            filename_buffer.set_len(filename_length);
87        }
88
89        // read the dang filename, finally
90        input
91            .read_exact(filename_buffer.as_mut_bytes())
92            .map_err(|e| format!("Reached end of stream unexpectedly when reading filename: {}", e))?;
93        let filename =
94            String::from_utf16(&filename_buffer).map_err(|e| format!("Filename was not valid UTF-16: {}", e))?;
95        debug_println!("Decoded filename: {}", filename);
96
97        // get the file length
98        input
99            .read_exact(&mut length_buffer)
100            .map_err(|e| format!("Reached end of stream unexpectedly when reading file length: {}", e))?;
101        let file_length: u64 = u32::from_le_bytes(length_buffer) as u64;
102        debug_println!("file_length = {}", file_length);
103
104        // create the output file
105        let output_file_path = directory_path.join(&filename);
106        let mut output_file =
107            File::create(&output_file_path).map_err(|e| format!("Unable to create output file: {}", e))?;
108
109        let mut output_file_reader = input.take(file_length);
110
111        let bytes_written = io::copy(&mut output_file_reader, &mut output_file)
112            .map_err(|e| format!("Error writing decompressed file: {}", e))?;
113        assert_eq!(bytes_written, file_length, "unexpected number of bytes written to file");
114        input = output_file_reader.into_inner();
115
116        debug_println!("wrote {}", output_file_path.display());
117    }
118
119    Ok(())
120}
121
122/// Give `directory_path`, a path to a decompressed barotrauma save directory, compress it into a new .save file next
123/// to the directory. This will fail if the output file already exists.
124pub fn compress<P: AsRef<Path>>(directory_path: P) -> Result<(), String> {
125    let file_path: PathBuf = directory_path.as_ref().with_extension("save");
126
127    // enumerate files in the input directory
128    let mut input_file_paths = Vec::with_capacity(2);
129    for entry in fs::read_dir(directory_path).map_err(|e| format!("Unable to enumerate input directory: {}", e))? {
130        let entry =
131            entry.map_err(|e| format!("Unable to read an entry while enumerating the input directory: {}", e))?;
132        let path = entry.path();
133        if !path.is_file() {
134            // the directory must be flat... I think? If baro supports directory structure then color me surprised.
135            return Err(format!("Unable to compress nested directories: \"{}\"", path.display()));
136        }
137        input_file_paths.push(path);
138    }
139
140    // ensure the output file doesn't already exist, as I don't want users to accidentally clobber their saves
141    if file_path.exists() {
142        return Err(format!("Target file \"{}\" already exists", file_path.display()));
143    }
144
145    // create the output file
146    let output_file = File::create(file_path).map_err(|e| format!("Unable to create output file: {}", e))?;
147    // I do three small writes in a row for file metadata, so we buffer the writer here
148    let gzip_output = BufWriter::new(output_file);
149    debug_println!("fs write buffer size = {}", gzip_output.capacity());
150    // default compression is *probably* fine
151    let mut output = GzEncoder::new(gzip_output, Compression::default());
152
153    // this buffer is specifically for holding u16-aligned filenames
154    let mut filename_buffer = Vec::<u16>::with_capacity(INITIAL_FILENAME_BUFFER_SIZE);
155
156    // add each file to the gzip
157    for input_file_path in input_file_paths {
158        debug_println!("processing: {}", input_file_path.display());
159        let mut input_file = File::open(&input_file_path).map_err(|e| format!("Unable to open input file: {}", e))?;
160
161        // write the filename length prefix
162        let input_filename = input_file_path
163            .file_name()
164            .ok_or("Unable to extract filename of input file")?
165            .to_str()
166            .ok_or("Unable to convert input filename to unicode")?;
167        let input_filename_length = input_filename.len() as u32;
168        filename_buffer.clear();
169        filename_buffer.extend(input_filename.encode_utf16());
170
171        // write the filename length prefix
172        let input_filename_length_prefix = input_filename_length.to_le_bytes();
173        output
174            .write_all(&input_filename_length_prefix)
175            .map_err(|e| format!("Unable to write filename length prefix to save: {}", e))?;
176
177        // write the filename
178        output
179            .write_all(filename_buffer.as_bytes())
180            .map_err(|e| format!("Unable to write filename to save: {}", e))?;
181
182        // write the file size prefix
183        let file_size = input_file
184            .metadata()
185            .map_err(|e| format!("Unable to read metadata for input file: {}", e))?
186            .len();
187        let file_size_prefix: u32 = file_size.try_into().map_err(|e| {
188            format!(
189                "Input file too long (blame the Baro devs for their 4GB filesize limit): {}",
190                e
191            )
192        })?;
193        let file_size_prefix = file_size_prefix.to_le_bytes();
194        output
195            .write_all(&file_size_prefix)
196            .map_err(|e| format!("Unable to write filesize prefix to save: {}", e))?;
197
198        // write the file contents
199        io::copy(&mut input_file, &mut output).map_err(|e| format!("Error writing input file to save: {}", e))?;
200    }
201
202    // because we're using a BufWriter we should explicitly flush to disk so we can handle any errors
203    output
204        .flush()
205        .map_err(|e| format!("Error flushing save to disk: {}", e))?;
206    Ok(())
207}