Files
benilla-octo/crates/benilla-ui/src/script/chat_send.rs
T
SamandClaude Fable 5 b5bc569156 the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:31 +07:00

245 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **`SendChatMessage`** — the addon's own line into the chat wire (decision 1199).
//!
//! 24 corpus addons call it, and it is the one verb that makes an addon able to *say* anything:
//! every "announce the pull", every raid warning helper, every whisper-the-invite bot is this one
//! call. It is `function engine` in the captured `_G` — the engine takes the line and the wire
//! sends it, with no FrameXML in between.
//!
//! ```lua
//! SendChatMessage(text [, chatType [, language [, channel/target]]])
//! ```
//!
//! `chatType` defaults to `"SAY"`; `language` is accepted and **ignored** (§2); the fourth
//! argument is the whisper target for `"WHISPER"` and the channel name or number for
//! `"CHANNEL"`, and is unused otherwise.
//!
//! ## The seam
//!
//! Same shape as [`super::social`]'s: the verb queues a [`ChatSend`] and the app drains it
//! ([`super::UiScript::take_chat_sends`]) into `ClientCommand::Chat`. It deliberately does **not**
//! go through the chat edit box's own drain: that path runs the slash grammar, and an addon's
//! `SendChatMessage("/dance", "SAY")` must *say the four characters*, not dance. The reference has
//! the same split — `ChatEdit_SendText` parses, `SendChatMessage` does not.
//!
//! ## What is not carried, and why
//!
//! **`language`.** The reference resolves a language id to the garble table the receiver's client
//! reverses. benilla sends every line in the speaker's own tongue because the app's chat command
//! carries no language field yet, and inventing one here would put the id in a queue nothing
//! reads. Accepted and dropped, said out loud rather than silently — an addon passing it is not
//! wrong, we are incomplete.
//!
//! **No rate limit and no length cap.** The reference truncates at 255 and the *server* throttles.
//! Ours passes the line through; vmangos enforces both, so a misbehaving addon meets the same wall
//! it would meet on a real server rather than a client-side one we made up.
use mlua::{Lua, MultiValue, Value};
use super::Model;
impl super::UiScript {
/// Push the player's **default chat language** — the name `GetDefaultLanguage()` answers.
/// `None` (the default) is the reference's own no-player-object state, which returns **zero
/// Lua values**, not `nil`. The app resolves it once per world entry from
/// `ChrRaces.BaseLanguage` × `Languages.dbc` (`benilla_formats::DefaultLanguages`).
pub fn set_default_language(&mut self, name: Option<String>) {
self.model_mut().default_language = name;
}
}
/// One queued outbound chat line (`SendChatMessage`), drained by the app into the wire.
///
/// Plain data — [`super::social::SocialRequest`]'s twin, and deliberately *not* the app's own
/// `ClientCommand::Chat`: this crate has no wire types and must not grow one.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChatSend {
/// The line, verbatim. Never parsed here — a leading `/` is four characters, not a command.
pub text: String,
/// The chat type token, uppercased (`"SAY"`, `"YELL"`, `"PARTY"`, `"GUILD"`, `"WHISPER"`,
/// `"CHANNEL"`, `"EMOTE"`, …). Uppercased because the reference's own token compare is, and
/// addons write `"say"` as often as `"SAY"`.
pub chat_type: String,
/// The whisper target (`"WHISPER"`) or the channel name/number (`"CHANNEL"`); `None`
/// otherwise. Kept as a string for the channel case, where an addon passes either `"General"`
/// or `1` and the app resolves both.
pub target: Option<String>,
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// GetDefaultLanguage() → **exactly ONE value, a string** — or **zero values**, which is not
// the same thing (`0x49fcd0`, wow-re `bag-language-combat-action-bindings.md` §2).
//
// The binding takes **no arguments** (no arg-presence check and no arg-fetch call anywhere in
// its 94 bytes — contrast its sibling `GetLanguageByIndex 0x49fbe0`, which opens with
// `0x6f34d0`), so the corpus's `GetDefaultLanguage("player")` — `Auctioneer/AucAskPrice.lua:42`
// and `Enchantrix/EnchantrixBarker.lua:149` both write it — is harmlessly ignored, here as
// there.
//
// There is exactly one push in the body and it is `0x6f3890` (push-STRING): the numeric
// language id is consumed as a table index and never reaches Lua, so this is not `(name, id)`
// and not an id. All **four** failure edges — no player object, a negative id, an id past the
// language count, a null record — converge on `0x49fd2a xor eax,eax; ret`, i.e. **zero Lua
// values**. That is shape 2 of the argument ABI ([`super::binding_abi`]) and it is the one
// place in this repo where the distinction is observable: `select('#', GetDefaultLanguage())`
// is `0` outside the world and `1` inside it, while a single-value caller reads `nil` either
// way. Returning `nil` here would be a quiet divergence, so we do not.
//
// **Its sibling is misspelled in the binary, and is deliberately NOT registered here.** The
// `.data` `{const char* name, void* fn}` record at `0x843628` names `0x49fb30`
// **`GetNumLaguages`** — and a whole-image byte search for the correct spelling returns **zero
// hits** (with `GetNumLaguages` and `GetDefaultLanguage` as satisfied positive controls), so
// the typo is the only Lua-visible name and a faithful client would ship it. What is missing
// is not the name but the *answer*: `0x49fb30`'s body was not carved beyond "returns through
// the push-number helper", no corpus addon calls it at all, and registering it would mean
// inventing a count. The name is recorded here so that whoever carves the body knows how to
// spell the global.
lua.globals().set(
"GetDefaultLanguage",
lua.create_function(|lua, _ignored: MultiValue| {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.default_language.clone()
};
Ok(match name {
Some(n) => MultiValue::from_vec(vec![Value::String(lua.create_string(&n)?)]),
None => MultiValue::new(),
})
})?,
)?;
lua.globals().set(
"SendChatMessage",
lua.create_function(
|lua, (text, chat_type, _language, target): (String, Option<String>, Value, Value)| {
// The reference's default is SAY — an addon that passes only a string is saying it.
let chat_type = chat_type
.unwrap_or_else(|| "SAY".into())
.to_ascii_uppercase();
// The fourth argument is a name for WHISPER and a name-or-number for CHANNEL, so
// it arrives as either a string or a number and is normalised to text here rather
// than at every consumer.
let target = match target {
Value::String(s) => Some(s.to_string_lossy()),
Value::Integer(n) => Some(n.to_string()),
Value::Number(n) => Some(format!("{n:.0}")),
_ => None,
};
lua.app_data_mut::<Model>()
.expect("model app_data")
.chat_sends
.push(ChatSend {
text,
chat_type,
target,
});
Ok(())
},
)?,
)?;
Ok(())
}
impl super::UiScript {
/// Drain the lines `SendChatMessage` queued since the last call.
pub fn take_chat_sends(&mut self) -> Vec<ChatSend> {
std::mem::take(&mut self.model_mut().chat_sends)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
/// The default is SAY, the type is uppercased, and **a leading slash is text**.
///
/// That last one is the whole reason this verb has its own queue rather than reusing the chat
/// box's: the box's drain runs the slash grammar, and an addon announcing "/dance" wants the
/// six characters said, not the emote played. The reference splits the same way.
#[test]
fn send_chat_message_queues_a_line_without_parsing_it() {
let mut s = UiScript::new().unwrap();
s.run(r#"SendChatMessage("hello")"#).unwrap();
s.run(r#"SendChatMessage("/dance", "say")"#).unwrap();
assert_eq!(
s.take_chat_sends(),
vec![
ChatSend {
text: "hello".into(),
chat_type: "SAY".into(),
target: None,
},
ChatSend {
text: "/dance".into(),
chat_type: "SAY".into(),
target: None,
},
]
);
// Drained, not re-read.
assert!(s.take_chat_sends().is_empty());
}
/// The fourth argument is a whisper target or a channel, and a channel arrives as either a
/// name or a number — both normalise to text so one consumer handles both.
#[test]
fn the_target_argument_takes_a_name_or_a_channel_number() {
let mut s = UiScript::new().unwrap();
s.run(r#"SendChatMessage("hi", "WHISPER", nil, "Bob")"#)
.unwrap();
s.run(r#"SendChatMessage("lf1m", "CHANNEL", nil, 1)"#)
.unwrap();
s.run(r#"SendChatMessage("lf1m", "CHANNEL", nil, "General")"#)
.unwrap();
let sent = s.take_chat_sends();
assert_eq!(sent[0].target.as_deref(), Some("Bob"));
assert_eq!(sent[1].target.as_deref(), Some("1"));
assert_eq!(sent[2].target.as_deref(), Some("General"));
assert_eq!(sent[1].chat_type, "CHANNEL");
}
/// `language` is accepted and dropped — an addon passing it must not error, and the module
/// doc says why nothing reads it.
#[test]
fn the_language_argument_is_accepted_and_ignored() {
let mut s = UiScript::new().unwrap();
s.run(r#"SendChatMessage("hi", "SAY", 7)"#).unwrap();
assert_eq!(s.take_chat_sends().len(), 1);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **One string, or ZERO values — never `nil`.** The four failure edges reach
/// `0x49fd2a xor eax,eax; ret` *without* passing through `luaL_error`, which is the only place
/// the "returns nothing" shape is real. A single-value caller cannot tell the two apart;
/// `select('#', …)` can, and both corpus callers feed the result straight to
/// `SendChatMessage`, where the difference is an argument that exists versus one that does not.
#[test]
fn get_default_language_is_one_string_or_zero_values() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', GetDefaultLanguage())")
.unwrap(),
0,
"no player object → ZERO values, not nil"
);
s.set_default_language(Some("Common".into()));
assert_eq!(
s.eval::<i64>("return select('#', GetDefaultLanguage())")
.unwrap(),
1,
"one value — not (name, id)"
);
assert_eq!(
s.eval::<String>("return GetDefaultLanguage()").unwrap(),
"Common"
);
// The binding reads NO arguments; the corpus passes `"player"` anyway and must not error.
assert_eq!(
s.eval::<String>(r#"return GetDefaultLanguage("player")"#)
.unwrap(),
"Common"
);
}
}