Files
moonwell-client-core/crates/benilla-ui/src/script/lua50.rs
T
Sam 321bcbb393 the cooldown pie survives a /reload, and Bagnon's bags keep their order
- the cooldown pie, the GCD sweep, minimap pings and the map arrow draw again after a logout, login or /reload; they went blank until a restart, which read as OmniCC killing the cooldowns (2113)
- an addon's own font on a button label keeps its face, size and outline instead of being undone every frame, MikScrollingBattleText's reported look (2112)
- an empty label reads as nothing rather than an empty string, so Cartographer's map label keeps its colour; a slider thumb with no authored size takes its art's own size, so Dewdrop's Look'n'Feel slider moves (2109, 2110)
- Bagnon's saved bag list comes back in the order it was written, so the keyring stops jumping to the front after a restart; hovering a Bagnon bag costs a third of what it did (2111, 2114)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:49:50 +07:00

665 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **The Lua 5.0 dialect** — the layer that makes an mlua 5.1 VM answer the questions a 1.12 addon
//! asks about its interpreter (decision 1194).
//!
//! The 1.12.1 client embeds **Lua 5.0**; we embed mlua's `lua51`. Every prior decision in this
//! crate treated that as a detail — 0068 called the target "stock Lua 5.1", and the WoW stdlib
//! layer beside this one is written as aliases *onto* 5.1. It is not a detail. It is the single
//! largest measured blocker in the whole addon arc:
//!
//! ```text
//! what stopped them (addons whose FIRST load error was each):
//! 61 error: 'X' is obsolete <- table.setn, raised by Lua 5.1
//! ```
//!
//! Sixty-one of 218 real vanilla addons, plus ~23 more that merely depend on one of them, stopped
//! on their first chunk. The chain is worth spelling out because it is not obvious and because
//! **we caused the last link ourselves**: `AceLibrary.lua` opens with
//!
//! ```lua
//! local version = GetBuildInfo()
//! if string.find(version, "^2%.") then
//! table_setn = function() end -- TBC: Lua 5.1, setn is gone
//! else
//! table_setn = table.setn -- 1.12: Lua 5.0, setn is real
//! end
//! ```
//!
//! Ace asks the client which client it is and picks the right dialect. We started answering
//! `"1.12.1"` truthfully (1192 phase 5, `GetBuildInfo`), Ace correctly took the 5.0 branch, and our
//! 5.1 VM raised. The addon was right, the API was right, and the interpreter underneath was
//! lying. That is what this module fixes.
//!
//! ## What it does, in three parts
//!
//! 1. **`table.setn`/`table.getn` get their 5.0 meaning back.** 5.0 remembers a table's size —
//! in a numeric `t.n` field if one already exists, otherwise in a private weak-keyed table
//! where nothing walking the table can see it. 5.1 removed the concept and made `setn` raise.
//! The exact mechanism matters more than it looks: see [`LUA_5_0_TABLE_SIZE`] for the draft
//! that got it wrong and the 39 addons that noticed.
//! 2. **The 5.1-only members are removed.** `string.gmatch`/`match`/`reverse`, `table.maxn`,
//! `math.fmod`/`modf`/`huge`/`cosh`/`sinh`/`tanh`, and the whole `coroutine` table — none of
//! which 1.12 has. This is 1189's *"a superset is not free"* one level below `_G`: an addon
//! that writes `string.gmatch or string.gfind` (Ace does, on the very next line) picks the
//! branch we leave standing, so leaving both standing chooses the wrong one for it.
//! 3. **The 5.0 compat globals the reference actually has get added** — `sort`, `foreach`,
//! `foreachi`. `reference/1.12-globals.tsv` lists all three; we had the other seventeen of that
//! family and not these.
//!
//! ## What this module does NOT have to do: the vararg `arg` table
//!
//! Lua 5.0 gives every vararg function an implicit `arg` table with an `n` field, and 1.12's
//! FrameXML uses it directly — `QuestTimerFrame_Update(...)` walks `for i=1, arg.n`, and so do
//! `FCFDropDown_LoadChannels`, `TradeSkill_OnEvent` and the GM-ticket and CVar families. Three of
//! our own transcriptions carry a note saying they rewrote it "the 5.1-native way" with
//! `select("#", ...)`, which reads as though the dialect demanded it.
//!
//! **It does not.** mlua's vendored 5.1 is built with `LUA_COMPAT_VARARG`, so `arg` and `arg.n`
//! are already there — measured, not assumed: `local function f(...) return arg.n end; f('a','b')`
//! answers 2 in this VM. That matters for 1751: a stock file using 5.0 varargs needs nothing from
//! this module, and the rewrite was a precaution against a problem we did not have.
//!
//! ## The one known divergence, stated rather than hidden
//!
//! `table.insert`/`table.remove` stay on 5.1's `#t` border rather than consulting `getn`. **This
//! is byte-confirmed as a real divergence, not a suspicion**: the RE dispatch found that 1.12's
//! whole table library is `n`-based — `luaL_getn` is called by `insert`/`remove`/`concat`/`sort`/
//! `foreachi` *and* base `unpack`, and `insert`/`remove` **update** the stored size. So
//! `setn(t, 0)` on a non-empty table makes the next `insert` land at index 1 there and at
//! `#t + 1` here.
//!
//! It stays, and the trade was measured before it was made. Over the 218-addon corpus there are
//! 1,720 `setn` call sites; **1,471 pass 0** — always immediately after a
//! `for k in pairs(t) do t[k] = nil end` wipe, where `#t` is already 0 and the two agree — and 290
//! more pass `table.getn(source)` straight after copying that source, where they agree too. Fewer
//! than 20 sites could observe the difference, and none of them is reached at load. Replacing
//! `table.insert` with a Lua-level reimplementation to serve those would put an interpreted
//! function on the hottest path in every addon in existence. Revisit it if a *runtime* failure is
//! ever traced here — not before.
//!
//! ## Ground truth (decision 1196 — verified, no longer derived)
//!
//! This module first shipped with its member lists taken from Lua 5.0's published library
//! registrations and cross-checked against two artifacts we hold. An RE dispatch into
//! wow-5875-re then read the binary (`system/ui/scratch/lua-dialect.md`), and every list here is
//! now the array in the image:
//!
//! - **Lua 5.0**, on five independent discriminators — not just the `$Lua: Lua 5.0 …` blob at
//! `0x811b30` but `LUA_REGISTRYINDEX = -10000` (5.1 uses −10002), a `luaT_eventname[]` pool with
//! no `__mod`/`__len` (so **no `%` and no `#` operator**), and live `luaL_getn`/`luaL_setn`.
//! - **Five libraries are opened** by `InitLua 0x7039e0` and no more: base (a 36-entry array at
//! `0x811e28` looped straight into `_G`), `string` (12, array `0x822d88`), `table` (8,
//! `0x822d40`), `math` (24, `0x822c60`), and **`bit`** (8, `0x822c18`). `luaL_openlib` has four
//! call sites image-wide, so `os`/`io`/`debug`/`coroutine` are absent by construction.
//! - **The compat globals are a Lua chunk, not C macros** — a 1310-byte `compat.lua` compiled into
//! `.data` at `0x8722e8`, run last at init. That is why the set is a specific subset (`getn`
//! aliased, `setn` deliberately not) rather than "whatever `LUA_COMPAT_*` gives you", and it is
//! also where the **degree-based** bare `sin`/`cos`/`tan` come from — a convention this crate's
//! stdlib layer had already reached independently.
use mlua::{Lua, Table, Value};
/// Members that exist in Lua 5.1 and **not** in Lua 5.0, by library.
///
/// Removed rather than left standing, per 1189: a feature-detecting addon branches on presence,
/// and the branch it should take is the one 1.12 gives it.
const REMOVED: &[(&str, &[&str])] = &[
// 5.1 added the new pattern-matching spellings; 5.0 has only `gfind` and no `reverse`.
("string", &["gmatch", "match", "reverse"]),
// 5.1 added `maxn`; 5.0's table library is concat/foreach/foreachi/getn/setn/sort/insert/remove.
("table", &["maxn"]),
// 5.1 renamed `mod`→`fmod` (we keep `mod`, which 1.12 has as a bare global too) and added
// `modf`, `huge`, and the hyperbolics.
("math", &["fmod", "modf", "huge", "cosh", "sinh", "tanh"]),
];
/// Lua 5.0's remembered table size, and the whole `n`-based table library that rides it
/// (decision 2102) — [`table_size`]'s own header is the mechanism, the byte census behind it, and
/// the runtime failure that retired this module's "revisit if ever traced" note.
mod table_size;
/// 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 gets its 5.0 meaning back ─────────────────────────────
table_size::install(lua)?;
// ── 2 · the 5.1-only members go ───────────────────────────────────────────────────────────
for (lib, names) in REMOVED {
let Ok(t) = g.get::<Table>(*lib) else {
continue;
};
for name in *names {
t.set(*name, Value::Nil)?;
}
}
// `coroutine` is not in the 1.12 client's `_G` at all — not a member gap, a whole library the
// client does not open. It was on `reference_surface`'s beyond-1.12 exception list as
// "inherited rather than chosen"; it is now neither.
g.set("coroutine", Value::Nil)?;
// ── 3 · the 5.0 compat globals the reference has and we lacked ────────────────────────────
// `sort`/`foreach`/`foreachi` are in `reference/1.12-globals.tsv`; `setn` deliberately is not,
// so no bare `setn` is installed even though `table.setn` now works. The client's compat set
// is a specific subset, not "all of them".
let table: Table = g.get("table")?;
for name in ["sort", "foreach", "foreachi"] {
if let Ok(f) = table.get::<Value>(name) {
if !matches!(f, Value::Nil) {
g.set(name, f)?;
}
}
}
// `getn` is re-bound here rather than left to the stdlib layer's `table.getn or …` fallback,
// so the bare global and the member are the same function and cannot drift apart.
g.set("getn", table.get::<Value>("getn")?)?;
// ── 4 · what the binary said and nobody had asked (decision 1196) ─────────────────────────
// `print` and `_VERSION` are **not in 1.12's `_G`** — the captured table says so and the RE
// dispatch found why: the base library is a 36-entry array looped into `_G`, and neither is in
// it (`_VERSION`'s literal is not even in the image). Both were on `reference_surface`'s
// exception list as "inherited rather than chosen"; nothing of ours uses either.
g.set("print", Value::Nil)?;
g.set("_VERSION", Value::Nil)?;
// `__pow` is a **global** in 1.12 (`function engine` in the captured table) — Lua 5.0
// implements `^` by calling it, and 5.1 made the operator native, so the name simply
// disappeared. Present here because an addon can see it, not because anything calls it.
g.set(
"__pow",
lua.create_function(|_, (a, b): (f64, f64)| Ok(a.powf(b)))?,
)?;
install_bit(lua)?;
Ok(())
}
/// The **`bit` library** — 1.12 opens one, and it is not stock Lua (decision 1196).
///
/// `InitLua 0x7039e0` opens exactly five libraries: base, `string`, `table`, `math`, and **`bit`**
/// (`0x7fadc0`, array `0x822c18`). The captured `_G` agrees — `bit` is there, attributed `engine`.
/// We had assumed the opposite: `attempt to index global 'bit'` showed up in the corpus survey and
/// was written off as an addon reaching for something 1.12 lacks. It was reaching for something
/// **we** lacked.
///
/// Eight functions, the array's own order and its own names. Semantics are 32-bit two's
/// complement, which is what every caller assumes (`band(flags, 0xFF)`); `arshift` is the
/// sign-propagating shift `rshift` is not, which is the only pair anyone gets wrong.
fn install_bit(lua: &Lua) -> mlua::Result<()> {
let bit = lua.create_table()?;
// Lua numbers are doubles; the client's bit ops truncate to a 32-bit int and return a signed
// one. `as i64 as u32` is the C cast chain (`(unsigned)(int)x`) rather than a saturating one,
// so `bnot(0)` answers -1 exactly as it does 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))))?,
)?;
// The shifts mask their count to 5 bits, like the x86 shift instructions the client compiles
// to — `lshift(1, 32)` is `1`, not `0`, and an addon that shifts by a computed width relies on
// it not trapping.
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` — integer remainder, not `math.mod`'s float one. The library's own eighth entry.
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)
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// **The 61-addon fix, as the addon actually writes it.**
///
/// This is `AceLibrary.lua`'s own opening, verbatim in shape: ask the client which client it
/// is, take the 5.0 branch, use `table.setn`. Under mlua's stock 5.1 the last line raises
/// `'setn' is obsolete` and the addon's first chunk dies — taking ~84 of 218 corpus addons
/// with it, because the Ace/FuBar half of the ecosystem embeds this file.
#[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);
}
/// `setn`/`getn` are 5.0's real pair — a remembered size that is **not** a no-op and **not** a
/// field the table's owner can see.
///
/// Both halves have already been got wrong once each. A no-op passes the Ace probe above and
/// fails the round-trip here; an unconditional `rawset(t, "n", …)` passes both and breaks 39
/// addons that walk the table (see [`LUA_5_0_TABLE_SIZE`]).
#[test]
fn getn_and_setn_remember_a_size_without_polluting_the_table() {
let s = UiScript::new().unwrap();
// No remembered size: the border, exactly as 5.1 answers.
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 anything walking the table. This is the assertion that would have
// caught the first draft: AceOO-2.0's `_Embed` walks a mixin's fields with `next` and
// errors on any the 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 carries a numeric `n` keeps using it, which is 5.0's own branch and
// why `arg.n` behaves the way 5.0-era code expects.
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, not a second implementation.
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.
///
/// `string.gmatch or string.gfind` is the line **directly below** Ace's `setn` probe: with
/// both present it takes `gmatch`, a function 1.12 does not have. That is 1189's superset
/// argument one level below `_G`, and this is the test that keeps it true.
#[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 that replace them 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"
);
}
}
/// `mod` still works after `math.fmod` is removed — the stdlib layer aliased the 5.1 name.
#[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);
}
/// The three compat globals the reference has and we did not.
#[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
);
}
/// The `bit` library exists in 1.12 and we did not have it (decision 1196).
///
/// `InitLua 0x7039e0` opens exactly five libraries and `bit` is the fifth; the captured `_G`
/// lists it as `engine`. The corpus's `attempt to index global 'bit'` had been written off as
/// an addon reaching past 1.12 — it was reaching past *us*.
#[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"
);
}
// Two's complement, signed out — `bnot(0)` is -1, not 4294967295.
assert_eq!(s.eval::<i64>("return bit.bnot(0)").unwrap(), -1);
assert_eq!(
s.eval::<i64>("return bit.band(0x1234, 0xFF)").unwrap(),
0x34
);
assert_eq!(s.eval::<i64>("return bit.bor(0xF0, 0x0F)").unwrap(), 0xFF);
assert_eq!(s.eval::<i64>("return bit.bxor(0xFF, 0x0F)").unwrap(), 0xF0);
assert_eq!(s.eval::<i64>("return bit.lshift(1, 4)").unwrap(), 16);
// The pair everyone gets wrong: 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, like the x86 instruction it compiles to.
assert_eq!(s.eval::<i64>("return bit.lshift(1, 32)").unwrap(), 1);
assert_eq!(s.eval::<i64>("return bit.mod(17, 5)").unwrap(), 2);
}
/// `print` and `_VERSION` are **not** in 1.12's `_G`, and `__pow` is.
///
/// All three are things a `_G`-shaped instrument could always have caught and nobody had
/// looked: the first two sat on the beyond-1.12 exception list as "inherited rather than
/// chosen", and the third was simply missing.
#[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`; 5.1 made it native and the global vanished.
assert_eq!(s.eval::<f64>("return __pow(2, 10)").unwrap(), 1024.0);
}
/// **The corpus's other dialect probe, answered the way 1.12's parser answers it** (2101,
/// closing 1208).
///
/// Ace2 asks "am I on Lua 5.1?" by compiling a 5.1-only construct — the vararg *expression*,
/// which 5.0 has no grammar for (5.0 collects varargs into `arg`; the shipped 1.12 FrameXML
/// reads `arg`/`arg.n` 64 times across 177 files and never once writes `...` as a value):
///
/// ```lua
/// local lua51 = loadstring("return function(...) return ... end") and true or false
/// ```
///
/// 170 `loadstring` sites across the 219-addon corpus are that one question. Answering it
/// `true` puts every Ace-embedding addon on its client-2.0 branch — which is how Cartographer
/// came to hook `CloseSpecialWindows`, a name 1.12's `_G` does not have.
///
/// The fix is the parser, not a library: `simpleexp` (client `0x6fd240`) has no `TK_DOTS`
/// arm, so `...` as a value falls to `prefixexp` and raises. The exact probe line is asserted
/// here in the shape the addons write it.
#[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"
);
// ...and it fails the way 5.0 fails it: `simpleexp`'s default arm -> `prefixexp`, whose
// head accepts only `(` or a NAME. Same reject site, same words, as a stray `;`.
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 same question, which we already answered correctly (1194): `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"
);
// And now EVERY vararg function gets one. 5.1 cleared VARARG_NEEDSARG for any function
// that mentioned `...`; with the arm deleted nothing clears it, which is 5.0's rule.
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 is 5.0's own grammar and must still parse — the deletion is
// `simpleexp`'s arm, not `parlist`'s.
assert!(s
.eval::<bool>(r#"return loadstring("return function(...) return arg.n end") ~= nil"#)
.unwrap());
}
/// **`#` and `%` are not in 1.12's grammar either** (2101) — the same class, the same
/// byte evidence, gated in the same hunk-set.
///
/// `getunopr` (`0x6fe0a0`) is a leaf testing exactly two tokens, `-` and `not`, so its
/// `OPR_NOUNOPR` is 2 — a three-member enum, 5.0's, not 5.1's four-member one with
/// `OPR_LEN`. `getbinopr` (`0x6fe0c0`) bases its switch at `'*'` (0x2A); `%` is 0x25, below
/// the range, and reaches `OPR_NOBINOPR` = 14 — a fifteen-member `BinOpr`, again 5.0's. The
/// metamethod-name pool at `0x871896` has neither `__len` nor `__mod`, which is where
/// wow-5875-re saw it first (`system/ui/scratch/lua-dialect.md` §1).
#[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"
);
}
// The 5.0 spellings of both, which the reference has and every 1.12-era addon uses.
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 shapes the reference's own files are written in still load** — the other half of the
/// gate, and the one that would make it a regression if it ever stopped being true.
///
/// Measured, not assumed: the three constructs above appear **zero** times in the extracted
/// 1.12 FrameXML (177 files), GlueXML, Blizzard's own addons, and the director's installed
/// AddOns folder. This chunk is the 5.0 grammar those files actually use, including the three
/// 5.0-isms the fork restores (1215's iterator-less generic-for, `LUA_COMPAT_LSTR`'s nesting
/// long strings, 1315's constructor 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 (1215)
local sum = 0
for k, v in { 3, 4 } do sum = sum + v end
-- a table constructor with 5.0's compat semicolon (1315)
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::<i64>("return BENILLA_GRAMMAR_OK").unwrap(),
6 + 7 + 3 + 2 + 1 + 21
);
}
/// **The six `debug*` stubs are the REFERENCE's no-ops, not ours** — and that distinction is
/// the whole reason they are allowed to exist under real 1.12 names.
///
/// wow-re carved all eight (2026-08-11): `debuginfo`, `debugload`, `debugprint`, `debugdump`,
/// `debugbreak` and `debugtimestamp` are byte-identical `xor eax,eax; ret` — three bytes, no
/// call, no memory write, **zero Lua return values**. Only `debugprofilestart`/`stop` are real.
/// A no-op we invented would be the "capability absent without a failure" class 1203 named;
/// a no-op the client itself ships is a transcription.
#[test]
fn the_debug_family_is_six_stubs_and_two_real_ones() {
let s = UiScript::new().unwrap();
// Zero values, not nil — `select('#')` is the only check that can tell them apart.
for name in [
"debuginfo",
"debugload",
"debugprint",
"debugdump",
"debugbreak",
"debugtimestamp",
] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', {name}())"))
.unwrap(),
0,
"{name} returns nothing at all"
);
}
// ...and the two that are real answer a number of milliseconds.
let ms: f64 = s
.eval("debugprofilestart() return debugprofilestop()")
.unwrap();
assert!(ms >= 0.0, "elapsed milliseconds, not nil: {ms}");
}
/// The divergence that USED to be pinned here is gone (decision 2102): `table.insert` now
/// consults the remembered size, as `0x7fb6d8` does. The behaviour it asserted — an append
/// landing at the border after `setn(t, 0)` — was traced to a real addon failure, so the
/// mechanism moved to [`super::table_size`] and its tests moved with it. This is the one line
/// that used to say otherwise, kept as the shape a reader will look for.
#[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"
);
}
/// **`{ [1] = a, [2] = b, … }` is a LIST, and `pairs` walks it 1, 2, 3** — decision 2111, the
/// fourth "restore what 5.1 changed" hunk in `third_party/lua-src`.
///
/// 1.12's `recfield` keeps `cc->nh++` **inside** its `TK_NAME` arm (`0x6fd5a4`'s
/// `cmp [edi+0x10],0x116` / `0x6fd5ad jne 0x6fd5d4` — 5.0's own placement), so a
/// `[expr] = value` field credits **neither** `OP_NEWTABLE` size hint. The table is born with
/// `B = C = 0`, i.e. on the shared dummy node, so the first store finds no free node, takes
/// `luaH_newkey`'s rehash tail, and `rehash` sizes an **array part** for the dense integer
/// keys; `luaH_next` walks the array part first, ascending by index, and the node part after.
/// Stock 5.1 moved that `cc->nh++` out of the arm, pre-sized a node vector big enough that no
/// rehash ever fires, and left all n keys in the hash — where `next` is slot order.
///
/// wow-5875-re `system/ui/scratch/lua-table-storage-and-next-order.md` — **executed** on
/// `WoW.exe`'s own bytes (`lua_open` → `luaL_loadbuffer` → `lua_pcall` → `lua_next`), not
/// derived, including the exact `Bagnon_Core.lua` below; ascending holds for n = 1..24 and
/// regardless of the order the fields are written in.
///
/// It matters because every saved-variables file we write is that constructor (decision 1128's
/// grammar, and the reference's own writer's — it never emits a bare positional entry), so
/// before this hunk every list an addon saved came back in hash order. Bagnon 6.10.22 walks
/// its saved bag order with `pairs` and drew the keyring first, ahead of the backpack.
#[test]
fn a_bracketed_key_constructor_is_an_array_and_walks_in_index_order() {
let s = UiScript::new().unwrap();
// Bagnon's own table, exactly 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 — this is the director's Bagnon grid"
);
// …and 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,"
);
// The positional spelling was always an array and still is — the control that keeps the
// first assertion from reading as vacuous.
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,"
);
}
}