addons talk to each other: the addon message lane, both halves

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:38:27 +07:00
co-authored by Claude Fable 5
parent c504136638
commit c8ed53c4fe
12 changed files with 1360 additions and 8 deletions
+87
View File
@@ -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<String>,
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<u32> = [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"
);
}
}
}
+79 -5
View File
@@ -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::<Messages<ServerSaidMessage>>();
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<ChatLog>,
social: Res<SocialState>,
net_commands: Res<NetCommands>,
mut server_said: MessageWriter<ServerSaidMessage>| {
chat(
m.clone(),
&mut chat_log,
&social,
&net_commands,
&mut server_said,
);
},
)
.unwrap();
let log = world.resource::<ChatLog>();
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.
+6
View File
@@ -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)
}
+4
View File
@@ -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)
+123 -3
View File
@@ -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,
@@ -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=<path> 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<NonSendMut<benilla_ui::script::UiScript>>,
commands: Res<NetCommands>,
) {
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");
}
}
}
+144
View File
@@ -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::<String>("return seen[1]").unwrap(),
"oRA",
"arg1 is the PREFIX"
);
assert_eq!(
s.eval::<String>("return seen[2]").unwrap(),
"SYNC:1",
"arg2 is the MESSAGE"
);
assert_eq!(
s.eval::<String>("return seen[3]").unwrap(),
"PARTY",
"arg3 is the DISTRIBUTION, not the sender"
);
assert_eq!(
s.eval::<String>("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"
);
}
@@ -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.
+101
View File
@@ -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
@@ -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<Self> {
// `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<String> {
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::<Model>().expect("model app_data");
!model.party.raid.is_empty()
};
lua.app_data_mut::<Model>()
.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<AddonSend> {
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));
}
}
}
+3
View File
@@ -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)?;
+6
View File
@@ -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<super::chat_send::ChatSend>,
/// 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<super::addon_message::AddonSend>,
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),