Files
benilla-octo/crates/benilla-formats/examples/wmo_liquid_scope.rs
T
Sam SungandClaude Opus 5.5 8da634afa9 Comments in benilla-formats: one line of fact each, no history, no record numbers (#130)
Part of #41: the comments of `benilla-formats` (the library, its extract tools, examples and tests) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 19,654 comment lines become 7,264.

Fixes #105: the WMO collision comments now name walking's box-query leaf `0x6bca50` and the camera and line-of-sight segment-query leaf `0x6bc700`, in `models/collision.rs`, `examples/wmo_bsp.rs` and `tests/chain.rs`.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - A placed doodad's material alpha reads file slot 0 on its spawn clock, while the reference reads bone 0's armed sequence, re-rolled every play window. The old text said doodads are armed once at load.
  - The fog end is stored ×36 and scaled once at load (`0x6d6090`). It is not raw.
  - The MCSH bit picks a map doodad's sun scale, 1.0 lit or 0.5 shadowed. 2.5 belongs to an entity's light node.
  - `SpellRec` is the row as stored, column × 4, not a translated copy.
  - A global sequence's clock is anchored at the instance's attach time (`0x714352`).
  - Particle twinkle is a gated size multiplier (`0x7b2adc`).
  - The blob shadow sizes from the Stand box.
  - Data claims are checked against the install: the weapon-trail kits' parameters and alphas, the beam rows' noise, and the maps' `field_16`.
  - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Four second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference, the code and the shipped data.

String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:38:53 +07:00

408 lines
15 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.
//! Where WMO liquid scoping can claim the wrong position, read off every placed WMO (from an ADT
//! or a WDT), each hit with a `.go`. `cargo run -p benilla-formats --example wmo_liquid_scope`
//!
//! - STOREY: a pool's wet cells over another group's floor, submerged by placement scoping alone.
//! - OVERHANG: how far wet cells reach past their own group's XY box, up to 25 yd, which is why
//! benilla bounds a pool by that box's Z floor only where the reference tests the whole box
//! (`0x6a4e00`).
//! - DRY-GROUP: a group with a `groupLiquid` other than `0xf` and no MLIQ, which the reference
//! answers with an unconditional hit at height `FLT_MAX` (`0x6b9f10`).
//!
//! Output is Blizzard data: never commit it.
use std::collections::BTreeMap;
use std::io::Cursor;
use benilla_formats::LiquidMesh;
/// MOGP `groupLiquid` (`+0x34`) for a group holding no liquid.
const NO_LIQUID: u32 = 0xf;
/// Half the 34133⅓-yard map (32 tiles × 533⅓), the MODF-to-world origin shift.
const MAP_CENTER: f32 = 17066.666;
struct Group {
liquid_type: u32,
liquid: Option<LiquidMesh>,
/// MOGP bounding box in model space, at `+0x0c`.
lo: [f32; 3],
hi: [f32; 3],
}
struct Site {
map: String,
/// Placement origin in WoW world coords.
pos: [f32; 3],
rot: [f32; 3],
unique_id: u32,
}
fn u32_at(b: &[u8], i: usize) -> Option<u32> {
b.get(i..i + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
fn f32_at(b: &[u8], i: usize) -> Option<f32> {
u32_at(b, i).map(f32::from_bits)
}
/// Walk top-level chunks for one whose reversed magic matches.
fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> {
let mut off = 0usize;
while off + 8 <= bytes.len() {
let size = u32_at(bytes, off + 4)? as usize;
let body = bytes.get(off + 8..off + 8 + size)?;
if &bytes[off..off + 4] == magic {
return Some(body);
}
off += 8 + size;
}
None
}
fn read_group(bytes: &[u8]) -> Option<Group> {
let mogp = chunk(bytes, b"PGOM")?; // MOGP
if mogp.len() < 0x44 {
return None;
}
let corner = |i: usize| Some([f32_at(mogp, i)?, f32_at(mogp, i + 4)?, f32_at(mogp, i + 8)?]);
Some(Group {
liquid_type: u32_at(mogp, 0x34)?,
liquid: benilla_formats::wmo_group_liquid_mesh(bytes),
lo: corner(0x0c)?,
hi: corner(0x18)?,
})
}
/// Whether the group carries an MLIQ sub-chunk after MOGP's 68-byte header; [`Group::liquid`] is
/// also `None` for an all-holes grid.
fn has_mliq(bytes: &[u8]) -> bool {
chunk(bytes, b"PGOM").is_some_and(|m| m.len() > 0x44 && chunk(&m[0x44..], b"QILM").is_some())
}
/// A MODF placement's model-to-world rotation in the WoW frame (Z up), the `in_wow` half of
/// `benilla_assets::coords::placement_rotation`: `Rx(90°)·Ry(ry-180°)·Rz(-rx)·Rx(rz-90°)`.
fn model_to_world(rot_deg: [f32; 3], p: [f32; 3]) -> [f32; 3] {
let (rx, ry, rz) = (
rot_deg[0].to_radians(),
rot_deg[1].to_radians(),
rot_deg[2].to_radians(),
);
let rot_x = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0], p[1] * c - p[2] * s, p[1] * s + p[2] * c]
};
let rot_y = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0] * c + p[2] * s, p[1], -p[0] * s + p[2] * c]
};
let rot_z = |p: [f32; 3], a: f32| {
let (s, c) = a.sin_cos();
[p[0] * c - p[1] * s, p[0] * s + p[1] * c, p[2]]
};
// Right-to-left, as the quaternion product composes.
let p = rot_x(p, rz - std::f32::consts::FRAC_PI_2);
let p = rot_z(p, -rx);
let p = rot_y(p, ry - std::f32::consts::PI);
rot_x(p, std::f32::consts::FRAC_PI_2)
}
fn go_at(site: &Site, model_pt: [f32; 3]) -> String {
let r = model_to_world(site.rot, model_pt);
format!(
"{}: .go xyz {:.2} {:.2} {:.2}",
site.map,
site.pos[0] + r[0],
site.pos[1] + r[1],
site.pos[2] + r[2],
)
}
/// Every wet cell of a liquid mesh as `(x0, y0, x1, y1, height)` in model space.
fn wet_cells(lq: &LiquidMesh) -> Vec<([f32; 4], f32)> {
let (cols, rows) = (lq.grid[0] as usize, lq.grid[1] as usize);
let mut out = Vec::new();
for j in 0..rows.saturating_sub(1) {
for i in 0..cols.saturating_sub(1) {
if !lq.wet.get(j * (cols - 1) + i).copied().unwrap_or(false) {
continue;
}
let c = [
lq.positions[j * cols + i],
lq.positions[j * cols + i + 1],
lq.positions[(j + 1) * cols + i],
lq.positions[(j + 1) * cols + i + 1],
];
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
let mut h = f32::MIN;
for v in c {
for a in 0..2 {
lo[a] = lo[a].min(v[a]);
hi[a] = hi[a].max(v[a]);
}
h = h.max(v[2]);
}
out.push(([lo[0], lo[1], hi[0], hi[1]], h));
}
}
out
}
fn main() -> anyhow::Result<()> {
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
// ---- pass 1: every WMO actually placed in the world, and where. ------------------------
let mut placed: BTreeMap<String, Vec<Site>> = BTreeMap::new();
let adts: Vec<String> = chain
.list()?
.into_iter()
.map(|e| e.name)
.filter(|n| n.to_lowercase().ends_with(".adt"))
.collect();
eprintln!("scanning {} ADTs…", adts.len());
for name in &adts {
let Ok(bytes) = chain.read_file(name) else {
continue;
};
let Ok(benilla_adt::ParsedAdt::Root(adt)) =
benilla_adt::parse_adt(&mut Cursor::new(&*bytes))
else {
continue;
};
let lower = name.to_lowercase();
let map = lower.split(['\\', '/']).nth(2).unwrap_or("?").to_string();
for p in &adt.wmo_placements {
let Some(model) = adt.wmos.get(p.name_id as usize) else {
continue;
};
let e = placed
.entry(model.to_lowercase().replace('/', "\\"))
.or_default();
if !e.iter().any(|s| s.map == map) {
e.push(Site {
map: map.clone(),
pos: [
MAP_CENTER - p.position[2],
MAP_CENTER - p.position[0],
p.position[1],
],
rot: p.rotation,
unique_id: p.unique_id,
});
}
}
}
// A WMO-only map has no ADT: its world is the WDT's single `MODF` (20 of the 43 shipped maps).
let wdts: Vec<String> = chain
.list()?
.into_iter()
.map(|e| e.name)
.filter(|n| n.to_lowercase().ends_with(".wdt"))
.collect();
eprintln!("scanning {} WDTs for WMO-only maps…", wdts.len());
for name in &wdts {
let Ok(bytes) = chain.read_file(name) else {
continue;
};
let Ok(wdt) =
benilla_wdt::WdtReader::new(Cursor::new(&*bytes), benilla_wdt::WowVersion::Classic)
.read()
else {
continue;
};
let Some(g) = wdt.global_wmo() else { continue };
let lower = name.to_lowercase();
let map = lower.split(['\\', '/']).nth(2).unwrap_or("?").to_string();
// No `MAP_CENTER - v` remap: a global WMO's MODF is in world coords already, and every
// shipped one sits at (0,0,0).
placed
.entry(g.model.to_lowercase().replace('/', "\\"))
.or_default()
.push(Site {
map,
pos: g.position,
rot: g.rotation,
unique_id: 0,
});
}
eprintln!("{} distinct WMO models placed in the world", placed.len());
// ---- pass 2: read every placed model's groups. -----------------------------------------
let mut dry_groups: Vec<String> = Vec::new();
let mut storey: Vec<(f32, String)> = Vec::new();
let mut overhang: Vec<(f32, String)> = Vec::new();
let mut floor_misses = 0usize;
// groupLiquid value -> (groups with an MLIQ chunk, groups without).
let mut type_census: BTreeMap<u32, (usize, usize)> = BTreeMap::new();
for (model, sites) in &placed {
let stem = model.strip_suffix(".wmo").unwrap_or(model);
let mut groups: Vec<Group> = Vec::new();
for gi in 0.. {
let Ok(gb) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else {
break;
};
let Some(g) = read_group(&gb) else { break };
let e = type_census.entry(g.liquid_type).or_default();
if has_mliq(&gb) {
e.0 += 1;
} else {
e.1 += 1;
if g.liquid_type != NO_LIQUID {
// Aim at the room's box centre, just off its floor, not the placement origin.
let mid = |a: usize| (g.lo[a] + g.hi[a]) * 0.5;
dry_groups.push(format!(
" {model} group {gi:03} liquidType {} box z [{:.1}..{:.1}]\n room {}\n origin{}",
g.liquid_type,
g.lo[2],
g.hi[2],
go_at(&sites[0], [mid(0), mid(1), g.lo[2] + 1.5]),
go_at(&sites[0], [0.0, 0.0, 0.0]),
));
}
}
groups.push(g);
}
// STOREY: for each pool, the lower groups its wet cells hang over.
for (ai, a) in groups.iter().enumerate() {
let Some(lq) = a.liquid.as_ref() else {
continue;
};
let cells = wet_cells(lq);
// OVERHANG: the farthest a wet cell reaches past its own group's XY box.
let out = cells.iter().fold(0.0f32, |m, (xy, _)| {
m.max(a.lo[0] - xy[0])
.max(xy[2] - a.hi[0])
.max(a.lo[1] - xy[1])
.max(xy[3] - a.hi[1])
});
if out > 0.01 {
overhang.push((out, format!("{model} pool g{ai:03}")));
}
for (bi, b) in groups.iter().enumerate() {
if ai == bi {
continue;
}
// Where the query fires: under the pool's height, in a wet cell, on B's floor.
let stand = b.lo[2] + 1.5;
let mut best: Option<([f32; 4], f32)> = None;
for (xy, h) in &cells {
if *h <= stand {
continue; // the pool is at or below this floor: no false submersion
}
let overlap =
xy[0] < b.hi[0] && xy[2] > b.lo[0] && xy[1] < b.hi[1] && xy[3] > b.lo[1];
// Skip B when its box holds the liquid height: that submersion is real.
if overlap && *h > b.hi[2] && best.is_none_or(|(_, bh)| *h > bh) {
best = Some((*xy, *h));
}
}
if let Some((xy, h)) = best {
let pt = [(xy[0] + xy[2]) * 0.5, (xy[1] + xy[3]) * 0.5, stand];
// Whether the floor rule (nothing below the pool's group box) closes this.
let floored = stand < a.lo[2];
if !floored {
floor_misses += 1;
}
storey.push((
h - stand,
format!(
" {:>7.1} yd {} {model} pool g{ai:03} over g{bi:03} (floor {:.1}, pool {:.1}, pool-group floor {:.1})\n {} [uid {}]",
h - stand,
if floored { "FLOOR-FIXES" } else { "floor-MISSES" },
b.lo[2],
h,
a.lo[2],
go_at(&sites[0], pt),
sites[0].unique_id,
),
));
}
}
}
}
overhang.sort_by(|a, b| b.0.total_cmp(&a.0));
println!(
"\n=== OVERHANG: how far a pool's WET CELLS reach outside its OWN group's box, in XY ===\n\
(the risk in narrowing the scope from placement to GROUP: where a pool hangs over the\n\
neighbouring group, a subject standing under it is in group B while the water is group A's,\n\
and a strict group match would stop it swimming. 0 = the pool never leaves its own room.)"
);
for (d, line) in overhang.iter().take(15) {
println!(" {d:>7.2} yd {line}");
}
// ---- the archive-wide census. -----------------------------------------------------------
// `groupLiquid` over every group file in the archive, placed or not.
let mut all_census: BTreeMap<u32, (usize, usize)> = BTreeMap::new();
let mut odd: Vec<String> = Vec::new();
for name in chain.list()?.into_iter().map(|e| e.name) {
let lower = name.to_lowercase();
// group files are `<stem>_NNN.wmo`
if !lower.ends_with(".wmo") {
continue;
}
let Some(stem) = lower.strip_suffix(".wmo") else {
continue;
};
if stem.len() < 4 || !stem[stem.len() - 3..].bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let Some(g) = read_group(&bytes) else {
continue;
};
let mliq = has_mliq(&bytes);
let e = all_census.entry(g.liquid_type).or_default();
if mliq {
e.0 += 1;
} else {
e.1 += 1;
}
if g.liquid_type != NO_LIQUID {
odd.push(format!(
" {lower} liquidType {} {}",
g.liquid_type,
if mliq { "HAS MLIQ" } else { "no MLIQ" }
));
}
}
println!("\n=== groupLiquid census over the WHOLE ARCHIVE: value -> (with MLIQ, without) ===");
for (ty, (with, without)) in &all_census {
println!(" {ty:>5} -> {with:>5} with MLIQ, {without:>5} without");
}
println!("\n every non-0xf group in the archive ({}):", odd.len());
for line in &odd {
println!("{line}");
}
println!("\n=== groupLiquid census over placed models: value -> (with MLIQ, without) ===");
for (ty, (with, without)) in &type_census {
println!(" {ty:>5} -> {with:>5} with MLIQ, {without:>5} without");
}
println!(
"\n=== DRY-GROUP: groupLiquid != 0xf but no MLIQ chunk ({}) ===",
dry_groups.len()
);
for line in &dry_groups {
println!("{line}");
}
storey.sort_by(|a, b| b.0.total_cmp(&a.0));
println!(
"\n=== STOREY: a walkable floor under another group's WET CELLS ({}) ===",
storey.len()
);
println!(" the FLOOR rule (a pool never claims below its own group box) misses {floor_misses} of {}", storey.len());
for (_, line) in &storey {
println!("{line}");
}
Ok(())
}