Eleven data goldens, and the gesture scan's order now matters
First batch of the benilla-formats goldens, all of them the realm's tables being bigger or retuned rather than anything misread. The safety check came first and is worth recording: **every `*_FIELDS` constant in the crate still matches its live DBC header** — Spell 173, AreaTable 25, CreatureFamily 18, Talent 21, Lock 33 and the rest — so no decoder is reading a moved layout, which is the only way one of these counts could have been a defect rather than data. Added `examples/dbc_shapes` to read that off the chain in one go. Counts: `AnimationData` 208 → 227, `AreaPOI` 339 → 483, `AreaTrigger` 432 → 530 (172/144 across the two continents, from 133/121), `AreaTable` 1081 → 1481, `GameTips` 74 → 95, cinematics 10 → 13 sequences and cameras both. Three that are more than a count: - **Rested experience is ×1.5 here, not ×2.** `Exhaustion.dbc` row 1 is where the client reads the multiplier it shows, and the realm retuned it. Every vanilla guide quotes 2.0. - **The help window offers two ticket categories, not ten** — "report someone's behavior" and "an issue with my character" replace 1.12's Stuck/Guild/Item/… list. - **Warlock minion pet icons are blank.** `CreatureFamily` rows 15-19, 23, 28, 29 carry no icon where 1.12 gave each one a path, so `GetPetIcon()` answers nil for a minion and an addon that concatenates it unguarded gets nothing. Not a column slip: every hunter family in the same column answers a real path. The realm also backported the TBC families (Dragonhawk, Ravager, Warp Stalker, Sporebat, Nether Ray, Serpent), added four of its own (Fox, Plagued, Horse, Moth) and filled two minion ids, Incubus 18 and Felguard 29 — 35 families where 5875 had 23. And `Emotes.dbc`'s gesture scan, which was pinned on a premise that has stopped holding: 1.12 had one row per gesture bit, so the walk order could not matter. The realm's file has three rows carrying `0x8` and a row (424) carrying `0x8|0x10|0x20`, so it matters now — and **our scan is already right**, which the disassembly settles rather than luck. `0x603a60` walks the table backwards (`imul $0x1c` to the end, `sub $0x1c` per step) and overwrites each slot, so the earliest record survives; and its bit test is an if/else-if chain, so one row fills at most the slot of its first set bit. Ours walks forward keeping the first, with `position` for the chain — the same table. The test now asserts that rule, re-derived from the file, instead of counting carriers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
//! Every `DBFilesClient/*.dbc` the player's chain holds, with its header shape.
|
||||
//!
|
||||
//! A DBC header is `WDBC`, then record count, field count, record size and string-block size
|
||||
//! (`0x4c7c20` reads it). The **field count is the layout**: a decoder keyed to a column index
|
||||
//! reads the wrong column the moment it moves, and that is a defect. The record count is just how
|
||||
//! much data the realm ships, and a golden that pins one is a canary, not a contract.
|
||||
//!
|
||||
//! `cargo run -q -p benilla-formats --example dbc_shapes`
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let data = benilla_formats::wow_data()
|
||||
.ok_or_else(|| anyhow::anyhow!("no client data (set WOW_DATA)"))?;
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
let mut names: Vec<String> = chain
|
||||
.list_under("DBFilesClient")?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.filter(|f| f.to_ascii_lowercase().ends_with(".dbc"))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
println!(
|
||||
"{:<44} {:>8} {:>7} {:>8} {:>9}",
|
||||
"dbc", "records", "fields", "recsize", "strings"
|
||||
);
|
||||
for name in names {
|
||||
let Ok(bytes) = chain.read_file(&name) else {
|
||||
continue;
|
||||
};
|
||||
if bytes.len() < 20 || &bytes[..4] != b"WDBC" {
|
||||
continue;
|
||||
}
|
||||
let u = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap());
|
||||
let leaf = name.rsplit(['\\', '/']).next().unwrap_or(&name);
|
||||
println!(
|
||||
"{:<44} {:>8} {:>7} {:>8} {:>9}",
|
||||
leaf,
|
||||
u(4),
|
||||
u(8),
|
||||
u(12),
|
||||
u(16)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,7 +114,9 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_anim_data_catalog(&mut chain).expect("load AnimationData");
|
||||
assert_eq!(cat.len(), 208);
|
||||
// 227, where 1.12 shipped 208: the realm adds animations. Its `AnimationData.dbc` is
|
||||
// still 7 fields wide, which is the half a decoder depends on.
|
||||
assert_eq!(cat.len(), 227);
|
||||
|
||||
for (&id, row) in &cat.rows {
|
||||
assert!(
|
||||
|
||||
@@ -127,7 +127,8 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_area_poi_catalog(&mut chain).expect("load AreaPOI");
|
||||
assert_eq!(cat.len(), 339, "all 339 rows load");
|
||||
// 483, where 1.12 shipped 339: the realm adds points of interest. 29 fields either way.
|
||||
assert_eq!(cat.len(), 483, "all 483 rows load");
|
||||
|
||||
let mut nonempty_names = 0;
|
||||
let mut worldstate_nonzero = 0;
|
||||
@@ -145,7 +146,7 @@ mod tests {
|
||||
worldstate_nonzero += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(nonempty_names, 339, "every row has a readable enUS name");
|
||||
assert_eq!(nonempty_names, 483, "every row has a readable enUS name");
|
||||
assert!(
|
||||
worldstate_nonzero >= 100,
|
||||
"a meaningful share of rows carry a live WorldStateID: {worldstate_nonzero}"
|
||||
|
||||
@@ -315,7 +315,9 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_area_sound_catalog(&mut chain).expect("load area-sound catalog");
|
||||
assert_eq!(cat.len(), 1081, "all AreaTable rows load");
|
||||
// 1481 `AreaTable` rows, where 1.12 shipped 1081 — the realm's own zones and subzones.
|
||||
// 25 fields either way.
|
||||
assert_eq!(cat.len(), 1481, "all AreaTable rows load");
|
||||
|
||||
let elwynn = cat.resolve(12).expect("Elwynn resolves");
|
||||
assert_eq!(elwynn.area_name, "Elwynn Forest");
|
||||
|
||||
@@ -203,7 +203,9 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_area_trigger_catalog(&mut chain).expect("load AreaTrigger");
|
||||
assert_eq!(cat.len(), 432, "5875 ships 432 area triggers");
|
||||
// 530, where 5875 shipped 432: the realm adds triggers for its own content. 10 fields
|
||||
// either way, so the decode is reading the same layout.
|
||||
assert_eq!(cat.len(), 530, "the realm ships 530 area triggers");
|
||||
|
||||
// The Darnassus portal pair, 10-yd spheres on Kalimdor: vmangos's `areatrigger_teleport`
|
||||
// sends 527 to (8785.79, 966.98, 30.20), trigger 542's position here, and 542 to 527's.
|
||||
@@ -230,6 +232,8 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(cat.on_map(1).iter().all(|t| t.map_id == 1));
|
||||
assert_eq!((cat.on_map(0).len(), cat.on_map(1).len()), (133, 121));
|
||||
// The realm's new triggers land on both continents: 172 on Eastern Kingdoms and 144 on
|
||||
// Kalimdor, where 5875 had 133 and 121.
|
||||
assert_eq!((cat.on_map(0).len(), cat.on_map(1).len()), (172, 144));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,8 +333,10 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_cinematics(&mut chain).expect("load cinematic tables");
|
||||
assert_eq!(cat.sequence_count(), 10);
|
||||
assert_eq!(cat.camera_count(), 10);
|
||||
// 13 of each, where 1.12 shipped 10: the realm adds cinematics of its own, and adds a
|
||||
// camera per sequence, so the two tables stay in step.
|
||||
assert_eq!(cat.sequence_count(), 13);
|
||||
assert_eq!(cat.camera_count(), 13);
|
||||
|
||||
for id in RACE_INTROS {
|
||||
let seq = cat.sequence(id).unwrap_or_else(|| panic!("sequence {id}"));
|
||||
|
||||
@@ -232,15 +232,31 @@ mod tests {
|
||||
fams.icon(1),
|
||||
Some("Interface\\Icons\\Ability_Hunter_Pet_Wolf")
|
||||
);
|
||||
// A warlock minion has one too, for `GetPetIcon`.
|
||||
assert!(fams
|
||||
.icon(15)
|
||||
.is_some_and(|i| i.starts_with("Interface\\Icons\\")));
|
||||
|
||||
for id in 1..=27 {
|
||||
// A warlock minion has **none** on this realm, where 1.12 gave each one an icon. That is
|
||||
// the data and not a column slip: every hunter family in the same column answers a real
|
||||
// path, and the file is still 18 fields wide. The consequence is worth naming —
|
||||
// `GetPetIcon()` answers nil for a minion here, so an addon that concatenates it without a
|
||||
// guard gets nothing rather than an icon.
|
||||
const MINIONS: [u32; 8] = [15, 16, 17, 18, 19, 23, 28, 29];
|
||||
for id in MINIONS {
|
||||
let Some(f) = fams.get(id) else { continue };
|
||||
assert_eq!(
|
||||
fams.icon(id),
|
||||
None,
|
||||
"minion {id} ({}) carries an icon",
|
||||
f.name
|
||||
);
|
||||
}
|
||||
|
||||
// Every other shipped family does have one, across the whole of the realm's range.
|
||||
for id in 1..=40 {
|
||||
let Some(f) = fams.get(id) else { continue };
|
||||
if MINIONS.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
fams.icon(id).is_some(),
|
||||
fams.icon(id)
|
||||
.is_some_and(|i| i.to_ascii_lowercase().starts_with("interface\\icons\\")),
|
||||
"family {id} ({}) has no icon",
|
||||
f.name
|
||||
);
|
||||
@@ -304,7 +320,11 @@ mod tests {
|
||||
fn the_real_creature_families_name_every_pet() {
|
||||
let Some(mut chain) = chain() else { return };
|
||||
let f = load_creature_families(&mut chain).expect("load CreatureFamily.dbc");
|
||||
assert_eq!(f.len(), 23, "5875 ships 23 creature families");
|
||||
// 35, where 5875 shipped 23: the realm backported the TBC pet families (Dragonhawk,
|
||||
// Ravager, Warp Stalker, Sporebat, Nether Ray, Serpent) and added four of its own (Fox,
|
||||
// Plagued, Horse, Moth), plus two warlock minions, Incubus (18) and Felguard (29). The
|
||||
// ids are not contiguous either way — 10, 13, 14 and 22 are holes in both files.
|
||||
assert_eq!(f.len(), 35, "the realm ships 35 creature families");
|
||||
|
||||
assert_eq!(f.name(1), Some("Wolf"));
|
||||
assert_eq!(f.name(2), Some("Cat"));
|
||||
@@ -316,8 +336,12 @@ mod tests {
|
||||
assert_eq!(f.name(19), Some("Doomguard"));
|
||||
assert_eq!(f.name(23), Some("Imp"));
|
||||
|
||||
// The id gaps: a dense 1..=23 read would shift half the table.
|
||||
for missing in [10, 13, 14, 18, 22] {
|
||||
// The realm filled 18 with Incubus, so the gaps are three rather than four.
|
||||
assert_eq!(f.name(18), Some("Incubus"));
|
||||
assert_eq!(f.name(29), Some("Felguard"));
|
||||
|
||||
// The id gaps that remain: a dense 1..=35 read would shift half the table.
|
||||
for missing in [10, 13, 14, 22] {
|
||||
assert_eq!(f.name(missing), None, "id {missing} is not in the file");
|
||||
}
|
||||
assert_eq!(f.name(0), None);
|
||||
@@ -382,8 +406,10 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
empty,
|
||||
[15, 16, 17, 19, 23, 28],
|
||||
"Felhunter/Voidwalker/Succubus/Doomguard/Imp + Remote Control — no tameable beast"
|
||||
[15, 16, 17, 18, 19, 23, 28, 29],
|
||||
"the six 1.12 rows with no diet plus the realm's two new minions, Incubus (18) and \
|
||||
Felguard (29) — and still no tameable beast among them, which is what matters: the \
|
||||
diet tooltip formats `BuildListString()`'s nil for an empty list and errors"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -212,15 +212,61 @@ mod tests {
|
||||
assert_eq!(cat.gesture(slot), Some(emote_id), "{what} slot -> emote id");
|
||||
assert_eq!(cat.anim(emote_id), Some(anim_id), "{what} emote -> AnimID");
|
||||
}
|
||||
// One shipped row per bit, so the walk order cannot matter.
|
||||
for bit in GESTURE_FLAG_BITS {
|
||||
let carriers = cat
|
||||
.emote_flags
|
||||
// The walk order **does** matter here, where on 1.12 it could not: that file had one row
|
||||
// per bit and the realm's has several. Bit `0x8` is carried by ids 1, 412 and 424, `0x10`
|
||||
// by 6 and 424, `0x20` by 5 and 424. So this asserts the rule rather than the tally, and
|
||||
// the rule is the reference's own two halves (`0x603a60`):
|
||||
//
|
||||
// 1. The bit test is an **if/else-if chain**, so a row fills at most one slot — the slot
|
||||
// of its *first* set bit. Row 424 carries `0x8|0x10|0x20` and competes for slot 0 only.
|
||||
// 2. The scan walks the table **backwards** (`imul $0x1c` to the end, then `sub $0x1c`
|
||||
// per step) and **overwrites** the slot on each match, so the surviving write is the
|
||||
// earliest record. Ours walks forward and keeps the first, which is the same table.
|
||||
let first_bit = |flags: u32| GESTURE_FLAG_BITS.iter().position(|b| flags & b != 0);
|
||||
for (slot, bit) in GESTURE_FLAG_BITS.iter().enumerate() {
|
||||
let carriers: Vec<u32> = {
|
||||
let mut v: Vec<u32> = cat
|
||||
.emote_flags
|
||||
.iter()
|
||||
.filter(|(_, f)| *f & bit != 0)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
};
|
||||
assert!(
|
||||
!carriers.is_empty(),
|
||||
"no shipped row carries {bit:#x} at all"
|
||||
);
|
||||
// Only the rows this bit is *first* for are candidates, and the lowest id of those
|
||||
// wins. A row whose earlier bit is set belongs to that earlier slot instead.
|
||||
let want = carriers
|
||||
.iter()
|
||||
.filter(|(_, f)| *f & bit != 0)
|
||||
.count();
|
||||
assert_eq!(carriers, 1, "exactly one shipped row carries {bit:#x}");
|
||||
.copied()
|
||||
.find(|id| cat.emote_flags.get(id).copied().and_then(first_bit) == Some(slot))
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{bit:#x} has carriers {carriers:?} but none of them \
|
||||
reach slot {slot}, so the slot would be filled by no row"
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
cat.gesture(slot),
|
||||
Some(want),
|
||||
"slot {slot} ({bit:#x}) must be the lowest id this bit is first for; \
|
||||
carriers {carriers:?}"
|
||||
);
|
||||
}
|
||||
// And the multi-bit row really is attributed to one slot only.
|
||||
assert_eq!(
|
||||
cat.emote_flags.get(&424).copied().and_then(first_bit),
|
||||
Some(0),
|
||||
"424 carries 0x8|0x10|0x20 and belongs to slot 0 alone"
|
||||
);
|
||||
assert!(
|
||||
(1..GESTURE_FLAG_BITS.len()).all(|s| cat.gesture(s) != Some(424)),
|
||||
"…so it must not have filled a later slot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -66,7 +66,15 @@ mod tests {
|
||||
.iter()
|
||||
.map(|r| (r.id, (r.name.as_str(), r.factor)))
|
||||
.collect();
|
||||
assert_eq!(by_id[&1], ("Rested", 2.0), "the ×2 is this row's data");
|
||||
// **1.5, not 1.12's 2.0.** The realm retuned rested experience, and this row is where
|
||||
// the client reads the multiplier it shows — so our figure follows the realm's rather than
|
||||
// the one every vanilla guide quotes. Data, not a decode slip: the file is still 15 fields
|
||||
// wide and the other four rows are unchanged.
|
||||
assert_eq!(
|
||||
by_id[&1],
|
||||
("Rested", 1.5),
|
||||
"the realm's rested multiplier is this row's data"
|
||||
);
|
||||
assert_eq!(by_id[&2], ("Normal", 1.0));
|
||||
assert_eq!(by_id[&3], ("XXXTired", 1.0), "beta tier, never sent");
|
||||
assert_eq!(by_id[&4], ("XXXTired", 0.5));
|
||||
|
||||
@@ -70,7 +70,8 @@ mod tests {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_game_tips(&mut chain).expect("load GameTips");
|
||||
assert_eq!(cat.len(), 74, "the 5875 enGB chain ships 74 tips");
|
||||
// 95, where the 5875 enGB chain shipped 74: the realm adds tips of its own.
|
||||
assert_eq!(cat.len(), 95, "the realm's enGB chain ships 95 tips");
|
||||
assert!(
|
||||
cat.get(0).unwrap().starts_with("|cffffd100"),
|
||||
"the colour prefix is authored INTO the data, not added by the renderer: {:?}",
|
||||
@@ -82,7 +83,7 @@ mod tests {
|
||||
cat.get(0)
|
||||
);
|
||||
assert!(
|
||||
cat.get(74).is_none(),
|
||||
cat.get(95).is_none(),
|
||||
"one past the end is None, not a panic"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,22 +86,17 @@ mod tests {
|
||||
.iter()
|
||||
.map(|c| (c.id, c.name.as_str()))
|
||||
.collect();
|
||||
// The realm replaced 1.12's ten categories with two, so the help window offers a
|
||||
// behaviour report and a character issue and nothing else. The ids are still what the
|
||||
// ticket goes out on, and the order is still what the window paints.
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
(1, "Stuck"),
|
||||
(2, "Behavior/Harassment"),
|
||||
(3, "Guild"),
|
||||
(4, "Item"),
|
||||
(5, "Environmental"),
|
||||
(6, "Non-Quest/Creep"),
|
||||
(7, "Quest/Quest NPC"),
|
||||
(8, "Technical"),
|
||||
(9, "Account/Billing"),
|
||||
(10, "Character"),
|
||||
(1, "I want to report someone's behavior"),
|
||||
(2, "I have an issue with my character"),
|
||||
],
|
||||
"the wire ids and the painted order both matter — see the module doc"
|
||||
);
|
||||
assert_eq!(cat.len(), 10, "the whole shipped table");
|
||||
assert_eq!(cat.len(), 2, "the whole shipped table");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user