Files
benilla-octo/crates/benilla-ui/src/script/chat_misc.rs
T
Sam SungandClaude Opus 5.5 1efd9a7163 Lua bindings coerce numbers, flags and colours as 1.12.1 does (#991)
Lua bindings now read their arguments the way the 1.12.1 client does:

- Numeric strings parse as Storm's `SStrToInt` (`0x64ac60`) and `SStrToFloat` (`0x64aaa0`). That covers channel keys, `GetNumber` and `item:` fields.
- Flag arguments go through `GetBoolOrDefault` (`0x6f1c10`) with each site's default. That is about 40 bindings, `SetChecked()` and `ShowHelm()` among them.
- Colour channels take numeric strings (`lua_tonumber`), and alphas are `isnumber`-gated: no raise, 1.0 by default. A three-argument `SetVertexColor` keeps the current alpha (`0x79ae0a`). So action-bar rings dimmed by a drag stay dimmed, as in 1.12.1.
- `SetTexture("1")` takes the colour form. MessageFrame channels follow `0x795590`.
- `LoggingChat`/`LoggingCombat` return the flag.

Binding-level tests use the reference's values.

Fixes #135
Fixes #144
Fixes #160
Fixes #161

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:41:47 +07:00

305 lines
12 KiB
Rust

//! The engine verbs stock `ChatFrame.lua`'s slash handlers call that are not sends, channels or
//! window state: `DoEmote` (`0x49fd30`), `RandomRoll` (`0x48c7b0`), `AssistByName` (`0x489c40`),
//! `UninviteByName` (`0x48a610`), `ConsoleExec`, `LoggingChat` and `LoggingCombat`. Each is a
//! queue or a flag the app drains.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::bool_or_default;
use super::Model;
/// `DoEmote(token [, target])`: the `EmotesText.dbc` name token (`"WAVE"`, not `/wave`) and an
/// optional target string (`0x49fd30` reads arg 2 with `lua_tostring`): a player name, or a `%`
/// word for the selection. A non-string, non-number second argument reads as none.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EmoteRequest {
pub token: String,
pub target: Option<String>,
}
/// The reference's `lua_tonumber` coercion: a number or a numeric string; anything else is 0.
fn to_u32(v: &Value) -> u32 {
match v {
Value::Integer(i) => u32::try_from(*i).unwrap_or(0),
Value::Number(n) if n.is_finite() && *n >= 0.0 => *n as u32,
Value::String(s) => s
.to_str()
.ok()
.and_then(|s| s.trim().parse::<f64>().ok())
.filter(|n| n.is_finite() && *n >= 0.0)
.map_or(0, |n| n as u32),
_ => 0,
}
}
impl super::UiScript {
/// `DoEmote` calls since the last drain.
pub fn take_emote_requests(&mut self) -> Vec<EmoteRequest> {
std::mem::take(&mut self.model_mut().emote_requests)
}
/// `RandomRoll(min, max)` calls since the last drain, each a `CMSG_RANDOM_ROLL`.
pub fn take_roll_requests(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().roll_requests)
}
/// `UninviteByName` calls since the last drain.
pub fn take_uninvite_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().uninvite_requests)
}
/// `ConsoleExec` lines that write no CVar: the app's console commands (`reloadui`, …).
pub fn take_console_lines(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().console_lines)
}
/// `(chat, combat)`, as `LoggingChat` and `LoggingCombat` last set them.
pub fn logging_flags(&self) -> (bool, bool) {
let model = self.model_mut();
(model.logging_chat, model.logging_combat)
}
/// Whether a logging flag moved since the last drain: the app's cue to open or close its logs.
pub fn take_logging_changes(&mut self) -> bool {
std::mem::take(&mut self.model_mut().logging_changed)
}
}
/// `ConsoleExec` (`0x515f10`) is in the registrar both states call (`0x51bb20`, from the glue
/// registrar `0x46abb0` and the in-game `0x51bb60`), so it installs apart from this file's in-game
/// verbs.
pub(super) fn install_console_exec(lua: &Lua) -> mlua::Result<()> {
// `ConsoleExec("name value")`: a registered CVar with a value is written at once, as `SetCVar`
// writes it; anything else, a bare CVar name included (the reference answers it with
// `CVar "%s" is "%s"`, `0x63dde0`), goes to the app's command registry.
lua.globals().set(
"ConsoleExec",
lua.create_function(|lua, line: Option<String>| {
let Some(line) = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) else {
return Ok(());
};
let (name, value) = line
.split_once(char::is_whitespace)
.map_or((line.as_str(), ""), |(n, v)| (n, v.trim()));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let is_cvar = model.cvars.contains_key(&name.to_ascii_lowercase());
if is_cvar && !value.is_empty() {
super::cvars::write_cvar(&mut model, name, value.to_string());
} else {
model.console_lines.push(line.clone());
}
Ok(())
})?,
)?;
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"DoEmote",
lua.create_function(|lua, (token, target): (Option<String>, Value)| {
let Some(token) = token.filter(|t| !t.is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.emote_requests.push(EmoteRequest {
token: token.to_ascii_uppercase(),
target: match target {
Value::String(s) => Some(s.to_string_lossy()),
Value::Integer(i) => Some(i.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
.filter(|t| !t.is_empty()),
});
Ok(())
})?,
)?;
g.set(
"RandomRoll",
lua.create_function(|lua, (min, max): (Value, Value)| {
let (min, max) = (to_u32(&min), to_u32(&max));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.roll_requests.push((min, max));
Ok(())
})?,
)?;
g.set(
"AssistByName",
lua.create_function(|lua, name: Option<String>| {
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.script_calls.push(super::ScriptCall::Select(
super::SelectionRequest::AssistByName(name),
));
Ok(())
})?,
)?;
g.set(
"UninviteByName",
lua.create_function(|lua, name: Option<String>| {
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.uninvite_requests.push(name);
Ok(())
})?,
)?;
for (name, chat) in [("LoggingChat", true), ("LoggingCombat", false)] {
g.set(
name,
lua.create_function(move |lua, args: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// With an argument (`lua_gettop > 0`, `0x49fe57`) the flag is set from
// `GetBoolOrDefault` (`0x49fe69`, default 1), so an explicit nil clears it. Both
// paths then answer the flag as it stands, 1 or nil, one value (`0x49feb2`-`0x49fee1`):
// the reference re-reads it after the log-open attempt, which can clear it
// (`0x49feab`); the app owns that attempt here.
if let Some(on) = args.front().map(|v| bool_or_default(Some(v), true)) {
let slot = if chat {
&mut model.logging_chat
} else {
&mut model.logging_combat
};
if *slot != on {
*slot = on;
model.logging_changed = true;
}
}
let now = if chat {
model.logging_chat
} else {
model.logging_combat
};
Ok(if now { Value::Integer(1) } else { Value::Nil })
})?,
)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::{SelectionRequest, UiScript};
#[test]
fn do_emote_queues_the_uppercased_token_and_a_non_empty_target() {
let mut s = UiScript::new().unwrap();
s.run("DoEmote('wave', 'Bob') DoEmote('DANCE', '') DoEmote('') DoEmote()")
.unwrap();
assert_eq!(
s.take_emote_requests(),
vec![
EmoteRequest {
token: "WAVE".into(),
target: Some("Bob".into())
},
EmoteRequest {
token: "DANCE".into(),
target: None
},
]
);
assert!(s.take_emote_requests().is_empty(), "drained");
}
/// The second argument is a string, never a unit token: `"target"` is a name like any other,
/// and `%t` reaches the app's selection arm untouched (`0x49fdb1`).
#[test]
fn do_emote_second_argument_is_passed_as_a_string() {
let mut s = UiScript::new().unwrap();
s.run("DoEmote('WAVE', 'target') DoEmote('WAVE', '%t') DoEmote('WAVE', {})")
.unwrap();
let targets: Vec<_> = s
.take_emote_requests()
.into_iter()
.map(|e| e.target)
.collect();
assert_eq!(
targets,
vec![Some("target".into()), Some("%t".into()), None]
);
}
#[test]
fn random_roll_coerces_the_strings_the_stock_handler_passes() {
let mut s = UiScript::new().unwrap();
s.run("RandomRoll('1', '100') RandomRoll(5, 10) RandomRoll('x', nil)")
.unwrap();
assert_eq!(s.take_roll_requests(), vec![(1, 100), (5, 10), (0, 0)]);
}
#[test]
fn assist_and_uninvite_by_name_queue_their_names() {
let mut s = UiScript::new().unwrap();
s.run("AssistByName('Alice') AssistByName('') UninviteByName('Carol') UninviteByName()")
.unwrap();
assert_eq!(
s.take_selection_requests(),
vec![SelectionRequest::AssistByName("Alice".into())]
);
assert_eq!(s.take_uninvite_requests(), vec!["Carol".to_string()]);
}
#[test]
fn console_exec_writes_a_registered_cvar_and_queues_anything_else() {
let mut s = UiScript::new().unwrap();
s.register_cvars([("uiScale", "1")]);
s.run("ConsoleExec('uiscale 0.8') ConsoleExec('reloadui') ConsoleExec(' ') ConsoleExec('uiScale')")
.unwrap();
assert_eq!(
s.eval::<String>("return GetCVar('uiScale')").unwrap(),
"0.8",
"the CVar store is the same one SetCVar writes, matched case-insensitively"
);
// A bare CVar name, whose value the reference prints, goes to the command lane.
assert_eq!(
s.take_console_lines(),
vec!["reloadui".to_string(), "uiScale".to_string()]
);
assert_eq!(
s.take_cvar_changes(),
vec![("uiScale".to_string(), "0.8".to_string())],
"a bare name writes nothing"
);
}
#[test]
fn the_logging_flags_read_as_one_or_nil_and_set_from_any_truthy_arg() {
let mut s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return LoggingChat() == nil").unwrap());
assert!(s.eval::<bool>("return LoggingCombat() == nil").unwrap());
assert_eq!(
s.arity("LoggingChat(true)").unwrap(),
1,
"the setter answers the flag too (`0x49fed3`)"
);
assert_eq!(s.eval::<i64>("return LoggingChat()").unwrap(), 1);
assert_eq!(s.logging_flags(), (true, false));
assert!(s.take_logging_changes());
s.run("LoggingCombat(1) LoggingChat(false)").unwrap();
assert_eq!(s.logging_flags(), (false, true));
assert!(s.take_logging_changes());
s.run("LoggingCombat(true)").unwrap();
assert!(
!s.take_logging_changes(),
"a write that moves nothing cues nothing"
);
// The setter answers the flag it leaves: 1 or nil, one value either way.
assert_eq!(s.eval::<i64>("return LoggingChat(true)").unwrap(), 1);
assert_eq!(s.arity("LoggingChat(nil)").unwrap(), 1);
assert!(s.eval::<bool>("return LoggingChat(nil) == nil").unwrap());
}
}