Files
benilla-octo/crates/benilla-formats/examples/liquid_scan.rs
T
gromandClaude Opus 5 14d5dc36e9 formats: benilla-formats is green — the world-data goldens, and two tests that
were measuring nothing

The last 11. The realm reships `World\Maps\`, `HumanMale.m2` and
`TaxiPathNode.dbc`, so these tests had lost the *examples* they picked without
losing the properties they were for. Two had quietly stopped testing anything.

**A guard that was a proxy, not a fact.** The lava seam test skipped a vertex
whose UV was `[0.0, 0.0]`, standing in for "this vertex is dry". The realm's
lava leaves non-zero texcoords on undrawn verts, so 121 meaningless pairs were
entering the compare and one of them failed. Gating on the mesh's own `wet` map
instead leaves 277 pairs — the verts both neighbouring blocks actually draw —
and every one of them agrees. The invariant was never in doubt; the guard was.

**A keyless bone read as an empty band.** `empty_band_poses_the_bone` asserted
HumanMale's waist (bone 27) carries exactly one clamp key in Run. In the realm's
model bone 27 has no rotation or translation keys in *any* of its 156 sequences,
so there was nothing to clamp and the assertion was reading a keyless bone. The
subject is the five finger bones 100..=104 now: each appears in every one of the
156 sequences with exactly one clamp key at t = 0, which is the rule — an empty
band poses the bone and never drops it.

**The skeleton was renumbered, and the poses came with it.** 1.12's finger bone
102 is unposed at the HandsClosed frame; bone 47 carries the curl, within 0.004
of 1.12's `[0.2536, -0.016, 0.0577, 0.9654]`. Same for the eyelid: bone 75 ->
bone 24, still scale-only, still a 6633 ms loop that opens at t=0 and shuts
mid-loop. That the *pose* survived is what says renumbering rather than
re-animation, so the window arithmetic is still what is under test. (The model
does have HandsClosed throughout — `parse_m2_animations` not surfacing it is
exactly why the raw reader exists.) Ten new global-sequence bones came with it,
all parks: six 33 ms two-key constant scales and four rotation channels with
empty tables, recorded so a real loop appearing among them reads as a change.

**Tiles chosen instead of inherited.** `examples/liquid_scan.rs` walks a map and
reports which tiles still have each liquid property, so a re-recorded test names
a tile that was picked:
- the river mouth moved to `Azeroth_33_38`, three Still-over-Ocean chunks, all
  disjoint — 1.12's 33_33 has no two-block chunk left. Four tiles *do* overlap
  and are kept out deliberately: Kalimdor 26_54, 41_29, 41_30 and the realm's
  own Azeroth 36_51, where 1.12 had only 26_54.
- the ocean depth divisor moved to `Azeroth_29_54`: 13210 depth bytes inside the
  ramp and not one pinned at 255, so a wrong divisor cannot hide behind
  saturation. 1.12's 41_41 carries no ocean at all now.

**The impassable band was thinned.** `Azeroth_33_44` authors 17 impassable
chunks where 1.12's copy had 48, and the chunk 1.46 yd east of the reported
`.go xyz -6601.98 -531.87 335.60` is among the ones it dropped — so the data no
longer walls the reported spot. The `Some(true)` leg would have stopped being
tested, so it moved to a column inside one of the 17 the realm does flag.

**The transport goldens no longer describe this client.** `TaxiPathNode.dbc` is
re-authored: 11219 rows against 9582, and 7975 rows kept their id while changing
path, index *and* coordinates. Path 436 is gone; the eight survivors miss
vmangos's 1.12 periods by −290815 to +271184 ms, and no transport path crosses
continents any longer. Closing that needs the realm's own transport periods,
which only live observation gives — so the calibration is a report now, with the
deltas printed on the same variant grid a future observation can be compared on.
What is still assertable is asserted: `client_period_ms` and the timetable's own
`period_ms` are one number on all eight. The two map-invariant tests never
needed a server period at all, so they run on taxi path 72 — 37 nodes over maps
{0, 1}, the only shipped path that still spans both — and additionally assert
that every transport golden is single-map, so none of them can silently stand in
for it again.

**benilla-formats: 445 passed, 0 failed.** `cargo fmt --all` and
`clippy -p benilla-formats --all-targets` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 19:58:18 -06:00

188 lines
6.3 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.
//! `cargo run -p benilla-formats --example liquid_scan -- [map]` — pick ADT tiles by the liquid
//! property a test needs, instead of by the tile a test used to name.
//!
//! The realm reships most of `World\Maps\` (its `Azeroth_33_33.adt` is 1947378 bytes against
//! 1.12's 1911729), so a test that picked a tile as an *example* of a property — a river mouth
//! with two stacked MCLQ blocks, ocean whose depth bytes span the ramp rather than pinning at 255,
//! lava that must tile across a chunk seam — can lose its example without losing the property.
//! This walks a whole map and reports which tiles still have each, so a re-recorded test names a
//! tile that was chosen rather than inherited.
//!
//! Raw `benilla_adt` only: the kind is the majority non-dry nibble over drawn cells, which is what
//! `liquid::build_liquid_mesh` does, and its mesh is not needed to find candidates.
use std::collections::BTreeMap;
use anyhow::Result;
use benilla_adt::{parse_adt, MclqChunk, McnkChunk, ParsedAdt};
const GRID: usize = 9;
const CELLS: usize = 8;
const DRY_NIBBLE: u8 = 0x0f;
/// 0 Still, 1 Ocean, 2 Magma, as `LiquidKind::from_adt_nibble` reads it; `None` when nothing draws.
fn block_class(lq: &MclqChunk) -> Option<u8> {
if lq.vertices.len() < GRID * GRID || lq.tile_flags.len() < CELLS * CELLS {
return None;
}
let mut counts = [0u32; 16];
let mut drawn = 0;
for &f in &lq.tile_flags {
let n = f & 0x0f;
if n == DRY_NIBBLE {
continue;
}
counts[n as usize] += 1;
drawn += 1;
}
if drawn == 0 {
return None;
}
let nib = counts
.iter()
.enumerate()
.max_by_key(|(_, &c)| c)
.map(|(n, _)| n as u8)?;
match nib & 3 {
0 => Some(0),
1 => Some(1),
2 => Some(2),
_ => None,
}
}
fn name(c: u8) -> &'static str {
["Still", "Ocean", "Magma"][c as usize]
}
/// Which 8×8 cells a block draws.
fn wet(lq: &MclqChunk) -> Vec<bool> {
lq.tile_flags
.iter()
.map(|f| f & 0x0f != DRY_NIBBLE)
.collect()
}
/// One two-block chunk: its index, the two blocks' classes, and whether they are disjoint.
type Pair = (usize, u8, u8, bool);
struct Tile {
/// Chunks with two blocks, and the pair's classes.
pairs: Vec<Pair>,
/// Depth bytes seen per class.
mid: BTreeMap<u8, usize>,
pinned: BTreeMap<u8, usize>,
classes: BTreeMap<u8, usize>,
/// Chunks carrying magma, for the seam test.
magma_chunks: usize,
}
fn scan(root: &[McnkChunk]) -> Tile {
let mut t = Tile {
pairs: Vec::new(),
mid: BTreeMap::new(),
pinned: BTreeMap::new(),
classes: BTreeMap::new(),
magma_chunks: 0,
};
for (i, m) in root.iter().enumerate() {
let classes: Vec<Option<u8>> = m.liquids.iter().map(block_class).collect();
if classes.contains(&Some(2)) {
t.magma_chunks += 1;
}
for c in classes.iter().flatten() {
*t.classes.entry(*c).or_default() += 1;
}
if m.liquids.len() == 2 {
if let (Some(a), Some(b)) = (classes[0], classes[1]) {
// Disjoint is what `liquid_at`'s lowest-wins resolve needs.
let (wa, wb) = (wet(&m.liquids[0]), wet(&m.liquids[1]));
let disjoint = wa.iter().zip(&wb).all(|(x, y)| !(*x && *y));
t.pairs.push((i, a, b, disjoint));
}
}
for (lq, class) in m.liquids.iter().zip(&classes) {
let Some(c) = class else { continue };
for v in &lq.vertices {
let b = v.depth_byte();
if (42..=200).contains(&b) {
*t.mid.entry(*c).or_default() += 1;
}
if b == u8::MAX {
*t.pinned.entry(*c).or_default() += 1;
}
}
}
}
t
}
fn main() -> Result<()> {
let data = benilla_formats::wow_data().expect("set WOW_DATA");
let map = std::env::args().nth(1).unwrap_or_else(|| "Azeroth".into());
let mut chain = benilla_formats::Chain::open(&data)?;
let mut best_pairs: Vec<(String, usize, Vec<Pair>)> = Vec::new();
let mut best_ocean: Vec<(String, usize, usize)> = Vec::new();
let mut best_magma: Vec<(String, usize)> = Vec::new();
for ty in 0..64u32 {
for tx in 0..64u32 {
let path = format!("World\\Maps\\{map}\\{map}_{tx}_{ty}.adt");
if !chain.contains(&path) {
continue;
}
let Ok(bytes) = chain.read_file(&path) else {
continue;
};
let Ok(ParsedAdt::Root(root)) = parse_adt(&mut std::io::Cursor::new(&bytes[..])) else {
continue;
};
let t = scan(&root.mcnk_chunks);
let tile = format!("{map}_{tx}_{ty}");
if !t.pairs.is_empty() {
best_pairs.push((tile.clone(), t.pairs.len(), t.pairs.clone()));
}
if let Some(&mid) = t.mid.get(&1) {
best_ocean.push((tile.clone(), mid, *t.pinned.get(&1).unwrap_or(&0)));
}
if t.magma_chunks > 0 {
best_magma.push((tile, t.magma_chunks));
}
}
}
println!(
"== tiles with a two-block chunk ({} tiles) ==",
best_pairs.len()
);
best_pairs.sort_by_key(|(_, n, _)| std::cmp::Reverse(*n));
for (tile, n, pairs) in best_pairs.iter().take(12) {
let kinds: Vec<String> = pairs
.iter()
.take(4)
.map(|(i, a, b, d)| {
format!(
"chunk {i}: {}+{}{}",
name(*a),
name(*b),
if *d { "" } else { " OVERLAP" }
)
})
.collect();
println!(" {tile}: {n} chunks {}", kinds.join(", "));
}
println!("== ocean tiles by mid-band depth bytes (42..=200) ==");
best_ocean.sort_by_key(|(_, mid, _)| std::cmp::Reverse(*mid));
for (tile, mid, pinned) in best_ocean.iter().take(12) {
println!(" {tile}: mid {mid}, pinned {pinned}");
}
println!("== magma tiles by chunk count ==");
best_magma.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
for (tile, n) in best_magma.iter().take(12) {
println!(" {tile}: {n} chunks");
}
Ok(())
}