diff --git a/crates/benilla-app/assets/ui/MerchantFrame.xml b/crates/benilla-app/assets/ui/MerchantFrame.xml index 1392ef2e..69bfdf18 100644 --- a/crates/benilla-app/assets/ui/MerchantFrame.xml +++ b/crates/benilla-app/assets/ui/MerchantFrame.xml @@ -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 diff --git a/crates/benilla-app/assets/ui/MoneyFrame.xml b/crates/benilla-app/assets/ui/MoneyFrame.xml index 8635baab..e417fccd 100644 --- a/crates/benilla-app/assets/ui/MoneyFrame.xml +++ b/crates/benilla-app/assets/ui/MoneyFrame.xml @@ -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 - `` 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 `` 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 diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 71d3b167..c84d3b41 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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) } diff --git a/crates/benilla-app/src/net/apply/anim.rs b/crates/benilla-app/src/net/apply/anim.rs index 8ba34ffd..705b0027 100644 --- a/crates/benilla-app/src/net/apply/anim.rs +++ b/crates/benilla-app/src/net/apply/anim.rs @@ -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, + 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, diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index f75c74d5..bbfc8dd7 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -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 diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index 3da13113..cadbfd9c 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -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 { diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs index 8f1ed0c9..b5e11762 100644 --- a/crates/benilla-app/src/ui_chat/event.rs +++ b/crates/benilla-app/src/ui_chat/event.rs @@ -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. diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index 2888e38b..bd43f0be 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -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>) { + 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 { + 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, commands: Res, time: Res