creature: a display that names a model is drawn, whatever its scale column says

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-18 09:02:19 -06:00
co-authored by Claude Opus 5
parent e527931885
commit df921cdb47
2 changed files with 75 additions and 4 deletions
+23 -4
View File
@@ -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()
}
}
}
+52
View File
@@ -508,6 +508,58 @@ mod tests {
assert_eq!(tiers.iter().sum::<usize>(), 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<u32> = 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<u32>, Vec<u32>) =
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() {