dvpl-engine 0.1.0

DVPL file format engine for World of Tanks Blitz
Documentation
//! DVPL file format engine for World of Tanks Blitz.
//!
//! Provides [`encode`] and [`decode`] for the DVPL container format used by `WoT` Blitz
//! game assets. Usable as a pure Rust library (`cargo add dvpl-engine`) or as a
//! Python extension via `PyO3` (`pip install dvpl-converter`).
//!
//! # DVPL format
//!
//! A `.dvpl` file is a payload followed by a 20-byte footer:
//!
//! | Field             | Size    | Encoding       |
//! |-------------------|---------|----------------|
//! | `original_size`   | 4 bytes | little-endian  |
//! | `compressed_size` | 4 bytes | little-endian  |
//! | `crc32`           | 4 bytes | little-endian  |
//! | `compression`     | 4 bytes | little-endian  |
//! | `magic`           | 4 bytes | `b"DVPL"`      |
//!
//! Compression types: [`COMP_NONE`] (0), [`COMP_LZ4`] (1), [`COMP_LZ4_HC`] (2).
//!
//! # Examples
//!
//! ```
//! use dvpl_engine::COMP_LZ4_HC;
//! use dvpl_engine::decode;
//! use dvpl_engine::encode;
//!
//! let original = b"Hello DVPL!";
//! let dvpl_blob = encode(original, COMP_LZ4_HC).unwrap();
//! let decoded = decode(&dvpl_blob).unwrap();
//! assert_eq!(original.as_slice(), &decoded);
//! ```

#[cfg(feature = "python")]
mod python;

use crc32fast::Hasher;
use thiserror::Error;

/// Magic bytes identifying a valid DVPL footer.
const MAGIC: &[u8; 4] = b"DVPL";

/// Size of the DVPL footer in bytes.
const FOOTER_SIZE: usize = 20;

/// No compression - payload stored as-is.
pub const COMP_NONE: u32 = 0;

/// Standard LZ4 block compression.
pub const COMP_LZ4: u32 = 1;

/// LZ4 high-compression mode (better ratio, slower compression).
pub const COMP_LZ4_HC: u32 = 2;

/// All failure modes during DVPL encode/decode.
///
/// Returned by [`encode`] and [`decode`] to describe exactly what went wrong.
#[derive(Debug, Error)]
pub enum DvplError {
    /// Input shorter than 20 bytes (footer size).
    #[error("File too small ({0} bytes)")]
    TooSmall(usize),

    /// Footer magic does not match `b"DVPL"`.
    #[error("Bad magic: expected DVPL, got {}", format_magic(.0))]
    BadMagic([u8; 4]),

    /// Payload length disagrees with the `compressed_size` field in the footer.
    #[error("Size mismatch: footer says {expected}, got {actual}")]
    SizeMismatch { expected: usize, actual: usize },

    /// CRC32 of the payload does not match the checksum in the footer.
    #[error("CRC32 mismatch: expected {expected:#010x}, got {actual:#010x}")]
    CrcMismatch { expected: u32, actual: u32 },

    /// Decompressed output length disagrees with the `original_size` field.
    #[error("Decompressed size mismatch: expected {expected}, got {actual}")]
    DecompressedSizeMismatch { expected: usize, actual: usize },

    /// Footer contains an unrecognized compression type value.
    #[error("Unknown compression type: {0}")]
    UnknownCompression(u32),

    /// Upstream [`lz4`] crate returned an error during compress/decompress.
    #[error("lz4 error: {0}")]
    Lz4(String),
}

/// Format a 4-byte magic value as a Python-style byte string literal (e.g. `b"DVPL"`).
#[allow(clippy::trivially_copy_pass_by_ref)]
fn format_magic(magic: &[u8; 4]) -> String {
    let s: String = magic
        .iter()
        .map(|&b| {
            if b.is_ascii_graphic() || b == b' ' {
                (b as char).to_string()
            } else {
                format!("\\x{b:02x}")
            }
        })
        .collect();
    format!("b\"{s}\"")
}

/// Compute a CRC32 checksum via hardware-accelerated [`crc32fast`].
fn crc32(data: &[u8]) -> u32 {
    let mut hasher = Hasher::new();
    hasher.update(data);
    hasher.finalize()
}

/// Read a little-endian [`u32`] from `buf` at the given byte `offset`.
fn read_u32_le(buf: &[u8], offset: usize) -> u32 {
    u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
}

/// Decode a DVPL blob: validate footer, verify CRC32, decompress payload.
///
/// See the [module-level docs](self) for the footer layout.
///
/// # Errors
///
/// Returns [`DvplError`] if the footer is malformed, the CRC32 doesn't match,
/// the decompressed size is wrong, or the compression type is unrecognized.
///
/// # Examples
///
/// ```
/// use dvpl_engine::COMP_LZ4;
/// use dvpl_engine::decode;
/// use dvpl_engine::encode;
///
/// let blob = encode(b"round trip", COMP_LZ4).unwrap();
/// let original = decode(&blob).unwrap();
/// assert_eq!(original, b"round trip");
/// ```
pub fn decode(data: &[u8]) -> Result<Vec<u8>, DvplError> {
    if data.len() < FOOTER_SIZE {
        return Err(DvplError::TooSmall(data.len()));
    }

    let footer = &data[data.len() - FOOTER_SIZE..];
    let original_size = read_u32_le(footer, 0) as usize;
    let compressed_size = read_u32_le(footer, 4) as usize;
    let checksum = read_u32_le(footer, 8);
    let comp_type = read_u32_le(footer, 12);

    let magic: [u8; 4] = footer[16..20].try_into().unwrap();
    if magic != *MAGIC {
        return Err(DvplError::BadMagic(magic));
    }

    let payload = &data[..data.len() - FOOTER_SIZE];
    if payload.len() != compressed_size {
        return Err(DvplError::SizeMismatch {
            expected: compressed_size,
            actual: payload.len(),
        });
    }

    let calculated_crc = crc32(payload);
    if calculated_crc != checksum {
        return Err(DvplError::CrcMismatch {
            expected: checksum,
            actual: calculated_crc,
        });
    }

    let result = match comp_type {
        COMP_NONE => payload.to_vec(),
        COMP_LZ4 | COMP_LZ4_HC => lz4::block::decompress(payload, Some(original_size as i32))
            .map_err(|e| DvplError::Lz4(e.to_string()))?,
        _ => return Err(DvplError::UnknownCompression(comp_type)),
    };

    if result.len() != original_size {
        return Err(DvplError::DecompressedSizeMismatch {
            expected: original_size,
            actual: result.len(),
        });
    }

    Ok(result)
}

/// Encode raw data into a DVPL blob: compress, compute CRC32, append footer.
///
/// See the [module-level docs](self) for the footer layout.
///
/// # Arguments
///
/// * `data` - Raw bytes to encode.
/// * `comp_type` - One of [`COMP_NONE`], [`COMP_LZ4`], or [`COMP_LZ4_HC`].
///
/// # Errors
///
/// Returns [`DvplError::UnknownCompression`] if `comp_type` is not recognized.
///
/// # Examples
///
/// ```
/// use dvpl_engine::COMP_LZ4_HC;
/// use dvpl_engine::encode;
///
/// let blob = encode(b"hello", COMP_LZ4_HC).unwrap();
/// assert_eq!(&blob[blob.len() - 4..], b"DVPL");
/// ```
pub fn encode(data: &[u8], comp_type: u32) -> Result<Vec<u8>, DvplError> {
    let payload = match comp_type {
        COMP_NONE => data.to_vec(),
        COMP_LZ4 => {
            lz4::block::compress(data, None, false).map_err(|e| DvplError::Lz4(e.to_string()))?
        }
        COMP_LZ4_HC => {
            // compression level 9 = high compression
            lz4::block::compress(
                data,
                Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
                false,
            )
            .map_err(|e| DvplError::Lz4(e.to_string()))?
        }
        _ => return Err(DvplError::UnknownCompression(comp_type)),
    };

    let checksum = crc32(&payload);
    let original_size = data.len() as u32;
    let compressed_size = payload.len() as u32;

    let mut result = Vec::with_capacity(payload.len() + FOOTER_SIZE);
    result.extend_from_slice(&payload);
    result.extend_from_slice(&original_size.to_le_bytes());
    result.extend_from_slice(&compressed_size.to_le_bytes());
    result.extend_from_slice(&checksum.to_le_bytes());
    result.extend_from_slice(&comp_type.to_le_bytes());
    result.extend_from_slice(MAGIC);

    Ok(result)
}

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

    const SAMPLE: &[u8] = b"Hello World of Tanks Blitz!";

    #[test]
    fn round_trip_none() {
        let encoded = encode(SAMPLE, COMP_NONE).unwrap();
        assert_eq!(decode(&encoded).unwrap(), SAMPLE);
    }

    #[test]
    fn round_trip_lz4() {
        let encoded = encode(SAMPLE, COMP_LZ4).unwrap();
        assert_eq!(decode(&encoded).unwrap(), SAMPLE);
    }

    #[test]
    fn round_trip_lz4_hc() {
        let encoded = encode(SAMPLE, COMP_LZ4_HC).unwrap();
        assert_eq!(decode(&encoded).unwrap(), SAMPLE);
    }

    #[test]
    fn round_trip_empty() {
        let encoded = encode(b"", COMP_LZ4_HC).unwrap();
        assert_eq!(decode(&encoded).unwrap(), b"");
    }

    #[test]
    fn footer_has_magic() {
        let encoded = encode(SAMPLE, COMP_NONE).unwrap();
        assert_eq!(&encoded[encoded.len() - 4..], b"DVPL");
    }

    #[test]
    fn too_small() {
        assert!(matches!(decode(b"short"), Err(DvplError::TooSmall(5))));
    }

    #[test]
    fn bad_magic() {
        let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
        let len = blob.len();
        blob[len - 1] = b'X';
        assert!(matches!(decode(&blob), Err(DvplError::BadMagic(_))));
    }

    #[test]
    fn crc_mismatch() {
        let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
        blob[0] ^= 0xFF;
        assert!(matches!(decode(&blob), Err(DvplError::CrcMismatch { .. })));
    }

    #[test]
    fn unknown_compression() {
        assert!(matches!(
            encode(SAMPLE, 99),
            Err(DvplError::UnknownCompression(99))
        ));
    }

    #[test]
    fn size_mismatch() {
        let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
        let footer = blob[blob.len() - FOOTER_SIZE..].to_vec();
        blob.truncate(blob.len() - FOOTER_SIZE - 1);
        blob.extend_from_slice(&footer);
        assert!(matches!(decode(&blob), Err(DvplError::SizeMismatch { .. })));
    }
}