- 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>
221 lines
8.7 KiB
Rust
221 lines
8.7 KiB
Rust
//! **Every WMO in the chain that authors a surface too dark to render on its own** — the world sweep
|
||
//! behind decision 0956's Dire Maul report:
|
||
//! `cargo run --release -p benilla-formats --example wmo_dark_floors [-- <substring>]`
|
||
//!
|
||
//! The bug 0956 fixed was never Dire Maul's: a WMO transition group may bake a surface at near-black
|
||
//! with `MOCV.α = 0`, leaving the runtime bright-doorway fade (`FixColorVertexAlpha`) to light it.
|
||
//! Skip the fade and the interior TRANS/INT law renders the bake literally — a black floor. One
|
||
//! building was *reported*; this asks the question the report could not, which is how many others
|
||
//! were sitting there unreported.
|
||
//!
|
||
//! Severity is **area, not vertex count**. A vertex is *dark* when the file bakes it near-black at
|
||
//! zero alpha (luminance < 48, `α == 0`) and the fade lifts it past 200 — it would have drawn black
|
||
//! and now draws lit. A **triangle** counts when all three of its vertices are, and its model-space
|
||
//! area is summed: that is literally the square yardage of black floor a player would have walked
|
||
//! over (floor, wall or ceiling alike). Batches are reported with their share of their own surface, so a wholly-black walkway
|
||
//! (Dire Maul: 100%) sorts above a darkened seam.
|
||
//!
|
||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||
|
||
use benilla_wmo::{parse_wmo, ParsedWmo};
|
||
|
||
/// Rec. 601 luminance of a BGRA slot, in bytes.
|
||
fn lum(c: [u8; 4]) -> f32 {
|
||
0.299 * f32::from(c[2]) + 0.587 * f32::from(c[1]) + 0.114 * f32::from(c[0])
|
||
}
|
||
|
||
/// Would this slot have rendered black, and does the fade rescue it?
|
||
fn dark(raw: [u8; 4], fixed: [u8; 4]) -> bool {
|
||
raw[3] == 0 && lum(raw) < 48.0 && lum(fixed) > 200.0
|
||
}
|
||
|
||
/// One reported surface: a batch of a group whose bake would not have rendered.
|
||
struct Hit {
|
||
wmo: String,
|
||
group: usize,
|
||
batch: usize,
|
||
class: &'static str,
|
||
texture: String,
|
||
/// Square yards of triangle whose every vertex was dark.
|
||
area: f32,
|
||
/// That area as a share of the batch's own total.
|
||
share: f32,
|
||
}
|
||
|
||
fn main() -> anyhow::Result<()> {
|
||
let filter = std::env::args().nth(1).map(|s| s.to_lowercase());
|
||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||
let mut chain = benilla_formats::open_chain(&data)?;
|
||
|
||
// Root .wmo files only: a group file is `<stem>_NNN.wmo`.
|
||
let mut roots: Vec<String> = chain
|
||
.list()?
|
||
.into_iter()
|
||
.map(|e| e.name)
|
||
.filter(|n| {
|
||
let l = n.to_lowercase();
|
||
l.ends_with(".wmo")
|
||
&& !l[..l.len() - 4]
|
||
.rsplit_once('_')
|
||
.is_some_and(|(_, t)| t.len() == 3 && t.bytes().all(|b| b.is_ascii_digit()))
|
||
})
|
||
.filter(|n| filter.as_ref().is_none_or(|f| n.to_lowercase().contains(f)))
|
||
.collect();
|
||
roots.sort();
|
||
roots.dedup();
|
||
eprintln!("sweeping {} WMO roots…", roots.len());
|
||
|
||
let mut hits: Vec<Hit> = Vec::new();
|
||
let (mut scanned, mut groups) = (0usize, 0usize);
|
||
|
||
for path in &roots {
|
||
let Ok(root_bytes) = chain.read_file(&path.to_ascii_lowercase()) else {
|
||
continue;
|
||
};
|
||
let Ok(root) = benilla_formats::parse_wmo_root(&root_bytes) else {
|
||
continue;
|
||
};
|
||
let Ok(ParsedWmo::Root(raw_root)) = parse_wmo(&mut std::io::Cursor::new(&root_bytes))
|
||
else {
|
||
continue;
|
||
};
|
||
scanned += 1;
|
||
let stem = &path[..path.len() - 4];
|
||
for gi in 0..root.group_count() as usize {
|
||
let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo").to_ascii_lowercase())
|
||
else {
|
||
continue;
|
||
};
|
||
let (Some(raw), Some(fixed)) = (
|
||
benilla_formats::wmo_group_raw_colors(&gbytes),
|
||
benilla_formats::wmo_group_fixed_colors(&gbytes, &root),
|
||
) else {
|
||
continue;
|
||
};
|
||
let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut std::io::Cursor::new(&gbytes)) else {
|
||
continue;
|
||
};
|
||
groups += 1;
|
||
let is_dark: Vec<bool> = raw.iter().zip(&fixed).map(|(r, f)| dark(*r, *f)).collect();
|
||
if !is_dark.iter().any(|&d| d) {
|
||
continue;
|
||
}
|
||
// MOBA is laid out TRANS, INT, EXT — only the first two render the bake.
|
||
let (trans_n, int_n) = benilla_formats::wmo_group_header(&gbytes)
|
||
.map(|_| ())
|
||
.and_then(|()| {
|
||
let m = mogp(&gbytes)?;
|
||
(m.len() >= 0x2c).then(|| {
|
||
(
|
||
u16::from_le_bytes([m[0x28], m[0x29]]) as usize,
|
||
u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize,
|
||
)
|
||
})
|
||
})
|
||
.unwrap_or((0, 0));
|
||
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 {
|
||
continue; // EXT batches take the exterior law; the bake never shows
|
||
};
|
||
let start = batch.start_index as usize;
|
||
let idx = group
|
||
.vertex_indices
|
||
.get(start..start + batch.count as usize)
|
||
.unwrap_or(&[]);
|
||
let (mut dark_area, mut total_area) = (0.0f32, 0.0f32);
|
||
for t in idx.chunks_exact(3) {
|
||
let p: Vec<[f32; 3]> = t
|
||
.iter()
|
||
.filter_map(|&i| group.vertex_positions.get(i as usize))
|
||
.map(|v| [v.x, v.y, v.z])
|
||
.collect();
|
||
if p.len() != 3 {
|
||
continue;
|
||
}
|
||
let (u, v) = (
|
||
[p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]],
|
||
[p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]],
|
||
);
|
||
let c = [
|
||
u[1] * v[2] - u[2] * v[1],
|
||
u[2] * v[0] - u[0] * v[2],
|
||
u[0] * v[1] - u[1] * v[0],
|
||
];
|
||
let a = 0.5 * (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt();
|
||
total_area += a;
|
||
if t.iter()
|
||
.all(|&i| is_dark.get(i as usize).copied() == Some(true))
|
||
{
|
||
dark_area += a;
|
||
}
|
||
}
|
||
// A quad of black floor is ~10 yd²; below 2 yd² is a seam, not a surface.
|
||
if dark_area < 2.0 {
|
||
continue;
|
||
}
|
||
let material = raw_root.materials.get(batch.material_id as usize);
|
||
let texture = material
|
||
.map(|m| m.get_texture1_index(&raw_root.texture_offset_index_map))
|
||
.and_then(|i| raw_root.textures.get(i as usize))
|
||
.map(|t| t.rsplit('\\').next().unwrap_or(t).to_string())
|
||
.unwrap_or_else(|| "(none)".into());
|
||
hits.push(Hit {
|
||
wmo: path.clone(),
|
||
group: gi,
|
||
batch: bi,
|
||
class,
|
||
texture,
|
||
area: dark_area,
|
||
share: dark_area / total_area.max(1e-6),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
hits.sort_by(|a, b| b.area.total_cmp(&a.area));
|
||
println!("\n=== Surfaces that rendered black before the doorway fade (by area) ===\n");
|
||
for h in &hits {
|
||
println!(
|
||
"{:9.1} yd² {:3.0}% g{:03} b{:<3} {:<5} {:<28} {}",
|
||
h.area,
|
||
h.share * 100.0,
|
||
h.group,
|
||
h.batch,
|
||
h.class,
|
||
h.texture,
|
||
h.wmo
|
||
);
|
||
}
|
||
let buildings: std::collections::BTreeSet<&str> = hits.iter().map(|h| h.wmo.as_str()).collect();
|
||
println!(
|
||
"\n{} surface(s) across {} building(s) of {scanned} scanned ({groups} MOCV groups); \
|
||
{:.0} yd² of unrenderable surface in total",
|
||
hits.len(),
|
||
buildings.len(),
|
||
hits.iter().map(|h| h.area).sum::<f32>()
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// The MOGP super-chunk payload of a group file.
|
||
fn mogp(bytes: &[u8]) -> Option<&[u8]> {
|
||
let mut off = 0usize;
|
||
while off + 8 <= bytes.len() {
|
||
let len = u32::from_le_bytes([
|
||
bytes[off + 4],
|
||
bytes[off + 5],
|
||
bytes[off + 6],
|
||
bytes[off + 7],
|
||
]) as usize;
|
||
if &bytes[off..off + 4] == b"PGOM" {
|
||
return bytes.get(off + 8..(off + 8 + len).min(bytes.len()));
|
||
}
|
||
off += 8 + len;
|
||
}
|
||
None
|
||
}
|