From 06f9cc93df3dad5af60adf691fbe8a24f8ce13ed Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:52:19 +0700 Subject: [PATCH] char create: the CharBaseInfo guard requires every shipped race/class pair and lets a patch add more, as the class-list builder lists every row (#631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CharCreateCatalog::load`'s misparse guard demanded each race's `CharBaseInfo` classes equal the shipped set exactly. A patch that adds a race/class row therefore failed the load, and without the catalog the create screen is disabled and the glue preview stands no body. 1.12.1's class-list builder (`0x4706b0`) lists every `CharBaseInfo` row for the selected race, skipping only Dwarf+Mage (`0x4706e3`-`0x4706ec`), so the patched class appears there. The guard, now `shipped_combos_present`, requires every shipped pair to be present and lets extra pairs through, so a misparse that loses a row still refuses. `a_patched_combo_passes_the_guard_and_a_lost_one_does_not` fails under the old exact-match rule. Built and gated on macOS. Fixes #617 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/characters/customization.rs | 48 ++++++++++++++----- 1 file changed, 37 insertions(+), 11 deletions(-) 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!();