rustydemon_blp2/blp.rs
1use std::io::{self, Read};
2
3use crate::dxt::{self, DxtFlags};
4use crate::error::BlpError;
5
6// ── Enumerations ──────────────────────────────────────────────────────────────
7
8/// How the pixel data inside a BLP file is encoded.
9///
10/// This is stored as a single byte in BLP2 headers and as a 4-byte integer
11/// in BLP0/BLP1 headers.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[repr(u8)]
14pub enum ColorEncoding {
15 /// JPEG-compressed data. A shared JPEG header precedes the per-mipmap data.
16 Jpeg = 0,
17 /// Indexed-color palette with up to 256 entries. Alpha is stored separately
18 /// after the pixel-index bytes, in 0-, 1-, 4-, or 8-bit-per-pixel form.
19 Palette = 1,
20 /// DirectX texture compression (DXT1, DXT3, or DXT5), selected by
21 /// [`PixelFormat`] and [`BlpFile::alpha_size`].
22 Dxt = 2,
23 /// Uncompressed 32-bit pixels stored on-disk as BGRA. [`BlpFile::get_pixels`]
24 /// swaps the channels to RGBA before returning.
25 Argb8888 = 3,
26 /// Identical to [`Argb8888`](ColorEncoding::Argb8888); present in some older files.
27 Argb8888Dup = 4,
28}
29
30impl TryFrom<u8> for ColorEncoding {
31 type Error = BlpError;
32 fn try_from(v: u8) -> Result<Self, Self::Error> {
33 match v {
34 0 => Ok(Self::Jpeg),
35 1 => Ok(Self::Palette),
36 2 => Ok(Self::Dxt),
37 3 => Ok(Self::Argb8888),
38 4 => Ok(Self::Argb8888Dup),
39 _ => Err(BlpError::UnsupportedEncoding(v)),
40 }
41 }
42}
43
44/// The sub-format used when [`ColorEncoding`] is [`Dxt`](ColorEncoding::Dxt).
45///
46/// Not all variants are used by every game; BLP files in the wild primarily
47/// use `Dxt1`, `Dxt3`, and `Dxt5`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49#[repr(u8)]
50pub enum PixelFormat {
51 /// DXT1 (BC1) — 4 bits/pixel, 0- or 1-bit alpha.
52 Dxt1 = 0,
53 /// DXT3 (BC2) — 8 bits/pixel, explicit 4-bit alpha.
54 Dxt3 = 1,
55 /// Uncompressed 32-bit ARGB (rarely used as a `PixelFormat` value).
56 Argb8888 = 2,
57 /// 16-bit ARGB (1-bit alpha, 5-bit per RGB channel).
58 Argb1555 = 3,
59 /// 16-bit ARGB (4-bit per channel).
60 Argb4444 = 4,
61 /// 16-bit RGB (5-6-5, no alpha).
62 Rgb565 = 5,
63 /// 8-bit alpha-only channel.
64 A8 = 6,
65 /// DXT5 (BC3) — 8 bits/pixel, interpolated 8-bit alpha.
66 Dxt5 = 7,
67 /// Unspecified / unknown format.
68 Unspecified = 8,
69 /// 16-bit ARGB (2-bit alpha, 5-bit per RGB channel).
70 Argb2565 = 9,
71 /// BC5 (two-channel compression, used for normal maps).
72 Bc5 = 11,
73}
74
75impl TryFrom<u8> for PixelFormat {
76 type Error = ();
77 fn try_from(v: u8) -> Result<Self, ()> {
78 match v {
79 0 => Ok(Self::Dxt1),
80 1 => Ok(Self::Dxt3),
81 2 => Ok(Self::Argb8888),
82 3 => Ok(Self::Argb1555),
83 4 => Ok(Self::Argb4444),
84 5 => Ok(Self::Rgb565),
85 6 => Ok(Self::A8),
86 7 => Ok(Self::Dxt5),
87 8 => Ok(Self::Unspecified),
88 9 => Ok(Self::Argb2565),
89 11 => Ok(Self::Bc5),
90 _ => Err(()),
91 }
92 }
93}
94
95// ── Palette entry ─────────────────────────────────────────────────────────────
96
97#[derive(Clone, Copy, Default)]
98struct Rgba8 {
99 r: u8,
100 g: u8,
101 b: u8,
102 a: u8,
103}
104
105// ── Limits ────────────────────────────────────────────────────────────────────
106
107/// Maximum decoded RGBA byte count we will attempt to allocate (~256 MiB).
108///
109/// A malicious file could declare enormous dimensions; this ceiling prevents
110/// unbounded heap allocation before the mip data bounds check would catch it.
111const MAX_IMAGE_BYTES: usize = 256 * 1024 * 1024;
112
113/// Maximum accepted size for the shared JPEG header block.
114///
115/// Legitimate JPEG headers are a few hundred bytes at most. Values above this
116/// threshold indicate a malformed or malicious file.
117const MAX_JPEG_HEADER: usize = 64 * 1024;
118
119// ── BlpFile ───────────────────────────────────────────────────────────────────
120
121/// A parsed BLP texture file.
122///
123/// Load with [`BlpFile::open`] (from disk) or [`BlpFile::from_bytes`] (from
124/// an in-memory buffer), then call [`BlpFile::get_pixels`] to decode a mipmap
125/// level into raw RGBA bytes.
126///
127/// The entire file is kept in memory so that any mipmap level can be decoded
128/// on demand without re-opening the file.
129///
130/// # Example
131///
132/// ```no_run
133/// use rustydemon_blp2::BlpFile;
134///
135/// let blp = BlpFile::open("icon.blp")?;
136///
137/// for level in 0..blp.mipmap_count() as u32 {
138/// let (pixels, w, h) = blp.get_pixels(level)?;
139/// println!("mip {level}: {w}×{h} ({} bytes)", pixels.len());
140/// }
141/// # Ok::<(), rustydemon_blp2::BlpError>(())
142/// ```
143pub struct BlpFile {
144 /// How the pixel data is encoded on disk.
145 pub color_encoding: ColorEncoding,
146 /// Bits of alpha precision stored per pixel.
147 ///
148 /// * `0` — no alpha (all pixels are fully opaque)
149 /// * `1` — 1-bit alpha (transparent or opaque)
150 /// * `4` — 4-bit alpha (16 levels)
151 /// * `8` — 8-bit alpha (256 levels)
152 pub alpha_size: u8,
153 /// DXT sub-format, relevant only when
154 /// `color_encoding == ColorEncoding::Dxt`.
155 pub preferred_format: PixelFormat,
156 /// Width of the base (level-0) mipmap in pixels.
157 pub width: u32,
158 /// Height of the base (level-0) mipmap in pixels.
159 pub height: u32,
160 mip_offsets: [u32; 16],
161 mip_sizes: [u32; 16],
162 palette: [Rgba8; 256],
163 jpeg_header: Vec<u8>,
164 data: Vec<u8>,
165}
166
167// ── Construction ──────────────────────────────────────────────────────────────
168
169impl BlpFile {
170 /// Load and parse a BLP file from disk.
171 ///
172 /// The entire file is read into memory. Use [`from_bytes`](Self::from_bytes)
173 /// if you already have the data in a buffer (e.g. extracted from a CASC archive).
174 ///
175 /// # Errors
176 ///
177 /// Returns [`BlpError::Io`] if the file cannot be read, or any parse error
178 /// documented on [`from_bytes`](Self::from_bytes).
179 pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, BlpError> {
180 Self::from_bytes(std::fs::read(path)?)
181 }
182
183 /// Parse a BLP file from an in-memory byte buffer.
184 ///
185 /// The buffer is consumed and stored internally so that mipmap data can be
186 /// decoded later without additional allocations.
187 ///
188 /// # Errors
189 ///
190 /// | Error | Cause |
191 /// |-------|-------|
192 /// | [`BlpError::Io`] | Buffer is too short to contain a valid header |
193 /// | [`BlpError::InvalidMagic`] | First 4 bytes are not `BLP0`, `BLP1`, or `BLP2` |
194 /// | [`BlpError::InvalidFormatVersion`] | BLP2 format version field ≠ 1 |
195 /// | [`BlpError::UnsupportedEncoding`] | Color encoding byte is unrecognised |
196 /// | [`BlpError::DataTooShort`] | JPEG header size field exceeds 64 KiB |
197 pub fn from_bytes(data: Vec<u8>) -> Result<Self, BlpError> {
198 parse_header(io::Cursor::new(data))
199 }
200}
201
202// ── Queries ───────────────────────────────────────────────────────────────────
203
204impl BlpFile {
205 /// The number of mipmap levels stored in this file (0–16).
206 ///
207 /// Counted as the number of leading non-zero entries in the mipmap offset
208 /// table. A value of `0` means the file contains no usable image data.
209 pub fn mipmap_count(&self) -> usize {
210 self.mip_offsets.iter().take_while(|&&o| o != 0).count()
211 }
212
213 /// Decode a mipmap level and return `(pixels, width, height)`.
214 ///
215 /// `pixels` is a `Vec<u8>` containing raw **RGBA** data (red first, alpha
216 /// last), 4 bytes per pixel, in row-major left-to-right top-to-bottom order.
217 /// Its length is always `width * height * 4`.
218 ///
219 /// `mipmap_level` is **clamped** to the available range: requesting level 99
220 /// on a file with 3 mip levels returns level 2. Level 0 is always the largest
221 /// (base) image.
222 ///
223 /// # Errors
224 ///
225 /// | Error | Cause |
226 /// |-------|-------|
227 /// | [`BlpError::NoMipmaps`] | `mipmap_count() == 0` |
228 /// | [`BlpError::ImageTooLarge`] | `width × height × 4` overflows or exceeds 256 MiB |
229 /// | [`BlpError::OutOfBounds`] | Mipmap offset/size points outside the file buffer |
230 /// | [`BlpError::DataTooShort`] | Mipmap slice is too small for the declared dimensions |
231 /// | [`BlpError::JpegDecode`] | JPEG data is invalid or corrupt |
232 pub fn get_pixels(&self, mipmap_level: u32) -> Result<(Vec<u8>, u32, u32), BlpError> {
233 let count = self.mipmap_count();
234 if count == 0 {
235 return Err(BlpError::NoMipmaps);
236 }
237
238 let level = (mipmap_level as usize).min(count - 1);
239 let scale = 1u32 << level;
240 let w = (self.width / scale).max(1);
241 let h = (self.height / scale).max(1);
242
243 // Reject before any allocation attempt: w * h * 4 must not overflow
244 // and must not exceed our self-imposed ceiling.
245 let byte_count = (w as usize)
246 .checked_mul(h as usize)
247 .and_then(|n| n.checked_mul(4))
248 .ok_or(BlpError::ImageTooLarge)?;
249 if byte_count > MAX_IMAGE_BYTES {
250 return Err(BlpError::ImageTooLarge);
251 }
252
253 let raw = self.mip_data(level)?;
254 let pixels = self.decode(w, h, raw)?;
255
256 Ok((pixels, w, h))
257 }
258}
259
260// ── Internal helpers ──────────────────────────────────────────────────────────
261
262impl BlpFile {
263 fn mip_data(&self, level: usize) -> Result<&[u8], BlpError> {
264 let off = self.mip_offsets[level] as usize;
265 let size = self.mip_sizes[level] as usize;
266 // checked_add prevents wrapping overflow before the slice bound check.
267 let end = off.checked_add(size).ok_or(BlpError::OutOfBounds)?;
268 self.data.get(off..end).ok_or(BlpError::OutOfBounds)
269 }
270
271 fn decode(&self, w: u32, h: u32, data: &[u8]) -> Result<Vec<u8>, BlpError> {
272 match self.color_encoding {
273 ColorEncoding::Jpeg => self.decode_jpeg(data),
274 ColorEncoding::Palette => self.decode_palette(w, h, data),
275 ColorEncoding::Dxt => self.decode_dxt(w, h, data),
276 ColorEncoding::Argb8888 | ColorEncoding::Argb8888Dup => Ok(bgra_to_rgba(data)),
277 }
278 }
279
280 // ── JPEG ──────────────────────────────────────────────────────────────────
281
282 fn decode_jpeg(&self, data: &[u8]) -> Result<Vec<u8>, BlpError> {
283 // BLP JPEG files share a single header across all mipmap levels.
284 // Prepend it before handing the data to the decoder.
285 let combined: Vec<u8> = if self.jpeg_header.is_empty() {
286 data.to_vec()
287 } else {
288 let mut v = Vec::with_capacity(self.jpeg_header.len() + data.len());
289 v.extend_from_slice(&self.jpeg_header);
290 v.extend_from_slice(data);
291 v
292 };
293
294 let img =
295 image::load_from_memory(&combined).map_err(|e| BlpError::JpegDecode(e.to_string()))?;
296 Ok(img.to_rgba8().into_raw())
297 }
298
299 // ── Palette ───────────────────────────────────────────────────────────────
300
301 fn decode_palette(&self, w: u32, h: u32, data: &[u8]) -> Result<Vec<u8>, BlpError> {
302 let n_pixels = (w as usize)
303 .checked_mul(h as usize)
304 .ok_or(BlpError::ImageTooLarge)?;
305
306 // Data layout: [palette_index × n_pixels] [alpha_data]
307 if data.len() < n_pixels {
308 return Err(BlpError::DataTooShort);
309 }
310
311 // Validate that the alpha region is also present before the pixel loop.
312 if self.alpha_size != 0 {
313 let alpha_bytes: usize = match self.alpha_size {
314 1 => n_pixels.div_ceil(8), // 1 bit/pixel, rounded up
315 4 => n_pixels.div_ceil(2), // 4 bits/pixel, rounded up
316 8 => n_pixels, // 1 byte/pixel
317 _ => 0,
318 };
319 let required = n_pixels
320 .checked_add(alpha_bytes)
321 .ok_or(BlpError::DataTooShort)?;
322 if data.len() < required {
323 return Err(BlpError::DataTooShort);
324 }
325 }
326
327 let mut out = vec![0u8; n_pixels * 4];
328 for i in 0..n_pixels {
329 // data[i] is a u8 (0–255) and palette has exactly 256 entries — always in bounds.
330 let c = self.palette[data[i] as usize];
331 out[i * 4] = c.r;
332 out[i * 4 + 1] = c.g;
333 out[i * 4 + 2] = c.b;
334 out[i * 4 + 3] = palette_alpha(data, i, n_pixels, self.alpha_size);
335 }
336
337 Ok(out)
338 }
339
340 // ── DXT ───────────────────────────────────────────────────────────────────
341
342 fn decode_dxt(&self, w: u32, h: u32, data: &[u8]) -> Result<Vec<u8>, BlpError> {
343 // DXT variant selection mirrors the original SereniaBLPLib logic:
344 // alpha_size > 1 + preferred_format == Dxt5 → DXT5
345 // alpha_size > 1 + anything else → DXT3
346 // alpha_size == 0 or 1 → DXT1
347 let flag = if self.alpha_size > 1 {
348 if self.preferred_format == PixelFormat::Dxt5 {
349 DxtFlags::Dxt5
350 } else {
351 DxtFlags::Dxt3
352 }
353 } else {
354 DxtFlags::Dxt1
355 };
356
357 dxt::decompress_image(w, h, data, flag).ok_or(BlpError::ImageTooLarge)
358 }
359}
360
361// ── Header parser ─────────────────────────────────────────────────────────────
362
363/// Reads the BLP header from `cur`, then returns a [`BlpFile`] that retains
364/// the cursor's inner `Vec<u8>` for on-demand mipmap decoding.
365fn parse_header(mut cur: io::Cursor<Vec<u8>>) -> Result<BlpFile, BlpError> {
366 const MAGIC_BLP0: u32 = 0x30504c42; // b"BLP0"
367 const MAGIC_BLP1: u32 = 0x31504c42; // b"BLP1"
368 const MAGIC_BLP2: u32 = 0x32504c42; // b"BLP2"
369
370 let magic = read_u32(&mut cur)?;
371 if magic != MAGIC_BLP0 && magic != MAGIC_BLP1 && magic != MAGIC_BLP2 {
372 return Err(BlpError::InvalidMagic);
373 }
374
375 // BLP0/BLP1 store every header field as a 4-byte integer.
376 // BLP2 packs color_encoding, alpha_size, preferred_format, and has_mipmaps
377 // into individual bytes, preceded by a 4-byte format version field.
378 let (color_encoding, alpha_size, preferred_format, width, height) = match magic {
379 MAGIC_BLP0 | MAGIC_BLP1 => {
380 let enc = ColorEncoding::try_from(read_i32(&mut cur)? as u8)?;
381 let alpha = read_i32(&mut cur)? as u8;
382 let w = read_i32(&mut cur)? as u32;
383 let h = read_i32(&mut cur)? as u32;
384 let pf = PixelFormat::try_from(read_i32(&mut cur)? as u8)
385 .unwrap_or(PixelFormat::Unspecified);
386 let _hm = read_i32(&mut cur)?; // has_mipmaps flag — unused; we scan the offset table
387 (enc, alpha, pf, w, h)
388 }
389 MAGIC_BLP2 => {
390 let ver = read_u32(&mut cur)?;
391 if ver != 1 {
392 return Err(BlpError::InvalidFormatVersion(ver));
393 }
394 let enc = ColorEncoding::try_from(read_u8(&mut cur)?)?;
395 let alpha = read_u8(&mut cur)?;
396 let pf = PixelFormat::try_from(read_u8(&mut cur)?).unwrap_or(PixelFormat::Unspecified);
397 let _hm = read_u8(&mut cur)?;
398 let w = read_i32(&mut cur)? as u32;
399 let h = read_i32(&mut cur)? as u32;
400 (enc, alpha, pf, w, h)
401 }
402 _ => unreachable!(),
403 };
404
405 // Mipmap offset table: 16 × u32, zero entries mean "no mip at this level".
406 let mut mip_offsets = [0u32; 16];
407 for o in &mut mip_offsets {
408 *o = read_u32(&mut cur)?;
409 }
410
411 // Mipmap size table: 16 × u32, parallel to mip_offsets.
412 let mut mip_sizes = [0u32; 16];
413 for s in &mut mip_sizes {
414 *s = read_u32(&mut cur)?;
415 }
416
417 let mut palette = [Rgba8::default(); 256];
418 let mut jpeg_header = Vec::new();
419
420 match color_encoding {
421 ColorEncoding::Palette => {
422 // 256 palette entries, each a little-endian i32 with layout BGRA
423 // (blue in the lowest byte, alpha in the highest).
424 for c in &mut palette {
425 let v = read_i32(&mut cur)?;
426 c.b = (v & 0xFF) as u8;
427 c.g = ((v >> 8) & 0xFF) as u8;
428 c.r = ((v >> 16) & 0xFF) as u8;
429 c.a = ((v >> 24) & 0xFF) as u8;
430 }
431 }
432 ColorEncoding::Jpeg => {
433 // A single JPEG header is shared by all mipmap levels.
434 // Guard against maliciously large size claims before allocating.
435 let hdr_size = read_i32(&mut cur)? as usize;
436 if hdr_size > MAX_JPEG_HEADER {
437 return Err(BlpError::DataTooShort);
438 }
439 let mut hdr = vec![0u8; hdr_size];
440 cur.read_exact(&mut hdr)?;
441 jpeg_header = hdr;
442 }
443 _ => {}
444 }
445
446 let data = cur.into_inner();
447
448 Ok(BlpFile {
449 color_encoding,
450 alpha_size,
451 preferred_format,
452 width,
453 height,
454 mip_offsets,
455 mip_sizes,
456 palette,
457 jpeg_header,
458 data,
459 })
460}
461
462// ── Alpha extraction ──────────────────────────────────────────────────────────
463
464/// Extracts the alpha value for pixel `index` from a palette mipmap data slice.
465///
466/// Data layout: `[palette_index × n_pixels][alpha_data]`, where `alpha_start`
467/// equals `n_pixels`. The caller must verify that `data` is long enough before
468/// calling this function.
469///
470/// ## Packing formats
471///
472/// * **1-bit** — 8 pixels packed into each byte, LSB first.
473/// Bit set → `0xFF` (opaque), bit clear → `0x00` (transparent).
474/// * **4-bit** — 2 pixels per byte. Even pixels use the low nibble (shifted
475/// left to the high-nibble position), odd pixels use the high nibble as-is.
476/// This matches the original SereniaBLPLib behaviour.
477/// * **8-bit** — one byte per pixel, direct value.
478/// * **Other** — treated as no alpha; returns `0xFF` (fully opaque).
479fn palette_alpha(data: &[u8], index: usize, alpha_start: usize, alpha_size: u8) -> u8 {
480 match alpha_size {
481 1 => {
482 let byte = data[alpha_start + index / 8];
483 if byte & (0x01 << (index % 8)) != 0 {
484 0xFF
485 } else {
486 0x00
487 }
488 }
489 4 => {
490 let byte = data[alpha_start + index / 2];
491 if index.is_multiple_of(2) {
492 (byte & 0x0F) << 4
493 } else {
494 byte & 0xF0
495 }
496 }
497 8 => data[alpha_start + index],
498 _ => 0xFF,
499 }
500}
501
502// ── Channel swap ──────────────────────────────────────────────────────────────
503
504/// Converts a BGRA buffer to RGBA by swapping the red and blue channels in place.
505///
506/// ARGB8888 data is stored on disk in BGRA memory order (matching the
507/// `System.Drawing` / GDI+ convention). This function normalises it to the
508/// RGBA order that [`BlpFile::get_pixels`] always returns.
509fn bgra_to_rgba(src: &[u8]) -> Vec<u8> {
510 let mut out = src.to_vec();
511 for chunk in out.chunks_exact_mut(4) {
512 chunk.swap(0, 2);
513 }
514 out
515}
516
517// ── Low-level reader helpers ──────────────────────────────────────────────────
518
519fn read_u8(r: &mut impl Read) -> io::Result<u8> {
520 let mut b = [0u8; 1];
521 r.read_exact(&mut b)?;
522 Ok(b[0])
523}
524
525fn read_u32(r: &mut impl Read) -> io::Result<u32> {
526 let mut b = [0u8; 4];
527 r.read_exact(&mut b)?;
528 Ok(u32::from_le_bytes(b))
529}
530
531fn read_i32(r: &mut impl Read) -> io::Result<i32> {
532 let mut b = [0u8; 4];
533 r.read_exact(&mut b)?;
534 Ok(i32::from_le_bytes(b))
535}