Files
moonwell-client-core/crates/benilla-ui/src/script/lua50.rs
T
Sam Sung ab15e9c933 Script errors reach the error handler at the raise, as 1.12.1 does (#2674)
A script error reached the `seterrorhandler` function once per frame, from a queue, after every other listener had run. In 1.12.1 the handler is the error function of the pcall each script runner makes (`0x704e4f`-`0x704e79`; the error function `0x703b40`). So it runs at the raise, inside the failing script: `this`, `event` and `arg1` are still the failing handler's, `debugstack()` shows the failing call, and the event's next listener has not run yet.

Every benilla runner now passes that error function: handlers, OnLoad, file chunks, RunScript, key bindings and slash commands. It returns the handler's result, as `0x703b40` does. The queue is gone.

A handler body that fails to compile reaches no handler (`0x704c70`). benilla's host log keeps each original fault, and `/errors` replays the ones raised before benilla's layer loaded.

Tests cover the raise-time globals, the stack, the listener order, OnUpdate and chunks.

Fixes #2656

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

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

1080 lines
44 KiB
Rust

//! The Lua 5.0 dialect: what the script host sets over the client-shaped state benilla-lua opens.
//!
//! The reference embeds Lua 5.0 (`$Lua: Lua 5.0` at `0x811b30`), and `InitLua 0x7039e0` opens
//! five libraries: base (`0x811e28`, 36 entries), `string` (`0x822d88`, 12), `table`
//! (`0x822d40`, 8), `math` (`0x822c60`, 24) and `bit` (`0x822c18`, 8), with no `os`, `io`,
//! `debug` or `coroutine`. The grammar is benilla-lua's (`crates/benilla-lua/GRAMMAR.md`).
//!
//! `...` is a parameter-list token only: a vararg function reads its arguments through 5.0's
//! `arg` table (`arg.n`, `unpack(arg)`). So Lua written here forwards an unknown argument list
//! only through a table, and a verb whose arity matters (`GetPoint()` against `GetPoint(nil)`)
//! stays in Rust.
use mlua::Lua;
/// Lua 5.0's remembered table size and the `n`-based table library that reads and updates it,
/// which are benilla-lua's own natives.
#[cfg(test)]
mod table_size;
/// Install the dialect: the string type without a metatable and the `__pow` global. The bare
/// aliases are not here; the state's own `compat.lua` chunk defines them.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// The state opens as the client's (`benilla_lua::new_state`): no 5.1 member, `coroutine`,
// `select`, `print` or `_VERSION` is in it, and none is set to nil here, which would make
// the key (5.0's `luaH_set` makes one even for nil) and move the layout.
// 1.12's string type has no metatable, so `("x"):upper()` raises `attempt to index a string
// value`. There it cannot carry one: `luaT_gettmbyobj 0x6f7bd0` finds metamethods only on
// tables and userdata, `lua_setmetatable 0x6f4020` writes only those two tags, and
// `luaopen_string 0x7fd810` installs none, where 5.1's does. Base `setmetatable` (`0x702a40`)
// rejects a non-table in both.
lua.set_type_metatable::<mlua::String>(None);
// `__pow` is a 1.12 global, `luaopen_math`'s `math_pow` (`0x7fb4c4`), which `^` on two
// numbers calls (`0x6f9b52`).
g.set(
"__pow",
lua.create_native("__pow", mlua::benilla_lua::math_pow())?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// `0x703200`: `lua_getgccount` and `lua_getgcthreshold`, each `>> 10`, two values.
#[test]
fn gcinfo_answers_two_numbers_as_1_12_does() {
let s = UiScript::new().unwrap();
assert_eq!(
s.arity("gcinfo()").unwrap(),
2,
"`mov eax, 2` at 0x703239 — one value is 5.1's shape, not 1.12's"
);
assert_eq!(
s.eval::<Vec<String>>("local a, b = gcinfo() return { type(a), type(b) }")
.unwrap(),
vec!["number".to_string(), "number".to_string()],
"(number,number) — `bindings.tsv` types the row `agree`"
);
// `luaC_checkGC` keeps 5.0's threshold above the live count.
assert!(
s.eval::<bool>("local c, t = gcinfo() return t > c")
.unwrap(),
"the threshold must sit above the live count, as 5.0's collector guarantees"
);
// AceAddon-2.0's memory report divides the second value by 1024.
assert!(
s.eval::<String>(
"local mem, threshold = gcinfo() \
return string.format('%.3f', threshold / 1024)"
)
.is_ok(),
"AceAddon-2.0's memory report divides the second slot by 1024"
);
}
/// `InitLua` sets the threshold to 256 KB right after `lua_open` (`0x7039f2`), so a new state
/// that has not counted that much answers 256 for it.
#[test]
fn a_new_state_answers_init_luas_256_kb_threshold() {
let s = UiScript::new().unwrap();
let info = s.eval::<Vec<f64>>("return { gcinfo() }").unwrap();
assert!(info[0] < 256.0, "the state counts {} KB", info[0]);
assert_eq!(info[1], 256.0);
}
/// `0x703250`: `luaL_optnumber(L, 1, 0.0)`, `_ftol`, `lua_setgcthreshold`, no values, and no
/// string options.
#[test]
fn collectgarbage_answers_nothing_and_takes_a_number() {
let s = UiScript::new().unwrap();
assert_eq!(
s.arity("collectgarbage()").unwrap(),
0,
"`xor eax,eax` at 0x70326f — 5.1 returns one value here"
);
// The reference's only argument form, a number, which stock 5.1 rejects.
assert_eq!(s.arity("collectgarbage(0)").unwrap(), 0);
assert!(s.eval::<()>("collectgarbage(2048)").is_ok());
// `lua_tonumber` coerces a numeric string through `strtod`.
assert!(s.eval::<()>(r#"collectgarbage("100")"#).is_ok());
// A non-numeric string is a type error, in 5.0's words.
let e = s
.eval::<()>(r#"collectgarbage("count")"#)
.unwrap_err()
.to_string();
assert!(
e.contains("bad argument #1 to `collectgarbage' (number expected, got string)"),
"5.1's string options must not exist here: {e}"
);
// An explicit threshold above the live count is what `gcinfo` reports back.
assert!(s
.eval::<bool>(
"local c = gcinfo() collectgarbage(c * 4) local _, t = gcinfo() return t >= c"
)
.unwrap());
}
/// `collectgarbage(n)` sets the collector's own threshold (`lua_setgcthreshold 0x6f4400`): one
/// far above the count lets garbage pile up uncollected, and `gcinfo` counts it.
#[test]
fn collectgarbage_paces_the_collector_itself() {
let s = UiScript::new().unwrap();
let grown: f64 = s
.eval(
"collectgarbage(1000000) local a = gcinfo() \
for i = 1, 400000 do local t = {i, i, i, i} end \
local b = gcinfo() return b - a",
)
.unwrap();
assert!(
grown > 30_000.0,
"400000 uncollected tables of 96 bytes, counted as {grown} KB"
);
// A threshold at or below the count collects now.
assert!(s
.eval::<bool>("local a = gcinfo() collectgarbage(0) return gcinfo() < a - 30000")
.unwrap());
}
/// `^` calls the global `__pow`, `luaopen_math`'s `math_pow` (`0x7fb4c4`): the client's `pow`
/// answers the indefinite for `1 ^ inf`, and its argument check words the error.
#[test]
fn pow_is_the_clients_math_pow() {
let s = UiScript::new().unwrap();
assert!(
s.eval::<bool>("local x = 1 ^ (1/0) return x ~= x").unwrap(),
"C99's 1 is not the client's `-1.#IND`"
);
let e: String = s
.eval("local ok, e = pcall(function() return __pow('x', 2) end) return e")
.unwrap();
assert!(
e.ends_with("bad argument #1 to `__pow' (number expected, got string)"),
"{e}"
);
}
/// `loadstring` answers the function alone on success (`mov eax,1` at `0x7032ba`).
#[test]
fn loadstring_answers_one_value() {
let s = UiScript::new().unwrap();
assert_eq!(s.arity("loadstring('x = 1')").unwrap(), 1);
assert_eq!(s.arity("loadstring('x = ')").unwrap(), 2);
}
/// `luaB_loadstring 0x703280` names a chunk by its source unless given a name (`0x70329a`),
/// and `luaO_chunkid 0x6f5c40` renders each form.
#[test]
fn a_loadstring_chunk_is_named_by_its_own_source() {
let s = UiScript::new().unwrap();
let raised = |src: &str| -> String {
s.eval::<String>(&format!(
"local f = loadstring({src}) local ok, e = pcall(f) return tostring(e)"
))
.unwrap()
};
assert!(
raised(r#""error('boom')""#).starts_with(r#"[string "error('boom')"]:1: boom"#),
"the default name is the source: {}",
raised(r#""error('boom')""#)
);
// `=`: printed verbatim, the marker removed once.
assert!(raised(r#""error('boom')", "=myname""#).starts_with("myname:1: boom"));
// `@`: the file form, the path printed plainly.
assert!(raised(r#""error('boom')", "@a/b.lua""#).starts_with("a/b.lua:1: boom"));
// No marker: wrapped as a string chunk.
assert!(raised(r#""error('boom')", "plain""#).starts_with(r#"[string "plain"]:1: boom"#));
// The failure leg returns Lua's message unchanged (`load_aux`), without the category word
// mlua's `Display` adds.
let e: String = s
.eval(r#"local f, e = loadstring("return 1+") return tostring(e)"#)
.unwrap();
assert_eq!(
e, "[string \"return 1+\"]:1: unexpected symbol near `<eof>'",
"the second return is the message verbatim, with no mlua decoration"
);
}
/// `luaB_assert 0x7031a0`: `lua_settop(L, 1)` at `0x7031e5`, then `mov eax,1` at `0x7031ea`;
/// only a call with more than one argument tells 5.0 from 5.1.
#[test]
fn assert_answers_one_value_and_keeps_5_0_s_messages() {
let s = UiScript::new().unwrap();
assert_eq!(
s.arity("assert(1, 2, 3)").unwrap(),
1,
"5.0 truncates to the first argument; 5.1 returns them all"
);
assert_eq!(s.eval::<i64>("return (assert(7, 'x'))").unwrap(), 7);
// 0 is truthy: only nil and false raise.
assert_eq!(s.arity("assert(0)").unwrap(), 1);
let raised = |call: &str| -> String {
s.eval::<String>(&format!(
"local ok, e = pcall(function() {call} end) return tostring(e)"
))
.unwrap()
};
// `luaL_optlstring(L, 2, "assertion failed!" /*0x872bdc*/)`, then `luaL_error`.
assert!(raised("assert(false)").contains("assertion failed!"));
assert!(raised("assert(nil, 'my message')").contains("my message"));
// A number is a string to `luaL_optlstring`; anything else is a bad argument.
assert!(raised("assert(false, 42)").contains("42"));
assert!(
raised("assert(false, {})").contains("bad argument #2 to `assert' (string expected"),
"{}",
raised("assert(false, {})")
);
// `luaL_checkany 0x6f4bb0`: an absent argument 1 raises, an explicit nil does not.
assert!(
raised("assert()").contains("bad argument #1 to `assert' (value expected)"),
"{}",
raised("assert()")
);
}
/// `AceLibrary.lua`'s opening: it takes the 5.0 branch from `GetBuildInfo()` and calls
/// `table.setn`, which stock 5.1 raises on.
#[test]
fn acelibrarys_own_dialect_probe_takes_the_5_0_branch_and_works() {
let s = UiScript::new().unwrap();
s.run(
r#"
local table_setn
local version = GetBuildInfo()
if string.find(version, "^2%.") then
table_setn = function() end
else
table_setn = table.setn
end
local t = { "a", "b", "c" }
for k in pairs(t) do t[k] = nil end
table_setn(t, 0)
AceProbe = table.getn(t)
"#,
)
.expect("Ace's dialect probe must not raise");
assert_eq!(s.eval::<i64>("return AceProbe").unwrap(), 0);
}
/// The remembered size is neither a no-op nor a visible `n` key.
#[test]
fn getn_and_setn_remember_a_size_without_polluting_the_table() {
let s = UiScript::new().unwrap();
// No remembered size: a count from 1 to the first nil.
assert_eq!(s.eval::<i64>("return table.getn({1,2,3})").unwrap(), 3);
// The size round-trips...
assert_eq!(
s.eval::<i64>("local t = {1,2,3} table.setn(t, 7) return table.getn(t)")
.unwrap(),
7
);
// ...and is invisible to `pairs`: AceOO-2.0's `_Embed` errors on any field its target
// already has.
assert_eq!(
s.eval::<i64>(
"local t = {1,2,3} table.setn(t, 0) \
local c = 0 for k in pairs(t) do c = c + 1 end return c"
)
.unwrap(),
3,
"a remembered size must not appear as a key — 39 corpus addons died on a stray 'n'"
);
// A table that already has a numeric `n` keeps its size there, 5.0's own branch.
assert_eq!(
s.eval::<i64>("local t = {1,2,3, n=3} table.setn(t, 7) return t.n")
.unwrap(),
7
);
// ...and the bare global is the same function.
assert!(
s.eval::<bool>("return getn == table.getn").unwrap(),
"a divergent `getn` global is how the two answers start disagreeing"
);
}
/// The 5.1-only members are gone, so an addon's feature detection picks 1.12's branch.
#[test]
fn the_5_1_only_members_are_not_offered() {
let s = UiScript::new().unwrap();
for expr in [
"string.gmatch",
"string.match",
"string.reverse",
"table.maxn",
"math.fmod",
"math.modf",
"math.huge",
"math.cosh",
"coroutine",
] {
assert!(
s.eval::<bool>(&format!("return {expr} == nil")).unwrap(),
"{expr} is a Lua 5.1 addition — 1.12 does not have it"
);
}
// ...and the 5.0 spellings are present.
for expr in ["string.gfind", "math.mod", "table.setn", "table.foreach"] {
assert!(
s.eval::<bool>(&format!("return {expr} ~= nil")).unwrap(),
"{expr} is Lua 5.0's own spelling and 1.12 has it"
);
}
}
/// `math_atan2`, `math_mod` and `math_pow` (`0x7faf30`, `0x7fafd0`, `0x7fb050`) check
/// argument 1, then 2 (`0x7fb057`, `0x7fb066`); only `math_ldexp` (`0x7fb1f0`) checks 2 first.
#[test]
fn two_argument_math_checks_its_first_argument_first() {
let s = UiScript::new().unwrap();
let raised = |call: &str| -> String {
s.eval::<String>(&format!(
"local ok, e = pcall(function() return {call} end) return e"
))
.unwrap()
};
for (call, want) in [
(
"math.pow()",
"bad argument #1 to `pow' (number expected, got no value)",
),
(
"math.atan2('x', 'y')",
"bad argument #1 to `atan2' (number expected, got string)",
),
(
"math.mod(nil, {})",
"bad argument #1 to `mod' (number expected, got nil)",
),
(
"math.ldexp()",
"bad argument #2 to `ldexp' (number expected, got no value)",
),
] {
assert!(raised(call).ends_with(want), "{call}: {}", raised(call));
}
}
/// The bare `mod` is `math.mod`, so it survives `math.fmod`'s removal.
#[test]
fn the_bare_math_family_survives_the_removals() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<f64>("return mod(7, 3)").unwrap(), 1.0);
assert_eq!(s.eval::<f64>("return floor(2.7)").unwrap(), 2.0);
}
/// `...` parses in a parameter list and nowhere else; `arg` carries the arguments.
#[test]
fn vararg_is_a_declaration_only() {
let s = UiScript::new().unwrap();
let parses = |src: &str| s.run(src).is_ok();
// Declaration, and 5.0's way to read what it captured.
assert!(parses("return function(x, ...) return x end"));
assert!(parses("return function(x, ...) return arg.n end"));
assert!(parses(
"local f = tostring return function(x, ...) return f(x, unpack(arg)) end"
));
// Every use of `...` as an expression fails, at chunk level too, where 5.1 allows it.
assert!(!parses("return function(x, ...) return ... end"));
assert!(!parses(
"local f = tostring return function(x, ...) return f(x, ...) end"
));
assert!(!parses("local a = ... return a"));
}
/// The client's `luaO_str2d` (`0x6f5900`) is `strtod` (`0x741e39`) and a trailing-space skip:
/// a decimal numeral or nothing. Every coercion goes through it, so hexadecimal, `inf` and
/// `nan` are not numbers to `tonumber`, to arithmetic or to a binding's argument, and a source
/// `0x10` does not compile.
#[test]
fn only_a_decimal_numeral_is_a_number() {
let s = UiScript::new().unwrap();
for text in [
"0x10", "0X1A", "0x1p4", "-0x2", " 0x10 ", "inf", "nan", "infinity",
// A marker with no digit after it ends the numeral before it (`0x411432`), so it
// trails; `f` is no marker.
"1e", "1d", "1f2",
] {
assert!(
s.eval::<bool>(&format!("return tonumber({text:?}) == nil"))
.unwrap(),
"tonumber({text:?})"
);
}
for (text, n) in [
(" 12 ", 12.0),
("1e2", 100.0),
("-.5", -0.5),
("5.", 5.0),
("+3", 3.0),
// The client's strtod also takes `d`/`D` as the exponent marker (`0x4112d1`).
("1d2", 100.0),
("-2.5D-1", -0.25),
(" 1d+1 ", 10.0),
] {
assert_eq!(
s.eval::<f64>(&format!("return tonumber({text:?})"))
.unwrap(),
n,
"tonumber({text:?})"
);
}
// A numeral too long for the conversion's stack buffer takes the same path.
let long = format!("1{}d-70", "0".repeat(70));
assert_eq!(
s.eval::<f64>(&format!("return tonumber({long:?})"))
.unwrap(),
1.0
);
// A base still reads hexadecimal digits: that is `strtoul`, not `luaO_str2d`.
assert_eq!(s.eval::<f64>(r#"return tonumber("10", 16)"#).unwrap(), 16.0);
assert!(!s
.eval::<bool>(r#"return (pcall(function() return "0x2" + 1 end))"#)
.unwrap());
assert_eq!(s.eval::<f64>(r#"return "2" + 1"#).unwrap(), 3.0);
// A binding's numeric argument: `GetCraftInfo`'s index raises on a non-number.
assert!(!s
.eval::<bool>(r#"return (pcall(GetCraftInfo, "0x2"))"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return (pcall(GetCraftInfo, "2"))"#)
.unwrap());
// The numeral reader (`0x6ffd20`) stops at the `x`: `0x10` is 0 and then the name `x10`.
assert!(s.run("return 0x10").is_err());
}
/// The client's numeral reader (`0x6ffd20`, 5.0's `read_numeral`) takes digits, one `.`,
/// digits, then `e`/`E`, a sign and digits, and nothing more: a letter or `_` after a number
/// starts the next token, and `1..2` is its own error (`0x8721dc`).
#[test]
fn a_numeral_ends_where_the_clients_reader_stops() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<(i64, i64)>("a=1b=2 return a, b").unwrap(),
(1, 2),
"two statements, as the client reads them"
);
assert!(
s.run("a = 0x10()").is_err(),
"compiles as `a = 0` then a call to `x10`, which is nil"
);
assert!(s
.eval::<bool>("return loadstring('a = 0x10()') ~= nil")
.unwrap());
assert_eq!(s.eval::<f64>("return 1.5e+3").unwrap(), 1500.0);
assert_eq!(s.eval::<f64>("return .5E-1").unwrap(), 0.05);
assert_eq!(s.eval::<f64>("return 2.").unwrap(), 2.0);
let err = s.run("return 1..2").unwrap_err().to_string();
assert!(
err.contains("ambiguous syntax (decimal point x string concatenation)"),
"{err}"
);
// One `.` per numeral: `1.2.3` is `1.2` then `.3`.
let err = s.run("return 1.2.3").unwrap_err().to_string();
assert!(err.contains("near `.3'"), "{err}");
for malformed in ["return 3e", "return .5.3"] {
let err = s.run(malformed).unwrap_err().to_string();
assert!(err.contains("malformed number"), "{malformed}: {err}");
}
}
#[test]
fn sort_foreach_and_foreachi_are_bare_globals_like_the_reference() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<String>("local t = {'c','a','b'} sort(t) return table.concat(t)")
.unwrap(),
"abc"
);
assert_eq!(
s.eval::<i64>("local n = 0 foreach({1,2,3}, function() n = n + 1 end) return n")
.unwrap(),
3
);
assert_eq!(
s.eval::<i64>("local n = 0 foreachi({1,2,3}, function() n = n + 1 end) return n")
.unwrap(),
3
);
}
#[test]
fn the_bit_library_is_present_with_the_references_eight_functions() {
let s = UiScript::new().unwrap();
for name in [
"bnot", "band", "bor", "bxor", "lshift", "rshift", "arshift", "mod",
] {
assert!(
s.eval::<bool>(&format!("return type(bit.{name}) == 'function'"))
.unwrap(),
"bit.{name} is one of the array's eight entries"
);
}
// Unsigned out, the word loaded by `fild qword` with its high word zeroed (`0x7faa6d`).
assert_eq!(s.eval::<i64>("return bit.bnot(0)").unwrap(), 4_294_967_295);
assert_eq!(
s.eval::<i64>("return bit.band(4294967295, 4294967295)")
.unwrap(),
4_294_967_295
);
assert_eq!(
s.eval::<i64>("return bit.bor(-1, 0)").unwrap(),
4_294_967_295
);
assert_eq!(
s.eval::<i64>("return bit.bxor(-1, 1)").unwrap(),
4_294_967_294
);
assert_eq!(
s.eval::<i64>("return bit.lshift(1, 31)").unwrap(),
2_147_483_648
);
assert_eq!(
s.eval::<i64>("return bit.rshift(-1, 0)").unwrap(),
4_294_967_295
);
// `arshift` and `mod` alone load `fild dword`: signed (`0x7fad06`, `0x7fadab`).
assert_eq!(s.eval::<i64>("return bit.arshift(-8, 1)").unwrap(), -4);
assert_eq!(s.eval::<i64>("return bit.mod(-7, 4)").unwrap(), -3);
// `__ftol` truncates to 64 bits and the low word is read: past 64 bits is 0.
assert_eq!(s.eval::<i64>("return bit.bor(2^32 + 5, 0)").unwrap(), 5);
assert_eq!(
s.eval::<i64>("return bit.bnot(2^64)").unwrap(),
4_294_967_295
);
// A shift reads its count, argument 2, first (`0x7fac18`).
assert!(s
.eval::<String>("local ok, e = pcall(function() return bit.lshift() end) return e")
.unwrap()
.ends_with("bad argument #2 to `lshift' (number expected, got no value)"));
assert_eq!(s.eval::<i64>("return bit.band(4660, 255)").unwrap(), 0x34);
assert_eq!(s.eval::<i64>("return bit.bor(240, 15)").unwrap(), 0xFF);
assert_eq!(s.eval::<i64>("return bit.bxor(255, 15)").unwrap(), 0xF0);
assert_eq!(s.eval::<i64>("return bit.lshift(1, 4)").unwrap(), 16);
// `rshift` is logical, `arshift` propagates the sign.
assert_eq!(s.eval::<i64>("return bit.rshift(-1, 28)").unwrap(), 15);
assert_eq!(s.eval::<i64>("return bit.arshift(-1, 28)").unwrap(), -1);
// The shift count masks to 5 bits, as the x86 instruction does.
assert_eq!(s.eval::<i64>("return bit.lshift(1, 32)").unwrap(), 1);
assert_eq!(s.eval::<i64>("return bit.mod(17, 5)").unwrap(), 2);
}
/// `bit.band`, `bor` and `bxor` fold every argument from 2 to `lua_gettop` into the first
/// (`0x7faac0`..`0x7faad9`): one argument answers itself, and each is checked in turn.
#[test]
fn band_bor_and_bxor_fold_every_argument() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return bit.bor(1, 2, 4)").unwrap(), 7);
assert_eq!(s.eval::<i64>("return bit.band(15, 7, 3)").unwrap(), 3);
assert_eq!(s.eval::<i64>("return bit.bxor(1, 3, 7, 15)").unwrap(), 10);
assert_eq!(s.eval::<i64>("return bit.band(-1)").unwrap(), 4_294_967_295);
assert!(s
.eval::<String>(
"local ok, e = pcall(function() return bit.bor(1, 2, 'x') end) return e"
)
.unwrap()
.ends_with("bad argument #3 to `bor' (number expected, got string)"));
assert!(s
.eval::<String>("local ok, e = pcall(function() return bit.band() end) return e")
.unwrap()
.ends_with("bad argument #1 to `band' (number expected, got no value)"));
}
/// `bit.mod` tests only the low word of its divisor's `__ftol` (`0x7fad32`); a 0 there answers
/// 1 divided by the divisor (`0x7fad42`), with argument 1 never read, and raises nothing.
#[test]
fn bit_mod_by_a_zero_word_answers_one_over_the_divisor() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<f64>("return bit.mod(7, 0.5)").unwrap(), 2.0);
assert_eq!(
s.eval::<f64>("return bit.mod(7, 0)").unwrap(),
f64::INFINITY
);
assert_eq!(
s.eval::<f64>("return bit.mod(7, 2^32)").unwrap(),
1.0 / 4_294_967_296.0
);
assert_eq!(s.eval::<f64>("return bit.mod(nil, '0.25')").unwrap(), 4.0);
assert_eq!(s.eval::<i64>("return bit.mod(7, 2^32 + 3)").unwrap(), 1);
}
/// `print` and `_VERSION` are not in 1.12's `_G`, and `__pow` is.
#[test]
fn the_base_library_matches_the_captured_globals() {
let s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return print == nil").unwrap());
assert!(s.eval::<bool>("return _VERSION == nil").unwrap());
// 5.0 implements `^` by calling `__pow`.
assert_eq!(s.eval::<f64>("return __pow(2, 10)").unwrap(), 1024.0);
}
/// Ace2's `lua51` probe, `loadstring("return function(...) return ... end")`, fails as on
/// 1.12: the reference's `simpleexp` (`0x6fd240`) has no `...` arm.
#[test]
fn the_vararg_expression_is_a_syntax_error_as_it_is_on_1_12() {
let s = UiScript::new().unwrap();
assert!(
s.eval::<bool>(
r#"return (loadstring("return function(...) return ... end") and true or false) == false"#
)
.unwrap(),
"the Ace2 lua51 probe must answer false, as it does on the 1.12 client"
);
// `prefixexp` then accepts only `(` or a name, so this is 5.0's own `unexpected symbol`.
let msg: String = s
.eval(r#"local f, e = loadstring("return ...") return tostring(e)"#)
.unwrap();
assert!(
msg.contains("unexpected symbol"),
"5.0's own message for this, not a bespoke one: {msg}"
);
// The 5.0 half of the question: `arg`.
assert_eq!(
s.eval::<i64>("local f = function(...) return arg.n end return f(1, 2, 3)")
.unwrap(),
3,
"`arg` is 5.1's compat-vararg table, which is the form all of 1.12 FrameXML uses"
);
// Every vararg function gets `arg`, 5.0's rule: without that arm nothing clears
// `VARARG_NEEDSARG`.
assert_eq!(
s.eval::<i64>("local f = function(a, ...) return arg.n end return f(1, 2, 3)")
.unwrap(),
2
);
// A `...` in a parameter list still parses: the deletion is `simpleexp`'s arm, not
// `parlist`'s.
assert!(s
.eval::<bool>(r#"return loadstring("return function(...) return arg.n end") ~= nil"#)
.unwrap());
}
/// The reference's `getunopr` (`0x6fe0a0`) knows only `-` and `not`, its `getbinopr`
/// (`0x6fe0c0`) has no `%`, and its metamethod names (`0x871896`) have no `__len` or `__mod`.
#[test]
fn the_length_and_modulo_operators_are_not_in_the_grammar() {
let s = UiScript::new().unwrap();
for probe in ["return #t", "return 7 % 3", "local n = #({1,2}) return n"] {
assert!(
s.eval::<bool>(&format!("return loadstring({:?}) == nil", probe))
.unwrap(),
"{probe} is 5.1-only syntax; 1.12's parser rejects it"
);
}
// 5.0's spellings of both.
assert_eq!(s.eval::<i64>("return table.getn({1,2,3})").unwrap(), 3);
assert_eq!(s.eval::<i64>("return getn({1,2,3})").unwrap(), 3);
assert_eq!(s.eval::<f64>("return math.mod(7, 3)").unwrap(), 1.0);
assert_eq!(s.eval::<i64>("return string.len('abcd')").unwrap(), 4);
}
/// The 5.0 grammar the stock files use still parses, including the three constructs 5.1
/// dropped: the iterator-less generic-for, nested long strings (`read_long_string`,
/// `0x700010`) and the constructor's extra semicolon.
#[test]
fn the_5_0_grammar_the_reference_writes_still_parses() {
let s = UiScript::new().unwrap();
s.run(
r#"
-- 5.0 varargs: the implicit `arg` table, the form all of 1.12 FrameXML uses.
local function count(...) local n = 0 for i = 1, arg.n do n = n + arg[i] end return n end
-- the iterator-less generic-for
local sum = 0
for k, v in { 3, 4 } do sum = sum + v end
-- a table constructor with 5.0's compat semicolon
local t = { a = 1; b = 2; }
-- nesting long strings, 5.0's
local s2 = [[outer [[inner]] outer]]
-- 5.0's own spellings for what `#`/`%` would say
BENILLA_GRAMMAR_OK = count(1, 2, 3) + sum + t.a + t.b
+ table.getn({ 1, 2 }) + math.mod(7, 3) + string.len(s2)
"#,
)
.expect("the reference's own grammar must still load");
assert_eq!(
s.eval::<i64>("return BENILLA_GRAMMAR_OK").unwrap(),
6 + 7 + 3 + 2 + 1 + 21
);
}
/// Six of the reference's eight `debug*` functions (`[0x7027e0, 0x702840)`) are
/// `xor eax,eax; ret`, returning nothing; only `debugprofilestart`/`stop` are real.
#[test]
fn the_debug_family_is_six_stubs_and_two_real_ones() {
let s = UiScript::new().unwrap();
// Zero values, not one nil: only a count tells them apart.
for name in [
"debuginfo",
"debugload",
"debugprint",
"debugdump",
"debugbreak",
"debugtimestamp",
] {
assert_eq!(
s.arity(&format!("{name}()")).unwrap(),
0,
"{name} returns nothing at all"
);
}
// ...and the two real ones answer milliseconds.
let ms: f64 = s
.eval("debugprofilestart() return debugprofilestop()")
.unwrap();
assert!(ms >= 0.0, "elapsed milliseconds, not nil: {ms}");
}
/// `table.insert` appends after the remembered size (its `luaL_getn` call, `0x7fb6d8`).
#[test]
fn table_insert_consults_the_remembered_size() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("local t = {1,2,3} table.setn(t, 0) table.insert(t, 9) return t[1]")
.unwrap(),
9,
"5.0's insert writes index getn+1, which after setn(t, 0) is index 1"
);
}
/// A `{ [1] = a, [2] = b }` constructor builds an array part, so `pairs` walks it in index
/// order. The reference's `recfield` credits no size hint for a `[expr] =` field (its
/// `TK_NAME` test at `0x6fd5a4`), so the table starts on the dummy node and the first store's
/// rehash sizes an array part, which `luaH_next` walks before the hash. Saved variables are
/// written in this form, as the reference writes them, so a saved list reloads in order.
#[test]
fn a_bracketed_key_constructor_is_an_array_and_walks_in_index_order() {
let s = UiScript::new().unwrap();
// Bagnon's own table, as its saved-variables file spells it.
assert_eq!(
s.eval::<String>(
"local t = { [1] = 0, [2] = 1, [3] = 2, [4] = 3, [5] = 4, [6] = -2 }
local out = '' for _, v in pairs(t) do out = out .. v .. ',' end return out"
)
.unwrap(),
"0,1,2,3,4,-2,",
"the keyring (-2) is LAST — the order Bagnon's grid shows"
);
// The order the fields are written in does not change it.
assert_eq!(
s.eval::<String>(
"local t = { [3] = 'c', [1] = 'a', [2] = 'b' }
local out = '' for k in pairs(t) do out = out .. k .. ',' end return out"
)
.unwrap(),
"1,2,3,"
);
// A key the array part cannot hold comes after the run, in hash order.
assert_eq!(
s.eval::<String>(
"local t = { ['a'] = 1, [1] = 10, [2] = 20, [3] = 30 }
local out = '' for k in pairs(t) do out = out .. tostring(k) .. ',' end return out"
)
.unwrap(),
"1,2,3,a,"
);
// Control: the positional spelling is an array too.
assert_eq!(
s.eval::<String>(
"local t = { 0, 1, 2, 3, 4, -2 }
local out = '' for _, v in pairs(t) do out = out .. v .. ',' end return out"
)
.unwrap(),
"0,1,2,3,4,-2,"
);
}
}
#[cfg(test)]
mod error_quoting_tests {
use crate::script::UiScript;
/// Error messages quote a program element as the reference does, `` `x' ``, 5.0's
/// `LUA_QL`, and `debugstack` renders its own frames the same way
/// (`0x703760`).
#[test]
fn errors_quote_program_elements_the_way_lua_5_0_does() {
let s = UiScript::new().unwrap();
let err = |lua: &str| {
s.eval::<String>(&format!(
"local ok, e = pcall(function() {lua} end) return tostring(e)"
))
.unwrap()
};
// luaG_typeerror.
let e = err("local t = nil return t.x");
assert!(
e.contains("attempt to index local `t' (a nil value)"),
"typeerror keeps 5.1's quoting: {e}"
);
// luaL_argerror, through a library function that raises one.
let e = err(r#"return string.rep(nil, 2)"#);
assert!(
e.contains("bad argument #1 to `rep'"),
"argerror keeps 5.1's quoting: {e}"
);
// luaX_lexerror, the client's `0x87217c` format.
let e = s
.eval::<String>(r#"local f, e = loadstring("return 1 +") return tostring(e)"#)
.unwrap();
assert!(
e.contains("near `<eof>'"),
"the lexer keeps 5.1's quoting: {e}"
);
// No message opens a quote with an apostrophe; the closing quote is one in both dialects.
for e in [
err("local t = nil return t.x"),
err("return nosuchfn()"),
err("return string.rep(nil, 2)"),
] {
assert!(
!e.contains(" '") && !e.contains("('"),
"a 5.1-quoted element survives in: {e}"
);
}
}
/// The client's engine functions and libraries raise through `luaL_error` (`0x6f4940`), which
/// prefixes `luaL_where(1)` (`0x6f48e0`), the calling Lua function's `chunk:line: `, and word
/// a bad argument as `luaL_argerror` (`0x6f4810`) does. Only `PlaySound`, `PlayMusic` and
/// `PlaySoundFile` raise bare, through `lua_error` (`0x458708`, `0x458758`, `0x4587a1`).
#[test]
fn a_rust_callbacks_error_carries_its_callers_position() {
let s = UiScript::new().unwrap();
let raised = |body: &str| -> String {
s.lua()
.load(format!(
"local ok, e = pcall(function()\n{body}\nend) return tostring(e)"
))
.set_name("=X.lua")
.eval::<String>()
.unwrap()
};
assert_eq!(raised("GetCVar()"), "X.lua:2: Usage: GetCVar(\"cvar\")");
assert_eq!(raised("assert(false, 'x')"), "X.lua:2: x");
assert_eq!(raised("assert(nil)"), "X.lua:2: assertion failed!");
for sound in ["PlaySound()", "PlayMusic()", "PlaySoundFile()"] {
assert!(
raised(sound).starts_with("Usage: "),
"{sound}: {}",
raised(sound)
);
}
// A typed argument fails as `luaL_checktype` words it, under the name it was called by.
let typed = s
.lua()
.create_function(|_, (_t, _n): (mlua::Table, f64)| Ok(()))
.unwrap();
s.lua().globals().set("Typed", typed).unwrap();
assert_eq!(
raised("Typed(nil)"),
"X.lua:2: bad argument #1 to `Typed' (table expected, got nil)"
);
assert_eq!(
raised("local f = Typed f({})"),
"X.lua:2: bad argument #2 to `f' (number expected, got no value)"
);
assert_eq!(
raised("local o = {m = Typed} o:m()"),
"X.lua:2: bad argument #1 to `m' (number expected, got no value)"
);
}
/// Error text is bytes, as the client's C strings are: `assert`'s message goes through
/// `luaL_error("%s")` (`0x7031d0`), byte for byte up to a NUL, and `luaL_argerror`
/// (`0x6f4810`) and the type errors name a function, field or chunk with its raw bytes.
#[test]
fn error_text_keeps_its_bytes() {
let s = UiScript::new().unwrap();
let caught = |body: &str| -> String {
s.eval::<String>(&format!(
"local ok, e = pcall(function() {body} end) \
local o = {{}} for i = 1, string.len(e) do o[i] = string.byte(e, i) end \
return table.concat(o, ' ')"
))
.unwrap()
};
let ends = |body: &str, want: &[u8]| {
let got: Vec<u8> = caught(body)
.split(' ')
.map(|b| b.parse::<u8>().unwrap())
.collect();
assert!(got.ends_with(want), "{body}: {got:?}");
};
ends("assert(false, '\\233')", b":1: \xe9");
ends("assert(false, 'a\\0b')", b":1: a");
// A function stored under a Latin-1 name, then called with a bad argument and as a
// non-function.
ends(
"getfenv(0)['\\233'] = string.rep getfenv(0)['\\233']()",
b"bad argument #1 to `\xe9' (string expected, got no value)",
);
ends(
"local t = {} t['\\233'] = 1 t['\\233']()",
b"attempt to call field `\xe9' (a number value)",
);
// A chunk named by its own source keeps its bytes, through `RunScript` too.
ends(
"local f, e = loadstring('return \\233 +') error(e, 0)",
b"[string \"return \xe9 +\"]:1: unexpected symbol near `\xe9'",
);
}
/// The error handler is called with the value raised, as `lua_pcall` hands its error
/// function the value (`0x703b40`): a table as itself, a message with its bytes.
#[test]
fn the_error_handler_hears_the_value_raised() {
let mut s = UiScript::new().unwrap();
s.run(
"caught = {} seterrorhandler(function(e) table.insert(caught, e) end) \
E = {} local f = CreateFrame('Frame') f:RegisterEvent('PLAYER_ALIVE') \
f:SetScript('OnEvent', function() error(E) end) \
local g = CreateFrame('Frame') g:RegisterEvent('PLAYER_ALIVE') \
g:SetScript('OnEvent', function() error('\\233', 0) end)",
)
.unwrap();
s.fire_event("PLAYER_ALIVE", vec![]);
s.run("RunScript('return \\233 +')").unwrap();
// `RunScript`'s chunk is named by its source's bytes (`0x48b9bb`).
assert!(s
.eval::<bool>(
"return caught[3] == '[string \"return \\233 +\"]:1: unexpected symbol near `\\233\\''"
)
.unwrap());
assert!(s.eval::<bool>("return caught[1] == E").unwrap());
assert!(s.eval::<bool>("return caught[2] == '\\233'").unwrap());
}
/// A Lua error that comes back through a Rust callback goes on as it was raised: the value
/// itself, as `lua_error` raises what a protected call left, never its text again, and never
/// with a second position.
#[test]
fn a_lua_error_passes_through_a_callback_unchanged() {
let s = UiScript::new().unwrap();
let through = s
.lua()
.create_function(|_, f: mlua::Function| f.call::<()>(()))
.unwrap();
s.lua().globals().set("Through", through).unwrap();
assert!(s
.eval::<bool>(
"local E = {} local ok, e = pcall(Through, function() error(E) end) return e == E"
)
.unwrap());
assert_eq!(
s.lua()
.load(
"local ok, e = pcall(function() Through(function() error('boom') end) end) \
return e"
)
.set_name("=Y.lua")
.eval::<String>()
.unwrap(),
"Y.lua:1: boom"
);
assert!(s
.eval::<bool>(
"local ok, e = pcall(Through, function() error('\\233', 0) end) return e == '\\233'"
)
.unwrap());
}
/// `select` is 5.1's; 5.0 counts arguments with `arg.n`, and the host with
/// [`UiScript::arity`]. Both must tell zero returns from one `nil`, or an arity check passes a
/// binding that returns nothing.
#[test]
fn select_is_not_a_1_12_global_and_arg_n_answers_instead() {
let mut s = UiScript::new().unwrap();
// `ShowNameplates` below submits to a call host; this VM keeps the call instead.
s.keep_calls();
assert!(
s.eval::<bool>("return select == nil").unwrap(),
"`select` is 5.1's base library; `reference/lua_globals.tsv` has no row for it"
);
// 5.0's spelling, as an addon writes it.
assert_eq!(
s.eval::<Vec<i64>>(
"local function n(...) return arg.n end \
local function two_with_a_nil() return 1, nil end \
local function nothing() end \
return { n(), n(nil), n(two_with_a_nil()), n(nothing()), n(1, 2, 3) }",
)
.unwrap(),
vec![0, 1, 2, 0, 3],
"arg.n must count trailing nils AND tell zero returns from one nil"
);
// And the host's.
assert_eq!(s.arity("nil").unwrap(), 1, "one nil is one value");
assert_eq!(s.arity("ShowNameplates()").unwrap(), 0, "and zero is zero");
}
/// 1.12's string type has no metatable, so a method call on a string raises, in the
/// reference's words: ``attempt to %s a %s value`` (`0x871c10`) and
/// ``attempt to %s %s `%s' (a %s value)`` (`0x871c2c`).
#[test]
fn the_string_type_has_no_metatable() {
let s = UiScript::new().unwrap();
assert!(
s.eval::<bool>(r#"return getmetatable("") == nil"#).unwrap(),
"5.1's luaopen_string ends in a createmetatable 5.0's does not have"
);
let literal: String = s
.eval(
r#"local ok, err = pcall(function() return ("abc"):upper() end) return tostring(err)"#,
)
.unwrap();
assert!(
literal.contains("attempt to index a string value"),
"5.0's own luaG_typeerror wording: {literal:?}"
);
let named: String = s
.eval(
"local ok, err = pcall(function() local v = 'abc' return v:sub(1, 2) end) \
return tostring(err)",
)
.unwrap();
assert!(
named.contains("attempt to index local `v' (a string value)"),
"the named form, with 5.0's backtick-apostrophe quoting: {named:?}"
);
// Only the method dispatch goes: the string library and its bare aliases still work.
s.run(
r#"assert(string.upper("ab") == "AB")
assert(string.sub("hello", 2, 3) == "el")
assert(string.find("hello", "ll") == 3)
assert(strupper("ab") == "AB" and strsub("hello", 2, 3) == "el")
assert(format("%2$s %1$s", "a", "b") == "b a")"#,
)
.unwrap();
}
/// `bit.mod(INT32_MIN, -1)` is 0: Rust's `%` panics on it, in a release build too.
#[test]
fn bit_mod_of_int_min_by_minus_one_is_zero() {
std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(|| {
let s = UiScript::new().unwrap();
let got: Vec<i64> = s
.eval("return { bit.mod(-2^31, -1), bit.mod(2^31, -1), bit.mod(-7, 2) }")
.unwrap();
assert_eq!(got, vec![0, 0, -1]);
})
.unwrap()
.join()
.unwrap();
}
}