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
263 lines
9.6 KiB
Rust
263 lines
9.6 KiB
Rust
//! `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(())
|
|
}
|