- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module - the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder - the glue screens get a capture shutter, and the engine stops depending on the protocol crate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
306 lines
12 KiB
Rust
306 lines
12 KiB
Rust
//! 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 -- <wmo-path-or-substring> [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 <wmo-path-or-substring> [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<usize> = 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<f32> = 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(())
|
||
}
|