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
188 lines
6.3 KiB
Rust
188 lines
6.3 KiB
Rust
//! `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(())
|
||
}
|