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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user