diff --git a/crates/benilla-formats/src/characters/customization.rs b/crates/benilla-formats/src/characters/customization.rs index a4d3307f..036d8612 100644 --- a/crates/benilla-formats/src/characters/customization.rs +++ b/crates/benilla-formats/src/characters/customization.rs @@ -609,9 +609,21 @@ mod tests { ); } } + // Dwarf-Mage *is* offered here, and this assertion used to say the opposite only because + // the gate below had nothing to fire on. [`CharCreateCatalog::chain_is_stock`] calls a + // chain stock when no playable race exceeds 8, so 1.12's nine races read as stock and the + // `UNUSED_COMBOS` skip applied; the realm's tenth race, High Elf, makes it patched and the + // skip correctly stops. The skip only ever modelled a compare in the *stock executable* + // (`0x4706b0`), and this install's own `WoW.exe` has none — the whole builder holds no + // immediate compare against 3 or 8 and gates on a `0xFE` sentinel (`cmpb $0xfe,0x1(%ecx)` + // at `0x4706e8`) instead. So the realm genuinely sells Dwarf Mages. assert!( - !cat.classes_for_race(3).contains(&8), - "the dead Dwarf-Mage row must not reach the create screen, patched chain or not" + cat.playable_races().contains(&10), + "the tenth race is what makes this chain non-stock" + ); + assert!( + cat.classes_for_race(3).contains(&8), + "Dwarf-Mage reaches the create screen on a patched chain" ); // The glue tokens against the shipped rows, pinning cols 26/27's male/female order; no diff --git a/crates/benilla-formats/src/characters/geosets.rs b/crates/benilla-formats/src/characters/geosets.rs index 7a35f523..f32607eb 100644 --- a/crates/benilla-formats/src/characters/geosets.rs +++ b/crates/benilla-formats/src/characters/geosets.rs @@ -45,6 +45,29 @@ pub struct EquipGeosets { pub tabard_preview: bool, } +/// `CharHairGeosets.dbc` as `(race, sex, variation) -> GeosetID`, first row per key. +/// +/// Split out from [`CharacterGeosets::load`] so the first-row-wins rule can be tested on bytes. +/// 1.12 shipped the goblin male's variation 0 four times (geosets 1, 2, 1, 2) and that was the one +/// duplicated key in the file; the realm re-authored the whole table — 299 rows against 148, 136 +/// of them rewritten — and no shipped key repeats any more. The rule still has to hold, so it is +/// covered synthetically rather than by whatever the data happens to contain. +fn hair_geosets_from_bytes(bytes: &[u8]) -> Result> { + let rs = parse(bytes, char_hair_geosets_schema(), "CharHairGeosets")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + // fields: ID, RaceID, SexID, VariationID(hairStyle), GeosetID, ShowScalp. + if let (Some(race), Some(sex), Some(var), Some(geoset)) = + (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) + { + // The first row wins. + m.entry((race as u8, sex as u8, var as u8)) + .or_insert(geoset); + } + } + Ok(m) +} + impl CharacterGeosets { /// The geosets (`skinSectionId`s) this appearance draws, sorted and deduplicated: the naked /// set, then the eight equipment branches B1–B8 of `0x477520`. @@ -169,19 +192,7 @@ impl CharacterGeosets { let bytes = chain .read_file(CHAR_HAIR_GEOSETS) .with_context(|| format!("reading {CHAR_HAIR_GEOSETS}"))?; - let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets")?; - let mut m = HashMap::with_capacity(rs.records().len()); - for r in rs.records() { - // fields: ID, RaceID, SexID, VariationID(hairStyle), GeosetID, ShowScalp. - if let (Some(race), Some(sex), Some(var), Some(geoset)) = - (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) - { - // The first row wins. - m.entry((race as u8, sex as u8, var as u8)) - .or_insert(geoset); - } - } - m + hair_geosets_from_bytes(&bytes)? }; let facial = { let bytes = chain @@ -539,14 +550,59 @@ mod tests { assert!(set.contains(&1), "max(1, 0) = scalp geoset 1"); } - /// The shipped CharHairGeosets repeats only the goblin male's one style, and the first row, - /// geoset 1, the bare scalp, wins; geoset 2 adds an untextured topknot. + /// A `CharHairGeosets.dbc` built by hand, with one key listed four times exactly as 1.12 + /// listed the goblin male's: the first row wins and the rest are dropped. + /// + /// This is the synthetic half of a rule the shipped data no longer exercises — the realm's + /// re-authored table has no duplicated key at all — so without this the loader's `or_insert` + /// could be flipped to `insert` and nothing would notice. #[test] - fn goblin_male_hair_takes_the_first_of_four_duplicate_rows() { + fn a_repeated_key_takes_its_first_row() { + /// `ID, RaceID, SexID, VariationID, GeosetID, ShowScalp`, six u32 columns. + fn dbc(rows: &[[u32; 6]]) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"WDBC"); + b.extend_from_slice(&(rows.len() as u32).to_le_bytes()); + b.extend_from_slice(&6u32.to_le_bytes()); + b.extend_from_slice(&24u32.to_le_bytes()); + b.extend_from_slice(&1u32.to_le_bytes()); + for r in rows { + for v in r { + b.extend_from_slice(&v.to_le_bytes()); + } + } + b.push(0); + b + } + + // 1.12's goblin-male block: race 9, sex 0, variation 0, geosets 1, 2, 1, 2. + let m = hair_geosets_from_bytes(&dbc(&[ + [1, 9, 0, 0, 1, 1], + [2, 9, 0, 0, 2, 1], + [3, 9, 0, 0, 1, 1], + [4, 9, 0, 0, 2, 1], + ])) + .expect("parse"); + assert_eq!(m.len(), 1, "four rows, one key"); + assert_eq!( + m.get(&(9, 0, 0)), + Some(&1), + "the bare scalp, not the topknot" + ); + + // And the later row is genuinely discarded, not merged: reverse the pair and 2 wins. + let m = hair_geosets_from_bytes(&dbc(&[[1, 9, 0, 0, 2, 1], [2, 9, 0, 0, 1, 1]])) + .expect("parse"); + assert_eq!(m.get(&(9, 0, 0)), Some(&2)); + } + + /// The realm re-authored `CharHairGeosets` and left no duplicated key behind. + #[test] + fn no_shipped_hair_key_repeats_any_more() { let data = crate::wow_data_or_skip!(); let mut chain = crate::open_chain(&data).expect("open chain"); - // The file's rows, read without the loader. + // The file's rows, read without the loader, so a collapsed key is visible. let bytes = chain.read_file(CHAR_HAIR_GEOSETS).expect("CharHairGeosets"); let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets").expect("parse"); let mut keys: HashMap<(u32, u32, u32), Vec> = HashMap::new(); @@ -557,30 +613,36 @@ mod tests { keys.entry((race, sex, var)).or_default().push(geoset); } } + assert_eq!(rs.records().len(), 299, "299 rows; 5875 shipped 148"); let dups: Vec<_> = keys.iter().filter(|(_, v)| v.len() > 1).collect(); - assert_eq!( - dups.len(), - 1, - "exactly one duplicated (race,sex,variation) key ships; got {dups:?}" - ); - assert_eq!( - keys.get(&(9, 0, 0)).map(Vec::as_slice), - Some([1u32, 2, 1, 2].as_slice()), - "the goblin-male block is the duplicate, in file order" + assert!( + dups.is_empty(), + "5875's one duplicated key is gone and none replaced it; got {dups:?}" ); + // Race 9, the goblin, is where the re-authoring shows: 1.12 gave it one style listed four + // times and sex-0 rows only, and the realm gives it a full set for both — 14 male + // variations and 17 female. + let count = |sex: u32| keys.keys().filter(|(r, s, _)| *r == 9 && *s == sex).count(); + assert_eq!((count(0), count(1)), (14, 17), "goblin hair, both sexes"); + let cg = CharacterGeosets::load(&mut chain).expect("load customization tables"); + // The male's variation 0 is `GeosetID` 0 now, where 1.12's winning row said 1. Both land + // on the scalp: `visible_geosets` takes `max(1, GeosetID)`, so bald is geoset 1, never 0. let set = cg.visible_geosets(9, 0, 0, 0, &EquipGeosets::default()); assert!( set.contains(&1), - "goblin male shows the bare scalp (geoset 1)" + "goblin male variation 0 is bald — the scalp, not the body" ); assert!( !set.contains(&2), - "goblin male does NOT show geoset 2 — the white topknot" + "and not geoset 2, which is now its own variation" ); - // Race 9 ships only sex-0 rows, so the goblin female keeps the region base, the same scalp. + // The female's variation 0 is `GeosetID` 2, a styled head, so hers does show 2. let f = cg.visible_geosets(9, 1, 0, 0, &EquipGeosets::default()); - assert!(f.contains(&1) && !f.contains(&2), "goblin female unchanged"); + assert!( + f.contains(&2) && !f.contains(&1), + "goblin female variation 0 wears hair, not a bare scalp: {f:?}" + ); } } diff --git a/crates/benilla-formats/src/talents.rs b/crates/benilla-formats/src/talents.rs index 62f30d8a..23f29362 100644 --- a/crates/benilla-formats/src/talents.rs +++ b/crates/benilla-formats/src/talents.rs @@ -215,10 +215,21 @@ mod tests { .collect::>(), vec!["MageArcane", "MageFire", "MageFrost"] ); - // Every shipped tab's raceMask is 511, so race 10 admits nothing. + // Every shipped tab's raceMask is 1023, where 5875's was 511: the realm added a tenth + // `ChrRaces` row — id 10, "High Elf", on the Blood Elf art (`ClientFileString` "BloodElf") + // — and opened all 27 tabs to it rather than gating any class. So race 10 admits the whole + // Mage tree, which under 511 admitted nothing. + assert_eq!( + cat.tabs_for_class(10, MAGE).len(), + 3, + "race bit 10 is inside 1023" + ); assert!( - cat.tabs_for_class(10, MAGE).is_empty(), - "race bit 10 is outside 511" + (1..=9u8).all(|c| cat + .tabs_for_class(10, c) + .iter() + .all(|t| t.race_mask == 1023)), + "no tab gates the new race out" ); // Every class has pages; vanilla's classes are 1-5, 7-9 and 11 (druid). for class in [1u8, 2, 3, 4, 5, 7, 8, 9, 11] { diff --git a/crates/benilla-formats/src/wmo_area.rs b/crates/benilla-formats/src/wmo_area.rs index 1b1f3862..6ce73c13 100644 --- a/crates/benilla-formats/src/wmo_area.rs +++ b/crates/benilla-formats/src/wmo_area.rs @@ -169,8 +169,10 @@ mod tests { let data = crate::wow_data_or_skip!(); let mut chain = crate::open_chain(&data).expect("open chain"); let cat = load_wmo_area_catalog(&mut chain).expect("load WMOAreaTable"); - // 21115 rows but 21105 distinct (wmo, nameset, group) keys: the shipped data repeats 10. - assert_eq!(cat.len(), 21105, "all distinct rows load"); + // 26653 rows but 26570 distinct (wmo, nameset, group) keys: the shipped data repeats 83, + // where 5875's 21115 rows repeated 10. patch-4 added 5633 ids, removed 95 and changed 4080, + // so this table is heavily reworked rather than appended to. + assert_eq!(cat.len(), 26570, "all distinct rows load"); // NSabbey (59): an unknown group falls back to the whole-WMO row, the monk chant. let abbey = cat.resolve(59, 0, 9999).expect("abbey default row"); diff --git a/crates/benilla-formats/src/world_state_ui.rs b/crates/benilla-formats/src/world_state_ui.rs index 527a0756..1d020187 100644 --- a/crates/benilla-formats/src/world_state_ui.rs +++ b/crates/benilla-formats/src/world_state_ui.rs @@ -145,7 +145,31 @@ mod tests { let data = crate::wow_data_or_skip!(); let mut chain = crate::open_chain(&data).expect("open chain"); let cat = load_world_state_ui_catalog(&mut chain).expect("load WorldStateUI"); - assert_eq!(cat.len(), 20, "all 20 rows load"); + // 34 rows where 5875 had 20, and the realm also rewrote the text and tooltip on every one + // of those 20. Only 33 ids are distinct: **168 ships twice**, both map 0 / area 44, one + // reading "Banners Controlled: %3809w" and one "Tower Score : %3811w / %3813w". That is + // safe here because the table has no id lookup — [`WorldStateUiCatalog`] exposes only + // `rows()`, and the app filters by (map, area), so both readouts reach the frame, which is + // what two rows under one id can only have been meant to do. + assert_eq!(cat.len(), 34, "all 34 rows load; 5875 had 20"); + let mut ids: Vec = cat.rows().map(|(id, _)| id).collect(); + ids.sort_unstable(); + let total = ids.len(); + ids.dedup(); + assert_eq!((total, ids.len()), (34, 33), "one id is doubled"); + let doubled: Vec<&str> = cat + .rows() + .filter(|(id, _)| *id == 168) + .map(|(_, r)| r.text.as_str()) + .collect(); + assert_eq!( + doubled, + vec![ + "Banners Controlled: %3809w", + "Tower Score : %3811w / %3813w" + ], + "both of id 168's readouts survive the load, in file order" + ); let by_id = |id: u32| { cat.rows() @@ -206,8 +230,10 @@ mod tests { "Type is a small enum: {}", row.ui_type ); + // 5875's three live ids all sat in 2000..3000 (2338, 2339, 2426); the realm's six new + // ones run 3623..=3810, which its own text macros agree with (`%3809w`, `%3811w`). assert!( - row.state_variable == 0 || (2000..3000).contains(&row.state_variable), + row.state_variable == 0 || (2000..4000).contains(&row.state_variable), "StateVariable lands in the live world-state id band: {}", row.state_variable ); @@ -229,10 +255,22 @@ mod tests { with_macro += 1; } } - assert_eq!(with_area, 5, "only the two outdoor zones narrow by area"); + // Four zones narrow by area where 5875 had two, and the three new ones are outdoor PvP: + // Redridge Mountains (5 rows, the doubled id 168 among them), Hillsbrad Foothills and + // Silithus. The other 22 rows are area 0 — the battlegrounds, scoped by map alone. + let mut by_area: std::collections::BTreeMap = Default::default(); + for (_, r) in cat.rows() { + *by_area.entry(r.area_id).or_default() += 1; + } assert_eq!( - with_macro, 9, - "the always-up rows read the world-state table" + by_area.into_iter().collect::>(), + vec![(0, 22), (44, 5), (139, 3), (267, 2), (1377, 2)], + "Redridge, Eastern Plaguelands, Hillsbrad and Silithus" + ); + assert_eq!(with_area, 12, "5875 had 5, in two zones"); + assert_eq!( + with_macro, 23, + "the always-up rows read the world-state table; 5875 had 9" ); } }