diff --git a/crates/benilla-formats/examples/dbc_diff.rs b/crates/benilla-formats/examples/dbc_diff.rs new file mode 100644 index 00000000..c19be638 --- /dev/null +++ b/crates/benilla-formats/examples/dbc_diff.rs @@ -0,0 +1,262 @@ +//! `cargo run -p benilla-formats --example dbc_diff -- ` — 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=` 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>, + strings: Vec, +} + +/// 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 { + 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 = (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("") + } + + /// 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 [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 = args.collect(); + let base_arg = rest + .iter() + .find_map(|a| a.strip_prefix("--base=").map(str::to_string)); + let row_arg: Option = rest + .iter() + .find_map(|a| a.strip_prefix("--row=").and_then(|n| n.parse().ok())); + let only: BTreeSet = 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
{ + 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 = 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 = win + .rows + .keys() + .filter(|k| !old.rows.contains_key(k)) + .copied() + .collect(); + let gone: Vec = 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)> = Vec::new(); + let mut per_slot: BTreeMap = 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 = 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(()) +} diff --git a/crates/benilla-formats/src/creature_sound.rs b/crates/benilla-formats/src/creature_sound.rs index c52d0846..0b13566c 100644 --- a/crates/benilla-formats/src/creature_sound.rs +++ b/crates/benilla-formats/src/creature_sound.rs @@ -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 = 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), diff --git a/crates/benilla-formats/src/item_sounds.rs b/crates/benilla-formats/src/item_sounds.rs index 19b70415..60b9e4b6 100644 --- a/crates/benilla-formats/src/item_sounds.rs +++ b/crates/benilla-formats/src/item_sounds.rs @@ -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"); } } diff --git a/crates/benilla-formats/src/item_visuals.rs b/crates/benilla-formats/src/item_visuals.rs index d551a3cd..06a56c0b 100644 --- a/crates/benilla-formats/src/item_visuals.rs +++ b/crates/benilla-formats/src/item_visuals.rs @@ -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 = 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![ + (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 diff --git a/crates/benilla-formats/src/sound_entries.rs b/crates/benilla-formats/src/sound_entries.rs index df1a691b..6c1a1c75 100644 --- a/crates/benilla-formats/src/sound_entries.rs +++ b/crates/benilla-formats/src/sound_entries.rs @@ -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 = 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![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 = 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 = 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![3792], + "WaterFallLarge is the only pre-realm kit that is wholly silent" ); } } diff --git a/crates/benilla-formats/src/spell_visual/tests.rs b/crates/benilla-formats/src/spell_visual/tests.rs index 21d837ff..980f5e1a 100644 --- a/crates/benilla-formats/src/spell_visual/tests.rs +++ b/crates/benilla-formats/src/spell_visual/tests.rs @@ -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" ); } diff --git a/crates/benilla-formats/src/vocal_ui_sounds.rs b/crates/benilla-formats/src/vocal_ui_sounds.rs index d092b758..955658f3 100644 --- a/crates/benilla-formats/src/vocal_ui_sounds.rs +++ b/crates/benilla-formats/src/vocal_ui_sounds.rs @@ -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 = 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); } }