//! What a WMO's surfaces are **baked** to — the per-group MOCV census behind a "this floor is black" //! report: //! `cargo run -p benilla-formats --example wmo_mocv -- [gN | x,y,z]` //! //! An interior WMO surface takes no exterior light in the reference: an INT-class batch draws //! `tex × MOCV` and a TRANS-class batch lerps to it by `MOCV.a`, so the *only* thing between a black //! floor and a lit one is the bake in the file — plus the runtime portal fixup the client applies //! over it (`FixColorVertexAlpha`, wow-re `wmo-group-lighting.md §4`, which brightens interior verts //! toward white within 6.67 yd of an exterior-neighbour portal). A "dark floor here" report therefore //! has exactly two shapes, told apart by numbers rather than by looking: either the file bakes it //! dark (and the fixup is what lights it), or our reader/classifier mislabels the batch. This prints //! both halves — the group's MOGP flags and per-batch class, and the MOCV spread of each batch's //! vertices split by facing, so a FLOOR (n.z up) reads apart from the walls sharing its batch. //! //! With a model-space `x,y,z` it reports only the groups whose bounding box contains the point (a //! `.gps` pin, inverse-transformed by the placement, names the group the director is standing in). //! //! Output is Blizzard data — pipe it to the scratchpad, never into the repo. use benilla_wmo::{parse_wmo, ParsedWmo}; /// Walk a WMO file's top-level chunks for `magic` (FourCC reversed on disk), returning its payload. /// The examples can't use the crate-private reader, and a group file's MOGP header is the only thing /// here that isn't already on a parsed struct. fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { let mut off = 0usize; while off + 8 <= bytes.len() { let id = &bytes[off..off + 4]; let len = u32::from_le_bytes([ bytes[off + 4], bytes[off + 5], bytes[off + 6], bytes[off + 7], ]) as usize; let body = off + 8; if id == magic { return bytes.get(body..(body + len).min(bytes.len())); } off = body + len; } None } /// The MOCV spread of one set of vertices, in bytes. struct Stat { n: usize, lum_min: f32, lum_max: f32, sum: [f32; 4], } impl Stat { fn new() -> Self { Self { n: 0, lum_min: f32::MAX, lum_max: f32::MIN, sum: [0.0; 4], } } fn add(&mut self, c: [f32; 4]) { let lum = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; self.n += 1; self.lum_min = self.lum_min.min(lum); self.lum_max = self.lum_max.max(lum); for (i, ch) in c.iter().enumerate() { self.sum[i] += ch; } } fn line(&self) -> String { if self.n == 0 { return "—".into(); } let k = self.n as f32; format!( "n={:<5} mean rgba ({:3.0},{:3.0},{:3.0},{:3.0}) lum {:3.0}..{:3.0}", self.n, self.sum[0] / k, self.sum[1] / k, self.sum[2] / k, self.sum[3] / k, self.lum_min, self.lum_max, ) } } fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); let pat = args .next() .ok_or_else(|| anyhow::anyhow!("usage: wmo_mocv [gN | x,y,z]"))?; let filter = args.next(); let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); let mut chain = benilla_formats::open_chain(&data)?; // A `.wmo` argument is taken as a literal chain path, but only if the chain HAS it: `stormwind.wmo` // ends in `.wmo` and is not a path, and the old "ends_with ⇒ literal" rule turned that into a hard // read error. Read as "no groups affected" from a grepped log, it cost a wrong all-clear on // Stormwind, Ironforge and Stratholme. Falling back to the substring search cannot mislead: the // resolved path is printed, and a genuine miss still errors. let lower = pat.to_lowercase(); let path = if lower.ends_with(".wmo") && chain.read_file(&lower).is_ok() { pat.clone() } else { chain .list()? .into_iter() .map(|e| e.name) .find(|n| { let l = n.to_lowercase(); l.ends_with(".wmo") && l.contains(&lower) && !l.contains("_00") }) .ok_or_else(|| anyhow::anyhow!("no root .wmo matching {pat:?}"))? }; println!("{path}"); let root_bytes = chain.read_file(&path.to_ascii_lowercase())?; let ParsedWmo::Root(root) = parse_wmo(&mut std::io::Cursor::new(&root_bytes))? else { anyhow::bail!("{path} is a group file, want the root"); }; // MOHD: nTextures, nGroups, nPortals, nLights, nDoodadNames, nDoodadDefs, nDoodadSets (7×u32), // then ambColor @0x1c, wmoID @0x20, bbox @0x24 (24 B), flags @0x3c. if let Some(h) = chunk(&root_bytes, b"DHOM") { let at = |i: usize| u32::from_le_bytes([h[i], h[i + 1], h[i + 2], h[i + 3]]); println!( "MOHD: {} groups, {} portals, {} lights, {} doodad defs ambColor {:#010x} \ wmoID {} flags {:#06x}", at(4), at(8), at(0xc), at(0x14), at(0x1c), at(0x20), if h.len() >= 0x40 { at(0x3c) } else { 0 }, ); } println!( "{} materials, {} groups", root.materials.len(), root.n_groups ); let wmo_root = benilla_formats::parse_wmo_root(&root_bytes)?; let survey = filter.as_deref() == Some("--survey"); let want_group: Option = filter .as_deref() .and_then(|f| f.strip_prefix('g')) .and_then(|n| n.parse().ok()); let want_point: Option<[f32; 3]> = filter.as_deref().filter(|f| f.contains(',')).and_then(|f| { let v: Vec = f.split(',').filter_map(|s| s.trim().parse().ok()).collect(); (v.len() == 3).then(|| [v[0], v[1], v[2]]) }); let stem = &path[..path.len() - 4]; for gi in 0..root.n_groups as usize { if want_group.is_some_and(|w| w != gi) { continue; } let gpath = format!("{stem}_{gi:03}.wmo"); let Ok(gbytes) = chain.read_file(&gpath.to_ascii_lowercase()) else { continue; }; let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut std::io::Cursor::new(&gbytes)) else { continue; }; // MOGP header: flags @0x08, bbox min @0x0c / max @0x18, portal span @0x24/0x26, and the // batch-section counts TRANS @0x28 / INT @0x2a (MOBA is laid out TRANS, INT, EXT). let Some(mogp) = chunk(&gbytes, b"PGOM").filter(|m| m.len() >= 0x2c) else { continue; }; let f32_at = |i: usize| f32::from_le_bytes([mogp[i], mogp[i + 1], mogp[i + 2], mogp[i + 3]]); let bb = [ f32_at(0x0c), f32_at(0x10), f32_at(0x14), f32_at(0x18), f32_at(0x1c), f32_at(0x20), ]; if let Some(p) = want_point { if !(0..3).all(|i| p[i] >= bb[i] - 1.0 && p[i] <= bb[i + 3] + 1.0) { continue; } } let trans_n = u16::from_le_bytes([mogp[0x28], mogp[0x29]]) as usize; let int_n = u16::from_le_bytes([mogp[0x2a], mogp[0x2b]]) as usize; let interior = (group.flags & 0x48) == 0; let has_colors = group.vertex_colors.len() == group.vertex_positions.len(); println!( "\n=== g{gi:03} flags {:#010x} {} portals {} bbox ({:.0},{:.0},{:.0})..\ ({:.0},{:.0},{:.0}) {} verts MOCV {} {} batches (trans {trans_n}, int {int_n})", group.flags, if interior { "INTERIOR" } else { "exterior" }, u16::from_le_bytes([mogp[0x26], mogp[0x27]]), bb[0], bb[1], bb[2], bb[3], bb[4], bb[5], group.vertex_positions.len(), if has_colors { "yes" } else { "ABSENT" }, group.render_batches.len(), ); // The fade's blast radius on this group: how many authored slots it rewrites, and by how much // — the number that says whether the doorway fixup is a targeted lift or a wash over the room. if let Some(fixed) = benilla_formats::wmo_group_fixed_colors(&gbytes, &wmo_root) { let lum = |c: [f32; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; let (mut changed, mut before, mut after) = (0usize, 0.0f32, 0.0f32); for (raw, f) in group.vertex_colors.iter().zip(&fixed) { let (b, a) = ( lum([raw.r as f32, raw.g as f32, raw.b as f32]), lum([f[2] as f32, f[1] as f32, f[0] as f32]), ); before += b; after += a; if [raw.b, raw.g, raw.r, raw.a] != *f { changed += 1; } } let n = fixed.len().max(1) as f32; println!( " doorway fade: {changed}/{} verts rewritten, mean lum {:.0} → {:.0}", fixed.len(), before / n, after / n ); } if survey { continue; } for (bi, batch) in group.render_batches.iter().enumerate() { let class = if bi < trans_n { "TRANS" } else if bi < trans_n + int_n { "INT" } else { "EXT" }; let material = root.materials.get(batch.material_id as usize); let tex = material .map(|m| m.get_texture1_index(&root.texture_offset_index_map)) .and_then(|i| root.textures.get(i as usize)) .map(String::as_str) .unwrap_or("(none)"); let start = batch.start_index as usize; let idx = group .vertex_indices .get(start..start + batch.count as usize) .unwrap_or(&[]); // Split by facing: a floor's up-facing verts are what a "dark floor" report is about. let (mut up, mut other) = (Stat::new(), Stat::new()); for &i in idx { let i = i as usize; let Some(c) = group.vertex_colors.get(i) else { continue; }; let c = [c.r as f32, c.g as f32, c.b as f32, c.a as f32]; if group.vertex_normals.get(i).is_some_and(|n| n.z > 0.7) { up.add(c); } else { other.add(c); } } println!( " b{bi:3} {class:<5} mat{:<3} flags {:#06x} blend {} {tex}", batch.material_id, material.map_or(0, |m| m.flags), material.map_or(0, |m| m.blend_mode), ); println!(" up-facing {}", up.line()); println!(" other {}", other.line()); } // The same census *after* the reader's bright-doorway fade — what the renderer actually // uploads. A batch that reads black above and white here is a floor the file never baked and // the runtime fixup lights (a WMO transition corridor); one that stays black in both is a // genuinely dark room. let subs = benilla_formats::wmo_group_submeshes(&gbytes, &wmo_root)?; println!( " --- after FixColorVertexAlpha ({} submeshes) ---", subs.len() ); for (si, s) in subs.iter().enumerate() { let (mut up, mut other) = (Stat::new(), Stat::new()); for (vi, c) in s.vertex_colors.iter().enumerate() { let c = c.map(|v| v * 255.0); if s.normals.get(vi).is_some_and(|n| n[2] > 0.7) { up.add(c); } else { other.add(c); } } println!( " s{si:3} {} up-facing {}", s.texture.as_deref().unwrap_or("(none)"), up.line() ); println!(" other {}", other.line()); } } Ok(()) }