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