diff --git a/crates/benilla-app/src/entities/display.rs b/crates/benilla-app/src/entities/display.rs index c096a160..10812de1 100644 --- a/crates/benilla-app/src/entities/display.rs +++ b/crates/benilla-app/src/entities/display.rs @@ -191,21 +191,30 @@ impl DisplayModel { } /// Whether the display names a model file. An empty `parts` from a named model is that model's - /// answer, draw nothing; [`empty_display`] (no catalog row, a zero-scale row, no model path) is + /// answer, draw nothing; [`empty_display`] (no catalog row, no model path) is /// an unresolved display, and earns the debug cube. pub(super) fn names_a_model(&self) -> bool { !matches!(self.handle, ModelHandle::None) } } -/// A creature display's M2, its skins filled at build; a missing or zero-scale row is empty. +/// A creature display's M2, its skins filled at build; only a display with no row is empty. +/// +/// It does **not** gate on `CreatureDisplayInfo.CreatureModelScale`. That column does not scale a +/// spawned unit here or in the reference — the server folds it into `OBJECT_FIELD_SCALE_X` +/// (see [`super::Creatures::model_scale`], whose only reader is the glue screens' pet), so a unit +/// is drawn at the size the server sent and the column is never consulted. Gating on it cost this +/// chain a contiguous block of **100 custom display rows, 20256-20356**, every one a character +/// model (27 HumanMale, 16 HumanFemale, 16 TrollMale, 12 GnomeMale…) authored with the column left +/// at `0.0` — a whole zone's NPCs, each one a red debug cube with an empty portrait instead of a +/// body. A row that names a model names a model. pub(super) fn new_creature_display( catalog: &CreatureCatalog, display_id: u32, asset_server: &AssetServer, ) -> DisplayModel { match catalog.model(display_id) { - Some(m) if m.scale > 0.0 => DisplayModel { + Some(m) => DisplayModel { handle: ModelHandle::M2(asset_server.load(m2_url(&m.model_path))), dir: model_dir(&m.model_path).to_string(), skins: m.textures, @@ -216,7 +225,17 @@ pub(super) fn new_creature_display( .is_some_and(|p| p.eq_ignore_ascii_case("character\\")), ..empty_shell() }, - _ => empty_display(), + // The one way left for a unit to end up as the red debug cube, said once per display id + // (a display is built once and cached). The cube is a gap of ours — the reference draws + // the model, or nothing, but never a box — so this line is what names the display the + // server sent that this chain's DBCs cannot answer for. + None => { + info!( + "creature display {display_id} has no row in this chain's \ + CreatureDisplayInfo/CreatureModelData — nothing to draw but the debug cube" + ); + empty_display() + } } } diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 0fc8d49a..db6d80af 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -508,6 +508,58 @@ mod tests { assert_eq!(tiers.iter().sum::(), cat.display.len()); } + /// A zero `CreatureDisplayInfo.CreatureModelScale` cannot mean "no model": the column does not + /// scale a spawned unit at all — the server folds it into `OBJECT_FIELD_SCALE_X`, and the only + /// reader on this side is the glue screens' pet — so rows carrying a `0.0` still name a real + /// model and must still be drawn. + /// + /// Stock 1.12 ships three of them (displays 16, 17 and 19: HumanThief, HUMLBlackSmith, + /// HUMLMerchant), and OctoWoW's chain adds a contiguous custom block, 20256-20356, every row a + /// character model. Gating a display on this column turned that whole block into red debug + /// cubes with empty portraits — a zone's worth of NPCs. + #[test] + fn a_zero_display_scale_still_names_a_real_model() { + 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 zero: Vec = cat + .display + .iter() + .filter(|(_, row)| row.scale == 0.0) + .map(|(id, _)| *id) + .collect(); + assert!( + !zero.is_empty(), + "every chain carries some: stock 1.12 alone has displays 16, 17 and 19" + ); + // A row whose `ModelID` names no `CreatureModelData` row is a different shape, and the + // only one that earns the debug cube: the chain carries exactly one, display 185 with + // `ModelID = 0`. + let (no_model, named): (Vec, Vec) = + zero.iter().partition(|id| cat.model(**id).is_none()); + for id in &no_model { + let row = &cat.display[id]; + assert!( + !cat.models.contains_key(&row.model_id), + "display {id} answers None only for want of CreatureModelData row {}", + row.model_id + ); + } + assert!( + !named.is_empty(), + "every chain carries some: stock 1.12 alone has displays 16, 17 and 19" + ); + for id in &named { + let m = cat.model(*id).expect("partitioned as resolvable"); + assert!( + !m.model_path.trim().is_empty(), + "display {id} carries a zero scale but still names {:?}", + m.model_path + ); + } + } + /// Tier 3 is ordinary fauna, so its records base cannot mean no blood. #[test] fn the_tier_three_fallback_bleeds_red_on_ordinary_creatures() {