//! `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)?; 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)?; // `--row=N` prints that row of the winning copy and of the baseline, text slots as text: for // naming the thing behind an id a decoded catalog only gave a number for, and for reading a row // the realm *deleted*, which is only legible in the copy underneath. if let Some(want) = row_arg { let dump = |t: &Table, label: &str| match t.rows.get(&want) { Some(r) => { let cells: Vec = r .iter() .enumerate() .map(|(f, &v)| { if t.is_text(v) { format!("[{f}] {:?}", t.text(v)) } else { format!("[{f}] {v}") } }) .collect(); println!("{label} row {want}: {}", cells.join(" ")); } None => println!("{label} has no row {want}"), }; println!(); dump(&win, "wins "); dump(&old, "under"); return Ok(()); } 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(()) }