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.