From 14d5dc36e9347980ed7dc772fe10c1f1748cea2b Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 31 Oct 2026 19:58:18 -0600 Subject: [PATCH] =?UTF-8?q?formats:=20benilla-formats=20is=20green=20?= =?UTF-8?q?=E2=80=94=20the=20world-data=20goldens,=20and=20two=20tests=20t?= =?UTF-8?q?hat=20were=20measuring=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../benilla-formats/examples/liquid_scan.rs | 187 ++++++++++++++++ crates/benilla-formats/src/liquid.rs | 54 +++-- crates/benilla-formats/src/models/anim.rs | 144 +++++++++++-- crates/benilla-formats/src/terrain.rs | 29 ++- .../benilla-formats/src/transports/tests.rs | 203 +++++++++++------- 5 files changed, 492 insertions(+), 125 deletions(-) create mode 100644 crates/benilla-formats/examples/liquid_scan.rs diff --git a/crates/benilla-formats/examples/liquid_scan.rs b/crates/benilla-formats/examples/liquid_scan.rs new file mode 100644 index 00000000..7a9cf7e0 --- /dev/null +++ b/crates/benilla-formats/examples/liquid_scan.rs @@ -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 { + 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 { + 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, + /// Depth bytes seen per class. + mid: BTreeMap, + pinned: BTreeMap, + classes: BTreeMap, + /// 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> = 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)> = 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 = 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(()) +} diff --git a/crates/benilla-formats/src/liquid.rs b/crates/benilla-formats/src/liquid.rs index 1a2b060c..39337121 100644 --- a/crates/benilla-formats/src/liquid.rs +++ b/crates/benilla-formats/src/liquid.rs @@ -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 = Vec::new(); for (src, mesh) in &ocean { diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index 0bd63c6e..32e08dd0 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -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 = 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 = 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::>()); + 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}" ); } } diff --git a/crates/benilla-formats/src/terrain.rs b/crates/benilla-formats/src/terrain.rs index a1666af4..f018b544 100644 --- a/crates/benilla-formats/src/terrain.rs +++ b/crates/benilla-formats/src/terrain.rs @@ -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); } diff --git a/crates/benilla-formats/src/transports/tests.rs b/crates/benilla-formats/src/transports/tests.rs index 9fb70aac..d81b8c8d 100644 --- a/crates/benilla-formats/src/transports/tests.rs +++ b/crates/benilla-formats/src/transports/tests.rs @@ -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 = 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 = cross.iter().map(|n| n.map_id).collect(); + assert_eq!( + maps.iter().copied().collect::>(), + 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 = 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 = nodes.iter().map(|n| n.map_id).collect(); + assert_eq!(maps.iter().copied().collect::>(), 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::>() + .len(), + 1, + "path {} is single-map", + g.path_id + ); + } }