Files
benilla-octo/crates/benilla-formats/tests/chain.rs
T
SamandClaude Fable 5 34fdbd1d16 the keyring lands, keys open doors, and a building can own the sky
- The keyring is built. Your first key makes the keyring button appear by
  the bags, opening the window the original keeps its dungeon keys in — the
  server had been sending the keys all along; nothing had ever shown them.
- Keys open their doors now. The client used to cast the open as a bare
  spell, which the server rightly refuses; it now sends the item-use the
  original sends, carrying which key from which slot. The tooltip plays
  along: "Locked" turns the soft green when something you carry opens it,
  and a key stops inventing a "Use: Opening" line it never had.
- A building can own the sky: a WMO can name its own painted skybox, and
  Stratholme's burning city now draws its near-black-to-red dome instead of
  the zone's khaki gradient. The exact rule for when it applies settles in
  the next snapshot.
- Signpost tooltips follow the cursor again — the tooltip anchor forks per
  object class, corner for doors, cursor for signs — and the Blasted Lands
  lightning stops striking the same spot for ever: a placed doodad re-rolls
  its animation variation every play-window, like the original.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:54:18 +07:00

1260 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration test against the real client at `<repo>/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::<f32>() > 0.2 && a.sun_color.iter().sum::<f32>() > 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<f32> = 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<f32> = mesh.positions.iter().map(|p| p[0]).collect();
let ys: Vec<f32> = 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<String> = 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"
);
}