- 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>
342 lines
16 KiB
Rust
342 lines
16 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|