Files
benilla-octo/crates/benilla-formats/src/creatures.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

1049 lines
42 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.
//! Creature display resolution: a `displayId` to its M2, skin textures and scale through
//! `CreatureDisplayInfo` and `CreatureModelData`. A humanoid NPC's look is not on the wire: it
//! comes from `CreatureDisplayInfoExtra` and a baked atlas under `Textures\BakedNpcTextures\`.
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{f32_at, parse, str_at, u32_at};
const CREATURE_MODEL_DATA: &str = "DBFilesClient\\CreatureModelData.dbc";
const CREATURE_DISPLAY_INFO: &str = "DBFilesClient\\CreatureDisplayInfo.dbc";
const CREATURE_DISPLAY_INFO_EXTRA: &str = "DBFilesClient\\CreatureDisplayInfoExtra.dbc";
/// A resolved creature display: model, scale, skins and, for a humanoid NPC, its appearance.
#[derive(Debug, Clone)]
pub struct CreatureModel {
/// `.mdx` path (the M2 loader maps it to `.m2`), e.g. `Creature\Basilisk\Basilisk.mdx`.
pub model_path: String,
/// `CreatureModelData.modelScale * CreatureDisplayInfo.creatureModelScale`.
pub scale: f32,
/// `textureVariation[0..2]`, bare names found beside the model, for its `Monster1/2/3` slots;
/// a humanoid NPC's body build (`0x5fb200`) never reads them.
pub textures: [Option<String>; 3],
/// A character-model NPC's appearance; `None` for a plain creature (`ExtendedDisplayInfoID` 0).
pub npc_appearance: Option<NpcAppearance>,
/// `CreatureDisplayInfo.BloodLevel` (+0x28), tier 1 of the reference's `UnitBloodLevels` row
/// resolve, which [`crate::BloodCatalog::level_key`] performs.
pub blood_display: i32,
/// `CreatureModelData.BloodID` (+0x14), tier 2 of the same resolve: `−1` is a miss that falls
/// through to tier 3, not bloodlessness.
pub blood_model: i32,
/// `CreatureModelData.collisionHeight`, in raw model units.
pub collision_height: f32,
}
/// A character-model NPC's appearance, from `CreatureDisplayInfoExtra.dbc`. `skin` and `face` are
/// already in the baked atlas; they serve the live composite when a row has no bake name.
#[derive(Debug, Clone)]
pub struct NpcAppearance {
pub race: u8,
pub sex: u8,
pub skin: u8,
pub face: u8,
pub hair_style: u8,
pub hair_color: u8,
pub facial_hair: u8,
/// Worn `ItemDisplayInfo` ids (fields 8..17), 0 = empty, by body slot: head, shoulder, shirt,
/// chest, belt, pants, boots, wrist, gloves, tabard (no cloak column).
pub equipment: [u32; 10],
/// The baked body atlas under `Textures\BakedNpcTextures\`; `None` composites the body live.
pub bake_name: Option<String>,
}
/// The archive path of a baked NPC atlas: `Textures\BakedNpcTextures\<bake>`, with `.blp` appended
/// when the bake name lacks it, as the reference's texture create appends the extension a name has
/// not (`CImageManager::CreateTexture`). The atlas ships with the extension, and custom chains
/// (TurtleWoW) store the bare name.
pub fn baked_atlas_path(bake_name: &str) -> String {
let path = format!("Textures\\BakedNpcTextures\\{bake_name}");
if bake_name.to_ascii_lowercase().ends_with(".blp") {
path
} else {
format!("{path}.blp")
}
}
/// One CreatureDisplayInfo row (the parts we use).
#[derive(Debug, Clone)]
struct DisplayRow {
model_id: u32,
/// `ExtendedDisplayInfoID`: the `CreatureDisplayInfoExtra` key; 0 = none.
extended_id: u32,
scale: f32,
textures: [Option<String>; 3],
/// `BloodLevel` (field 10), a per-display `UnitBloodLevels` override; 0 names no row.
blood_level: u32,
/// `SizeClass` (field 9, +0x24), the display's override, read signed: `−1` defers to the model.
size_class: i32,
/// `CreatureModelAlpha` (field 5, +0x14), 0..=255: the `baseAlpha` of the reference's unit
/// alpha product (`0x60d2d0`, × 1/255), which is a flat 1.0 for players.
model_alpha: u32,
}
/// One CreatureModelData row (the parts we use).
#[derive(Debug, Clone)]
struct ModelRow {
path: String,
scale: f32,
/// `Flags` (field 1); bit 0x2 is the no-breath flag.
flags: u32,
/// `SizeClass` (field 3, +0x0c), 0 Small to 4 Colossal, read signed like the display's.
size_class: i32,
/// `BloodID`, read signed: `−1` is a tier-2 miss, not bloodlessness.
blood: i32,
/// `FootprintTextureID` (field 6), read signed: `−1` leaves no prints.
footprint_texture: i32,
/// `FootprintTextureLength`/`Width` (fields 7/8), in inches (× 1/36 to yards, `0x607a00`).
footprint_length: f32,
footprint_width: f32,
/// `collisionHeight` (field 15), raw model units.
collision_height: f32,
/// `FoleyMaterialID` (field 10): the reference reads `[unit+0xb3c]+0x28`, field 10 of 16.
foley_material: u32,
/// `FootstepShakeSize`/`DeathThudShakeSize` (fields 11/12), `CameraShakes.dbc` ids; 0 = none.
footstep_shake: u32,
death_thud_shake: u32,
}
/// A footprint decal: the `FootprintTextures.dbc` key and the print size in yards (length along
/// the facing, width across), before the unit's scale.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FootprintParams {
pub texture_id: u32,
pub length: f32,
pub width: f32,
}
/// The creature display tables; `Default` is empty, which consumers treat as a failed load.
#[derive(Default)]
pub struct CreatureCatalog {
display: HashMap<u32, DisplayRow>,
models: HashMap<u32, ModelRow>,
extra: HashMap<u32, NpcAppearance>,
}
impl CreatureCatalog {
/// Every display id that resolves to a drawable model, with its path.
///
/// For the UI side, which cannot borrow this catalog: a Lua model pane asked to show a
/// creature by display id (`PlayerModel:SetUnit(<displayID>)`) holds a `'static` probe, so it
/// takes an owned map built once per VM rather than a reference. Zero-scale rows are dropped,
/// as the world path drops them.
pub fn display_model_paths(&self) -> HashMap<u32, (String, [Option<String>; 3])> {
self.display
.iter()
.filter_map(|(&id, row)| {
let model = self.models.get(&row.model_id)?;
(row.scale > 0.0).then(|| (id, (model.path.clone(), row.textures.clone())))
})
.collect()
}
/// A display's own `creatureModelScale`, the mount scale law: a mount renders at
/// `OBJECT_FIELD_SCALE_X × creatureModelScale`, without `modelScale` (`0x613ef0`).
pub fn display_scale(&self, display_id: u32) -> Option<f32> {
self.display.get(&display_id).map(|r| r.scale)
}
/// A display's footstep camera shake, a `CameraShakes.dbc` id (`FootstepShakeSize`).
pub fn footstep_shake(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let id = self.models.get(&row.model_id)?.footstep_shake;
(id != 0).then_some(id)
}
/// A display's camera shake as the body lands (`DeathThudShakeSize`).
pub fn death_thud_shake(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let id = self.models.get(&row.model_id)?.death_thud_shake;
(id != 0).then_some(id)
}
/// A display's audible size class, 0 Small to 4 Colossal, the axis of
/// [`crate::DeathThudCatalog`]. The display's `SizeClass` wins and only its `−1` defers to the
/// model's (`0x625500`); `None` when both are `−1`, which the reference's unsigned `>= 5` gate
/// (`0x623744`) also silences.
pub fn size_class(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let class = match row.size_class {
-1 => self.models.get(&row.model_id)?.size_class,
c => c,
};
u32::try_from(class).ok()
}
/// Every model row naming a shake: `(model id, path, footstep id, death-thud id)`.
pub fn shaking_models(&self) -> impl Iterator<Item = (u32, &str, u32, u32)> + '_ {
self.models
.iter()
.filter(|(_, m)| m.footstep_shake != 0 || m.death_thud_shake != 0)
.map(|(id, m)| (*id, m.path.as_str(), m.footstep_shake, m.death_thud_shake))
}
/// Every model path: being a creature model decides whether an M2's animation events reach
/// `CGUnit_C::HandleAnimEvent`.
pub fn model_paths(&self) -> impl Iterator<Item = &str> + '_ {
self.models.values().map(|m| m.path.as_str())
}
/// Every model row as `(model id, path, SizeClass)`; most displays override the column.
pub fn sized_models(&self) -> impl Iterator<Item = (u32, &str, i32)> + '_ {
self.models
.iter()
.map(|(id, m)| (*id, m.path.as_str(), m.size_class))
}
/// Every display as `(display id, model id)`.
pub fn display_models(&self) -> impl Iterator<Item = (u32, u32)> + '_ {
self.display.iter().map(|(id, r)| (*id, r.model_id))
}
/// `modelScale × creatureModelScale`. The server folds it into `OBJECT_FIELD_SCALE_X`, which
/// alone scales a unit (`0x613ef0`), so only the glue screens read this: the reference sizes
/// the serverless character-select pet by it (`0x472dc6`).
pub fn model_scale(&self, display_id: u32) -> Option<f32> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
Some(model.scale * row.scale)
}
/// `CreatureModelAlpha / 255`, the first factor of the unit alpha product.
pub fn display_base_alpha(&self, display_id: u32) -> Option<f32> {
self.display
.get(&display_id)
.map(|r| f32::from(r.model_alpha.min(255) as u8) / 255.0)
}
/// `CreatureModelData.collisionHeight`, the `h` of every depth line (swim `0.75·h`, splash
/// `0.4·h`, foam gate `2·h`, read against `CMovement+0xb4`). It is the M2's collision-box Z
/// extent, pre-scale: the prism is this × `max(OBJECT_FIELD_SCALE_X, creatureModelScale)`
/// (`0x60b312` → `0x617501`). A miss takes the constructor default, `2.0277777` (`0x616fd8`).
pub fn collision_height(&self, display_id: u32) -> Option<f32> {
let row = self.display.get(&display_id)?;
Some(self.models.get(&row.model_id)?.collision_height)
}
/// A display's foley material (`Material.dbc`), the creature half of the footfall rustle:
/// `0x623610` hands `[[unit+0xb3c]+0x28]` to `0x4584e0`, while a player reads its chest
/// (`0x62fa30`). Every shipped row is 0, so an NPC's silent armor is the data, not a bug.
pub fn foley_material(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
Some(self.models.get(&row.model_id)?.foley_material)
}
/// Whether the model may wear the `$BTH` breath effects: `CreatureModelData.Flags & 0x2`
/// suppresses them (`0x600003`, the row at `[unit+0xb3c]`); an unknown display breathes.
pub fn breathes(&self, display_id: u32) -> bool {
self.display
.get(&display_id)
.and_then(|row| self.models.get(&row.model_id))
.is_none_or(|m| m.flags & 0x2 == 0)
}
/// A display's footprint decal, fields 6..=8, in yards (inches × 1/36, the client's cache at
/// `0x607a00` for the getter `0x607920`); `None` for a `−1` texture or a 0×0 print.
pub fn footprint(&self, display_id: u32) -> Option<FootprintParams> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
let texture_id = u32::try_from(model.footprint_texture).ok()?;
let (length, width) = (model.footprint_length / 36.0, model.footprint_width / 36.0);
(length > 0.0 && width > 0.0).then_some(FootprintParams {
texture_id,
length,
width,
})
}
/// Resolve an NPC display id to its model.
pub fn model(&self, display_id: u32) -> Option<CreatureModel> {
let row = self.display.get(&display_id)?;
let model = self.models.get(&row.model_id)?;
// A non-zero `ExtendedDisplayInfoID` with an extra row marks a character-model NPC.
let npc_appearance = (row.extended_id != 0)
.then(|| self.extra.get(&row.extended_id).cloned())
.flatten();
Some(CreatureModel {
model_path: model.path.clone(),
scale: model.scale * row.scale,
textures: row.textures.clone(),
npc_appearance,
blood_display: row.blood_level as i32,
blood_model: model.blood,
collision_height: model.collision_height,
})
}
/// Number of display entries (for logging/diagnostics).
pub fn len(&self) -> usize {
self.display.len()
}
/// Whether the catalog has no display entries.
pub fn is_empty(&self) -> bool {
self.display.is_empty()
}
/// Number of appearance rows; 0 means `CreatureDisplayInfoExtra` failed to load.
pub fn extra_len(&self) -> usize {
self.extra.len()
}
}
/// CreatureModelData.dbc: 16 fields in build 5875 (no `mountHeight`).
pub(crate) fn creature_model_data_schema() -> Schema {
let mut s = Schema::new("CreatureModelData");
for (name, ty) in [
("ID", FieldType::UInt32),
("Flags", FieldType::UInt32),
("ModelName", FieldType::String),
("SizeClass", FieldType::UInt32),
("ModelScale", FieldType::Float32),
("BloodID", FieldType::UInt32),
("FootprintTextureID", FieldType::UInt32),
("FootprintTextureLength", FieldType::Float32),
("FootprintTextureWidth", FieldType::Float32),
("FootprintParticleScale", FieldType::Float32),
("FoleyMaterialID", FieldType::UInt32),
("FootstepShakeSize", FieldType::UInt32),
("DeathThudShakeSize", FieldType::UInt32),
("SoundID", FieldType::UInt32),
("CollisionWidth", FieldType::Float32),
("CollisionHeight", FieldType::Float32),
] {
s.add_field(SchemaField::new(name, ty));
}
s
}
/// CreatureDisplayInfo.dbc: 12 fields in build 5875.
pub(crate) fn creature_display_info_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfo");
for (name, ty) in [
("ID", FieldType::UInt32),
("ModelID", FieldType::UInt32),
("SoundID", FieldType::UInt32),
("ExtendedDisplayInfoID", FieldType::UInt32),
("CreatureModelScale", FieldType::Float32),
("CreatureModelAlpha", FieldType::UInt32),
("TextureVariation0", FieldType::String),
("TextureVariation1", FieldType::String),
("TextureVariation2", FieldType::String),
// Not `PortraitTextureName` (absent in 5875): a signed size class, tested for -1 at
// `0x625509`.
("SizeClass", FieldType::UInt32),
("BloodLevel", FieldType::UInt32),
// Some maps label this BloodID, but its 5875 values (33..188) are NPC sound ids.
("NPCSoundID", FieldType::UInt32),
] {
s.add_field(SchemaField::new(name, ty));
}
s
}
/// CreatureDisplayInfoExtra.dbc: 19 fields, 76-byte records in 5875 (vmangos `DBCStructure.h`).
pub(crate) fn creature_display_info_extra_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfoExtra");
for (name, ty) in [
("ID", FieldType::UInt32),
("Race", FieldType::UInt32),
("Sex", FieldType::UInt32),
("SkinColor", FieldType::UInt32),
("FaceType", FieldType::UInt32),
("HairStyle", FieldType::UInt32),
("HairColor", FieldType::UInt32),
("FacialHair", FieldType::UInt32),
] {
s.add_field(SchemaField::new(name, ty));
}
for i in 0..10 {
s.add_field(SchemaField::new(format!("Equipment{i}"), FieldType::UInt32));
}
s.add_field(SchemaField::new("BakeName", FieldType::String));
s
}
/// Load the creature DBCs from the patch chain into a [`CreatureCatalog`].
pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
let models = {
let bytes = chain
.read_file(CREATURE_MODEL_DATA)
.with_context(|| format!("reading {CREATURE_MODEL_DATA}"))?;
let rs = parse(&bytes, creature_model_data_schema(), "CreatureModelData")?;
let mut m = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 2)) {
m.insert(
id,
ModelRow {
path: name,
scale: f32_at(r, 4).unwrap_or(1.0),
flags: u32_at(r, 1).unwrap_or(0),
size_class: u32_at(r, 3).map_or(-1, |v| v as i32),
blood: u32_at(r, 5).map_or(0, |v| v as i32),
footprint_texture: u32_at(r, 6).map_or(-1, |v| v as i32),
footprint_length: f32_at(r, 7).unwrap_or(0.0),
footprint_width: f32_at(r, 8).unwrap_or(0.0),
collision_height: f32_at(r, 15).unwrap_or(0.0),
foley_material: u32_at(r, 10).unwrap_or(0),
footstep_shake: u32_at(r, 11).unwrap_or(0),
death_thud_shake: u32_at(r, 12).unwrap_or(0),
},
);
}
}
m
};
let display = {
let bytes = chain
.read_file(CREATURE_DISPLAY_INFO)
.with_context(|| format!("reading {CREATURE_DISPLAY_INFO}"))?;
let rs = parse(
&bytes,
creature_display_info_schema(),
"CreatureDisplayInfo",
)?;
let mut d = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let (Some(id), Some(model_id)) = (u32_at(r, 0), u32_at(r, 1)) {
d.insert(
id,
DisplayRow {
model_id,
extended_id: u32_at(r, 3).unwrap_or(0),
scale: f32_at(r, 4).unwrap_or(1.0),
textures: [str_at(&rs, r, 6), str_at(&rs, r, 7), str_at(&rs, r, 8)],
blood_level: u32_at(r, 10).unwrap_or(0),
size_class: u32_at(r, 9).map_or(-1, |v| v as i32),
model_alpha: u32_at(r, 5).unwrap_or(255),
},
);
}
}
d
};
// Best-effort: without it only humanoid NPCs lose their skins; the caller logs `extra_len()`.
let extra = load_creature_display_info_extra(chain).unwrap_or_default();
Ok(CreatureCatalog {
display,
models,
extra,
})
}
/// Load CreatureDisplayInfoExtra.dbc: `extendedDisplayInfoId` → [`NpcAppearance`].
fn load_creature_display_info_extra(chain: &mut Chain) -> Result<HashMap<u32, NpcAppearance>> {
let bytes = chain
.read_file(CREATURE_DISPLAY_INFO_EXTRA)
.with_context(|| format!("reading {CREATURE_DISPLAY_INFO_EXTRA}"))?;
let rs = parse(
&bytes,
creature_display_info_extra_schema(),
"CreatureDisplayInfoExtra",
)?;
let mut e = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
if let Some(id) = u32_at(r, 0) {
e.insert(
id,
NpcAppearance {
race: u32_at(r, 1).unwrap_or(0) as u8,
sex: u32_at(r, 2).unwrap_or(0) as u8,
skin: u32_at(r, 3).unwrap_or(0) as u8,
face: u32_at(r, 4).unwrap_or(0) as u8,
hair_style: u32_at(r, 5).unwrap_or(0) as u8,
hair_color: u32_at(r, 6).unwrap_or(0) as u8,
facial_hair: u32_at(r, 7).unwrap_or(0) as u8,
equipment: std::array::from_fn(|i| u32_at(r, 8 + i).unwrap_or(0)),
bake_name: str_at(&rs, r, 18),
},
);
}
}
Ok(e)
}
#[cfg(test)]
mod tests {
use super::*;
/// `BloodID = −1` is a tier-2 miss, so 595 displays reach tier 3 and bleed red.
#[test]
fn blood_row_tiers_over_the_shipped_displays() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let blood = crate::load_blood_catalog(&mut chain).expect("blood tables");
// A tier resolves iff `level_key` finds its id with the other tier forced to miss.
let resolves = |v: i32| {
u32::try_from(v)
.ok()
.is_some_and(|k| blood.level_key(v, i32::MIN) == Some(k))
};
let mut tiers = [0usize; 3];
for &display_id in cat.display.keys() {
let Some(m) = cat.model(display_id) else {
continue;
};
tiers[if resolves(m.blood_display) {
0
} else if resolves(m.blood_model) {
1
} else {
2
}] += 1;
}
assert_eq!(
tiers,
[36, 9903, 595],
"tier 1 / tier 2 / tier 3 over the 10534 shipped displays"
);
assert_eq!(tiers.iter().sum::<usize>(), cat.display.len());
}
/// Tier 3 is ordinary fauna, so its records base cannot mean no blood.
#[test]
fn the_tier_three_fallback_bleeds_red_on_ordinary_creatures() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let blood = crate::load_blood_catalog(&mut chain).expect("blood tables");
// Each has `BloodID = −1` and no display override: tier 3 alone resolves it.
for path in [
r"Creature\Quillboar\QuillBoar.mdx",
r"Creature\Crocodile\Crocodile.mdx",
r"Creature\GnollMelee\GnollMelee.mdx",
r"Creature\MountainGiant\MountainGiant.mdx",
r"Creature\NagaFemale\Siren.mdx",
r"Creature\Troll\TrollMelee.mdx",
] {
let mut seen = 0usize;
for &display_id in cat.display.keys() {
let Some(m) = cat.model(display_id) else {
continue;
};
if !m.model_path.eq_ignore_ascii_case(path) {
continue;
}
seen += 1;
assert_eq!(
(m.blood_display, m.blood_model),
(0, -1),
"{path} display {display_id} is no longer a pure tier-3 case"
);
assert_eq!(
blood.level_key(m.blood_display, m.blood_model),
Some(1),
"{path} display {display_id} must fall through to the records base (RED)"
);
}
assert!(seen > 0, "{path} is missing from the shipped display table");
}
// The records-base row draws, both facings and both sizes.
for (front, large) in [(true, false), (true, true), (false, false), (false, true)] {
assert!(
blood.effect_id(1, 2, front, large).is_some(),
"the records-base row draws nothing (front {front}, large {large})"
);
}
}
/// Nine models have two `CreatureModelData` rows that disagree about blood, eight with one side
/// `−1`: the value is unfilled, which tier 3 handles.
#[test]
fn the_minus_one_blood_id_is_unfilled_data() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let mut by_path: HashMap<String, Vec<i32>> = HashMap::new();
for m in cat.models.values() {
by_path
.entry(m.path.to_ascii_lowercase())
.or_default()
.push(m.blood);
}
let mut split: Vec<&str> = by_path
.iter()
.filter(|(_, ids)| ids.contains(&-1) && ids.iter().any(|&v| v > 0))
.map(|(p, _)| p.as_str())
.collect();
split.sort_unstable();
assert_eq!(
split.len(),
8,
"models whose duplicate rows disagree about blood, one of them −1: {split:?}"
);
assert!(
split.iter().any(|p| p.ends_with(r"murloc\babymurloc.mdx")),
"the Baby Murloc pair is the clearest case and must be among them: {split:?}"
);
// Within one creature family, one model says −1 and its siblings name a colour.
let blood_of = |needle: &str| {
cat.models
.values()
.find(|m| m.path.to_ascii_lowercase().ends_with(needle))
.unwrap_or_else(|| panic!("{needle} is not in CreatureModelData"))
.blood
};
for (unspecified, sibling) in [
(
r"quillboar\quillboar.mdx",
r"quillboar\quillboarwarrior.mdx",
),
(r"gnollmelee\gnollmelee.mdx", r"gnollcaster\gnollcaster.mdx"),
(r"troll\trollmelee.mdx", r"troll\troll.mdx"),
(r"nagafemale\siren.mdx", r"nagamale\nagamale.mdx"),
] {
assert_eq!(
blood_of(unspecified),
-1,
"{unspecified} should be the unfilled one"
);
assert!(
blood_of(sibling) > 0,
"{sibling} should name a real colour, splitting its own family"
);
}
}
/// HumanMale's Base boot print (id 1) is 12×10 inches, 1/3 × 5/18 yd; a `−1` model has none.
#[test]
fn footprints_resolve_on_real_data() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let human = cat
.display
.iter()
.find(|(_, r)| {
cat.models.get(&r.model_id).is_some_and(|m| {
m.path
.eq_ignore_ascii_case(r"Character\Human\Male\HumanMale.mdx")
})
})
.map(|(&d, _)| d)
.expect("some display wears the HumanMale body");
let p = cat.footprint(human).expect("HumanMale leaves prints");
assert_eq!(p.texture_id, 1, "the Base boot print");
assert!((p.length - 12.0 / 36.0).abs() < 1e-6, "length {}", p.length);
assert!((p.width - 10.0 / 36.0).abs() < 1e-6, "width {}", p.width);
let printless = cat
.display
.iter()
.find(|(_, r)| {
cat.models
.get(&r.model_id)
.is_some_and(|m| m.footprint_texture == -1)
})
.map(|(&d, _)| d)
.expect("some display over a printless model");
assert_eq!(cat.footprint(printless), None);
}
#[test]
fn character_model_npcs_resolve_a_shipped_baked_atlas() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
assert!(
cat.extra_len() > 1000,
"CreatureDisplayInfoExtra loaded ({} rows)",
cat.extra_len()
);
let mut verified = 0;
for (&disp, row) in &cat.display {
if row.extended_id == 0 {
continue;
}
let Some(m) = cat.model(disp) else { continue };
let Some(bake) = m.npc_appearance.as_ref().and_then(|a| a.bake_name.as_ref()) else {
continue;
};
assert!(
m.model_path.to_ascii_lowercase().starts_with("character\\"),
"an extended-display NPC wears a Character\\ body, got {}",
m.model_path
);
let path = baked_atlas_path(bake);
assert!(
chain.read_file(&path).is_ok(),
"the baked body atlas ships: {path}"
);
verified += 1;
if verified >= 20 {
break;
}
}
assert!(
verified >= 5,
"found + verified several baked character-model NPCs (got {verified})"
);
// The full surface: every bake name resolves by the client rule (`baked_atlas_path`), and
// a bare-name row whose atlas ships only once `.blp` is appended is the TurtleWoW case.
// Rows that ship neither way name content the reference cannot load either, so only the
// rule is asserted; the count is printed.
let mut shipped = 0;
let mut missing: Vec<&str> = Vec::new();
for app in cat.extra.values() {
let Some(bake) = app.bake_name.as_deref() else {
continue;
};
if chain.read_file(&baked_atlas_path(bake)).is_ok() {
shipped += 1;
} else {
missing.push(bake);
}
}
eprintln!(
"baked atlases ship for {shipped} rows; {} rows name an absent atlas",
missing.len()
);
assert!(
shipped > 1000,
"the baked NPC atlases ship across the catalog (got {shipped})"
);
}
/// The three arms of `0x625500`: an override, a `−1` deferral and an agreeing row.
#[test]
fn the_size_class_column_resolves_its_three_arms() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
assert_eq!(
cat.size_class(792),
Some(3),
"Gorilla display overrides to Giant"
);
assert_eq!(
cat.size_class(170),
Some(4),
"Sea Giant falls back to Colossal"
);
assert_eq!(
cat.size_class(1921),
Some(3),
"Ancient Protector agrees at Giant"
);
assert_eq!(cat.size_class(49), Some(1), "HumanMale is Medium");
assert_eq!(cat.size_class(0), None, "no such display");
// Every display lands in `0..=4`, so the reference's unsigned `>= 5` gate never fires here.
let mut n = 0;
for &id in cat.display.keys() {
let class = cat
.size_class(id)
.expect("every display resolves a size class");
assert!(
class <= 4,
"display {id} resolves to {class}, past Colossal"
);
n += 1;
}
assert_eq!(n, 10_534, "the whole shipped display table");
}
/// Fields 11 and 12 are `CameraShakes.dbc` ids; 25 models, all heavy, carry a footstep shake.
#[test]
fn the_footstep_shake_columns_are_the_thumping_giants() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
// Display 1921 is the plain Ancient Protector (model 67): footstep row 1, thud row 11.
assert_eq!(
cat.footstep_shake(1921),
Some(1),
"Ancient Protector footstep"
);
assert_eq!(
cat.death_thud_shake(1921),
Some(11),
"Ancient Protector thud"
);
// Display 1460 is Onu's Ancient of Lore (model 187), the heavier row 2.
assert_eq!(
cat.footstep_shake(1460),
Some(2),
"Ancient of Lore footstep"
);
// The control: a player body shakes nothing in either column.
assert_eq!(cat.footstep_shake(49), None, "HumanMale leaves no thump");
assert_eq!(cat.death_thud_shake(49), None, "nor a thud");
// Every id a creature names lands on a row of the 24-row table.
let shakes = crate::load_camera_shakes(&mut chain).expect("load CameraShakes.dbc");
let mut footstep = 0;
for (_, path, foot, thud) in cat.shaking_models() {
if foot != 0 {
footstep += 1;
assert!(shakes.get(foot).is_some(), "{path} footstep {foot} dangles");
}
if thud != 0 {
assert!(shakes.get(thud).is_some(), "{path} thud {thud} dangles");
}
}
assert_eq!(footstep, 25, "the shipped footstep-shake census");
}
#[test]
fn stormwind_guard_equipment_columns_decode_in_bodyslot_order() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let guard = cat
.model(3167)
.expect("Stormwind City Guard display 3167 resolves");
let npc = guard
.npc_appearance
.expect("display 3167 is a character-model NPC with an appearance row");
// Body-slot order: head, shoulder, shirt, chest, belt, pants, boots, wrist, gloves, tabard.
assert_eq!(
npc.equipment,
[14964, 7541, 7223, 0, 7224, 7225, 7255, 0, 7698, 6255],
"SW Guard worn-equipment ids in bodyslot order"
);
}
#[test]
fn the_breathless_models_are_the_ones_with_no_breath() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
assert_eq!(
cat.models.values().filter(|m| m.flags & 0x2 != 0).count(),
99,
"the shipped census: 99 of {} models suppress breath",
cat.models.len()
);
for (display_id, label) in [
(49, "HumanMale"),
(50, "HumanFemale"),
(53, "DwarfMale"),
(59, "TaurenMale"),
(1564, "GnomeFemale"),
] {
assert!(cat.breathes(display_id), "{label} breathes");
}
for (display_id, label) in [
(158, "Skeleton"),
(110, "WaterElemental"),
(169, "Infernal"),
] {
assert!(!cat.breathes(display_id), "{label} has no breath to see");
}
assert!(
cat.breathes(0),
"an unknown display falls to the common case"
);
}
/// Every shipped model has `FoleyMaterialID` 0; a data file that grows one fails here.
#[test]
fn no_shipped_model_carries_a_foley() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("creature catalog");
assert!(!cat.models.is_empty(), "models loaded");
let foleyed: Vec<u32> = cat
.models
.iter()
.filter(|(_, m)| m.foley_material != 0)
.map(|(&id, _)| id)
.collect();
assert!(
foleyed.is_empty(),
"shipped data grew a creature foley material: models {foleyed:?}"
);
// Field 11 is not all zero, so the zero at field 10 is the data, not a slid schema.
let shakers = cat
.models
.values()
.filter(|m| m.footstep_shake != 0)
.count();
assert_eq!(shakers, 25, "footstep-shake rows (schema alignment guard)");
}
#[test]
fn collision_height_is_the_m2_collision_box() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
// (display id, label, expected column); ids from ChrRaces Male/FemaleDisplayId.
let races: &[(u32, &str, f32)] = &[
(49, "HumanMale", 2.031),
(50, "HumanFemale", 1.913),
(51, "OrcMale", 2.361),
(52, "OrcFemale", 2.051),
(53, "DwarfMale", 1.667),
(54, "DwarfFemale", 1.528),
(55, "NightElfMale", 2.438),
(56, "NightElfFemale", 2.250),
(57, "ScourgeMale", 1.861),
(58, "ScourgeFemale", 1.844),
(59, "TaurenMale", 1.653),
(60, "TaurenFemale", 2.111),
(1563, "GnomeMale", 1.056),
(1564, "GnomeFemale", 1.000),
(1478, "TrollMale", 2.083),
(1479, "TrollFemale", 1.839),
];
for &(display_id, label, expect) in races {
let m = cat
.model(display_id)
.unwrap_or_else(|| panic!("{label}: display {display_id} resolves"));
let h = cat.collision_height(display_id).expect("collision height");
assert!(
(h - expect).abs() < 5e-4,
"{label}: column is {h}, expected {expect}"
);
assert_eq!(h, m.collision_height, "{label}: accessor vs CreatureModel");
let bytes = chain
.read_file(&crate::models::model_path(&m.model_path))
.unwrap_or_else(|e| panic!("{label}: read {}: {e:#}", m.model_path));
let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes[..]))
.unwrap_or_else(|e| panic!("{label}: parse M2: {e:#}"));
let hdr = &fmt.model().header;
let box_z = (hdr.collision_box_max[2] - hdr.collision_box_min[2]).abs();
assert!(
(box_z - h).abs() < 5e-4,
"{label}: MD20 collision box Z extent {box_z} != column {h}"
);
}
// Not vacuous: the races differ.
let gnome = cat.collision_height(1564).unwrap();
let nelf = cat.collision_height(55).unwrap();
assert!(
nelf > gnome * 2.0,
"a night elf is over twice a gnome ({nelf} vs {gnome}) — the whole point of the plumb"
);
}
/// vmangos folds both scales into `SCALE_X`, so the prism's `max(SCALE_X, display scale)` floor
/// bites only where `modelScale < 1`, and no shipped row is below 1.
#[test]
fn no_shipped_model_scales_below_one_so_the_prism_floor_is_inert_at_rest() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let under: Vec<_> = cat
.models
.iter()
.filter(|(_, m)| m.scale < 1.0)
.map(|(id, m)| (*id, m.scale))
.collect();
assert!(
under.is_empty(),
"a sub-1 modelScale would make the floor bite at rest: {under:?}"
);
// Not vacuous: the column is read, and varies.
assert!(
cat.models.values().any(|m| m.scale > 1.0),
"some row scales above 1.0, or this is asserting on a zeroed column"
);
}
/// The prism comes from `UNIT_FIELD_NATIVEDISPLAYID`, so a druid in form keeps the druid's
/// depth lines, where the form's row would move the swim line up to 0.72 yd. A form scales
/// `SCALE_X` by 1.0 or 0.80 (vmangos `GetShapeshiftDisplayInfo`).
#[test]
fn a_shapeshift_moves_the_collision_prism_and_the_native_row_is_what_stops_it() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let h = |display: u32, scale_x: f32| {
let col = cat.collision_height(display).expect("collision height");
let s = cat.display_scale(display).expect("display scale");
col * scale_x.max(s)
};
// (label, native display, form display, native SCALE_X, the form's scale factor)
let cases: &[(&str, u32, u32, f32, f32)] = &[
("NElf M → cat", 55, 892, 1.0, 0.80),
("NElf M → bear", 55, 2281, 1.0, 1.0),
("Tauren M → moonkin", 59, 15375, 1.35, 1.0),
("Tauren M → bear", 59, 2289, 1.35, 1.0),
];
let mut worst: f32 = 0.0;
for &(label, native, form, native_scale_x, factor) in cases {
let scale_x = native_scale_x * factor;
let (reference, ours_before) = (h(native, scale_x), h(form, scale_x));
let swim_delta = 0.75 * (ours_before - reference);
assert!(
swim_delta.abs() > 0.05,
"{label}: the two readings must actually differ, else this test asserts nothing \
(reference {reference}, form-derived {ours_before})"
);
worst = worst.max(swim_delta.abs());
}
assert!(
(worst - 0.72).abs() < 0.02,
"worst swim-line divergence is {worst} yd, the doc says 0.72"
);
// The sign flips with the form, so this is no constant offset.
assert!(h(892, 0.80) < h(55, 0.80), "NElf cat: form row is shorter");
assert!(
h(15375, 1.35) > h(59, 1.35),
"Tauren moonkin: form row is taller"
);
// The tauren bear value seen live: h = 2.083 × 1.35.
assert!(
(h(2289, 1.35) - 2.083 * 1.35).abs() < 5e-3,
"tauren bear form-derived h should reproduce the 2.812 seen live"
);
}
/// The Shore Strider (display 4945, model 35): column 2.083 × display scale 1.75 = 3.645 yd
/// with or without the floor, since its `modelScale` is 1.0.
#[test]
fn the_shore_strider_prism_is_the_same_under_both_readings() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
let m = cat.model(4945).expect("display 4945 resolves");
assert_eq!(m.model_path, "Creature\\SeaGiant\\SeaGiant.mdx");
let column = cat.collision_height(4945).expect("collision height");
let display_scale = cat.display_scale(4945).expect("display scale");
assert!((column - 2.083).abs() < 5e-4, "column is {column}");
assert!(
(display_scale - 1.75).abs() < 5e-4,
"CreatureDisplayInfo.scale is {display_scale}"
);
// vmangos ships `creature_template.display_scale = 0` for entry 5359, so SCALE_X is the
// folded `modelScale × displayScale` = 1.0 × 1.75.
let scale_x = m.scale;
assert!((scale_x - 1.75).abs() < 5e-4, "folded SCALE_X is {scale_x}");
assert_eq!(
column * scale_x,
column * scale_x.max(display_scale),
"the floor is inert on this row — the height was not the bug"
);
}
}