Files
benilla-octo/crates/benilla-ui/src/script/slash.rs
T
Sam SungandClaude Opus 5.5 05e204de10 benilla's layer speaks the 1.12 API alone: handlers take this and no arguments, GetBinding answers the 1.12 list, the Key Bindings page captures through its window, /errors chains the error handler, and the host hooks leave the VM (#569)
benilla's layer now uses only the 1.12 API and the 1.12 Lua forms, and the host hooks it used are gone from the VM.

- **The VM calls handlers as 1.12.1 does.** A handler body is compiled as the chunk itself, so `self` in it is an ordinary global (`0x704c70`). A handler is called with no arguments (`0x705199`). The loader reads no `function=` attribute.
- **`GetBinding`/`GetNumBindings` answer the 1.12 list** (`0x4b7f60`): `HEADER_*` section rows, plus every key bound to a command, with no category column. The stock `KeyBindingFrame` now shows its section headers and both key columns.
- **The Key Bindings page captures keys through its own window**, as `Blizzard_BindingUI` does: the frame's `OnKeyDown` for keys, clicks for mouse buttons, and the wheel. It takes the `Benilla` prefix, so loading the stock binding addon no longer breaks it. The UI pointer feed now hands frames mouse buttons 4 and 5.
- **`/errors` keeps its log by chaining the Lua error handler**, and the stock ScriptErrors dialog is unchanged. Load failures that never raise now print one chat line each at world entry.
- **Slash commands:** `/reload` and `/errors` are `SlashCmdList` rows in the layer. `/convertraid` is gone; 1.12.1 has no such command. The dev commands are host rows in dev builds only, so a player build answers them with the unknown-command line once.
- **Naming:** every global the layer defines is `Benilla`-prefixed. `BenillaBindCapture`, `KeyBindings_OnHostKey`, the three script-error globals and `SubmitChatInput` are removed.

Verified:
- New tests for each rule and each removed surface; 16 mutations each fail a test.
- Region dumps of the Options window (general page, Key Bindings, armed) are identical before and after.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run bound a key, a Shift chord and the middle button through the page, and opened `/errors`.

Built on macOS; no platform seam touched.

Fixes #539
Fixes #544
Fixes #407
Part of #171

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:12:27 +07:00

179 lines
6.8 KiB
Rust

//! `SlashCmdList` dispatch from the host, for a line that reaches it without passing the stock
//! `ChatEdit_ParseText` (`ChatFrame.lua`): the same walk, over the table the stock `ChatFrame.lua`
//! declares, where the stock commands, benilla's layer's and an addon's are entries.
use mlua::{Table, Value};
impl super::UiScript {
/// Dispatch `/cmd args` through `SlashCmdList`, returning whether a handler ran. The host calls
/// this after its own commands miss, so an addon never shadows one; a handler that raises is
/// recorded as a script error, not propagated, and still counts as handled.
pub fn run_slash_command(&mut self, cmd: &str, args: &str) -> bool {
let Some(handler) = self.find_slash_handler(cmd) else {
return false;
};
// The rest of the line after the alias and one space, spaces intact: the reference's
// `strsub(text, strlen(cmdString) + 2)`.
if let Err(e) = handler.call::<()>(args.to_string()) {
self.model_mut().record_script_error(format!("/{cmd}: {e}"));
}
true
}
/// Whether a `SlashCmdList` entry claims `cmd`, without running it.
pub fn has_slash_command(&self, cmd: &str) -> bool {
self.find_slash_handler(cmd).is_some()
}
/// Register a host command as a `SlashCmdList` row, as an addon registers one: `SLASH_<key><n>`
/// for each alias (with its `/`) and a handler that queues `"<first alias> <msg>"` on the chat
/// input the host drains ([`Self::take_chat_input`]). A no-op before `ChatFrame.lua` has
/// declared `SlashCmdList`.
pub fn register_host_slash_command(&self, key: &str, aliases: &[&str]) -> mlua::Result<()> {
let globals = self.lua.globals();
let Ok(list) = globals.get::<Table>("SlashCmdList") else {
return Ok(());
};
let Some(first) = aliases.first() else {
return Ok(());
};
for (n, alias) in aliases.iter().enumerate() {
globals.set(format!("SLASH_{key}{}", n + 1), *alias)?;
}
let first = first.to_string();
let handler = self.lua.create_function(move |lua, msg: Option<String>| {
let line = format!("{first} {}", msg.unwrap_or_default());
lua.app_data_mut::<super::Model>()
.expect("model app_data")
.chat_input
.push(line);
Ok(())
})?;
list.set(key, handler)
}
/// The reference's walk: over `SlashCmdList`'s keys, then `SLASH_<key><n>` to the first gap,
/// case-insensitive.
fn find_slash_handler(&self, cmd: &str) -> Option<mlua::Function> {
let globals = self.lua.globals();
let list: Table = globals.get("SlashCmdList").ok()?;
let want = cmd.to_ascii_uppercase();
for pair in list.pairs::<String, Value>() {
let Ok((index, Value::Function(handler))) = pair else {
continue; // Deviation: skipped because it is no command; the reference raises
};
for n in 1.. {
let alias: Option<String> = globals.get(format!("SLASH_{index}{n}")).ok().flatten();
let Some(alias) = alias else { break };
let alias = alias.trim_start_matches('/');
if alias.eq_ignore_ascii_case(&want) {
return Some(handler);
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
#[test]
fn an_addon_registers_a_slash_command_and_it_dispatches() {
let mut s = UiScript::new().unwrap();
// A bare VM has no `ChatFrame.xml`, so the test declares the table itself.
s.run("SlashCmdList = {}").unwrap();
s.run(
r#"
SLASH_PROBE1 = "/probe"
SLASH_PROBE2 = "/pr"
SlashCmdList["PROBE"] = function(msg) ProbeGot = msg end
"#,
)
.unwrap();
assert!(s.run_slash_command("probe", "set foo 3"));
assert_eq!(
s.eval::<String>("return ProbeGot").unwrap(),
"set foo 3",
"the handler gets the rest of the LINE, spaces intact — its own parser starts there"
);
// The second alias reaches the same handler, and matching is case-insensitive.
assert!(s.run_slash_command("PR", "again"));
assert_eq!(s.eval::<String>("return ProbeGot").unwrap(), "again");
// An unclaimed command is not handled, so the host still prints HELP_TEXT_SIMPLE.
assert!(!s.run_slash_command("nosuchthing", ""));
}
/// The reference's `while cmdString` loop.
#[test]
fn the_alias_walk_stops_at_the_first_gap() {
let s = UiScript::new().unwrap();
s.run("SlashCmdList = {}").unwrap();
s.run(
r#"
SLASH_GAPPY1 = "/one"
SLASH_GAPPY3 = "/three"
SlashCmdList["GAPPY"] = function() end
"#,
)
.unwrap();
assert!(s.has_slash_command("one"));
assert!(
!s.has_slash_command("three"),
"SLASH_GAPPY3 is unreachable without SLASH_GAPPY2 — in the reference too"
);
}
#[test]
fn a_raising_handler_is_collected_and_still_counts_as_handled() {
let mut s = UiScript::new().unwrap();
s.run("SlashCmdList = {}").unwrap();
s.run(
r#"
SLASH_BOOM1 = "/boom"
SlashCmdList["BOOM"] = function() error("nope") end
"#,
)
.unwrap();
assert!(
s.run_slash_command("boom", ""),
"the command existed and ran — that it failed is a separate fact"
);
assert!(
s.errors().iter().any(|e| e.contains("/boom")),
"the failure surfaces where every other script error does"
);
}
/// A host row is an ordinary `SlashCmdList` row that queues its line for the host.
#[test]
fn a_host_command_queues_its_line() {
let mut s = UiScript::new().unwrap();
s.register_host_slash_command("HOSTPROBE", &["/hostprobe", "/hp"])
.unwrap();
assert!(
!s.has_slash_command("hostprobe"),
"no table yet: nothing registers"
);
s.run("SlashCmdList = {}").unwrap();
s.register_host_slash_command("HOSTPROBE", &["/hostprobe", "/hp"])
.unwrap();
assert_eq!(s.eval::<String>("return SLASH_HOSTPROBE2").unwrap(), "/hp");
assert!(s.run_slash_command("hp", "a b"));
assert_eq!(s.take_chat_input(), ["/hostprobe a b"]);
}
#[test]
fn a_non_function_entry_is_not_a_command() {
let s = UiScript::new().unwrap();
s.run("SlashCmdList = {}").unwrap();
s.run(r#"SLASH_ODD1 = "/odd" SlashCmdList["ODD"] = "not a function""#)
.unwrap();
assert!(!s.has_slash_command("odd"));
}
}