Files
benilla-octo/crates/benilla-blp/src/lib.rs
T
SamandClaude Fable 5 7146bc91f0 the login scene frames its art, char select remembers, DXT stays DXT
- the glue scene widens only as far as its art goes: none of the seven login dioramas is drawn wider than about 3:2 — measured per texel by `benilla-extract glueextent`, which rasterises every batch into the camera's own tan-space, painting where the reference's alpha-key test passes — so the frame zooms down to the reference's own 16:9 opening (never tighter) and pillarboxes past the aspect the art cannot fill, instead of showing the clear colour past the backdrop's slanted edges; 4:3 stays byte-identical
- the character screen opens on whoever you last logged in as: the reference's `lastCharacterIndex` CVar, written at Enter World and only there — clicks and arrow keys never touch it, which is what makes the value mean "last logged in" rather than "last clicked" — restored on every character-list rebuild and clamped into range
- spam-hovering tooltips stops costing 2 ms: two write sites gave up naming their node and re-derived the whole layout graph (1,812 µs against the ledger's 44, on 919 of 1,262 resolves) — the wrap pin now touches the one region it wrote, and re-pointing a tooltip at the button under the cursor retargets the node's edges in place instead of throwing the cached graph away
- two corrections off the project's first GPU-bound report (a Steam Deck at 94% GPU busy on 8.4 W of a 15 W budget, the memory-stall signature): `farclip` ships the reference's registered 350 instead of the clamp's 777 — the value also sizes terrain residency, so 777 streamed, held and drew 4.8× the area — and BLP textures upload WoW's own DXT blocks verbatim the way the reference does, instead of decoding to RGBA8 at 8× the bytes of DXT1; the terrain slider still reaches 777, and the lanes that read texels on the CPU (terrain arrays, the character-skin compositor) stay decoded and say why

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:11:59 +07:00

693 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A BLP2 texture decoder for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-blp` (decision
//! 0021). Decodes to `Rgba8Unorm` (the gamma-byte form the renderer uploads verbatim).
//!
//! 1.12 ships **BLP2 / Direct** content in three shapes (no JPEG in practice):
//! - **Raw1** — a 256-entry **BGRA** palette + 1-byte indices, with a separate 0/1/4/8-bit alpha block.
//! - **Raw3** — uncompressed BGRA8.
//! - **DXTC** — S3TC blocks; `alpha_type` selects DXT1 (0) / DXT3 (1) / DXT5 (7), decoded via the same
//! `texpresso` codec `wow-blp` uses, so the output is byte-identical.
//!
//! **Two entry points, because the GPU wants the blocks and the CPU wants the pixels.**
//! [`decode`] always yields `Rgba8Unorm`; [`decode_native`] hands back the DXTC blocks *verbatim*
//! and only decodes the shapes that have no GPU-native form (Raw1/Raw3). The reference client
//! uploads the stored blocks untouched (`glCompressedTexImage2DARB`; wow-re `system/image/image.md`
//! "raw passthrough — device eats DXT"), so the native path is both the faithful one and 4x-8x
//! cheaper in VRAM and texture bandwidth. Decoding is for consumers that read texels on the CPU.
//!
//! Two fidelity points that cost the old `wow-blp` two forks, folded in here natively:
//! 1. the palette is **BGRA**, not RGBA (reading it as RGBA swaps R↔B on every palettized Blizzard
//! atlas — the "Westfall clutter is blue" bug);
//! 2. a stale/unknown `alpha_type` byte (e.g. `2` on alpha-less particle atlases) is **not fatal** —
//! alpha presence is governed by `alpha_bits`; `alpha_type` only picks the DXT variant.
//!
//! Proven byte-for-byte against `wow-blp` over the real corpus during the decision-0021 migration
//! (oracle test in git history); the `benilla-formats` texture loaders exercise it on every run.
//!
//! Byte access goes through `benilla-bytes` (decision 0064): every header read is bounds-checked,
//! and `width`/`height` are capped at [`MAX_DIM`] so a corrupt header can't turn into a
//! multi-gigabyte allocation in [`decode_dxt`].
use benilla_bytes::ByteExt;
/// Decode error. Anything outside the 1.12 BLP2 envelope surfaces here rather than guessing.
#[derive(Debug)]
pub enum Error {
NotBlp2,
Truncated(&'static str),
UnknownCompression(u8),
BadColorMap,
OutOfBounds {
level: usize,
},
/// `width`/`height` from the header exceed [`MAX_DIM`] — most likely a corrupt or hostile
/// header, since no real 1.12 BLP is anywhere near this large.
DimensionsTooLarge {
width: u32,
height: u32,
},
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::NotBlp2 => write!(f, "not a BLP2 file (bad magic)"),
Error::Truncated(what) => write!(f, "truncated BLP: {what}"),
Error::UnknownCompression(c) => write!(f, "unknown BLP compression {c}"),
Error::BadColorMap => write!(f, "BLP color map shorter than 256 entries"),
Error::OutOfBounds { level } => write!(f, "BLP mip level {level} out of bounds"),
Error::DimensionsTooLarge { width, height } => write!(
f,
"BLP dimensions {width}x{height} exceed the {MAX_DIM} sanity cap"
),
}
}
}
impl std::error::Error for Error {}
type Result<T> = std::result::Result<T, Error>;
/// A decoded BLP: mip-major `Rgba8Unorm`. `mips[0]` is the full image and is always present; deeper
/// levels follow while the file carries them. [`mip_chain_count`](DecodedBlp::mip_chain_count) is the
/// count `wow-blp` reports for the dimensions (what the renderer's authored-mip upload iterates).
pub struct DecodedBlp {
pub width: u32,
pub height: u32,
/// One RGBA8 buffer per decoded mip level, level 0 first.
pub mips: Vec<MipLevel>,
mip_chain_count: usize,
}
/// One decoded mip level.
pub struct MipLevel {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
impl DecodedBlp {
/// The authored mip-chain length for these dimensions, matching `wow-blp`'s `mipmaps_count()`
/// (`max(log2 w, log2 h)` when the file has mipmaps, else 0). The renderer's mip-chain upload
/// iterates exactly this many levels; `mips` may hold one more (level 0) when this is 0.
pub fn mip_chain_count(&self) -> usize {
self.mip_chain_count
}
}
const HEADER_SIZE: usize = 148; // magic..=mip_sizes[16]
const PALETTE_SIZE: usize = 256 * 4;
/// Sanity cap on header `width`/`height`. Real 1.12 art tops out at 1024×1024; this is pure
/// headroom above any real asset, and its only job is bounding allocation: [`decode_dxt`] sizes
/// both its block-padding buffer and its `w*h*4` RGBA output straight from these fields, so a
/// corrupt header claiming e.g. 65535×65535 would otherwise reserve gigabytes before a single
/// byte of pixel data is checked.
const MAX_DIM: u32 = 8192;
/// `wow-blp`'s mip count formula (kept bit-identical, float `log2` and all, so the chain length we
/// report matches the oracle exactly).
fn mip_chain_count(width: u32, height: u32, has_mipmaps: bool) -> usize {
if has_mipmaps {
let w = (width as f32).log2() as usize;
let h = (height as f32).log2() as usize;
w.max(h)
} else {
0
}
}
fn level_size(width: u32, height: u32, level: usize) -> (u32, u32) {
if level == 0 {
(width, height)
} else {
((width >> level).max(1), (height >> level).max(1))
}
}
/// The texel form a [`NativeBlp`] level carries.
///
/// A BLP is one of three shapes on disk (module header); only DXTC has a GPU-native form, so the
/// other two report [`Self::Rgba8Unorm`] and carry decoded pixels. Callers switch on this to pick a
/// `TextureFormat` and to size a level.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum BlpTexels {
/// Decoded pixels — the Raw1 (palettized) and Raw3 (BGRA8) shapes, which have no block form.
Rgba8Unorm,
/// DXT1 blocks (8 bytes per 4x4), `alpha_type` 0. 1-bit alpha at most.
Bc1,
/// DXT3 blocks (16 bytes per 4x4), `alpha_type` 1. Explicit 4-bit alpha.
Bc2,
/// DXT5 blocks (16 bytes per 4x4), `alpha_type` 7. Interpolated alpha.
Bc3,
}
impl BlpTexels {
/// Is this a block-compressed form (i.e. can it be uploaded to the GPU verbatim)?
pub fn is_block_compressed(self) -> bool {
!matches!(self, Self::Rgba8Unorm)
}
/// How many bytes a `width x height` level occupies in this form.
///
/// Block forms round **up** to whole 4x4 blocks, which is why a 2x2 or 1x1 tail mip still costs
/// one full block — exactly what the BLP stores for it, and what wgpu expects to receive.
pub fn level_bytes(self, width: u32, height: u32) -> usize {
match self {
Self::Rgba8Unorm => (width as usize) * (height as usize) * 4,
Self::Bc1 | Self::Bc2 | Self::Bc3 => {
let blocks = width.div_ceil(4) as usize * height.div_ceil(4) as usize;
blocks * if self == Self::Bc1 { 8 } else { 16 }
}
}
}
/// The `texpresso` codec for a block form, or `None` for [`Self::Rgba8Unorm`].
fn codec(self) -> Option<texpresso::Format> {
match self {
Self::Rgba8Unorm => None,
Self::Bc1 => Some(texpresso::Format::Bc1),
Self::Bc2 => Some(texpresso::Format::Bc2),
Self::Bc3 => Some(texpresso::Format::Bc3),
}
}
}
/// A BLP with its levels in whatever form the file stores them — see [`decode_native`].
pub struct NativeBlp {
pub width: u32,
pub height: u32,
/// What every entry of `mips` holds.
pub texels: BlpTexels,
/// One buffer per level, level 0 first. Never empty (same guarantee as [`DecodedBlp::mips`]).
pub mips: Vec<NativeMip>,
mip_chain_count: usize,
}
/// One level of a [`NativeBlp`].
pub struct NativeMip {
pub width: u32,
pub height: u32,
/// Block bytes (DXTC) or RGBA8 pixels — per [`NativeBlp::texels`]. Always exactly
/// [`BlpTexels::level_bytes`] long: a short tail level is zero-padded, matching what
/// [`decode`] does before handing blocks to the codec.
pub bytes: Vec<u8>,
}
impl NativeBlp {
/// See [`DecodedBlp::mip_chain_count`].
pub fn mip_chain_count(&self) -> usize {
self.mip_chain_count
}
}
/// The header fields both entry points read, parsed once.
struct Header<'a> {
compression: u8,
alpha_bits: u32,
alpha_type: u8,
width: u32,
height: u32,
offsets: Vec<u32>,
sizes: Vec<u32>,
palette: &'a [u8],
/// The authored chain length, `mip_chain_count(width, height, has_mipmaps)`.
chain: usize,
}
/// Parse and bounds-check the BLP2 header, palette and level table. Shared by [`decode`] and
/// [`decode_native`] so the two can never disagree about what a file says.
fn parse_header(bytes: &[u8]) -> Result<Header<'_>> {
if bytes.len() < HEADER_SIZE || &bytes[0..4] != b"BLP2" {
return Err(Error::NotBlp2);
}
// content @4 (u32; 1 = Direct — we only handle direct, the only kind 1.12 ships).
// These reads are already covered by the `HEADER_SIZE` length check above; routed through
// `ByteExt` anyway for uniformity (decision 0064) — no future reader should have to ask
// "is this offset guarded?".
let compression = bytes.u8_at(8).ok_or(Error::Truncated("header"))?;
let alpha_bits = bytes.u8_at(9).ok_or(Error::Truncated("header"))? as u32;
let alpha_type = bytes.u8_at(10).ok_or(Error::Truncated("header"))?;
let has_mipmaps = bytes.u8_at(11).ok_or(Error::Truncated("header"))? != 0;
let width = bytes.u32_at(12).ok_or(Error::Truncated("header"))?;
let height = bytes.u32_at(16).ok_or(Error::Truncated("header"))?;
let offsets: Vec<u32> = (0..16)
.map(|i| bytes.u32_at(20 + i * 4).ok_or(Error::Truncated("header")))
.collect::<Result<_>>()?;
let sizes: Vec<u32> = (0..16)
.map(|i| bytes.u32_at(84 + i * 4).ok_or(Error::Truncated("header")))
.collect::<Result<_>>()?;
// The corrupt-header guard (see `MAX_DIM`): bound before anything downstream sizes an
// allocation from these fields. Zero is left alone — see the decode_dxt/decode_raw1/
// decode_raw3 zero-pixel behavior, unchanged from before this migration.
if width > MAX_DIM || height > MAX_DIM {
return Err(Error::DimensionsTooLarge { width, height });
}
// BLP2 direct content always carries a 256-entry palette right after the header (used only by
// Raw1; present-but-ignored for Raw3/DXT). Read as BGRA bytes.
let palette = bytes
.get(HEADER_SIZE..HEADER_SIZE + PALETTE_SIZE)
.ok_or(Error::BadColorMap)?;
let chain = mip_chain_count(width, height, has_mipmaps);
Ok(Header {
compression,
alpha_bits,
alpha_type,
width,
height,
offsets,
sizes,
palette,
chain,
})
}
impl Header<'_> {
/// The raw stored bytes of level `level`, or `None` when the chain ends early (some textures
/// stop before the formula's count).
fn level_data<'b>(&self, bytes: &'b [u8], level: usize) -> Result<Option<&'b [u8]>> {
let off = self.offsets[level] as usize;
let sz = self.sizes[level] as usize;
if level > 0 && (sz == 0 || off == 0) {
return Ok(None);
}
bytes
.get(off..off + sz)
.map(Some)
.ok_or(Error::OutOfBounds { level })
}
/// Which block form this file's DXTC levels are in. `alpha_type` 0→DXT1, 1→DXT3, 7→DXT5;
/// anything else (a stale byte — e.g. `2` on alpha-less particle atlases) → DXT1, since
/// `alpha_bits` already governs whether alpha is meaningful.
fn dxt_texels(&self) -> BlpTexels {
match self.alpha_type {
1 => BlpTexels::Bc2,
7 => BlpTexels::Bc3,
_ => BlpTexels::Bc1,
}
}
}
/// Decode a BLP2 texture (raw archive bytes) to RGBA8 mip levels.
pub fn decode(bytes: &[u8]) -> Result<DecodedBlp> {
let h = parse_header(bytes)?;
// Always decode level 0 (blp_to_rgba needs it even when the chain count is 0); then deeper levels
// up to the chain count, stopping if the file doesn't carry one.
let n = h.chain.clamp(1, 16);
let mut mips = Vec::with_capacity(n);
for level in 0..n {
let (lw, lh) = level_size(h.width, h.height, level);
let Some(data) = h.level_data(bytes, level)? else {
break;
};
let rgba = match h.compression {
1 => decode_raw1(h.palette, data, lw, lh, h.alpha_bits),
2 => decode_dxt(data, lw, lh, h.dxt_texels()),
3 => decode_raw3(data, lw, lh),
other => return Err(Error::UnknownCompression(other)),
}?;
mips.push(MipLevel {
width: lw,
height: lh,
rgba,
});
}
Ok(DecodedBlp {
width: h.width,
height: h.height,
mips,
mip_chain_count: h.chain,
})
}
/// Decode a BLP2 texture **keeping its DXTC blocks verbatim** — the form the reference uploads.
///
/// DXTC levels come back as block bytes with [`NativeBlp::texels`] naming the variant; Raw1/Raw3
/// files, which have no block form, are decoded exactly as [`decode`] would and report
/// [`BlpTexels::Rgba8Unorm`]. Either way every level is padded to its full
/// [`BlpTexels::level_bytes`], so a caller can concatenate the chain and hand it to the GPU without
/// re-deriving sizes.
pub fn decode_native(bytes: &[u8]) -> Result<NativeBlp> {
let h = parse_header(bytes)?;
let texels = if h.compression == 2 {
h.dxt_texels()
} else {
BlpTexels::Rgba8Unorm
};
let n = h.chain.clamp(1, 16);
let mut mips = Vec::with_capacity(n);
for level in 0..n {
let (lw, lh) = level_size(h.width, h.height, level);
let Some(data) = h.level_data(bytes, level)? else {
break;
};
let bytes = match h.compression {
1 => decode_raw1(h.palette, data, lw, lh, h.alpha_bits)?,
2 => pad_to(data, texels.level_bytes(lw, lh)),
3 => decode_raw3(data, lw, lh)?,
other => return Err(Error::UnknownCompression(other)),
};
mips.push(NativeMip {
width: lw,
height: lh,
bytes,
});
}
Ok(NativeBlp {
width: h.width,
height: h.height,
texels,
mips,
mip_chain_count: h.chain,
})
}
/// Decode one block-compressed level to RGBA8 — the inverse of keeping the blocks.
///
/// For a caller that took [`decode_native`]'s passthrough and then found it could not upload it
/// after all (no BC on the device): it can get the pixels without re-reading and re-parsing the
/// file. `texels` must be a block form; [`BlpTexels::Rgba8Unorm`] returns `bytes` unchanged, since
/// that is already the answer.
pub fn decode_level(texels: BlpTexels, width: u32, height: u32, bytes: &[u8]) -> Vec<u8> {
let Some(fmt) = texels.codec() else {
return bytes.to_vec();
};
let blocks = pad_to(bytes, fmt.compressed_size(width as usize, height as usize));
let mut out = vec![0u8; (width as usize) * (height as usize) * 4];
fmt.decompress(&blocks, width as usize, height as usize, &mut out);
out
}
/// `data` grown to exactly `need` bytes with zeros (or truncated if the file over-stores).
///
/// The pad is what [`decode`] has always done before handing a short tail mip to the codec
/// (matching wow-blp / SereniaBLPLib); the native path owes the GPU the same full level.
fn pad_to(data: &[u8], need: usize) -> Vec<u8> {
let mut out = vec![0u8; need];
let n = data.len().min(need);
out[..n].copy_from_slice(&data[..n]);
out
}
/// Palettized: 1-byte indices into a 256-entry BGRA palette, then a packed alpha block (`alpha_bits`).
fn decode_raw1(palette: &[u8], data: &[u8], w: u32, h: u32, alpha_bits: u32) -> Result<Vec<u8>> {
let px = (w as usize) * (h as usize);
let indices = data.get(..px).ok_or(Error::Truncated("raw1 indices"))?;
let alpha = &data[px..];
let mut out = vec![0u8; px * 4];
for i in 0..px {
let ci = indices[i] as usize;
let p = ci * 4;
// Palette is BGRA: byte0=B, byte1=G, byte2=R. (The fork fix — RGBA reading swaps R↔B.)
out[i * 4] = palette[p + 2]; // R
out[i * 4 + 1] = palette[p + 1]; // G
out[i * 4 + 2] = palette[p]; // B
out[i * 4 + 3] = alpha_at(alpha, i, alpha_bits);
}
Ok(out)
}
/// Per-pixel alpha from the packed block. `0` ⇒ opaque (no alpha channel); `1/4/8` ⇒ packed bits.
fn alpha_at(alpha: &[u8], i: usize, alpha_bits: u32) -> u8 {
match alpha_bits {
1 => {
let bit = (alpha.get(i / 8).copied().unwrap_or(0) >> (i % 8)) & 1;
if bit == 1 {
255
} else {
0
}
}
4 => {
let block = alpha.get(i / 2).copied().unwrap_or(0);
let nib = if i.is_multiple_of(2) {
block & 0x0F
} else {
block >> 4
};
(nib << 4) | nib
}
8 => alpha.get(i).copied().unwrap_or(255),
_ => 255, // alpha_bits == 0: fully opaque
}
}
/// Uncompressed BGRA8 → RGBA8.
fn decode_raw3(data: &[u8], w: u32, h: u32) -> Result<Vec<u8>> {
let px = (w as usize) * (h as usize);
let src = data.get(..px * 4).ok_or(Error::Truncated("raw3 pixels"))?;
let mut out = vec![0u8; px * 4];
for i in 0..px {
out[i * 4] = src[i * 4 + 2]; // R
out[i * 4 + 1] = src[i * 4 + 1]; // G
out[i * 4 + 2] = src[i * 4]; // B
out[i * 4 + 3] = src[i * 4 + 3]; // A
}
Ok(out)
}
/// DXTC → RGBA8. The variant comes from [`Header::dxt_texels`]. Undersized small-mip data is
/// zero-padded to the block size (matching wow-blp / SereniaBLPLib), so a short tail mip still
/// decodes.
fn decode_dxt(data: &[u8], w: u32, h: u32, texels: BlpTexels) -> Result<Vec<u8>> {
let fmt = texels.codec().expect("dxt_texels never returns Rgba8Unorm");
let blocks = pad_to(data, fmt.compressed_size(w as usize, h as usize));
let mut out = vec![0u8; (w as usize) * (h as usize) * 4];
fmt.decompress(&blocks, w as usize, h as usize, &mut out);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal-but-complete BLP2 header (magic..=mip_sizes[16], `HEADER_SIZE` bytes).
#[allow(clippy::too_many_arguments)]
fn header(
compression: u8,
alpha_bits: u8,
alpha_type: u8,
has_mipmaps: u8,
width: u32,
height: u32,
offsets: [u32; 16],
sizes: [u32; 16],
) -> Vec<u8> {
let mut b = vec![0u8; HEADER_SIZE];
b[0..4].copy_from_slice(b"BLP2");
b[4..8].copy_from_slice(&1u32.to_le_bytes()); // content: 1 = Direct
b[8] = compression;
b[9] = alpha_bits;
b[10] = alpha_type;
b[11] = has_mipmaps;
b[12..16].copy_from_slice(&width.to_le_bytes());
b[16..20].copy_from_slice(&height.to_le_bytes());
for (i, o) in offsets.iter().enumerate() {
b[20 + i * 4..24 + i * 4].copy_from_slice(&o.to_le_bytes());
}
for (i, s) in sizes.iter().enumerate() {
b[84 + i * 4..88 + i * 4].copy_from_slice(&s.to_le_bytes());
}
b
}
#[test]
fn corrupt_header_huge_dims_errors_cleanly() {
// The fix under test: a header claiming an absurd width/height must fail fast, not size a
// multi-gigabyte allocation in decode_dxt. No palette/pixel bytes needed — the dimension
// check runs before either is touched.
let b = header(3, 0, 0, 0, 65535, 65535, [0; 16], [0; 16]);
assert!(matches!(
decode(&b),
Err(Error::DimensionsTooLarge {
width: 65535,
height: 65535
})
));
// Right at the cap is fine (still fails later for lack of palette/pixel bytes, but not on
// the dimension check); just over the cap on either axis alone is rejected.
let b = header(3, 0, 0, 0, MAX_DIM + 1, 4, [0; 16], [0; 16]);
assert!(matches!(decode(&b), Err(Error::DimensionsTooLarge { .. })));
let b = header(3, 0, 0, 0, 4, MAX_DIM + 1, [0; 16], [0; 16]);
assert!(matches!(decode(&b), Err(Error::DimensionsTooLarge { .. })));
}
#[test]
fn truncated_header_errors_cleanly() {
// Shorter than HEADER_SIZE, even with a valid magic, is rejected before any field read.
let mut b = vec![0u8; 10];
b[0..4].copy_from_slice(b"BLP2");
assert!(matches!(decode(&b), Err(Error::NotBlp2)));
assert!(matches!(decode(&[]), Err(Error::NotBlp2)));
assert!(matches!(decode(b"not a blp at all"), Err(Error::NotBlp2)));
}
/// Build a DXT-compressed (compression 2) BLP with a real mip chain: 8x8 down to 1x1, so the
/// tail levels are the sub-block ones that make block handling interesting.
fn dxt_blp(alpha_type: u8, block_bytes: usize) -> Vec<u8> {
let mut offsets = [0u32; 16];
let mut sizes = [0u32; 16];
let mut payload = Vec::new();
let base = (HEADER_SIZE + PALETTE_SIZE) as u32;
// 8x8, 4x4, 2x2, 1x1 — mip_chain_count(8, 8, true) == 3, so both entry points read the
// first THREE levels (8x8, 4x4, 2x2); the 1x1 is stored but past the authored count.
for (i, (w, h)) in [(8u32, 8u32), (4, 4), (2, 2), (1, 1)]
.into_iter()
.enumerate()
{
let blocks = w.div_ceil(4) as usize * h.div_ceil(4) as usize;
let n = blocks * block_bytes;
offsets[i] = base + payload.len() as u32;
sizes[i] = n as u32;
// Deterministic non-trivial block payload so a byte swap would show.
payload.extend((0..n).map(|k| (k as u32 * 37 + i as u32 * 11) as u8));
}
let mut b = header(2, 8, alpha_type, 1, 8, 8, offsets, sizes);
b.resize(HEADER_SIZE + PALETTE_SIZE, 0);
b.extend_from_slice(&payload);
b
}
/// The load-bearing equivalence for the native upload path: `decode_native` must hand back the
/// file's blocks *verbatim*, and running those blocks through the codec must reproduce exactly
/// what `decode` produces. If these ever diverge, a GPU-uploaded texture stops matching what
/// every CPU-side consumer sees.
#[test]
fn native_blocks_are_verbatim_and_decode_to_the_same_pixels() {
for (alpha_type, texels, block_bytes) in [
(0u8, BlpTexels::Bc1, 8usize),
(1, BlpTexels::Bc2, 16),
(7, BlpTexels::Bc3, 16),
(2, BlpTexels::Bc1, 8), // stale alpha_type byte falls back to DXT1
] {
let b = dxt_blp(alpha_type, block_bytes);
let native = decode_native(&b).expect("valid DXT BLP decodes natively");
let decoded = decode(&b).expect("valid DXT BLP decodes to pixels");
assert_eq!(native.texels, texels, "alpha_type {alpha_type}");
assert!(native.texels.is_block_compressed());
assert_eq!(native.mips.len(), decoded.mips.len());
assert_eq!(native.mip_chain_count(), decoded.mip_chain_count());
for (level, (n, d)) in native.mips.iter().zip(&decoded.mips).enumerate() {
assert_eq!((n.width, n.height), (d.width, d.height));
// Every level is exactly its full block size — including the 2x2 and 1x1 tails,
// which occupy one whole block each.
assert_eq!(
n.bytes.len(),
texels.level_bytes(n.width, n.height),
"level {level} block size"
);
// Verbatim: the bytes are the file's, not re-encoded.
let off = native_level_offset(&b, level);
assert_eq!(
&n.bytes[..],
&b[off..off + n.bytes.len()],
"level {level} must be the file's own blocks"
);
// And they decode to exactly what the RGBA path produced.
let fmt = texels.codec().unwrap();
let mut out = vec![0u8; (n.width as usize) * (n.height as usize) * 4];
fmt.decompress(&n.bytes, n.width as usize, n.height as usize, &mut out);
assert_eq!(out, d.rgba, "level {level} pixels must match decode()");
}
}
}
/// Where level `i`'s payload starts, read back out of the header we built.
fn native_level_offset(blp: &[u8], level: usize) -> usize {
u32::from_le_bytes(blp[20 + level * 4..24 + level * 4].try_into().unwrap()) as usize
}
/// Raw1/Raw3 have no block form, so the native path decodes them and says so — a caller that
/// switches on `texels` gets a correct `Rgba8Unorm` upload rather than garbage.
#[test]
fn native_reports_rgba_for_the_shapes_with_no_block_form() {
let pixel_offset = (HEADER_SIZE + PALETTE_SIZE) as u32;
let mut offsets = [0u32; 16];
let mut sizes = [0u32; 16];
offsets[0] = pixel_offset;
sizes[0] = 2 * 2 * 4;
let mut b = header(3, 8, 0, 0, 2, 2, offsets, sizes);
b.resize(HEADER_SIZE + PALETTE_SIZE, 0);
b.extend_from_slice(&[
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160,
]);
let native = decode_native(&b).expect("raw3 decodes natively");
assert_eq!(native.texels, BlpTexels::Rgba8Unorm);
assert!(!native.texels.is_block_compressed());
assert_eq!(native.mips[0].bytes, decode(&b).unwrap().mips[0].rgba);
assert_eq!(
native.mips[0].bytes.len(),
BlpTexels::Rgba8Unorm.level_bytes(2, 2)
);
}
/// A tail level the file under-stores is zero-padded to a whole block, so the concatenated
/// chain a caller uploads is always exactly the size wgpu expects.
#[test]
fn a_short_tail_level_is_padded_to_its_full_block() {
let mut b = dxt_blp(0, 8);
// Level 2 is the 2x2 tail — one whole BC1 block. Shrink its recorded size to 3 bytes.
let short = 3u32;
b[84 + 2 * 4..88 + 2 * 4].copy_from_slice(&short.to_le_bytes());
let native = decode_native(&b).expect("short tail still decodes");
let tail = native.mips.last().unwrap();
assert_eq!((tail.width, tail.height), (2, 2));
assert_eq!(tail.bytes.len(), 8, "one whole BC1 block");
assert!(tail.bytes[short as usize..].iter().all(|&x| x == 0));
// And decode() survives the same truncation — the two paths pad identically.
assert_eq!(decode(&b).unwrap().mips.len(), native.mips.len());
}
#[test]
fn tiny_raw3_decodes_expected_pixels() {
// A 2x2 uncompressed BGRA8 (Raw3) image, no mipmaps: the smallest concrete shape decode()
// handles. Pixel data sits right after the header + 256-entry BGRA palette.
let pixel_offset = (HEADER_SIZE + PALETTE_SIZE) as u32;
let pixel_size = 2 * 2 * 4;
let mut offsets = [0u32; 16];
let mut sizes = [0u32; 16];
offsets[0] = pixel_offset;
sizes[0] = pixel_size;
let mut b = header(3, 8, 0, 0, 2, 2, offsets, sizes);
b.resize(HEADER_SIZE + PALETTE_SIZE, 0); // palette bytes: unused by Raw3, present anyway
// Four BGRA8 pixels.
let bgra: &[u8] = &[
10, 20, 30, 40, // B G R A
50, 60, 70, 80, //
90, 100, 110, 120, //
130, 140, 150, 160,
];
b.extend_from_slice(bgra);
let decoded = decode(&b).expect("valid tiny BLP decodes");
assert_eq!(decoded.width, 2);
assert_eq!(decoded.height, 2);
assert_eq!(decoded.mip_chain_count(), 0); // no mipmaps: chain formula reports 0
assert_eq!(decoded.mips.len(), 1); // level 0 is still decoded regardless
let mip = &decoded.mips[0];
assert_eq!(mip.width, 2);
assert_eq!(mip.height, 2);
// BGRA -> RGBA per pixel.
assert_eq!(
mip.rgba,
vec![
30, 20, 10, 40, // R G B A
70, 60, 50, 80, //
110, 100, 90, 120, //
150, 140, 130, 160,
]
);
}
}