diff --git a/crates/benilla-formats/examples/dbc_shapes.rs b/crates/benilla-formats/examples/dbc_shapes.rs new file mode 100644 index 00000000..2a45cce3 --- /dev/null +++ b/crates/benilla-formats/examples/dbc_shapes.rs @@ -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 = 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(()) +} diff --git a/crates/benilla-formats/src/anim_data.rs b/crates/benilla-formats/src/anim_data.rs index 1f0dcdde..eb45ecef 100644 --- a/crates/benilla-formats/src/anim_data.rs +++ b/crates/benilla-formats/src/anim_data.rs @@ -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!( diff --git a/crates/benilla-formats/src/area_poi.rs b/crates/benilla-formats/src/area_poi.rs index e5b4a6d1..a970b691 100644 --- a/crates/benilla-formats/src/area_poi.rs +++ b/crates/benilla-formats/src/area_poi.rs @@ -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}" diff --git a/crates/benilla-formats/src/area_sound.rs b/crates/benilla-formats/src/area_sound.rs index 8aed2517..6d75c208 100644 --- a/crates/benilla-formats/src/area_sound.rs +++ b/crates/benilla-formats/src/area_sound.rs @@ -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"); diff --git a/crates/benilla-formats/src/area_trigger.rs b/crates/benilla-formats/src/area_trigger.rs index 787872b1..da04d6bd 100644 --- a/crates/benilla-formats/src/area_trigger.rs +++ b/crates/benilla-formats/src/area_trigger.rs @@ -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)); } } diff --git a/crates/benilla-formats/src/cinematics.rs b/crates/benilla-formats/src/cinematics.rs index f20c6e8a..4b30545c 100644 --- a/crates/benilla-formats/src/cinematics.rs +++ b/crates/benilla-formats/src/cinematics.rs @@ -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}")); diff --git a/crates/benilla-formats/src/creature_families.rs b/crates/benilla-formats/src/creature_families.rs index 3f839de4..005502b8 100644 --- a/crates/benilla-formats/src/creature_families.rs +++ b/crates/benilla-formats/src/creature_families.rs @@ -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" ); } diff --git a/crates/benilla-formats/src/emotes.rs b/crates/benilla-formats/src/emotes.rs index 99ff886a..250d8166 100644 --- a/crates/benilla-formats/src/emotes.rs +++ b/crates/benilla-formats/src/emotes.rs @@ -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 = { + let mut v: Vec = 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] diff --git a/crates/benilla-formats/src/exhaustion.rs b/crates/benilla-formats/src/exhaustion.rs index b77f0ef6..8906d827 100644 --- a/crates/benilla-formats/src/exhaustion.rs +++ b/crates/benilla-formats/src/exhaustion.rs @@ -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)); diff --git a/crates/benilla-formats/src/game_tips.rs b/crates/benilla-formats/src/game_tips.rs index aab28d94..52a342fa 100644 --- a/crates/benilla-formats/src/game_tips.rs +++ b/crates/benilla-formats/src/game_tips.rs @@ -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" ); } diff --git a/crates/benilla-formats/src/gm_ticket_category.rs b/crates/benilla-formats/src/gm_ticket_category.rs index f33c1a4e..a2c22563 100644 --- a/crates/benilla-formats/src/gm_ticket_category.rs +++ b/crates/benilla-formats/src/gm_ticket_category.rs @@ -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"); } }