diff --git a/crates/benilla-formats/src/characters/customization.rs b/crates/benilla-formats/src/characters/customization.rs index f420ef5b..7311eae4 100644 --- a/crates/benilla-formats/src/characters/customization.rs +++ b/crates/benilla-formats/src/characters/customization.rs @@ -43,8 +43,9 @@ const KNOWN_FILES: [(u8, &str); 8] = [ (8, "Troll"), ]; -/// The shipped `CharBaseInfo` rows by race, the load-time guard on the 2-byte parse; not the -/// playable set, as it holds the dead Dwarf-Mage row ([`UNUSED_COMBOS`]). +/// The shipped `CharBaseInfo` rows by race, the load-time guard on the 2-byte parse: each must be +/// present, and a patch may add more, as the class-list builder `0x4706b0` lists every row for the +/// race. Not the playable set, as it holds the dead Dwarf-Mage row ([`UNUSED_COMBOS`]). const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ (1, &[1, 2, 4, 5, 8, 9]), // Human (2, &[1, 3, 4, 7, 9]), // Orc @@ -60,6 +61,24 @@ const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ /// literal `race == 3 && class == 8` (Dwarf-Mage), though the row and its outfit are populated. const UNUSED_COMBOS: [(u8, u8); 1] = [(3, 8)]; +/// The `CharBaseInfo` misparse guard: every shipped pair is present; a patch's extra pairs pass. +fn shipped_combos_present(combos: &HashSet<(u8, u8)>) -> Result<()> { + for (race, classes) in KNOWN_COMBOS { + let missing: Vec = classes + .iter() + .copied() + .filter(|&c| !combos.contains(&(race, c))) + .collect(); + if !missing.is_empty() { + bail!( + "CharBaseInfo misparse: race {race} lacks shipped classes {missing:?} \ + (check the 2-byte race/class layout)" + ); + } + } + Ok(()) +} + /// One worn item of a CharStartOutfit row: its ItemDisplayInfo id and its InventoryType. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StartOutfitItem { @@ -183,15 +202,7 @@ impl CharCreateCatalog { } fn self_check(&self) -> Result<()> { - for (race, classes) in KNOWN_COMBOS { - let got = self.classes_for_race(race); - if got != classes { - bail!( - "CharBaseInfo misparse: race {race} classes {got:?} != known {classes:?} \ - (check the 2-byte race/class layout)" - ); - } - } + shipped_combos_present(&self.combos)?; for (race, file) in KNOWN_FILES { if self.race_file(race) != Some(file) { bail!( @@ -518,6 +529,21 @@ mod tests { .is_some_and(|vs| vs.contains(&facial_hair)) } + #[test] + fn a_patched_combo_passes_the_guard_and_a_lost_one_does_not() { + let mut combos: HashSet<(u8, u8)> = KNOWN_COMBOS + .iter() + .flat_map(|&(r, cs)| cs.iter().map(move |&c| (r, c))) + .collect(); + assert!(shipped_combos_present(&combos).is_ok()); + // A patch that adds Human Hunter: 1.12.1's builder lists it, so the load must go on. + combos.insert((1, 3)); + assert!(shipped_combos_present(&combos).is_ok()); + // A misparse that drops a shipped pair still refuses. + combos.remove(&(2, 7)); + assert!(shipped_combos_present(&combos).is_err()); + } + #[test] fn char_create_catalog_matches_the_5875_dbcs() { let data = crate::wow_data_or_skip!();