Files
benilla-octo/crates/benilla-formats/examples/wmo_bsp.rs
T
SamandClaude Fable 5 6b980b9efb the player build: one binary that carries its content
- 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>
2026-08-11 18:18:02 +07:00

294 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Which WMO faces the reference's collision BSP can actually **reach** — and which ones we collide
//! that it never tests:
//! `cargo run -p benilla-formats --example wmo_bsp -- <wmo-path-or-substring>`
//! e.g. `wmo_bsp cavernsoftime`.
//!
//! The reference never brute-forces a WMO's triangles. Every collision query descends the group's
//! **MOBN** BSP and tests only the faces its leaves list in **MOBR**, then applies the per-face MOPY
//! reject mask (wow-re `collision.md` § "The WMO per-face MOPY collision mask": leaf `0x6bc700`,
//! *"a BSP-leaf face is skipped iff `(MOPY.flags & rejectMask) != 0`"* — walking `0x84`, camera
//! `0x82`). We parse no BSP at all: `accumulate_wmo_group_faces` walks **every** MOPY-passing
//! triangle. So the two agree only where MOBR happens to reference everything the mask keeps.
//!
//! This tool measures that gap per group — how many faces we collide that the reference's BSP cannot
//! reach, and where they are — which would be the difference between "solid wall" and "walk straight
//! through" at a WMO the file never asked to be solid. **Measured on CavernsOfTime (decision 0963):
//! the gap is ZERO across all 35 groups** — MOBR's leaves reach every non-DETAIL face, so our
//! brute-force set is exactly the reference's reachable set and the shortcut costs nothing but time.
//! Re-run it on any WMO whose collision is under suspicion before assuming the same.
//!
//! `inward` is the other half of the same question, for *rendering*: the share of a group's faces
//! whose winding normal points at the group's own centroid. A hollow room hull authored to be seen
//! from inside runs high (CoT's 34 interior groups: 59–100%), which is why the reference — single-sided since
//! MOMT `0x04` is clear — is see-through from outside, and why we were not until 0680.
//!
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
use std::collections::HashSet;
use benilla_wmo::{parse_wmo, ParsedWmo};
/// The walking gather's persistent MOPY reject bit (DETAIL/decal) — wow-re mask `0x84` minus the
/// transient `0x80` visited bit.
const MOPY_DETAIL: u8 = 0x04;
fn main() -> anyhow::Result<()> {
let pat = std::env::args()
.nth(1)
.ok_or_else(|| anyhow::anyhow!("usage: wmo_bsp <wmo-path-or-substring>"))?;
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let path = if pat.to_lowercase().ends_with(".wmo") {
pat.clone()
} else {
let lower = pat.to_lowercase();
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}\n");
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");
};
let unculled = root
.materials
.iter()
.filter(|m| m.flags & 0x04 != 0)
.count();
println!(
"=== MOMT: {unculled} of {} materials carry UNCULLED (0x04) ===",
root.materials.len()
);
let stem = path.to_ascii_lowercase();
let stem = stem.strip_suffix(".wmo").unwrap_or(&stem);
println!("\n=== per group: what the BSP reaches vs what we collide ===");
println!(
"{:>4} {:>10} {:>7} {:>8} {:>8} {:>9} {:>9} {:>6} gap (we−bsp)",
"grp", "mogpFlags", "faces", "MOBNn", "MOBRn", "bspFaces", "weCollide", "inward",
);
let (mut tot_faces, mut tot_bsp, mut tot_we, mut tot_gap) = (0usize, 0usize, 0usize, 0usize);
for gi in 0..root.n_groups {
let group_path = format!("{stem}_{gi:03}.wmo");
let Ok(gbytes) = chain.read_file(&group_path) else {
continue;
};
let ParsedWmo::Group(group) = parse_wmo(&mut std::io::Cursor::new(&gbytes))? else {
continue;
};
let n_faces = group.vertex_indices.len() / 3;
let nodes = mogp_subchunk(&gbytes, b"NBOM").unwrap_or(&[]);
let mobn = nodes.len() / 0x10;
let mobr: Vec<u16> = mogp_subchunk(&gbytes, b"RBOM")
.map(|c| {
c.chunks_exact(2)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.collect()
})
.unwrap_or_default();
// Reachability, not mere presence: a face listed in MOBR but sitting under a leaf the
// descent never enters is never tested. Walk MOBN from node 0 and union the leaves' spans.
let (bsp_reach, bad) = descend(nodes, &mobr);
// What the reference's walking query can ever hit: a face the BSP lists AND whose MOPY
// clears DETAIL. What we hit: every face whose MOPY clears DETAIL.
let (mut bsp_hit, mut we_hit, mut gap) = (0usize, 0usize, 0usize);
let mut gap_aabb = [[f32::MAX; 3], [f32::MIN; 3]];
// Which way the shell presents. A hollow interior hull is authored to be seen from INSIDE:
// its winding normals point at the group's own centroid, so single-sided it is invisible
// (and see-through) from outside. Counted over every face, render or not.
let mut centroid = [0.0f64; 3];
for p in &group.vertex_positions {
for (c, v) in [p.x, p.y, p.z].into_iter().enumerate() {
centroid[c] += f64::from(v);
}
}
let n_v = group.vertex_positions.len().max(1) as f64;
let centroid = centroid.map(|c| (c / n_v) as f32);
let mut inward = 0usize;
for f in 0..n_faces {
if let Some(n) = winding_normal(&group, f) {
let c = face_centroid(&group, f).unwrap_or([0.0; 3]);
let to_mid = [centroid[0] - c[0], centroid[1] - c[1], centroid[2] - c[2]];
if n[0] * to_mid[0] + n[1] * to_mid[1] + n[2] * to_mid[2] > 0.0 {
inward += 1;
}
}
let flags = group.material_info.get(f).map_or(0, |m| m.flags);
if flags & MOPY_DETAIL != 0 {
continue;
}
we_hit += 1;
if bsp_reach.contains(&(f as u16)) {
bsp_hit += 1;
} else {
gap += 1;
for k in 0..3 {
let Some(&vi) = group.vertex_indices.get(f * 3 + k) else {
continue;
};
let Some(p) = group.vertex_positions.get(vi as usize) else {
continue;
};
for (c, v) in [p.x, p.y, p.z].into_iter().enumerate() {
gap_aabb[0][c] = gap_aabb[0][c].min(v);
gap_aabb[1][c] = gap_aabb[1][c].max(v);
}
}
}
}
tot_faces += n_faces;
tot_bsp += bsp_hit;
tot_we += we_hit;
tot_gap += gap;
let note = if gap > 0 {
format!(
"{gap:>6} aabb ({:.0},{:.0},{:.0})…({:.0},{:.0},{:.0})",
gap_aabb[0][0],
gap_aabb[0][1],
gap_aabb[0][2],
gap_aabb[1][0],
gap_aabb[1][1],
gap_aabb[1][2],
)
} else {
" 0".to_string()
};
println!(
"{gi:>4} {:>#10x} {n_faces:>7} {mobn:>8} {:>8} {bsp_hit:>9} {we_hit:>9} {:>6} {note}{}",
group.flags,
mobr.len(),
format!("{:.0}%", 100.0 * inward as f32 / n_faces.max(1) as f32),
if bad > 0 {
format!(" [!! {bad} malformed node refs — layout suspect]")
} else {
String::new()
},
);
}
println!(
"\ntotals: {tot_faces} faces · bsp-reachable+walkable {tot_bsp} · we collide {tot_we} · \
gap {tot_gap}"
);
Ok(())
}
/// A face's three positions, or `None` if any index is out of range.
fn face_verts(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[[f32; 3]; 3]> {
let mut out = [[0.0f32; 3]; 3];
for (k, slot) in out.iter_mut().enumerate() {
let vi = *group.vertex_indices.get(f * 3 + k)? as usize;
let p = group.vertex_positions.get(vi)?;
*slot = [p.x, p.y, p.z];
}
Some(out)
}
/// The right-hand-rule normal of a face in its authored winding — the direction the face PRESENTS
/// under the reference's `GL_BACK`/CCW cull (and ours: `wow_to_bevy` is a proper rotation).
fn winding_normal(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> {
let [a, b, c] = face_verts(group, f)?;
let (u, v) = (
[b[0] - a[0], b[1] - a[1], b[2] - a[2]],
[c[0] - a[0], c[1] - a[1], c[2] - a[2]],
);
Some([
u[1] * v[2] - u[2] * v[1],
u[2] * v[0] - u[0] * v[2],
u[0] * v[1] - u[1] * v[0],
])
}
/// A face's centroid.
fn face_centroid(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> {
let vs = face_verts(group, f)?;
let mut out = [0.0f32; 3];
for (c, slot) in out.iter_mut().enumerate() {
*slot = (vs[0][c] + vs[1][c] + vs[2][c]) / 3.0;
}
Some(out)
}
/// Walk the MOBN tree from node 0 and return the set of MOBR **face indices the descent can
/// actually reach**, plus a count of malformed references (out-of-range children or face spans).
///
/// Node layout (wowdev, stride `0x10` — the stride itself is VERIFIED in wow-re
/// `models/scratch/re-pass.md`): `u16 flags · i16 negChild · i16 posChild · u16 nFaces ·
/// u32 faceStart · f32 planeDist`. `flags & 0x4` = leaf. The `bad` counter is the falsifier: read
/// the layout wrong and children/spans go out of range immediately rather than quietly.
fn descend(nodes: &[u8], mobr: &[u16]) -> (HashSet<u16>, usize) {
let n = nodes.len() / 0x10;
let (mut seen, mut reach, mut bad) = (vec![false; n], HashSet::new(), 0usize);
let mut stack = if n > 0 { vec![0i16] } else { Vec::new() };
while let Some(i) = stack.pop() {
let Ok(u) = usize::try_from(i) else { continue };
if u >= n {
bad += 1;
continue;
}
if std::mem::replace(&mut seen[u], true) {
continue;
}
let b = &nodes[u * 0x10..u * 0x10 + 0x10];
let rd16 = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]);
let flags = u16::from_le_bytes([b[0], b[1]]);
if flags & 0x4 != 0 {
let n_faces = u16::from_le_bytes([b[6], b[7]]) as usize;
let start = u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize;
match mobr.get(start..start + n_faces) {
Some(span) => reach.extend(span.iter().copied()),
None => bad += 1,
}
} else {
for c in [rd16(2), rd16(4)] {
if c >= 0 {
stack.push(c);
}
}
}
}
(reach, bad)
}
/// Find a sub-chunk of a group file's MOGP super-chunk by on-disk (reversed) magic.
fn mogp_subchunk<'a>(group_bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> {
let mogp = top_chunk(group_bytes, b"PGOM")?;
let mut off = 0x44usize;
while off + 8 <= mogp.len() {
let size = u32::from_le_bytes(mogp[off + 4..off + 8].try_into().ok()?) as usize;
let (start, end) = (off + 8, off + 8 + size);
if end > mogp.len() {
break;
}
if &mogp[off..off + 4] == magic {
return Some(&mogp[start..end]);
}
off = end;
}
None
}
/// Find a top-level chunk by on-disk (reversed) magic.
fn top_chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> {
let mut off = 0usize;
while off + 8 <= bytes.len() {
let size = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().ok()?) as usize;
let (start, end) = (off + 8, off + 8 + size);
if end > bytes.len() {
break;
}
if &bytes[off..off + 4] == magic {
return Some(&bytes[start..end]);
}
off = end;
}
None
}