Files
benilla-octo/crates/benilla-formats/src/items.rs
T
SamandClaude Fable 5.1 5f622e6fe8 air elementals, Ragnaros and the forge lava animate
- every unit, GameObject and held item runs its texture animation: air elementals, Ragnaros, the Ash'ari Crystal, Ironforge's forge lava, the Scourge rune circles and the Zul'Gurub forcefields were static (2295)
- a GameObject whose animation slots disagree gets a material of its own: the Blood of Heroes pools bubble and the Freezing Trap's glow pulses (2295)
- three sound kits with a doubled separator in their path were silent, the flight-path-discovered chime among them; the Ashbringer's lines stay silent, as they are in 1.12 (2294)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 16:42:33 +07:00

425 lines
21 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.
//! `ItemDisplayInfo.dbc` adapter — displayId → held-item / worn-equipment visual identity (decision
//! 0072 slice 1: combat presence, held items via the display chain).
//!
//! Layout — **TRIPLE-VERIFIED** against build 5875 (`decisions/0072`: our raw dump
//! `record_count=29604`; wow-re's `charactermodel` disasm; wow-re's independent dbc-node schema
//! catalog): 23 fields / 92-byte records. Columns: `0` id (key) · `1`/`2` model name L/R (string) ·
//! `3`/`4` model texture L/R (string) · `5` icon (string, unread — no consumer yet) · `6`-`8`
//! geosetGroup[0..2] (u32) · `9` **flags** (u32 — the client's `ItemDisplayInfo_inmem[+0x24]`, of
//! which only **bit 0** is read: the guild-emblem tabard, [`ItemDisplay::takes_guild_emblem`]) ·
//! `10` the ranged-weapon `SpellVisual.dbc`
//! id (u32 — the substitute visual a RANGED-attribute spell with no own visual borrows for its
//! fire animation; byte-verified `0x60d493: mov eax,[eax+0x28]`, wow-re
//! `throw-ranged-attack-anim.md`) · `11` the `ItemGroupSounds.dbc` id
//! (u32 — the pickup/place sound group; byte-verified `0x458008: mov eax,[edx+0x2c]`, wow-re
//! `system/sound/scratch/item-pickup-place-sound.md`, corroborated 20513/20513 valid ids on the
//! real DBC) · `12`/`13` helm-vis (u32) · `14`-`21` the 8 body-region textures, in **ArmUpper,
//! ArmLower, Hand, TorsoUpper, TorsoLower, LegUpper, LegLower, Foot** order (string) · `22` the
//! **`ItemVisuals.dbc` id** — the item's intrinsic glow (i32; byte-verified `0x47a200: mov
//! edx,[esi+0x58]`, the held-item attach handing it on — decision 0805,
//! [`crate::ItemVisualCatalog`]).
//!
//! **Trap, load-bearing:** `model` and `model_texture` are **independently-resolved basenames** in
//! the same `Item\ObjectComponents\<dir>\` folder — never derive one from the other. Real
//! counter-example: the Worn Wooden Shield row carries model `Shield_Round_A_01.mdx` (→ `.m2` on
//! disk) alongside texture `Buckler_Damaged_A_01Purple` (no relation to the model's own name).
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, RecordSet, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::models::model_path;
const ITEM_DISPLAY_INFO: &str = "DBFilesClient\\ItemDisplayInfo.dbc";
/// One `ItemDisplayInfo` row: the visual identity a held item or worn equipment display resolves to.
///
/// `model`/`model_texture` carry **no directory prefix** — the app layer owns the
/// `Item\ObjectComponents\<dir>\` join, since `<dir>` depends on the item's inventory type (Weapon,
/// Shield, Head, …), which isn't a column in this DBC.
#[derive(Debug, Clone, Default)]
pub struct ItemDisplay {
/// Left/right model basename (index 0/1), the existing `.mdx`→`.m2` normalizer applied
/// (lowercased, extension swapped) — see [`crate::models::model_path`]. No directory. `None` for
/// an empty column (most rows carry only the left slot; two-handed/dual visuals use both).
pub model: [Option<String>; 2],
/// Left/right model-texture basename (index 0/1) — raw, **no extension** (the app appends
/// `.blp`) and **not** derived from [`Self::model`] (see the module trap note).
pub model_texture: [Option<String>; 2],
/// `geosetGroup[0..2]` — worn-equipment geoset selectors (robes etc.); `0` where unauthored.
/// Unused by held items.
pub geoset_groups: [u32; 3],
/// The 8 body-region texture overrides this display paints on, in ArmUpper/ArmLower/Hand/
/// TorsoUpper/TorsoLower/LegUpper/LegLower/Foot order. `None` per-slot where this display
/// doesn't touch that region (most held-item rows: all 8 empty).
pub region_textures: [Option<String>; 8],
/// A helm's `HelmetGeosetVisData` row ids — `[male, female]` (cols 12/13; VERIFIED wow-re
/// RF-0083: `ItemDisplayInfo_inmem[+0x30 + sex*4]`). `0` = no vis row (non-helm displays).
pub helmet_vis: [u32; 2],
/// The inventory icon (col 5) as a ready `Interface\Icons\…` MPQ path, extensionless as the
/// DBC stores names (the BLP loader appends it). Unlike the model columns there is no
/// variable directory to join — the icon dir is fixed — so this one ships app-ready, same as
/// `SpellCatalog`'s icons. `None` on the ~26% of rows that are icon-less visual attachments.
/// Anchored on live server-truth pairings (vmangos `item_template.display_id`, the values the
/// real wire answers): Worn Shortsword 1542 → `INV_Sword_04`, Tough Jerky 2473 →
/// `INV_Misc_Food_16`, Worn Wooden Shield 18730 → `INV_Shield_09`, Hearthstone 6418 →
/// `INV_Misc_Rune_01`.
pub icon: Option<String>,
/// The `ItemGroupSounds.dbc` id (col 11) — the item's pickup/place/use sound group
/// ([`crate::item_sounds`]). `0` = no group (the display drags silently), matching the client's
/// bounds-check-and-return (`0x45800b`).
pub group_sounds: u32,
/// The ranged-weapon `SpellVisual.dbc` id (col 10) — the SUBSTITUTE visual a RANGED-attribute
/// spell with no own visual borrows from the equipped ranged weapon (the client's `0x60d450`
/// fallback: how Throw/Auto Shot get their fire clips — wow-re `throw-ranged-attack-anim.md`).
/// `0` on every non-ranged display; only three distinct nonzero ids exist across the real
/// table (thrown 98 · bow 5 · gun/rifle 224).
pub spell_visual: u32,
/// The flags word (col 9) — the client's `ItemDisplayInfo_inmem[+0x24]`. Only **bit 0** has a
/// reader in the image, and it means *this garment takes the wearer's guild emblem*
/// ([`Self::takes_guild_emblem`]); bit 1 appears on four chest/robe rows with no reader found.
/// Carried whole rather than as a bool so the unread bits stay visible.
pub flags: u32,
/// The `ItemVisuals.dbc` id (col 22) — the display's **intrinsic glow**: the permanent weapon
/// glows, resolved to up to five `Spells\Enchantments\*.mdx` models by
/// [`crate::ItemVisualCatalog`] (decision 0805). **Signed**, because the client reads it that
/// way (`0x4798c0`'s `jle` gate): `0` = none on 29 239 of the 29 604 rows, and **five shipped
/// rows carry `-1`**, which is also none.
pub item_visual: i32,
}
impl ItemDisplay {
/// The `HelmetGeosetVisData` row pair (`[male, female]`) this display hides hair / facial hair /
/// ears with — **only when the display is actually a worn helm**, i.e. it names a head model.
///
/// The gate is the point. `helmet_vis` is authored on 1314 of the 29604 shipped rows, and **12
/// of those name no model at all** — jewellery-shaped rows (15676 is an
/// `INV_Jewelry_Amulet_01` icon with no mesh) that still carry a full hide mask. Nothing wears
/// them: with no `ModelName` there is no helm to attach and no helm to tuck hair under. But
/// `CreatureDisplayInfoExtra`'s head column points **126 character-model NPC displays** at
/// exactly those rows, and honouring the mask there strips the NPC's hairstyle to the bare
/// scalp (geoset 1), its ears to the tucked variant (701) and its earrings/beard to their
/// group bases — while 1.12.1 renders them in full (B93: Jubie Gadgetspring, display 7969 →
/// extra 5503 → head display **15676** → vis row **306** = `[446,478,510,222,238]`, every
/// column with the gnome bit `1<<7` set).
///
/// **The gate is `ModelName[0]` alone** — on-disk column 1, the LEFT slot — VERIFIED at the
/// bytes (wow-re RF-0085, `0x4799c1`): the head-slot handler `0x4799a0` loads
/// `[[cc+0x4a8] + 4]` and `cmp byte ptr [ecx],0`, a **string-emptiness** test, jumping straight
/// to the epilogue `0x479b33` past the whole geoset-vis tail. Column 2 (the right slot) is
/// never consulted, and neither is the helm M2's load result — `0x4798c0`'s return is clobbered
/// untested, so this is decided synchronously off the DBC row, never off whether the model
/// resolved. On the shipped table the two readings coincide (41 rows fill only the right slot;
/// **none** of them carries a vis pair), which the test asserts rather than assumes.
pub fn worn_helm_vis(&self) -> Option<[u32; 2]> {
self.model[0].is_some().then_some(self.helmet_vis)
}
/// Whether this display is a **guild-emblem tabard** — the garment whose torso art the wearer's
/// guild tabard replaces (`flags & 1`, the client's `0x472ca4 test byte ptr [rec+0x24],1` on the
/// tabard equip slot). A tabard that clears the bit keeps its own `TorsoUpper`/`TorsoLower`
/// columns; one that sets it has those columns overwritten by the three
/// `Textures\GuildEmblems\` layers ([`crate::GuildEmblem`]) whenever the wearer's guild
/// identity is known.
///
/// **Eleven** of the 29604 shipped rows carry a nonzero `flags`; **seven** set bit 0, and they
/// are exactly the tabard-shaped ones (Crusader ×2, Default ×2, Stromgard, Hillsbrad, and
/// **20621** — the row item 5976 *Guild Tabard* wears, the only one any 1.12.1 item template
/// points at). The other four set bit **1** on chest/robe rows (Samurai plate, the Horde robe
/// family) and nothing reads it.
pub fn takes_guild_emblem(&self) -> bool {
self.flags & FLAG_GUILD_EMBLEM_TABARD != 0
}
}
/// [`ItemDisplay::flags`] bit 0 — see [`ItemDisplay::takes_guild_emblem`].
const FLAG_GUILD_EMBLEM_TABARD: u32 = 0x1;
/// `ItemDisplayInfo.dbc`, keyed by `displayId` (the id `ItemDisplayInfoID`/`UNIT_VIRTUAL_ITEM_SLOT_DISPLAY`
/// resolve into — decision 0072).
pub struct ItemDisplayCatalog {
displays: HashMap<u32, ItemDisplay>,
}
impl ItemDisplayCatalog {
/// Build a catalog from an explicit row map — for tests and synthetic fixtures, the twin of
/// `SpellCatalog::from_displays`. The live path is [`load_item_display_catalog`].
pub fn from_displays(displays: HashMap<u32, ItemDisplay>) -> Self {
ItemDisplayCatalog { displays }
}
/// Look up a display id, or `None` if unknown.
pub fn get(&self, display_id: u32) -> Option<&ItemDisplay> {
self.displays.get(&display_id)
}
pub fn len(&self) -> usize {
self.displays.len()
}
pub fn is_empty(&self) -> bool {
self.displays.is_empty()
}
/// Iterate `(displayId, row)` (order unspecified) — the cross-DBC join checks use it, and so
/// does the corpus sweep that has to name *which* display reaches a model
/// (`benilla-extract entityuvscan`). The id is carried because a census that cannot join back
/// to a row id answers "how many" and never "which one".
pub fn iter(&self) -> impl Iterator<Item = (u32, &ItemDisplay)> {
self.displays.iter().map(|(&id, d)| (id, d))
}
}
/// `ItemDisplayInfo.dbc` — 23 fields / 92-byte records in build 5875 (see the module doc for the
/// column pins). Unpinned integer columns are still typed `UInt32` (matching the file's own record
/// stride) even though nothing reads them, so the schema's field-count check against the real header
/// stays exact.
pub(crate) fn item_display_info_schema() -> Schema {
let mut s = Schema::new("ItemDisplayInfo");
for (name, ty) in [
("ID", FieldType::UInt32),
("ModelNameLeft", FieldType::String),
("ModelNameRight", FieldType::String),
("ModelTextureLeft", FieldType::String),
("ModelTextureRight", FieldType::String),
("Icon", FieldType::String),
("GeosetGroup0", FieldType::UInt32),
("GeosetGroup1", FieldType::UInt32),
("GeosetGroup2", FieldType::UInt32),
] {
s.add_field(SchemaField::new(name, ty));
}
// fields 9..14 (positional): 9 the flags word, 10 the ranged-weapon SpellVisual id, 11 the
// ItemGroupSounds id, 12/13 helm-vis.
for name in [
"Flags",
"SpellVisualID",
"ItemGroupSoundsID",
"HelmVisMale",
"HelmVisFemale",
] {
s.add_field(SchemaField::new(name, FieldType::UInt32));
}
for name in [
"ArmUpperTexture",
"ArmLowerTexture",
"HandTexture",
"TorsoUpperTexture",
"TorsoLowerTexture",
"LegUpperTexture",
"LegLowerTexture",
"FootTexture",
] {
s.add_field(SchemaField::new(name, FieldType::String));
}
s.add_field(SchemaField::new("ItemVisualID", FieldType::UInt32));
s
}
/// Build the catalog from an already-parsed record set — the testable core (no `Chain` needed);
/// [`load_item_display_catalog`] is the chain-reading wrapper.
fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog {
let mut displays = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let model = [
str_at(&rs, r, 1).map(|s| model_path(&s)),
str_at(&rs, r, 2).map(|s| model_path(&s)),
];
let model_texture = [str_at(&rs, r, 3), str_at(&rs, r, 4)];
let geoset_groups = [
u32_at(r, 6).unwrap_or(0),
u32_at(r, 7).unwrap_or(0),
u32_at(r, 8).unwrap_or(0),
];
let region_textures = std::array::from_fn(|i| str_at(&rs, r, 14 + i));
let helmet_vis = [u32_at(r, 12).unwrap_or(0), u32_at(r, 13).unwrap_or(0)];
let icon = str_at(&rs, r, 5).map(|i| format!("Interface\\Icons\\{i}"));
let group_sounds = u32_at(r, 11).unwrap_or(0);
let spell_visual = u32_at(r, 10).unwrap_or(0);
let flags = u32_at(r, 9).unwrap_or(0);
let item_visual = u32_at(r, 22).unwrap_or(0) as i32;
displays.insert(
id,
ItemDisplay {
model,
model_texture,
geoset_groups,
region_textures,
helmet_vis,
icon,
group_sounds,
spell_visual,
flags,
item_visual,
},
);
}
ItemDisplayCatalog { displays }
}
/// Load `ItemDisplayInfo.dbc` off the patch chain into an [`ItemDisplayCatalog`].
pub fn load_item_display_catalog(chain: &mut Chain) -> Result<ItemDisplayCatalog> {
let bytes = chain
.read_file(ITEM_DISPLAY_INFO)
.with_context(|| format!("reading {ITEM_DISPLAY_INFO}"))?;
let rs = parse(&bytes, item_display_info_schema(), "ItemDisplayInfo")?;
Ok(catalog_from_records(rs))
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal synthetic WDBC (20-byte header + fixed-width records + a string block) — the same
/// shape `benilla-dbc`'s own tests build, reproduced here so this adapter is testable without a
/// real client install.
fn build_wdbc(
record_count: u32,
field_count: u32,
record_size: u32,
records: &[u8],
strings: &[u8],
) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(b"WDBC");
b.extend_from_slice(&record_count.to_le_bytes());
b.extend_from_slice(&field_count.to_le_bytes());
b.extend_from_slice(&record_size.to_le_bytes());
b.extend_from_slice(&(strings.len() as u32).to_le_bytes());
b.extend_from_slice(records);
b.extend_from_slice(strings);
b
}
const FIELD_COUNT: u32 = 23;
const RECORD_SIZE: u32 = FIELD_COUNT * 4;
const OFF_EMPTY: u32 = 0;
fn u32le(v: u32) -> [u8; 4] {
v.to_le_bytes()
}
/// A growable string block: offset 0 is always `""` (an absent column resolves there); each
/// `push` appends a NUL-terminated string and returns its offset.
#[derive(Default)]
struct StringBlock(Vec<u8>);
impl StringBlock {
fn new() -> Self {
Self(vec![0u8]) // offset 0 = ""
}
fn push(&mut self, s: &str) -> u32 {
let off = self.0.len() as u32;
self.0.extend_from_slice(s.as_bytes());
self.0.push(0);
off
}
}
/// The ammo-display **shape rule** on the real build-5875 rows (decision 0099 phase 5): a
/// projectile display carries its flight model in the **right** slot only (`Ammo\` dir) —
/// verified over every display a class-6 projectile item resolves to (17 rows, vmangos
/// `item_template`) — while a thrown weapon fills the **left** slot (`Weapon\` dir; the weapon
/// itself flies). The missile spawner keys its dir choice on that shape; the client keys the
/// same fork on the wire ammo block's InventoryType (`0x19`=THROWN, wow-re
/// `item-visual-enchant.md` §4) — identical output on every real row. Skips without client data.
#[test]
fn real_ammo_displays_carry_flight_models_right_thrown_left() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
// Rough Arrow's display: flight model + its object skin, right slot, left empty.
let arrow = cat.get(5996).expect("arrow display");
assert_eq!(arrow.model, [None, Some("arrowflight_01.m2".into())]);
assert_eq!(arrow.model_texture[1].as_deref(), Some("Arrow_A_01Brown"));
// Light Shot's display: the bullet pair.
let shot = cat.get(5998).expect("bullet display");
assert_eq!(shot.model[1].as_deref(), Some("bulletflight_01.m2"));
// Balanced Throwing Dagger's display: the weapon model in the LEFT slot (it lives in
// `Item\ObjectComponents\Weapon\`, verified against the MPQ listing).
let thrown = cat.get(16752).expect("thrown display");
assert_eq!(thrown.model[0].as_deref(), Some("thrown_1h_dagger_a_01.m2"));
assert_eq!(
thrown.model_texture[0].as_deref(),
Some("Thrown_1H_Dagger_A_01Copper")
);
}
#[test]
fn parses_model_and_texture_independently_and_skips_empty_columns() {
let mut strings = StringBlock::new();
let off_model = strings.push("Shield_Round_A_01.mdx");
let off_texture = strings.push("Buckler_Damaged_A_01Purple");
let mut rec = Vec::with_capacity(RECORD_SIZE as usize);
rec.extend(u32le(18730)); // ID — the Worn Wooden Shield's real displayId
rec.extend(u32le(off_model)); // ModelNameLeft
rec.extend(u32le(OFF_EMPTY)); // ModelNameRight — absent
rec.extend(u32le(off_texture)); // ModelTextureLeft
rec.extend(u32le(OFF_EMPTY)); // ModelTextureRight — absent
rec.extend(u32le(OFF_EMPTY)); // Icon — unread
rec.extend(u32le(1)); // GeosetGroup0
rec.extend(u32le(0)); // GeosetGroup1
rec.extend(u32le(0)); // GeosetGroup2
rec.extend(u32le(0)); // Unk9
rec.extend(u32le(0)); // Unk10
rec.extend(u32le(21)); // ItemGroupSoundsID
rec.extend(u32le(0)); // HelmVisMale
rec.extend(u32le(0)); // HelmVisFemale
for _ in 0..8 {
rec.extend(u32le(OFF_EMPTY)); // region textures — this row paints none
}
rec.extend(u32le(0)); // ItemVisualID
assert_eq!(rec.len(), RECORD_SIZE as usize);
let bytes = build_wdbc(1, FIELD_COUNT, RECORD_SIZE, &rec, &strings.0);
let rs = parse(&bytes, item_display_info_schema(), "test").expect("synthetic DBC parses");
let catalog = catalog_from_records(rs);
assert_eq!(catalog.len(), 1);
let display = catalog.get(18730).expect("displayId 18730 present");
// The model normalizer applied (lowercased, `.mdx` → `.m2`) — reused, not duplicated.
assert_eq!(
display.model[0].as_deref(),
Some("shield_round_a_01.m2"),
"model name normalized like any other model path"
);
assert_eq!(display.model[1], None, "empty ModelNameRight column ⇒ None");
// The texture is an independently-resolved basename — NOT derived from the model name (the
// module-doc trap this test guards).
assert_eq!(
display.model_texture[0].as_deref(),
Some("Buckler_Damaged_A_01Purple"),
"model texture is unrelated to the model's own basename"
);
assert_eq!(display.model_texture[1], None);
assert_eq!(display.geoset_groups, [1, 0, 0]);
assert!(
display.region_textures.iter().all(Option::is_none),
"a held-item row paints no body regions"
);
assert_eq!(
display.group_sounds, 21,
"field 11 is the ItemGroupSounds id"
);
}
#[test]
fn unknown_display_id_misses() {
let rec = vec![0u8; RECORD_SIZE as usize]; // ID 0, everything else empty/zero
let bytes = build_wdbc(1, FIELD_COUNT, RECORD_SIZE, &rec, &StringBlock::new().0);
let rs = parse(&bytes, item_display_info_schema(), "test").expect("synthetic DBC parses");
let catalog = catalog_from_records(rs);
assert_eq!(catalog.len(), 1);
assert!(catalog.get(999).is_none());
}
}