channel lines get their warm color, and a /wave gets its sentence

- a channel line renders in the channel family's color, not the grey notice row the client consults and throws away
- a text emote composes its sentence — gender and target picked from the game's own tables — so a /wave is a line in chat, not just an animation
- a FontString's `font=` resolves a font object by name and gates the whole block, `<Gradient>` tints the art it sits on, and buttons' state fonts read `font=` too — Bagnon's white boxes and bare stack counts are fixed
- the money frame sizes its coins from the digits themselves, so Bagnon's gold stops opening cramped

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:25:00 +07:00
co-authored by Claude Fable 5
parent a8718ad62c
commit a075a2ef44
23 changed files with 1495 additions and 129 deletions
+4 -13
View File
@@ -60,19 +60,10 @@
{ 0.5, 0.75 }, -- [3] copper
}
-- The laid-out width of a number in NumberFontNormal: the sum of its digits' advances from
-- BENILLA_DIGIT_W (the app's once-per-atlas feed — our synchronous stand-in for the real
-- GetTextWidth). The 7px fallback only matters headless (engine tests run without a font
-- atlas); in-app the feed lands on the first frame the atlas exists.
function BenillaNumberWidth(n)
local s = tostring(n)
local t = BENILLA_DIGIT_W
local w = 0
for i = 1, string.len(s) do
w = w + ((t and t[string.sub(s, i, i)]) or 7)
end
return w
end
-- (BenillaNumberWidth — the digit-sum this kit sizes its slots by — is an ENGINE binding,
-- published beside the BENILLA_DIGIT_W feed it reads. It was Lua in this file until
-- MoneyFrame.xml's ShowCoin became its second caller and no shipped file turned out to be a
-- dependency of both: whichever one owned it, some fixture met it as nil.)
-- Unused slots HIDE (the real collapse — MoneyFrame.lua l.230/247 goldButton:Hide()), never
-- SetWidth(0): a 0 width means "derive from opposing anchors" to the layout (the client's
+26 -14
View File
@@ -50,9 +50,12 @@
· `message()` (ref l.145, l.173, l.181) — the one-line StaticPopup shim, which belongs to
BasicControls.xml, a file family we have not built. Guarded, so a misuse of the API stays
silent instead of erroring on the diagnostic.
· `Button:GetTextWidth()` (ref l.202 etc.) — our Button has no such method. The button's own
`<ButtonText name="$parentText">` FontString carries `GetStringWidth`, which is the same
measurement of the same string with the same font object, and that is what we call.
· `Button:GetTextWidth()` (ref l.202 etc.) — BUILT (it forwards to the label FontString's
`GetStringWidth`; it arrived with Bagnon's character list, which sizes itself by it). We
still do not call it here, and the reason is timing, not absence: both it and
`GetStringWidth` are served from the measure round-trip, which lands a frame after the
tick that set the text, so either one reads 0 on the update that needs it. ShowCoin sums
BENILLA_DIGIT_W instead — see there.
· The four `Pickup*`/`Add*` mail verbs the pickup closures name are not in 1.12's `_G` either
(they are absent from reference/1.12-globals.tsv) — so those closures are as unreachable
here as they are there. They exist because the table's shape is the contract.
@@ -276,19 +279,28 @@
-- Lay one denomination out: its digits, and the width those digits plus the coin icon need.
--
-- The specification measures with `Button:GetTextWidth()` (ref l.202 and kin); our Button
-- carries no such method, but the `<ButtonText name="$parentText">` FontString the templates
-- below name does carry `GetStringWidth`, which measures the same string with the same font
-- object. It reads 0 before the label has resolved (headless, no font atlas), which leaves the
-- coin at exactly its icon width — the same shape, without the digits.
-- The specification measures with `Button:GetTextWidth()` (ref l.202 and kin), and the real
-- client answers that SYNCHRONOUSLY — its font engine is inline, so the coin is the right
-- width on the frame that set its text.
--
-- Ours cannot ask a FontString: `GetStringWidth` is served from the measure round-trip,
-- which lands at extract, a frame AFTER the tick that called SetText. Reading it here
-- returned 0 on every first open and left all three coins at exactly one icon width, with
-- their digits overlapping — the director's "gold numbers are all cramped up when I first
-- open the bags; close and open again and it looks good", the reopen being the cached
-- measure from the first.
--
-- So this sums BENILLA_DIGIT_W instead, via MerchantFrame.xml's BenillaNumberWidth: the
-- app measures NumberFontNormal's ten digits once per atlas scale and pushes them as data
-- (`UiScript::set_digit_advances`), which makes the answer available IN this tick. That
-- feed was built for this frame — its doc names "the real SmallMoneyFrame calls
-- GetTextWidth mid-update, MoneyFrame.lua l.202" — and only the merchant price ever used
-- it. A number is the one string whose width is a pure sum of per-glyph advances (no
-- kerning enters the client's step law), which is why the table can be exact here and
-- cannot generalise to arbitrary text.
local function ShowCoin(coin, coinName, amount, iconWidth)
coin:SetText(amount)
local label = getglobal(coinName .. "Text")
local digits = 0
if label and label.GetStringWidth then
digits = label:GetStringWidth() or 0
end
coin:SetWidth(digits + iconWidth)
coin:SetWidth(BenillaNumberWidth(amount) + iconWidth)
coin:Show()
end
+12 -3
View File
@@ -683,9 +683,18 @@ pub(super) fn apply_net_updates(
sound_id,
instant,
} => world::weather(weather_type, grade, sound_id, instant, &mut audio.1),
SessionEvent::TextEmote { guid, text_emote } => {
anim::text_emote(guid, text_emote, &index, &mut audio.2)
}
SessionEvent::TextEmote {
guid,
text_emote,
target_name,
} => anim::text_emote(
guid,
text_emote,
target_name,
&index,
&mut audio.2,
&mut chat_log,
),
SessionEvent::Emote { guid, emote_id } => {
anim::emote(guid, emote_id, &index, &mut audio.2)
}
+9
View File
@@ -14,16 +14,25 @@ use super::super::{EmoteKind, EmoteMessage, GuidIndex};
/// `SMSG_TEXT_EMOTE` — someone performed a `/`-emote (the TextEmote.dbc id; the anim, if any, is
/// the emote row's).
///
/// **Two consequences, and they are independent** (decision 1274): the anim + voice ride the
/// [`EmoteMessage`] and need the performer *streamed*; the chat sentence is queued for
/// [`crate::ui_chat`]'s composer and needs only the performer's *name*. An emote from someone
/// off-screen therefore still prints its line, which is the reference's shape — `0x49dbe0`
/// resolves a name and never touches the object manager.
pub(super) fn text_emote(
guid: u64,
text_emote: u32,
target_name: String,
index: &GuidIndex,
out: &mut MessageWriter<EmoteMessage>,
chat_log: &mut crate::ui_chat::ChatLog,
) {
out.write(EmoteMessage {
source: index.0.get(&guid).copied(),
kind: EmoteKind::Text(text_emote),
});
chat_log.push_text_emote(guid, text_emote, target_name);
}
/// `SMSG_EMOTE` — a bare Emotes.dbc anim id on a unit (the server-driven one-shot: NPC scripts,
+4 -1
View File
@@ -47,9 +47,12 @@ impl Plugin for UiChatPlugin {
// arm, and silently loads nothing — no zone channels, ever, with no error. A live
// probe is what caught that; no unit test could, because the tests hand the catalog in.
// `crate::area`'s `AreaTable.dbc` load carries the same ordering for the same reason.
// `EmotesText.dbc` × `EmotesTextData.dbc` (decision 1274) rides the same ordering for
// the same reason: the sentence tables are read once, off the open patch chain.
.add_systems(
Startup,
channels::load_chat_channels.after(benilla_assets::AssetSet::Open),
(channels::load_chat_channels, feed::load_emote_texts)
.after(benilla_assets::AssetSet::Open),
)
// The slash-command table (decision 0881), built from the reference's own alias strings
// once the VM's globals and the emote catalog exist — both are `Startup`, so this runs
+5
View File
@@ -103,6 +103,11 @@ impl SendType {
/// The header's display color = the matching receive kind's table color
/// (`ChatEdit_UpdateHeader` reads `ChatTypeInfo[type]`).
///
/// CHANNEL takes the same override the render path does — `info = ChatTypeInfo["CHANNEL"..
/// channel]` (l.1902), the number from `GetChannelName`. It lands on the identical FFC0C0
/// while the extras carry CHANNEL's seed color (1275), so the CHANNEL arm below is right
/// today; a per-number recolor is what would make the box's number load-bearing here.
fn color(self) -> [u8; 3] {
use ChatEventKind as K;
default_color(match self {
+47 -3
View File
@@ -419,9 +419,11 @@ pub(crate) fn group_kinds(group: ChatGroup) -> &'static [ChatEventKind] {
}
}
/// The kind's default color — the complete shipped table (chat-cache COLORS ≡ wow-re
/// `chat-color-table.md`, both quoted in 0288's pin; entries this kind set carries). The numbered
/// CHANNEL1..10 rows all ship the CHANNEL pink until per-number recolor exists (settings).
/// The kind's row in the color table — NOT necessarily the color a line renders in. The channel
/// family's row is looked up and then *replaced*; [`resolved_color`] is the law that renders.
///
/// The complete shipped table (chat-cache COLORS ≡ wow-re `chat-color-table.md`, both quoted in
/// 0288's pin; entries this kind set carries).
pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
use ChatEventKind as K;
match kind {
@@ -456,6 +458,48 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
}
}
/// `ChatTypeInfo["CHANNEL"..n]` — the color of the numbered channel in slot `n` (arg8).
///
/// These are not part of the 94-entry static table: the boot seed creates ten *extra* registry
/// entries named `CHANNEL1`…`CHANNEL10` and colors every one of them from the **live CHANNEL
/// entry**, so they all start at CHANNEL's FFC0C0 (wow-re `chat-color-table.md`, "Seeding" —
/// `0x4982c0`, and `ResetChatColors 0x4a09e0` re-does exactly that). Per-number recolor is a
/// `ChangeChatColor` away, so this stays a function of `n` even though nothing varies by it yet.
fn channel_row_color(_number: u32) -> [u8; 3] {
[255, 192, 192]
}
/// The color a line actually renders in — `ChatFrame_OnEvent`'s `info` resolution, which is not
/// simply [`default_color`] of the kind (ref ChatFrame.lua l.1371-1386).
///
/// The handler looks up `ChatTypeInfo[type]` and then, for every `CHANNEL*` type, **replaces** it
/// with the numbered channel's own row — `info = ChatTypeInfo["CHANNEL"..arg8]` (l.1381). Its
/// guard is `strsub(type,1,7) == "CHANNEL" and type ~= "CHANNEL_LIST" and (arg1 ~= "INVITE" or
/// type ~= "CHANNEL_NOTICE_USER")`, transcribed below. So the grey CHANNEL_NOTICE row (C0C0C0) is
/// looked up and then thrown away: a join/leave notice renders in the channel's FFC0C0, which is
/// what makes those lines read warm rather than white in the real client (1275).
///
/// **KNOWN DIVERGENCE — arg8 == 0.** The reference reaches its override only after finding the
/// channel in `ChatFrame1.channelList`; a miss `return`s and the line never renders at all. That
/// list is FrameXML's own (`ChatFrame_AddChannel`), which we do not model — our channel list is
/// the *client-side* one ([`super::edit::ChannelState`]) — so implementing the drop would gate on
/// the wrong list and silently eat notices about channels we are not in ("Not on channel %s."
/// being the sharpest case). We render those, in the row the extras are seeded from. 1275.
pub(crate) fn resolved_color(event: &ChatEvent, kind: ChatEventKind) -> [u8; 3] {
use ChatEventKind as K;
let invite_notice = kind == K::ChannelNoticeUser
&& event.notice_byte() == Some(benilla_protocol::messages::channel_notice::INVITE);
match kind {
K::Channel | K::ChannelJoin | K::ChannelLeave | K::ChannelNotice | K::ChannelNoticeUser
if !invite_notice =>
{
channel_row_color(event.channel_number)
}
// CHANNEL_LIST and the INVITE notice keep the row they were looked up in.
_ => default_color(kind),
}
}
/// Map a wire `ChatMsg` byte (`SMSG_MESSAGECHAT.chat_type`) to its event kind. `None` = a type
/// vmangos never emits as wire chat (the combat-log block) or one we don't model — the router
/// drops it loudly.
+196 -37
View File
@@ -10,8 +10,11 @@ use benilla_protocol::messages::{
channel_notice, ChannelNoticeTail, ChatMessage, LevelUpInfo, XpGain, MACRO_EXPANDED_TYPES,
};
use benilla_assets::{LockRecover, WorldAssets};
use benilla_formats::{EmoteLine, EmoteTextCatalog};
use crate::names::NameCache;
use crate::net::{GuidIndex, NetCommands, ObjectStore};
use crate::net::{GuidIndex, NetCommands, ObjectStore, SelfGuid};
use super::edit::ChannelState;
use super::event::{flag_of_tag, kind_of_wire, language_name, ChatEvent, ChatEventKind};
@@ -22,6 +25,32 @@ use super::frames::{route, ChatWindows};
/// round-trip). The line renders with a placeholder rather than being lost.
const NAME_MAX_TRIES: u16 = 120;
/// The text-emote sentence tables (decision 1274), read once off the patch chain.
#[derive(Resource)]
pub(crate) struct EmoteTexts(pub(crate) EmoteTextCatalog);
/// **The locale column is 0.** `[0xc0e080]` is the client's locale slot; only enUS is populated in
/// this install, and every other DBC catalog in the tree reads column 0 for the same reason.
const LOCALE: usize = 0;
/// Load `EmotesText.dbc` × `EmotesTextData.dbc`. `.after(benilla_assets::AssetSet::Open)` at the
/// call site is load-bearing for the reason [`super::channels::load_chat_channels`] records:
/// without it the patch chain does not exist yet and this silently loads nothing.
pub(super) fn load_emote_texts(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_emote_text_catalog(&mut chain)
};
match loaded {
Ok(cat) => {
info!("chat: {} emote sentence tables", cat.len());
commands.insert_resource(EmoteTexts(cat));
}
Err(e) => warn!("chat: emote text catalog failed to load: {e:#}"),
}
}
/// One queued item awaiting its turn through [`feed_chat`].
enum Pending {
/// A decoded `SMSG_MESSAGECHAT`: needs kind mapping + (player kinds) an ask-once name resolve.
@@ -62,6 +91,22 @@ enum Pending {
bonus: u32,
tries: u16,
},
/// A text emote (`SMSG_TEXT_EMOTE`) awaiting the PERFORMER's name — decision 1274.
///
/// Parked here for the reference's own reason, not merely for convenience: `0x49dbe0` composes
/// the sentence immediately when the `NameCache` already holds the performer, and otherwise
/// queues a `PENDINGTEXTEMOTE` node (`0x49cc00`) that the name-query callback (`0x49d0d0`)
/// drains into the same composer. This queue *is* that node, and the bounded `tries` is its
/// give-up edge.
///
/// `target` is the raw wire name (empty = untargeted), never re-resolved — the reference
/// passes the server's string straight into the format.
TextEmote {
performer: u64,
text_id: u32,
target: String,
tries: u16,
},
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`): the toast + conditional chat line pair
/// (the drain fires them — the toast needs the script, which only [`feed_chat`] holds).
Discovery { area: String, xp: u32 },
@@ -95,6 +140,32 @@ pub(super) fn fire_addon_message(
);
}
/// One text emote's chat event — the sentence plus the performer name — or `None` when the
/// sentence table has nothing to say (the reference's `0x49b4bd` tail: SIT/STAND/TRAIN ship blank
/// in every locale, so a vanilla `/sit` prints no line at all).
///
/// Split out of the drain for [`fire_addon_message`]'s reason: the scaffolding around it is not
/// what can be wrong, the **shape** is — which sentence form the facts select, and which slot the
/// performer's name lands in.
///
/// **`sender` is the PERFORMER**, and it never reaches the rendered line: TEXT_EMOTE is one of the
/// verbatim families in `ChatFrame_OnEvent`, so the sentence *is* the line and this slot only ever
/// reaches addons as arg2. VERIFIED at the bytes: the last push before the event fire at
/// `0x49b495` is `[ebp-0x4]`, the `NameCache` record resolved for the performer at `0x49b289`
/// (`record+0` is the name string).
pub(super) fn text_emote_event(
cat: &EmoteTextCatalog,
text_id: u32,
line: &EmoteLine,
) -> Option<ChatEvent> {
Some(ChatEvent {
kind: Some(ChatEventKind::TextEmote),
text: cat.compose(text_id, line, LOCALE)?,
sender: line.performer.to_string(),
..Default::default()
})
}
/// The pending chat items the net/loot/quest feeds fill and [`feed_chat`] drains. Cleared on
/// disconnect (a half-resolved line from a dead session must not leak into the next).
#[derive(Resource, Default)]
@@ -225,6 +296,17 @@ impl ChatLog {
}
}
/// Queue a text emote's chat line (`SMSG_TEXT_EMOTE` → CHAT_MSG_TEXT_EMOTE; decision 1274) for
/// the performer-name resolve, then the `EmotesText`/`EmotesTextData` composition.
pub(crate) fn push_text_emote(&mut self, performer: u64, text_id: u32, target: String) {
self.pending.push(Pending::TextEmote {
performer,
text_id,
target,
tries: 0,
});
}
/// Queue an area discovery's announcement (`SMSG_EXPLORATION_EXPERIENCE`; decision 0828,
/// surfaces corrected by the 0829 RE): the ERR_ZONE_EXPLORED toast fires on **every** packet
/// (UIErrorsFrame, via `UI_INFO_MESSAGE` — never chat), and the ERR_ZONE_EXPLORED_XP chat
@@ -375,6 +457,63 @@ fn notice_event(
}
}
/// Deliver one built event: the joined-list upkeep on **both sides** of the render, with the
/// channel stamp and [`route`] between them — the client's own split, and the reason a leave line
/// still knows its number.
///
/// **YOU_JOINED lands before, YOU_LEFT lands after.** The notice's arg4/arg7/arg8/arg9 are read off
/// the client's channel record ([`ChannelState::stamp_channel`] is our leg of that), so a record
/// torn down before the line is composed costs it the slot number — and, since the color resolves
/// through `ChatTypeInfo["CHANNEL"..arg8]` ([`super::event::resolved_color`]), its color with it.
/// The reference's YOU_LEFT arm only *flags* the teardown (`0x49c115 mov dword [ebp-0xc],1`); the
/// event fires first (`0x49c5b0 call 0x49a870`) and only then does `0x49c5b5 test` /
/// `0x49c5c2 call 0x49bbd0` destroy the record — VERIFIED by disassembly at those addresses (1275).
/// We removed first and printed "Left Channel: [General]" where the client prints "Left Channel:
/// [2. General - Elwynn Forest]"; an addon reading `GetChannelName` from its own handler also saw
/// the channel already gone, which the reference never shows it either.
///
/// **Unmodeled, and visible right there in that arm:** the teardown flag is the *YOU_LEFT* leg's
/// alone. Notice `0x03` splits on `rec+0x9c == 3` — the SUSPENDED leg (`0x49c0e9`) takes a
/// different token AND jumps past `0x49c115`, so a suspended channel keeps its record and its
/// number. We do not model that state field (it is the same one that makes `0x02` "YOU_CHANGED"),
/// so every `0x03` here is a genuine leave.
///
/// The two arms log, because this edge is where "we asked to join" becomes "the server says we are
/// in": [`super::channels`]'s walk only ever proves the request went out, and the round trip is
/// what actually arms an addon (it is the `CHAT_MSG_CHANNEL_NOTICE` Ace2's whole init gate waits
/// on). A join the server refuses is otherwise completely silent on this side. Each arm also
/// mirrors the list into the VM, where `GetChannelName` reads it (17 corpus sites across 6 addons):
/// these two are the only places it ever changes.
pub(super) fn deliver(
script: &mut benilla_ui::script::UiScript,
windows: &mut ChatWindows,
channels: &mut ChannelState,
event: &mut ChatEvent,
) {
let notice = event
.notice_byte()
.filter(|_| event.kind == Some(ChatEventKind::ChannelNotice));
if notice == Some(channel_notice::YOU_JOINED) && channels.number_of(&event.channel).is_none() {
channels.joined.push(event.channel.clone());
debug!(
"chat: server confirms channel {:?} joined (slot {})",
event.channel,
channels.joined.len()
);
script.set_joined_channels(channels.joined.clone());
}
// The wire name, kept before `stamp_channel` decorates arg4 with the slot number.
let leaving = (notice == Some(channel_notice::YOU_LEFT)).then(|| event.channel.clone());
// A member-line / notice channel renders numbered when we know its slot.
channels.stamp_channel(event);
route(script, windows, event);
if let Some(name) = leaving {
debug!("chat: server confirms channel {name:?} left");
channels.joined.retain(|c| !c.eq_ignore_ascii_case(&name));
script.set_joined_channels(channels.joined.clone());
}
}
/// Drain [`ChatLog`]: resolve names (ask-once, bounded), build events, [`route`] them. Also ticks
/// the whisper-chime throttle.
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
@@ -389,6 +528,10 @@ pub(super) fn feed_chat(
bubble_cfg: Res<crate::chat_bubble::BubbleConfig>,
commands: Res<NetCommands>,
time: Res<Time>,
// The text-emote sentence seam (decision 1274): the tables, plus the guid the composer's
// "are you the performer?" test compares against.
emote_texts: Option<Res<EmoteTexts>>,
self_guid: Res<SelfGuid>,
// The `$`-macro subject seam: monster/BG lines expand against the guid the line is ADDRESSED to,
// which needs the object index + the streamed unit's descriptors. See [`macro_subject`].
guids: Res<GuidIndex>,
@@ -407,42 +550,7 @@ pub(super) fn feed_chat(
for item in pending {
match item {
Pending::Event(mut event) => {
// The joined-list upkeep (the client-side number law): YOU_JOINED appends,
// YOU_LEFT (and the suspend form) removes.
//
// Logged, because this edge is where "we asked to join" becomes "the server says
// we are in": [`super::channels`]'s walk only ever proves the request went out, and
// the round trip is what actually arms an addon (it is the `CHAT_MSG_CHANNEL_NOTICE`
// Ace2's whole init gate waits on). A join the server refuses is otherwise
// completely silent on this side.
if event.kind == Some(ChatEventKind::ChannelNotice) {
match event.notice.as_str() {
"2" if channels.number_of(&event.channel).is_none() => {
channels.joined.push(event.channel.clone());
debug!(
"chat: server confirms channel {:?} joined (slot {})",
event.channel,
channels.joined.len()
);
}
"3" => {
debug!("chat: server confirms channel {:?} left", event.channel);
channels
.joined
.retain(|c| !c.eq_ignore_ascii_case(&event.channel));
}
_ => {}
}
// Mirror the confirmed list into the VM, where `GetChannelName` reads it
// (17 corpus sites across 6 addons). Here rather than beside either arm
// because this is the ONLY place the list changes, so one push cannot drift
// from it — and unconditional within the notice branch so a notice that
// changed nothing still costs one clone rather than risking a missed edge.
script.set_joined_channels(channels.joined.clone());
}
// A member-line / notice channel renders numbered when we know its slot.
channels.stamp_channel(&mut event);
route(&mut script, &mut windows, &event);
deliver(&mut script, &mut windows, &mut channels, &mut event);
}
Pending::Wire { msg, tries } => {
let kind = kind_of_wire(msg.chat_type);
@@ -687,6 +795,57 @@ pub(super) fn feed_chat(
),
);
}
Pending::TextEmote {
performer,
text_id,
target,
tries,
} => {
// Both names resolve ask-once: the performer's is the sentence's `%s`, and our own
// is what the "is the target me?" compare needs (`GetOwnName` in the reference,
// which never has to wait for it — we can, so we park like every other line).
let performer_name = names.resolve(performer, &commands).map(str::to_string);
let your_name = self_guid
.0
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
if (performer_name.is_none() || your_name.is_none()) && tries < NAME_MAX_TRIES {
still.push(Pending::TextEmote {
performer,
text_id,
target,
tries: tries + 1,
});
continue;
}
// No name for the performer ⇒ **no line**, not an "Unknown" one: the reference
// bails outright when `NameCache::GetRecord` misses (`0x49b28c`), and that cache
// is player-only. The emote is still an animation and a voice — those rode the
// `EmoteMessage` path at decode and do not depend on this.
let (Some(performer_name), Some(cat)) = (performer_name, emote_texts.as_deref())
else {
debug!("chat: text emote {text_id} from {performer:#x} has no sentence source");
continue;
};
let event = text_emote_event(
&cat.0,
text_id,
&EmoteLine {
performer: &performer_name,
performer_is_you: self_guid.0 == Some(performer),
// Sex 1 = Female, from the same `SMSG_NAME_QUERY_RESPONSE` the name came
// from — exactly where the reference reads it (`record+0x13c`).
performer_female: names
.player_traits(performer)
.is_some_and(|(_, _, sex)| sex == 1),
target: &target,
your_name: your_name.as_deref().unwrap_or_default(),
},
);
// A dry ladder is a real outcome, not a failure: SIT/STAND/TRAIN ship blank in
// every locale, so a vanilla `/sit` prints nothing.
let Some(event) = event else { continue };
route(&mut script, &mut windows, &event);
}
Pending::Discovery { area, xp } => {
// The toast fires every time; the chat line only rides XP (decisions 0828/0829).
script.fire_event(
+19 -7
View File
@@ -4,14 +4,15 @@
//! window; [`compose`] is the `ChatFrame_OnEvent` composition law transcribed — the `CHAT_*_GET`
//! patterns, the `<AFK>/<DND>/<GM>` flag prefix, the `|Hplayer:…|h[Name]|h` link (never on EMOTE
//! or monster lines), the `[Language]` header, the `[N. Name]` channel prefix with its " - Zone"
//! tail stripped, and the `CHAT_<X>_NOTICE` channel-notice strings. Formats are QUOTED from the
//! extracted GlobalStrings (0288's pin, §2/§4); colors come from [`super::event::default_color`].
//! tail stripped (the SPEECH branch only — a notice prints arg4 whole, 1275), and the
//! `CHAT_<X>_NOTICE` channel-notice strings. Formats are QUOTED from the extracted GlobalStrings
//! (0288's pin, §2/§4); colors come from [`super::event::resolved_color`].
use bevy::prelude::*;
use benilla_protocol::messages::channel_notice as notice;
use super::event::{default_color, event_name, group_kinds, ChatEvent, ChatEventKind, ChatGroup};
use super::event::{event_name, group_kinds, resolved_color, ChatEvent, ChatEventKind, ChatGroup};
/// How long after a received whisper the `TellMessage` alert stays silent
/// (`CHAT_TELL_ALERT_TIME = 300` — ref ChatFrame.lua l.4: only a tell arriving ≥5 min after the
@@ -111,7 +112,7 @@ pub(crate) fn route(
};
// ── our own window: the transcribed ChatFrame_OnEvent, i.e. the first-registered listener ──
if let Some(line) = compose(event, kind) {
let color = default_color(kind);
let color = resolved_color(event, kind);
for idx in 0..2 {
if windows.wants(idx, kind) {
add(script, &format!("ChatFrame{}", idx + 1), &line, color);
@@ -164,8 +165,9 @@ pub(crate) fn compose(event: &ChatEvent, kind: ChatEventKind) -> Option<String>
| K::BgSystemHorde => event.text.clone(),
// "%s is ignoring you." (CHAT_IGNORED, arg2).
K::Ignored => format!("{} is ignoring you.", event.sender),
// "[%s] " .. the member list (CHAT_CHANNEL_LIST_GET).
K::ChannelList => format!("[{}] {}", strip_zone(&event.channel), event.text),
// "[%s] " .. the member list (CHAT_CHANNEL_LIST_GET, l.1409) — arg4 WHOLE, see
// [`strip_zone`]: only the speech branch runs the gsub.
K::ChannelList => format!("[{}] {}", event.channel, event.text),
K::ChannelNotice | K::ChannelNoticeUser => {
return compose_notice(event);
}
@@ -253,6 +255,13 @@ fn get_pattern(kind: ChatEventKind) -> &'static str {
/// Strip the zone tail from a channel display name (`gsub(arg4, "%s%-%s.*", "")` —
/// "General - Elwynn Forest" → "General", "2. Trade - City" → "2. Trade").
///
/// **The speech branch is the ONLY caller, and that is the reference's own shape** (1275): the
/// gsub sits at l.1463, inside the `else` arm that builds a player/monster line, *after* every
/// notice arm has already returned. CHANNEL_NOTICE (l.1424), CHANNEL_NOTICE_USER (l.1416/1418)
/// and CHANNEL_LIST (l.1409) each pass **arg4 whole** into their format — so the real client's
/// join line reads "Joined Channel: [1. General - Elwynn Forest]" while a line spoken in that same
/// channel is prefixed "[1. General]". We stripped in all four and lost the tail from three.
fn strip_zone(channel: &str) -> &str {
match channel.find(" - ") {
Some(i) => &channel[..i],
@@ -264,8 +273,11 @@ fn strip_zone(channel: &str) -> &str {
/// `CHAT_<X>_NOTICE` string (GlobalStrings 493-745); `channel` fills `%s` first, the tail names
/// (already guid-resolved by the bridge) fill the rest. `None` = the 1.12 UI shows nothing for
/// this notice (MODE_CHANGE has no NOTICE string — flag-change chatter is silent).
///
/// `chan` is arg4 **whole**, zone tail and all — see [`strip_zone`] for why the notice arms are
/// not the gsub's callers.
pub(crate) fn compose_notice(event: &ChatEvent) -> Option<String> {
let chan = strip_zone(&event.channel);
let chan = &event.channel;
let a = &event.sender; // the notice's first name (actor / affected)
let b = &event.target; // the second name (kicked-by style)
let n: u8 = event.notice_byte().unwrap_or(0xFF);
+194 -1
View File
@@ -105,6 +105,75 @@ fn system_and_loot_lines_are_verbatim() {
);
}
/// B156's visible half, in one assertion: a TEXT_EMOTE line renders **verbatim**, and setting the
/// performer in `sender` (arg2, for addons) must not make the composer bracket a name onto it the
/// way it does for SAY. If this ever starts reading "[Bob] Bob waves.", the sender slot has leaked
/// into the render.
#[test]
fn text_emote_lines_are_verbatim_and_never_wear_the_senders_name() {
let e = ev(K::TextEmote, "Bob waves at you.", "Bob");
assert_eq!(compose(&e, K::TextEmote).unwrap(), "Bob waves at you.");
// The control: the same event as a SAY *does* get the bracketed link, so the assertion above
// is about the TEXT_EMOTE arm and not about `compose` having stopped decorating anything.
assert!(compose(&ev(K::Say, "hi", "Bob"), K::Say)
.unwrap()
.contains("[Bob]"));
}
/// The receive half of B156 on the real tables (decision 1274): the five reachable sentence forms,
/// the performer in arg2, and the three silent rows. The composition law itself is pinned in
/// `benilla_formats::emote_text`; what this covers is the seam — that the app hands the composer
/// the right facts and puts the result in the right slots. Skips without client data.
#[test]
fn a_received_text_emote_composes_its_sentence_and_names_the_performer() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_emote_text_catalog(&mut chain).expect("emote text catalog");
const WAVE: u32 = 101;
const SIT: u32 = 86;
fn them(target: &'static str) -> benilla_formats::EmoteLine<'static> {
benilla_formats::EmoteLine {
performer: "Bob",
performer_is_you: false,
performer_female: false,
target: if target == "-" { "" } else { target },
your_name: "Me",
}
}
fn mine(target: &'static str) -> benilla_formats::EmoteLine<'static> {
benilla_formats::EmoteLine {
performer: "Me",
performer_is_you: true,
..them(target)
}
}
let line = |text_id, l| super::feed::text_emote_event(&cat, text_id, &l);
for (l, expected) in [
(them("Jane"), "Bob waves at Jane."),
(them("Me"), "Bob waves at you."),
(them("-"), "Bob waves."),
] {
let e = line(WAVE, l).expect("a sentence");
assert_eq!(e.text, expected);
assert_eq!(e.kind, Some(K::TextEmote));
// arg2 is the performer, not the target — the reference pushes the performer's NameCache
// record (`0x49b47c`).
assert_eq!(e.sender, "Bob");
}
for (l, expected) in [
(mine("Jane"), "You wave at Jane."),
(mine("-"), "You wave."),
] {
let e = line(WAVE, l).expect("a sentence");
assert_eq!(e.text, expected);
assert_eq!(e.sender, "Me");
}
// SIT's columns point at EmotesTextData rows that ship blank: no line, not an empty one.
assert!(line(SIT, them("-")).is_none(), "/sit prints nothing");
}
#[test]
fn level_up_lines_follow_the_reference_order_and_forms() {
use benilla_protocol::messages::LevelUpInfo;
@@ -213,6 +282,12 @@ fn channel_line_prefixes_the_stripped_channel() {
);
}
/// The notice arms print arg4 **whole** — zone tail and all (1275).
///
/// The pair to hold in view is [`channel_line_prefixes_the_stripped_channel`] directly above: the
/// same channel, the same arg4, and the reference renders them differently. `gsub(arg4,
/// "%s%-%s.*", "")` lives at l.1463, inside the speech `else` arm, *after* every notice arm has
/// returned — so speech says "[General]" and the join notice says "[General - Elwynn Forest]".
#[test]
fn channel_notices_compose_by_the_notice_law() {
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
@@ -220,7 +295,7 @@ fn channel_notices_compose_by_the_notice_law() {
e.notice = "2".into(); // YOU_JOINED
assert_eq!(
compose(&e, K::ChannelNotice).unwrap(),
"Joined Channel: [General]"
"Joined Channel: [General - Elwynn Forest]"
);
let mut kick = ChatEvent::text_only(K::ChannelNotice, String::new());
kick.channel = "World".into();
@@ -522,6 +597,124 @@ fn stamping_a_channel_splits_the_display_form_from_the_base_name() {
assert_eq!(other.zone_channel_id, 0); // arg7
}
/// **A channel notice renders in the CHANNEL row, not the CHANNEL_NOTICE row** (1275).
///
/// `ChatFrame_OnEvent` looks up `ChatTypeInfo[type]` and then overwrites it for the whole channel
/// family: `info = ChatTypeInfo["CHANNEL"..arg8]` (l.1381). So the grey C0C0C0 the CHANNEL_NOTICE
/// row carries is looked up and thrown away, and the join line comes out the channel's FFC0C0 —
/// which is what the director's eye caught: our notices read white-grey where the client's read
/// warm. Driven through the real router into the real window and read back off the extracted
/// quad, because the color that matters is the one that reaches the screen.
#[test]
fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() {
use benilla_ui::script::QuadContent;
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState::default();
channels.joined.push("General - Elwynn Forest".into());
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "General - Elwynn Forest".into();
e.notice = "2".into(); // YOU_JOINED
super::feed::deliver(&mut s, &mut windows, &mut channels, &mut e);
s.resolve();
let line = "Joined Channel: [1. General - Elwynn Forest]";
let color = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color: Some(c),
..
} if t == line => Some(*c),
_ => None,
})
.unwrap_or_else(|| panic!("the notice line {line:?} rendered"));
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
assert!(
near(color[0], 1.0) && near(color[1], 192.0 / 255.0) && near(color[2], 192.0 / 255.0),
"FFC0C0 (the CHANNEL1 row), not C0C0C0 (the CHANNEL_NOTICE row): {color:?}"
);
}
/// The two rows the reference's own guard exempts from that override, and the family it covers.
///
/// The condition is `strsub(type,1,7) == "CHANNEL" and type ~= "CHANNEL_LIST" and (arg1 ~= "INVITE"
/// or type ~= "CHANNEL_NOTICE_USER")` — so CHANNEL_LIST keeps C08080 and an INVITE notice keeps the
/// CHANNEL_NOTICE_USER grey, while everything else in the family takes the channel's color even
/// though their own rows differ (CHANNEL_JOIN/LEAVE are C08080, the notices C0C0C0).
#[test]
fn the_channel_color_override_covers_the_family_but_not_its_two_exemptions() {
use super::event::resolved_color;
let chan = [255, 192, 192];
let mut joined = ev(K::ChannelJoin, "", "Ann");
joined.channel_number = 2;
assert_eq!(resolved_color(&joined, K::ChannelJoin), chan);
assert_eq!(resolved_color(&joined, K::ChannelLeave), chan);
assert_eq!(resolved_color(&joined, K::Channel), chan);
let mut notice = ChatEvent::text_only(K::ChannelNotice, String::new());
notice.notice = "3".into(); // YOU_LEFT
notice.channel_number = 2;
assert_eq!(resolved_color(&notice, K::ChannelNotice), chan);
assert_ne!(resolved_color(&notice, K::ChannelNotice), [192, 192, 192]);
// CHANNEL_LIST: exempt, keeps its own C08080.
let list = ChatEvent::text_only(K::ChannelList, "Ann, Bob".into());
assert_eq!(resolved_color(&list, K::ChannelList), [192, 128, 128]);
// INVITE (0x18) on CHANNEL_NOTICE_USER: exempt, keeps the notice grey — and the exemption is
// arg1's, so the same kind carrying any other token does take the channel color.
let mut invite = ChatEvent::text_only(K::ChannelNoticeUser, String::new());
invite.notice = "24".into();
assert_eq!(
resolved_color(&invite, K::ChannelNoticeUser),
[192, 192, 192]
);
invite.notice = "23".into(); // PLAYER_ALREADY_MEMBER
assert_eq!(resolved_color(&invite, K::ChannelNoticeUser), chan);
}
/// **The leave line still knows its number, because the record dies after the line** (1275).
///
/// [`super::feed::deliver`] is the ordering under test: we used to drop the channel from the joined
/// list before composing, so `stamp_channel` missed and the line came out "Left Channel: [General]"
/// — unnumbered, and (with the color override above) resolved against arg8 = 0. The reference's
/// YOU_LEFT arm flags the teardown and runs it *after* the fire (`0x49c5b0` fire, `0x49c5c2 call
/// 0x49bbd0`), so the line is numbered and an addon's handler still sees the channel.
#[test]
fn a_leave_notice_keeps_its_number_because_the_record_dies_after_the_line() {
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState::default();
channels.joined.push("World".into());
channels.joined.push("General - Elwynn Forest".into());
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "General - Elwynn Forest".into();
e.notice = "3".into(); // YOU_LEFT
super::feed::deliver(&mut s, &mut windows, &mut channels, &mut e);
assert_eq!(e.channel, "2. General - Elwynn Forest", "arg4 was stamped");
assert_eq!(
e.channel_number, 2,
"arg8 — what the color resolves through"
);
assert_eq!(
super::frames::compose(&e, K::ChannelNotice).unwrap(),
"Left Channel: [2. General - Elwynn Forest]"
);
assert_eq!(
channels.joined,
["World"],
"and only THEN is the record gone"
);
}
/// Every notice byte the composer renders has a token to fire, and vice versa — the two tables are
/// the same set by assertion rather than by good intentions (they are read off the same
/// `CHAT_<X>_NOTICE` GlobalStrings keys).
@@ -966,3 +966,275 @@ fn the_whole_bagnon_window_survives_being_used() {
);
}
}
/// Bagnon's window with a **stacked** item in the backpack — the count FontString only paints when
/// the stack is >1 (`SetItemButtonCount`), so the plain [`open_bagnon`] fixture's single Small
/// Brown Pouch can never exercise it.
fn open_bagnon_stacked(root: &Path) -> UiScript {
let mut s = seat(root, Seat::BeforeAddons);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 200,
item_id: 4496,
quality: Some(1),
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("ToggleBackpack()").expect("ToggleBackpack");
for _ in 0..3 {
s.tick(0.1);
}
s.resolve();
s
}
/// The resolved `QuadContent::Text` for the one named FontString, or a panic naming what was found.
fn text_quad(s: &mut UiScript, owner: &str) -> QuadContent {
s.resolve();
s.extract()
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Text { text: Some(_), .. })
&& s.target_owner_name(q.target).as_deref() == Some(owner)
})
.map(|q| q.content.clone())
.unwrap_or_else(|| panic!("no text quad owned by {owner}"))
}
/// **The director's report: "item stack size text is missing the black bg."**
///
/// Bagnon writes `<FontString name="$parentCount" font="NumberFontNormal">`
/// (`Bagnon_Core/core/Item.xml:14`) — the `font=` attribute, which the reference resolves against
/// the font-object registry BEFORE the filesystem (`0x783d15 call 0x783870(value, create = 0)`;
/// see [`super::super::loader`]'s `apply_fontstring_font`). We read it as a path only, so the
/// literal string `"NumberFontNormal"` became the face: no height, no colour, and no
/// `outline="NORMAL"` — and the atlas fell back silently to Friz 12.
///
/// **The black is the OUTLINE, not a shadow.** `NumberFontNormal` carries no `<Shadow>` in 1.12
/// (`Fonts.xml:226`, matching the install byte for byte), so `shadow: None` here is correct and is
/// asserted as such — a future "fix" that adds one would be a divergence, not an improvement.
#[test]
fn a_stack_count_wears_the_font_object_its_font_attr_names() {
let root = corpus_or_skip!();
let mut s = open_bagnon_stacked(&root);
let QuadContent::Text {
text,
font,
font_height,
outline,
shadow,
color,
..
} = text_quad(&mut s, "BagnonItem1")
else {
unreachable!("filtered to Text above")
};
assert_eq!(text.as_deref(), Some("200"), "the stack count is on screen");
// Every one of these was lost while `font=` was read as a path. The face is the assertion that
// the registry was consulted at all; the outline is the one the director can SEE.
assert_eq!(
font.as_deref(),
Some("Fonts\\ARIALN.TTF"),
"`font=\"NumberFontNormal\"` must resolve the font OBJECT, not become a font path"
);
assert_eq!(font_height, Some(14.0), "NumberFontNormal's height");
assert_eq!(
outline,
benilla_ui::script::Outline::Normal,
"the black ring around a stack count IS `outline=\"NORMAL\"` — this is the reported symptom"
);
assert_eq!(
color,
Some([1.0, 1.0, 1.0, 1.0]),
"NumberFontNormal's white"
);
assert_eq!(
shadow, None,
"and NO drop shadow: NumberFontNormal has no <Shadow> in 1.12 either (Fonts.xml:226). \
The readability is the outline; adding a shadow here would be a divergence"
);
}
/// **The director's report: the character dropdown draws as a solid WHITE box.**
///
/// `BagnonPopupFrame` (`Bagnon_Core/core/Frame.xml:23-36`) backs itself with
/// `Interface\ChatFrame\ChatFrameBackground` — art that is white by design — and tints it
/// black→dark-grey with a `<Gradient>`. The loader parsed `<Color>` and `<TexCoords>` but never
/// `<Gradient>`, and dropped it in silence, so the white art rendered untinted.
///
/// `<Gradient>` survives beside a `file=` where `<Color>` does not, and that asymmetry is the
/// point: they land in different fields (vertex colours `+0xb8` vs the texture `+0xcc` —
/// wow-re `texture-color-composition.md` §1-2).
#[test]
fn a_texture_gradient_tints_the_art_it_sits_on() {
let root = corpus_or_skip!();
let mut s = open_bagnon_stacked(&root);
s.run("BagnonDBUI_ShowCharacterList(Bagnon)")
.expect("BagnonDBUI_ShowCharacterList");
for _ in 0..3 {
s.tick(0.1);
}
s.resolve();
let bg = s
.extract()
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("ChatFrameBackground"))
&& s.target_owner_name(q.target).as_deref() == Some("BagnonDBUICharacterList")
})
.map(|q| q.content.clone())
.expect("the popup's background art reaches the render list");
let QuadContent::Texture { color, .. } = bg else {
unreachable!("filtered to Texture above")
};
// The two stops are (0,0,0,0.9) and (0.2,0.2,0.2,0.9); `RegionData::gradient` is folded to its
// midpoint by the paint, so this is 0.1 grey at the stops' shared alpha. `None` — or white —
// is the bug: untinted ChatFrameBackground is the white slab on the director's screen.
assert_eq!(
color,
Some([0.1, 0.1, 0.1, 0.9]),
"the <Gradient> must tint the art; untinted this is the reported white box"
);
}
/// **The director's report: "when I first open the bags with bagnon the gold numbers are all
/// cramped up; if I close and open again it looks good."**
///
/// Bagnon's money display is `SmallMoneyFrameTemplate` — OUR `MoneyFrame.xml`. Its `ShowCoin` used
/// to size each coin with `label:GetStringWidth()`, which is served from the measure round-trip and
/// therefore reads **0 in the tick that set the text**: every coin came out at exactly one icon
/// width and the digits overlapped. The second open looked right because the first open's measure
/// had landed in the cache by then — the reopen was reading the previous open's numbers.
///
/// The fix sums `BENILLA_DIGIT_W`, the app's per-digit advance feed
/// ([`benilla_ui::script::UiScript::set_digit_advances`]) — data pushed ahead, so the answer exists
/// *in* the tick. That feed's own doc names this frame as what it was built for; only the merchant
/// price had ever used it.
///
/// The assertion is the FIRST open, which is the half that was broken.
#[test]
fn a_money_frame_is_the_right_width_on_the_first_open() {
let root = corpus_or_skip!();
let mut s = seat(&root, Seat::BeforeAddons);
// 12345g 67s 89c — three denominations with different digit counts, so a width that ignored
// the digits (the bug) cannot coincide with one that counts them.
s.set_money(123_456_789);
// The app feeds this once per atlas scale, before any window opens. A flat 8px advance makes
// the expected widths exact arithmetic rather than a font-dependent number.
s.set_digit_advances(&[8.0; 10]);
s.run("ToggleBackpack()").expect("ToggleBackpack");
for _ in 0..3 {
s.tick(0.1);
}
s.resolve();
let widths: Vec<f32> = s
.eval(
"local o = {} \
for _, n in ipairs({\"Gold\", \"Silver\", \"Copper\"}) do \
local b = getglobal(\"BagnonMoneyFrame\" .. n .. \"Button\") \
table.insert(o, b and b:GetWidth() or -1) \
end \
return o",
)
.expect("the three coin buttons");
// MONEY_ICON_WIDTH_SMALL is 13; the digits are 8px each. 12345 → 5, 67 → 2, 89 → 2.
assert_eq!(
widths,
vec![5.0 * 8.0 + 13.0, 2.0 * 8.0 + 13.0, 2.0 * 8.0 + 13.0],
"each coin must be its digits PLUS its icon on the first open. All three coming back at \
13.0 — the bare icon width — is the reported cramping: it means the width was taken from \
a text measure that had not landed yet"
);
}
/// The font a FontString/Button label actually resolved: `(path, height)`.
fn resolved_font(s: &UiScript, lua_expr: &str) -> (String, String) {
let r: Vec<String> = s
.eval(&format!(
"local fs = {lua_expr} \
if not fs then return {{ \"MISSING\", \"MISSING\" }} end \
local p, h = fs:GetFont() \
return {{ tostring(p), tostring(h) }}"
))
.expect("GetFont");
(r[0].clone(), r[1].clone())
}
/// **A Button's `<NormalFont>`/`<HighlightFont>`/`<DisabledFont>` take `font=` as well as
/// `inherits=`** — they are `<Font>`-TYPED elements, routed by `CSimpleButton::LoadXML 0x7788c0`
/// at `0x778bf4` into the same `0x783c30` a top-level `<Font>` uses, so `font=` gets the identical
/// registry-first treatment (`0x783d15` → `0x783d22 call 0x770c60`).
///
/// We read only `inherits=`, so every corpus button declaring `font=` silently kept **no font
/// object at all**. Bagnon does it at seven sites; `BagnonDBUINameBox` asks for
/// `GameFontNormalLarge` (16px) and got nothing. Real FrameXML always writes `inherits=`, which is
/// why nothing we ship ever noticed.
///
/// `style=` is deliberately not read — it does not exist in 1.12.1 (an isolated-token scan returns
/// zero against nine controls each returning one); it is a later-client idiom.
#[test]
fn a_button_state_font_takes_the_font_attribute_not_just_inherits() {
let root = corpus_or_skip!();
let mut s = open_bagnon_stacked(&root);
s.run("BagnonDBUI_ShowCharacterList(Bagnon)")
.expect("BagnonDBUI_ShowCharacterList");
for _ in 0..3 {
s.tick(0.1);
}
assert_eq!(
resolved_font(
&s,
"getglobal(\"BagnonDBUICharacterList1\"):GetFontString()"
),
("Fonts\\FRIZQT__.TTF".into(), "16".into()),
"`<NormalFont font=\"GameFontNormalLarge\"/>` must link the font object — 16px, not the \
12px default, and emphatically not the nil this returned while only `inherits=` was read"
);
}
/// **A `<FontHeight>` with no `font=` beside it is dead XML**, and so is a bare `outline=`.
///
/// `CSimpleFontString::LoadXML`'s height/outline/monochrome block sits at `[0x77111e, 0x771254)`
/// and its ONLY predecessor is the `font=`-names-a-file miss leg. With no `font=` at all,
/// `0x7710f1`/`0x7710fa je 0x771254` jump straight past it: the attributes are never parsed. They
/// have no independent existence — they are companions of a font FILE path, nothing more.
///
/// The reference's own FrameXML contains exactly one such site, and it is the proof: `ZoneText.xml`
/// gives `AutoFollowStatusText` `inherits="GameFontNormal"` (12px) **and** a
/// `<FontHeight val="20">`. The real client draws that line at 12 and Blizzard's 20 never does
/// anything. We honoured it — one of the few places benilla was drawing text at a size the
/// reference does not.
#[test]
fn a_fontheight_with_no_font_attr_beside_it_is_never_read() {
let root = corpus_or_skip!();
let s = open_bagnon_stacked(&root);
assert_eq!(
resolved_font(&s, "AutoFollowStatusText"),
("Fonts\\FRIZQT__.TTF".into(), "12".into()),
"AutoFollowStatusText inherits GameFontNormal (12) and declares <FontHeight val=20> with \
no font= — the 20 is dead XML in the reference, so 20 here means we are reading an \
attribute the client never reaches"
);
}
@@ -37,6 +37,10 @@ fn harness(money: u64) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_money(money);
// The digit advances the app feeds once per atlas scale. Fed here so the width arithmetic
// below is deliberate rather than an artefact of an unfed VM — a flat 8px per digit makes
// every expected number readable as `digits x 8 + icon`.
s.set_digit_advances(&[8.0; 10]);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
@@ -206,20 +210,25 @@ fn update_paints_a_static_frame_by_name_and_resizes_it() {
);
// Every coin is its digits + a 13px icon (MONEY_ICON_WIDTH_SMALL, since this frame is `small`),
// and the frame is the surviving coins packed with the -4px MONEY_BUTTON_SPACING gap. Headless
// there is no font atlas, so the digit width is 0 and the arithmetic is exactly the icons:
// 13 (base) + 13 + 13 + 13 - (-4) - (-4) = 60.
// and the frame is the surviving coins packed with the -4px MONEY_BUTTON_SPACING gap:
// 13 (base) + 21 + 21 + 21 - (-4) - (-4) = 84, each 21 being one 8px digit plus the icon
// (2g 3s 4c is a single digit per denomination).
//
// **The digits used to contribute 0 here and this test asserted the bare 13s that came of it.**
// That was the director's cramped gold on a first open: `ShowCoin` sized each coin from a text
// measure that lands a frame later, so it read 0 every first time. It sums the engine's
// `BenillaNumberWidth` feed now, which answers in the same tick.
assert_eq!(
s.eval::<f64>("return TestPurseGoldButton:GetWidth()")
.unwrap(),
13.0
21.0
);
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 60.0);
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 84.0);
// Collapse away two denominations and the frame shrinks to match: 13 + 13 = 26.
// Collapse away two denominations and the frame shrinks to match: 13 + 21 = 34.
s.run("MoneyFrame_Update(\"TestPurse\", 9)").unwrap();
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 26.0);
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 34.0);
}
/// The large template uses the 19px icon and its own spacing — the same painter, the other size
@@ -227,15 +236,17 @@ fn update_paints_a_static_frame_by_name_and_resizes_it() {
#[test]
fn the_large_template_measures_with_the_nineteen_pixel_icon() {
let s = harness(20_304);
// One 8px digit + the 19px icon — the same digit sum as the small template, over the other
// icon constant. That the two differ ONLY by the icon is what this test is for.
assert_eq!(
s.eval::<f64>("return TestBigPurseGoldButton:GetWidth()")
.unwrap(),
19.0
8.0 + 19.0
);
assert_eq!(
s.eval::<f64>("return TestBigPurse:GetWidth()").unwrap(),
19.0 * 4.0 + 8.0,
"19 base + three 19px coins, packed with two -4px gaps"
19.0 + 27.0 * 3.0 + 8.0,
"19 base + three 27px coins, packed with two -4px gaps"
);
assert_eq!(
s.eval::<i64>("return MONEY_ICON_WIDTH").unwrap(),
+412
View File
@@ -0,0 +1,412 @@
//! `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.
///
/// **The self-target edge is real and it is the reference's**: `target_is_you` is only ever tested
/// when you are *not* the performer (the guid compare at `0x49b2c8` jumps past it), so emoting at
/// yourself takes the "you are the performer, the target is someone else" leg and renders
/// **"You wave at ⟨YourName⟩."** — vanilla has no reflexive form. [`EmoteTextCatalog::compose`]
/// reproduces that by deriving both facts here rather than trusting a caller to pre-clear one.
#[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 self-target edge (`0x49b2c8` jumps past the target compare): emoting at yourself is
/// **not** reflexive — you get your own name in the target slot.
#[test]
fn emoting_at_yourself_takes_the_performer_leg_and_names_you() {
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.")
);
}
/// 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");
}
}
+2
View File
@@ -123,6 +123,8 @@ mod npc_greeting;
pub use npc_greeting::{load_npc_greeting_catalog, NpcGreeting, NpcGreetingCatalog};
mod emotes;
pub use emotes::{load_emote_sound_catalog, EmoteSoundCatalog};
mod emote_text;
pub use emote_text::{load_emote_text_catalog, EmoteLine, EmoteTextCatalog};
mod environmental_damage;
pub use environmental_damage::{load_environmental_damage, EnvironmentalDamageTable};
mod footsteps;
+7 -2
View File
@@ -437,8 +437,13 @@ pub enum SessionEvent {
instant: bool,
},
/// A nearby unit's chat emote (`SMSG_TEXT_EMOTE`): the `EmotesText.dbc` id; the guid names
/// the performer (voice race/sex resolve from its descriptor).
TextEmote { guid: u64, text_emote: u32 },
/// the performer (voice race/sex resolve from its descriptor). `target_name` is the wire's
/// target display name, empty when untargeted — the sentence-form selector, decision 1274.
TextEmote {
guid: u64,
text_emote: u32,
target_name: String,
},
/// A unit's anim emote (`SMSG_EMOTE`): the `Emotes.dbc` id (drives the anim + its
/// `EventSoundID`).
Emote { guid: u64, emote_id: u32 },
+10 -2
View File
@@ -45,8 +45,16 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
sound_id,
instant,
}],
ServerPacket::TextEmote { guid, text_emote } => {
vec![SessionEvent::TextEmote { guid, text_emote }]
ServerPacket::TextEmote {
guid,
text_emote,
target_name,
} => {
vec![SessionEvent::TextEmote {
guid,
text_emote,
target_name,
}]
}
ServerPacket::Emote { guid, emote_id } => vec![SessionEvent::Emote { guid, emote_id }],
ServerPacket::InitialSpells {
+10 -2
View File
@@ -330,11 +330,19 @@ pub enum ServerPacket {
},
/// `SMSG_TEXT_EMOTE` — a nearby unit performed a chat emote (`/wave`). Payload VERIFIED
/// vmangos `EmoteChatBuilder`: `u64 guid, u32 textEmote (EmotesText.dbc), u32 emoteNum,
/// u32 namelen, char name[namelen]` (the target's name; namelen ≥ 1). The name is parsed
/// past and dropped (the chat line renders from it later; audio keys on ids).
/// u32 namelen, char name[namelen]` (the target's name; namelen ≥ 1, a lone NUL when there was
/// no target).
///
/// **`target_name` is load-bearing and used to be dropped here** (decision 1274 / B156): it is
/// what selects the emote's sentence FORM — untargeted vs targeted vs "…at you" — in
/// `benilla_formats::EmoteTextCatalog`, and it is the raw wire string, never round-tripped
/// through a name cache, exactly as the reference passes it. `emoteNum` really is
/// dropped: the receive side reads it off the wire and never consumes it (wow-re
/// `ui/scratch/text-emote-composition.md` §3).
TextEmote {
guid: u64,
text_emote: u32,
target_name: String,
},
/// `SMSG_EMOTE` — a unit plays an anim emote. Payload VERIFIED vmangos
/// `Unit::HandleEmote`: `u32 emoteId (Emotes.dbc), u64 guid`.
+16 -3
View File
@@ -314,12 +314,25 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_TEXT_EMOTE => {
let guid = read_u64_le(&mut r)?;
let text_emote = read_u32_le(&mut r)?;
// Read and dropped on purpose — `emoteNum` selects neither the sentence nor the voice
// kit on the receive side (wow-re `ui/scratch/text-emote-composition.md` §3).
let _emote_num = read_u32_le(&mut r)?;
let namelen = read_u32_le(&mut r)?;
// The target's name, length-prefixed **including its NUL** (vmangos writes a lone
// `0x00` and `namelen == 1` when there was no target). Trimmed at the first NUL, so an
// untargeted emote arrives as the empty string — which is precisely the "untargeted"
// bit of the sentence-form selector, not a missing value.
let namelen = read_u32_le(&mut r)? as usize;
let mut name = Vec::with_capacity(namelen.min(64));
for _ in 0..namelen {
read_u8(&mut r)?; // the target's name — chat consumes it later; audio keys on ids
name.push(read_u8(&mut r)?);
}
let end = name.iter().position(|&b| b == 0).unwrap_or(name.len());
let target_name = String::from_utf8_lossy(&name[..end]).into_owned();
ServerPacket::TextEmote {
guid,
text_emote,
target_name,
}
ServerPacket::TextEmote { guid, text_emote }
}
opcode::SMSG_EMOTE => {
let emote_id = read_u32_le(&mut r)?;
+16 -4
View File
@@ -115,19 +115,31 @@ fn message_chat_decodes_every_wire_shape() {
fn text_emote_and_emote_decode() {
let body = hx("7700000000000000650000000000000004000000426f6200");
let p = messages::parse_server(messages::opcode::SMSG_TEXT_EMOTE, &body).unwrap();
// The target name is KEPT, not skipped past: it is the sentence-form selector (decision 1274).
// The NUL inside `namelen` is trimmed, so the string is exactly the display name.
assert!(matches!(
p,
&p,
ServerPacket::TextEmote {
guid: 0x77,
text_emote: 101,
}
target_name,
} if target_name == "Bob"
));
assert!(matches!(
decode(p)[..],
&decode(p)[..],
[SessionEvent::TextEmote {
guid: 0x77,
text_emote: 101,
}]
target_name,
}] if target_name == "Bob"
));
// Untargeted: vmangos writes `namelen == 1` and a lone NUL, which must read as the EMPTY
// string — the "untargeted" bit — and not as a one-byte name.
let body = hx("770000000000000065000000000000000100000000");
assert!(matches!(
messages::parse_server(messages::opcode::SMSG_TEXT_EMOTE, &body).unwrap(),
ServerPacket::TextEmote { ref target_name, .. } if target_name.is_empty()
));
let body = hx("060000008800000000000000");
+126 -18
View File
@@ -214,6 +214,47 @@ impl Loader<'_> {
// SetVertexColor rather than being replaced by it (`RegionData::fill`).
self.call_region(region_wrapper, "SetTexture", (c[0], c[1], c[2], c[3]), dbg);
}
// `<Gradient orientation><MinColor/><MaxColor/></Gradient>` — the texture's FOUR-vertex
// colour, and the one XML surface that tints a `file=` texture. It survives alongside
// the art because it lands in a different field: the child loop writes the vertex
// colours (`+0xb8`, stride 4 — `0x77304d`-`0x77305f`, "4 iff count > 1") while `file=`
// is read only afterwards into `+0xcc`
// (`texture-color-composition.md` §1-2). That is why `<Color>` beside a `file=` is
// discarded above and a `<Gradient>` beside one is not.
//
// Dropping this silently is what painted Bagnon's character-list popup as a WHITE SLAB:
// its background art is `ChatFrameBackground`, which is white by design and is meant to
// be tinted black→dark-grey by exactly this element (`Bagnon_Core/core/Frame.xml:32`).
//
// MinColor → the first stop, MaxColor → the second, which is the natural reading and
// the Lua binding's own argument order — but the XML arm's stop-to-vertex mapping is
// **not byte-read** (wow-re records the `<Gradient>` tag compare at `0x7700b0` and the
// stride, not the arm's body). It cannot matter yet: `RegionData::gradient` is folded to
// its midpoint by the paint (`script::extract`), which is symmetric in the two stops.
// Swapping them becomes visible the day a quad carries a real two-stop tint.
if let Some(g) = children_named(region, "Gradient").next() {
let stop = |tag: &str| children_named(g, tag).next().map(color_of);
// A half-declared gradient is skipped whole rather than half-applied against an
// invented default — an absent stop is not a black one.
if let (Some(min), Some(max)) = (stop("MinColor"), stop("MaxColor")) {
self.call_region(
region_wrapper,
"SetGradientAlpha",
(
g.attr("orientation").unwrap_or_default().to_string(),
min[0],
min[1],
min[2],
min[3],
max[0],
max[1],
max[2],
max[3],
),
dbg,
);
}
}
// `<TexCoords left right top bottom>`: the UV sub-rect this texture samples (decision
// 0084 — quadrant/atlas slicing). ref-MerchantFrame.xml l.259/268/395.
if let Some(tc) = tex_coords_of(region) {
@@ -292,11 +333,40 @@ impl Loader<'_> {
None
}
/// Is `name` a registered `<Font>` OBJECT? The plain registry probe — **no template walk**,
/// unlike [`Self::font_object_through_templates`]. This is the reference's `0x783870(value,
/// create = 0)` lookup, which is a flat registry hit or nothing; the template chain is
/// `inherits=`'s affordance alone.
///
/// Case-folded, because 1.12 matches font names with `SStrCmpI` (`0x783870`).
fn is_font_object(&self, name: &str) -> bool {
let model = self.model();
let fonts = model.framexml_fonts.borrow();
fonts.contains_key(name) || fonts.keys().any(|k| k.eq_ignore_ascii_case(name))
}
/// A `<FontString>`'s font resolution: `inherits="GameFontNormal"` → `SetFontObject` (the named
/// virtual Font object's face/height/color/outline become this string's defaults), then the
/// FontString's own `font=`/`<FontHeight>`/`outline=` → `SetFont` overrides. Called before the
/// generic visual pass (so an explicit `<Color>` still overrides the object's color). An
/// unregistered `inherits=` target is a warn-once gap, not a dropped region.
///
/// **`font=` is a font-object NAME first and a file path only on a miss** — the half of this
/// that was missing, and a silent one. The reference tries the registry before the filesystem:
/// `0x783d15 call 0x783870(value, create = 0)`, and on a hit takes the same live-link
/// `0x770c60` that `SetFontObject` uses, skipping the file-path branch entirely
/// (wow-re `system/ui/scratch/font-object-lua-surface.md` §7 + its `LoadXML` surface table
/// row 2; `CSimpleFontString::LoadXML 0x770f40` at `0x7710e1`-`0x771114`).
///
/// Reading it as a path only is what made Bagnon's item-stack counts unreadable: its
/// `<FontString name="$parentCount" font="NumberFontNormal">` (`Bagnon_Core/core/Item.xml:14`)
/// stored the literal string `"NumberFontNormal"` as a font PATH, which resolves to no face —
/// so height, colour and, above all, `outline="NORMAL"` were all lost, and the atlas fell back
/// silently to Friz 12. The black ring around a stack count is that outline (`NumberFontNormal`
/// carries no `<Shadow>` in 1.12 — the readability is the halo, not a drop shadow).
///
/// Real FrameXML never exercises this: it writes `font=` on `<Font>` elements only, always as a
/// path. The addon corpus writes it on FontStrings 117 times.
pub(super) fn apply_fontstring_font(&mut self, region: &Element, wrapper: &Table, dbg: &str) {
if let Some(name) = region.attr("inherits") {
// **Resolved through the TEMPLATE chain, not taken literally.** A FontString's
@@ -328,25 +398,63 @@ impl Loader<'_> {
);
}
}
// Explicit face/size/outline on the FontString itself override the inherited object.
let font = region.attr("font").map(str::to_string);
let height = children_named(region, "FontHeight")
.next()
.and_then(abs_value);
let outline = region.attr("outline").map(str::to_string);
if font.is_some() || height.is_some() || outline.is_some() {
// NOT the `SetFont` binding. XML supplies any subset of the three, while the
// reference's `SetFont` requires a path AND a height (raising `0x87c69c` otherwise),
// because the real client applies XML font attributes in C++ and never through the Lua
// method. Routing the loader through the binding is exactly what forced that binding to
// stay lenient — one name doing two jobs. This is the XML job.
if let Err(e) =
crate::script::apply_font_parts(self.lua, wrapper, font, height, outline)
{
self.report
.errors
.push(format!("{dbg}: region font attrs: {e}"));
// **`font=` gates this whole block, and that is the shape of the bytes** — not a set of
// independent overrides (`0x7710e1`-`0x771254`, wow-re
// `system/ui/scratch/fontstring-loadxml-font-attrs.md`). Three outcomes, and only three:
//
// 1. `font=` names a **registered font object** → `SetFontObject` (`0x771104` lookup,
// `0x771114 call 0x770c60` — the same live link `inherits=` takes) and then
// `0x771119 jmp 0x771254`, which **skips `<FontHeight>`, `outline=`, `monochrome=` and
// the file load entirely**. The skipped values are not lost: the link forces all five
// changed bits (`0x770d0c`) and the applier `0x770800` pushes the source's
// height/face/outline down through the inherit mask.
// 2. `font=` names anything else → a font **FILE**, and `<FontHeight>`/`outline=`/
// `monochrome=` are read as ITS companions (the miss leg, `0x77110b je 0x77111e`).
// 3. **No `font=` at all** → `0x7710f1`/`0x7710fa je 0x771254` jump straight past, so
// `<FontHeight>` and `outline=` are **never parsed**. That block's only predecessor is
// the miss leg in (2); the attributes have no independent existence.
//
// (3) is the one that changes something we already draw. Real FrameXML has exactly one such
// site — `ZoneText.xml`'s `AutoFollowStatusText`, `inherits="GameFontNormal"` with a
// `<FontHeight val="20">` and no `font=` — so the real client draws that line at
// GameFontNormal's **12**, and Blizzard's 20 is dead XML. Our copy carries the same dead 20
// and honoured it until now. The corpus has 22 more of these plus 5 stray `outline=`.
//
// Only `spacing=`/`justifyH`/`justifyV`/`<Color>`/`<Shadow>` sit past the join and are
// genuine post-link overrides — they are applied elsewhere (`apply_region_layout` /
// `apply_region_visual`), which is already the right side of this line.
match region.attr("font") {
Some(name) if self.is_font_object(name) => {
if let Err(e) = wrapper.call_method::<()>("SetFontObject", name.to_string()) {
self.warn_once(
&format!("fontobj:{name}"),
format!("{dbg}: <FontString font=\"{name}\">: {e}"),
);
}
}
Some(path) => {
// A font FILE, with its two companions. NOT the `SetFont` binding: XML supplies any
// subset, while the reference's `SetFont` requires a path AND a height (raising
// `0x87c69c` otherwise), because the real client applies XML font attributes in C++
// and never through the Lua method. Routing the loader through the binding is
// exactly what forced that binding to stay lenient — one name doing two jobs.
let height = children_named(region, "FontHeight")
.next()
.and_then(abs_value);
let outline = region.attr("outline").map(str::to_string);
if let Err(e) = crate::script::apply_font_parts(
self.lua,
wrapper,
Some(path.to_string()),
height,
outline,
) {
self.report
.errors
.push(format!("{dbg}: region font attrs: {e}"));
}
}
None => {}
}
}
}
+20 -2
View File
@@ -183,8 +183,26 @@ impl Loader<'_> {
("DisabledFont", "SetDisabledFontObject"),
] {
for f in children_named(el, child) {
if let Some(name) = f.attr("inherits") {
self.call(wrapper, method, name.to_string(), dbg);
// These three are `<Font>`-TYPED elements — `CSimpleButton::LoadXML 0x7788c0`
// routes them at `0x778bf4` into the SAME `0x783c30` a top-level `<Font>` uses — so
// they take `inherits=` and `font=` alike, and `font=` wins: both land in one slot
// and `0x770c60` unlinks the previous parent (wow-re
// `fontstring-loadxml-font-attrs.md` C5; `font=`'s registry-first path is
// `0x783d15` → `0x783d22 call 0x770c60` → `0x783d27 jmp 0x783ee0`).
//
// Reading only `inherits=` here left every corpus button that writes `font=` on its
// own default font — Bagnon writes it at seven sites, which is why its character
// list's names and its "Show Bags" label were not the Large/normal faces they ask
// for. Real FrameXML always writes `inherits=`, so nothing we ship noticed.
//
// `style=` is deliberately NOT read: it does not exist in 1.12.1. An isolated-token
// scan for it returns zero against nine controls that each return one — it is a
// later-client idiom, and the reference writes `<NormalFont inherits="GameFontNormal"/>`
// (`UIPanelTemplates.xml:20-22`).
for attr in ["inherits", "font"] {
if let Some(name) = f.attr(attr) {
self.call(wrapper, method, name.to_string(), dbg);
}
}
// An element-level justify (`<NormalFont inherits="QuestFont" justifyH="LEFT"/>`)
// lands on the label region itself — v1: one region-level set (the ref declares
+62 -7
View File
@@ -519,10 +519,12 @@ impl UiScript {
worldmap::install(&lua)?;
net_stats::install(&lua)?;
Ok(UiScript {
let s = UiScript {
lua,
instructions: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
})
};
s.publish_number_width()?;
Ok(s)
}
/// The embedded VM — for the Bevy plugin / TOC-XML loader to add the game-state API bindings
@@ -879,12 +881,18 @@ impl UiScript {
}
/// Feed the number font's per-digit advance widths (logical px, `'0'..='9'` in order) as the
/// Lua global table `BENILLA_DIGIT_W` (keyed by digit *string*, `["0"].."["9"]`). The
/// synchronous half of the money layout's metrics (the real `SmallMoneyFrame` calls
/// `GetTextWidth` mid-update — MoneyFrame.lua l.202 — while our `GetStringWidth` measure
/// round-trip is a frame late): the app measures NumberFontNormal's digits once per atlas
/// scale and `BenillaNumberWidth` (MerchantFrame.xml) sums them per price.
/// Lua global table `BENILLA_DIGIT_W` (keyed by digit *string*, `["0"].."["9"]`).
///
/// The synchronous half of the money layout's metrics. The real `SmallMoneyFrame` calls
/// `GetTextWidth` mid-update (MoneyFrame.lua l.202) and the real client answers it inline;
/// our `GetStringWidth` is served from the measure round-trip, which lands at extract — a frame
/// AFTER the tick that set the text. A kit that sizes itself from its own label therefore reads
/// 0 the first time and stays wrong until something redraws it.
///
/// The sum lives in [`Self::publish_number_width`] beside this, not in XML: its two callers are
/// in different shipped files and no third file is a dependency of both.
pub fn set_digit_advances(&mut self, advances: &[f32; 10]) {
self.model_mut().digit_advances = Some(*advances);
let set = || -> mlua::Result<()> {
let t = self.lua.create_table()?;
for (i, w) in advances.iter().enumerate() {
@@ -899,6 +907,53 @@ impl UiScript {
}
}
/// Publish `BenillaNumberWidth(n)` — the laid-out width of a number in NumberFontNormal, the
/// sum of its digits' advances from the [`Self::set_digit_advances`] feed.
///
/// **Exact, not an approximation.** The client's width law is a floor-and-add over a per-glyph
/// advance with no kerning term, so a number's width IS the sum of its digits'. That is equally
/// why it stops at numbers: arbitrary text still needs the real measure, and a caller that wants
/// one still waits a frame.
///
/// **Published from here rather than written in XML** — which is where it started, and the move
/// is the point. Its callers are `BenillaMoney_Set` (MerchantFrame.xml) and `ShowCoin`
/// (MoneyFrame.xml), and there is no third shipped file both of them load: fixtures take
/// arbitrary subsets, so whichever file owned it, some fixture met it as nil. Seated at VM
/// construction it is simply always there, and the headless fallback lives in one place.
///
/// The 7px fallback is for a VM whose host never fed the advances — every engine test, since
/// there is no font atlas without the app. In-app the feed lands on the first frame the atlas
/// exists.
fn publish_number_width(&self) -> mlua::Result<()> {
let f = self.lua.create_function(|lua, n: mlua::Value| {
// `tostring(n)` semantics: the callers pass a number, but a string is as valid an
// argument here as it is to the Lua original this replaces.
let s = match &n {
mlua::Value::Integer(i) => i.to_string(),
mlua::Value::Number(x) => {
if *x == x.trunc() && x.is_finite() {
format!("{}", *x as i64)
} else {
x.to_string()
}
}
mlua::Value::String(s) => s.to_str()?.to_string(),
_ => String::new(),
};
let model = lua.app_data_ref::<Model>().expect("model");
let adv = model.digit_advances;
Ok(s.chars()
.map(|c| match (c.to_digit(10), adv) {
(Some(d), Some(a)) => a[d as usize],
// A non-digit (a minus sign, a separator) has no advance in the feed, and
// neither does anything before the host has fed one.
_ => 7.0,
})
.sum::<f32>())
})?;
self.lua.globals().set("BenillaNumberWidth", f)
}
// ── Input: pointer-leaves-window cleanup (decision 0216 §3; the hit-test/mouse dispatch that
// used to sit here now lives in [`pointer`]) ─────────────────────────────────────────────────
+5
View File
@@ -24,6 +24,10 @@ pub(crate) struct Model {
pub(crate) addons: Vec<super::addon::AddOnInfo>,
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
pub(crate) addons_root: Option<std::path::PathBuf>,
/// NumberFontNormal's per-digit advances, `'0'..='9'` ([`super::UiScript::set_digit_advances`])
/// — the synchronous stand-in for a text measure, read by the `BenillaNumberWidth` binding.
/// `None` until the host feeds them, which is every VM without a font atlas (every engine test).
pub(crate) digit_advances: Option<[f32; 10]>,
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
/// Both are directories holding one `<Addon>.lua` per declaring addon.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
@@ -1010,6 +1014,7 @@ impl Model {
Model {
addons: Vec::new(),
addons_root: None,
digit_advances: None,
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),