Files
benilla-octo/crates/benilla-formats/src/characters/customization.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

728 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Character-creation source data from ChrRaces, CharBaseInfo and CharStartOutfit, and the five
//! appearance-dial ranges per (race, sex), counted from the DBCs: skin colour, face and hair colour
//! off the CharSections rows the client groups into its grid (`0x476020`), hair and facial-hair
//! styles off CharHairGeosets and CharacterFacialHairStyles. Every tuple in a (race, sex)'s ranges
//! passes vmangos's `Player::ValidateAppearance`, since the shipped grids are complete over each
//! coupled pair.
use std::collections::{HashMap, HashSet};
use anyhow::{bail, Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc";
const CHAR_BASE_INFO: &str = "DBFilesClient\\CharBaseInfo.dbc";
const CHAR_START_OUTFIT: &str = "DBFilesClient\\CharStartOutfit.dbc";
const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc";
const CHAR_FACIAL_HAIR_STYLES: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc";
const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc";
// CharSections `SectionType` values the dial ranges read (hair is 3 at `0x4784c0`).
const SECTION_SKIN: u8 = 0;
const SECTION_FACE: u8 = 1;
const SECTION_HAIR: u8 = 3;
/// A CharSections availability key: `(race, sex, sectionType, variation, color)`.
type SectionKey = (u8, u8, u8, u8, u8);
/// The eight stock 1.12 races, the fixture the catalog test walks. The creatable set is read from
/// `CharBaseInfo` at load, so a patched chain that adds races (TurtleWoW's Goblin 9 and BloodElf 10)
/// is discovered, not hard-coded.
#[cfg(test)]
const STOCK_RACES: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];
/// The shipped ChrRaces fileStrings (col 15), guarding [`load_races`]' string columns.
const KNOWN_FILES: [(u8, &str); 8] = [
(1, "Human"),
(2, "Orc"),
(3, "Dwarf"),
(4, "NightElf"),
(5, "Scourge"),
(6, "Tauren"),
(7, "Gnome"),
(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`]).
const KNOWN_COMBOS: [(u8, &[u8]); 8] = [
(1, &[1, 2, 4, 5, 8, 9]), // Human
(2, &[1, 3, 4, 7, 9]), // Orc
(3, &[1, 2, 3, 4, 5, 8]), // Dwarf; 8 (Mage) is the dead row
(4, &[1, 3, 4, 5, 11]), // Night Elf
(5, &[1, 4, 5, 8, 9]), // Undead
(6, &[1, 3, 7, 11]), // Tauren
(7, &[1, 4, 8, 9]), // Gnome
(8, &[1, 3, 4, 5, 7, 8]), // Troll
];
/// `CharBaseInfo` pairs the **stock** client never offers: its class-list builder `0x4706b0` skips
/// the literal `race == 3 && class == 8` (Dwarf-Mage), though the row and its outfit are populated.
///
/// Stripped only from a stock chain — see [`CharCreateCatalog::chain_is_stock`]. The skip lives in
/// the *executable*, not the data, so a realm that ships its own client can simply not have it:
/// disassembling this install's `WoW.exe` at `0x4706b0` finds **no immediate compare against 3 or
/// 8** anywhere in the builder (it gates on a `0xFE` sentinel instead), while its `CharBaseInfo`
/// lists Dwarf-Mage like any other row. Applying the stock skip there deletes a combination the
/// realm genuinely offers, which is what it did until this gate existed.
const UNUSED_COMBOS: [(u8, u8); 1] = [(3, 8)];
/// One worn item of a CharStartOutfit row: its ItemDisplayInfo id and its InventoryType.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartOutfitItem {
pub display_id: u32,
pub inv_type: u8,
}
/// The five appearance-dial counts for one (race, sex); each dial cycles index `0..count-1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DialRanges {
pub skin: u8,
pub face: u8,
pub hair_style: u8,
pub hair_color: u8,
pub facial_hair: u8,
}
/// Character-creation source data, loaded once from the DBCs ([`Self::load`]).
pub struct CharCreateCatalog {
/// race → (male displayId, female displayId), from ChrRaces cols 4/5.
displays: HashMap<u8, (u32, u32)>,
/// race → ChrRaces fileString (col 15), the GlueStrings key stem (`RACE_INFO_<FILE>`).
files: HashMap<u8, String>,
/// race → ([male, female] facial-hair token, cols 26/27; hair token, col 28).
custom_tokens: HashMap<u8, ([String; 2], String)>,
combos: HashSet<(u8, u8)>,
ranges: HashMap<(u8, u8), DialRanges>,
/// (race, class, sex) → the worn starting-outfit items.
start_outfits: HashMap<(u8, u8, u8), Vec<StartOutfitItem>>,
/// The creatable races, ascending: every `CharBaseInfo` race with a body display and
/// customization tokens. Stock 1.12 is 1–8; TurtleWoW adds 9 (Goblin) and 10 (BloodElf).
playable: Vec<u8>,
}
impl CharCreateCatalog {
/// The body displayId for a (race, sex), resolved through CreatureDisplayInfo like any unit.
pub fn body_display(&self, race: u8, sex: u8) -> Option<u32> {
self.displays
.get(&race)
.map(|&(m, f)| if sex == 0 { m } else { f })
}
/// Drop the stock executable's hardcoded combination skips, but only from a stock chain.
/// See [`UNUSED_COMBOS`] for why a modified client does not get them.
fn apply_stock_skips(&mut self) {
if !self.chain_is_stock() {
return;
}
for combo in UNUSED_COMBOS {
self.combos.remove(&combo);
}
}
/// Whether this chain is stock 1.12's data, which decides if the executable's hardcoded
/// combination skips ([`UNUSED_COMBOS`]) apply.
///
/// The test is a race past the shipped eight. It is a proxy — the skip lives in the client
/// binary, which this crate does not read — but a chain that adds a *race* has replaced the
/// glue and the `CharBaseInfo` wholesale, so it is running a client built to its own rules
/// rather than the stock builder whose literal this reproduces. `char_create/screen.rs` picks
/// its tower metrics off the same signal (the fifth race row) for the same reason.
fn chain_is_stock(&self) -> bool {
self.playable.iter().all(|&r| r <= 8)
}
/// Whether a race may be created as a class (CharBaseInfo less `UNUSED_COMBOS`).
pub fn allows(&self, race: u8, class: u8) -> bool {
self.combos.contains(&(race, class))
}
/// The ChrRaces fileString for a race (`"Human"`, `"Scourge"`), the GlueStrings key stem.
pub fn race_file(&self, race: u8) -> Option<&str> {
self.files.get(&race).map(String::as_str)
}
/// A race's hair glue token (`"NORMAL"`, `"HORNS"`), keying `HAIR_<tok>_STYLE`/`_COLOR`.
pub fn hair_customization(&self, race: u8) -> Option<&str> {
self.custom_tokens.get(&race).map(|(_, h)| h.as_str())
}
/// A (race, sex)'s facial-hair glue token, keying `FACIAL_HAIR_<tok>`; the glue hides the dial
/// when `GetFacialHairCustomization()` is `"NONE"`.
pub fn facial_hair_customization(&self, race: u8, sex: u8) -> Option<&str> {
self.custom_tokens
.get(&race)
.map(|(f, _)| f[(sex as usize).min(1)].as_str())
}
/// The classes a race may be created as, ascending by id: the client lists them in CharBaseInfo
/// row order (`0x4706b0`), which is ascending class id in the shipped file.
pub fn classes_for_race(&self, race: u8) -> Vec<u8> {
let mut cs: Vec<u8> = self
.combos
.iter()
.filter(|&&(r, _)| r == race)
.map(|&(_, c)| c)
.collect();
cs.sort_unstable();
cs
}
/// The five appearance-dial counts for a (race, sex).
pub fn ranges(&self, race: u8, sex: u8) -> Option<DialRanges> {
self.ranges.get(&(race, sex)).copied()
}
/// The worn level-1 gear the create preview dresses a (race, class, sex) in.
pub fn start_outfit(&self, race: u8, class: u8, sex: u8) -> &[StartOutfitItem] {
self.start_outfits
.get(&(race, class, sex))
.map_or(&[], Vec::as_slice)
}
/// The creatable races, ascending, as `GetAvailableRaces` enumerates them. Stock 1.12 is the
/// shipped eight; a patched chain adds its own rows, so the create screen's race buttons come
/// from here rather than a fixed list.
pub fn playable_races(&self) -> &[u8] {
&self.playable
}
/// Load the catalog from the patch chain. The misparse guards compare the raw combos, so they
/// run before `UNUSED_COMBOS` are stripped.
pub fn load(chain: &mut Chain) -> Result<Self> {
let (displays, files, custom_tokens) = load_races(chain)?;
let combos = load_combos(chain)?;
let start_outfits = load_start_outfits(chain)?;
let avail = load_available_sections(chain)?;
let hair_geo = load_hair_geosets(chain)?;
let facial = load_facial_hair_styles(chain)?;
// The creatable set is the `CharBaseInfo` races that carry a body and customization
// tokens: the shipped eight, plus any the chain's own patch adds (TurtleWoW's 9/10). A
// race the file lists with no body is not offered, as the client's own list skips it.
let mut playable: Vec<u8> = combos.iter().map(|&(r, _)| r).collect();
playable.sort_unstable();
playable.dedup();
playable.retain(|r| displays.contains_key(r) && custom_tokens.contains_key(r));
let mut ranges = HashMap::new();
for &race in &playable {
for sex in [0u8, 1] {
ranges.insert(
(race, sex),
derive_ranges(race, sex, &avail, &hair_geo, &facial),
);
}
}
let mut catalog = Self {
displays,
files,
custom_tokens,
combos,
ranges,
start_outfits,
playable,
};
catalog.self_check()?;
// The stock client's hardcoded skips, and only on a stock chain. A modified client carries
// its own builder, and this realm's does not have them (see `UNUSED_COMBOS`).
catalog.apply_stock_skips();
Ok(catalog)
}
fn self_check(&self) -> Result<()> {
// The shipped rows guard the 2-byte parse. A patched chain only ever adds combos
// (TurtleWoW grants Human Hunter, Orc Mage, Dwarf Warlock and more), so the stock set must
// be a subset of the parsed one; a transposed or truncated read loses it.
for (race, classes) in KNOWN_COMBOS {
let got = self.classes_for_race(race);
if !classes.iter().all(|c| got.contains(c)) {
bail!(
"CharBaseInfo misparse: race {race} classes {got:?} missing stock {classes:?} \
(check the 2-byte race/class layout)"
);
}
}
for (race, file) in KNOWN_FILES {
if self.race_file(race) != Some(file) {
bail!(
"ChrRaces misparse: race {race} fileString {:?} != known {file:?} \
(check the string-column positions in load_races)",
self.race_file(race)
);
}
}
// CharStartOutfit layout guard: the shipped Human Warrior male set, (display,
// InventoryType) for the shirt, pants, boots, shortsword and shield.
let hw = self.start_outfit(1, 1, 0);
for &(disp, inv) in &[
(9891u32, 4u8),
(9892, 7),
(10141, 8),
(1542, 21),
(18730, 14),
] {
if !hw
.iter()
.any(|it| it.display_id == disp && it.inv_type == inv)
{
bail!(
"CharStartOutfit misparse: Human Warrior male missing display {disp} inv {inv} \
(got {hw:?}) — check the 41-field / 152-byte packed layout"
);
}
}
for &race in &self.playable {
for sex in [0u8, 1] {
if self.body_display(race, sex).unwrap_or(0) == 0 {
bail!("ChrRaces: race {race} sex {sex} has no body displayId");
}
if self.facial_hair_customization(race, sex).is_none()
|| self.hair_customization(race).is_none()
{
bail!("ChrRaces: race {race} has no customization tokens");
}
let r = self
.ranges(race, sex)
.with_context(|| format!("no ranges for race {race} sex {sex}"))?;
if r.skin == 0
|| r.face == 0
|| r.hair_style == 0
|| r.hair_color == 0
|| r.facial_hair == 0
{
bail!("customization ranges: race {race} sex {sex} has a zero dial ({r:?})");
}
}
}
Ok(())
}
}
/// ChrRaces (29 fields, 116-byte records) → per race row: displayIds (cols 4/5), fileString (15),
/// facial-hair (26/27) and hair (28) tokens; other string columns are read as ignored `u32`s. Every
/// row loads; [`CharCreateCatalog::playable_races`] keeps the ones `CharBaseInfo` makes creatable.
#[allow(clippy::type_complexity)] // one pass over one DBC → the catalog's three race-keyed maps
fn load_races(
chain: &mut Chain,
) -> Result<(
HashMap<u8, (u32, u32)>,
HashMap<u8, String>,
HashMap<u8, ([String; 2], String)>,
)> {
let bytes = chain
.read_file(CHR_RACES)
.with_context(|| format!("reading {CHR_RACES}"))?;
let mut schema = Schema::new("ChrRaces");
for i in 0..29 {
let ty = match i {
15 | 26 | 27 | 28 => FieldType::String,
_ => FieldType::UInt32,
};
schema.add_field(SchemaField::new(format!("f{i}"), ty));
}
let rs = parse(&bytes, schema, "ChrRaces")?;
let mut displays = HashMap::new();
let mut files = HashMap::new();
let mut tokens = HashMap::new();
for r in rs.records() {
let Some(race) = u32_at(r, 0) else { continue };
let race = race as u8;
if let (Some(male), Some(female)) = (u32_at(r, 4), u32_at(r, 5)) {
displays.insert(race, (male, female));
}
if let Some(file) = str_at(&rs, r, 15) {
files.insert(race, file);
}
if let (Some(fm), Some(ff), Some(hair)) =
(str_at(&rs, r, 26), str_at(&rs, r, 27), str_at(&rs, r, 28))
{
tokens.insert(race, ([fm, ff], hair));
}
}
Ok((displays, files, tokens))
}
/// CharBaseInfo → (race, class) pairs: two one-byte fields, parsed by hand off the record block.
fn load_combos(chain: &mut Chain) -> Result<HashSet<(u8, u8)>> {
let bytes = chain
.read_file(CHAR_BASE_INFO)
.with_context(|| format!("reading {CHAR_BASE_INFO}"))?;
if bytes.len() < 20 || &bytes[0..4] != b"WDBC" {
bail!("CharBaseInfo: not a WDBC file");
}
let record_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
let field_count = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
let record_size = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
if field_count != 2 || record_size != 2 {
bail!("CharBaseInfo: unexpected layout (fields {field_count}, record size {record_size}; expected 2/2)");
}
let data = &bytes[20..];
let mut combos = HashSet::new();
for i in 0..record_count {
let off = i * record_size;
if off + 2 > data.len() {
bail!("CharBaseInfo: record {i} runs past the file");
}
combos.insert((data[off], data[off + 1]));
}
Ok(combos)
}
/// CharStartOutfit → (race, class, sex) → worn items, parsed by hand (41 fields, 152 bytes): `ID`
/// u32 @0, race/class/gender u8 @4/5/6, `ItemId[12]` @8, `DisplayId[12]` @56, `InventoryType[12]`
/// @104. A slot is kept when both are ≥ 1, dropping the `-1` tail and the InventoryType-0 bags.
fn load_start_outfits(chain: &mut Chain) -> Result<HashMap<(u8, u8, u8), Vec<StartOutfitItem>>> {
let bytes = chain
.read_file(CHAR_START_OUTFIT)
.with_context(|| format!("reading {CHAR_START_OUTFIT}"))?;
if bytes.len() < 20 || &bytes[0..4] != b"WDBC" {
bail!("CharStartOutfit: not a WDBC file");
}
let record_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
let field_count = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
let record_size = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
if field_count != 41 || record_size != 152 {
bail!(
"CharStartOutfit: unexpected layout (fields {field_count}, record size {record_size}; \
expected 41/152)"
);
}
let data = &bytes[20..];
let i32_at = |rec: &[u8], off: usize| i32::from_le_bytes(rec[off..off + 4].try_into().unwrap());
let mut map: HashMap<(u8, u8, u8), Vec<StartOutfitItem>> = HashMap::new();
for i in 0..record_count {
let off = i * record_size;
if off + record_size > data.len() {
bail!("CharStartOutfit: record {i} runs past the file");
}
let rec = &data[off..off + record_size];
let (race, class, sex) = (rec[4], rec[5], rec[6]);
let mut items = Vec::new();
for slot in 0..12 {
let display = i32_at(rec, 56 + slot * 4);
let inv = i32_at(rec, 104 + slot * 4);
if display >= 1 && inv >= 1 {
items.push(StartOutfitItem {
display_id: display as u32,
inv_type: inv as u8,
});
}
}
map.insert((race, class, sex), items);
}
Ok(map)
}
/// CharSections → the selectable keys, rows without flag `0x1` (`SECTION_FLAG_UNAVAILABLE`): the
/// domain vmangos's `GetCharSectionEntry` searches.
fn load_available_sections(chain: &mut Chain) -> Result<HashSet<SectionKey>> {
let bytes = chain
.read_file(CHAR_SECTIONS)
.with_context(|| format!("reading {CHAR_SECTIONS}"))?;
// 10 fields (ID, Race, Sex, SectionType, Variation, Color, Tex0-2, Flags); we read the ints.
let rs = parse(&bytes, all_u32_schema("CharSections", 10), "CharSections")?;
let mut avail = HashSet::new();
for r in rs.records() {
if let (Some(race), Some(sex), Some(ty), Some(var), Some(color), Some(flags)) = (
u32_at(r, 1),
u32_at(r, 2),
u32_at(r, 3),
u32_at(r, 4),
u32_at(r, 5),
u32_at(r, 9),
) {
if flags & 0x1 == 0 {
avail.insert((race as u8, sex as u8, ty as u8, var as u8, color as u8));
}
}
}
Ok(avail)
}
/// CharHairGeosets → (race, sex) → hair-style variations, keyed on cols 1/2/3 as `0x478540` does.
fn load_hair_geosets(chain: &mut Chain) -> Result<HashMap<(u8, u8), HashSet<u8>>> {
let bytes = chain
.read_file(CHAR_HAIR_GEOSETS)
.with_context(|| format!("reading {CHAR_HAIR_GEOSETS}"))?;
let rs = parse(
&bytes,
all_u32_schema("CharHairGeosets", 6),
"CharHairGeosets",
)?;
let mut map: HashMap<(u8, u8), HashSet<u8>> = HashMap::new();
for r in rs.records() {
if let (Some(race), Some(sex), Some(var)) = (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3)) {
map.entry((race as u8, sex as u8))
.or_default()
.insert(var as u8);
}
}
Ok(map)
}
/// CharacterFacialHairStyles → (race, sex) → variations, keyed on cols 0/1/2 as `0x478740` does.
fn load_facial_hair_styles(chain: &mut Chain) -> Result<HashMap<(u8, u8), HashSet<u8>>> {
let bytes = chain
.read_file(CHAR_FACIAL_HAIR_STYLES)
.with_context(|| format!("reading {CHAR_FACIAL_HAIR_STYLES}"))?;
let rs = parse(
&bytes,
all_u32_schema("CharacterFacialHairStyles", 9),
"CharacterFacialHairStyles",
)?;
let mut map: HashMap<(u8, u8), HashSet<u8>> = HashMap::new();
for r in rs.records() {
if let (Some(race), Some(sex), Some(var)) = (u32_at(r, 0), u32_at(r, 1), u32_at(r, 2)) {
map.entry((race as u8, sex as u8))
.or_default()
.insert(var as u8);
}
}
Ok(map)
}
/// Count distinct values on one axis of a (race, sex, sectionType) group in `avail`.
fn section_axis_count(
avail: &HashSet<SectionKey>,
race: u8,
sex: u8,
ty: u8,
variation_axis: bool,
) -> u8 {
avail
.iter()
.filter(|&&(r, s, t, _, _)| r == race && s == sex && t == ty)
.map(|&(_, _, _, v, c)| if variation_axis { v } else { c })
.collect::<HashSet<u8>>()
.len() as u8
}
fn derive_ranges(
race: u8,
sex: u8,
avail: &HashSet<SectionKey>,
hair_geo: &HashMap<(u8, u8), HashSet<u8>>,
facial: &HashMap<(u8, u8), HashSet<u8>>,
) -> DialRanges {
DialRanges {
skin: section_axis_count(avail, race, sex, SECTION_SKIN, false),
face: section_axis_count(avail, race, sex, SECTION_FACE, true),
// Hair styles are CharHairGeosets variations, at least 1 (bald), not CharSections HAIR
// variations: Orc female has one HAIR variation with no geoset.
hair_style: hair_geo
.get(&(race, sex))
.map_or(1, |v| v.len().max(1) as u8),
hair_color: section_axis_count(avail, race, sex, SECTION_HAIR, false),
facial_hair: facial.get(&(race, sex)).map_or(0, |v| v.len() as u8),
}
}
/// `n` `UInt32` fields for a DBC read by index; the parser's field-count check guards the layout.
fn all_u32_schema(name: &str, n: usize) -> Schema {
let mut s = Schema::new(name);
for i in 0..n {
s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
/// CharSections `SECTION_TYPE_FACIAL_HAIR`, needed only by the transcribed check.
const SECTION_FACIAL_HAIR: u8 = 2;
/// vmangos's `Player::ValidateAppearance` (`Player.cpp:326`), run on `CMSG_CHAR_CREATE`;
/// `avail` already excludes the `0x1`-flagged rows, and face is keyed by skin colour.
fn validate_appearance(
avail: &HashSet<SectionKey>,
facial: &HashMap<(u8, u8), HashSet<u8>>,
race: u8,
sex: u8,
skin: u8,
face: u8,
hair: u8,
hair_color: u8,
facial_hair: u8,
) -> bool {
let has = |ty, var, color| avail.contains(&(race, sex, ty, var, color));
if !has(SECTION_SKIN, 0, skin) {
return false;
}
if !has(SECTION_FACE, face, skin) {
return false;
}
if !has(SECTION_HAIR, hair, hair_color) {
return false;
}
// Tauren and all females but Night Elf (4) and Undead (5) have no FACIAL_HAIR sections.
let excluded = race == 6 || (sex == 1 && race != 4 && race != 5);
if !excluded && !has(SECTION_FACIAL_HAIR, facial_hair, hair_color) {
return false;
}
facial
.get(&(race, sex))
.is_some_and(|vs| vs.contains(&facial_hair))
}
#[test]
fn char_create_catalog_matches_the_5875_dbcs() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = CharCreateCatalog::load(&mut chain).expect("load char-create catalog");
let creatures = crate::load_creature_catalog(&mut chain).expect("load creatures");
for race in STOCK_RACES {
for sex in [0u8, 1] {
let display = cat.body_display(race, sex).expect("has display");
let model = creatures.model(display).unwrap_or_else(|| {
panic!("display {display} (race {race} sex {sex}) resolves")
});
assert!(
model
.model_path
.to_ascii_lowercase()
.starts_with("character\\"),
"race {race} sex {sex} display {display} → {:?} (expected a Character body)",
model.model_path
);
}
}
// The stock pairs are the raw table less the dead pairs; a patched chain may add more
// (TurtleWoW's Human Hunter, Orc Mage, Dwarf Warlock), so the stock set is a subset.
for (race, classes) in KNOWN_COMBOS {
let got = cat.classes_for_race(race);
for &c in classes {
if UNUSED_COMBOS.contains(&(race, c)) {
continue;
}
assert!(
got.contains(&c),
"race {race} is missing stock class {c} ({got:?})"
);
}
}
assert!(
!cat.classes_for_race(3).contains(&8),
"the dead Dwarf-Mage row must not reach the create screen, patched chain or not"
);
// The glue tokens against the shipped rows, pinning cols 26/27's male/female order; no
// shipped row is "NONE".
assert_eq!(cat.hair_customization(6), Some("HORNS"), "Tauren hair tok");
assert_eq!(cat.hair_customization(1), Some("NORMAL"), "Human hair tok");
assert_eq!(
cat.facial_hair_customization(1, 0),
Some("NORMAL"),
"Human male facial tok"
);
assert_eq!(
cat.facial_hair_customization(1, 1),
Some("PIERCINGS"),
"Human female facial tok"
);
assert_eq!(
cat.facial_hair_customization(4, 1),
Some("MARKINGS"),
"Night Elf female facial tok"
);
assert_eq!(
cat.facial_hair_customization(8, 0),
Some("TUSKS"),
"Troll male facial tok"
);
// Every creatable combo wears at least one item; the Human Mage wears a robe (inv 20).
for (race, classes) in KNOWN_COMBOS {
for &class in classes
.iter()
.filter(|&&c| !UNUSED_COMBOS.contains(&(race, c)))
{
for sex in [0u8, 1] {
let out = cat.start_outfit(race, class, sex);
assert!(
!out.is_empty(),
"race {race} class {class} sex {sex} has no start outfit"
);
assert!(
out.iter().all(|it| it.display_id >= 1 && it.inv_type >= 1),
"race {race} class {class} sex {sex} outfit has a 0/empty entry: {out:?}"
);
}
}
}
let mage = cat.start_outfit(1, 8, 0);
assert!(
mage.iter()
.any(|it| it.inv_type == 20 && it.display_id == 12647),
"Human Mage male should wear a robe (inv 20, disp 12647): {mage:?}"
);
// Every tuple in the dial ranges passes ValidateAppearance.
let avail = load_available_sections(&mut chain).expect("sections");
let facial = load_facial_hair_styles(&mut chain).expect("facial hair");
for race in STOCK_RACES {
for sex in [0u8, 1] {
let r = cat.ranges(race, sex).expect("ranges");
for sk in 0..r.skin {
for fa in 0..r.face {
for hs in 0..r.hair_style {
for hc in 0..r.hair_color {
for fh in 0..r.facial_hair {
assert!(
validate_appearance(
&avail, &facial, race, sex, sk, fa, hs, hc, fh
),
"race {race} sex {sex} tuple \
skin={sk} face={fa} hair={hs} hairColor={hc} facial={fh} \
fails ValidateAppearance"
);
}
}
}
}
}
}
}
}
/// Dwarf-Mage is the one combination stock 1.12 lists in `CharBaseInfo` and then refuses in
/// code (`0x4706b0`). That refusal is in the **executable**, so it must not be applied to a
/// realm shipping its own client: this install's `WoW.exe` has no immediate compare against 3
/// or 8 anywhere in that builder, and its `CharBaseInfo` lists the row like any other — so
/// stripping it deleted a class the realm genuinely offers, which is exactly what the user
/// reported as "dwarves should be able to be mages".
#[test]
fn the_stock_dwarf_mage_refusal_applies_to_a_stock_chain_and_not_a_modified_one() {
let dwarf_mage = |playable: Vec<u8>| {
let mut c = CharCreateCatalog {
displays: HashMap::new(),
files: HashMap::new(),
custom_tokens: HashMap::new(),
combos: HashSet::from([(3u8, 1u8), (3, 8)]),
ranges: HashMap::new(),
start_outfits: HashMap::new(),
playable,
};
c.apply_stock_skips();
c.allows(3, 8)
};
assert!(
!dwarf_mage(vec![1, 2, 3, 4, 5, 6, 7, 8]),
"a stock chain keeps the executable's refusal — vanilla never offered Dwarf-Mage"
);
assert!(
dwarf_mage(vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
"a chain carrying races past the shipped eight runs its own builder, so the DBC rules"
);
}
}