jomini 0.34.1

Low level, performance oriented parser for save and game files from EU4, CK3, HOI4, Vic3, Imperator, and other PDS titles
Documentation
use crate::envelope::errors::{EnvelopeError, EnvelopeErrorKind};
use std::io::Write;

/// The kind of save file
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveHeaderKind {
    /// uncompressed text
    Text,

    /// uncompressed binary
    Binary,

    /// uncompressed text metadata header with compressed text gamestate
    UnifiedText,

    /// uncompressed binary metadata header with compressed binary gamestate
    UnifiedBinary,

    /// metadata is stored within the zip file
    SplitText,

    /// metadata is stored within the zip file
    SplitBinary,

    /// An unknown type
    Other(u16),
}

impl SaveHeaderKind {
    /// Creates a SaveHeaderKind from a numeric value
    pub fn new(kind: u16) -> SaveHeaderKind {
        match kind {
            0 => SaveHeaderKind::Text,
            1 => SaveHeaderKind::Binary,
            2 => SaveHeaderKind::UnifiedText,
            3 => SaveHeaderKind::UnifiedBinary,
            4 => SaveHeaderKind::SplitText,
            5 => SaveHeaderKind::SplitBinary,
            x => SaveHeaderKind::Other(x),
        }
    }

    /// Returns the numeric value of this header kind
    pub fn value(&self) -> u16 {
        match self {
            SaveHeaderKind::Text => 0,
            SaveHeaderKind::Binary => 1,
            SaveHeaderKind::UnifiedText => 2,
            SaveHeaderKind::UnifiedBinary => 3,
            SaveHeaderKind::SplitText => 4,
            SaveHeaderKind::SplitBinary => 5,
            SaveHeaderKind::Other(x) => *x,
        }
    }

    /// Returns true if this header kind indicates binary encoding
    pub fn is_binary(&self) -> bool {
        matches!(
            self,
            SaveHeaderKind::Binary | SaveHeaderKind::UnifiedBinary | SaveHeaderKind::SplitBinary
        )
    }

    /// Returns true if this header kind indicates text encoding
    pub fn is_text(&self) -> bool {
        matches!(
            self,
            SaveHeaderKind::Text | SaveHeaderKind::UnifiedText | SaveHeaderKind::SplitText
        )
    }
}

/// The first line of the save file
///
/// For a breakdown of the fields, see the PDX Unlimiter source:
///
/// <https://github.com/crschnick/pdx_unlimiter/blob/6363689c8a89a73bc5db4cca4eff249261807d38/pdxu-io/src/main/java/com/crschnick/pdxu/io/savegame/ModernHeader.java#L7-L25>
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SaveHeader {
    version: u16,
    kind: SaveHeaderKind,
    random: [u8; 8],
    meta_len: u64,
    unknown: [u8; 8],
    unknown_len: usize,
    header_len: usize,
}

impl SaveHeader {
    pub(crate) const SIZE: usize = 33;

    /// Creates a SaveHeader by parsing from a byte slice
    pub fn from_slice(data: &[u8]) -> Result<Self, EnvelopeError> {
        if data.len() < 24 {
            return Err(EnvelopeErrorKind::InvalidHeader.into());
        }

        if !matches!(&data[..3], [b'S', b'A', b'V']) {
            return Err(EnvelopeErrorKind::InvalidHeader.into());
        }

        // Parse version as u16 from hex at [3:5]
        let version_hex =
            std::str::from_utf8(&data[3..5]).map_err(|_| EnvelopeErrorKind::InvalidHeader)?;
        let version =
            u16::from_str_radix(version_hex, 16).map_err(|_| EnvelopeErrorKind::InvalidHeader)?;

        // Parse kind
        let kind_hex =
            std::str::from_utf8(&data[5..7]).map_err(|_| EnvelopeErrorKind::InvalidHeader)?;
        let kind =
            u16::from_str_radix(kind_hex, 16).map_err(|_| EnvelopeErrorKind::InvalidHeader)?;

        let random = data[7..15].try_into().unwrap();

        // Parse metadata length
        let meta_hex =
            std::str::from_utf8(&data[15..23]).map_err(|_| EnvelopeErrorKind::InvalidHeader)?;
        let meta_len =
            u64::from_str_radix(meta_hex, 16).map_err(|_| EnvelopeErrorKind::InvalidHeader)?;

        // Find newline position (scan up to SIZE bytes)
        let search_data = &data[..Self::SIZE.min(data.len())];
        let newline_pos = search_data
            .iter()
            .position(|&b| b == b'\n')
            .ok_or(EnvelopeErrorKind::InvalidHeader)?;

        // Check if preceded by '\r' (CRLF)
        let has_cr = newline_pos > 0 && data[newline_pos - 1] == b'\r';
        let padding_end = newline_pos - (has_cr as usize);

        let (unknown_len, unknown) = if padding_end == 23 {
            (0, [0u8; 8])
        } else if padding_end == 31 {
            let mut pad = [0u8; 8];
            pad.copy_from_slice(&data[23..31]);
            (8, pad)
        } else {
            return Err(EnvelopeErrorKind::InvalidHeader.into());
        };

        let header_len = newline_pos + 1;
        Ok(SaveHeader {
            version,
            kind: SaveHeaderKind::new(kind),
            random,
            meta_len,
            unknown,
            unknown_len,
            header_len,
        })
    }

    /// Returns the save file kind (text/binary and compression info)
    pub fn kind(&self) -> SaveHeaderKind {
        self.kind
    }

    /// Sets the save file kind
    pub fn set_kind(&mut self, kind: SaveHeaderKind) {
        self.kind = kind;
    }

    /// Returns the save file format version
    pub fn version(&self) -> u16 {
        self.version
    }

    /// Returns the length of the header line in bytes
    pub fn header_len(&self) -> usize {
        self.header_len
    }

    /// Returns the length of the metadata section in bytes
    pub fn metadata_len(&self) -> u64 {
        self.meta_len
    }

    /// Sets the metadata section length in bytes
    pub fn set_metadata_len(&mut self, len: u64) {
        self.meta_len = len
    }

    /// Writes the header to a writer in the save file format
    pub fn write<W>(&self, mut writer: W) -> std::io::Result<()>
    where
        W: Write,
    {
        writer.write_all(b"SAV")?;
        write!(writer, "{0:02x}", self.version)?;
        write!(writer, "{0:02x}", self.kind.value())?;
        writer.write_all(&self.random)?;
        write!(writer, "{0:08x}", self.meta_len)?;
        // Write padding bytes if this is an extended format header
        if self.unknown_len > 0 {
            writer.write_all(&self.unknown[..self.unknown_len])?;
        }
        if self.header_len() == 25 || self.header_len() == 33 {
            writer.write_all(b"\r")?;
        }
        writer.write_all(b"\n")?;
        Ok(())
    }
}

impl std::fmt::Display for SaveHeader {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut buf = Vec::new();
        self.write(&mut buf).map_err(|_| std::fmt::Error)?;
        let s = std::str::from_utf8(&buf).map_err(|_| std::fmt::Error)?;
        f.write_str(s)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_save_header() {
        let data = b"SAV0102a40f789f000067c4\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();

        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::UnifiedText);
        assert_eq!(header.header_len(), 24);
        assert_eq!(header.metadata_len(), 26564);

        let mut out = Vec::new();
        header.write(&mut out).unwrap();

        assert_eq!(&out, data);
    }

    #[test]
    fn test_save_header_allow_crlf() {
        let data = b"SAV0102a40f789f000067c4\r\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();

        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::UnifiedText);
        assert_eq!(header.header_len(), 25);
        assert_eq!(header.metadata_len(), 26564);

        let mut out = Vec::new();
        header.write(&mut out).unwrap();

        assert_eq!(&out, b"SAV0102a40f789f000067c4\r\n");
    }

    #[test]
    fn test_split_vic3() {
        // Vic3 save where the metadata is stored within the zip file
        let data = b"SAV010580b859da00000000\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();

        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::SplitBinary);
        assert_eq!(header.header_len(), 24);
        assert_eq!(header.metadata_len(), 0);
    }

    #[test]
    fn test_debug_vic3() {
        // Vic3 save where everything is plain text
        let data = b"SAV010078544999000003bc\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();

        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::Text);
        assert_eq!(header.header_len(), 24);
        assert_eq!(header.metadata_len(), 956);
    }

    #[test]
    fn test_eu5_ironman_header() {
        // EU5 ironman save where the binary header is precedes the zip
        let data = b"SAV0103daabb23800062a40\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();
        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::UnifiedBinary);
        assert_eq!(header.header_len(), 24);
        assert_eq!(header.metadata_len(), 404032);
    }

    #[test]
    fn test_eu5_debug_header() {
        // EU5 debug save where everything is plaintext, no zip
        let data = b"SAV0100797de2430004dc53\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();
        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::Text);
        assert_eq!(header.header_len(), 24);
        assert_eq!(header.metadata_len(), 318547);
    }

    #[test]
    fn test_ck3_binary_autosave_header() {
        // CK3 binary autosave
        let data = b"SAV0101ad23696300004c29\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();
        assert_eq!(header.version(), 1);
        assert_eq!(header.kind(), SaveHeaderKind::Binary);
        assert_eq!(header.header_len(), 24);
        assert_eq!(header.metadata_len(), 19497);
    }

    #[test]
    fn test_eu5_debug_header_2() {
        // patch 1.0.8 changed up the header version to use the longer format
        let data = b"SAV020013f56aaf0004e72300000000\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();
        assert_eq!(header.version(), 2);
        assert_eq!(header.kind(), SaveHeaderKind::Text);
        assert_eq!(header.header_len(), 32);
        assert_eq!(header.metadata_len(), 0x4e723);
    }

    #[test]
    fn test_eu5_debug_header_2_bin() {
        // An eu5 patch 1.0.8 binary long format
        let data = b"SAV0203fbf95f030006353800000000\n";
        let header = SaveHeader::from_slice(&data[..]).unwrap();
        assert_eq!(header.version(), 2);
        assert_eq!(header.kind(), SaveHeaderKind::UnifiedBinary);
        assert_eq!(header.header_len(), 32);
        assert_eq!(header.metadata_len(), 0x63538);
    }
}