//! Integration test against the real client at `/WoW/Data` (gitignored). //! //! Skips (passes) when the client isn't present, so CI without assets stays green. //! Document: place a legally-owned 1.12.1 client's `Data` dir at the repo root `WoW/`. use std::path::PathBuf; use benilla_formats::{open_chain, Chain}; fn vanilla_data_dir() -> PathBuf { // crates/benilla-formats -> repo root -> WoW/Data PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") } #[test] fn reads_spell_dbc_from_vanilla_chain() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // Spell.dbc lives in patch.MPQ; reading by name must resolve through the chain. let bytes = chain .read_file("DBFilesClient/Spell.dbc") .expect("read DBFilesClient/Spell.dbc"); assert_eq!(&bytes[..4], b"WDBC", "DBC files start with the WDBC magic"); assert!( bytes.len() > 1_000_000, "Spell.dbc should be sizable, got {} bytes", bytes.len() ); } #[test] fn resolves_and_reads_across_archive_types() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } // One `Chain` (the `&self` AssetReader backend and the `&mut` streaming loaders are now the same // type) must resolve and read across archive kinds — DBC (dbc/patch.MPQ), a UI BLP, and a // base-archive M2 with no per-archive listfile (resolved by name hash). Non-empty reads pin // cross-archive resolution and the whole-file (no-PTCH) invariant for these files. let chain = open_chain(&data).expect("open vanilla patch chain"); for path in [ "DBFilesClient/Spell.dbc", "DBFilesClient/TaxiNodes.dbc", "Interface/Icons/Spell_Holy_ArcaneIntellect.blp", // Physical .m2 in a listfile-less base archive (references use .mdx; the .mdx→.m2 remap is a // loader concern, so the reader gets the on-disk path) — proves base-archive resolution. "Creature\\Kobold\\Kobold.m2", ] { assert!(chain.contains(path), "chain should resolve {path}"); let bytes = chain .read(path) .unwrap_or_else(|e| panic!("chain read {path}: {e:#}")); assert!(!bytes.is_empty(), "{path} read empty"); } } #[test] fn culls_constant_zero_alpha_m2_batches() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // OrgrimmarFloatingEmbers' only render batch is an 84-yd box with a transparency-weight track of // 0.0 — a particle-emitter anchor the real client never draws (it culls batches at alpha ≤ 0, // verified). It must be culled, leaving the model with no render geometry (its 3 emitters remain). let embers = benilla_formats::load_m2_mesh( &mut chain, "world/kalimdor/orgrimmar/passivedoodads/orgrimmarbonfire/orgrimmarfloatingembers.m2", ) .expect("mesh embers"); assert!( embers.is_empty(), "the weight-0 emitter box must be culled, got {} batches", embers.len() ); // Control — a normal lamppost keeps every batch (body + glow cards), proving the zero-alpha cull // doesn't over-cull visible geometry. let lamppost = benilla_formats::load_m2_mesh( &mut chain, "world/azeroth/elwynn/passivedoodads/lamppost/lamppost.m2", ) .expect("mesh lamppost"); // Five batches: body + glass + two billboard cards (the spherical additive glow and the cylindrical // top-flame) split out from their mixed batch onto their own bones, so each faces the camera about // its own pivot (decision 0028). The point here is the alpha-cull leaves every visible batch intact. assert_eq!(lamppost.len(), 5, "a normal prop keeps all its batches"); } #[test] fn suppresses_white1_invisible_trap_placeholder() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // The `SpellObject_InvisibleTrap` placeholder (GameObject displayId 1287 — Fire-Festival fury zones, // rallying-cry triggers, …): a flat opaque quad textured with the engine utility-white WHITE1.BLP. // The real client draws it at per-instance alpha ≈0 (invisible); benilla recognises the placeholder // and drops its geometry (decision 0030). It must reduce to zero render batches. let trap = benilla_formats::load_m2_mesh( &mut chain, "world/generic/passivedoodads/traps/spellobject_invisibletrap.m2", ) .expect("mesh trap"); assert!( trap.is_empty(), "the WHITE1 invisible-trap placeholder must be suppressed, got {} batches", trap.len() ); // Control — a *visible* flat decal that shares the trap's degenerate render box but carries a real // texture + AlphaTest (an orc bedroll). It must NOT be suppressed: the fingerprint is opaque+WHITE1, // not flatness, so genuine ground decals are kept. let mat = benilla_formats::load_m2_mesh( &mut chain, "world/azeroth/burningsteppes/passivedoodads/orcsleepmats/orcsleepmat01.m2", ) .expect("mesh orc sleep mat"); assert!( !mat.is_empty(), "a visible flat decal (real texture, AlphaTest) must be kept, not suppressed" ); } #[test] fn decodes_a_blp_icon_to_png() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let bytes = chain .read_file("Interface/Icons/Spell_Holy_ArcaneIntellect.blp") .expect("read spell icon BLP"); let out = std::env::temp_dir().join("benilla_formats_test_icon.png"); let (w, h) = benilla_formats::blp_to_png(&bytes, &out).expect("decode BLP -> PNG"); assert_eq!((w, h), (64, 64), "spell icons are 64x64"); let png = std::fs::read(&out).expect("read written PNG"); assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10], "PNG magic"); let _ = std::fs::remove_file(&out); } #[test] fn dumps_taxinodes_to_csv() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let bytes = chain .read_file("DBFilesClient/TaxiNodes.dbc") .expect("read TaxiNodes.dbc"); let out = std::env::temp_dir().join("benilla_formats_test_taxi.csv"); let (records, fields) = benilla_formats::dbc_to_csv(&bytes, "TaxiNodes.dbc", &out).expect("dbc->csv"); assert_eq!((records, fields), (85, 16), "vanilla TaxiNodes.dbc shape"); let csv = std::fs::read_to_string(&out).expect("read csv"); assert!( csv.lines() .next() .unwrap() .starts_with("ID,MapID,X,Y,Z,Name"), "schema-derived header" ); assert!(csv.contains("Stormwind"), "expected a known flight node"); assert_eq!(csv.lines().count(), 86, "85 records + 1 header row"); let _ = std::fs::remove_file(&out); } #[test] fn meshes_an_elwynn_terrain_tile() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) .expect("find an Elwynn tile"); let tile = benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); assert!(!tile.chunks.is_empty(), "tile should have terrain chunks"); assert!( tile.vertex_count() <= 256 * 145, "<= 256 chunks * (81 outer + 64 inner) = 145 verts/chunk (center-fan)" ); // Hilltop sanity (B1): every chunk should carry the 64 inner center verts, and at least one // tile-wide cell must have its center vertex deviate by > 1 yd from the corner-mean — that's // the authored hilltop bump we used to flatten. Catches a regression to the old 81-vert mesher. let mut hilltop_dev = 0.0f32; for c in &tile.chunks { assert_eq!(c.positions.len(), 145, "chunk = 81 outer + 64 inner verts"); for r in 0..8usize { for col in 0..8usize { let tl = c.positions[r * 17 + col][2]; let tr = c.positions[r * 17 + col + 1][2]; let bl = c.positions[(r + 1) * 17 + col][2]; let br = c.positions[(r + 1) * 17 + col + 1][2]; let ctr = c.positions[r * 17 + 9 + col][2]; let mean = (tl + tr + bl + br) * 0.25; hilltop_dev = hilltop_dev.max((ctr - mean).abs()); } } } assert!( hilltop_dev > 1.0, "expected ≥1 cell on this Elwynn tile with center−corner deviation > 1 yd \ (proves we read the authored MCVT inner heights, not just outer); got max {hilltop_dev:.2}", ); let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]); for chunk in &tile.chunks { assert_eq!(chunk.indices.len() % 3, 0, "triangle list per chunk"); assert_eq!(chunk.positions.len(), chunk.uvs.len(), "one UV per vertex"); assert!( chunk .indices .iter() .all(|&i| (i as usize) < chunk.positions.len()), "chunk indices reference valid vertices" ); for p in &chunk.positions { for i in 0..3 { mn[i] = mn[i].min(p[i]); mx[i] = mx[i].max(p[i]); } } } // Bounds sanity — guards against the MCNK axis-scramble bug. // One tile spans ~533 yds in X and Y; tile 31_48's world X is negative; Z holds plausible // terrain heights (a leaked horizontal axis would blow the Z span way past this). let span = |i: usize| mx[i] - mn[i]; assert!( (500.0..540.0).contains(&span(0)) && (500.0..540.0).contains(&span(1)), "X/Y should each span ~one tile (533 yds); got X={:.1}, Y={:.1}", span(0), span(1) ); assert!( mx[0] < 0.0, "tile 31_48 world X is negative; got max X={:.1}", mx[0] ); assert!( span(2) < 600.0, "Z should be terrain heights; got span {:.1}", span(2) ); // Texturing data: some chunk should reference a base-layer .blp from MTEX. assert!( tile.chunks.iter().any(|c| c .base_texture .as_deref() .is_some_and(|t| t.to_ascii_lowercase().ends_with(".blp"))), "expected a base-layer .blp texture on some chunk" ); // Multi-layer splat data (Phase 6): Elwynn blends grass/dirt/road, so some chunk should have // >1 layer and a non-trivial packed alpha map. let multilayer = tile .chunks .iter() .find(|c| c.layer_textures.len() > 1) .expect("some chunk should have multiple texture layers"); let alpha = multilayer .alpha_map .as_ref() .expect("a multi-layer chunk carries a packed alpha map"); let size = benilla_formats::ALPHA_MAP_SIZE as usize; assert_eq!(alpha.len(), size * size * 4, "alpha map is RGBA size²"); assert!( alpha .chunks_exact(4) .any(|px| px[0] > 0 || px[1] > 0 || px[2] > 0), "a multi-layer chunk's alpha map should have some non-zero blend weight" ); assert!( multilayer.layer_textures.len() <= 4, "at most 4 terrain layers per chunk" ); } #[test] fn terrain_chunks_carry_unit_upward_mcnr_normals() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) .expect("find an Elwynn tile"); let tile = benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); let (mut checked, mut up) = (0usize, 0usize); for chunk in &tile.chunks { if chunk.normals.is_empty() { continue; } assert_eq!( chunk.normals.len(), chunk.positions.len(), "one MCNR normal per vertex" ); for n in &chunk.normals { let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); assert!( (0.9..=1.1).contains(&len), "decoded normal should be ~unit, got len {len:.3} for {n:?}" ); checked += 1; // WoW Z is up; ground normals point outward/up. If the X/Z/Y decode were wrong, "up" // would land in a different component and this would collapse — so it pins the axis. if n[2] > 0.5 { up += 1; } } } assert!(checked > 0, "Elwynn tile should carry MCNR normals"); assert!( up * 100 / checked >= 70, "most terrain normals should point up (WoW +Z); only {up}/{checked} did" ); } #[test] fn terrain_is_watertight_at_chunk_seams() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) .expect("find an Elwynn tile"); let tile = benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); // Bucket every vertex by quantized XY (0.25-yd buckets only group truly-coincident verts, since // the grid spacing is ~4.17 yd). A vertex shared by adjacent chunks must come out *bit-identical* // in X/Y after lattice snapping — otherwise the fractional misalignment reopens the seam. use std::collections::HashMap; let mut buckets: HashMap<(i64, i64), Vec<[f32; 3]>> = HashMap::new(); for c in &tile.chunks { for p in &c.positions { let key = ((p[0] * 4.0).round() as i64, (p[1] * 4.0).round() as i64); buckets.entry(key).or_default().push(*p); } } let mut shared = 0usize; for ps in buckets.values() { if ps.len() < 2 { continue; } shared += 1; for w in ps.windows(2) { assert_eq!( w[0][0], w[1][0], "shared-edge X must be bit-identical (no seam)" ); assert_eq!( w[0][1], w[1][1], "shared-edge Y must be bit-identical (no seam)" ); assert!( (w[0][2] - w[1][2]).abs() < 0.01, "shared-edge heights should match: {} vs {}", w[0][2], w[1][2] ); } } assert!( shared > 0, "tile should have shared chunk-edge vertices to check" ); } #[test] fn loads_a_block_of_elwynn_tiles() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let tiles = benilla_formats::load_tiles_around(&mut chain, "Azeroth", -8840.56, 489.7, 1) .expect("load tile block"); assert!( tiles.len() > 1, "radius 1 around Stormwind should load several contiguous tiles, got {}", tiles.len() ); let mut coords: Vec<_> = tiles.iter().map(|(c, _)| *c).collect(); coords.sort_unstable(); coords.dedup(); assert_eq!(coords.len(), tiles.len(), "tiles must be distinct"); } #[test] fn reads_and_decodes_a_terrain_texture() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) .expect("find a tile"); let tile = benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); let texture = tile .chunks .iter() .find_map(|c| c.base_texture.clone()) .expect("at least one chunk has a base texture"); let (w, h, rgba) = benilla_formats::read_texture_rgba(&mut chain, &texture).expect("decode terrain texture"); assert!(w > 0 && h > 0, "non-empty texture, got {w}x{h}"); assert_eq!(rgba.len(), (w * h * 4) as usize, "RGBA8 pixel buffer"); } #[test] fn resolves_creature_models_from_dbcs() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let catalog = benilla_formats::load_creature_catalog(&mut chain).expect("load creature display catalog"); assert!( catalog.len() > 1000, "vanilla has thousands of creature displays, got {}", catalog.len() ); // Scan display ids; many should resolve. Prove at least one resolved creature model that ships // in this client actually meshes (not every CreatureModelData path is present on disk). Scale 0 // is valid — many low-id displays are invisible trigger NPCs — so we only require most positive. let (mut resolved, mut positive_scale, mut meshed) = (0u32, 0u32, false); for id in 1..6000u32 { let Some(m) = catalog.model(id) else { continue }; resolved += 1; if m.scale > 0.0 { positive_scale += 1; } let p = m.model_path.to_ascii_lowercase(); if !meshed && m.scale > 0.0 && p.contains("creature") && p.ends_with(".mdx") { if let Ok(subs) = benilla_formats::load_m2_mesh_skinned(&mut chain, &m.model_path, &m.textures) { meshed |= !subs.is_empty(); } } } assert!( resolved > 500, "many display ids should resolve, got {resolved}" ); assert!( positive_scale * 2 > resolved, "most resolved displays should have positive scale, got {positive_scale}/{resolved}" ); assert!( meshed, "expected at least one resolved creature model to mesh" ); } #[test] fn resolves_gameobject_models_from_dbc() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let catalog = benilla_formats::load_gameobject_catalog(&mut chain) .expect("load gameobject display catalog"); assert!( catalog.len() > 500, "vanilla has many GameObject displays, got {}", catalog.len() ); // Every resolved path is a model; prove at least one meshes (dispatches .mdx/.mdl/.m2 vs .wmo). let mut meshed = false; for id in 1..3000u32 { let Some(path) = catalog.model_path(id).map(str::to_string) else { continue; }; let p = path.to_ascii_lowercase(); assert!( p.ends_with(".mdx") || p.ends_with(".mdl") || p.ends_with(".wmo"), "unexpected GameObject model path: {path}" ); if !meshed { if let Ok(subs) = benilla_formats::load_object_model(&mut chain, &path) { meshed |= !subs.is_empty(); } } } assert!(meshed, "expected at least one GameObject model to mesh"); } #[test] fn loads_classic_models_with_cosmetic_chunks() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // These vanilla M2s aborted `parse_m2` with "failed to fill whole buffer" on stock wow-m2 0.6.4 // (malformed particle-emitter / light / texture-animation chunks — warcraft-rs#56). Our patched // fork degrades those cosmetic chunks to empty, so geometry now loads. They must mesh. for path in [ "Creature\\Kobold\\Kobold.mdx", "Creature\\Imp\\Imp.mdx", "World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.mdx", ] { let subs = benilla_formats::load_m2_mesh(&mut chain, path) .unwrap_or_else(|e| panic!("load {path}: {e:#}")); assert!(!subs.is_empty(), "{path} should produce submeshes"); } } /// Span (max−min) per axis of a point set; `[0;3]` if empty. fn span3(ps: &[[f32; 3]]) -> [f32; 3] { if ps.is_empty() { return [0.0; 3]; } let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]); for p in ps { for i in 0..3 { mn[i] = mn[i].min(p[i]); mx[i] = mx[i].max(p[i]); } } [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]] } #[test] fn decodes_m2_collision_hulls() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // Golden vectors hand-verified against the raw bytes 2026-06-02. The // hull is a coarse TRUNK, far smaller than the render canopy — the "blocked by trunk, walk through // leaves" shape. Exact tri/vert counts fire if a wow-m2 bump changes the bounding-array decode. let pine = benilla_formats::load_m2_collision_hull( &mut chain, "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01\\ElwynnPine01.mdx", ) .expect("pine hull"); assert_eq!(pine.triangle_count(), 6, "ElwynnPine01 hull = 6 tris"); assert_eq!(pine.positions.len(), 5, "ElwynnPine01 hull = 5 verts"); let s = span3(&pine.positions); assert!( (9.0..11.0).contains(&s[2]), "pine trunk hull ~10 yd tall, got Z span {:.2}", s[2] ); assert!( s[0] < 4.0 && s[1] < 4.0, "pine trunk hull is thin (≪ canopy), got X={:.2} Y={:.2}", s[0], s[1] ); assert!( pine.indices .iter() .all(|&i| (i as usize) < pine.positions.len()), "hull indices in range" ); let canopy = benilla_formats::load_m2_collision_hull( &mut chain, "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTreeCanopy01.mdx", ) .expect("canopy hull"); assert_eq!( canopy.triangle_count(), 52, "ElwynnTreeCanopy01 hull = 52 tris" ); assert_eq!( canopy.positions.len(), 30, "ElwynnTreeCanopy01 hull = 30 verts" ); // Sanity: the hull is much smaller than the rendered model's authored bbox (trunk vs full canopy). let bounds = benilla_formats::load_m2_bounds( &mut chain, "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTreeCanopy01.mdx", ) .expect("canopy bounds"); let render_x = bounds.bbox_max[0] - bounds.bbox_min[0]; let hull_x = span3(&canopy.positions)[0]; assert!( hull_x < render_x * 0.6, "collision hull (X span {hull_x:.1}) should be far tighter than the render bbox ({render_x:.1})" ); } #[test] fn filters_wmo_collidable_triangles_by_mopy() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // Golden vector: the binary-verified client filter (collide iff `!(flags & 0x04 DETAIL)`, // WoW.exe 5875 — collision.md §F) selects exactly 5114 collidable tris from the Goldshire Inn. (On // this fixture that equals the old `COLLISION||(RENDER&&!DETAIL)` count — every non-DETAIL face here // is also COLLISION-or-RENDER; the rules diverge only on flags-0x00/0x40-only faces, absent here.) // Pins both the filter clause and the wow-wmo group decode. let inn = benilla_formats::load_wmo_collision_tris( &mut chain, "World\\wmo\\Azeroth\\Buildings\\GoldshireInn\\GoldshireInn.wmo", ) .expect("goldshire inn collision"); assert_eq!(inn.triangle_count(), 5114, "Goldshire Inn collidable tris"); assert!( inn.indices .iter() .all(|&i| (i as usize) < inn.positions.len()), "collidable indices in range" ); let s = span3(&inn.positions); assert!( (40.0..80.0).contains(&s[0]) && (20.0..50.0).contains(&s[1]) && (20.0..45.0).contains(&s[2]), "inn collidable bbox should be building-sized, got {s:?}" ); // A bridge is ~entirely collision surface (you walk across the deck): elwynnwidebridge = 100%. let bridge = benilla_formats::load_wmo_collision_tris( &mut chain, "World\\wmo\\Azeroth\\Collidable Doodads\\Elwynn\\WideBridge\\ElwynnWideBridge.wmo", ) .expect("elwynn wide bridge collision"); assert!( bridge.triangle_count() > 0, "a bridge must carry collidable triangles (the 'walk under bridges' fix)" ); } #[test] fn derives_the_wmo_window_glass_law_from_momt() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // Golden vectors for the three MOMT window/glass mechanisms (wow-re `wmo-lit-selector` §1, // `wmo-interior-night-light` §2/§4). The Goldshire Inn authors both window kinds: // mat 5 MM_ELWYNN_WND_EXT__01, flags 0x11 (UNLIT|SIDN), exterior groups only → the unlit // fullbright pane (SIDN inert under lighting-off), sidn colour still carried; // mat 21 MM_ELWYNN_WND_INT__01, flags 0x28 (WINDOW), interior groups' EXT section → lit by // the interior midpoint light, never fullbright, no SIDN colour. let inn = benilla_formats::load_wmo( &mut chain, "World\\wmo\\Azeroth\\Buildings\\GoldshireInn\\GoldshireInn.wmo", ) .expect("goldshire inn render submeshes"); let tex_is = |sub: &benilla_formats::RenderSubmesh, name: &str| { sub.texture .as_deref() .is_some_and(|t| t.to_ascii_uppercase().ends_with(name)) }; let ext_panes: Vec<_> = inn .iter() .filter(|s| tex_is(s, "MM_ELWYNN_WND_EXT__01.BLP")) .collect(); assert!(!ext_panes.is_empty(), "inn exterior window panes present"); for s in &ext_panes { assert!(!s.interior, "the EXT pane sits in an exterior group"); assert!(s.emissive, "UNLIT on an exterior group ⇒ unlit fullbright"); assert_eq!(s.sidn, Some([203, 203, 203]), "the authored SIDN colour"); assert!(!s.window, "no WINDOW bit on the exterior pane"); } let int_panes: Vec<_> = inn .iter() .filter(|s| tex_is(s, "MM_ELWYNN_WND_INT__01.BLP")) .collect(); assert!(!int_panes.is_empty(), "inn interior window panes present"); for s in &int_panes { assert!(s.interior, "the INT pane sits in interior groups"); assert!( !s.emissive, "the interior drawer ignores UNLIT — never fullbright" ); assert!(s.window, "WINDOW (0x20) ⇒ the interior midpoint light"); assert_eq!(s.sidn, None, "no SIDN flag on the interior pane"); assert_eq!( s.wmo_batch, Some(benilla_formats::WmoBatchClass::Ext), "the inn's interior panes land in the EXT-in-group (lit) section" ); } // The SIDN colour is a CImVector — BGRA on disk. Shadowfang's stained glass authors an // ASYMMETRIC colour (disk bytes 216,231,250,255), so this pins the BGRA→RGB decode: a warm // cream (250,231,216), not a cool blue — a swizzle bug can't pass. let sfk = benilla_formats::load_wmo( &mut chain, "World\\wmo\\Dungeon\\LD_ShadowFang\\LD_ShadowFang.wmo", ) .expect("shadowfang render submeshes"); assert!( sfk.iter().any(|s| s.sidn == Some([250, 231, 216])), "Shadowfang authors the warm-cream SIDN glass (BGRA 216,231,250 → RGB 250,231,216)" ); } #[test] fn reads_authentic_atmosphere_from_light_dbc() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); // Northshire (Human start) on Azeroth resolves to the continent's global daytime light. let a = cat.sample_noon(0, [-8949.95, -132.49, 83.5]); // Fog bands are RAW (no /36 — that scale is positions-only; wow-re `rf-weather-fog-veil.md`): // the client pushes `min(raw, farclip)`, so these huge land values resolve to the view distance. // Must have read real bands, not fallen back to the default. assert_ne!( a.fog_end, benilla_formats::Atmosphere::DEFAULT.fog_end, "got the fallback atmosphere — Light bands didn't resolve" ); // Exact verified ground truth (2026-05-25): Northshire → global light #1 → clear LightParams 12. // Decoded independently from the raw DBC bytes and cross-checked vs wowdev.wiki; locking these // pins the band index formula, the row→meaning map, the color decode, AND the light selection. let to255 = |c: [f32; 3]| { [ (c[0] * 255.0).round() as i32, (c[1] * 255.0).round() as i32, (c[2] * 255.0).round() as i32, ] }; assert!( (a.fog_end - 500.0).abs() < 1.0, "clear fog_end should be 500 yd (raw 18000/36, decision 0324), got {}", a.fog_end ); assert!( (a.fog_start_frac - 0.25).abs() < 0.001, "clear fog start fraction should be +0.25, got {}", a.fog_start_frac ); assert_eq!(to255(a.sky[0]), [0, 31, 73], "SkyTop/zenith (int row 2)"); assert_eq!(to255(a.sky[1]), [58, 162, 207], "SkyMiddle (int row 3)"); assert_eq!(to255(a.sky[2]), [153, 220, 245], "SkyBand1 (int row 4)"); assert_eq!(to255(a.sky[3]), [175, 218, 224], "SkyBand2 (int row 5)"); assert_eq!( to255(a.sky[4]), [180, 180, 180], "SkySmog/horizon (int row 6)" ); assert_eq!(to255(a.sun_color), [255, 247, 222], "sun (int row 9)"); assert_eq!(to255(a.ambient), [104, 130, 154], "ambient (int row 1)"); assert_eq!(to255(a.fog_color), [77, 120, 143], "fog color (int row 7)"); for c in [a.fog_color, a.sun_color, a.ambient] { for ch in c { assert!( (0.0..=1.0).contains(&ch), "color channel out of range: {ch}" ); } } assert!( a.fog_color.iter().sum::() > 0.2 && a.sun_color.iter().sum::() > 0.5, "daytime colors implausibly dark: fog {:?} sun {:?}", a.fog_color, a.sun_color ); } /// Elwynn's STORM param (k2 = LightParams 10) at midday — the §5-verified endpoints from wow-re's /// `rf-weather-fog-veil.md` (decoded off the real DBCs and byte-traced through the blend). The /// load-bearing pin is the **negative fog-start fraction**: −0.5, UNCLAMPED — the mechanism behind /// the reference's constant ~33% near veil under rain. A clamp regression here silently kills the veil. #[test] fn reads_elwynn_storm_fog_endpoints() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); // Northshire, noon, storm slot (Elwynn global light #1 → storm LightParams 10, single-stop bands). let s = cat.sample(0, [-8949.95, -132.49, 83.5], 1440, true); assert!( (s.fog_end - 277.8).abs() < 1.0, "storm fog_end should be ~278 yd (raw 10000/36, decision 0324), got {}", s.fog_end ); assert!( (s.fog_start_frac - -0.5).abs() < 0.001, "storm fog start fraction should be −0.5 (negative ⇒ the near veil), got {}", s.fog_start_frac ); let to255 = |c: [f32; 3]| { [ (c[0] * 255.0).round() as i32, (c[1] * 255.0).round() as i32, (c[2] * 255.0).round() as i32, ] }; assert_eq!(to255(s.fog_color), [82, 84, 82], "storm fog color (grey)"); assert_eq!( to255(s.sun_diffuse), [101, 101, 101], "storm diffuse (flat grey)" ); assert_eq!(to255(s.ambient), [78, 78, 95], "storm ambient (grey-blue)"); } #[test] fn reads_map_directories_from_map_dbc() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let cat = benilla_formats::load_map_catalog(&mut chain).expect("load Map.dbc"); // 5875 has ~44 maps (2 continents, ~12 dungeons, BGs, dev maps). assert!( cat.len() >= 40, "expected ≥40 Map.dbc rows, got {}", cat.len() ); // Canonical IDs — pin the schema so a field-count drift can't silently break the directory // column (which would silently make every cross-map teleport pick the wrong terrain). assert_eq!(cat.directory(0), Some("Azeroth")); assert_eq!(cat.directory(1), Some("Kalimdor")); // Deadmines instance — a dungeon, proves dungeons resolve too. assert_eq!(cat.directory(36), Some("DeadminesInstance")); } #[test] fn decodes_azeroth_wdl_against_apitrace_ground_truth() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); // Eastern Kingdoms distant-terrain heightmap. let wdl = benilla_formats::WdlFile::load(&mut chain, "Azeroth").expect("load Azeroth.wdl"); // VERIFIED by walking the real file (2026-05-30): MVER=18, 687 present tiles. assert_eq!(wdl.present_count(), 687, "Azeroth.wdl present-tile count"); // Golden vector tied to apitrace WoW.8: the 2nd WDL tile drawn that frame spanned world // X[-9066.7,-8533.3] Y[-1600,-1066.7] (= tile_x 34, tile_y 48) with vertex heights 54..349. assert!( wdl.is_present(34, 48), "apitrace tile (34,48) must be present" ); let mesh = wdl.tile_mesh(34, 48).expect("mesh for (34,48)"); assert_eq!(mesh.positions.len(), 545, "545 verts (17x17 + 16x16)"); assert_eq!( mesh.indices.len(), 3072, "3072 indices (16x16 cells x 4 tris)" ); // Heights (world Z) — exact int16 from the file; must match the captured VB range. let zs: Vec = mesh.positions.iter().map(|p| p[2]).collect(); let zmin = zs.iter().copied().fold(f32::INFINITY, f32::min); let zmax = zs.iter().copied().fold(f32::NEG_INFINITY, f32::max); assert_eq!(zmin, 54.0, "tile (34,48) min height (apitrace VB)"); assert_eq!(zmax, 349.0, "tile (34,48) max height (apitrace VB)"); // Planar extent — one ADT tile (533.33 yd) on each axis, matching the captured VB span. let xs: Vec = mesh.positions.iter().map(|p| p[0]).collect(); let ys: Vec = mesh.positions.iter().map(|p| p[1]).collect(); let span = |v: &[f32]| { v.iter().copied().fold(f32::NEG_INFINITY, f32::max) - v.iter().copied().fold(f32::INFINITY, f32::min) }; assert!( (span(&xs) - 533.333).abs() < 0.5, "X span ~533.33, got {}", span(&xs) ); assert!( (span(&ys) - 533.333).abs() < 0.5, "Y span ~533.33, got {}", span(&ys) ); // Max-X/-Y corner = tile origin (matches wow_wdt::tile_to_world(34,48)). let xmax = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); let ymax = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); assert!((xmax - (-8533.3)).abs() < 0.5, "tile origin X, got {xmax}"); assert!((ymax - (-1066.7)).abs() < 0.5, "tile origin Y, got {ymax}"); } /// The two **fixed global `LightParams` rows** magma and slime submersion read (`light::PARAM_MAGMA` /// / `PARAM_SLIME`, byte-VERIFIED `0x6d2371`), pinned against the shipped client. /// /// This is the cross-check that earned the finding rather than merely repeating it: nothing in /// `Light.dbc` references rows 6 or 7, so no position-keyed sample can reach them and no zone can /// vouch for them. What vouches for them is that they decode to *exactly* the vanilla lava and slime /// view — dense fiery orange and dense pure green — which a wrong pair of row numbers would not. #[test] fn magma_and_slime_submersion_read_the_fixed_global_light_params() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); let to255 = |c: [f32; 3]| { [ (c[0] * 255.0).round() as i32, (c[1] * 255.0).round() as i32, (c[2] * 255.0).round() as i32, ] }; // Two positions on different continents whose CLEAR atmospheres differ — the fixed rows must be // identical at both, because they are zone-independent. This is the property that separates "a // fixed global row" from "a zone underwater slot that happens to look right at one pin". let pins = [ (0u32, [-7531.21f32, -1123.64, 172.58]), // Blackrock Mountain (the B24/B68 lava pin) (1u32, [-5075.53f32, -2063.09, -50.10]), // Thousand Needles (the B68 water pin) ]; for kind in [ benilla_formats::Submersion::Magma, benilla_formats::Submersion::Slime, ] { let sampled: Vec<_> = pins .iter() .map(|(map, pos)| cat.sample_blended(*map, *pos, 1440, false, kind, false)) .collect(); assert_eq!( to255(sampled[0].fog_color), to255(sampled[1].fog_color), "{kind:?} is zone-INDEPENDENT: the fixed row must not vary by position" ); assert_eq!( sampled[0].fog_end, sampled[1].fog_end, "{kind:?} fog end must not vary by position" ); } // Magma → row 7. Dense fiery orange, and the shortest fog in the client: 972/36 = 27 yd, with a // −2.0 start fraction putting `1 − 27/(27+54)` = 67 % of it at the eye itself. let magma = cat.sample_blended( 0, pins[0].1, 1440, false, benilla_formats::Submersion::Magma, false, ); assert_eq!( to255(magma.fog_color), [200, 52, 0], "magma fog (row 7 int 7)" ); assert_eq!(to255(magma.ambient), [255, 55, 0], "magma ambient"); assert!( (magma.fog_end - 27.0).abs() < 0.01, "magma fog end 972/36 = 27 yd, got {}", magma.fog_end ); assert!( (magma.fog_start_frac + 2.0).abs() < 1e-6, "magma start fraction −2.0, got {}", magma.fog_start_frac ); // Slime → row 6. Pure green at 1800/36 = 50 yd, −1.0 start = 50 % at the eye. let slime = cat.sample_blended( 0, pins[0].1, 1440, false, benilla_formats::Submersion::Slime, false, ); assert_eq!( to255(slime.fog_color), [0, 255, 0], "slime fog (row 6 int 7)" ); assert_eq!(to255(slime.ambient), [0, 60, 0], "slime ambient"); assert!( (slime.fog_end - 50.0).abs() < 0.01, "slime fog end 1800/36 = 50 yd, got {}", slime.fog_end ); assert!( (slime.fog_start_frac + 1.0).abs() < 1e-6, "slime start fraction −1.0, got {}", slime.fog_start_frac ); // And the whole point: neither is the zone's own underwater atmosphere. At the Thousand Needles // pin the water murk is the olive-brown LP 203 — being in lava there must not show it. let water = cat.sample_blended( 1, pins[1].1, 1440, false, benilla_formats::Submersion::Water, false, ); assert_ne!( to255(water.fog_color), to255(magma.fog_color), "lava must not inherit the zone's water murk" ); assert_ne!( to255(water.fog_color), to255(slime.fog_color), "slime must not inherit the zone's water murk" ); // Dry is different again — the guard against "everything resolved to one fallback". let dry = cat.sample_blended( 1, pins[1].1, 1440, false, benilla_formats::Submersion::Dry, false, ); assert_ne!( to255(dry.fog_color), to255(water.fog_color), "the water pin's underwater slot must differ from its clear slot" ); } /// The far band's tile window **contains the camera's own tile** (decision 0684). Dropping it was /// invisible at the default view distance — a 533 yd tile sits inside a 777 yd wall, so it would /// have been discarded anyway — and a hole above the horizon at any lower one, where the own tile is /// the only thing that draws the near horizon. The director found it at view distance 320 in /// Weazel's Crater; this pins the window at the coordinates they reported. #[test] fn the_wdl_window_contains_the_cameras_own_tile() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let wdl = benilla_formats::WdlFile::load(&mut chain, "Kalimdor").expect("load Kalimdor.wdl"); // Weazel's Crater, Thousand Needles — the report's spot. The client's debug panel read // `tile 39,42` standing there, which is what `world_to_tile` must agree on. let (x, y) = (-5841.9_f32, -3802.4); let own = benilla_wdt::world_to_tile(x, y); assert_eq!(own, (39, 42), "the reported tile for Weazel's Crater"); assert!(wdl.is_present(own.0, own.1), "the own tile is authored"); let window = wdl.tiles_in_ring(x, y, 5); assert!( window.contains(&own), "the camera's own WDL tile must be in the window — without it the near horizon is a hole" ); // And it is a full (2r+1)² window, not a ring: every present tile within the radius. let expected = (-5i32..=5) .flat_map(|dy| (-5i32..=5).map(move |dx| (own.0 as i32 + dx, own.1 as i32 + dy))) .filter(|&(tx, ty)| (0..64).contains(&tx) && (0..64).contains(&ty)) .filter(|&(tx, ty)| wdl.is_present(tx as u32, ty as u32)) .count(); assert_eq!(window.len(), expected, "every present tile in the window"); } /// A **tombstoned path must never fall through to the live copy a base archive still holds.** /// /// `patch.MPQ` deletes 26 paths that `model.MPQ` and friends still carry in full — cut content the /// 1.12.1 client does not load (decision 0246). The failure this pins is silent and points the /// wrong way: a chain that resolved *past* the delete-marker would serve `OgreMage.m2`'s 386 KB as /// if it were current, looking entirely healthy while rendering a model the reference never draws. /// Until now the property was held by a comment in `Chain::read` and nothing else. /// /// Found by comparing our corpus census against wow-re's: their chain over-enumerated by exactly /// these 26, because `list` took the winning listfile entry whatever its flags. Ours already /// filtered — the point of this test is that it stays that way. /// /// Non-vacuous by construction: each name is first read straight out of the base archive that still /// holds it, so the test fails loudly if the pairing ever stops being a real tombstone-over-content /// case rather than passing on an absent file. #[test] fn tombstoned_paths_never_fall_through_to_the_base_copy() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } // Tombstoned by `patch.MPQ`; still present, in full, in `model.MPQ` (measured: 386416 / 437456). const DELETED: [&str; 2] = [ "Creature\\OgreMage\\OgreMage.m2", "Creature\\OgreWarlord\\OgreWarlord.m2", ]; let base = Chain::open(&data.join("model.MPQ")).expect("open model.MPQ alone"); let chain = Chain::open(&data).expect("open the vanilla patch chain"); let listed: std::collections::HashSet = chain .list() .expect("list the chain") .into_iter() .map(|e| e.name.replace('/', "\\").to_ascii_lowercase()) .collect(); for name in DELETED { // The positive control: the content really is still there to be served by mistake. let live = base .read(name) .unwrap_or_else(|e| panic!("{name} must still exist in model.MPQ: {e}")); assert!( live.len() > 100_000, "{name} is a real model in the base archive, got {} bytes", live.len() ); // And the composite refuses it on all three paths. assert!(!chain.contains(name), "{name} is deleted from the chain"); let err = chain .read(name) .expect_err("reading a tombstoned path must fail, not return the base copy") .to_string(); assert!( err.contains("deleted from patch chain"), "the error must name the tombstone, not a generic miss: {err}" ); assert!( !listed.contains(&name.to_ascii_lowercase()), "{name} must not be enumerated" ); } } /// The WMO **skybox** pair, against the shipped Stratholme files (`crate::models::wmo`, /// `benilla::wmo_sky`): a root's MOSB model and the per-group `0x40000` gate that asks for it. /// /// The sibling roots are the whole point of the fixture. `Stratholme_B` is the burning **city** — it /// names `StratholmeSkybox.m2` *and* 61 of its 83 groups set the bit, which is why the reference's /// sky in King's Square is a painted red sky and not map 329's khaki `Light.dbc` gradient. /// `Stratholme.wmo` is the **dungeon** next door: no MOSB, and not one of its 92 groups sets the bit. /// A regression that drops either half (the chunk parse, or the flag) shows up here as one of the two /// halves going quiet, and the two together are what keeps the gate off the chunk alone — four 1.12 /// roots name a skybox no group ever asks for (`benilla-extract skyboxscan`). #[test] fn reads_the_wmo_skybox_and_its_per_group_gate() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); return; } let mut chain = open_chain(&data).expect("open vanilla patch chain"); let city = chain .read_file("World\\wmo\\Dungeon\\LD_Stratholme\\Stratholme_B.wmo") .expect("read the Stratholme city WMO root"); let city = benilla_formats::parse_wmo_root(&city).expect("parse the city root"); assert_eq!( city.skybox(), Some("environments\\stars\\stratholmeskybox.m2"), "the city root's MOSB names the painted sky (normalized .mdx -> .m2)" ); let asking = city.group_infos().iter().filter(|g| g.show_skybox).count(); assert_eq!( (asking, city.group_infos().len()), (61, 83), "the city's open streets ask for the skybox; its enclosed rooms don't" ); let dungeon = chain .read_file("World\\wmo\\Dungeon\\LD_Stratholme\\Stratholme.wmo") .expect("read the Stratholme dungeon WMO root"); let dungeon = benilla_formats::parse_wmo_root(&dungeon).expect("parse the dungeon root"); assert_eq!( dungeon.skybox(), None, "the dungeon root's MOSB is the empty string — no skybox" ); assert_eq!( dungeon .group_infos() .iter() .filter(|g| g.show_skybox) .count(), 0, "and not one of its groups asks for one" ); }