Skip to main content

dvpl_engine/
lib.rs

1//! DVPL file format engine for World of Tanks Blitz.
2//!
3//! Provides [`encode`] and [`decode`] for the DVPL container format used by `WoT` Blitz
4//! game assets. Usable as a pure Rust library (`cargo add dvpl-engine`) or as a
5//! Python extension via `PyO3` (`pip install dvpl-converter`).
6//!
7//! # DVPL format
8//!
9//! A `.dvpl` file is a payload followed by a 20-byte footer:
10//!
11//! | Field             | Size    | Encoding       |
12//! |-------------------|---------|----------------|
13//! | `original_size`   | 4 bytes | little-endian  |
14//! | `compressed_size` | 4 bytes | little-endian  |
15//! | `crc32`           | 4 bytes | little-endian  |
16//! | `compression`     | 4 bytes | little-endian  |
17//! | `magic`           | 4 bytes | `b"DVPL"`      |
18//!
19//! Compression types: [`COMP_NONE`] (0), [`COMP_LZ4`] (1), [`COMP_LZ4_HC`] (2).
20//!
21//! # Cargo features
22//!
23//! - `python` - enables `PyO3` bindings. Used internally by maturin to build the
24//!   Python wheel. Do not enable when consuming `dvpl-engine` as a Rust library;
25//!   it pulls in `pyo3` + `extension-module` for no Rust-side benefit.
26//!
27//! # Examples
28//!
29//! ```
30//! use dvpl_engine::COMP_LZ4_HC;
31//! use dvpl_engine::decode;
32//! use dvpl_engine::encode;
33//!
34//! let original = b"Hello DVPL!";
35//! let dvpl_blob = encode(original, COMP_LZ4_HC).unwrap();
36//! let decoded = decode(&dvpl_blob).unwrap();
37//! assert_eq!(original.as_slice(), &decoded);
38//! ```
39
40#[cfg(feature = "python")]
41mod python;
42
43use crc32fast::Hasher;
44use thiserror::Error;
45
46/// Magic bytes identifying a valid DVPL footer.
47const MAGIC: &[u8; 4] = b"DVPL";
48
49/// Size of the DVPL footer in bytes.
50const FOOTER_SIZE: usize = 20;
51
52/// No compression - payload stored as-is.
53pub const COMP_NONE: u32 = 0;
54
55/// Standard LZ4 block compression.
56pub const COMP_LZ4: u32 = 1;
57
58/// LZ4 high-compression mode (better ratio, slower compression).
59pub const COMP_LZ4_HC: u32 = 2;
60
61/// All failure modes during DVPL encode/decode.
62///
63/// Returned by [`encode`] and [`decode`] to describe exactly what went wrong.
64#[derive(Debug, Error)]
65pub enum DvplError {
66    /// Input shorter than 20 bytes (footer size).
67    #[error("File too small ({0} bytes)")]
68    TooSmall(usize),
69
70    /// Footer magic does not match `b"DVPL"`.
71    #[error("Bad magic: expected DVPL, got {}", format_magic(.0))]
72    BadMagic([u8; 4]),
73
74    /// Payload length disagrees with the `compressed_size` field in the footer.
75    #[error("Size mismatch: footer says {expected}, got {actual}")]
76    SizeMismatch { expected: usize, actual: usize },
77
78    /// CRC32 of the payload does not match the checksum in the footer.
79    #[error("CRC32 mismatch: expected {expected:#010x}, got {actual:#010x}")]
80    CrcMismatch { expected: u32, actual: u32 },
81
82    /// Decompressed output length disagrees with the `original_size` field.
83    #[error("Decompressed size mismatch: expected {expected}, got {actual}")]
84    DecompressedSizeMismatch { expected: usize, actual: usize },
85
86    /// Footer contains an unrecognized compression type value.
87    #[error("Unknown compression type: {0}")]
88    UnknownCompression(u32),
89
90    /// Upstream [`lz4`] crate returned an error during compress/decompress.
91    #[error("lz4 error: {0}")]
92    Lz4(String),
93}
94
95/// Format a 4-byte magic value as a Python-style byte string literal (e.g. `b"DVPL"`).
96#[allow(clippy::trivially_copy_pass_by_ref)]
97fn format_magic(magic: &[u8; 4]) -> String {
98    let s: String = magic
99        .iter()
100        .map(|&b| {
101            if b.is_ascii_graphic() || b == b' ' {
102                (b as char).to_string()
103            } else {
104                format!("\\x{b:02x}")
105            }
106        })
107        .collect();
108    format!("b\"{s}\"")
109}
110
111/// Compute a CRC32 checksum via hardware-accelerated [`crc32fast`].
112fn crc32(data: &[u8]) -> u32 {
113    let mut hasher = Hasher::new();
114    hasher.update(data);
115    hasher.finalize()
116}
117
118/// Read a little-endian [`u32`] from `buf` at the given byte `offset`.
119fn read_u32_le(buf: &[u8], offset: usize) -> u32 {
120    u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
121}
122
123/// Decode a DVPL blob: validate footer, verify CRC32, decompress payload.
124///
125/// See the [module-level docs](self) for the footer layout.
126///
127/// # Errors
128///
129/// Returns [`DvplError`] if the footer is malformed, the CRC32 doesn't match,
130/// the decompressed size is wrong, or the compression type is unrecognized.
131///
132/// # Examples
133///
134/// ```
135/// use dvpl_engine::COMP_LZ4;
136/// use dvpl_engine::decode;
137/// use dvpl_engine::encode;
138///
139/// let blob = encode(b"round trip", COMP_LZ4).unwrap();
140/// let original = decode(&blob).unwrap();
141/// assert_eq!(original, b"round trip");
142/// ```
143pub fn decode(data: &[u8]) -> Result<Vec<u8>, DvplError> {
144    if data.len() < FOOTER_SIZE {
145        return Err(DvplError::TooSmall(data.len()));
146    }
147
148    let footer = &data[data.len() - FOOTER_SIZE..];
149    let original_size = read_u32_le(footer, 0) as usize;
150    let compressed_size = read_u32_le(footer, 4) as usize;
151    let checksum = read_u32_le(footer, 8);
152    let comp_type = read_u32_le(footer, 12);
153
154    let magic: [u8; 4] = footer[16..20].try_into().unwrap();
155    if magic != *MAGIC {
156        return Err(DvplError::BadMagic(magic));
157    }
158
159    let payload = &data[..data.len() - FOOTER_SIZE];
160    if payload.len() != compressed_size {
161        return Err(DvplError::SizeMismatch {
162            expected: compressed_size,
163            actual: payload.len(),
164        });
165    }
166
167    let calculated_crc = crc32(payload);
168    if calculated_crc != checksum {
169        return Err(DvplError::CrcMismatch {
170            expected: checksum,
171            actual: calculated_crc,
172        });
173    }
174
175    let result = match comp_type {
176        COMP_NONE => payload.to_vec(),
177        COMP_LZ4 | COMP_LZ4_HC => lz4::block::decompress(payload, Some(original_size as i32))
178            .map_err(|e| DvplError::Lz4(e.to_string()))?,
179        _ => return Err(DvplError::UnknownCompression(comp_type)),
180    };
181
182    if result.len() != original_size {
183        return Err(DvplError::DecompressedSizeMismatch {
184            expected: original_size,
185            actual: result.len(),
186        });
187    }
188
189    Ok(result)
190}
191
192/// Encode raw data into a DVPL blob: compress, compute CRC32, append footer.
193///
194/// See the [module-level docs](self) for the footer layout.
195///
196/// # Arguments
197///
198/// * `data` - Raw bytes to encode.
199/// * `comp_type` - One of [`COMP_NONE`], [`COMP_LZ4`], or [`COMP_LZ4_HC`].
200///
201/// # Errors
202///
203/// Returns [`DvplError::UnknownCompression`] if `comp_type` is not recognized.
204///
205/// # Examples
206///
207/// ```
208/// use dvpl_engine::COMP_LZ4_HC;
209/// use dvpl_engine::encode;
210///
211/// let blob = encode(b"hello", COMP_LZ4_HC).unwrap();
212/// assert_eq!(&blob[blob.len() - 4..], b"DVPL");
213/// ```
214pub fn encode(data: &[u8], comp_type: u32) -> Result<Vec<u8>, DvplError> {
215    let payload = match comp_type {
216        COMP_NONE => data.to_vec(),
217        COMP_LZ4 => {
218            lz4::block::compress(data, None, false).map_err(|e| DvplError::Lz4(e.to_string()))?
219        }
220        COMP_LZ4_HC => {
221            // compression level 9 = high compression
222            lz4::block::compress(
223                data,
224                Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
225                false,
226            )
227            .map_err(|e| DvplError::Lz4(e.to_string()))?
228        }
229        _ => return Err(DvplError::UnknownCompression(comp_type)),
230    };
231
232    let checksum = crc32(&payload);
233    let original_size = data.len() as u32;
234    let compressed_size = payload.len() as u32;
235
236    let mut result = Vec::with_capacity(payload.len() + FOOTER_SIZE);
237    result.extend_from_slice(&payload);
238    result.extend_from_slice(&original_size.to_le_bytes());
239    result.extend_from_slice(&compressed_size.to_le_bytes());
240    result.extend_from_slice(&checksum.to_le_bytes());
241    result.extend_from_slice(&comp_type.to_le_bytes());
242    result.extend_from_slice(MAGIC);
243
244    Ok(result)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    const SAMPLE: &[u8] = b"Hello World of Tanks Blitz!";
252
253    #[test]
254    fn round_trip_none() {
255        let encoded = encode(SAMPLE, COMP_NONE).unwrap();
256        assert_eq!(decode(&encoded).unwrap(), SAMPLE);
257    }
258
259    #[test]
260    fn round_trip_lz4() {
261        let encoded = encode(SAMPLE, COMP_LZ4).unwrap();
262        assert_eq!(decode(&encoded).unwrap(), SAMPLE);
263    }
264
265    #[test]
266    fn round_trip_lz4_hc() {
267        let encoded = encode(SAMPLE, COMP_LZ4_HC).unwrap();
268        assert_eq!(decode(&encoded).unwrap(), SAMPLE);
269    }
270
271    #[test]
272    fn round_trip_empty() {
273        let encoded = encode(b"", COMP_LZ4_HC).unwrap();
274        assert_eq!(decode(&encoded).unwrap(), b"");
275    }
276
277    #[test]
278    fn footer_has_magic() {
279        let encoded = encode(SAMPLE, COMP_NONE).unwrap();
280        assert_eq!(&encoded[encoded.len() - 4..], b"DVPL");
281    }
282
283    #[test]
284    fn too_small() {
285        assert!(matches!(decode(b"short"), Err(DvplError::TooSmall(5))));
286    }
287
288    #[test]
289    fn bad_magic() {
290        let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
291        let len = blob.len();
292        blob[len - 1] = b'X';
293        assert!(matches!(decode(&blob), Err(DvplError::BadMagic(_))));
294    }
295
296    #[test]
297    fn crc_mismatch() {
298        let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
299        blob[0] ^= 0xFF;
300        assert!(matches!(decode(&blob), Err(DvplError::CrcMismatch { .. })));
301    }
302
303    #[test]
304    fn unknown_compression() {
305        assert!(matches!(
306            encode(SAMPLE, 99),
307            Err(DvplError::UnknownCompression(99))
308        ));
309    }
310
311    #[test]
312    fn size_mismatch() {
313        let mut blob = encode(SAMPLE, COMP_NONE).unwrap();
314        let footer = blob[blob.len() - FOOTER_SIZE..].to_vec();
315        blob.truncate(blob.len() - FOOTER_SIZE - 1);
316        blob.extend_from_slice(&footer);
317        assert!(matches!(decode(&blob), Err(DvplError::SizeMismatch { .. })));
318    }
319}