- 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>
733 lines
37 KiB
Rust
733 lines
37 KiB
Rust
//! **`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));
|
|
}
|
|
}
|
|
}
|