formats: re-record the sound and visual goldens, and a tool to say what moved

Six modules' data goldens against the realm's own files, plus the thing that
made the last three honest rather than guessed: `examples/dbc_diff.rs`.

The tool reads the copy of a DBC the chain *wins* and the copy it shadows and
diffs them row by row. `Chain::copies` is ascending priority and several
`patch-*` archives are mounted, so 1.12's own file is usually still underneath
the realm's; the baseline is the highest copy in a stock archive (`dbc.MPQ`,
`patch.MPQ`, `patch-2`) so the diff reads "what OctoWoW changed about 1.12".
Schema-free — field 0 is the id, a row is its raw 4-byte slots — so an
undecoded column still shows up as "id N differs at slot K". `--row=N` dumps a
row with its text slots as text, which is how three ids below got names.

Two findings, both the shape of a reference into a table that has no such row:

- **The glow band.** The realm rewrote `ItemDisplayInfo.dbc` (patch-5) and left
  `ItemVisuals.dbc` as 1.12's out of base `patch.MPQ` — 34 rows, ids `1..=134`.
  22 ids its new displays name run past that end, where a later expansion's
  table continues, so 61 items wear no glow. The reference drops them exactly
  as we do (`0x479700` skips a visual id past the max id), so this is the
  realm's data, not our decode. Pinned by id with the displays behind each.

- **The silent kits.** `SoundEntries.dbc` grew 4623 -> 8805 rows and 4764 of
  its 16537 variation cells name a file the chain has not got: 236 directories
  hold no file at all (LesserManaFiend, ChineseDragon, ZoneMusic\BloodElf), and
  the ones that exist miss the TBC boss voices — Illidan, Akama, Lady Vashj,
  Kael'thas — plus 552 of `Sound\Doodad`. What makes that an observation about
  the data and not about our path join is *where* the misses fall: of the 2834
  kits with a dead variation, 2832 have an id past 4623, the last row 5875
  shipped. A faulty separator would miss evenly across old rows and new. Only
  395 OgreFidget and 3792 WaterFallLarge predate the realm, and only 3792 is
  wholly silent, so nothing that used to make a sound has gone quiet.

The rest are censuses re-recorded with their 5875 figures beside them. Every
one is safe to re-record for a mechanical reason: `benilla-dbc` fails the load
outright when a schema's expanded field count differs from the header's
(`lib.rs:215`), so a test that *reaches* a count assertion has already proved
its layout unchanged.

- SoundEntries 4623 -> 8805 rows, EAXDef census (2072,2,2549) -> (6241,4,2560),
  type-17 NPC voices 275 -> 529 (still every one authored dry), variation cells
  8961 -> 16537. Kit 8940's ten ASH_SPEAK misses stay, and now assert *why*:
  the asset ships and it is the authored leading `\` that cannot reach it.
- VocalUISounds 533 -> 688 rows, live normal kits 785 -> 1085.
- ItemSounds nonzero field-11 20513 -> 32728.
- CreatureSoundData 406 -> 525 rows. A fifth row carries pet barks, and
  `dbc_diff` settles what it is: row 348 is 1.12's own and columns 27-29 are
  the only thing patch-4 changed about it, filled with the Imp's own kits — so
  an imp barks whichever of its three models is in play, `Creature\Imp\Imp.mdx`
  and TBC's `Impoutland` and `Moargminion`. Row 348 says nothing else at all.
- SpellVisual 2165 -> 2313, SpellVisualKit 1772 -> 1969, SpellChainEffects
  18 -> 21, chain CharProc census (68,48,20) -> (110,51,59). The three new
  beams are HarvestBeam, SpiritLinkBeam and Beam_ChainIron_NoAlpha, and unlike
  the sound rows the art ships with them; 23 zeroes every parameter the others
  animate. All three keep 1.12's segment length.
- ItemVisuals: enchants carrying a glow 102 -> 109, and 108 of 109 resolve —
  still exactly the one -1, so every new glowing enchant names a row that
  exists. Only the display side dangles.

benilla-formats: 38 -> 25 failures, no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-31 19:10:27 -06:00
co-authored by Claude Opus 5
parent ca60826bec
commit e58ddc10ab
7 changed files with 481 additions and 48 deletions
+262
View File
@@ -0,0 +1,262 @@
//! `cargo run -p benilla-formats --example dbc_diff -- <DBC name>` — what the realm changed in one
//! table, by reading the copy the client wins *and* the copy it shadows and diffing them row by row.
//!
//! The chain mounts several `patch-*` archives, so a table the realm rewrote usually still has
//! 1.12's own file sitting underneath it ([`Chain::copies`] lists every copy, *ascending*
//! priority). A goldens figure that moved is then answerable without a reference install: which
//! ids arrived, which left, and which kept their id while changing a column.
//!
//! The baseline is the highest copy in a **stock** archive — `dbc.MPQ`, `patch.MPQ`, `patch-2` are
//! 1.12's own, and `patch-3` upwards are the realm's — so the diff reads "what OctoWoW changed
//! about 1.12", not "what the last patch changed about the one before it". `--base=<archive>` picks
//! another one; `--all` lists what every copy holds.
//!
//! The diff is schema-free. Both files' headers give `field_count`/`record_size`, field 0 is the id
//! by convention, and a row is compared as its raw 4-byte slots — so a column whose meaning we have
//! not decoded still shows up as "row N differs at slot K". Strings live in a separate block per
//! file, so a slot holding a string offset is reported as a difference only when the *text* differs.
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{bail, Context, Result};
/// One file's rows, keyed by field 0, plus the strings each slot may point into.
struct Table {
fields: usize,
rows: BTreeMap<u32, Vec<u32>>,
strings: Vec<u8>,
}
/// The archives 1.12 itself shipped; anything else in `Data` is the realm's.
const STOCK: [&str; 3] = ["dbc.MPQ", "patch.MPQ", "patch-2.MPQ"];
fn file_name(p: &std::path::Path) -> &str {
p.file_name().and_then(|n| n.to_str()).unwrap_or("")
}
fn u32_at(b: &[u8], off: usize) -> u32 {
u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
fn read(bytes: &[u8]) -> Result<Table> {
if bytes.len() < 20 || &bytes[..4] != b"WDBC" {
bail!("not a WDBC file");
}
let (records, fields, recsize, strsize) = (
u32_at(bytes, 4) as usize,
u32_at(bytes, 8) as usize,
u32_at(bytes, 12) as usize,
u32_at(bytes, 16) as usize,
);
if recsize != fields * 4 {
bail!("record size {recsize} is not {fields} 4-byte slots");
}
let body = 20 + records * recsize;
let mut rows = BTreeMap::new();
for i in 0..records {
let at = 20 + i * recsize;
let slots: Vec<u32> = (0..fields).map(|f| u32_at(bytes, at + f * 4)).collect();
rows.insert(slots[0], slots);
}
let strings = bytes.get(body..body + strsize).unwrap_or_default().to_vec();
Ok(Table {
fields,
rows,
strings,
})
}
impl Table {
/// The string at `off`, for the slots that hold an offset into the string block.
fn text(&self, off: u32) -> &str {
let s = self.strings.get(off as usize..).unwrap_or_default();
let end = s.iter().position(|&b| b == 0).unwrap_or(s.len());
std::str::from_utf8(&s[..end]).unwrap_or("<utf8>")
}
/// Whether `off` plausibly names a non-empty string in this file's block. A numeric slot can
/// hold a value that lands inside the block by coincidence, so require `off` to be a string
/// *start* — preceded by the NUL that ended the one before it — which rejects almost all of
/// them. A small id or a float's bit pattern can still slip through, so a reported string is a
/// hint to check, never proof the slot is textual.
fn is_text(&self, off: u32) -> bool {
let i = off as usize;
i != 0
&& i < self.strings.len()
&& self.strings.get(i - 1) == Some(&0)
&& !self.text(off).is_empty()
}
}
fn main() -> Result<()> {
let Some(data) = benilla_formats::wow_data() else {
eprintln!("no install: set WOW_DATA");
std::process::exit(1);
};
let mut args = std::env::args().skip(1);
let name = args.next().unwrap_or_else(|| {
eprintln!("usage: dbc_diff <DBC name> [slot…] e.g. dbc_diff CreatureSoundData.dbc 27 28");
std::process::exit(2);
});
let name = if name.contains('\\') {
name
} else {
format!("DBFilesClient\\{name}")
};
// Report only these slots when given; otherwise every slot that differs.
let rest: Vec<String> = args.collect();
let base_arg = rest
.iter()
.find_map(|a| a.strip_prefix("--base=").map(str::to_string));
let row_arg: Option<u32> = rest
.iter()
.find_map(|a| a.strip_prefix("--row=").and_then(|n| n.parse().ok()));
let only: BTreeSet<usize> = rest.iter().filter_map(|a| a.parse().ok()).collect();
let chain = benilla_formats::Chain::open(&data).context("chain opens")?;
let copies = chain.copies(&name);
if copies.is_empty() {
bail!("{name} is in no archive");
}
println!("{name}");
for (p, size) in &copies {
println!(" {:>12} bytes {}", size, p.display());
let _ = size;
}
if copies.len() < 2 {
println!("\nonly one copy — nothing underneath it to diff against");
return Ok(());
}
let open = |p: &std::path::Path| -> Result<Table> {
let a = benilla_mpq::Archive::open(p).with_context(|| format!("open {}", p.display()))?;
read(&a.read_file(&name)?)
};
// `copies` is ascending priority, so the client reads the last one.
let win = open(copies[copies.len() - 1].0)?;
// `--row=N` just prints that row of the winning copy, text slots as text: for naming the thing
// behind an id a decoded catalog only gave a number for.
if let Some(want) = row_arg {
match win.rows.get(&want) {
Some(r) => {
let cells: Vec<String> = r
.iter()
.enumerate()
.map(|(f, &v)| {
if win.is_text(v) {
format!("[{f}] {:?}", win.text(v))
} else {
format!("[{f}] {v}")
}
})
.collect();
println!("\nrow {want}: {}", cells.join(" "));
}
None => println!("\nno row {want}"),
}
return Ok(());
}
let base = match base_arg {
Some(ref want) => copies
.iter()
.find(|(p, _)| file_name(p).eq_ignore_ascii_case(want))
.with_context(|| format!("{want} holds no copy of {name}"))?,
// The highest stock copy: 1.12's own last word on the table.
None => copies
.iter()
.rev()
.find(|(p, _)| STOCK.iter().any(|s| file_name(p).eq_ignore_ascii_case(s)))
.unwrap_or(&copies[0]),
};
println!(
"\ndiffing {} against {}",
file_name(copies[copies.len() - 1].0),
file_name(base.0)
);
let old = open(base.0)?;
println!(
"\nwins: {} rows x {} fields under: {} rows x {} fields",
win.rows.len(),
win.fields,
old.rows.len(),
old.fields
);
if win.fields != old.fields {
println!("!! the field count moved — every decoded column is suspect");
}
let added: Vec<u32> = win
.rows
.keys()
.filter(|k| !old.rows.contains_key(k))
.copied()
.collect();
let gone: Vec<u32> = old
.rows
.keys()
.filter(|k| !win.rows.contains_key(k))
.copied()
.collect();
println!("added {} ids, removed {} ids", added.len(), gone.len());
let show = |label: &str, v: &[u32]| {
if !v.is_empty() {
println!(
" {label}: {:?}{}",
&v[..v.len().min(20)],
if v.len() > 20 { " …" } else { "" }
);
}
};
show("added", &added);
show("removed", &gone);
// Rows that kept their id and changed a column.
let mut changed: Vec<(u32, Vec<usize>)> = Vec::new();
let mut per_slot: BTreeMap<usize, usize> = BTreeMap::new();
for (id, now) in &win.rows {
let Some(was) = old.rows.get(id) else {
continue;
};
let mut slots = Vec::new();
for f in 0..now.len().min(was.len()) {
if now[f] == was[f] {
continue;
}
// A string slot differs only when the text does: the offsets are per file.
if win.is_text(now[f]) && old.is_text(was[f]) && win.text(now[f]) == old.text(was[f]) {
continue;
}
if only.is_empty() || only.contains(&f) {
slots.push(f);
}
}
if !slots.is_empty() {
for &f in &slots {
*per_slot.entry(f).or_default() += 1;
}
changed.push((*id, slots));
}
}
println!("rows that kept their id and changed: {}", changed.len());
if !per_slot.is_empty() {
println!(" per slot: {per_slot:?}");
}
for (id, slots) in changed.iter().take(25) {
let (now, was) = (&win.rows[id], &old.rows[id]);
let detail: Vec<String> = slots
.iter()
.map(|&f| {
if win.is_text(now[f]) || old.is_text(was[f]) {
format!("[{f}] {:?} -> {:?}", old.text(was[f]), win.text(now[f]))
} else {
format!("[{f}] {} -> {}", was[f], now[f])
}
})
.collect();
println!(" id {id}: {}", detail.join(", "));
}
if changed.len() > 25 {
println!(" … {} more", changed.len() - 25);
}
Ok(())
}
+46 -4
View File
@@ -188,7 +188,8 @@ mod tests {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_voice_catalog(&mut chain).expect("load creature voices");
assert_eq!(cat.len(), 406, "all CreatureSoundData rows load");
// 525 where 5875 had 406, 30 fields either way.
assert_eq!(cat.len(), 525, "all CreatureSoundData rows load");
let v = cat.for_display(26).expect("display 26 has a voice");
assert_eq!(v.death, 314);
@@ -204,10 +205,16 @@ mod tests {
.expect("human male resolves via the model");
assert_eq!(human.footstep_class, 7);
}
/// Only four rows carry columns 27-29, and their kits are named `_KILL`, `_ORDER` and
/// `_DISMISS` in column order; a hunter pet's are 0, so it is silent, as in the reference.
/// Five rows carry columns 27-29, and their kits are named `_KILL`, `_ORDER` and `_DISMISS` in
/// column order; a hunter pet's are 0, so it is silent, as in the reference.
///
/// The fifth is the realm's doing. `dbc_diff CreatureSoundData.dbc` against `patch.MPQ` says
/// row 348 is 1.12's own and those three columns are the *only* thing patch-4 changed about it,
/// filled with the Imp's own kits — so an imp keeps its barks whichever of its three models is
/// in play: `Creature\Imp\Imp.mdx` (371) and TBC's `Impoutland` (2571) and `Moargminion`
/// (2572), which the realm backported. Row 348 says nothing else at all.
#[test]
fn only_the_four_demon_voices_carry_pet_barks() {
fn five_rows_carry_pet_barks_and_the_fifth_is_the_realms() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_voice_catalog(&mut chain).expect("load creature voices");
@@ -256,9 +263,44 @@ mod tests {
"A_VOIDWALKER_ORDER".into(),
"A_VOIDWALKER_DISMISS".into()
),
// The realm's, and the Imp's own three kits again, not new recordings.
(
348,
"A_IMP_KILL".into(),
"A_IMP_ORDER".into(),
"A_Imp_Dismiss".into()
),
],
);
// Row 348 is nothing but the barks: no death, no aggro, no exertion, no footstep class.
// Whatever wears those three imp models is heard only when it is commanded.
let alt = &cat.rows[&348];
assert_eq!(
(alt.death, alt.aggro, alt.exertion, alt.footstep_class),
(0, 0, [0, 0], 0),
"row 348 carries only columns 27-29"
);
// Same kit ids as row 11, so the two agree bark for bark.
let imp_row = &cat.rows[&11];
assert_eq!(
(alt.pet_attack, alt.pet_order, alt.pet_dismiss),
(imp_row.pet_attack, imp_row.pet_order, imp_row.pet_dismiss),
);
// And the three models that reach it are the imp family, 1.12's and the two backports.
let mut models: Vec<u32> = cat
.model_to_sound
.iter()
.filter(|(_, &s)| s == 348)
.map(|(&m, _)| m)
.collect();
models.sort_unstable();
assert_eq!(
models,
vec![371, 2571, 2572],
"Imp, Impoutland, Moargminion"
);
let imp = cat.for_display(904).expect("an imp display resolves");
assert_eq!(
(imp.pet_attack, imp.pet_order, imp.pet_dismiss),
+1 -1
View File
@@ -105,7 +105,7 @@ mod tests {
}
}
}
assert_eq!(nonzero, 20513, "the nonzero field-11 count");
assert_eq!(nonzero, 32728, "the nonzero field-11 count; 5875 had 20513");
assert_eq!(valid, nonzero, "every nonzero group id resolves");
}
}
+57 -11
View File
@@ -7,6 +7,10 @@
//! `0x479700` skips a visual or effect id that is negative, past the table's max id or a null
//! row, and an empty model path; ItemVisuals row 28 ships two garbage effect ids. The catalog
//! applies the same skips at load.
//!
//! The realm rewrote `ItemDisplayInfo.dbc` (patch-5) but left both glow tables as 1.12's own out
//! of base `patch.MPQ`, so 22 visual ids its new displays name have no row to reach — the
//! "past the table's max id" skip above is what swallows them, on the real client as here.
use std::collections::HashMap;
@@ -294,14 +298,17 @@ mod tests {
assert!(cat.effects(9999).is_none(), "past maxId");
}
/// The realm rewrote `ItemDisplayInfo.dbc` (patch-5) and left `ItemVisuals.dbc` as 1.12's own
/// out of base `patch.MPQ`, so 61 of its new displays name a glow row that was never shipped.
#[test]
fn real_displays_carrying_a_visual_all_resolve() {
fn real_displays_resolve_except_the_realms_unshipped_visual_band() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let visuals = load_item_visual_catalog(&mut chain).expect("load ItemVisuals");
let displays = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
let (mut carried, mut minus_one, mut resolved, mut models) = (0, 0, 0, 0);
let mut unshipped: std::collections::BTreeMap<i32, u32> = Default::default();
for (_, d) in displays.iter() {
if d.item_visual == 0 {
continue;
@@ -315,35 +322,74 @@ mod tests {
resolved += 1;
models += slots.iter().flatten().count();
}
None => assert_eq!(
d.item_visual, -1,
"the only unresolvable visual ids on the shipped table are -1"
),
None if d.item_visual == -1 => {}
None => *unshipped.entry(d.item_visual).or_default() += 1,
}
}
assert_eq!(carried, 365, "displays carrying a nonzero ItemVisuals id");
assert_eq!(minus_one, 5, "…of which five are the skipped -1");
assert_eq!(resolved, 360);
// 973 carrying rows where 5875 had 365, and 9 of the skipped -1 where it had 5: the realm
// put a glow on a great many more items than 1.12 did.
assert_eq!(carried, 973, "displays carrying a nonzero ItemVisuals id");
assert_eq!(minus_one, 9, "…of which nine are the skipped -1");
assert_eq!(resolved, 903);
assert_eq!(
models, 1588,
models, 4247,
"glow-model instances the shipped displays add up to"
);
// The band with no row behind it. `ItemVisuals.dbc` is base `patch.MPQ`'s 1.12 file, 34
// rows with ids `1..=134`; these 22 ids carry on past that end, which is where a later
// expansion's table continues — the realm backported the *displays* and not the visuals.
// The reference drops them exactly as we do (`0x479700` skips a visual id past the table's
// max id), so these 61 items wear no glow on the real client either. Pinned as a set, by
// id and by how many displays sit behind each, so a visuals row arriving later deletes its
// entry and a 23rd dangling id is read rather than absorbed.
assert_eq!(
unshipped.into_iter().collect::<Vec<_>>(),
vec![
(137, 1),
(138, 3),
(139, 3),
(140, 14),
(141, 4),
(142, 1),
(143, 6),
(144, 1),
(145, 4),
(146, 2),
(147, 1),
(148, 3),
(149, 2),
(150, 2),
(151, 2),
(152, 2),
(153, 2),
(154, 2),
(158, 1),
(161, 2),
(164, 2),
(166, 1),
],
"the realm's unshipped glow ids, and the displays behind each"
);
}
/// Every shipped field-22 value but one `-1` names an ItemVisuals row, which pins the column.
/// 109 enchants carry a glow where 5875 had 102.
#[test]
fn real_enchant_visuals_resolve() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let visuals = load_item_visual_catalog(&mut chain).expect("load ItemVisuals");
let enchants = load_enchant_catalog(&mut chain).expect("load SpellItemEnchantment");
assert_eq!(enchants.visual_count(), 102, "enchants carrying a visual");
assert_eq!(enchants.visual_count(), 109, "enchants carrying a visual");
let resolved = enchants
.iter_visuals()
.filter(|(_, v)| visuals.effects(*v).is_some())
.count();
assert_eq!(resolved, 101, "…all but the one -1 row resolve to a row");
// 108 of 109, still exactly the one `-1`: unlike the display side, every one of the
// realm's seven new glowing enchants names a row the shipped table actually has.
assert_eq!(resolved, 108, "…all but the one -1 row resolve to a row");
// Rockbiter 3 (enchant 1) → visual 61 → the slot-3 rock glow.
let rockbiter = visuals
+61 -21
View File
@@ -174,7 +174,9 @@ mod tests {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_sound_kit_catalog(&mut chain).expect("load sound kits");
assert_eq!(cat.len(), 4623, "all 5875 SoundEntries rows load");
// 8805 where 5875 shipped 4623: the realm nearly doubles the sound table. 29 fields
// either way, and a moved field count fails the load outright, so the decode is sound.
assert_eq!(cat.len(), 8805, "all 8805 SoundEntries rows load");
// Kit 3, decoded by hand from the file's bytes.
let kit = cat.get(3).expect("kit 3 exists");
@@ -225,14 +227,18 @@ mod tests {
}
assert_eq!(
(n[0], n[1], n[2]),
(2072, 2, 2549),
"the 5875 EAXDef census"
(6241, 4, 2560),
"the EAXDef census; 5875 read (2072, 2, 2549)"
);
// NPC voice lines (`SoundType` 17, which `NPCSounds.dbc` names) are all dry, so NPCs in a
// reverberant interior carry no echo.
let voices: Vec<_> = cat.kits.values().filter(|k| k.sound_type == 17).collect();
assert_eq!(voices.len(), 275, "the type-17 NPC voice rows");
assert_eq!(
voices.len(),
529,
"the type-17 NPC voice rows; 5875 had 275"
);
assert!(
voices.iter().all(|k| k.eax_def == 0),
"every NPC voice kit is authored dry"
@@ -269,10 +275,19 @@ mod tests {
);
}
/// Every kit path asked of the real chain: the reference's join resolves 8934 of 8961
/// variations, and the 27 it misses are dead in the reference too. An unconditional separator
/// would also silence kits 1519, 2988 and 3412, each one variation under a `DirectoryBase`
/// that ends in `\`.
/// Every kit path asked of the real chain. The realm ships a `SoundEntries.dbc` whose rows run
/// far past the audio it ships with them: 4764 of 16537 variation cells name a file the chain
/// has not got, spread over 286 directories of which 236 hold no file at all
/// (`Sound\Creature\LesserManaFiend`, `…\ChineseDragon`, `Sound\Music\ZoneMusic\BloodElf`),
/// and the dirs that do exist are missing TBC boss voices — Illidan, Akama, Lady Vashj,
/// Kael'thas, Maiev, Tyrande — plus 552 of `Sound\Doodad`. Playing a kit with no file behind it
/// is a no-op, here and on the real client.
///
/// What makes that an observation about the data rather than about our join is *where* the
/// misses fall: of the 2834 kits carrying a dead variation, 2832 have an id past 4623, the last
/// row 5875 shipped. A faulty separator would miss evenly across old rows and new.
/// An unconditional separator would also silence kits 1519, 2988 and 3412, each one variation
/// under a `DirectoryBase` that ends in `\`.
#[test]
fn real_sound_entries_paths_resolve_exactly_as_the_reference_s_do() {
let data = crate::wow_data_or_skip!();
@@ -290,37 +305,62 @@ mod tests {
}
}
dead.sort_unstable();
assert_eq!(total, 8961, "non-empty variation cells in the 5875 table");
assert_eq!(total, 16537, "non-empty variation cells; 5875 had 8961");
assert_eq!(
total - dead.len(),
8934,
"variations that resolve — the binary's own count; {dead:?}"
11773,
"variations that resolve; 5875 resolved 8934 of its 8961"
);
// Ten belong to kit 8940, silent in the reference too.
// The distribution is the decode's proof. 4623 was the last row 5875 shipped, so a kit
// above it is one the realm added; only two kits at or below it miss anything, and both
// missed in 5875 too — 395 `OgreFidget` wants an `mOgreFidget3.wav` that its directory
// (37 files present) has not got, and 3792 `WaterFallLarge` a `WaterFall2.wav`.
let affected: std::collections::BTreeSet<u32> = dead.iter().map(|(id, ..)| *id).collect();
assert_eq!(affected.len(), 2834, "kits with a dead variation");
assert_eq!(
affected
.iter()
.copied()
.filter(|&id| id <= 4623)
.collect::<Vec<_>>(),
vec![395, 3792],
"OgreFidget and WaterFallLarge are the only pre-realm kits that miss"
);
// Ten belong to kit 8940, and the quirk is ours to keep: the asset ships, and it is the
// authored leading `\` the reference emits that cannot reach it.
let ashbringer: Vec<_> = dead.iter().filter(|(id, ..)| *id == 8940).collect();
assert_eq!(ashbringer.len(), 10, "every ASH_SPEAK line misses");
assert!(
chain.contains("Sound\\Creature\\Ashbringer\\ASH_SPEAK_01.wav"),
"the asset ships — it is the authored path that cannot reach it"
);
assert!(
ashbringer
.iter()
.all(|(.., p)| p.starts_with("\\Sound\\Creature\\Ashbringer\\")),
"…and the leading separator is why: {ashbringer:?}"
);
// The other 17 are absent assets, in kits that mostly still play.
assert_eq!(dead.len() - ashbringer.len(), 17, "absent assets: {dead:?}");
// Only 8588, whose one variation is absent, and 8940 have nothing playable.
let silent: Vec<u32> = cat
// 2801 kits have no playable variation at all. Exactly one of them, 3792, predates the
// realm's additions, so nothing that used to make a sound has gone quiet.
let mut silent: Vec<u32> = cat
.kits
.values()
.filter(|k| !k.files.is_empty() && !k.files.iter().any(|(p, _)| chain.contains(p)))
.map(|k| k.id)
.collect();
let mut silent = silent;
silent.sort_unstable();
assert_eq!(silent.len(), 2801, "kits with nothing playable");
assert_eq!(
silent,
vec![8588, 8940],
"the only kits with no playable variation at all"
silent
.iter()
.copied()
.filter(|&id| id <= 4623)
.collect::<Vec<_>>(),
vec![3792],
"WaterFallLarge is the only pre-realm kit that is wholly silent"
);
}
}
@@ -216,8 +216,10 @@ fn real_spell_visual_chain_resolves_fireball() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_visual_catalog(&mut chain).expect("load SpellVisual/SpellVisualKit");
assert_eq!(cat.len(), 2165, "all 5875 SpellVisual rows load");
assert_eq!(cat.kit_len(), 1772, "all 5875 SpellVisualKit rows load");
// 2313 and 1969 where 5875 shipped 2165 and 1772; `dbc_diff` says purely additive — 148 and
// 197 new ids, none removed, 16 and 35 fields throughout.
assert_eq!(cat.len(), 2313, "all SpellVisual rows load");
assert_eq!(cat.kit_len(), 1969, "all SpellVisualKit rows load");
// Fireball, spell 133, is visual 67.
let stages = cat.stages(67).expect("Fireball's SpellVisual row");
@@ -439,8 +441,8 @@ fn real_chain_effects_table() {
let cat = load_spell_visual_catalog(&mut chain).expect("load spell visuals");
assert_eq!(
cat.chain_effect_len(),
18,
"18 SpellChainEffects rows in 5875"
21,
"21 SpellChainEffects rows; 5875 shipped 18"
);
let lightning = cat.chain_effect(1).expect("chain effect 1");
@@ -455,7 +457,7 @@ fn real_chain_effects_table() {
assert_eq!(lightning.bolt_life_ms, 1000);
assert_eq!(
lightning.bolt_stagger_ms, 300,
"id 1 is the one row at 300 ms — every other live row is 200"
"id 1 is the one row at 300 ms — every other row is 200, or the realm's all-zero 23"
);
assert_eq!(
@@ -475,10 +477,47 @@ fn real_chain_effects_table() {
"a drain rope is far straighter than a lightning arc"
);
// Ids are a lookup, never an index: 14 and 16 are absent from an otherwise 1..=20 run.
// Ids are a lookup, never an index: 14 and 16 are absent from an otherwise 1..=23 run.
assert!(cat.chain_effect(14).is_none() && cat.chain_effect(16).is_none());
assert!(cat.chain_effect(0).is_none(), "id 0 is the client's no-op");
assert!(cat.chain_effect(21).is_none());
assert!(cat.chain_effect(24).is_none());
// The realm's three, and unlike its sound rows the art ships with them (all three .blp files
// sit in patch-4 beside the table). 21 is a drain rope twice Drain Life's width, 22 scrolls
// *forward* and is the straightest beam in the table, and 23 zeroes every parameter the others
// animate — one still segment, no noise, no scroll, no bolt life.
let new: Vec<(u32, &str, f32, f32, f32, u32, u32)> = [21u32, 22, 23]
.iter()
.map(|&id| {
let c = cat.chain_effect(id).unwrap_or_else(|| panic!("row {id}"));
(
id,
c.texture.rsplit('\\').next().unwrap_or(""),
c.half_width,
c.noise_scale,
c.scroll_period_s,
c.bolt_life_ms,
c.bolt_stagger_ms,
)
})
.collect();
assert_eq!(
new,
vec![
(21, "HarvestBeam.blp", 0.5, 0.01, -0.5, 1000, 200),
(22, "SpiritLinkBeam.blp", 0.45, 0.001, 0.6, 1000, 200),
(23, "Beam_ChainIron_NoAlpha.blp", 0.25, 0.0, 0.0, 0, 0),
]
);
for id in [21u32, 22, 23] {
let tex = &cat.chain_effect(id).expect("row").texture;
assert!(chain.contains(tex), "row {id}: {tex} must ship");
}
// Every one of the three keeps 1.12's segment length, so they are authored against the same
// tessellation the reference's beam builder assumes.
assert!([21u32, 22, 23]
.iter()
.all(|&id| cat.chain_effect(id).expect("row").avg_seg_len == lightning.avg_seg_len));
}
/// Both chain keys reach their beams; folding type 0 to empty would lose every channel beam.
@@ -554,10 +593,12 @@ fn real_chain_procs_resolve_to_their_beams() {
}
}
}
// 5875 read (68, 48, 20). The realm's new kits add 3 live beams — one per new chain row — and
// 39 more padding slots, which is what a block of kits authored off a template looks like.
assert_eq!(
(slots, live, padding),
(68, 48, 20),
"68 chain CharProc slots ship: 48 live beams, 20 zero-param padding"
(110, 51, 59),
"110 chain CharProc slots ship: 51 live beams, 59 zero-param padding"
);
}
@@ -115,7 +115,8 @@ mod tests {
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_vocal_ui_sounds(&mut chain).expect("load VocalUISounds");
let kits = crate::load_sound_kit_catalog(&mut chain).expect("load SoundEntries");
assert_eq!(cat.len(), 533, "every shipped row is under the 0x44 bound");
// 688 rows where 5875 had 533, still every one under the 0x44 line bound.
assert_eq!(cat.len(), 688, "every shipped row is under the 0x44 bound");
let lines: std::collections::BTreeSet<u32> = cat.rows().iter().map(|r| r.line).collect();
assert_eq!(lines.iter().copied().max(), Some(67));
@@ -188,6 +189,7 @@ mod tests {
.flatten()
.filter(|k| kits.get(*k).is_some())
.count();
assert_eq!(normal_live, 785);
// 1085 where 5875 had 785.
assert_eq!(normal_live, 1085);
}
}