Files
benilla-octo/crates/benilla-formats/examples/dbc_diff.rs
T
gromandClaude Opus 5 e61976bc0e formats: the world-map goldens, and a nondeterministic continent lookup
The four map tables against the realm's own files. One real bug, and three
findings that are the realm's data rather than ours.

**`WorldMapArea::continent` was a coin flip.** It `find`s the `area_id == 0`
row for a map id, and 5875 had exactly one per map so order never mattered. The
realm ships two for map 0 — 14 "Azeroth" and its own 694 "World" — and the
lookup iterated the `by_id` `HashMap`, whose order is seeded per process. So
`continent(0)` answered Azeroth or World depending on the run. Now it walks
file order, which is the reference's own walk and picks 1.12's row; the test
asserts 14 and passes eight runs in a row where it used to flip.

- **WorldMapArea** 51 -> 132 rows loaded, of 175 in the file. The 43 dropped are
  dropped on purpose: `AreaName` empty, `AreaID` 0, the whole rect zero, one per
  instance map (33 Shadowfang through 533 Naxxramas, then the realm's 800s). An
  instance has no continent art — but `AreaID == 0` is exactly what
  `ui_world_map.rs:146` reads as "this row is a continent", so keeping them
  would put 43 unnamed continents in `GetMapContinents()`. `str_at` folds an
  empty string to `None` and the row falls out; the test now pins that as
  deliberate. The realm's third *named* continent is 694 "World", a second sheet
  over map 0 whose art ships (patch-M) but whose four rect columns are all zero
  — the only named row like that. `map_proj::zone_uv` already refuses a zero
  span and answers 0.0 instead of dividing, so that sheet puts a blip in the
  corner rather than NaN.

- **WorldMapContinent** 2 -> 3 rows. The new row's `MapID` is 42, which the same
  patch *deleted* from `Map.dbc` along with the rest of 1.12's dev test maps
  (13 "Testing", 25 "Scott Test", 29 "CashTest", 37 "Azshara Crater", 42
  "Collin's Test", 44 "<unused> Monastery"). It is inert rather than broken: the
  continent list comes from `WorldMapArea`, and this table is only ever reached
  by `wmc.get(map_id)` for maps 0 and 1, so nothing asks for continent 42.

- **The zone bitmaps are repainted.** `Azeroth.zmp` and `Kalimdor.zmp` have the
  realm's own copies in patch-3 and patch-4, and every id they write is a
  **zone** where 1.12 wrote sub-areas and left the client to roll them up.
  Azeroth: 1874 labelled cells over 34 distinct ids, all zone-level, against
  2630 over 334 of which 310 were sub-areas (Northshire Valley, Crystal Lake,
  Fargodeep Mine). After the one-hop rollup `ui_world_map.rs:193` applies, 1280
  cells agree, 534 name a *different* zone, 816 lost a zone and 60 gained one —
  a redrawn map, not a mechanical flattening. Nothing of ours needed changing:
  the rollup is a no-op on an already-flat grid. The test asserts the structural
  claim behind the number (every labelled cell is a top-level zone), which 1.12's
  own file would fail, so a decode slip cannot hide inside a re-recorded count.

- **WorldMapOverlay** 526 -> 707, and not an extension: 360 ids added, 179
  removed, 347 survivors given a different texture and rect. None of this
  table's numbering is 1.12's, so only the joins are worth asserting.

`dbc_diff` gained `--row=N`, which dumps a row from the winning copy *and* the
baseline — the only way to read a row the realm deleted, which is how the six
test maps above got their names.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 19:20:54 -06:00

268 lines
9.8 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)?;
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<String> = 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<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(())
}