From c0685f0273d81a0c921713e00e5031c3ce7589b5 Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 31 Oct 2026 19:44:37 -0600 Subject: [PATCH] formats: the realm's tenth race, and what it switched on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four goldens, and three of them turn out to be the same fact: `ChrRaces.dbc` has a tenth row — id 10, **High Elf**, on the Blood Elf art (`ClientFileString` "BloodElf"), Alliance-side. - **Every talent tab opened to it.** All 27 `TalentTab` rows went raceMask 511 -> 1023, so race 10 takes every class's tree rather than being gated out of any; the test asserted the opposite, that "race bit 10 is outside 511". `Talent.dbc` itself was rebuilt too — 476 rows, 317 ids added, 273 removed, 159 changed — so the trees are the realm's, not 1.12's. - **Dwarf Mages are real here, and the gate was already written for it.** `chain_is_stock` calls a chain stock when no playable race exceeds 8, so this install read as stock until race 10 arrived and the `UNUSED_COMBOS` Dwarf-Mage skip kept applying; the test's "patched chain or not" passed by accident. The skip only ever modelled a compare in the *stock* executable at `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`). Re-verified against the binary rather than trusted from the comment. `CharBaseInfo` grew 103 -> 139 bytes, which at 2 bytes a row is 18 new race/class pairs. - **`CharHairGeosets` was re-authored, and took a test's only coverage with it.** 299 rows against 148, 136 of the survivors rewritten, and 1.12's one duplicated key — the goblin male's variation 0, listed four times as geosets 1, 2, 1, 2 — is gone with none replacing it. That key was the *only* thing exercising the loader's first-row-wins rule, so re-recording the count alone would have quietly dropped it: `or_insert` could have become `insert` and nothing would have noticed. The hair decode is now a `hair_geosets_from_bytes` seam and a synthetic DBC replays 1.12's goblin block to pin the rule, plus the reverse order to prove the later row is discarded and not merged. The real-data test asserts the absence instead. Race 9 is where the re-authoring shows: the goblin has a full hair set for both sexes now, 14 male variations and 17 female, and the male's variation 0 is `GeosetID` 0 where 1.12's winning row said 1 — both land on scalp geoset 1, since `visible_geosets` takes `max(1, GeosetID)`. Also: - **WorldStateUI** 20 -> 34 rows, and the realm rewrote the text and tooltip on every one of 1.12's 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". Safe, and checked rather than assumed: the catalog exposes no id lookup at all, only `rows()`, and the app filters by (map, area), so both readouts reach the frame — which is the only thing two rows under one id can have been meant to do. Four zones narrow by area where 1.12 had two, the new three being outdoor PvP: Redridge Mountains, Hillsbrad Foothills and Silithus. Live `StateVariable`s run to 3810 where 1.12's three all sat under 3000. - **WMOAreaTable** 21115 -> 26653 rows and 21105 -> 26570 distinct (wmo, nameset, group) keys, so the repeats went 10 -> 83; 5633 ids added, 95 removed, 4080 changed. benilla-formats: 16 -> 11 failures (and one test more than before). `cargo fmt` and `clippy -p benilla-formats --all-targets` clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../src/characters/customization.rs | 16 ++- .../benilla-formats/src/characters/geosets.rs | 122 +++++++++++++----- crates/benilla-formats/src/talents.rs | 17 ++- crates/benilla-formats/src/wmo_area.rs | 6 +- crates/benilla-formats/src/world_state_ui.rs | 48 ++++++- 5 files changed, 167 insertions(+), 42 deletions(-) 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" ); } }