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
268 lines
9.8 KiB
Rust
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(())
|
|
}
|