From 62c019e744492a7f30a915ef797c2c4c26006462 Mon Sep 17 00:00:00 2001 From: grom Date: Tue, 3 Nov 2026 16:54:06 -0700 Subject: [PATCH] blp: a short colour map is a sliver tile, not a corrupt file Seven of the client's own Interface\WorldMap textures never decoded. They are the last column or row of an exploration overlay -- a few pixels wide, a handful of colours -- so they store a SHORT BGRA palette and end: RuinsOfThaurissan4.blp is 404 bytes in all, LakeEverstill6.blp is 276. The parser sliced the nominal 256 entries and refused anything shorter. A refused decode is a quad that draws nothing, and until this week a texture that drew nothing also said nothing, so the symptom was a hole in a zone's revealed art -- indistinguishable from not having explored it. It surfaced only once the stitched world's geometry was right and the render reached those tiles. The palette is now as much of it as the file has, and an index past the end is transparent black, which is what an unused index means. every_world_map_texture_in_the_chain_decodes walks the folder: 3553 textures, 7 failures before, 0 after, and it fails on the old decoder. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-blp/src/lib.rs | 25 +++++++++++---- crates/benilla-formats/src/lib.rs | 51 +++++++++++++++++++++++++++++++ docs/ADDON_COMPAT.md | 25 +++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/benilla-blp/src/lib.rs b/crates/benilla-blp/src/lib.rs index 9de7a8d6..5f24d014 100644 --- a/crates/benilla-blp/src/lib.rs +++ b/crates/benilla-blp/src/lib.rs @@ -196,9 +196,20 @@ fn parse_header(bytes: &[u8]) -> Result> { return Err(Error::DimensionsTooLarge { width, height }); } - // A 256-entry BGRA palette always follows the header; only Raw1 reads it. + // A BGRA palette follows the header; only Raw1 reads it. Nominally 256 entries, and the + // client's own art does not always ship that many: a sliver overlay tile that uses a handful + // of colours stores a short map and ends. `Interface\WorldMap\BurningSteppes\ + // RuinsOfThaurissan4.blp` is 404 bytes in all and `Redridge\LakeEverstill6.blp` is 276. + // Demanding the full 256 refused both, and a refused decode is a texture that draws nothing + // and — until `report_missing_texture` — said nothing, which read as holes in the map's + // exploration overlays. + // + // So: as much of the map as the file has. An index past the end is transparent black + // ([`decode_raw1`] reads defensively), which is what an unused index means; a tile that uses + // one it did not store would be a corrupt file, not this. let palette = bytes - .get(HEADER_SIZE..HEADER_SIZE + PALETTE_SIZE) + .get(HEADER_SIZE..) + .map(|rest| &rest[..rest.len().min(PALETTE_SIZE)]) .ok_or(Error::BadColorMap)?; let chain = mip_chain_count(width, height, has_mipmaps); @@ -384,10 +395,12 @@ fn decode_raw1(palette: &[u8], data: &[u8], w: u32, h: u32, alpha_bits: u32) -> for i in 0..px { let ci = indices[i] as usize; let p = ci * 4; - // The palette is BGRA. - out[i * 4] = palette[p + 2]; // R - out[i * 4 + 1] = palette[p + 1]; // G - out[i * 4 + 2] = palette[p]; // B + // The palette is BGRA, and may be shorter than 256 entries (see `parse_header`): an + // index past what the file stored is transparent black rather than a panic. + let at = |k: usize| palette.get(k).copied().unwrap_or(0); + out[i * 4] = at(p + 2); // R + out[i * 4 + 1] = at(p + 1); // G + out[i * 4 + 2] = at(p); // B out[i * 4 + 3] = alpha_at(alpha, i, alpha_bits); } Ok(out) diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 46c410d7..fce583c8 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -952,3 +952,54 @@ mod tests { let _ = std::fs::remove_file(&out); } } + +#[cfg(test)] +mod world_map_art_audit { + /// Every `Interface\WorldMap\` texture the client ships has to decode. + /// + /// These are the map's own art: twelve tiles a zone, plus an exploration overlay cut into + /// tiles whose last column or row is often a sliver a few pixels wide. A sliver that uses a + /// handful of colours stores a **short** colour map and ends — `RuinsOfThaurissan4.blp` is + /// 404 bytes, `LakeEverstill6.blp` is 276 — and demanding the nominal 256 entries refused + /// them. A refused decode is a quad that draws nothing, so the only symptom was a hole in a + /// zone's revealed art, which is indistinguishable from not having explored it. + /// + /// Over the whole folder rather than those two files, because the shape is a property of how + /// the art was cut and there is no reason to think the client's own cutter used it twice. + #[test] + fn every_world_map_texture_in_the_chain_decodes() { + let Some(data) = crate::wow_data() else { + eprintln!("skipped: no client data"); + return; + }; + let chain = crate::chain::Chain::open(&data).expect("open the patch chain"); + let entries = chain + .list_under("Interface\\WorldMap\\") + .expect("list the map art"); + let mut seen = 0usize; + let mut bad: Vec<(String, String)> = Vec::new(); + for e in &entries { + if !e.name.to_ascii_lowercase().ends_with(".blp") { + continue; + } + let Ok(bytes) = chain.read(&e.name) else { + continue; // a listed-but-unreadable entry is the chain's problem, not the decoder's + }; + seen += 1; + if let Err(err) = benilla_blp::decode(&bytes) { + bad.push((e.name.clone(), err.to_string())); + } + } + assert!( + seen > 500, + "only {seen} map textures found; is this the right chain?" + ); + assert!( + bad.is_empty(), + "{} of {seen} map textures failed to decode, e.g. {:?}", + bad.len(), + &bad[..bad.len().min(5)] + ); + eprintln!("{seen} Interface\\WorldMap textures decoded"); + } +} diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 1f190da4..c5b963d4 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -2926,3 +2926,28 @@ two zones meet. That is the baked composite the upstream build ships and this on (`MapZones/`, "every zone map composited onto the world ... in 512-px tiles", generated by the addon's own `tools/map-preview/render.py`). It is art, not geometry, and nothing this client does can conjure it. + +## Seven of the client's own map textures never decoded + +With the stitched world's geometry right, the render reached further and the new missing-texture +warning named five files on one shot: + +``` +Interface\WorldMap\BurningSteppes\RUINSOFTHAURISSAN4 +Interface\WorldMap\BurningSteppes\BLACKROCKPASS4 +Interface\WorldMap\BurningSteppes\MORGANSVIGIL4 +Interface\WorldMap\BurningSteppes\PILLAROFASH4 +Interface\WorldMap\Redridge\LAKEEVERSTILL6 +``` + +They are all in the chain. `RuinsOfThaurissan4.blp` is 404 bytes and `LakeEverstill6.blp` is 276 — +**sliver tiles**, the last column or row of an exploration overlay, a few pixels wide. A sliver +that uses a handful of colours stores a **short** colour map and ends, and the BLP parser demanded +the nominal 256 entries and refused the file. A refused decode is a quad that draws nothing, so +the only symptom was a hole in a zone's revealed art — indistinguishable from not having explored +it, which is why it had never been reported. + +The palette is now read as "as much as the file has", and an index past the end is transparent +black, which is what an unused index means. `every_world_map_texture_in_the_chain_decodes` walks +the whole folder: **3553 textures, 7 failures before, 0 after.** It fails on the old decoder, +which is the only reason to trust it.