Files
benilla-octo/crates/benilla-formats/src/emote_text.rs
T
SamandClaude Fable 5 5db8b5b9f4 zone channels wait for the world, and end with the character
- the zone-channel walk waits for real ground under the player, so login stops joining and leaving channels for zones you were never in
- the channel session ends with the character: a character switch no longer replays the old channels into the new session
- emoting at yourself goes out untargeted — "You wave.", exactly as the client sends it
- a body that is not in the scene casts no shadow, names nothing and cannot be clicked; and being attached to a body is not the same as being allowed to move it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:25:55 +07:00

432 lines
20 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.
//! `EmotesText.dbc` × `EmotesTextData.dbc` — the **sentence** an `SMSG_TEXT_EMOTE` becomes
//! ("Bob waves at you." / "You wave." / "Bob waves at Jane."), decision 1274.
//!
//! The sibling module [`crate::emotes`] owns the same `EmotesText.dbc`'s *other* half — the
//! `/command` name, the anim id, the voice kits. This one owns its 16 **`EmoteText[]`** columns and
//! the localized-string table they point into, because the two are consumed by different arcs
//! (audio+animation vs the chat log) and only one of them needs `EmotesTextData.dbc` at all.
//!
//! The law below is the real client's sole sentence composer, **`0x49b200`** — wow-re
//! `system/ui/scratch/text-emote-composition.md`, re-read at the bytes for this implementation
//! (`objdump 0x49b200..0x49b47c`), which is where the byte addresses in the comments come from.
//!
//! # The composer, in three parts
//!
//! **1 · A 4-bit column selector.** `index = gender | context`, where `context` is built from three
//! facts and the *order of the tests matters* (`0x49b2c3`-`0x49b316`):
//!
//! | bit | test | meaning |
//! |---|---|---|
//! | `2` (`0x49b2c8`) | performer guid == the active player's | **you are the performer** — and this *skips* the bit-0 test |
//! | `1` (`0x49b2f3`) | the wire's target name == your own name | **you are the target** (only ever tested when you are not the performer) |
//! | `4` (`0x49b311`) | the wire's target name is empty | **untargeted** |
//! | `8` (`0x49b31c`) | the performer's `NameCache` record `+0x13c == 1` (Female) | performer **gender** |
//!
//! Reachable columns are `{0,1,2,4,6} | gender` — 10 of the 16; 3/5/7/11/13/15 cannot be selected
//! (bit 0 and bit 2 are mutually exclusive, and bit 1 skips bit 0's test). WAVE (id 101) in the
//! shipped table: col0 "%s waves at %s.", col1 "%s waves at you.", col2 "You wave at %s.",
//! col4 "%s waves.", col6 "You wave.", every other column 0.
//!
//! **2 · A four-rung fallback ladder** when the picked column has no non-empty string for the
//! locale (`0x49b332`, `0x49b35e`, `0x49b38a`, `0x49b3c5`): `gender|ctx` → `ctx` → `gender|ctx'` →
//! `ctx'`, where `ctx' = (ctx & !1) | 4` ("drop target-self, force untargeted"). All four empty ⇒
//! **no chat line at all** (`0x49b4bd`), and the emote is anim + voice only.
//!
//! **3 · Four `SStrPrintf` sites**, picked by (performer-is-other) × (target-is-other), always
//! performer-first (`0x49b3fa`-`0x49b479`). See [`EmoteLine`] for the self-target edge that falls
//! out of the test order.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
const EMOTES_TEXT: &str = "DBFilesClient\\EmotesText.dbc";
const EMOTES_TEXT_DATA: &str = "DBFilesClient\\EmotesTextData.dbc";
/// `EmotesText.dbc`'s `EmoteText[]` width — the selector is 4 bits wide because this is 16.
const FORMS: usize = 16;
/// How many locale columns an `EmotesTextData.dbc` row carries in 5875 (`id + 8 strings + flags`
/// = the 10 fields the loader's `cmp eax,0xa` at `0x544579` insists on). Same shape as
/// [`crate::languages`]'s `Name_lang` block, and read the same way: the loader keeps every column,
/// the app asks for the slot it wants (`[0xc0e080]`, 0 = enUS, the only one this install fills).
const LOCALES: usize = 8;
/// The joined text-emote sentence tables.
#[derive(Debug, Default, Clone)]
pub struct EmoteTextCatalog {
/// `EmotesText.dbc` id → its 16 `EmoteText[]` columns (each an `EmotesTextData` id, 0 = none).
forms: HashMap<u32, [u32; FORMS]>,
/// `EmotesTextData.dbc` id → its localized format strings, one per locale column.
data: HashMap<u32, [Option<String>; LOCALES]>,
}
/// The facts `0x49b200` branches on, in the composer's own terms.
///
/// **`target_is_you` is only tested when you are not the performer** — the guid compare at
/// `0x49b2c8` jumps past it — so [`EmoteTextCatalog::compose`] derives both facts here rather than
/// trusting a caller to pre-clear one.
///
/// That branch order once looked like it meant "a self-emote renders *You wave at ⟨YourName⟩.*",
/// and decision 1274 said so. **It does not** (corrected by 1282): the sending client zeroes a
/// self-target before the packet is built (`DoEmote 0x5ef611`), so `target` arrives EMPTY for that
/// action and the untargeted column wins — "You wave.". Vanilla has no reflexive form *and* no
/// self-named one; the case simply never reaches this struct.
#[derive(Debug, Clone, Copy)]
pub struct EmoteLine<'a> {
/// The performer's resolved name — the `%s` the reference fills from the `NameCache` record
/// pointer it passes as-is (`record+0` *is* the name).
pub performer: &'a str,
/// The performer guid == the active player's (`0x49b2c8`).
pub performer_is_you: bool,
/// The performer's sex is Female (`record+0x13c == 1`) — the `+8` on the column index.
pub performer_female: bool,
/// The target's display name **as the server sent it** (the wire string, never round-tripped
/// through a name cache); empty = untargeted.
pub target: &'a str,
/// Your own name (`GetOwnName 0x609210`), compared case-insensitively against `target`
/// (`0x64a480`).
pub your_name: &'a str,
}
impl EmoteTextCatalog {
/// The composed chat line for a text emote, or `None` when the ladder runs dry (the
/// reference's "no line at all" tail — the emote is still an animation and a voice).
///
/// `locale` is the client's locale column; 0 (enUS) is the only one a 5875 enUS install fills.
pub fn compose(&self, text_id: u32, line: &EmoteLine, locale: usize) -> Option<String> {
let forms = self.forms.get(&text_id)?;
// ── the selector (`0x49b2c3`-`0x49b316`) ────────────────────────────────────────────────
// Both flags start "the other guy" and are cleared by the branch that fires — the order is
// load-bearing: matching the performer skips the target compare entirely.
let (mut performer_is_other, mut target_is_other) = (true, true);
let mut ctx = 0usize;
if line.performer_is_you {
ctx = 2;
performer_is_other = false;
} else if !line.target.is_empty() && line.target.eq_ignore_ascii_case(line.your_name) {
ctx = 1;
target_is_other = false;
}
if line.target.is_empty() {
ctx |= 4;
}
let gender = usize::from(line.performer_female) * 8;
// ── the ladder (`0x49b332` / `0x49b35e` / `0x49b38a` / `0x49b3c5`) ──────────────────────
// `ctx` is mutated in place by rung 3, exactly as the reference mutates `esi`, so rung 4
// reads the *corrected* context and not the original one.
let template = self
.template(forms, gender | ctx, locale)
.or_else(|| self.template(forms, ctx, locale))
.or_else(|| {
ctx = (ctx & !1) | 4;
self.template(forms, gender | ctx, locale)
})
.or_else(|| self.template(forms, ctx, locale))?;
// ── the four printf sites (`0x49b3fa`-`0x49b479`), performer first ──────────────────────
Some(match (performer_is_other, target_is_other) {
(true, true) => fill(template, &[line.performer, line.target]),
(true, false) => fill(template, &[line.performer]),
(false, true) => fill(template, &[line.target]),
// Provably unreachable in the reference (clearing `target_is_other` requires the
// performer branch not to have fired); kept as the format's own no-arg case.
(false, false) => fill(template, &[]),
})
}
/// One `EmoteText[index]` column resolved to a **non-empty** localized string, or `None` — the
/// reference's four rejections rolled into one (`index` past the array, the column's id absent
/// from `EmotesTextData`, the row's string blank for this locale).
fn template<'a>(
&'a self,
forms: &[u32; FORMS],
index: usize,
locale: usize,
) -> Option<&'a str> {
let data_id = *forms.get(index)?;
self.data.get(&data_id)?.get(locale)?.as_deref()
}
/// How many `EmotesText.dbc` rows carry a form table (169 in 5875).
pub fn len(&self) -> usize {
self.forms.len()
}
pub fn is_empty(&self) -> bool {
self.forms.is_empty()
}
}
/// `SStrPrintf` (`0x64a7f0`) reduced to what these templates actually use: replace each `%s` with
/// the next argument, left to right. A template with more `%s` than arguments keeps the surplus
/// verbatim — the reference would print stack garbage there, and no shipped row does it.
fn fill(template: &str, args: &[&str]) -> String {
let mut out = String::with_capacity(template.len() + 16);
let mut rest = template;
let mut args = args.iter();
while let Some(at) = rest.find("%s") {
let Some(arg) = args.next() else { break };
out.push_str(&rest[..at]);
out.push_str(arg);
rest = &rest[at + 2..];
}
out.push_str(rest);
out
}
/// `EmotesText.dbc` — `ID`, `Name`(str), `EmoteID`, `EmoteText[16]`; 19 fields × 4 = the 76-byte
/// record the client's loader checks for.
fn emotes_text_schema() -> Schema {
let mut s = Schema::new("EmotesText");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("Name", FieldType::String));
s.add_field(SchemaField::new("EmoteID", FieldType::UInt32));
for i in 0..FORMS {
s.add_field(SchemaField::new(format!("EmoteText{i}"), FieldType::UInt32));
}
s
}
/// `EmotesTextData.dbc` — `ID`, eight `Text_lang` strings, `Flags`; the classic 1.12
/// `LocalizedString` block (10 fields × 4 = 40 bytes, the reader's own `cmp eax,0x28`).
fn emotes_text_data_schema() -> Schema {
let mut s = Schema::new("EmotesTextData");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
for i in 0..LOCALES {
s.add_field(SchemaField::new(format!("Text{i}"), FieldType::String));
}
s.add_field(SchemaField::new("Flags", FieldType::UInt32));
s
}
/// Read and join both tables off the patch chain.
pub fn load_emote_text_catalog(chain: &mut Chain) -> Result<EmoteTextCatalog> {
let bytes = chain
.read_file(EMOTES_TEXT)
.with_context(|| format!("reading {EMOTES_TEXT}"))?;
let rs = parse(&bytes, emotes_text_schema(), "EmotesText.dbc")?;
let mut forms = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let mut cols = [0u32; FORMS];
for (i, slot) in cols.iter_mut().enumerate() {
*slot = u32_at(r, 3 + i).unwrap_or(0);
}
forms.insert(id, cols);
}
let bytes = chain
.read_file(EMOTES_TEXT_DATA)
.with_context(|| format!("reading {EMOTES_TEXT_DATA}"))?;
let rs = parse(&bytes, emotes_text_data_schema(), "EmotesTextData.dbc")?;
let mut data = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
let mut texts: [Option<String>; LOCALES] = Default::default();
for (locale, slot) in texts.iter_mut().enumerate() {
// `str_at` already answers `None` for an empty string, which is the reference's
// "blank for this locale ⇒ take the next rung" test (`cmpb $0,(%edx)`).
*slot = str_at(&rs, r, 1 + locale);
}
data.insert(id, texts);
}
Ok(EmoteTextCatalog { forms, data })
}
#[cfg(test)]
mod tests {
use super::*;
/// WAVE's five reachable enUS forms — the row wow-re's note pins, so a schema slip (a
/// column-index off-by-one, the wrong locale slot) shows up as the wrong sentence rather than
/// as a silent empty.
const WAVE: u32 = 101;
fn line<'a>(performer: &'a str, target: &'a str, you: &'a str) -> EmoteLine<'a> {
EmoteLine {
performer,
performer_is_you: false,
performer_female: false,
target,
your_name: you,
}
}
#[test]
fn fill_substitutes_left_to_right_and_keeps_a_surplus_verbatim() {
assert_eq!(
fill("%s waves at %s.", &["Bob", "Jane"]),
"Bob waves at Jane."
);
assert_eq!(fill("%s waves at you.", &["Bob"]), "Bob waves at you.");
assert_eq!(fill("You wave.", &["Bob"]), "You wave.");
// Surplus verbs stay literal rather than eating the rest of the template.
assert_eq!(fill("%s waves at %s.", &["Bob"]), "Bob waves at %s.");
assert_eq!(fill("", &["Bob"]), "");
}
/// The real chain: this module is a join between two shipped tables plus a bit-selector, and a
/// synthetic fixture would only test the plumbing.
#[test]
fn the_shipped_tables_compose_waves_five_forms() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
assert_eq!(cat.len(), 169, "one form table per EmotesText row");
// ctx 0 — someone else waves at someone else.
assert_eq!(
cat.compose(WAVE, &line("Bob", "Jane", "Me"), 0).as_deref(),
Some("Bob waves at Jane.")
);
// ctx 1 — someone else waves at YOU (the target name matches your own).
assert_eq!(
cat.compose(WAVE, &line("Bob", "Me", "Me"), 0).as_deref(),
Some("Bob waves at you.")
);
// ctx 4 — someone else waves at nobody.
assert_eq!(
cat.compose(WAVE, &line("Bob", "", "Me"), 0).as_deref(),
Some("Bob waves.")
);
// ctx 2 — YOU wave at someone.
let mine = EmoteLine {
performer_is_you: true,
..line("Me", "Jane", "Me")
};
assert_eq!(
cat.compose(WAVE, &mine, 0).as_deref(),
Some("You wave at Jane.")
);
// ctx 6 — YOU wave at nobody.
let mine = EmoteLine {
performer_is_you: true,
..line("Me", "", "Me")
};
assert_eq!(cat.compose(WAVE, &mine, 0).as_deref(), Some("You wave."));
}
/// **The composer has no self-target case, because it can never be handed one** — and this
/// test exists to record that, since the shape of the code invites the opposite conclusion
/// (decision 1282, correcting 1274).
///
/// The branch order is real: `0x49b2c8` matching the performer jumps past the target compare,
/// so if a self-emote *did* arrive with your own name in the target slot, this is what it
/// would render. It never arrives. The **send** side zeroes a self-target before the packet
/// exists (`DoEmote 0x5ef611`), so the server echoes an empty name and the untargeted column
/// wins — "You wave.", asserted just above. Reading this arm as "what a self-emote does" is
/// exactly the mistake 1274 made.
#[test]
fn the_self_named_target_form_is_unreachable_from_the_wire() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
let me = EmoteLine {
performer_is_you: true,
..line("Me", "Me", "Me")
};
assert_eq!(
cat.compose(WAVE, &me, 0).as_deref(),
Some("You wave at Me.")
);
// What the wire actually delivers for that same action — an EMPTY target — and therefore
// what a player really reads.
let me = EmoteLine {
performer_is_you: true,
..line("Me", "", "Me")
};
assert_eq!(cat.compose(WAVE, &me, 0).as_deref(), Some("You wave."));
}
/// The gender rung: WAVE has no female column, so `8|ctx` misses and rung 2 (`ctx` alone)
/// answers — a female performer must read exactly the same sentence, not nothing.
#[test]
fn a_missing_female_column_falls_back_to_the_ungendered_one() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
let her = EmoteLine {
performer_female: true,
..line("Ann", "Jane", "Me")
};
assert_eq!(
cat.compose(WAVE, &her, 0).as_deref(),
Some("Ann waves at Jane.")
);
}
/// A locale column this install does not ship is empty everywhere, so the ladder runs dry and
/// the composer emits no line — the reference's `0x49b4bd` tail, not a panic and not a blank.
#[test]
fn an_unshipped_locale_composes_nothing() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
assert_eq!(cat.compose(WAVE, &line("Bob", "Jane", "Me"), 5), None);
// …and so does an id with no row at all.
assert_eq!(cat.compose(9999, &line("Bob", "Jane", "Me"), 0), None);
}
/// The whole table, not one row. This is the check that would have caught B156's real shape —
/// "the sentence table is never consulted" reads identically to "this one emote has no text"
/// until you sweep all 169.
///
/// **Exactly three rows are legitimately silent**, and naming them is the point: `SIT`(86)
/// points columns 4/6 at `EmotesTextData` rows 446/447, which exist but ship **blank in every
/// locale**, while `STAND`(141) and `TRAIN`(264) have all sixteen columns zero. The ladder
/// therefore runs dry for them and the reference prints nothing — which is what a vanilla
/// `/sit` does. Every other row must compose a `%s`-free sentence in all five reachable
/// contexts.
#[test]
fn every_shipped_emote_composes_except_the_three_silent_rows() {
let data = crate::wow_data_or_skip!();
let mut chain = Chain::open(&data).expect("open patch chain");
let cat = load_emote_text_catalog(&mut chain).expect("load");
let mut ids: Vec<u32> = cat.forms.keys().copied().collect();
ids.sort_unstable();
let mut silent = Vec::new();
for id in ids {
for (label, l) in [
("other→other", line("Bob", "Jane", "Me")),
("other→you", line("Bob", "Me", "Me")),
("other→none", line("Bob", "", "Me")),
(
"you→other",
EmoteLine {
performer_is_you: true,
..line("Me", "Jane", "Me")
},
),
(
"you→none",
EmoteLine {
performer_is_you: true,
..line("Me", "", "Me")
},
),
] {
match cat.compose(id, &l, 0) {
None => silent.push(id),
Some(s) => {
assert!(!s.is_empty(), "EmotesText {id} composed empty for {label}");
assert!(
!s.contains("%s"),
"EmotesText {id} left a %s unfilled for {label}: {s:?}"
);
}
}
}
}
silent.dedup();
assert_eq!(silent, [86, 141, 264], "the silent set is SIT/STAND/TRAIN");
}
}