Files
benilla-octo/crates/benilla-formats/src/creature_sound.rs
T
SamandClaude Fable 5 6b980b9efb the player build: one binary that carries its content
- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module
- the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder
- the glue screens get a capture shutter, and the engine stops depending on the protocol crate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:02 +07:00

206 lines
7.6 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 voice: **CreatureDisplayInfo.SoundID → CreatureSoundData.dbc** (decision 0070
//! slice 3) — the per-display voice kit set (exertion/wound/death/aggro/fidgets…).
//!
//! Layouts — VERIFIED against build 5875 (header + row decode, 2026-07-02):
//! `CreatureSoundData` **406 × 30 × 120 B**: `ID, ExertionID, ExertionCriticalID, InjuryID,
//! InjuryCriticalID, InjuryCrushingBlowID, DeathID, StunID, StandID, FootstepID (a
//! FootstepTerrainLookup **class**, NOT a kit), AggroID, WingFlapID, WingGlideID, AlertID,
//! Fidget[4], CustomAttack[4], NPCSoundID, LoopSoundID, CreatureImpactType (0 flesh · 1 stone ·
//! 2 wood · 3 ethereal), JumpStartID, JumpEndID, PetAttackID, PetOrderID, PetDismissID`.
//! Spot-check row 26: exertion 312/313, injury 315/316/0, death 314, stun 690, stand 317,
//! footstep class 8, aggro 694, alert 1107 — all coherent kit-id ranges.
//! The chain is `UNIT_FIELD_DISPLAYID` → `CreatureDisplayInfo.SoundID`, **falling back to
//! `CreatureModelData.SoundID`** (col 13 of the 430 × 16 × 64 B table) when the display's own FK
//! is 0 — the client's generic resolution (wow-re `benilla-pins.md` B11b; the earlier "no model
//! fallback in 1.12" note here was wrong). The fallback is load-bearing: 10 261 of 10 534
//! displays carry SoundID 0, and with the model link 10 533/10 534 resolve a row (byte-census
//! 2026-07-03) — character displays reach footstep class 7 this way, as data, not client logic.
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
/// One `CreatureSoundData` row — every field is a SoundEntries kit id (0 = none) except where
/// noted.
pub struct CreatureVoice {
/// Attack grunt `[normal, critical]`.
pub exertion: [u32; 2],
/// Wound vocal `[normal, critical, crushing]`.
pub injury: [u32; 3],
pub death: u32,
pub stun: u32,
/// Fired by the `$FDX` anim event.
pub stand: u32,
/// `FootstepTerrainLookup.CreatureFootstepID` — the creature's footstep **class**, joined
/// against the terrain type (NOT a kit id).
pub footstep_class: u32,
pub aggro: u32,
/// Fired by `$WNG` / `$WGG`.
pub wing_flap: u32,
pub wing_glide: u32,
pub alert: u32,
/// Fired by `$FD1`..`$FD4`.
pub fidget: [u32; 4],
/// Fired by `$AH0`..`$AH3`.
pub custom_attack: [u32; 4],
/// A looping body sound (SoundEntries type 27).
pub loop_sound: u32,
/// Melee impact material: 0 flesh · 1 stone · 2 wood · 3 ethereal (WeaponImpactSounds slot
/// class — consumed with combat impacts).
pub impact_type: u32,
pub jump_start: u32,
pub jump_end: u32,
}
/// display id → voice rows, joined through `CreatureDisplayInfo.SoundID`.
pub struct CreatureVoiceCatalog {
display_to_sound: HashMap<u32, u32>,
rows: HashMap<u32, CreatureVoice>,
}
impl CreatureVoiceCatalog {
/// The voice set for a creature **display id** (the `NetEntity.display_id` the wire gives us).
pub fn for_display(&self, display_id: u32) -> Option<&CreatureVoice> {
self.rows.get(self.display_to_sound.get(&display_id)?)
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
fn csd_schema() -> Schema {
let mut s = Schema::new("CreatureSoundData");
for i in 0..30 {
s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32));
}
s
}
fn cdi_schema() -> Schema {
let mut s = Schema::new("CreatureDisplayInfo");
for i in 0..12 {
s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32));
}
s
}
fn cmd_schema() -> Schema {
let mut s = Schema::new("CreatureModelData");
for i in 0..16 {
s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32));
}
s
}
/// Read both tables off the patch chain into the joined catalog.
pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result<CreatureVoiceCatalog> {
let bytes = chain
.read_file("DBFilesClient\\CreatureSoundData.dbc")
.context("reading CreatureSoundData.dbc")?;
let rs = parse(&bytes, csd_schema(), "CreatureSoundData")?;
let mut rows = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let g = |i: usize| u32_at(r, i).unwrap_or(0);
rows.insert(
id,
CreatureVoice {
exertion: [g(1), g(2)],
injury: [g(3), g(4), g(5)],
death: g(6),
stun: g(7),
stand: g(8),
footstep_class: g(9),
aggro: g(10),
wing_flap: g(11),
wing_glide: g(12),
alert: g(13),
fidget: [g(14), g(15), g(16), g(17)],
custom_attack: [g(18), g(19), g(20), g(21)],
loop_sound: g(23),
impact_type: g(24),
jump_start: g(25),
jump_end: g(26),
},
);
}
let bytes = chain
.read_file("DBFilesClient\\CreatureModelData.dbc")
.context("reading CreatureModelData.dbc")?;
let rs = parse(&bytes, cmd_schema(), "CreatureModelData")?;
let mut model_to_sound = HashMap::new();
for r in rs.records() {
if let (Some(id), Some(sound)) = (u32_at(r, 0), u32_at(r, 13)) {
if sound != 0 {
model_to_sound.insert(id, sound);
}
}
}
let bytes = chain
.read_file("DBFilesClient\\CreatureDisplayInfo.dbc")
.context("reading CreatureDisplayInfo.dbc")?;
let rs = parse(&bytes, cdi_schema(), "CreatureDisplayInfo")?;
let mut display_to_sound = HashMap::new();
for r in rs.records() {
let (Some(id), Some(sound), Some(model)) = (u32_at(r, 0), u32_at(r, 2), u32_at(r, 1))
else {
continue;
};
// The display's own FK wins; 0 falls back to the model's (module docs — B11b).
let sound = if sound != 0 {
Some(sound)
} else {
model_to_sound.get(&model).copied()
};
if let Some(sound) = sound {
display_to_sound.insert(id, sound);
}
}
Ok(CreatureVoiceCatalog {
display_to_sound,
rows,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The join works on the real 5875 tables: display 26 resolves the byte-decoded row (death
/// kit 314, footstep class 8), and a majority of sound-linked displays resolve to a real row.
#[test]
fn real_creature_voice_resolves() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_voice_catalog(&mut chain).expect("load creature voices");
assert_eq!(cat.len(), 406, "all CreatureSoundData rows load");
let v = cat.for_display(26).expect("display 26 has a voice");
assert_eq!(v.death, 314);
assert_eq!(v.exertion, [312, 313]);
assert_eq!(v.footstep_class, 8);
assert_eq!(v.aggro, 694);
// The CreatureModelData fallback (B11b): the Elwynn wolf display (903) has
// CreatureDisplayInfo.SoundID 0 and resolves through its model's SoundID (43);
// the human-male character display (49) reaches footstep class 7 the same way.
let wolf = cat.for_display(903).expect("wolf resolves via the model");
assert_eq!(wolf.footstep_class, 8);
let human = cat
.for_display(49)
.expect("human male resolves via the model");
assert_eq!(human.footstep_class, 7);
}
}