From df921cdb476be2653db100d9a5884f06fa85753d Mon Sep 17 00:00:00 2001 From: grom Date: Sun, 18 Oct 2026 09:02:19 -0600 Subject: [PATCH] creature: a display that names a model is drawn, whatever its scale column says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A screenshot of the Gnomeregan Reclamation Facility (OctoWoW's own area 5211) with its NPCs replaced by flat grey boxes, and the target frame filled — `M. C. Volterwhite`, 57, 4.13k — behind an empty portrait. The boxes are `entities::attach`'s model-less debug cube, and the empty portrait is the same cause: the display resolved to nothing to draw. `new_creature_display` took a row only `if m.scale > 0.0`, where `scale` is `CreatureModelData.ModelScale × CreatureDisplayInfo.CreatureModelScale`. That column does not scale a spawned unit: the server folds it into `OBJECT_FIELD_SCALE_X`, which is why `Creatures::model_scale` says in its own doc that the glue screens' pet is its only reader. So the gate was testing a number nothing downstream consults, and a `0.0` in it meant "no model". His chain's `CreatureDisplayInfo.dbc` (the effective copy, `patch-A.mpq` — patches sort ascending by lowercase name, so A wins over 5) carries a contiguous custom block, **20256-20356, 100 rows, every one a character model**: 27 HumanMale, 16 HumanFemale, 16 TrollMale, 12 GnomeMale, 10 NightElfFemale and the rest, all authored with that column left at `0.0`. One batch of custom NPCs, one zone's worth of grey boxes. Stock 1.12 ships three of its own that way too (displays 16, 17 and 19: HumanThief, HUMLBlackSmith, HUMLMerchant), which is the proof the column was never a visibility switch. The gate is gone. The one shape left that earns a cube — a display id with no row in either DBC — now says so once per id, as the GameObject leg already does, so the next zone full of cubes names the ids instead of needing a screenshot. A test over the real chain pins the data: every zero-scale row still names a real model, and the only row that answers `None` does so for want of a `CreatureModelData` row (display 185, `ModelID = 0`), not for its scale. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/entities/display.rs | 27 +++++++++-- crates/benilla-formats/src/creatures.rs | 52 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) 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() {