Files
benilla-octo/crates/benilla-formats/src/emotes.rs
T
SamandClaude Fable 5 8709dd6bc9 dark overcast skies, a talk gesture, and NPCs that face you
- the Tainted Scar sky reads its dark red overcast instead of almost pure white: a light-band row with no keyframes now commits the client's own constant — black for colour rows, +0.0 for scalars — instead of an invented neutral-daytime palette; Blasted Lands authors cloud density 0.85, so the cloud gradient IS the sky there, and sky pixels go `rgb(255,255,255)` → `rgb(145,55,25)` while the terrain control stays bit-identical and the blessed capture sweep holds MAE 0.000
- an NPC you interact with turns to face you and holds the bearing while the window is open — client-side, a per-frame facing-goal chain whose smoother halves the remaining angle each frame (π settles in 8–9 frames); the server sends nothing, and a patrolling guard or a creature in combat is unaffected
- sending a chat line plays the talk gesture, like every 1.12 client: a say reads its last byte (`?` question, `!` exclamation, else talk), a yell always shouts, a laugh-word laughs — five gestures resolved from Emotes.dbc flag bits, and talking stows a drawn weapon
- a standing capture viewpoint pins the Tainted Scar sky for regression

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 17:52:20 +07:00

342 lines
16 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.
//! Emote audio data (decision 0070 slice 4): **EmotesText** (the `/wave` command table) joined
//! to **EmotesTextSound** (the race/sex voice kits) and **Emotes** (anim emotes + their
//! `EventSoundID`).
//!
//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-07-02):
//! - `EmotesText` **169 × 19 × 76 B**: `ID, Name(str, e.g. "WAVE"), EmoteID (→Emotes),
//! EmoteText[16]`. Spot-check: WAVE = id 101, emote 3.
//! - `EmotesTextSound` **418 × 5 × 20 B**: `ID, EmotesTextID, RaceID, SexID (0 male / 1 female),
//! SoundID (→SoundEntries)`.
//! - `Emotes` **78 × 7 × 28 B**: `ID, SlashCommand(str), AnimID, EmoteFlags, EmoteSpecProc,
//! EmoteSpecProcParam, EventSoundID (→SoundEntries)`. Spot-check row 2: ONESHOT_BOW, anim 66
//! (VERIFIED against vmangos `SharedDefines.h`'s `Emote` enum: `EMOTE_ONESHOT_BOW = 2`).
//!
//! `AnimID` (column 2) feeds both the one-shot anim-emote path (`SMSG_EMOTE`'s `Emotes.dbc` id) and
//! the looping state-emote idle (`UNIT_NPC_EMOTESTATE`, the same id space) — [`EmoteSoundCatalog::anim`].
//!
//! `EmoteSpecProc`/`EmoteSpecProcParam` (columns 4/5) carry the **posture** half of the client's
//! `DoEmote`: proc `1` means "this emote sets a stand state", and the param IS that state
//! ([`EmoteSoundCatalog::posture_state`]) — the mechanism behind `/sit` (proc `2` is the looping
//! state emote whose `EventSoundID` the `$ESD` event rings).
//!
//! `EmoteFlags` (column 3) feeds the **send-side posture-eligibility gate** — the only `EmoteFlags`
//! consumer, byte-verified at the real client's `CheckEmoteEligible` (`0x47db40`, called from
//! `DoEmote` `0x5ef560`): before `CMSG_TEXT_EMOTE` goes out, bit `0x0001` combined with a non-zero
//! stand-state aborts the *entire* emote (no packet, no anim — a seated `/bow` does nothing).
//! `wow-5875-re`'s `system/object-layer/scratch/emote-posture-gate.md` (commit `f9584b45`) is the
//! authority; [`EmoteSoundCatalog::emote_flags`] promotes the raw bits for `crate::chat`'s gate.
use std::collections::HashMap;
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
/// The joined emote-audio tables.
pub struct EmoteSoundCatalog {
/// Uppercased `EmotesText.Name` ("WAVE") → text-emote id — the `/command` send key.
by_name: HashMap<String, u32>,
/// text-emote id → `Emotes.dbc` id (the anim the emote plays; 0 = chat-only).
text_emote: HashMap<u32, u32>,
/// `(text-emote id, race, sex)` → voice kit.
voice: HashMap<(u32, u32, u32), u32>,
/// `Emotes.dbc` id → its `EventSoundID` kit (0 = none).
event_sound: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `AnimID` (`AnimationData.dbc` id; 0 = none).
anim: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteFlags` (the send-side posture-eligibility gate bits).
emote_flags: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteSpecProc` (column 4). `2` marks a looping STATE emote whose
/// `EventSoundID` the `$ESD` anim event rings (the client's `row[+0x10] == 2` gate at the
/// `$ESD` handler `0x6239f0` — wow-re `sound/scratch/gather-sound-anim-events.md`).
spec_proc: HashMap<u32, u32>,
/// `Emotes.dbc` id → its `EmoteSpecProcParam` (column 5). For `EmoteSpecProc == 1` this is the
/// **stand state** the emote sets — see [`EmoteSoundCatalog::posture_state`].
spec_proc_param: HashMap<u32, u32>,
/// The five **chat/interact gesture slots**, in [`GESTURE_FLAG_BITS`] order — see
/// [`EmoteSoundCatalog::gesture`].
gesture: [Option<u32>; GESTURE_FLAG_BITS.len()],
}
/// The five `EmoteFlags` bits the client hard-codes to build its gesture table (`0x603a60`, wow-re
/// `object-layer/scratch/chat-talk-gesture.md` §3), in slot order — **talk, question, exclamation,
/// shout, laugh**. The *bits* are the fidelity fact: the client never names an `Emotes.dbc` id or an
/// `AnimationData.dbc` id here, it scans the table for whichever row carries each bit. In the
/// shipped 1.12.1 data each bit is carried by exactly one row (ids 1 / 6 / 5 / 22 / 11 → anims
/// 60 / 65 / 64 / 81 / 70), but transcribing those numbers instead of the scan would bake content
/// into code.
pub const GESTURE_FLAG_BITS: [u32; 5] = [0x08, 0x10, 0x20, 0x40, 0x100];
impl EmoteSoundCatalog {
/// Resolve a `/command` name (case-insensitive) to its text-emote id.
pub fn text_id(&self, name: &str) -> Option<u32> {
self.by_name.get(&name.to_ascii_uppercase()).copied()
}
/// The voice kit for a performer of the given race/sex (`sex`: 0 male, 1 female).
pub fn voice(&self, text_id: u32, race: u32, sex: u32) -> Option<u32> {
self.voice.get(&(text_id, race, sex)).copied()
}
/// The anim emote a text emote plays (0/none for chat-only emotes).
pub fn text_emote(&self, text_id: u32) -> Option<u32> {
self.text_emote.get(&text_id).copied().filter(|&e| e != 0)
}
/// An anim emote's event-sound kit.
pub fn event_sound(&self, emote_id: u32) -> Option<u32> {
self.event_sound.get(&emote_id).copied().filter(|&k| k != 0)
}
/// An `Emotes.dbc` id's `AnimID` (the `AnimationData.dbc` id it plays; `0`/absent = none).
/// Shared by the one-shot anim-emote path (`SMSG_EMOTE`) and the looping state-emote idle
/// (`UNIT_NPC_EMOTESTATE`) — both carry an `Emotes.dbc` id in the same id space.
pub fn anim(&self, emote_id: u32) -> Option<u32> {
self.anim.get(&emote_id).copied().filter(|&a| a != 0)
}
/// The `Emotes.dbc` id in gesture slot `slot` — an index into [`GESTURE_FLAG_BITS`], which is
/// exactly the `code` the client's gesture dispatcher `0x60bb30` takes. `None` when no shipped
/// row carries that bit (the client's own "empty slot ⇒ no gesture" leg).
pub fn gesture(&self, slot: usize) -> Option<u32> {
self.gesture.get(slot).copied().flatten()
}
/// An `Emotes.dbc` id's raw `EmoteFlags` bits (`None` when the id isn't in the catalog; `0` is a
/// real, meaningful value — "no gate bits set" — so unlike [`Self::anim`]/[`Self::event_sound`]
/// it is *not* filtered out). Feeds `benilla::chat`'s send-side posture-eligibility gate — see
/// the module doc.
pub fn emote_flags(&self, emote_id: u32) -> Option<u32> {
self.emote_flags.get(&emote_id).copied()
}
/// An `Emotes.dbc` id's `EmoteSpecProc` (`None` when the id isn't in the catalog; `0` is a real
/// value, so not filtered). `2` = a looping state emote — the `$ESD` event-sound gate (see the
/// field doc).
pub fn spec_proc(&self, emote_id: u32) -> Option<u32> {
self.spec_proc.get(&emote_id).copied()
}
/// The **stand state** a POSTURE emote sets: `EmoteSpecProcParam` gated on `EmoteSpecProc == 1`
/// (`None` for every other emote). This is the client's `DoEmote` state branch — wow-re
/// `object-layer/scratch/emote-posture-gate.md` §1: `if (rec.EmoteSpecProc == 1 && …)
/// SetStandState(rec.SpecProcParam)`, the same `0x5ed430` setter the sit key drives. The five
/// reachable rows: STATE_SIT(13)→1, STATE_SLEEP(12)→3, STATE_KNEEL(68)→8, STATE_STAND(26)→0,
/// STATE_AT_EASE(313)→2 — which is why `/sit` sits at all, since the *server* deliberately does
/// nothing for a STATE text emote (vmangos `ChatHandler.cpp` `HandleTextEmoteOpcode`: SIT /
/// SLEEP / KNEEL / NONE break out before `HandleEmote`).
pub fn posture_state(&self, emote_id: u32) -> Option<u32> {
(self.spec_proc.get(&emote_id).copied() == Some(1))
.then(|| self.spec_proc_param.get(&emote_id).copied())
.flatten()
}
pub fn len(&self) -> usize {
self.by_name.len()
}
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
}
fn schema(name: &str, n: usize, strings: &[usize]) -> Schema {
let mut s = Schema::new(name);
for i in 0..n {
let ty = if strings.contains(&i) {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("f{i}"), ty));
}
s
}
/// Read the three tables off the patch chain.
pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result<EmoteSoundCatalog> {
let bytes = chain
.read_file("DBFilesClient\\EmotesText.dbc")
.context("reading EmotesText.dbc")?;
let rs = parse(&bytes, schema("EmotesText", 19, &[1]), "EmotesText")?;
let mut by_name = HashMap::with_capacity(rs.records().len());
let mut text_emote = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(name) = str_at(&rs, r, 1).filter(|n| !n.is_empty()) {
by_name.insert(name.to_ascii_uppercase(), id);
}
text_emote.insert(id, u32_at(r, 2).unwrap_or(0));
}
let bytes = chain
.read_file("DBFilesClient\\EmotesTextSound.dbc")
.context("reading EmotesTextSound.dbc")?;
let rs = parse(&bytes, schema("EmotesTextSound", 5, &[]), "EmotesTextSound")?;
let mut voice = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(text), Some(race), Some(sex), Some(kit)) =
(u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4))
else {
continue;
};
voice.insert((text, race, sex), kit);
}
let bytes = chain
.read_file("DBFilesClient\\Emotes.dbc")
.context("reading Emotes.dbc")?;
let rs = parse(&bytes, schema("Emotes", 7, &[1]), "Emotes")?;
let mut event_sound = HashMap::with_capacity(rs.records().len());
let mut anim = HashMap::with_capacity(rs.records().len());
let mut emote_flags = HashMap::with_capacity(rs.records().len());
let mut spec_proc = HashMap::with_capacity(rs.records().len());
let mut spec_proc_param = HashMap::with_capacity(rs.records().len());
// The gesture slots. The client walks the record array from the LAST index DOWN and its bit
// tests are an else-if chain, so a row carrying two of the bits lands only in the first
// matching slot and a duplicated bit resolves to the LOWEST array index. Iterating forward and
// letting an earlier row win reproduces both, without depending on either: no shipped row
// carries more than one of these bits, and none of them is duplicated.
let mut gesture: [Option<u32>; GESTURE_FLAG_BITS.len()] = [None; GESTURE_FLAG_BITS.len()];
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(flags) = u32_at(r, 3) {
if let Some(slot) = GESTURE_FLAG_BITS.iter().position(|b| flags & b != 0) {
gesture[slot].get_or_insert(id);
}
}
if let Some(kit) = u32_at(r, 6) {
event_sound.insert(id, kit);
}
if let Some(anim_id) = u32_at(r, 2) {
anim.insert(id, anim_id);
}
if let Some(flags) = u32_at(r, 3) {
emote_flags.insert(id, flags);
}
if let Some(proc) = u32_at(r, 4) {
spec_proc.insert(id, proc);
}
if let Some(param) = u32_at(r, 5) {
spec_proc_param.insert(id, param);
}
}
Ok(EmoteSoundCatalog {
by_name,
text_emote,
voice,
event_sound,
anim,
emote_flags,
spec_proc,
spec_proc_param,
gesture,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The five gesture slots resolve off the REAL shipped `Emotes.dbc` — the check that the
/// flag-bit scan finds the right rows, and that each bit really is carried by exactly one of
/// them. The expected ids/anims are the byte-verified table in wow-re
/// `object-layer/scratch/chat-talk-gesture.md` §3; the *code* never names them.
#[test]
fn the_five_gesture_slots_scan_to_the_shipped_rows() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_emote_sound_catalog(&mut chain).expect("load emote catalog");
// slot, expected Emotes.dbc id, expected AnimationData id, name
for (slot, emote_id, anim_id, what) in [
(0usize, 1u32, 60u32, "talk"),
(1, 6, 65, "question"),
(2, 5, 64, "exclamation"),
(3, 22, 81, "shout"),
(4, 11, 70, "laugh"),
] {
assert_eq!(cat.gesture(slot), Some(emote_id), "{what} slot -> emote id");
assert_eq!(cat.anim(emote_id), Some(anim_id), "{what} emote -> AnimID");
}
// Each of the five bits is carried by exactly one shipped row, so the else-if chain and
// the walk direction cannot matter. If a patch ever broke this, the scan would silently
// pick a different row and the gestures would drift — so assert it, do not assume it.
for bit in GESTURE_FLAG_BITS {
let carriers = cat
.emote_flags
.iter()
.filter(|(_, f)| *f & bit != 0)
.count();
assert_eq!(carriers, 1, "exactly one shipped row carries {bit:#x}");
}
}
/// The joins hold on real 5875 data: WAVE resolves by name to the byte-decoded id 101 with
/// anim emote 3; some voice row exists for a human (race 1) male; the voice map carries the
/// male/female split (the survey's sample rows pair sexes per race).
#[test]
fn real_emote_chain_resolves() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_emote_sound_catalog(&mut chain).expect("load emote catalog");
assert_eq!(cat.text_id("wave"), Some(101), "case-insensitive by name");
assert_eq!(cat.text_emote(101), Some(3), "WAVE plays anim emote 3");
assert_eq!(cat.anim(2), Some(66), "ONESHOT_BOW (id 2) plays AnimID 66");
// EmoteFlags for the director-verified posture-gate rows (emote-posture-gate.md §3), ids
// from vmangos `SharedDefines.h`'s `Emote` enum: BOW=2, CHEER=4, LAUGH=11, RUDE=14,
// APPLAUD=21, SALUTE=66.
assert_eq!(cat.emote_flags(2), Some(0x4801), "ONESHOT_BOW EmoteFlags");
assert_eq!(cat.emote_flags(4), Some(0x0800), "ONESHOT_CHEER EmoteFlags");
assert_eq!(
cat.emote_flags(11),
Some(0x0980),
"ONESHOT_LAUGH EmoteFlags"
);
assert_eq!(cat.emote_flags(14), Some(0x0001), "ONESHOT_RUDE EmoteFlags");
assert_eq!(
cat.emote_flags(21),
Some(0x0000),
"ONESHOT_APPLAUD EmoteFlags"
);
assert_eq!(
cat.emote_flags(66),
Some(0x0800),
"ONESHOT_SALUTE EmoteFlags"
);
// The $ESD gathering chain (decision 0562): STATE_WORK_NOSHEATHE_MINING (233) is a
// spec-proc-2 state emote carrying the MiningHit kit; its one-shot cousins carry proc 0.
assert_eq!(cat.spec_proc(233), Some(2), "mining state EmoteSpecProc");
assert_eq!(
cat.event_sound(233),
Some(3782),
"mining state EventSoundID (SoundEntries \"Mining\")"
);
assert_eq!(cat.anim(233), Some(136), "mining state plays anim 136");
assert_eq!(cat.spec_proc(2), Some(0), "ONESHOT_BOW EmoteSpecProc");
// The posture branch (`DoEmote`'s `EmoteSpecProc == 1` → `SetStandState(param)`): the four
// stand states a slash emote can reach, byte-decoded off the shipped table. The values are
// vmangos `UnitStandStateType` (STAND 0 · SIT 1 · SLEEP 3 · KNEEL 8).
assert_eq!(cat.posture_state(13), Some(1), "STATE_SIT sets SIT");
assert_eq!(cat.posture_state(12), Some(3), "STATE_SLEEP sets SLEEP");
assert_eq!(cat.posture_state(68), Some(8), "STATE_KNEEL sets KNEEL");
assert_eq!(cat.posture_state(26), Some(0), "STATE_STAND sets STAND");
// A proc-2 state emote and a one-shot are NOT posture emotes, however tempting their param
// looks: the gate is the proc column, not the param's presence.
assert_eq!(cat.posture_state(233), None, "mining state is proc 2");
assert_eq!(cat.posture_state(2), None, "ONESHOT_BOW is proc 0");
assert!(
cat.voice
.keys()
.any(|&(_, race, sex)| race == 1 && sex == 0),
"human male voice rows exist"
);
assert!(
cat.voice.keys().any(|&(_, _, sex)| sex == 1),
"female voice rows exist"
);
}
}