Files
benilla-octo/crates/benilla-ui/src/script/binding_abi.rs
T
Sam SungandClaude Opus 5.5 985dabff44 Layout-mode drag, title press takes the slot, links are their own button (#961)
A title-region press refused while another drag held the slot, `SetLayoutMode` stored nothing, and a press on a chat link made the chat frame itself the target, so every link click started a title move of the window.

In 1.12.1 the drag entry takes the slot unread (`0x7652b0`); a mid-drag press goes to the frozen hover (`0x76637f`). With `SetLayoutMode` (`0x488540`) on, a Ctrl or Alt press drags the nearest movable toplevel (`0x765490`) from a 3×3 grip grid (`0x765320`) and is consumed, world included; its release only ends the drag (`0x7664e0`). Each link span is its own Button (`0x7a3240`) routing `OnHyperlinkEnter`/`Leave`/`Click` to the parent (`0x7a3780`–`0x7a37e0`).

These now hold; a release goes to the capture whatever its button (`0x76642b`), and number arguments keep `_ftol`'s low dword.

Tests bite on the parent for each.

Fixes #951
Fixes #952
Fixes #957

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

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

336 lines
14 KiB
Rust

//! The argument and error ABI every registered Lua C-binding of the 1.12 client opens with.
//!
//! The reference's helpers: `is-number` (`0x6f34d0`) accepts tag 3 or a string coerced through
//! `0x6f7c20`; `is-number-or-string` (`0x6f3510`) accepts tag 3 or 4; `tonumber` (`0x6f3620`)
//! feeds `0x40a2b0`, a double-to-int32 cast that truncates toward zero (the x87 rounding control
//! set to chop), so -2.9 is -2.
//!
//! A bad argument raises: `0x6f4940` is `luaL_error`, which never returns (it ends in `longjmp` or
//! `exit(1)` through `0x6f4440`, `0x6fc780`, `0x6f5d80`), so the `xor eax,eax; ret` after it is
//! unreachable and the caller's statement is abandoned. An empty answer is settled per binding:
//!
//! 1. push nil, one value (`UnitAffectingCombat` on a unit out of combat, or a token naming none);
//! 2. zero values, distinct from nil to anything counting the returns (`arg.n`), reached only by a
//! `xor eax,eax; ret` that skips `0x6f4940` (`GetDefaultLanguage`'s failure edges);
//! 3. raise.
use mlua::{Lua, Value};
/// `_ftol` (`0x40a2b0`) as the bindings consume it: the x87 rounding control set to chop, a
/// `fistp` to a qword, and its low dword. What the qword cannot hold (an infinity, NaN, ±2^63 and
/// beyond) stores the integer-indefinite pattern `0x8000_0000_0000_0000`, whose low dword is 0.
pub(crate) fn ftol(n: f64) -> i32 {
const TWO_63: f64 = 9_223_372_036_854_775_808.0;
let t = n.trunc();
if (-TWO_63..TWO_63).contains(&t) {
t as i64 as i32
} else {
0
}
}
/// Shape A: `is-number` (`0x6f34d0`), `tonumber` (`0x6f3620`), then the truncating `0x40a2b0`
/// ([`ftol`]), raising the binding's `Usage:` on failure. A missing argument fails like a
/// wrong-typed one (`0x6f3410` returns NULL past `L->top`); a numeric string passes.
pub(crate) fn number_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<i32> {
match lua.coerce_number(v)? {
Some(n) => Ok(ftol(n)),
None => Err(mlua::Error::RuntimeError(usage.into())),
}
}
/// Shape D: an `is-number`-gated flag whose failure keeps its zero instead of raising. The value
/// must be strictly `> 0.0` (`0x53303f`), so 0, a negative and NaN are false and `0.5` or `"1"`
/// true; `SetInventoryItem`'s `nameOnly` is one.
pub(crate) fn positive_number_flag(lua: &Lua, v: Value) -> mlua::Result<bool> {
Ok(lua.coerce_number(v)?.is_some_and(|n| n > 0.0))
}
/// Shape C: a bare `lua_tonumber` (`0x6f3620`) with no `is-number` guard, so it cannot fail:
/// anything but a number or numeric string reads as 0.0. The reference uses it for colour and
/// coordinate tuples (`Set*Color`, `SetTexCoord`, `SetPosition`) and shape A for scalar setters
/// (`SetAlpha`, `SetWidth`, `SetValue`, `SetID`); which applies is per binding.
pub(crate) fn coerced_number(lua: &Lua, v: Option<Value>) -> f64 {
v.and_then(|v| lua.coerce_number(v).ok().flatten())
.unwrap_or(0.0)
}
/// `is-number-or-string` (`0x6f3510`) then `0x6f3690`, raising the binding's `Usage:` on failure:
/// a number is stringified (`UnitAffectingCombat(5)` passes here, then the unit resolver raises
/// `Unknown unit name` for `"5"`, `0x515c14`), and nil, a boolean, a table or a function raises.
pub(crate) fn string_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<String> {
match lua.coerce_string(v)? {
// Lossy: a Lua string is bytes, so invalid UTF-8 costs a glyph, never the call.
Some(s) => Ok(s.to_string_lossy()),
None => Err(mlua::Error::RuntimeError(usage.into())),
}
}
/// [`string_arg`]'s shape-C partner, for a binding that does not test the parse: a string or a
/// number is coerced (`CreateFrame("Frame", 5)` names the frame `"5"`), anything else is absent.
/// `CreateFrame`'s `name` and `inherits` and `CreateFontString`/`CreateTexture`'s `name` and
/// `layer` take this; `Model:SetModel`, which tests it, takes [`string_arg`]. A caller that must
/// refuse a number (the fourth argument of `CreateTexture`/`CreateFontString`) checks the tag.
pub(crate) fn optional_string(lua: &Lua, v: &Value) -> Option<String> {
match v {
// Lossy: invalid UTF-8 must not read as an absent argument.
Value::String(_) | Value::Number(_) | Value::Integer(_) => lua
.coerce_string(v.clone())
.ok()
.flatten()
.map(|s| s.to_string_lossy()),
_ => None,
}
}
/// A free-text argument for the text sinks (`SetText`, `SetFormattedText`, `EditBox:Insert`):
/// `Option<String>`'s conversion, except that a string that is not valid UTF-8 becomes lossy text
/// instead of raising, as the reference takes bytes.
pub(crate) fn text_arg(lua: &Lua, v: Option<Value>) -> mlua::Result<Option<String>> {
match v {
None | Some(Value::Nil) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.to_string_lossy())),
Some(other) => mlua::FromLua::from_lua(other, lua).map(Some),
}
}
/// Lua's number-to-string as the 1.12 client compiles it: MSVC's `sprintf("%.14g")`
/// (`luaV_tostring` `0x6f7c80`, format `0x871960`). `EditBox:SetNumber` (`0x798690`) formats
/// through it too, being byte-identical to `SetText` (`0x7984c0`). The exponent has at least three
/// digits: `1e20` prints `1e+020`.
///
/// Deviation: the result is correctly rounded, where the reference rounds to 17 digits half-up and
/// then again to 14, differing in the last digit on about 0.05% of doubles, because no consumer
/// reads that digit.
pub(crate) fn lua_number_text(v: f64) -> String {
if v.is_nan() {
// MSVC's own spellings, which are not "NaN".
return if v.is_sign_negative() {
"-1.#IND".into()
} else {
"1.#QNAN".into()
};
}
if v.is_infinite() {
return if v < 0.0 {
"-1.#INF".into()
} else {
"1.#INF".into()
};
}
if v == 0.0 {
// `%g` prints negative zero without the sign.
return "0".into();
}
const SIG: i32 = 14;
let exp = v.abs().log10().floor() as i32;
// Re-derive the exponent from the rounded form: 9.9999e2 rounds to 1e3 and changes style.
let exp = {
let probe = format!("{:.*e}", (SIG - 1) as usize, v);
probe
.rsplit('e')
.next()
.and_then(|e| e.parse::<i32>().ok())
.unwrap_or(exp)
};
// C's `%g` style rule verbatim: exponential when `exp < -4 || exp >= P`, else fixed.
if !(-4..SIG).contains(&exp) {
let mantissa = format!("{:.*e}", (SIG - 1) as usize, v);
let (m, _) = mantissa.split_once('e').unwrap_or((mantissa.as_str(), "0"));
let m = trim_g(m);
let sign = if exp < 0 { '-' } else { '+' };
// At least three exponent digits, more when needed.
format!("{m}e{sign}{:03}", exp.abs())
} else {
let decimals = (SIG - 1 - exp).max(0) as usize;
trim_g(&format!("{v:.decimals$}")).to_string()
}
}
/// Strip `%g`'s trailing zeros, and the decimal point if nothing follows it.
fn trim_g(s: &str) -> &str {
if !s.contains('.') {
return s;
}
s.trim_end_matches('0').trim_end_matches('.')
}
/// `GetBoolOrDefault` (`0x6f1c10`, jump table `0x6f1ce8`), the reference's boolean argument
/// coercion, which is not Lua truthiness:
///
/// - absent (`None`, `LUA_TNONE`) takes `default`, but nil is false; a binding whose default is
/// true takes a `MultiValue`, since mlua turns a missing argument into nil
/// - a boolean is itself; a number truncates toward zero (`0x40a2b0`), then `!= 0`
/// - a string goes by its first byte, through the remap table at `0x6f1d08`: `'0' 'F' 'N' 'f' 'n'`
/// false, `'1'..='9' 'T' 'Y' 't' 'y'` true; any other is compared whole, case-insensitively
/// (`0x64a4c0`): `"off"`/`"disabled"` false, `"on"`/`"enabled"` true, anything else `default`
/// - lightuserdata, a table, function, userdata or thread takes `default`
///
/// So `"0"` is false and `"0.5"` goes by its first byte: the Interface panel passes its options
/// the strings `"0"` and `"1"`.
pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
let Some(v) = v else {
return default; // LUA_TNONE
};
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::LightUserData(_) => default,
// Truncated like `0x40a2b0`, testing only the low dword.
Value::Integer(i) => (*i as i32) != 0,
Value::Number(n) => (*n as i64 as i32) != 0,
Value::String(s) => {
let bytes = s.as_bytes();
match bytes.first() {
Some(b'0' | b'F' | b'N' | b'f' | b'n') => false,
Some(b'1'..=b'9' | b'T' | b'Y' | b't' | b'y') => true,
// The keyword arm, also reached by every byte the remap table cannot index (and
// by an empty string).
_ => {
if bytes.eq_ignore_ascii_case(b"off") || bytes.eq_ignore_ascii_case(b"disabled")
{
false
} else if bytes.eq_ignore_ascii_case(b"on")
|| bytes.eq_ignore_ascii_case(b"enabled")
{
true
} else {
default
}
}
}
}
_ => default,
}
}
/// The 1.12 predicate return: `1` for true, nil for false, never a Lua boolean. The widget and
/// `Unit*` predicates push the number 1 (`0x6f3810`) or nil (`0x6f37f0`); outside the base library
/// (`rawequal`, `pcall`, `xpcall`), the one binding that calls `lua_pushboolean` (`0x6f39f0`) is
/// `IsPetAttackActive` (`0x4be0e0`). Direct comparisons see it: stock `UIOptionsFrame.xml:310`
/// saves `tostring(this:GetChecked())` and `BuffFrame.lua:71` compares it with `"1"`. The false
/// leg is per binding: `IsEnabled` (`0x7800b0`) and numeric getters such as `UnitLevel`
/// (`0x518144`) answer 0 and do not use this.
pub(crate) fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lua() -> Lua {
Lua::new()
}
/// `_ftol`'s low dword: chopped toward zero, wrapped to 32 bits, and 0 for whatever the qword
/// `fistp` cannot hold.
#[test]
fn ftol_keeps_the_low_dword_of_the_x87_qword() {
assert_eq!(ftol(-2.9), -2);
assert_eq!(ftol(2.9), 2);
assert_eq!(ftol(3e9), 3_000_000_000_i64 as i32);
assert_eq!(ftol(4_294_967_301.0), 5);
assert_eq!(ftol(-9_223_372_036_854_775_808.0), 0);
for none in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, 9.3e18, -9.3e18] {
assert_eq!(ftol(none), 0, "{none}");
}
}
#[test]
fn bool_or_default_is_not_lua_truthiness() {
let lua = lua();
let st = |t: &str| Value::String(lua.create_string(t).unwrap());
// Absent takes the default; an explicit nil is false.
assert!(bool_or_default(None, true));
assert!(!bool_or_default(None, false));
assert!(!bool_or_default(Some(&Value::Nil), true));
assert!(bool_or_default(Some(&Value::Boolean(true)), false));
assert!(!bool_or_default(Some(&Value::Boolean(false)), true));
// Numbers truncate toward zero: `0.5` is off.
assert!(!bool_or_default(Some(&Value::Number(0.5)), true));
assert!(!bool_or_default(Some(&Value::Number(-0.5)), true));
assert!(!bool_or_default(Some(&Value::Number(-0.999)), true));
assert!(bool_or_default(Some(&Value::Number(1.5)), false));
assert!(bool_or_default(Some(&Value::Number(-1.5)), false));
assert!(!bool_or_default(Some(&Value::Integer(0)), true));
assert!(bool_or_default(Some(&Value::Integer(7)), false));
// `"0"` is false, where Lua truthiness says true.
assert!(!bool_or_default(Some(&st("0")), true));
assert!(bool_or_default(Some(&st("1")), false));
// First byte only: `"0.5"` is false because it starts with `'0'`.
assert!(!bool_or_default(Some(&st("0.5")), true));
assert!(bool_or_default(Some(&st("9lives")), false));
for t in ["false", "F", "no", "NIL", "nope"] {
assert!(
!bool_or_default(Some(&st(t)), true),
"{t} starts F/N → false"
);
}
for t in ["true", "T", "yes", "Yup"] {
assert!(
bool_or_default(Some(&st(t)), false),
"{t} starts T/Y → true"
);
}
// The keyword arm, whole-string and case-insensitive.
assert!(!bool_or_default(Some(&st("off")), true));
assert!(!bool_or_default(Some(&st("OFF")), true));
assert!(!bool_or_default(Some(&st("Disabled")), true));
assert!(bool_or_default(Some(&st("on")), false));
assert!(bool_or_default(Some(&st("ENABLED")), false));
// Unrecognised strings, and every byte the remap table cannot index, take the default.
for t in ["", "-1", "?", "maybe", "@"] {
assert!(bool_or_default(Some(&st(t)), true), "{t:?} → default true");
assert!(
!bool_or_default(Some(&st(t)), false),
"{t:?} → default false"
);
}
// A table takes the default, like an absent argument.
let tbl = Value::Table(lua.create_table().unwrap());
assert!(bool_or_default(Some(&tbl), true));
assert!(!bool_or_default(Some(&tbl), false));
}
#[test]
fn number_arg_truncates_toward_zero_not_floor() {
let lua = lua();
assert_eq!(number_arg(&lua, Value::Number(2.9), "u").unwrap(), 2);
assert_eq!(number_arg(&lua, Value::Number(-2.9), "u").unwrap(), -2);
assert_eq!(number_arg(&lua, Value::Number(-0.5), "u").unwrap(), 0);
}
#[test]
fn number_arg_accepts_a_numeric_string_and_raises_on_everything_else() {
let lua = lua();
let s = lua.create_string("7").unwrap();
assert_eq!(number_arg(&lua, Value::String(s), "u").unwrap(), 7);
// Missing and wrong-typed arguments take the same raise.
for v in [Value::Nil, Value::Boolean(true)] {
let err = number_arg(&lua, v, "Usage: Thing(n)").unwrap_err();
assert!(format!("{err}").contains("Usage: Thing(n)"));
}
}
#[test]
fn string_arg_accepts_a_number_and_raises_on_nil() {
let lua = lua();
assert_eq!(string_arg(&lua, Value::Integer(5), "u").unwrap(), "5");
let err = string_arg(&lua, Value::Nil, "Usage: Thing(\"s\")").unwrap_err();
assert!(format!("{err}").contains("Usage: Thing(\"s\")"));
}
}