From c8ed53c4feaa2185e0f3d3d2b78cb6f6fd9c0786 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 14 Aug 2026 18:38:27 +0700 Subject: [PATCH] addons talk to each other: the addon message lane, both halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SendAddonMessage sends on the real 1.12 lane — an ordinary chat message wearing the addon-language sentinel — across the party, raid, guild and battleground channels, byte-verified in the client binary - the receive half fires CHAT_MSG_ADDON with the client's own four-argument shape, and the two halves are round-tripped against each other so they cannot drift apart silently - the `|`-escape scan is carved from the binary: colour escapes inside a message survive exactly as the real client allows them Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/net.rs | 87 +++ crates/benilla-app/src/net/apply/chat.rs | 84 +- crates/benilla-app/src/net/io.rs | 6 + crates/benilla-app/src/ui_chat.rs | 4 + crates/benilla-app/src/ui_chat/feed.rs | 126 ++- crates/benilla-app/src/ui_chat/input/mod.rs | 48 ++ crates/benilla-app/src/ui_chat/tests.rs | 144 ++++ .../benilla-protocol/src/world/writer/chat.rs | 27 + crates/benilla-protocol/tests/client.rs | 101 +++ crates/benilla-ui/src/script/addon_message.rs | 732 ++++++++++++++++++ crates/benilla-ui/src/script/mod.rs | 3 + crates/benilla-ui/src/script/model.rs | 6 + 12 files changed, 1360 insertions(+), 8 deletions(-) create mode 100644 crates/benilla-ui/src/script/addon_message.rs diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index ff48f354..cd832c4a 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -579,6 +579,26 @@ impl MoveKind { } } +/// The wire `ChatMsg` type byte an addon broadcast rides (decision 1235) — the client's own +/// four-lane whitelist at `0x49fa3f`-`0x49fa4e`, VERIFIED in `WoW.exe` (5875), wow-re +/// `system/ui/scratch/addon-chat-law.md` §5. +/// +/// A **total** map from a closed enum, and a named function rather than an inline `match` in +/// [`io`]'s dispatch so that it is assertable: this is the one place a distribution becomes a wire +/// byte, and getting it wrong sends an addon's payload down a lane nobody is listening on. There +/// is no fallback arm and none can be added — that is the whole reason the distribution crosses +/// the crate boundary as an enum instead of a token string. +pub(crate) fn addon_wire_chat_type(distribution: benilla_ui::script::AddonDistribution) -> u32 { + use benilla_protocol::messages as m; + use benilla_ui::script::AddonDistribution as D; + match distribution { + D::Party => m::CHAT_TYPE_PARTY, + D::Raid => m::CHAT_TYPE_RAID, + D::Guild => m::CHAT_TYPE_GUILD, + D::Battleground => m::CHAT_TYPE_BATTLEGROUND, + } +} + /// The wire `ChatMsg` type a chat-bar line sends as — the FULL sendable set (decision 0288 P5; /// vmangos `HandleChatMessageOpcode`'s switch). Each maps to its `WorldWriter` sender in [`io`]'s /// dispatch; `Whisper` carries its target and `Channel` its channel name through @@ -678,6 +698,23 @@ pub(crate) enum ClientCommand { target: Option, text: String, }, + /// **An addon broadcast** (`SendAddonMessage`, decision 1235) — a `CMSG_MESSAGECHAT` carrying + /// `LANG_ADDON` in the language field instead of the speaker's tongue, which is the entire + /// difference between addon data and speech (1.12.1 has no addon opcode). + /// + /// Deliberately **not** a flag on [`Self::Chat`]: that command's whole family speaks + /// `WorldWriter::chat_language`, and a language field bolted onto it would be a `None` that + /// fourteen arms have to remember to honour. This one carries no target (1.12's + /// `SendAddonMessage` has no fourth argument, so there is no whispered addon message) and no + /// language dial (there is exactly one legal value). + /// + /// `distribution` is the client's own four-value whitelist, already resolved and already + /// downgraded for the outside-a-raid case by the binding + /// ([`benilla_ui::script::AddonDistribution`]); `text` is the composed `prefix` TAB `message`. + AddonMessage { + distribution: benilla_ui::script::AddonDistribution, + text: String, + }, /// Ask a player character's name (`CMSG_NAME_QUERY`); answered by a `PlayerName` event into the /// [`crate::names::NameCache`]. Sent by the cache's ask-once resolve, never directly. NameQuery { guid: u64 }, @@ -1479,3 +1516,53 @@ pub(crate) struct AiReactionMessage { pub(crate) unit: Entity, pub(crate) hostile: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + /// **The addon lane's four wire bytes** (decision 1235) — the client's own whitelist at + /// `0x49fa3f`-`0x49fa4e`, and the last link in the chain between an addon's Lua call and the + /// bytes `benilla-protocol`'s `addon_message_bodies_golden` pins. Wrong here and the payload + /// goes down a lane nobody listens on, which is silent at both ends. + /// + /// `RAID_LEADER`/`RAID_WARNING`/`OFFICER`/`BATTLEGROUND_LEADER`/`CHANNEL` are asserted absent + /// from the map's *image*: vmangos would accept `LANG_ADDON` on all five, and the client sends + /// on none of them, so a future widening has to fail a test rather than pass unnoticed. + #[test] + fn addon_distributions_map_to_the_clients_own_four_wire_types() { + use benilla_protocol::messages as m; + use benilla_ui::script::AddonDistribution as D; + + assert_eq!(addon_wire_chat_type(D::Party), 0x01, "CHAT_MSG_PARTY"); + assert_eq!(addon_wire_chat_type(D::Raid), 0x02, "CHAT_MSG_RAID"); + assert_eq!(addon_wire_chat_type(D::Guild), 0x03, "CHAT_MSG_GUILD"); + assert_eq!( + addon_wire_chat_type(D::Battleground), + 0x5C, + "CHAT_MSG_BATTLEGROUND" + ); + + // The image is exactly those four — no distribution reaches a lane the reference refuses. + let image: Vec = [D::Party, D::Raid, D::Guild, D::Battleground] + .into_iter() + .map(addon_wire_chat_type) + .collect(); + for refused in [ + m::CHAT_TYPE_SAY, + m::CHAT_TYPE_YELL, + m::CHAT_TYPE_WHISPER, + m::CHAT_TYPE_EMOTE, + m::CHAT_TYPE_OFFICER, + m::CHAT_TYPE_CHANNEL, + m::CHAT_TYPE_RAID_LEADER, + m::CHAT_TYPE_RAID_WARNING, + m::CHAT_TYPE_BATTLEGROUND_LEADER, + ] { + assert!( + !image.contains(&refused), + "{refused:#04x} is a lane the client never sends addon data on" + ); + } + } +} diff --git a/crates/benilla-app/src/net/apply/chat.rs b/crates/benilla-app/src/net/apply/chat.rs index 885d1f4d..426be8d3 100644 --- a/crates/benilla-app/src/net/apply/chat.rs +++ b/crates/benilla-app/src/net/apply/chat.rs @@ -53,10 +53,29 @@ pub(super) fn chat( // **One divergence, named:** the reference suppresses *after* name resolution — an uncached // sender parks the line in `__AUPENDINGCHAT__` (`0x49d7ba`) behind `CMSG_NAME_QUERY`, and // `CHAT_MSG_ADDON` fires late from the callback `0x49ccc0`. We drop here instead, ahead of the - // name query [`ChatLog::push_wire`] would queue. Invisible today (nothing listens for - // `CHAT_MSG_ADDON` — benilla runs FrameXML, not third-party addons) and it saves a name query - // per addon sender; when an addon runtime lands, the *fire* must move downstream of the - // resolve so its `sender` arg is a name rather than a guid — the drop can stay here. + // name query [`ChatLog::push_wire`] would queue, which saves a name query per addon sender. + // + // **THE CHANNEL IS NOW HALF-OPEN, and that is the state of it — not a claim that it works.** + // Decision 1235 landed the SEND half: `SendAddonMessage` composes, validates against the + // client's four-lane whitelist and puts a real `CMSG_MESSAGECHAT`/`LANG_ADDON` on the wire. + // Nothing on THIS side changed, so an addon can broadcast and **cannot hear** — including its + // own echo (vmangos's `Group::BroadcastPacket` takes no ignore-guid, so the party lane returns + // a sender's own line, and an addon that sequences on that echo sees nothing). + // + // Opening the receive half is NOT deleting this `return`, which is why it is separate work: + // + // - `CHAT_MSG_ADDON` (event 227, `0x49a95f push 0xe3`) is **not a `ChatTypeInfo` key** and + // cannot ride [`crate::ui_chat::event`]'s pipeline — it has its own format string and its + // own 4-argument shape `(prefix, message, distribution, sender)`, against the chat + // family's ten. `every_fired_event_name_is_a_chat_type_info_key` would rightly fail it. + // - `prefix`/`message` come from splitting the text on its **FIRST** tab (`0x49a8d0`); with + // no tab the whole text is the *prefix* and the message is `""`, not the other way round. + // - `distribution` is the remap table `0x49aff4` → jump table `0x49afe0`: only + // PARTY/RAID/GUILD/BATTLEGROUND get names, every other type byte reports `"UNKNOWN"`. + // - the *fire* must move downstream of the name resolve so `sender` is a name, not a guid. + // The drop can stay here; the fire cannot. + // + // All four are recorded byte-exact in wow-re `system/ui/scratch/addon-chat-law.md` §3/§4/§6. // // The payload rides `debug!` and its own `addon` trace tag rather than vanishing, so // mixed-client traffic stays diagnosable: invisible in chat, never invisible to us. @@ -67,12 +86,25 @@ pub(super) fn chat( m.chat_type, m.sender_guid, m.text ); if benilla_assets::trace::enabled() { + // `<-` inbound, matching the `->` the outbound drain writes to this same tag + // (`ui_chat::input::drain_addon_message_sends`): one tag, both directions, so a + // live run's trace reads as the conversation it is. benilla_assets::trace::line( "addon", - &format!("[{:#04x}] {:?}", m.chat_type, m.text), + &format!("<- [{:#04x}] {:?}", m.chat_type, m.text), ); } + // **The channel is whole now.** Parked for the sender-name resolve rather than fired + // here, because the reference fires `CHAT_MSG_ADDON` DOWNSTREAM of the name query + // (`0x49ccc0`) so `sender` is a name and never a guid — the one requirement of the + // four that is about WHERE rather than what. `push_addon` carries the other three: the + // first-tab split, the no-tab-means-all-prefix direction, and the four-lane + // distribution remap. + chat_log.push_addon(&m.text, m.chat_type, m.sender_guid); } + // The line still never reaches a chat window: an addon message is not speech, and the + // reference's addon arm jumps below the render path (`0x49a970`). An IGNORED sender fires + // nothing at all — ignore beats addon, which is why that check still wraps this. return; } if m.chat_type == 0x0A { @@ -244,6 +276,48 @@ mod tests { assert!(!reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, ""))); } + /// ...and since the receive half opened, "never reaches the chat window" must not be allowed to + /// mean "vanished". The same line is PARKED for `CHAT_MSG_ADDON`, which is the distinction that + /// stopped being free the moment `Pending::Addon` existed — `pending_len` excludes addon items + /// precisely so the test above keeps asserting what it says, and this one covers the other half. + #[test] + fn an_addon_line_is_parked_for_the_addon_event_not_discarded() { + let mut world = World::new(); + world.init_resource::>(); + let (tx, _rx) = crossbeam_channel::unbounded(); + world.insert_resource(NetCommands(tx)); + world.insert_resource(SocialState::default()); + world.insert_resource(ChatLog::default()); + let m = a_party_line(LANGUAGE_ADDON, "Quiver\tVERSION:3.1.4"); + world + .run_system_once( + move |mut chat_log: ResMut, + social: Res, + net_commands: Res, + mut server_said: MessageWriter| { + chat( + m.clone(), + &mut chat_log, + &social, + &net_commands, + &mut server_said, + ); + }, + ) + .unwrap(); + let log = world.resource::(); + assert_eq!( + log.pending_addons(), + vec![( + "Quiver".to_string(), + "VERSION:3.1.4".to_string(), + "PARTY".to_string() + )], + "the line must be queued for CHAT_MSG_ADDON, not dropped" + ); + assert_eq!(log.pending_len(), 0, "and still never headed for a window"); + } + /// The control the gate must not swallow: real speech on the same lane, in every tongue a /// character can speak. A gate that keyed on "unknown language" instead of the sentinel would /// silently eat Demonic and Gutterspeak. diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 2326db7e..b5cf0f70 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -644,6 +644,12 @@ fn writer_loop( w.send_channel(target.as_deref().unwrap_or_default(), &text) } }, + // The addon lane (decision 1235). The distribution arrived as an enum and the + // map is TOTAL — no "unknown, guess SAY" arm exists, which is what the enum + // seam is for — so the whole arm is one call. + ClientCommand::AddonMessage { distribution, text } => { + w.send_addon_message(super::addon_wire_chat_type(distribution), &text) + } ClientCommand::JoinChannel { name, password } => { w.join_channel(&name, &password) } diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 8cc94f70..fd016ebd 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -78,6 +78,10 @@ impl Plugin for UiChatPlugin { // and in the same chain, so a `SendChatMessage` fired from a slash handler // that the box's drain just ran goes out on the same frame. input::drain_addon_chat_sends, + // …and its addon-lane twin (decision 1235). Same position in the chain, for + // the same reason: a `SendAddonMessage` fired from a handler that ran earlier + // in this chain goes out on this frame rather than the next. + input::drain_addon_message_sends, ) .chain() .after(UiInput) diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index b36781bd..2a6441e9 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -35,6 +35,17 @@ enum Pending { b_guid: u64, tries: u16, }, + /// An inbound addon line awaiting its SENDER's name — `CHAT_MSG_ADDON` (event 227, fired at + /// `0x49a95f`). Parked here rather than fired at decode for the reason wow-re records: the + /// reference fires it *downstream* of the name resolve, from the `CMSG_NAME_QUERY` callback + /// (`0x49ccc0`), so `sender` is a NAME and never a guid. + Addon { + prefix: String, + message: String, + distribution: String, + guid: u64, + tries: u16, + }, /// A `/random` broadcast awaiting the roller's name. Roll { min: u32, @@ -58,6 +69,32 @@ enum Pending { Event(ChatEvent), } +/// Fire `CHAT_MSG_ADDON` with the reference's four arguments, in the reference's order. +/// +/// Split out of the drain so the shape that reaches Lua is testable without standing up +/// `feed_chat`'s dozen resources — the scaffolding is not what can be wrong here; the argument +/// ORDER is. wow-re carves it as `SignalEvent2(227, "%s%s%s%s", prefix, message, distribution, +/// sender)` (`0x49a95f`), and `BigWigs` independently self-delivers +/// `self:CHAT_MSG_ADDON("BigWigs", msg, "RAID", playerName)` — a 2006 addon author and the binary +/// agreeing. +/// +/// Fired DIRECTLY rather than through [`route`]: `CHAT_MSG_ADDON` is not a `ChatTypeInfo` key and +/// carries four arguments against the chat family's ten, so the chat pipeline would mis-shape it +/// and `every_fired_event_name_is_a_chat_type_info_key` would rightly reject it. +pub(super) fn fire_addon_message( + script: &mut benilla_ui::script::UiScript, + prefix: String, + message: String, + distribution: String, + sender: String, +) { + use benilla_ui::script::ScriptValue::Str; + script.fire_event( + "CHAT_MSG_ADDON", + vec![Str(prefix), Str(message), Str(distribution), Str(sender)], + ); +} + /// 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)] @@ -112,6 +149,38 @@ impl ChatLog { } } + /// Queue an inbound addon line (`SMSG_MESSAGECHAT` carrying `LANG_ADDON`) for the sender-name + /// resolve, then `CHAT_MSG_ADDON`. + /// + /// **The split is the counter-intuitive part and it is the reference's** (`0x49a8d0`): the text + /// divides on its **FIRST** tab, and with **no tab at all the whole text is the PREFIX** and + /// the message is empty — not the other way round, which is what a reimplementation guesses. + /// + /// `distribution` is the remap at `0x49aff4`: only PARTY / RAID / GUILD / BATTLEGROUND have + /// names, and every other type byte reports `"UNKNOWN"` rather than being dropped — the + /// reference hands the addon a string it can branch on either way. + pub(crate) fn push_addon(&mut self, text: &str, chat_type: u8, guid: u64) { + let (prefix, message) = match text.find('\t') { + Some(i) => (text[..i].to_string(), text[i + 1..].to_string()), + None => (text.to_string(), String::new()), + }; + let distribution = match chat_type { + 0x01 => "PARTY", + 0x03 => "RAID", + 0x04 => "GUILD", + 0x18 => "BATTLEGROUND", + _ => "UNKNOWN", + } + .to_string(); + self.pending.push(Pending::Addon { + prefix, + message, + distribution, + guid, + tries: 0, + }); + } + /// Queue a `/random` broadcast (`MSG_RANDOM_ROLL`) for the roller-name resolve. pub(crate) fn push_roll(&mut self, min: u32, max: u32, roll: u32, guid: u64) { self.pending.push(Pending::Roll { @@ -171,11 +240,36 @@ impl ChatLog { self.pending.clear(); } - /// How many items are queued — the gates upstream of this log ([`crate::net::apply`]'s addon - /// and ignore gates) are "the line never got here at all", so their tests read this. + /// The parked addon lines as `(prefix, message, distribution)` — test-only, so the split and + /// the remap can be asserted without standing up a name cache and a VM. + #[cfg(test)] + pub(crate) fn pending_addons(&self) -> Vec<(String, String, String)> { + self.pending + .iter() + .filter_map(|p| match p { + Pending::Addon { + prefix, + message, + distribution, + .. + } => Some((prefix.clone(), message.clone(), distribution.clone())), + _ => None, + }) + .collect() + } + + /// How many parked items are headed for a CHAT WINDOW. + /// + /// Addon lines are excluded on purpose. They park in the same queue for the same ask-once name + /// resolve, but they are not speech and never render — so counting them here would make + /// `addon_chat_never_reaches_the_chat_window` fail the moment the receive half opened, which is + /// exactly what it did. "Pending" stopped meaning "will render" when `Pending::Addon` arrived. #[cfg(test)] pub(crate) fn pending_len(&self) -> usize { - self.pending.len() + self.pending + .iter() + .filter(|p| !matches!(p, Pending::Addon { .. })) + .count() } } @@ -498,6 +592,32 @@ pub(super) fn feed_chat( ); route(&mut script, &mut windows, &event); } + Pending::Addon { + prefix, + message, + distribution, + guid, + tries, + } => { + let name = names.resolve(guid, &commands).map(str::to_string); + if name.is_none() && tries < NAME_MAX_TRIES { + still.push(Pending::Addon { + prefix, + message, + distribution, + guid, + tries: tries + 1, + }); + continue; + } + fire_addon_message( + &mut script, + prefix, + message, + distribution, + name.unwrap_or_else(|| "Unknown".into()), + ); + } Pending::Roll { min, max, diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index f0aeb67a..6622dea0 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -890,3 +890,51 @@ pub(super) fn drain_addon_chat_sends( } } } + +/// Turn an addon's `SendAddonMessage` calls into sends (decision 1235) — the addon-to-addon lane, +/// not speech. +/// +/// Its own drain rather than a branch in [`drain_addon_chat_sends`] because the two queues carry +/// different things: a `SendChatMessage` line still needs a chat-type token resolved and may be +/// refused here, while a `SendAddonMessage` broadcast arrives **already validated** — the binding +/// owns the four-value whitelist, the `prefix` TAB `message` composition and the outside-a-raid +/// downgrade, exactly as the reference's `0x49f920` does, so nothing is left for this side to +/// decide. That is why there is no "unknown distribution" arm here and no way to add one: the +/// queue's own type is the enum. +/// +/// **The lane is traced in both directions, under one tag.** An addon channel is invisible by +/// construction — it never reaches a chat frame, which is the entire point of `LANG_ADDON` — so +/// without a line here a broadcast that went out and one that never happened look identical from +/// our own logs, which is exactly how a silently-discarded wire body survives (method.md's rule +/// that new wire bodies are proved, not assumed). `net::apply::chat` already writes inbound addon +/// traffic to the `addon` trace tag; this writes the outbound half to the same tag, so +/// `WOW_MOVE_TRACE= WOW_MOVE_TRACE_TAGS=addon` on a live run is the whole conversation in +/// one file, in order. +pub(super) fn drain_addon_message_sends( + script: Option>, + commands: Res, +) { + let Some(mut script) = script else { + return; + }; + for send in script.take_addon_sends() { + debug!( + "chat: addon broadcast on {} — {:?}", + send.distribution.token(), + send.text + ); + if benilla_assets::trace::enabled() { + benilla_assets::trace::line( + "addon", + &format!("-> {} {:?}", send.distribution.token(), send.text), + ); + } + let cmd = ClientCommand::AddonMessage { + distribution: send.distribution, + text: send.text, + }; + if commands.0.send(cmd).is_err() { + warn!("chat: not connected; addon broadcast dropped"); + } + } +} diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 66727d45..f1f214f3 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -1117,3 +1117,147 @@ fn a_sticky_whose_group_is_gone_opens_as_say() { assert_eq!(sticky_on_open(SendType::Guild, &solo), SendType::Guild); assert_eq!(sticky_on_open(SendType::Say, &solo), SendType::Say); } + +/// **The inbound addon split, and the direction a reimplementation gets backwards.** +/// +/// `CHAT_MSG_ADDON` (event 227) carries `(prefix, message, distribution, sender)`. The text divides +/// on its **FIRST** tab (`0x49a8d0`) — and with **no tab at all the whole text is the PREFIX** with +/// an empty message, not the reverse. wow-re records that direction explicitly because it is the +/// counter-intuitive one; this test is where it is pinned. +/// +/// `distribution` is the remap at `0x49aff4`: only the four lanes have names, and anything else +/// reports `"UNKNOWN"` rather than being dropped — the reference hands the addon a string it can +/// branch on either way. +#[test] +fn an_inbound_addon_line_splits_on_the_first_tab_only() { + const PARTY: u8 = 0x01; + const RAID: u8 = 0x03; + const GUILD: u8 = 0x04; + const BATTLEGROUND: u8 = 0x18; + const SAY: u8 = 0x00; + + let mut log = super::feed::ChatLog::default(); + // The ordinary shape. + log.push_addon("oRA\tSYNC:1", PARTY, 7); + // A message that itself contains tabs: only the FIRST one divides. + log.push_addon("CTRA\tA\tB\tC", RAID, 7); + // NO TAB — the whole text is the prefix, the message is empty. + log.push_addon("BareTag", GUILD, 7); + // An empty message after a trailing tab is still an empty message, not a missing one. + log.push_addon("Tag\t", BATTLEGROUND, 7); + // A lane with no name still arrives, labelled. + log.push_addon("X\ty", SAY, 7); + + assert_eq!( + log.pending_addons(), + vec![ + ("oRA".into(), "SYNC:1".into(), "PARTY".into()), + ("CTRA".into(), "A\tB\tC".into(), "RAID".into()), + ("BareTag".into(), String::new(), "GUILD".into()), + ("Tag".into(), String::new(), "BATTLEGROUND".into()), + ("X".into(), "y".into(), "UNKNOWN".into()), + ] + ); +} + +/// **`CHAT_MSG_ADDON` reaches Lua with the reference's four arguments, in the reference's order.** +/// +/// The split test above covers the parse; this covers the FIRE, which is the half that can be +/// silently wrong — an addon reading `arg3` as the sender instead of the distribution gets a string +/// either way and misbehaves without erroring. +/// +/// wow-re carves the shape as `SignalEvent2(227, "%s%s%s%s", prefix, message, distribution, sender)` +/// (`0x49a95f`); `BigWigs` self-delivers the identical order by hand. The handler below records all +/// four positionally, so a reordering fails on the values rather than on a count. +#[test] +fn the_addon_event_reaches_lua_with_four_arguments_in_order() { + let mut s = benilla_ui::script::UiScript::new().unwrap(); + s.run( + r#" + seen = nil + f = CreateFrame("Frame", "AddonSink") + f:RegisterEvent("CHAT_MSG_ADDON") + f:SetScript("OnEvent", function() + seen = { arg1, arg2, arg3, arg4 } + end) + "#, + ) + .unwrap(); + + super::feed::fire_addon_message( + &mut s, + "oRA".into(), + "SYNC:1".into(), + "PARTY".into(), + "Someone".into(), + ); + + assert!( + s.errors().is_empty(), + "the fire must not raise: {:?}", + s.errors() + ); + assert_eq!( + s.eval::("return seen[1]").unwrap(), + "oRA", + "arg1 is the PREFIX" + ); + assert_eq!( + s.eval::("return seen[2]").unwrap(), + "SYNC:1", + "arg2 is the MESSAGE" + ); + assert_eq!( + s.eval::("return seen[3]").unwrap(), + "PARTY", + "arg3 is the DISTRIBUTION, not the sender" + ); + assert_eq!( + s.eval::("return seen[4]").unwrap(), + "Someone", + "arg4 is the SENDER, and a name rather than a guid" + ); +} + +/// **The two halves of the addon lane, against each other.** +/// +/// Send (1235/1236) and receive (7bd5567f) landed in different sessions, and the agent that built +/// the send half flagged the gap honestly: they pass together but *"I have not independently +/// exercised the two together."* A two-account live loopback is still the only thing that proves +/// the round trip on the wire — this proves the halves agree with each OTHER, which is the part +/// that can drift without either side looking wrong on its own. +/// +/// The composition and the split are separate transcriptions of the same byte law (`0x49f9b3` +/// composes on a tab, `0x49a8d0` splits on the first one), written by different sessions from the +/// same note. If one had picked a different separator, or split last-tab instead of first, every +/// test on both sides would still pass. +/// +/// A message CONTAINING tabs is the case that discriminates: compose glues one tab, the split takes +/// only the first, so the payload must come back with its own tabs intact. +#[test] +fn an_addon_message_survives_its_own_send_and_receive() { + let mut s = benilla_ui::script::UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("oRA", "SYNC\t1\t2", "PARTY")"#) + .unwrap(); + assert!(s.errors().is_empty(), "send raised: {:?}", s.errors()); + + let sends = s.take_addon_sends(); + assert_eq!(sends.len(), 1, "one broadcast queued"); + let sent = &sends[0]; + assert_eq!(sent.distribution.token(), "PARTY"); + + // Now the wire turns around: the same text arrives as an ordinary PARTY line carrying + // LANG_ADDON, and the receive half parses it. + let mut log = super::feed::ChatLog::default(); + log.push_addon(&sent.text, 0x01, 7); + + assert_eq!( + log.pending_addons(), + vec![( + "oRA".to_string(), + "SYNC\t1\t2".to_string(), + "PARTY".to_string() + )], + "what one half composed, the other must recover — tabs in the payload included" + ); +} diff --git a/crates/benilla-protocol/src/world/writer/chat.rs b/crates/benilla-protocol/src/world/writer/chat.rs index 71ee490b..95e0a128 100644 --- a/crates/benilla-protocol/src/world/writer/chat.rs +++ b/crates/benilla-protocol/src/world/writer/chat.rs @@ -166,6 +166,33 @@ impl WorldWriter { ) } + /// **Send an addon broadcast** (`SendAddonMessage`, decision 1235) — the one `CMSG_MESSAGECHAT` + /// in this module that does **not** speak [`Self::chat_language`]. + /// + /// `chat_type` is one of the client's four addon lanes (`CHAT_TYPE_PARTY`/`RAID`/`GUILD`/ + /// `BATTLEGROUND`); `text` is the already-composed `prefix` TAB `message` payload — this verb + /// does not compose it, because the Lua binding does and the tab's position is the only thing + /// the far client's splitter has to go on. + /// + /// The language field carries [`messages::LANGUAGE_ADDON`] (`0xFFFFFFFF`), and that sentinel + /// is the entire difference between addon data and speech: 1.12.1 has no addon opcode. + /// VERIFIED in `WoW.exe` (5875), wow-re `system/ui/scratch/addon-chat-law.md` §5 — the binding + /// `0x49f920` writes opcode `0x95` (`0x49facf`), the chat type (`0x49fad8`), then + /// `or ebx,-0x1` / `push ebx` (`0x49fab9`/`0x49fadd`) into the u32 language write at + /// `0x49fae1`, then the message CString at `0x49faf0`. Corroborated end-to-end against a live + /// vmangos by `examples/addon_chat_probe` (decision 1029): the sentinel and the tab both + /// survive the relay untouched. + /// + /// Server-side the line is exempt from the `KnowsLanguage` gate, from flood control and from + /// `SanitizeChatMessage`, and gated instead by the `AddonChannel` config — see + /// [`messages::LANGUAGE_ADDON`] for the whole treatment. + pub fn send_addon_message(&mut self, chat_type: u32, text: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(chat_type, messages::LANGUAGE_ADDON, text), + ) + } + /// Send a `/1`-style channel line (`CHAT_MSG_CHANNEL`) — `channel` is the channel **name** /// (`Handlers/ChatHandler.cpp:255-327`; body in [`messages::messagechat_channel`]). Requires /// membership; the server silently drops it if we're not on the channel. diff --git a/crates/benilla-protocol/tests/client.rs b/crates/benilla-protocol/tests/client.rs index f9e7e5e7..a9ffd458 100644 --- a/crates/benilla-protocol/tests/client.rs +++ b/crates/benilla-protocol/tests/client.rs @@ -306,6 +306,107 @@ fn messagechat_sendable_types_golden() { ); } +/// **The addon broadcast body** (`SendAddonMessage`, decision 1235) — `CMSG_MESSAGECHAT` on one of +/// four ordinary lanes with the `LANG_ADDON` sentinel in the language field. Bytes hand-computed +/// from the layout, independent of the Rust builder. +/// +/// VERIFIED in `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md` §5, the binding +/// `0x49f920`: opcode `0x95` (`0x49facf`), u32 chat type (`0x49fad8`), u32 language from +/// `or ebx,-0x1` (`0x49fab9`) written at `0x49fae1`, then the message CString (`0x49faf0`). There +/// is **no prefix field and no target field on the wire** — the prefix is glued to the message +/// with a literal TAB (`_snprintf(dst, 0x800, "%s\t%s", …)` at `0x49f9b3`, format `0x844b5c`, raw +/// `25 73 09 25 73 00`) and the pair rides as one C-string. The far client splits on the FIRST tab +/// (`0x49a8d0`). +/// +/// Corroborated end-to-end against a live vmangos by `examples/addon_chat_probe` (decision 1029): +/// the sentinel comes back intact and the `0x09` survives (addon chat skips `SanitizeChatMessage`, +/// `Handlers/ChatHandler.cpp:49`). +#[test] +fn addon_message_bodies_golden() { + // `LANG_ADDON` — the whole discriminator. Not a tongue, and never rewritten by the server. + assert_eq!( + messages::LANGUAGE_ADDON, + 0xFFFF_FFFF, + "LANG_ADDON (vmangos SharedDefines.h:270)" + ); + + // oRA2 `Core.lua:563`, verbatim: SendAddonMessage("CTRA", msg, "RAID") — the corpus's one + // live caller of this verb. + // 02 00 00 00 CHAT_MSG_RAID + // ff ff ff ff LANG_ADDON + // 43 54 52 41 "CTRA" + // 09 TAB — the prefix/message separator + // 73 74 61 74 75 73 "status" + // 00 the message C-string's NUL + assert_eq!( + messages::messagechat( + messages::CHAT_TYPE_RAID, + messages::LANGUAGE_ADDON, + "CTRA\tstatus" + ), + hx("02000000ffffffff435452410973746174757300"), + "CMSG_MESSAGECHAT (addon, RAID) body" + ); + + // The other three lanes of the client's four-value whitelist (`0x49fa3f`-`0x49fa4e`). Only the + // type byte moves; the sentinel and the tab-composed payload are identical. + assert_eq!( + messages::messagechat( + messages::CHAT_TYPE_PARTY, + messages::LANGUAGE_ADDON, + "oRA\thello" + ), + hx("01000000ffffffff6f52410968656c6c6f00"), + "CMSG_MESSAGECHAT (addon, PARTY) body" + ); + assert_eq!( + messages::messagechat( + messages::CHAT_TYPE_GUILD, + messages::LANGUAGE_ADDON, + "oRA\thello" + ), + hx("03000000ffffffff6f52410968656c6c6f00"), + "CMSG_MESSAGECHAT (addon, GUILD) body" + ); + assert_eq!( + messages::messagechat( + messages::CHAT_TYPE_BATTLEGROUND, + messages::LANGUAGE_ADDON, + "oRA\thello" + ), + hx("5c000000ffffffff6f52410968656c6c6f00"), + "CMSG_MESSAGECHAT (addon, BATTLEGROUND) body" + ); + + // AceEvent-2.0.lua's own line — `SendAddonMessage("LOOT_OPENED", "", "RAID")`, the call + // replicated in 24 of the 218 corpus addons. **The empty message still carries its tab**: the + // composition is unconditional, so the payload is `"LOOT_OPENED\t"` and the body ends TAB, NUL. + // A builder that "helpfully" dropped a trailing separator would make the far client read + // prefix `"LOOT_OPENED"` with no message at all — which is the same thing, but only by + // accident of the receiver's no-tab rule; the bytes must still match the reference's. + assert_eq!( + messages::messagechat( + messages::CHAT_TYPE_RAID, + messages::LANGUAGE_ADDON, + "LOOT_OPENED\t" + ), + hx("02000000ffffffff4c4f4f545f4f50454e45440900"), + "CMSG_MESSAGECHAT (addon, empty message keeps its TAB) body" + ); + + // An addon line is byte-identical to ordinary speech on the same lane EXCEPT the language + // field — the property the receive-side gate rests on, pinned so a future refactor cannot + // quietly reintroduce a tongue here. + let addon = messages::messagechat(messages::CHAT_TYPE_PARTY, messages::LANGUAGE_ADDON, "x\ty"); + let speech = + messages::messagechat(messages::CHAT_TYPE_PARTY, messages::LANGUAGE_COMMON, "x\ty"); + assert_eq!(addon.len(), speech.len(), "same shape"); + assert_eq!(addon[0..4], speech[0..4], "same chat type"); + assert_eq!(addon[8..], speech[8..], "same payload"); + assert_eq!(&addon[4..8], &[0xFF, 0xFF, 0xFF, 0xFF], "LANG_ADDON"); + assert_eq!(&speech[4..8], &[0x07, 0x00, 0x00, 0x00], "LANG_COMMON"); +} + /// The channel wire family's CMSG bodies (decision 0288 phase 1) — join/leave/list + the full /// moderation set, ALL verified vmangos `Server/Packets/Channel.cpp` (every `ReadFromWorldPacket` /// there is a channel-name cstring, optionally followed by a second cstring: a password or a target diff --git a/crates/benilla-ui/src/script/addon_message.rs b/crates/benilla-ui/src/script/addon_message.rs new file mode 100644 index 00000000..7efd324b --- /dev/null +++ b/crates/benilla-ui/src/script/addon_message.rs @@ -0,0 +1,732 @@ +//! **`SendAddonMessage`** — the addon-to-addon channel (decision 1235). +//! +//! ```lua +//! SendAddonMessage(prefix, message [, distribution]) +//! ``` +//! +//! The corpus's single most-wanted missing global (24 of 218 addons name it). It is how an addon +//! talks to *its own copy running on someone else's client*: oRA2 broadcasting a CTRA raid status, +//! KLHThreatMeter shipping a threat table, BigWigs syncing a boss timer. +//! +//! ## 1.12.1 has no addon opcode — the whole thing is one sentinel in the language field +//! +//! There is no `CMSG_ADDON_MESSAGE`. `SendAddonMessage` composes its two strings into ONE chat +//! line, sends it as an ordinary `CMSG_MESSAGECHAT` on an ordinary chat lane, and sets the +//! `language` field to `LANG_ADDON` (`0xFFFFFFFF`). That sentinel is the *only* thing separating +//! addon data from speech, on both sides of the wire. +//! +//! VERIFIED in `WoW.exe` (5875) — wow-re `system/ui/scratch/addon-chat-law.md` §5, a §5 trio +//! (three independent derivations, orchestrator-arbitrated). The binding is `0x49f920`, body +//! `[0x49f920, 0x49fb2a)`: +//! +//! ```text +//! 49f951/49f958: lua_tostring(L,1) -> prefix (NULL -> "" at 0x49f96b-0x49f97a) +//! 49f95d/49f966: lua_tostring(L,2) -> message (NULL -> "" likewise) +//! 49f97f-49f987: BOTH empty -> luaL_error("Usage: SendAddonMessage(...)") +//! 49f9a2: push 0x844b5c ; "%s\t%s" (raw 25 73 09 25 73 00) +//! 49f9ad: push 0x800 ; 2048 +//! 49f9b2: push eax ; dst = [ebp-0x81c] +//! 49f9b3: call 0x64a7f0 ; _snprintf(dst, 0x800, "%s\t%s", prefix, message) +//! 49fa0b/49fa15: lua_isstring(L,3) ; absent/non-string -> esi = 1 = PARTY (0x49fa06/0x49fa12) +//! 49fa3f: cmp esi,0x01 ; je ; PARTY +//! 49fa44: cmp esi,0x02 ; je ; RAID +//! 49fa49: cmp esi,0x03 ; je ; GUILD +//! 49fa4e: cmp esi,0x5c ; je ; BATTLEGROUND +//! 49fa53: push 0x844b44 ; call 0x6f4940 ; luaL_error("Unknown addon chat type") +//! 49facf: call 0x418190 ; u32 opcode 0x95 = CMSG_MESSAGECHAT +//! 49fad8: call 0x418190 ; u32 chat type +//! 49fab9: or ebx,-0x1 +//! 49fadd: push ebx +//! 49fae1: call 0x4181f0 ; u32 language = 0xFFFFFFFF +//! 49faf0: call 0x418430 ; CString message +//! ``` +//! +//! Three consequences the shape forces, and each is a thing an implementation gets wrong: +//! +//! - **The prefix is not a wire field.** It is glued to the message with a literal TAB and the +//! pair rides as one C-string. The receiver splits on the FIRST tab (`0x49a8d0`, the same note +//! §3). Nothing on the wire knows a prefix exists. +//! - **There is no `target` argument.** The body fetches Lua indices `1`, `2` and `3` and no +//! other — there is no arg-4 fetch anywhere in its 522 bytes. 1.12 has no whispered addon +//! message; the modern 4-argument `SendAddonMessage(prefix, msg, "WHISPER", target)` does not +//! exist here, and `"WHISPER"` is not even a legal distribution (below). +//! - **`_snprintf` with size `0x800` truncates silently at 2047 bytes + NUL** (`0x64a861`- +//! `0x64a868`). No error, no tell. +//! +//! ## The distribution set is FOUR, and it is narrower than the server's +//! +//! [`AddonDistribution`] is the whole legal set — `PARTY`/`RAID`/`GUILD`/`BATTLEGROUND`. Two +//! independent mechanisms in the binary name the identical four, which is the cross-check that +//! settles it: the send-side whitelist above, and the *receive* side's remap table (`0x49aff4`, +//! 92 bytes = `00 01 02` then `04` x88 then `03`) feeding the 5-entry jump table `0x49afe0`, whose +//! four real arms push `"PARTY"`/`"RAID"`/`"GUILD"`/`"BATTLEGROUND"` and whose default pushes the +//! literal `"UNKNOWN"`. +//! +//! **vmangos is WIDER than the client** and that gap is deliberate, not a bug to close: +//! `WorldSession::IsLanguageAllowedForChatType` also permits `LANG_ADDON` on OFFICER, +//! RAID_LEADER, RAID_WARNING, BATTLEGROUND_LEADER and CHANNEL. A real client *accepts* addon +//! traffic inbound on all of those (the receive-side discriminator is language-only and reads no +//! type byte) while refusing to *send* on them. We match the client, not the server: the four. +//! +//! An unknown token and a known-but-not-whitelisted one (`"SAY"`, `"WHISPER"`, `"CHANNEL"`, +//! `"OFFICER"`, `"RAID_WARNING"` — all of which DO resolve in the shared 13-record type table at +//! `0x49f7a0`) converge on the **same** `luaL_error` at `0x49fa53`. That is why this module does +//! not model the 13-record table at all: its output is unobservable through this binding, because +//! both paths raise the identical error text. The note says so outright — *"a caller cannot tell a +//! typo from a rejection."* +//! +//! ## The seam +//! +//! [`super::chat_send`]'s shape: the verb validates, composes, and queues an [`AddonSend`]; the app +//! drains it ([`super::UiScript::take_addon_sends`]) into `ClientCommand::AddonMessage` and the +//! writer puts it on the wire. Plain data across the boundary — this crate holds no wire types — +//! but the distribution crosses as an **enum, not a token string**, so the app's map is total and +//! the "unknown type silently guessed into SAY" failure is impossible by construction rather than +//! by a second validation nobody remembers to write. +//! +//! ## The `|`-escape scan — carved, and it is a forward SKIP, not a flat scan (decision 1236) +//! +//! `0x49f9bb`-`0x49fa04`, running on the **composed** buffer (cursor `0x49f9bd +//! lea ecx,[ebp-0x81c]`, byte-identical to the `_snprintf` destination `lea` at `0x49f9a7`) and +//! strictly **before** the type whitelist — `0x49fa06` is the sole predecessor of `0x49fa0b`, so a +//! payload with a bad escape *and* a bad distribution reports the escape and never fetches +//! argument 3. See [`validate_escapes`] for the loop; the two facts that make it non-obvious: +//! +//! - **`|c` skips PAST its closer.** `strstr` (`0x64b4f0`, case-**sensitive** — the binary has a +//! folding twin at `0x64b540` and deliberately does not use it here) finds the first literal +//! `"|r"` and the cursor becomes `lea esi,[eax+0x2]` (`0x49f9f4`), i.e. beyond it. That single +//! `lea` is why a well-formed `|r` never reaches the loop head — under a flat scan its own `|` +//! would be a `|` followed by `r` and every colour escape in the game would raise. +//! - **An unmatched `|c` is not an error.** It NUL-terminates in place at the opening `|` +//! (`0x49fa6a`) and then `0x49fa6d jmp 0x49fa06` lands on the accept path — **the truncated text +//! is transmitted**, with no error and no signal to the addon. +//! +//! Because the scan runs on the composed buffer, prefix and message are validated as **one +//! string**: a `|c` opened in the prefix can be closed by a `|r` in the message, and an unmatched +//! one in the prefix truncates the payload to a bare prefix with no tab at all. +//! +//! ## What is deliberately NOT here, named rather than left to be found +//! +//! - **The no-group silent return** (`0x49fa9f`-`0x49faab` -> `0x49fb21`): the reference drops an +//! effective-PARTY send when `[0xbc6f48]`/`[0xbc6f4c]` are both zero, with no error and no +//! packet. Those two are **`partyMemberGuid[0].lo/.hi` — element 0 of a FOUR-entry array** +//! (stride 8, limit `0x20`; `Ui\PartyFrame.cpp`), not "the party GUID" as 1235 called them: +//! slot 0 being non-zero is a sound "in a party" test only because `SMSG_GROUP_LIST` refills the +//! roster lowest-slot-first (decision 1236 §3). +//! We do not implement it, and the reason is a window we have and the reference does +//! not: its gate reads the live group GUID, ours would read [`super::party::PartyState`], which +//! is `default()` between world entry and the first `SMSG_GROUP_LIST`. Implementing it would +//! silently swallow a real broadcast in that window — the exact failure this verb exists to +//! avoid. Not implementing it costs one `CMSG_MESSAGECHAT` that vmangos discards +//! (`HandleChatMessageOpcode`'s `if (!group) return`), which is the same observable nothing. + +use mlua::{Lua, Value}; + +use super::Model; + +/// The **four** distributions `SendAddonMessage` will send on — the client's own hard whitelist at +/// `0x49fa3f`-`0x49fa4e`, corroborated by the receive side's remap table (module doc). +/// +/// An enum rather than a token string on purpose: the binding is the only place a distribution can +/// be rejected, so everything downstream must be total. The wire type bytes it names are +/// `ChatMsg::CHAT_MSG_*` and live in `benilla_protocol::messages`; this crate carries no wire +/// types, so the app maps the variant. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AddonDistribution { + /// `PARTY` — wire chat type `0x01`. The **default** when the third argument is absent or not a + /// string (`esi` pre-seeded `1` at `0x49fa06`/`0x49fa12`, only overwritten by a successful + /// `lua_isstring`+lookup). + Party, + /// `RAID` — wire chat type `0x02`. Downgraded to [`Self::Party`] outside a raid; see + /// [`AddonDistribution::effective`]. + Raid, + /// `GUILD` — wire chat type `0x03`. + Guild, + /// `BATTLEGROUND` — wire chat type `0x5C`. + Battleground, +} + +impl AddonDistribution { + /// Resolve a Lua distribution token. `None` = raise `"Unknown addon chat type"`, which is the + /// binding's single answer for both an unrecognised token and a recognised-but-not-whitelisted + /// one (module doc). + /// + /// Case-insensitive because the reference's own lookup is: the shared table at `0x49f7a0` is + /// scanned with the case-insensitive compare `0x64a4c0` (`0x49f861`-`0x49f875`), so + /// `SendAddonMessage("p", "m", "raid")` is a RAID send on a real client. + pub fn from_token(token: &str) -> Option { + // `eq_ignore_ascii_case` rather than an uppercased allocation: the four tokens are ASCII + // and this runs on every addon broadcast. + for (name, dist) in [ + ("PARTY", Self::Party), + ("RAID", Self::Raid), + ("GUILD", Self::Guild), + ("BATTLEGROUND", Self::Battleground), + ] { + if token.eq_ignore_ascii_case(name) { + return Some(dist); + } + } + None + } + + /// The token the receive side reports as `CHAT_MSG_ADDON`'s 3rd argument — the strings the four + /// jump-table arms push (`0x8441b0`, `0x844600`, `0x8441a8`, `0x8445f0`). + pub fn token(self) -> &'static str { + match self { + Self::Party => "PARTY", + Self::Raid => "RAID", + Self::Guild => "GUILD", + Self::Battleground => "BATTLEGROUND", + } + } + + /// **RAID sent from outside a raid goes to PARTY**, silently — `0x49fa8b`-`0x49fa93` tests + /// `[0xb713e0]` and falls into the PARTY arm when it is zero. Every other distribution passes + /// through untouched. + /// + /// This is the same downgrade a player sees when `/raid` in a 5-man party lands in party chat, + /// and vmangos is the reason it has to exist client-side: `HandleChatMessageOpcode`'s + /// `CHAT_MSG_RAID` arm requires `group->IsRaidGroup()` and drops the message otherwise. Without + /// it, oRA2 — which sends `SendAddonMessage("CTRA", msg, "RAID")` unconditionally + /// (`Core.lua:563`) — would reach nobody in an ordinary 5-man group, which is most of the time + /// it runs. + /// + /// `in_raid` is [`super::party::PartyState::raid`] being non-empty, that list being + /// `GetNumRaidMembers()`'s own backing (empty outside a raid, and including the player inside + /// one). + /// + /// **That is not an analogy — it is the same cell** (decision 1236). `[0xb713e0]` is not a + /// flag: it is the raid member **count**, used as an unsigned loop bound over the 40-slot + /// roster at `0xb712a8`, and the binding `GetNumRaidMembers` (`0x4bb530`, `Ui\RaidInfo.cpp`) + /// returns that cell verbatim. So the reference's test `[0xb713e0] == 0` *is* + /// `GetNumRaidMembers() == 0`, and reading our own `GetNumRaidMembers` backing is the + /// mechanism rather than a stand-in for it. (1235 recorded this identity as corroborated-not- + /// carved and hedged accordingly; the RE it dispatched carved it, and the hedge is retired.) + pub fn effective(self, in_raid: bool) -> Self { + match self { + Self::Raid if !in_raid => Self::Party, + other => other, + } + } +} + +/// One queued addon broadcast, drained by the app into the wire. +/// +/// Plain data — [`super::chat_send::ChatSend`]'s twin. The prefix/message split is **already +/// gone** by the time one of these exists: the reference composes at the binding +/// (`_snprintf(dst, 0x800, "%s\t%s", …)` at `0x49f9b3`) and so do we, because the tab is the only +/// separator the wire has and nothing downstream may re-decide where it goes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AddonSend { + /// The composed payload — `prefix`, TAB, `message` — truncated to the reference's 2047 bytes. + /// Sent verbatim as the `CMSG_MESSAGECHAT` message C-string; vmangos does not sanitise a + /// `LANG_ADDON` line (`SanitizeChatMessage` returns early, `Handlers/ChatHandler.cpp:49`), so + /// the tab survives to the far client's splitter. + pub text: String, + /// The lane, **after** the outside-a-raid downgrade ([`AddonDistribution::effective`]) — what + /// the app should actually send on, not what the addon asked for. + pub distribution: AddonDistribution, +} + +/// `_snprintf(dst, 0x800, …)` writes at most 2047 bytes plus its NUL (`0x64a861`-`0x64a868`). +const ADDON_MESSAGE_CAP: usize = 0x800 - 1; + +/// **The `|`-escape scan** (`0x49f9bb`-`0x49fa04`, decision 1236) — the reference's only text +/// validation, run on the already-composed payload and **before** the distribution is even +/// fetched. +/// +/// Returns `Err` for an invalid escape (the caller raises the reference's own message); otherwise +/// `Ok`, having possibly **truncated `text` in place** at an unmatched `|c`, which is the +/// reference's silent leg — the shortened payload still goes out. +/// +/// The loop, arm for arm (`esi` = the cursor, always pointing at a `|` found by `strchr`): +/// +/// | at `|` the next byte is | reference | cursor | +/// |---|---|---| +/// | `\|` | accept the escaped pipe | `+2` (`0x49f9d7 add esi,0x2`) | +/// | `c` and a later `"\|r"` exists | accept the whole colour run | past the closer (`0x49f9f4 lea esi,[eax+0x2]`) | +/// | `c` and no `"\|r"` | **truncate here, then accept** | — (`0x49fa6a`, `0x49fa6d jmp 0x49fa06`) | +/// | anything else, incl. the NUL | `luaL_error` | — (`0x49fa6f`) | +/// +/// Consequences that fall straight out of that table and are pinned by the tests, because every +/// one of them is a thing a reimplementation gets wrong: +/// +/// - **`||` and `|c` are the ONLY accepted escape starts.** A bare `|r` raises, and so do `|H`, +/// `|h`, `|T`, `|t`, `|n` — and `|C`, because the `c` test is case-sensitive. Item links survive +/// only because they are *wrapped*: `|cffffffff|Hitem:…|h[x]|h|r` opens with `|c`, so the whole +/// run including every `|H`/`|h` is skipped to the closing `|r` without being examined. +/// - **Nothing between a `|c` and its first `|r` is validated.** No hex-digit check; a nested +/// `|c` is legal precisely because it is never looked at. This is a skip, not a nesting counter. +/// - **`|R` does not close `|c`** (case-sensitive `strstr`) — it truncates instead. +/// - **A lone trailing `|` raises**, because `strchr` will not match the NUL terminator +/// (`0x64a402` tests before `0x64a406` compares), so the byte after it is the NUL and the NUL is +/// not `|` or `c`. Pipe runs must therefore be even: `||||` is sent verbatim, `|||` raises. +fn validate_escapes(text: &mut String) -> Result<(), ()> { + let bytes = text.as_bytes(); + let mut i = 0usize; + while let Some(off) = bytes[i..].iter().position(|&b| b == b'|') { + let pipe = i + off; + match bytes.get(pipe + 1) { + // `||` — an escaped pipe. Past both bytes. + Some(b'|') => i = pipe + 2, + Some(b'c') => { + // `strstr` from the `|c`'s own `|` for the literal two bytes `"|r"` (operand + // `0x844538` = image bytes `7c 72 00`). `|c` cannot match `|r`, so the hit is + // always a later closer. + match bytes[pipe..] + .windows(2) + .position(|w| w == b"|r") + .map(|p| pipe + p) + { + // Skip PAST the closer, so its own `|` is never fed back to the loop head. + Some(closer) => i = closer + 2, + // No closer: NUL-terminate at the opening `|` and accept what is left. + None => { + text.truncate(pipe); + return Ok(()); + } + } + } + // Anything else — including `None`, the NUL that ends a lone trailing `|`. + _ => return Err(()), + } + } + Ok(()) +} + +/// `lua_tostring` semantics + the reference's NULL replacement: strings and numbers coerce, +/// everything else (nil, boolean, table, function) becomes the shared empty string `0x882748` +/// (`0x49f96b`-`0x49f97a`). `Lua::coerce_string` **is** `lua_tostring`, so this is the mechanism +/// rather than an imitation of it. +fn lua_tostring(lua: &Lua, v: &Value) -> mlua::Result { + Ok(lua + .coerce_string(v.clone())? + .map(|s| s.to_string_lossy()) + .unwrap_or_default()) +} + +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + lua.globals().set( + "SendAddonMessage", + lua.create_function( + |lua, (prefix, message, distribution): (Value, Value, Value)| { + let prefix = lua_tostring(lua, &prefix)?; + let message = lua_tostring(lua, &message)?; + // BOTH empty is the usage error, and only both — there is no empty-prefix-only and + // no empty-message-only check (`0x49f97f`-`0x49f987`). AceEvent-2.0 relies on it: + // `SendAddonMessage("LOOT_OPENED", "", "RAID")` is 24 of the corpus's 35 call + // sites (the same line, in 23 addons' embedded copies) and its message is + // deliberately empty. An empty-message guard would break the largest row here. + if prefix.is_empty() && message.is_empty() { + return Err(mlua::Error::RuntimeError( + r#"Usage: SendAddonMessage("prefix", "message" [,"type"])"#.into(), + )); + } + let mut text = format!("{prefix}\t{message}"); + // Truncate exactly where `_snprintf` does — by BYTES, since the reference is + // writing a C buffer. `floor_char_boundary` is not stable, so walk back to the + // nearest boundary: identical to the reference for ASCII (every corpus payload), + // and the alternative is a `String` that is not UTF-8, which cannot exist. + if text.len() > ADDON_MESSAGE_CAP { + let mut cut = ADDON_MESSAGE_CAP; + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + text.truncate(cut); + } + // **The escape scan runs HERE — after the compose, before argument 3 is fetched** + // (`0x49fa06` is the sole predecessor of `0x49fa0b`; decision 1236). So a call + // that is wrong in both ways reports the ESCAPE, and a bad distribution beside a + // bad escape is never even looked at. It may truncate `text` in place, which is + // the reference's silent leg. + if validate_escapes(&mut text).is_err() { + return Err(mlua::Error::RuntimeError( + "Invalid escape code in chat message".into(), + )); + } + // The third argument goes through `lua_isstring`, which is TRUE for a number too + // (`0x49fa0b`/`0x49fa15`) — so `SendAddonMessage("p", "m", 1)` resolves the token + // `"1"`, finds nothing, and raises, rather than being read as chat type 1. Absent + // or non-string (nil, table, boolean) takes `0x49fa1c je 0x49fa9f`, which skips + // the whitelist entirely with the pre-seeded `esi = 1` — the PARTY default. + let asked = match lua.coerce_string(distribution)? { + Some(token) => AddonDistribution::from_token(&token.to_string_lossy()) + .ok_or_else(|| { + mlua::Error::RuntimeError("Unknown addon chat type".into()) + })?, + None => AddonDistribution::Party, + }; + let in_raid = { + let model = lua.app_data_ref::().expect("model app_data"); + !model.party.raid.is_empty() + }; + lua.app_data_mut::() + .expect("model app_data") + .addon_sends + .push(AddonSend { + text, + distribution: asked.effective(in_raid), + }); + Ok(()) + }, + )?, + )?; + Ok(()) +} + +impl super::UiScript { + /// Drain the broadcasts `SendAddonMessage` queued since the last call. + pub fn take_addon_sends(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().addon_sends) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::script::party::{PartyState, RaidMemberInfo}; + use crate::script::UiScript; + + /// The composition law: prefix, a literal TAB, message — one string, and the prefix is not a + /// field. Plus the default distribution, which is PARTY and not SAY (this verb's default is + /// not `SendChatMessage`'s). + #[test] + fn a_broadcast_is_prefix_tab_message_and_defaults_to_party() { + let mut s = UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("oRA", "hello")"#).unwrap(); + assert_eq!( + s.take_addon_sends(), + vec![AddonSend { + text: "oRA\thello".into(), + distribution: AddonDistribution::Party, + }] + ); + // Drained, not re-read. + assert!(s.take_addon_sends().is_empty()); + } + + /// **The four, and only the four.** Every token the shared 13-record table at `0x49f7a0` + /// resolves but the whitelist at `0x49fa3f`-`0x49fa4e` rejects must raise — SAY and WHISPER + /// especially, since a client that accepted them would be inventing a 1.12 whispered addon + /// message that does not exist. The error text is the reference's, and it is the SAME one an + /// unknown token gets. + #[test] + fn the_distribution_set_is_exactly_party_raid_guild_battleground() { + let mut s = UiScript::new().unwrap(); + // In a raid, so RAID is not downgraded and its own byte is what is under test. + s.set_party(PartyState { + raid: vec![RaidMemberInfo::default()], + ..Default::default() + }); + for (token, want) in [ + ("PARTY", AddonDistribution::Party), + ("RAID", AddonDistribution::Raid), + ("GUILD", AddonDistribution::Guild), + ("BATTLEGROUND", AddonDistribution::Battleground), + // Case-insensitive: the reference's lookup uses the case-insensitive compare 0x64a4c0. + ("raid", AddonDistribution::Raid), + ("Guild", AddonDistribution::Guild), + ] { + s.run(&format!(r#"SendAddonMessage("p", "m", "{token}")"#)) + .unwrap_or_else(|e| panic!("{token} must send: {e}")); + let sent = s.take_addon_sends(); + assert_eq!(sent.len(), 1, "{token}"); + assert_eq!(sent[0].distribution, want, "{token}"); + } + // Resolved by the shared table, refused by the whitelist — one error text for both classes. + for token in [ + "SAY", + "YELL", + "WHISPER", + "EMOTE", + "OFFICER", + "CHANNEL", + "AFK", + "DND", + "RAID_WARNING", + "RAID_LEADER", + "BATTLEGROUND_LEADER", + "NOT_A_TYPE", + "", + ] { + let err = s + .run(&format!(r#"SendAddonMessage("p", "m", "{token}")"#)) + .expect_err(&format!("{token} must be refused, not guessed")); + assert!( + err.to_string().contains("Unknown addon chat type"), + "{token}: {err}" + ); + assert!( + s.take_addon_sends().is_empty(), + "{token} must queue nothing" + ); + } + } + + /// A refused distribution sends **nothing** — the whole point of the enum seam. An addon that + /// asked for a lane we cannot send on gets an error it can see, never a silent reroute onto + /// SAY (which would put its serialized payload in front of every player in range). + #[test] + fn a_number_distribution_raises_rather_than_being_read_as_a_type_byte() { + let mut s = UiScript::new().unwrap(); + // `lua_isstring` is true for a number, so the reference resolves the TOKEN "1" — which is + // not in the type table — and raises. It does not read 1 as CHAT_MSG_PARTY. + let err = s + .run(r#"SendAddonMessage("p", "m", 1)"#) + .expect_err("a numeric distribution must raise"); + assert!(err.to_string().contains("Unknown addon chat type"), "{err}"); + assert!(s.take_addon_sends().is_empty()); + } + + /// Non-string, non-number third argument is **absent**, not an error: `lua_isstring` fails and + /// the pre-seeded PARTY default stands. + #[test] + fn a_non_string_distribution_takes_the_party_default() { + let mut s = UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("p", "m", nil)"#).unwrap(); + s.run(r#"SendAddonMessage("p", "m", {})"#).unwrap(); + let sent = s.take_addon_sends(); + assert_eq!(sent.len(), 2); + assert!(sent + .iter() + .all(|a| a.distribution == AddonDistribution::Party)); + } + + /// **RAID outside a raid is PARTY.** The corpus's one live caller (oRA2 `Core.lua:563`) sends + /// `"RAID"` unconditionally, so without this its broadcasts reach nobody in an ordinary 5-man + /// group — vmangos drops a `CHAT_MSG_RAID` from a non-raid group. + #[test] + fn raid_outside_a_raid_is_downgraded_to_party() { + let mut s = UiScript::new().unwrap(); + // Default PartyState: not in a raid. + s.run(r#"SendAddonMessage("CTRA", "status", "RAID")"#) + .unwrap(); + assert_eq!( + s.take_addon_sends()[0].distribution, + AddonDistribution::Party + ); + // In a raid it stays RAID… + s.set_party(PartyState { + raid: vec![RaidMemberInfo::default()], + ..Default::default() + }); + s.run(r#"SendAddonMessage("CTRA", "status", "RAID")"#) + .unwrap(); + assert_eq!( + s.take_addon_sends()[0].distribution, + AddonDistribution::Raid + ); + // …and the downgrade is RAID's alone — GUILD and BATTLEGROUND never move. + s.set_party(PartyState::default()); + for (token, want) in [ + ("GUILD", AddonDistribution::Guild), + ("BATTLEGROUND", AddonDistribution::Battleground), + ] { + s.run(&format!(r#"SendAddonMessage("p", "m", "{token}")"#)) + .unwrap(); + assert_eq!(s.take_addon_sends()[0].distribution, want, "{token}"); + } + } + + /// Both arguments empty is the usage error; **either one alone is not**. AceEvent-2.0 — + /// literally every one of this verb's replicated corpus callers — sends an empty message with + /// a real prefix, and erroring on it would break the row this verb was written for. + #[test] + fn only_both_empty_is_a_usage_error() { + let mut s = UiScript::new().unwrap(); + let err = s + .run(r#"SendAddonMessage("", "")"#) + .expect_err("both empty must raise"); + assert!(err.to_string().contains("Usage: SendAddonMessage"), "{err}"); + assert!(s.take_addon_sends().is_empty()); + // AceEvent-2.0.lua's own line, verbatim — the empty message must go out. + s.run(r#"SendAddonMessage("LOOT_OPENED", "", "RAID")"#) + .unwrap(); + // A prefix-less payload is legal too; the receiver reads prefix "" and message "m". + s.run(r#"SendAddonMessage("", "m")"#).unwrap(); + let sent = s.take_addon_sends(); + assert_eq!(sent[0].text, "LOOT_OPENED\t"); + assert_eq!(sent[1].text, "\tm"); + } + + /// `lua_tostring` coercion, and the NULL -> "" replacement at `0x49f96b`-`0x49f97a`: a number + /// coerces, a table does not and becomes empty. `SendAddonMessage({}, {})` is therefore the + /// usage error, reached the same way `("", "")` reaches it. + #[test] + fn arguments_coerce_like_lua_tostring_and_nil_becomes_empty() { + let mut s = UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("v", 314)"#).unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "v\t314"); + s.run(r#"SendAddonMessage(42, "m")"#).unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "42\tm"); + // Non-coercible -> "" on both sides -> the usage error, not a Rust type error. + let err = s + .run(r#"SendAddonMessage({}, {})"#) + .expect_err("two tables coerce to two empty strings"); + assert!(err.to_string().contains("Usage: SendAddonMessage"), "{err}"); + } + + /// **The first tab is the split, so a tab inside the prefix is a payload bug the reference also + /// has** — no sanitisation of `0x09` exists anywhere in the binding. Pinned so nobody "fixes" + /// it: the receiver would split at the prefix's own tab and read a truncated prefix. + #[test] + fn a_tab_inside_the_prefix_is_passed_through_unsanitised() { + let mut s = UiScript::new().unwrap(); + s.run("SendAddonMessage(\"pre\\tfix\", \"body\")").unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "pre\tfix\tbody"); + } + + /// Silent truncation at 2047 bytes + the implicit NUL — `_snprintf`'s `0x800`, not the message + /// length and not the prefix length. No error: the addon is never told. + #[test] + fn the_payload_truncates_silently_at_2047_bytes() { + let mut s = UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("p", string.rep("x", 4000))"#) + .unwrap(); + let sent = s.take_addon_sends(); + assert_eq!(sent[0].text.len(), 2047); + // The tab survives the cut — it is at byte 1, far inside the cap. + assert!(sent[0].text.starts_with("p\tx")); + // A payload that exactly fills the buffer is NOT truncated. + s.run(r#"SendAddonMessage("p", string.rep("y", 2045))"#) + .unwrap(); + assert_eq!(s.take_addon_sends()[0].text.len(), 2047); + } + + /// **The escape scan is a forward SKIP, and a well-formed colour run must survive it.** This + /// is the test that would have caught the naive flat scan: under one, the `|` of every `|r` + /// terminator is a `|` followed by `r` and *every* colour escape in the game raises. + #[test] + fn a_well_formed_colour_run_passes_and_its_closer_is_skipped_not_scanned() { + let mut s = UiScript::new().unwrap(); + for payload in [ + "|cffff0000red|r", + "before |cffff0000red|r after", + // Nested opens are legal precisely because nothing between `|c` and the first `|r` is + // examined — a skip, not a nesting counter. + "|cffAA0000 a |cff00BB00 b |r", + // An item link survives only because it is WRAPPED: the `|H`/`|h` inside the colour + // run are never looked at. + "|cffa335ee|Hitem:12345:0:0:0|h[Thunderfury]|h|r", + // Escaped pipes, in even runs. + "a || b", + "||||", + ] { + s.run(&format!( + "SendAddonMessage(\"P\", {:?}, \"GUILD\")", + payload + )) + .unwrap_or_else(|e| panic!("{payload:?} must send: {e}")); + let sent = s.take_addon_sends(); + assert_eq!(sent.len(), 1, "{payload:?}"); + assert_eq!( + sent[0].text, + format!("P\t{payload}"), + "{payload:?} verbatim" + ); + } + } + + /// `||` and `|c` are the ONLY accepted starts — everything else raises with the reference's own + /// text, including a **bare `|r`**, the link tokens outside a colour run, the case-flipped + /// `|C`, and a lone trailing `|` (whose next byte is the NUL, which `strchr` never matches). + #[test] + fn every_other_escape_start_raises_including_a_bare_closer_and_a_trailing_pipe() { + let mut s = UiScript::new().unwrap(); + for payload in [ + "|r", // a closer with nothing open + "|Hitem:1:0:0:0|h[x]|h", // link tokens NOT wrapped in a colour run + "|h", + "|T", + "|t", + "|n", + "|C", // |C is not |c — the test is case-sensitive + "trailing |", // next byte is the NUL + "|||", // odd pipe run: the third one's next byte is the NUL + ] { + let err = s + .run(&format!( + "SendAddonMessage(\"P\", {:?}, \"GUILD\")", + payload + )) + .expect_err(&format!("{payload:?} must raise")); + assert!( + err.to_string() + .contains("Invalid escape code in chat message"), + "{payload:?}: {err}" + ); + assert!( + s.take_addon_sends().is_empty(), + "{payload:?} queues nothing" + ); + } + } + + /// **An unmatched `|c` is NOT an error — it truncates in place and the short payload is still + /// sent** (`0x49fa6a` then `0x49fa6d jmp 0x49fa06`, the accept path). Silent, exactly as the + /// reference is: the addon is never told its message was cut. + #[test] + fn an_unmatched_colour_open_truncates_in_place_and_still_sends() { + let mut s = UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("P", "keep|cff00ff00dropped", "GUILD")"#) + .unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "P\tkeep"); + // `|R` does not close `|c` — the strstr is case-SENSITIVE, so this truncates too. + s.run(r#"SendAddonMessage("P", "keep|cff00ff00x|Ry", "GUILD")"#) + .unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "P\tkeep"); + // The scan runs on the COMPOSED buffer, so an unmatched open in the PREFIX truncates the + // payload to a bare prefix — no tab, no message at all. + s.run(r#"SendAddonMessage("pre|cffAABBCCfix", "body", "GUILD")"#) + .unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "pre"); + } + + /// **Prefix and message are validated as ONE string**, because the scan runs after the + /// compose — so a colour opened in the prefix is legally closed by the message. + #[test] + fn a_colour_run_may_span_the_tab_because_the_scan_is_on_the_composed_buffer() { + let mut s = UiScript::new().unwrap(); + s.run(r#"SendAddonMessage("|cffff0000pre", "post|r", "GUILD")"#) + .unwrap(); + assert_eq!(s.take_addon_sends()[0].text, "|cffff0000pre\tpost|r"); + } + + /// **Ordering: the escape check precedes the distribution fetch.** A call that is wrong in + /// both ways reports the ESCAPE — `0x49fa06` is the sole predecessor of `0x49fa0b`, so + /// argument 3 is never even read. (1235 shipped these the other way round; 1236 corrects it.) + #[test] + fn a_bad_escape_beats_a_bad_distribution() { + let s = UiScript::new().unwrap(); + let err = s + .run(r#"SendAddonMessage("P", "|z", "TOTALLY_BOGUS")"#) + .expect_err("must raise"); + assert!( + err.to_string() + .contains("Invalid escape code in chat message"), + "the escape must win, not the type: {err}" + ); + // But an UNMATCHED `|c` is not an error, so there the bad type does surface — the exact + // asymmetry the two legs of the scan produce. + let err = s + .run(r#"SendAddonMessage("P", "|cff000000x", "TOTALLY_BOGUS")"#) + .expect_err("must raise"); + assert!(err.to_string().contains("Unknown addon chat type"), "{err}"); + } + + /// The distribution tokens round-trip to the strings the RECEIVE side reports as + /// `CHAT_MSG_ADDON`'s third argument (the four jump-table arms at `0x49a918`/`91f`/`926`/`92d`). + #[test] + fn the_tokens_are_the_receive_sides_own_strings() { + for (dist, token) in [ + (AddonDistribution::Party, "PARTY"), + (AddonDistribution::Raid, "RAID"), + (AddonDistribution::Guild, "GUILD"), + (AddonDistribution::Battleground, "BATTLEGROUND"), + ] { + assert_eq!(dist.token(), token); + assert_eq!(AddonDistribution::from_token(token), Some(dist)); + } + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 66373199..f7c9c8fa 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -37,6 +37,7 @@ //! (an opaque identity in a lightuserdata) — only the *encoding* differs. mod action; +mod addon_message; mod aura; mod backdrop; mod bank; @@ -119,6 +120,7 @@ mod worldmap; pub use action::{ActionSlot, ActionState}; pub use addon::AddOnInfo; +pub use addon_message::{AddonDistribution, AddonSend}; pub use aura::{AuraState, TrackingState}; pub use backdrop::{pieces, Backdrop, BackdropPiece, Insets}; pub use bank::BankState; @@ -325,6 +327,7 @@ impl UiScript { lua.set_app_data(Model::new()); addon::install(&lua)?; + addon_message::install(&lua)?; chat_send::install(&lua)?; channel::install(&lua)?; chat_window::install(&lua)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 3862d3e6..6d7f3de4 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -910,6 +910,11 @@ pub(crate) struct Model { /// (decision 1199). Deliberately a different queue from the chat box's input: the box's drain /// runs the slash grammar and this one must not. pub(crate) chat_sends: Vec, + /// Broadcasts an addon queued with `SendAddonMessage`, drained by the app into the wire + /// (decision 1235). Its own queue rather than [`Self::chat_sends`] because it is a different + /// wire: `LANG_ADDON` in the language field, a four-value distribution set, and a payload the + /// binding already composed as `prefix` TAB `message`. + pub(crate) addon_sends: Vec, pub(crate) realm_name: String, /// Frames per second, behind `GetFramerate()`. Pushed per tick by the app, which owns the /// clock this crate does not have. @@ -1134,6 +1139,7 @@ impl Model { pending_events: Vec::new(), cursor_pos: (0.0, 0.0), chat_sends: Vec::new(), + addon_sends: Vec::new(), realm_name: String::new(), framerate: 0.0, modifiers: (false, false, false),