Comments that pointed into the maintainer's private reverse-engineering notes (a note by name, a path, a section or finding id, the analysis's own name for a client function, a process word) now carry the fact itself, cited by the reference client's address, by vmangos file and line, or as "the reference". Nobody outside could follow those pointers. Comment text only, plus the test-assert and log strings that carried the same pointers. No claim changes: a comment the notes have since corrected keeps its wording here, and correcting it is separate work. The two reference generators, `scripts/gen-reference-globals.py` and `scripts/gen-reference-verb-events.py`, named the private notes' layout to find their inputs. They now take the input files as arguments (`--capture`, `--framexml`, `--names`, `--extents`, `--disasm`). Run on the same inputs, both write the committed tables row for row; only the two header lines that named the old environment variable change. Checked per file: - the code is identical: each file with its comments stripped compares equal before and after; a changed string literal is compared under a mask that proves only literal text moved and every literal kept its line count; - no client address the old text cited was dropped; - a second reviewer checked every added address against its derivation; - a detector for the notes' names, paths, ids, function names and process words finds none left; - `scripts/gates.sh` is green. Co-Authored-By: Claude Opus 5.5 (1M context) <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` `0x6c43d0`, 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(())
|
||
}
|