//! The Lua 5.0 dialect: the 5.1 VM's libraries reshaped to answer as the 1.12 client's Lua 5.0. //! //! 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 the Lua fork's, in `third_party/lua-src`. //! //! `...` 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, Table, Value}; /// The Lua 5.1 library members 5.0 lacks, removed so an addon's feature test /// (`string.gmatch or string.gfind`) takes the branch 1.12 gives it. const REMOVED: &[(&str, &[&str])] = &[ // 5.0 has `gfind`, and no `gmatch`, `match` or `reverse`. ("string", &["gmatch", "match", "reverse"]), // 5.0's eight are concat, foreach, foreachi, getn, setn, sort, insert and remove. ("table", &["maxn"]), // 5.0's `mod` is 5.1's `fmod` (and a bare global in 1.12); 5.0 has no `modf`, `huge` or // hyperbolics. ("math", &["fmod", "modf", "huge", "cosh", "sinh", "tanh"]), ]; /// Lua 5.0's remembered table size and the `n`-based table library that reads and updates it. mod table_size; /// Registry key prefix under which a removed 5.1 member is kept before it is nil'd, so the /// ClassicAPI compat layer can hand the *original* function back rather than reimplementing it /// (`compat/classic_api/stdlib_backport.rs`). Keyed `"__benilla_lua51.."`. /// /// Stashing is unconditional and costs nothing observable: the registry is not reachable from Lua, /// so a build that never loads `compat/` still presents exactly 1.12's surface. The alternative — /// compat re-implementing `string.match` in Lua — would be a second, subtly different pattern /// engine, which is the worst outcome for a verb addons use to parse their own saved strings. pub(in crate::script) const REG_LUA51_PREFIX: &str = "__benilla_lua51."; /// Stash one removed member so compat can restore it. Returns without storing when the member is /// absent, which keeps this honest if a future mlua drops something itself. fn stash(lua: &Lua, lib: &str, name: &str, f: &Value) -> mlua::Result<()> { if matches!(f, Value::Nil) { return Ok(()); } lua.set_named_registry_value(&format!("{REG_LUA51_PREFIX}{lib}.{name}"), f.clone()) } /// Install the dialect. Runs before the WoW stdlib layer, so its aliases bind the 5.0-shaped /// functions rather than the 5.1 ones they replace. pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); // ── 1 · the `n`-based table library ─────────────────────────────────────────────────────── table_size::install(lua)?; // ── 2 · the 5.1-only members go ─────────────────────────────────────────────────────────── for (lib, names) in REMOVED { let Ok(t) = g.get::(*lib) else { continue; }; for name in *names { // Stash before removing — see `REG_LUA51_PREFIX`. The member is still nil to Lua here; // only `compat/` can put it back, and only the ones ClassicAPI actually provides. stash(lua, lib, name, &t.get::(*name)?)?; t.set(*name, Value::Nil)?; } } // `InitLua` never opens `coroutine`. g.set("coroutine", Value::Nil)?; // `select` is 5.1's base library and has no row in 1.12's `_G`. A vararg function counts its // arguments with `arg.n`, and host code asks `UiScript::arity`. stash(lua, "_G", "select", &g.get::("select")?)?; g.set("select", Value::Nil)?; // 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::(None); // ── 3 · the 5.0 compat globals ──────────────────────────────────────────────────────────── // The client's compat chunk (`0x8722e8`) publishes these three bare, and no bare `setn` // (`reference/1.12-globals.tsv`). let table: Table = g.get("table")?; for name in ["sort", "foreach", "foreachi"] { if let Ok(f) = table.get::(name) { if !matches!(f, Value::Nil) { g.set(name, f)?; } } } // Bound here, not left to the stdlib layer, so the bare `getn` is `table.getn` itself. g.set("getn", table.get::("getn")?)?; // ── 4 · the base library's own shape ────────────────────────────────────────────────────── // `print` and `_VERSION` are neither in the base array (`0x811e28`) nor in 1.12's `_G`. g.set("print", Value::Nil)?; g.set("_VERSION", Value::Nil)?; // `__pow` is a 1.12 global, which 5.0 calls to implement `^`; nothing here calls it, but an // addon can see it. g.set( "__pow", lua.create_function(|_, (a, b): (f64, f64)| Ok(a.powf(b)))?, )?; install_bit(lua)?; install_gc(lua)?; install_assert(lua)?; Ok(()) } /// The `bit` library 1.12 opens (`0x7fadc0`, array `0x822c18`): its eight functions, in the /// array's order, on 32-bit two's complement. fn install_bit(lua: &Lua) -> mlua::Result<()> { let bit = lua.create_table()?; // The client truncates to a 32-bit int and answers signed. `as i64 as u32` is C's wrapping // `(unsigned)(int)x`, not a saturating cast, so `bnot(0)` is -1 as there. fn u32_of(v: f64) -> u32 { v as i64 as u32 } fn out(v: u32) -> i64 { v as i32 as i64 } bit.set( "bnot", lua.create_function(|_, a: f64| Ok(out(!u32_of(a))))?, )?; bit.set( "band", lua.create_function(|_, (a, b): (f64, f64)| Ok(out(u32_of(a) & u32_of(b))))?, )?; bit.set( "bor", lua.create_function(|_, (a, b): (f64, f64)| Ok(out(u32_of(a) | u32_of(b))))?, )?; bit.set( "bxor", lua.create_function(|_, (a, b): (f64, f64)| Ok(out(u32_of(a) ^ u32_of(b))))?, )?; // Shift counts mask to 5 bits, as the client's x86 shifts do: `lshift(1, 32)` is 1. bit.set( "lshift", lua.create_function(|_, (a, n): (f64, f64)| Ok(out(u32_of(a) << (u32_of(n) & 31))))?, )?; bit.set( "rshift", lua.create_function(|_, (a, n): (f64, f64)| Ok(out(u32_of(a) >> (u32_of(n) & 31))))?, )?; bit.set( "arshift", lua.create_function(|_, (a, n): (f64, f64)| { Ok(out(((u32_of(a) as i32) >> (u32_of(n) & 31)) as u32)) })?, )?; // `bit.mod`: an integer remainder, unlike `math.mod`'s float one. bit.set( "mod", lua.create_function(|_, (a, b): (f64, f64)| { let b = u32_of(b) as i32; if b == 0 { return Err(mlua::Error::runtime("bit.mod: division by zero")); } Ok(((u32_of(a) as i32) % b) as i64) })?, )?; lua.globals().set("bit", bit) } /// `gcinfo` and `collectgarbage` in their 5.0 shapes. `gcinfo` (`0x703200`) takes nothing and /// answers `(count_KB, threshold_KB)` (`lua_getgccount 0x6f43f0`, `lua_getgcthreshold /// 0x6f43e0`); `collectgarbage` (`0x703250`) takes an optional number, sets the threshold to that /// many KB (`lua_setgcthreshold 0x6f4400`) and answers nothing, so 5.1's string options raise. /// 5.1's collector has no threshold, so it is kept here by 5.0's rules against mlua's heap: a /// live count that reaches it collects, and a collection resets it to twice the live count. fn install_gc(lua: &Lua) -> mlua::Result<()> { /// 5.0's `GCthreshold` (`[G+0x24]`), in bytes. const REG_GC_THRESHOLD: &str = "__benilla_gc_threshold"; /// `lua_setgcthreshold 0x6f4400`'s unsigned bound, in kilobytes. const MAX_THRESHOLD_KB: i64 = 0x3f_ffff; fn live_bytes(lua: &Lua) -> usize { lua.used_memory() } /// 5.0's post-collection rule, `luaC_collectgarbage`: `GCthreshold = 2 * nblocks`. fn settle(lua: &Lua) -> mlua::Result<()> { let t = u64::try_from(live_bytes(lua)) .unwrap_or(u64::MAX) .saturating_mul(2); lua.set_named_registry_value(REG_GC_THRESHOLD, t as f64) } settle(lua)?; // `gcinfo()`: no argument, two numbers, never raises. lua.globals().set( "gcinfo", lua.create_function(|lua, ()| { let live = live_bytes(lua); let mut threshold: f64 = lua.named_registry_value(REG_GC_THRESHOLD).unwrap_or(0.0); // 5.0 collects the moment the live count reaches the threshold (`luaC_checkGC`), so a // script never sees a threshold at or below the count. With no allocation hook here, // the same rule runs on read. if (live as f64) >= threshold { settle(lua)?; threshold = lua.named_registry_value(REG_GC_THRESHOLD).unwrap_or(0.0); } // Both in KB, the reference's `shr eax,0xa`. Ok(((live >> 10) as f64, (threshold as u64 >> 10) as f64)) })?, )?; // `collectgarbage([n])`: a number, no return values, and a raise on anything else. lua.globals().set( "collectgarbage", lua.create_function(|lua, arg: Value| { // `luaL_optnumber(L, 1, 0.0)`: absent or nil is the default, a numeric string is // coerced (`lua_tonumber` runs `strtod`), anything else is a type error. let n = match &arg { Value::Nil => 0.0, Value::Integer(i) => *i as f64, Value::Number(n) => *n, Value::String(s) => { match s.to_str().ok().and_then(|t| t.trim().parse::().ok()) { Some(v) => v, None => { return Err(mlua::Error::RuntimeError(format!( "bad argument #1 to `collectgarbage' (number expected, got {})", type_name(&arg) ))) } } } other => { return Err(mlua::Error::RuntimeError(format!( "bad argument #1 to `collectgarbage' (number expected, got {})", type_name(other) ))) } }; // `_ftol` truncates toward zero; `0x6f4400`'s unsigned compare saturates a negative // or oversized count to `0xffffffff` bytes. let kb = n.trunc(); let threshold_bytes: f64 = if !(0.0..=MAX_THRESHOLD_KB as f64).contains(&kb) { u32::MAX as f64 } else { kb * 1024.0 }; lua.set_named_registry_value(REG_GC_THRESHOLD, threshold_bytes)?; // `luaC_checkGC`: a live count at the threshold collects, and the collection // re-settles it to `2 * nblocks`. if (live_bytes(lua) as f64) >= threshold_bytes { lua.gc_collect()?; settle(lua)?; } Ok(()) })?, )?; Ok(()) } /// `assert` in its 5.0 shape (`luaB_assert 0x7031a0`): it answers only its first argument /// (`lua_settop(L, 1)` at `0x7031e5`) where 5.1 answers them all, and a false value raises /// argument 2 or `assertion failed!` (`0x872bdc`) through `luaL_error 0x6f4940`. fn install_assert(lua: &Lua) -> mlua::Result<()> { let f = lua.create_function(|lua, args: mlua::MultiValue| { let mut it = args.into_iter(); // `luaL_checkany 0x6f4bb0`: an absent argument 1 raises; an explicit `nil` takes the // falsy leg below. let Some(v) = it.next() else { return Err(mlua::Error::RuntimeError( "bad argument #1 to `assert' (value expected)".into(), )); }; if matches!(v, Value::Nil | Value::Boolean(false)) { // `luaL_optlstring`: a string or a number; anything else raises its own bad // argument, the type spelled as `luaT_typenames 0x811cd0` spells it. let msg = match it.next() { None | Some(Value::Nil) => "assertion failed!".to_string(), Some(other) => match lua.coerce_string(other.clone())? { Some(s) => s.to_string_lossy(), None => { return Err(mlua::Error::RuntimeError(format!( "bad argument #2 to `assert' (string expected, got {})", type_name(&other) ))) } }, }; return Err(mlua::Error::RuntimeError(msg)); } // `lua_settop(L, 1)`: the first argument alone, whatever else was passed. Ok(v) })?; lua.globals().set("assert", f) } fn type_name(v: &Value) -> &'static str { match v { Value::Nil => "no value", Value::Boolean(_) => "boolean", Value::Integer(_) | Value::Number(_) => "number", Value::String(_) => "string", Value::Table(_) => "table", Value::Function(_) => "function", Value::Thread(_) => "thread", _ => "userdata", } } #[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::>("local a, b = gcinfo() return { type(a), type(b) }") .unwrap(), vec!["number".to_string(), "number".to_string()], "(number,number) — `1.12-shapes.tsv` types the row `agree`" ); // `luaC_checkGC` keeps 5.0's threshold above the live count. assert!( s.eval::("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::( "local mem, threshold = gcinfo() \ return string.format('%.3f', threshold / 1024)" ) .is_ok(), "AceAddon-2.0's memory report divides the second slot by 1024" ); } /// `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::( "local c = gcinfo() collectgarbage(c * 4) local _, t = gcinfo() return t >= c" ) .unwrap()); } /// `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::(&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 `'", "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::("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::(&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::("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::("return table.getn({1,2,3})").unwrap(), 3); // The size round-trips... assert_eq!( s.eval::("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::( "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::("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::("return getn == table.getn").unwrap(), "a divergent `getn` global is how the two answers start disagreeing" ); } /// The 5.1-only members this *dialect* removes stay removed in the finished VM — except the /// five `compat/classic_api/stdlib_backport.rs` deliberately hands back, which is a documented /// maintainer decision, not drift. /// /// Split in two on purpose. For the restored five, asserting "nil" would be asserting the /// opposite of the decision; instead this checks they are in the registry stash, which is /// proof *this layer* removed them and only compat put them back. For everything else the /// original assertion stands unchanged, so a genuine regression still fails here. #[test] fn the_5_1_only_members_are_not_offered() { let s = UiScript::new().unwrap(); for expr in [ "string.reverse", "table.maxn", "math.huge", "math.cosh", "coroutine", ] { assert!( s.eval::(&format!("return {expr} == nil")).unwrap(), "{expr} is a Lua 5.1 addition — 1.12 does not have it, and nothing restores it" ); } // The four library members compat restores are still on *this* layer's removal list — the // proof the dialect keeps removing them and only `compat/` puts them back. Asserted // against the const rather than the VM: the finished VM has them, so reading it would // assert the opposite of the decision, while the const says what this file still does. for (lib, name) in [ ("string", "match"), ("string", "gmatch"), ("math", "fmod"), ("math", "modf"), ] { assert!( super::REMOVED .iter() .any(|(l, names)| *l == lib && names.contains(&name)), "{lib}.{name} must stay on this dialect's removal list — \ compat restoring it is not licence for the dialect to stop removing it" ); } // ...and they really are reachable in the finished VM, which is the decision's other half. for expr in [ "select", "string.match", "string.gmatch", "math.fmod", "math.modf", ] { assert!( s.eval::(&format!("return {expr} ~= nil")).unwrap(), "{expr} is restored by compat/classic_api/stdlib_backport.rs" ); } // ...and the 5.0 spellings are present. for expr in ["string.gfind", "math.mod", "table.setn", "table.foreach"] { assert!( s.eval::(&format!("return {expr} ~= nil")).unwrap(), "{expr} is Lua 5.0's own spelling and 1.12 has it" ); } } /// 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::("return mod(7, 3)").unwrap(), 1.0); assert_eq!(s.eval::("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")); } #[test] fn sort_foreach_and_foreachi_are_bare_globals_like_the_reference() { let s = UiScript::new().unwrap(); assert_eq!( s.eval::("local t = {'c','a','b'} sort(t) return table.concat(t)") .unwrap(), "abc" ); assert_eq!( s.eval::("local n = 0 foreach({1,2,3}, function() n = n + 1 end) return n") .unwrap(), 3 ); assert_eq!( s.eval::("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::(&format!("return type(bit.{name}) == 'function'")) .unwrap(), "bit.{name} is one of the array's eight entries" ); } // Two's complement, signed out: `bnot(0)` is -1, not 4294967295. assert_eq!(s.eval::("return bit.bnot(0)").unwrap(), -1); // Decimal in the chunks, because 1.12's lexer has no hex literals (see // `a_numeral_ends_where_the_digits_do_so_a_glued_keyword_is_its_own_token`) and no chunk of // benilla's own Lua may use a construct the client's parser rejects. Hex stays on the Rust // side, where the masks are what they are. assert_eq!(s.eval::("return bit.band(4660, 255)").unwrap(), 0x34); assert_eq!(s.eval::("return bit.bor(240, 15)").unwrap(), 0xFF); assert_eq!(s.eval::("return bit.bxor(255, 15)").unwrap(), 0xF0); assert_eq!(s.eval::("return bit.lshift(1, 4)").unwrap(), 16); // `rshift` is logical, `arshift` propagates the sign. assert_eq!(s.eval::("return bit.rshift(-1, 28)").unwrap(), 15); assert_eq!(s.eval::("return bit.arshift(-1, 28)").unwrap(), -1); // The shift count masks to 5 bits, as the x86 instruction does. assert_eq!(s.eval::("return bit.lshift(1, 32)").unwrap(), 1); assert_eq!(s.eval::("return bit.mod(17, 5)").unwrap(), 2); } /// `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::("return print == nil").unwrap()); assert!(s.eval::("return _VERSION == nil").unwrap()); // 5.0 implements `^` by calling `__pow`. assert_eq!(s.eval::("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::( 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::("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::("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::(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::(&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::("return table.getn({1,2,3})").unwrap(), 3); assert_eq!(s.eval::("return getn({1,2,3})").unwrap(), 3); assert_eq!(s.eval::("return math.mod(7, 3)").unwrap(), 1.0); assert_eq!(s.eval::("return string.len('abcd')").unwrap(), 4); } /// Where the client's `read_numeral` (`0x6ffee0`) ends a numeral, and where 5.1's did not. /// Its only character-class test is the ctype digit mask (`0x6fff47`); the tail terminates the /// buffer and calls `luaO_str2d` (`0x6f5900`) with no further slurp, so `1then` is the number /// 1 and the name `then`. 5.1 ended with `while (isalnum(c) || c == '_') save_and_next(ls);`, /// gave the whole run to the numeral and failed it — which killed /// `AdvancedTradeSkillWindow2.lua:3878`, `if Item.ReplaceWords == 1then`, and with it a /// 4000-line file and every global it defines. #[test] fn a_numeral_ends_where_the_digits_do_so_a_glued_keyword_is_its_own_token() { let s = UiScript::new().unwrap(); // The addon's own line, verbatim. assert_eq!( s.eval::("local Item = { ReplaceWords = 1 } if Item.ReplaceWords == 1then return 7 end return 0") .unwrap(), 7 ); for (chunk, want) in [ ("local n = 0 for i = 1, 3do n = n + i end return n", 6), ("if 1~=2and 3==3 then return 1 end return 0", 1), ("local t = { 5 } return t[1]+0", 5), ] { assert_eq!( s.eval::(chunk) .unwrap_or_else(|e| panic!("{chunk}: {e}")), want ); } // A finished exponent is still one numeral, and the name behind it still its own token. assert_eq!( s.eval::("if 1e2then return 1e2 end return 0").unwrap(), 100.0 ); // The same boundary costs 1.12 its hex literals: the digit loop stops at the `x`, so the // client reads `0` and the name `xFF` and cannot parse the statement. Zero loaded sites in // the corpus (only pfQuest's unlisted `toolbox/pngLua`, which no toc row reaches). assert!(s .eval::(r#"return loadstring("return 0xFF") == nil"#) .unwrap()); // A malformed numeral still raises, and names the prefix the client would have named. assert!(s .eval::(r#"return loadstring("return 1.2.3") == nil"#) .unwrap()); } /// The 5.0 grammar the stock files use still parses, including the three constructs the fork /// restores: the iterator-less generic-for, nested long strings (`LUA_COMPAT_LSTR`) 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 (LUA_COMPAT_LSTR = 2) 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::("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::("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::( "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::( "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::( "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::( "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' ``: the fork's /// `luaconf.h` restores 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::(&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::(r#"local f, e = loadstring("return 1 +") return tostring(e)"#) .unwrap(); assert!( e.contains("near `'"), "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}" ); } } /// `select` is 5.1's; 5.0 counts arguments with `arg.n`. **It is now restored** by /// `compat/classic_api/stdlib_backport.rs` — a maintainer decision recorded there, taken /// because pfUI calls it and a real OctoWoW install has it via ClassicAPI. What this test pins /// is the half that did not change: `arg.n` is still the 5.0 way and still correct, so host /// code and 5.0-era addons keep working regardless of `select`'s presence. #[test] fn arg_n_counts_a_vararg_list_the_five_oh_way() { let s = UiScript::new().unwrap(); assert_eq!( s.eval::("local function f(...) return arg.n end return f('a', 'b', 'c')") .unwrap(), 3, "5.0 counts with arg.n, and that must keep working now that select is back" ); assert_eq!( s.eval::("local function f(...) return arg[2] end return f('a', 'b')") .unwrap(), "b" ); // And the two agree, which is the property a mixed-era corpus actually depends on. assert!( s.eval::( "local function f(...) return arg.n == select('#', unpack(arg)) end \ return f('a', 'b', 'c')" ) .unwrap(), "arg.n and select('#') must not disagree about the same list" ); } /// 5.0's `luaG_typeerror` wording — ``attempt to %s a %s value`` (`0x871c10`) and /// ``attempt to %s %s `%s' (a %s value)`` (`0x871c2c`) — checked on a **number**, which has no /// metatable in any Lua version. /// /// This used to check a *string*, because [`install`] removes the string metatable as 1.12 /// has none. It still does — but `compat/classic_api/string_methods.rs` deliberately puts one /// back, so a method call on a string now works in the assembled VM. That is a documented /// deviation (this build announces ClassicAPI, whose surface includes the 5.1 convention), and /// it is narrow: the members `install` removed stay removed, which /// `string_methods.rs`'s own test pins. The error-quoting coverage here is unchanged in /// substance — only its subject moved to a type nothing restores. #[test] fn the_five_oh_type_error_wording_and_the_string_library() { let s = UiScript::new().unwrap(); let literal: String = s .eval( r#"local ok, err = pcall(function() return (5):upper() end) return tostring(err)"#, ) .unwrap(); assert!( literal.contains("attempt to index a number value"), "5.0's own luaG_typeerror wording: {literal:?}" ); let named: String = s .eval( "local ok, err = pcall(function() local v = 5 return v:sub(1, 2) end) \ return tostring(err)", ) .unwrap(); assert!( named.contains("attempt to index local `v' (a number value)"), "the named form, with 5.0's backtick-apostrophe quoting: {named:?}" ); // The string library and its bare aliases are untouched either way. 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(); } }