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)

`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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 19:52:19 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6e9e5e995d
commit 06f9cc93df
@@ -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<u8> = 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!();