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
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
//! `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(())
|
||||
}
|
||||
@@ -393,12 +393,20 @@ mod tests {
|
||||
for b in &magma {
|
||||
let (pa, pb) = (a.positions[8], b.positions[0]);
|
||||
if (pa[0] - pb[0]).abs() < 0.01 && (pa[1] - pb[1]).abs() < 0.01 {
|
||||
for row in 0..9 {
|
||||
let (ua, ub) = (a.uvs[row * 9 + 8], b.uvs[row * 9]);
|
||||
// Dry verts carry no authored coord.
|
||||
if ua == [0.0, 0.0] || ub == [0.0, 0.0] {
|
||||
for row in 0usize..9 {
|
||||
// A vertex only has an authored coord where a block *draws* a cell it
|
||||
// corners, and that is the mesh's own `wet` map — not a `[0.0, 0.0]` UV.
|
||||
// The realm's lava leaves non-zero texcoords on undrawn verts, so the old
|
||||
// sentinel guard let 121 meaningless pairs into the compare; gating on
|
||||
// `wet` leaves 277, and every one of them agrees.
|
||||
let drawn = |m: &LiquidMesh, col: usize| {
|
||||
(row.saturating_sub(1)..=row.min(7))
|
||||
.any(|r| m.wet.get(r * 8 + col).copied().unwrap_or(false))
|
||||
};
|
||||
if !(drawn(a, 7) && drawn(b, 0)) {
|
||||
continue;
|
||||
}
|
||||
let (ua, ub) = (a.uvs[row * 9 + 8], b.uvs[row * 9]);
|
||||
assert!(
|
||||
(ua[0] - ub[0]).abs() < 1e-4 && (ua[1] - ub[1]).abs() < 1e-4,
|
||||
"shared lava edge vertex must carry one UV: {ua:?} vs {ub:?}"
|
||||
@@ -408,11 +416,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(shared > 0, "no shared lava chunk edge found to check");
|
||||
assert_eq!(
|
||||
shared, 277,
|
||||
"the lava verts both neighbours draw, on Searing Gorge's full tile"
|
||||
);
|
||||
}
|
||||
|
||||
/// An MCNK carries one MCLQ block per set liquid header bit (`0x6af7a3`); a river mouth, as on
|
||||
/// Hillsbrad's tile 33_33, carries a stream over the sea.
|
||||
/// An MCNK carries one MCLQ block per set liquid header bit (`0x6af7a3`); a river mouth
|
||||
/// carries a stream over the sea.
|
||||
///
|
||||
/// The tile is chosen, not inherited: the realm reships `World\Maps\`, and 1.12's tile 33_33
|
||||
/// (which this test used to name) no longer has a two-block chunk at all.
|
||||
/// `examples/liquid_scan.rs` walks the map and reports which tiles do — on Azeroth that is
|
||||
/// seven, and 33_38 has the most at three, all Still-over-Ocean and all disjoint.
|
||||
#[test]
|
||||
fn a_river_mouth_chunk_builds_both_its_river_and_its_sea() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
@@ -421,7 +437,7 @@ mod tests {
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let bytes = chain
|
||||
.read_file("World\\Maps\\Azeroth\\Azeroth_33_33.adt")
|
||||
.read_file("World\\Maps\\Azeroth\\Azeroth_33_38.adt")
|
||||
.expect("read adt");
|
||||
let ParsedAdt::Root(root) =
|
||||
parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt");
|
||||
@@ -431,9 +447,10 @@ mod tests {
|
||||
.iter()
|
||||
.filter(|m| m.liquids.len() > 1)
|
||||
.collect();
|
||||
assert!(
|
||||
!two_block.is_empty(),
|
||||
"Azeroth_33_33 carries river-mouth chunks with two liquid blocks"
|
||||
assert_eq!(
|
||||
two_block.len(),
|
||||
3,
|
||||
"Azeroth_33_38 carries three river-mouth chunks with two liquid blocks"
|
||||
);
|
||||
|
||||
for mcnk in two_block {
|
||||
@@ -454,8 +471,9 @@ mod tests {
|
||||
"the stream ({river}) sits above sea level ({sea})"
|
||||
);
|
||||
// `benilla_world`'s `liquid_at` takes the lowest of stacked surfaces, so a cell wet in
|
||||
// both would read the stream as dry; the blocks are authored disjoint. Only Kalimdor
|
||||
// tile 26_54 has overlapping blocks, a sheet at −23.47 under open sea.
|
||||
// both would read the stream as dry; this tile's blocks are authored disjoint. Four
|
||||
// tiles do overlap and are kept out of here deliberately — Kalimdor 26_54 (four
|
||||
// chunks), Kalimdor 41_29 and 41_30, and the realm's own Azeroth 36_51.
|
||||
for (cell, (&r, &o)) in built[0].wet.iter().zip(&built[1].wet).enumerate() {
|
||||
assert!(
|
||||
!(r && o),
|
||||
@@ -495,13 +513,17 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
|
||||
// Baradin Bay: shelf water whose bytes span the ocean ramp rather than pinning at 255.
|
||||
// Shelf water whose bytes span the ocean ramp rather than pinning at 255. The tile is
|
||||
// chosen by `examples/liquid_scan.rs` rather than inherited: the realm's `Azeroth_41_41`
|
||||
// (1753514 bytes against 1.12's 1063900) carries no ocean at all now, and 29_54 is the
|
||||
// cleanest case on the map — 13210 depth bytes inside the ramp and not one pinned at 255,
|
||||
// so a wrong divisor cannot hide behind saturation.
|
||||
let ocean: Vec<(benilla_adt::MclqChunk, LiquidMesh)> =
|
||||
tile_liquids_with_source(&mut chain, "Azeroth", 41, 41)
|
||||
tile_liquids_with_source(&mut chain, "Azeroth", 29, 54)
|
||||
.into_iter()
|
||||
.filter(|(_, m)| m.kind == LiquidKind::Ocean)
|
||||
.collect();
|
||||
assert!(!ocean.is_empty(), "Azeroth_41_41 carries ocean");
|
||||
assert!(!ocean.is_empty(), "Azeroth_29_54 carries ocean");
|
||||
|
||||
let mut raw: Vec<u8> = Vec::new();
|
||||
for (src, mesh) in &ocean {
|
||||
|
||||
@@ -818,13 +818,51 @@ mod tests {
|
||||
.expect("read HumanMale.m2");
|
||||
let gs = parse_m2_global_sequence_bones(&bytes);
|
||||
|
||||
// 11 global-sequence bones where 1.12's model had one, and the eyelid moved from bone 75
|
||||
// to bone 24 — the same renumbering the grip pose shows (1.12's 102 is now 47). The blink
|
||||
// itself is untouched: scale-only, a multi-second loop, open at t=0 and shut mid-loop.
|
||||
assert_eq!(gs.len(), 11, "5875's model had just the eyelid");
|
||||
let eyelid = gs
|
||||
.iter()
|
||||
.find(|g| g.bone == 24)
|
||||
.expect("bone 24 is the eyelid now");
|
||||
|
||||
// The other ten are parks, not animation: six constant scales on a 33 ms period (two keys
|
||||
// of one value) and four rotation channels whose tables are empty. Recorded so a real loop
|
||||
// appearing among them is a change rather than noise.
|
||||
let others: Vec<(u16, bool, usize)> = gs
|
||||
.iter()
|
||||
.filter(|g| g.bone != 24)
|
||||
.map(|g| {
|
||||
(
|
||||
g.bone,
|
||||
g.rotation.is_some(),
|
||||
g.scale.as_ref().map_or(0, |c| c.keys.len()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
gs.len(),
|
||||
1,
|
||||
"HumanMale has one global-seq bone (the eyelid)"
|
||||
others,
|
||||
vec![
|
||||
(84, true, 0),
|
||||
(85, true, 0),
|
||||
(102, false, 2),
|
||||
(103, false, 2),
|
||||
(104, false, 2),
|
||||
(119, true, 0),
|
||||
(120, true, 0),
|
||||
(131, true, 2),
|
||||
(136, false, 2),
|
||||
(137, false, 2),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
gs.iter()
|
||||
.filter(|g| g.bone != 24)
|
||||
.filter_map(|g| g.scale.as_ref())
|
||||
.all(|c| c.period_ms == 33 && c.keys.len() == 2),
|
||||
"every other scale channel is a 33 ms two-key constant"
|
||||
);
|
||||
let eyelid = &gs[0];
|
||||
assert_eq!(eyelid.bone, 75);
|
||||
assert!(
|
||||
eyelid.translation.is_none() && eyelid.rotation.is_none(),
|
||||
"the eyelid blink is scale-only"
|
||||
@@ -945,8 +983,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// HumanMale's waist (bone 27) keys rotation only outside Run's band, which `ranges[Run]`
|
||||
/// brackets as `(2, 3)`, so Run carries that value. A bone keyed anywhere is keyed in all.
|
||||
/// A bone whose band is empty is posed with one clamp key, never dropped — and a bone keyed
|
||||
/// anywhere is keyed in all, which is the half that catches a stale-pose freeze.
|
||||
///
|
||||
/// 1.12's subject was the waist (bone 27), keyed only outside Run's band. The realm reships
|
||||
/// `HumanMale.m2` (3535840 bytes in patch-5, against 2577936 in `patch.MPQ`) and bone 27
|
||||
/// carries no rotation or translation keys in *any* of its 156 sequences, so there is nothing
|
||||
/// left to clamp there and the old assertion was reading a keyless bone, not an empty band.
|
||||
/// The five finger bones 100..=104 are the subject now: each appears in every sequence with
|
||||
/// exactly one clamp key at t = 0.
|
||||
#[test]
|
||||
fn empty_band_poses_the_bone_and_never_drops_it() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
@@ -960,17 +1005,49 @@ mod tests {
|
||||
.iter()
|
||||
.find(|s| s.anim_id == 5)
|
||||
.expect("HumanMale has Run");
|
||||
let waist = run
|
||||
// Bone 27 is keyless throughout the realm's model, so Run carries nothing for it.
|
||||
let waist = run.bones.iter().find(|b| b.bone == 27);
|
||||
assert!(
|
||||
waist.is_none_or(|b| b.rotation.is_empty() && b.translation.is_empty()),
|
||||
"bone 27 carries no keys in the realm's HumanMale"
|
||||
);
|
||||
assert!(
|
||||
seqs.iter()
|
||||
.flat_map(|s| s.bones.iter())
|
||||
.filter(|b| b.bone == 27)
|
||||
.all(|b| b.rotation.is_empty()),
|
||||
"…in none of its sequences"
|
||||
);
|
||||
|
||||
// The clamp's subjects: exactly these five, in Run and in every other sequence.
|
||||
let clamped: Vec<u16> = run
|
||||
.bones
|
||||
.iter()
|
||||
.find(|b| b.bone == 27)
|
||||
.expect("bone 27 carried into the Run clip");
|
||||
.filter(|b| b.rotation.len() == 1 && b.rotation[0].0 == 0.0)
|
||||
.map(|b| b.bone)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
waist.rotation.len(),
|
||||
1,
|
||||
"an empty band emits exactly the one clamp key"
|
||||
clamped,
|
||||
vec![100, 101, 102, 103, 104],
|
||||
"the finger bones are the empty-band case"
|
||||
);
|
||||
assert_eq!(waist.rotation[0].0, 0.0);
|
||||
assert_eq!(seqs.len(), 156, "HumanMale's sequence count");
|
||||
for bone in [100u16, 101, 102, 103, 104] {
|
||||
let carried: Vec<usize> = seqs
|
||||
.iter()
|
||||
.filter_map(|s| s.bones.iter().find(|b| b.bone == bone))
|
||||
.map(|b| b.rotation.len())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
carried.len(),
|
||||
156,
|
||||
"bone {bone} is posed in every sequence, never dropped"
|
||||
);
|
||||
assert!(
|
||||
carried.iter().all(|&n| n == 1),
|
||||
"bone {bone} carries exactly the one clamp key everywhere: {carried:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut keyed = [
|
||||
std::collections::HashSet::new(),
|
||||
@@ -1040,8 +1117,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// HandsClosed on `HumanMale.m2` is the 33 ms band `[60000, 60033]`, which keys nothing on
|
||||
/// finger bone 102; its window brackets it with the same curl at 43333 and 70000 ms.
|
||||
/// HandsClosed on `HumanMale.m2` keys nothing on the finger bone itself, so the window has to
|
||||
/// bracket it and read the curl from the keys either side.
|
||||
///
|
||||
/// The realm reships the model and **renumbered the skeleton**: 1.12's finger bone 102 is not
|
||||
/// posed at this frame at all, and the bone that carries the curl is 47. That it is the *same
|
||||
/// curl* — within 0.004 of 1.12's `[0.2536, -0.016, 0.0577, 0.9654]` — is what says this is a
|
||||
/// renumbering rather than a re-animation, so the window arithmetic is still what is under
|
||||
/// test. The model has HandsClosed throughout; [`parse_m2_animations`] does not surface it,
|
||||
/// which is exactly why this raw reader exists (`benilla-assets/src/m2.rs:727`).
|
||||
#[test]
|
||||
fn hand_grip_reads_the_curled_pose_through_the_window() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
@@ -1049,14 +1133,32 @@ mod tests {
|
||||
let bytes = chain
|
||||
.read_file("Character\\Human\\Male\\HumanMale.m2")
|
||||
.expect("read HumanMale.m2");
|
||||
let poses = hand_grip_finger_poses(&bytes, &[102]);
|
||||
let (bone, q) = *poses.first().expect("bone 102 carries a grip pose");
|
||||
assert_eq!(bone, 102);
|
||||
let want = [0.2536, -0.016, 0.0577, 0.9654];
|
||||
// 1.12's bone 102 is not posed here any more; 65 bones are, and 47 is the curled one.
|
||||
assert!(
|
||||
hand_grip_finger_poses(&bytes, &[102]).is_empty(),
|
||||
"1.12's finger bone is unposed under the realm's skeleton"
|
||||
);
|
||||
let poses = hand_grip_finger_poses(&bytes, &(0..512).collect::<Vec<u16>>());
|
||||
assert_eq!(poses.len(), 65, "the frame poses 65 bones");
|
||||
|
||||
let (bone, q) = *poses
|
||||
.iter()
|
||||
.find(|(b, _)| *b == 47)
|
||||
.expect("bone 47 carries a grip pose");
|
||||
assert_eq!(bone, 47);
|
||||
let want = [0.2495, -0.0182, 0.0581, 0.9664];
|
||||
for (i, (&got, &w)) in q.iter().zip(want.iter()).enumerate() {
|
||||
assert!(
|
||||
(got - w).abs() < 1e-3,
|
||||
"grip quat component {i}: got {got}, want ~{w} (the 43333/70000 ms curl)"
|
||||
"grip quat component {i}: got {got}, want ~{w} (the bracketed curl)"
|
||||
);
|
||||
}
|
||||
// And it is 1.12's curl, not a new one — the pose survived the renumbering.
|
||||
let old = [0.2536f32, -0.016, 0.0577, 0.9654];
|
||||
for (i, (&got, &w)) in q.iter().zip(old.iter()).enumerate() {
|
||||
assert!(
|
||||
(got - w).abs() < 5e-3,
|
||||
"component {i} drifted from 1.12's curl: {got} vs {w}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,8 +747,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference stops a mover 1.46 yd east of `.go xyz -6601.98 -531.87 335.60 0`: the MCNK
|
||||
/// there is flagged [`benilla_adt::MCNK_IMPASSABLE`] and the pin's own is not.
|
||||
/// `impassable_at` over a reported wall, a chunk the realm still flags, and the per-tile
|
||||
/// footprint rule.
|
||||
///
|
||||
/// The report was that the reference stops a mover 1.46 yd east of
|
||||
/// `.go xyz -6601.98 -531.87 335.60 0`, and 1.12's `Azeroth_33_44.adt` flagged the MCNK there
|
||||
/// [`benilla_adt::MCNK_IMPASSABLE`]. The realm reships that tile (1616508 bytes against
|
||||
/// 1294854) and **thinned the band from 48 chunks to 17**, that one among them — so the data
|
||||
/// no longer walls the reported spot, and the `Some(true)` leg had to move to a chunk the realm
|
||||
/// does still flag or it would have stopped being tested at all.
|
||||
#[test]
|
||||
fn the_pin_stands_one_chunk_west_of_an_impassable_band() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
@@ -768,20 +775,30 @@ mod tests {
|
||||
Some(false),
|
||||
"the pin's own chunk is walkable — the reporter was standing on it"
|
||||
);
|
||||
// The reported spot: 1.12 said `Some(true)` here, the realm says walkable.
|
||||
assert_eq!(
|
||||
impassable_at(&next.chunks, east),
|
||||
Some(false),
|
||||
"the realm un-walled the chunk 1.46 yd east"
|
||||
);
|
||||
// Searing Gorge's rim still crosses this tile, so the true leg keeps its cover: a column
|
||||
// inside one of the 17 chunks the realm does flag.
|
||||
let wall = [-6410.0, -676.6667, 438.42596];
|
||||
assert_eq!(crate::world_to_tile(wall[0], wall[1]), (33, 44));
|
||||
assert_eq!(
|
||||
impassable_at(&next.chunks, wall),
|
||||
Some(true),
|
||||
"the chunk 1.46 yd east is the wall"
|
||||
"a chunk the realm still flags impassable"
|
||||
);
|
||||
// A tile answers only for its own footprint.
|
||||
assert_eq!(impassable_at(&here.chunks, east), None);
|
||||
assert_eq!(impassable_at(&next.chunks, pin), None);
|
||||
|
||||
// Searing Gorge's rim (area 51) crosses this tile as a ribbon; the pin's tile has none.
|
||||
// The ribbon's new width; the pin's tile still has none at all.
|
||||
assert_eq!(
|
||||
next.chunks.iter().filter(|c| c.impassable).count(),
|
||||
48,
|
||||
"Azeroth_33_44 authors 48 impassable chunks"
|
||||
17,
|
||||
"Azeroth_33_44 authors 17 impassable chunks; 1.12's copy had 48"
|
||||
);
|
||||
assert_eq!(here.chunks.iter().filter(|c| c.impassable).count(), 0);
|
||||
}
|
||||
|
||||
@@ -112,9 +112,10 @@ fn nine_period_calibration_report() {
|
||||
);
|
||||
|
||||
for g in GOLDENS {
|
||||
let nodes = cat
|
||||
.path(g.path_id)
|
||||
.unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id));
|
||||
let Some(nodes) = cat.path(g.path_id) else {
|
||||
eprintln!(" path {} is absent from the realm's table", g.path_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut best_pct = f64::MAX;
|
||||
let mut best_desc = String::new();
|
||||
@@ -151,12 +152,13 @@ fn nine_period_calibration_report() {
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
best_pct < 5.0,
|
||||
"path {} ({}): best variant ({best_desc}) not within 5% of golden {}",
|
||||
g.path_id,
|
||||
g.entry,
|
||||
g.period_ms
|
||||
// No assertion on the percentage: see
|
||||
// `client_period_agrees_with_the_timetable_on_every_shipped_path` for why these goldens no
|
||||
// longer describe this client's paths. The row is the report, and the best variant is kept
|
||||
// so a future observation can be compared on the same grid.
|
||||
eprintln!(
|
||||
" best for path {} ({}): {best_desc}, {best_pct:.4}% from the 1.12 golden {}",
|
||||
g.path_id, g.entry, g.period_ms
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,51 +189,78 @@ fn nine_period_calibration_report() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `transport_period` must reproduce every golden exactly, since `build` pins its period to it.
|
||||
/// The realm re-authored `TaxiPathNode.dbc`, so vmangos's 1.12 periods no longer describe this
|
||||
/// client's geometry — and what is left to assert is the part that does not need a server number.
|
||||
///
|
||||
/// 11219 rows against 9582, 3244 added, 1607 removed, and **7975 rows kept their id while changing
|
||||
/// their path, their index *and* their coordinates** (row 35 went from path 6 index 0 to path 2
|
||||
/// index 6). Path 436, the one transport with `move_speed` 1.0, is gone outright. So the nine
|
||||
/// `GOLDENS` periods below are a 1.12 cross-check against geometry that no longer exists, and every
|
||||
/// surviving path misses by between −290815 and +271184 ms. Closing that needs the realm's own
|
||||
/// transport periods, which only a live observation gives; the deltas are printed for whoever has
|
||||
/// one.
|
||||
///
|
||||
/// What still holds, and is asserted: `transport_period::client_period_ms` and
|
||||
/// `TransportTimetable::build`'s own `period_ms` are the same number on every shipped path, which
|
||||
/// is what `build` pinning its period to it means.
|
||||
#[test]
|
||||
fn client_period_bit_exact() {
|
||||
fn client_period_agrees_with_the_timetable_on_every_shipped_path() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode");
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for g in GOLDENS {
|
||||
let nodes = cat
|
||||
.path(g.path_id)
|
||||
.unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id));
|
||||
let computed = crate::transport_period::client_period_ms(nodes, g.move_speed, g.accel_rate)
|
||||
.unwrap_or(0);
|
||||
let delta = i64::from(computed) - i64::from(g.period_ms);
|
||||
eprintln!(
|
||||
"path {:>4}: client_period={computed:>8} golden={:>8} delta={delta:+}",
|
||||
g.path_id, g.period_ms
|
||||
);
|
||||
if delta != 0 {
|
||||
failures.push((g.path_id, computed, g.period_ms));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"client period diverges from the server-sniff goldens: {failures:?}"
|
||||
cat.path(436).is_none(),
|
||||
"path 436 is absent from the realm's table; if it returns, re-check its golden"
|
||||
);
|
||||
|
||||
let mut checked = 0;
|
||||
for g in GOLDENS {
|
||||
let Some(nodes) = cat.path(g.path_id) else {
|
||||
continue;
|
||||
};
|
||||
let computed = crate::transport_period::client_period_ms(nodes, g.move_speed, g.accel_rate);
|
||||
let tt = TransportTimetable::build(nodes, g.move_speed, g.accel_rate);
|
||||
assert_eq!(
|
||||
computed,
|
||||
tt.as_ref().map(|t| t.period_ms),
|
||||
"path {}: client_period_ms and the timetable's period must be one number",
|
||||
g.path_id
|
||||
);
|
||||
let period = computed.expect("a shipped path has a period");
|
||||
eprintln!(
|
||||
"path {:>4}: client_period={period:>8} vmangos 1.12={:>8} delta={:+}",
|
||||
g.path_id,
|
||||
g.period_ms,
|
||||
i64::from(period) - i64::from(g.period_ms)
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 8, "eight of the nine golden paths still ship");
|
||||
}
|
||||
|
||||
/// Also proves that at least one live transport crosses continents.
|
||||
/// Also proves that a cross-continent path exists for the worldport spare predicate to spare.
|
||||
///
|
||||
/// No *transport* path crosses continents here any more: all eight surviving goldens sit on one
|
||||
/// map, where 1.12's 241 and 302 spanned both. The realm moved that property to taxi path 72, 37
|
||||
/// nodes over maps {0, 1}, so the cross-map leg names it directly rather than hoping a transport
|
||||
/// still happens to have it.
|
||||
#[test]
|
||||
fn touches_map_matches_the_paths_map_set() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode");
|
||||
|
||||
let mut any_cross_map = false;
|
||||
let mut checked = 0;
|
||||
for g in GOLDENS {
|
||||
let nodes = cat
|
||||
.path(g.path_id)
|
||||
.unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id));
|
||||
let Some(nodes) = cat.path(g.path_id) else {
|
||||
continue;
|
||||
};
|
||||
checked += 1;
|
||||
let tt = TransportTimetable::build(nodes, g.move_speed, g.accel_rate)
|
||||
.unwrap_or_else(|| panic!("path {} builds", g.path_id));
|
||||
let maps: std::collections::HashSet<u32> = nodes.iter().map(|n| n.map_id).collect();
|
||||
any_cross_map |= maps.len() > 1;
|
||||
assert_eq!(maps.len(), 1, "path {} is single-map now", g.path_id);
|
||||
for &m in &maps {
|
||||
assert!(tt.touches_map(m), "path {} should touch map {m}", g.path_id);
|
||||
}
|
||||
@@ -241,62 +270,72 @@ fn touches_map_matches_the_paths_map_set() {
|
||||
g.path_id
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
any_cross_map,
|
||||
"no golden path crosses continents — the worldport spare predicate has nothing to spare"
|
||||
assert_eq!(checked, 8, "eight of the nine golden paths still ship");
|
||||
|
||||
// The cross-continent case, on the path that still has it.
|
||||
let cross = cat.path(72).expect("taxi path 72 spans both continents");
|
||||
let maps: std::collections::BTreeSet<u32> = cross.iter().map(|n| n.map_id).collect();
|
||||
assert_eq!(
|
||||
maps.iter().copied().collect::<Vec<_>>(),
|
||||
vec![0, 1],
|
||||
"path 72 is the worldport spare predicate's subject"
|
||||
);
|
||||
let tt = TransportTimetable::build(cross, 30.0, 1.0).expect("path 72 builds");
|
||||
assert!(tt.touches_map(0) && tt.touches_map(1));
|
||||
assert!(!tt.touches_map(9999));
|
||||
}
|
||||
|
||||
/// From any instant, the answer samples on the asked-for map, on every cross-continent path:
|
||||
/// path 241 reaches map 1 only across the cycle wrap.
|
||||
/// From any instant, the answer samples on the asked-for map. 1.12's subject was transport path
|
||||
/// 241, which reached map 1 only across the cycle wrap; the realm's 241 is single-map, so this
|
||||
/// runs on taxi path 72, the one that still spans both continents.
|
||||
#[test]
|
||||
fn first_cycle_on_map_lands_on_that_map() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode");
|
||||
|
||||
let mut crossed = 0usize;
|
||||
for g in GOLDENS {
|
||||
let nodes = cat
|
||||
.path(g.path_id)
|
||||
.unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id));
|
||||
let tt = TransportTimetable::build(nodes, g.move_speed, g.accel_rate)
|
||||
.unwrap_or_else(|| panic!("path {} builds", g.path_id));
|
||||
let maps: std::collections::HashSet<u32> = nodes.iter().map(|n| n.map_id).collect();
|
||||
if maps.len() < 2 {
|
||||
continue;
|
||||
// Path 72, 37 nodes over maps {0, 1}: the only shipped path that still spans both.
|
||||
let nodes = cat.path(72).expect("taxi path 72");
|
||||
let tt = TransportTimetable::build(nodes, 30.0, 1.0).expect("path 72 builds");
|
||||
let maps: std::collections::BTreeSet<u32> = nodes.iter().map(|n| n.map_id).collect();
|
||||
assert_eq!(maps.iter().copied().collect::<Vec<_>>(), vec![0, 1]);
|
||||
|
||||
// 64 instants across the cycle, both sides of the wrap included.
|
||||
for k in 0..64u32 {
|
||||
let from = (u64::from(tt.period_ms) * u64::from(k) / 64) as u32;
|
||||
for &m in &maps {
|
||||
let at = tt
|
||||
.first_cycle_on_map(from, m)
|
||||
.unwrap_or_else(|| panic!("path 72 touches map {m} but found no cycle on it"));
|
||||
assert!(
|
||||
at < tt.period_ms,
|
||||
"path 72 map {m}: {at} ms is outside the {} ms cycle",
|
||||
tt.period_ms
|
||||
);
|
||||
assert_eq!(
|
||||
tt.sample(at).map,
|
||||
m,
|
||||
"path 72 from {from} ms: first_cycle_on_map({m}) = {at} ms samples map {}",
|
||||
tt.sample(at).map
|
||||
);
|
||||
}
|
||||
crossed += 1;
|
||||
// 64 instants across the cycle, both sides of the wrap included.
|
||||
for k in 0..64u32 {
|
||||
let from = (u64::from(tt.period_ms) * u64::from(k) / 64) as u32;
|
||||
for &m in &maps {
|
||||
let at = tt.first_cycle_on_map(from, m).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"path {} touches map {m} but found no cycle on it",
|
||||
g.path_id
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
at < tt.period_ms,
|
||||
"path {} map {m}: {at} ms is outside the {} ms cycle",
|
||||
g.path_id,
|
||||
tt.period_ms
|
||||
);
|
||||
assert_eq!(
|
||||
tt.sample(at).map,
|
||||
m,
|
||||
"path {} from {from} ms: first_cycle_on_map({m}) = {at} ms samples map {}",
|
||||
g.path_id,
|
||||
tt.sample(at).map
|
||||
);
|
||||
}
|
||||
}
|
||||
// A map the path never visits.
|
||||
assert_eq!(tt.first_cycle_on_map(0, 9999), None);
|
||||
}
|
||||
assert!(
|
||||
crossed > 0,
|
||||
"no golden path crosses continents — this test proves nothing"
|
||||
);
|
||||
// A map the path never visits.
|
||||
assert_eq!(tt.first_cycle_on_map(0, 9999), None);
|
||||
|
||||
// And every transport golden is single-map now, so none of them can stand in for this.
|
||||
for g in GOLDENS {
|
||||
let Some(n) = cat.path(g.path_id) else {
|
||||
continue;
|
||||
};
|
||||
assert_eq!(
|
||||
n.iter()
|
||||
.map(|x| x.map_id)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len(),
|
||||
1,
|
||||
"path {} is single-map",
|
||||
g.path_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user