//! **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. //! //! **And the other half of that, which costs an hour to rediscover: `...` is a DECLARATION token //! here and nothing else.** This VM parses `function(x, ...)`, and then rejects every *use* of //! `...` as an expression — `return ...`, `f(x, ...)`, and `local a = ...` at chunk level — with //! `unexpected symbol near '...'`. `arg` and `unpack(arg)` are the only forwarding forms //! ([`vararg_is_a_declaration_only`] pins all six). That is 5.0's own shape and exactly right for //! the content we run, but it also binds anything WE write in Lua: an engine-side helper cannot //! forward an unknown argument list without a table, so a verb whose arity matters (`GetPoint()` //! vs `GetPoint(nil)`, `SetAllPoints()` vs `SetAllPoints(nil)`) has to stay on the Rust side. //! Decision 2310 is where that bit — a Lua dispatcher for the Region method map would have been //! ~4x cheaper than the Rust one and cannot be written in this dialect. //! //! ## 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**: 1.12's whole table library is //! `n`-based — `luaL_getn 0x6f5050` is called by `insert`/`remove`/`concat`/`sort`/`foreachi` *and* //! base `unpack`, and `insert`/`remove` **update** the stored size (`luaL_setn 0x6f4ea0`). 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. Every list here is now read //! straight off 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::(*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)?; // `select` is 5.1's base library, not 5.0's, and the 1.12 client's `_G` has no row for it. // It sat on `reference_surface`'s beyond-1.12 exception list under a reason that expired twice // over: first "our transcribed FrameXML uses it in 16 files" — 1751's migration retired every // one of those files, measured at zero by 2142 — then "it is mlua's, not ours to remove // without the 5.1 varargs it comes with". 2101 deleted those varargs from the grammar, which // is what turns this from a trade into a deletion: with `...`-as-a-value gone, `select`'s one // idiomatic shape cannot be written at all, and what is left — `select(k, f())` — is a 5.1 // spelling of the multiple assignment 5.0 writes with commas. // // **Nothing of ours called it in production**: 177 sites in 55 files were our own TESTS asking // a binding's arity, which is a host-side question and is now asked host-side // ([`super::UiScript::arity`]). The handful that need the count *inside* Lua use 5.0's own // answer, `arg.n` — which this VM hands every vararg function since 2101 deleted the arm that // cleared `VARARG_NEEDSARG`. g.set("select", Value::Nil)?; // **The string type has NO metatable in 1.12**, so `("x"):upper()` — and `s:sub(1, 2)` for a // string `s` — raises `attempt to index a string value` there and quietly worked here. 5.1's // `luaopen_string` ends in a `createmetatable` 5.0's does not have, and mlua runs 5.1. // // Read out of the client, and the claim is stronger than "nobody installs one": the type // *cannot* carry one, on the **reader** side, so the writer side never has to be argued. // `luaT_gettmbyobj 0x6f7bd0` is the one function the VM asks for any value's metamethod (7 // rel32 callers, covering TM_INDEX/TM_NEWINDEX/TM_CALL/order/arith). It loads `o->tt` at // `0x6f7be2` and enumerates two tags — `sub ecx,5; je` (LUA_TTABLE), `sub ecx,2; je` // (LUA_TUSERDATA) — then falls through at `0x6f7bee` to `mov eax,0x811bc0`, `&luaO_nilobject` // in **`.rdata`**: sixteen zero bytes with no writers by construction. There is nothing to // read, so a `__index` for a string cannot exist however it were installed. `G+0x80` is // `tmname[15]`, the metamethod *name* strings, not a per-type metatable array; a ModRM/SIB // census of every ×4-indexed operand across the text section finds no such read into writable // `.data` at all. // // The writer agrees from the other end. `lua_setmetatable 0x6f4020` takes the same two arms // (`0x6f409e`, `0x6f40a3`) into one `mov [edx+8], esi` — 5.0 puts `metatable` at offset 8 of // both `Table` and `Udata` — and every other tag falls to `0x6f40a8`'s `xor eax,eax`: return // 0, write nothing. `lua_getmetatable 0x6f3cf0` carries the identical `{5, 7}` switch. And the // layout settles it a third way: `luaS_newlstr 0x6f9d00` puts a string's **hash** at `+8` // (`0x6f9dbb`), the very offset the other two use for the metatable pointer. // // `luaopen_string 0x7fd810` is six instructions, `[0x7fd810, 0x7fd827)` — `push 0; // push 0x822d88; mov edx,0x871938; call luaL_openlib; mov eax,1; ret` — with no // `createmetatable` step, and `luaL_openlib` does not reach `0x6f4020` either. // // So this is not a policy choice about a superset — it is the type system. Note the reference // also *raises* rather than no-ops if an addon tries it itself: base `setmetatable 0x702a40`'s // first act is `luaL_checktype(L, 1, LUA_TTABLE)`, which mlua's 5.1 matches. // // Same rule as `coroutine` above, one layer below `_G`: an addon writing `s:gsub(...)` or // testing `getmetatable("")` is asking which interpreter it is on, and the branch it should // get is 1.12's. lua.set_type_metatable::(None); // ── 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::(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::("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 base // library's 36-entry array at `0x811e28` confirms why: 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)?; install_gc(lua)?; install_assert(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) } /// The **garbage-collector pair** — `gcinfo` answers TWO numbers and `collectgarbage` answers /// NONE and takes a *number* (decision 2136). /// /// Both are 5.0-shaped, and 5.1 changed both in ways that are observable from Lua — already /// carried in `reference/1.12-shapes.tsv` as `gcinfo … 2 exact (number,number) agree` and /// `collectgarbage … 0 exact () agree` — rows the return-shape gate never checked, because it /// filters to `table_kind = global` and these two are `baselib`. /// /// **`gcinfo 0x703200`** — extent `[0x703200, 0x703243)`, reads no argument and cannot raise: /// `lua_getgccount 0x6f43f0` then `lua_getgcthreshold 0x6f43e0`, each shifted `>> 10`, then /// `mov eax, 2`. So it is `(count_KB, threshold_KB)` — 5.1 kept the name and dropped the second /// value, which is what we answered. /// /// **`collectgarbage 0x703250`** — extent `[0x703250, 0x703273)`, `luaL_optnumber(L, 1, 0.0)` → /// `_ftol` → `lua_setgcthreshold 0x6f4400` → `xor eax,eax`. Zero values, and a **number** argument: /// 5.1's string options (`"count"`, `"collect"`, `"step"`, …) do not exist in the 35 bytes. The /// two dialects are therefore *inverted* on argument type — 5.1 rejects the number the reference /// requires, and accepts the strings the reference refuses: /// /// | call | the reference | stock 5.1 (what we had) | /// |---|---|---| /// | `collectgarbage()` | threshold 0 → full collect, 0 values | 1 value | /// | `collectgarbage(0)` | the same, explicitly | **raises** ``invalid option `0'`` | /// | `collectgarbage("count")` | **raises** `number expected, got string` | returns a number | /// /// **The threshold is modelled, and this is the one place a value is ours rather than the /// image's.** 5.1 replaced 5.0's stop-the-world collector with an incremental one and deleted /// `GCthreshold` outright, so there is no 5.1 counter to read. What a script *can* observe is the /// rule that moves it, and that rule is 5.0's own: `luaC_collectgarbage` ends /// `G->GCthreshold = 2*G->nblocks`, and `lua_setgcthreshold` writes `n << 10` then immediately /// collects if the live count has already reached it. Both are reproduced below against mlua's /// real heap, so `gcinfo()`'s second value tracks the first exactly as 5.0's does. The saturation /// is the image's: `0x6f4400` compares **unsigned** against `0x3fffff`, so a negative `n` — or one /// past 4194303 — saturates the threshold to `0xffffffff` bytes. /// /// **The reach is measured, not assumed.** `local mem, threshold = gcinfo()` is /// `AceAddon-2.0.lua`'s memory report (36 copies in the 219-addon corpus), and the next line is /// `string.format("… %.3f MiB …", threshold / 1024)` — arithmetic on a nil, i.e. a raise inside /// the report. pfUI's `modules/panel.lua:170` reads the same slot as `gckb` and guards it, so it /// silently prints `UNAVAILABLE` where the reference prints the number. `collectgarbage` has 14 /// corpus call sites and every one of them is a bare `collectgarbage()` whose result is /// discarded, so its arity has no reach today — the argument contract is what this fixes. fn install_gc(lua: &Lua) -> mlua::Result<()> { /// 5.0's `GCthreshold`, in **bytes** — the registry cell standing in for `[G+0x24]`. 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, cannot raise. 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's invariant, not just its arithmetic.** `luaC_checkGC` runs on allocation, so // the instant `nblocks` reaches `GCthreshold` a collection fires and re-settles the // threshold to `2 * nblocks`. A script therefore never observes a threshold at or // below the live count. We have no allocation hook, so the same rule is applied on // read: without it the cell set at construction goes stale as the heap grows and // `gcinfo()` reports a threshold *under* its own count, a state 5.0 cannot hold. if (live as f64) >= threshold { settle(lua)?; threshold = lua.named_registry_value(REG_GC_THRESHOLD).unwrap_or(0.0); } // Both shifts are the image's own `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 that is not one. lua.globals().set( "collectgarbage", lua.create_function(|lua, arg: Value| { // `luaL_optnumber(L, 1, 0.0)`: absent or nil takes the default; a numeric STRING is // coerced (the image's `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`: collect when the live count has reached the threshold, and the // collection then re-settles it to `2 * nblocks`. if (live_bytes(lua) as f64) >= threshold_bytes { lua.gc_collect()?; settle(lua)?; } Ok(()) })?, )?; Ok(()) } /// The type name `luaL_typerror` would print — `luaT_typenames 0x811cd0`'s spelling. /// **`assert` answers ONE value in 5.0, and all of its arguments in 5.1.** /// /// `luaB_assert 0x7031a0` is five calls: `luaL_checkany 0x6f4bb0` on argument 1, /// `lua_toboolean 0x6f3660`, and on the truthy leg `0x7031e5 call 0x6f3080` — `lua_settop(L, 1)` /// — then `0x7031ea mov eax,1`. The false leg is /// `luaL_optlstring(L, 2, "assertion failed!" /*0x872bdc*/)` into `luaL_error 0x6f4940`, which /// longjmps. 5.1 replaced the settop/`mov eax,1` pair with `return lua_gettop(L)`, so /// `assert(a, b)` answers two values there and one here. /// /// Found by the shape gate's base-library arm once it grew an argument ladder — the row /// (`arity 1 exact`) has been in `reference/1.12-shapes.tsv` since it was vendored, and a /// no-argument probe could never reach it because `assert()` raises. /// /// **Reach, measured rather than assumed:** 5 647 `assert(` sites across the 219-addon corpus and /// 73 in the shipped FrameXML, and **zero** of them bind more than one name from the result. So /// this buys the contract, not a fixed addon — the same footing as `collectgarbage`'s argument /// (2136 §3). The next caller to write `local a, b = assert(f())` is the one that finds out. 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` does not, it // is 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 is its own bad-argument // raise, in 5.0's own quoting (2122). 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; /// **`gcinfo()` answers TWO numbers, and the second one is what the corpus reads** /// (decision 2136). /// /// `0x703200` is `lua_getgccount` + `lua_getgcthreshold`, each `>> 10`, then `mov eax, 2`. /// 5.1 kept the name and dropped the second value, so we answered one — and the reach is not /// hypothetical: `AceAddon-2.0.lua`'s memory report is /// `local mem, threshold = gcinfo()` followed by `string.format("… %.3f MiB …", /// threshold / 1024)`, which is arithmetic on a nil, i.e. a raise *inside* the report, in a /// library 36 corpus addons embed. pfUI's `modules/panel.lua:170` reads the same slot as /// `gckb`, guards it, and so prints `UNAVAILABLE` where the reference prints a number. #[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`" ); // 5.0's collector keeps `GCthreshold` above `nblocks` (`luaC_checkGC` collects the moment // they meet, and the collection re-settles the threshold to `2 * nblocks`), so a script // cannot observe the pair inverted. 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 own line, verbatim in shape — this is the raise the fix removes. 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" ); } /// **`collectgarbage` answers NOTHING and takes a NUMBER** (decision 2136) — the two dialects /// are inverted on the argument, so this is not a superset either way. /// /// `0x703250` is 35 bytes: `luaL_optnumber(L, 1, 0.0)` → `_ftol` → `lua_setgcthreshold` → /// `xor eax,eax`. There is no string-option dispatch anywhere in it — that is 5.1's /// `collectgarbage`, and on this client `gcinfo()` is the only way to read the counters. #[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, which stock 5.1 rejects as ``invalid option `0'``. assert_eq!(s.arity("collectgarbage(0)").unwrap(), 0); assert!(s.eval::<()>("collectgarbage(2048)").is_ok()); // `lua_tonumber` coerces a numeric string through `strtod`, so this one is accepted. assert!(s.eval::<()>(r#"collectgarbage("100")"#).is_ok()); // …and a non-numeric string is a type error, in 5.0's own 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` then reports back. assert!(s .eval::( "local c = gcinfo() collectgarbage(c * 4) local _, t = gcinfo() return t >= c" ) .unwrap()); } /// **A `loadstring` chunk is named by its own SOURCE, and an explicit name is used verbatim** /// (decision 2136). /// /// `luaB_loadstring 0x703280` pushes the string `luaL_checklstring` just returned as /// `luaL_optlstring`'s `def` (`0x70329a`), so the default chunk name is the source text and /// `luaO_chunkid 0x6f5c40` renders it `[string "…"]`. We prepended `=` — chunkid's /// "print verbatim, undecorated" marker — which stripped the wrapper off every explicit name, /// left a literal `@` on a path-shaped one, and named a nameless chunk `(loadstring)`, a /// literal the image does not contain. All four are the prefix of a player-visible error. #[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 with the marker removed, exactly once. assert!(raised(r#""error('boom')", "=myname""#).starts_with("myname:1: boom")); // `'@'` — the file branch; the path prints plainly, without the marker. assert!(raised(r#""error('boom')", "@a/b.lua""#).starts_with("a/b.lua:1: boom")); // No marker — the third branch wraps it, which is what an unprefixed name gets. Four // corpus sites pass one (`loadstring(code, "safecall Dispatcher["..n.."]")`). assert!(raised(r#""error('boom')", "plain""#).starts_with(r#"[string "plain"]:1: boom"#)); // The failure leg returns Lua's message unchanged — `load_aux` pushes nil and the string // and stops. mlua's `Display` prefixes a category word that the image never 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" ); } /// **`assert` answers ONE value — 5.0's `lua_settop(L, 1); return 1`, not 5.1's /// `return lua_gettop(L)`** (`luaB_assert 0x7031a0`, settop at `0x7031e5`, `mov eax,1` at /// `0x7031ea`). /// /// Found by the shape gate's base-library arm once it grew an argument ladder; the row has /// said `arity 1 exact` since the table was vendored, and only a call with MORE than one /// argument can tell the two dialects apart. #[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); // A truthy `false`-adjacent value is still truthy: only nil and false take the 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 its own bad-argument raise, // in 5.0's backtick quoting (2122). 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()") ); } /// **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::("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::("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 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::( "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::("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::("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::(&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::(&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::("return mod(7, 3)").unwrap(), 1.0); assert_eq!(s.eval::("return floor(2.7)").unwrap(), 2.0); } /// **`...` declares a vararg function here and can never be read.** The six forms, together, /// because the split between them is the whole surprise: the declaration parses, every use /// does not, and `arg` carries what `...` cannot (the module header's second vararg note). #[test] fn vararg_is_a_declaration_only() { let s = UiScript::new().unwrap(); let parses = |src: &str| s.run(src).is_ok(); // Declaration, and the 5.0 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 — including at chunk level, where 5.1 proper allows // it and this build does not. 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 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::("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 ); } /// 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::(&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); assert_eq!( s.eval::("return bit.band(0x1234, 0xFF)").unwrap(), 0x34 ); assert_eq!(s.eval::("return bit.bor(0xF0, 0x0F)").unwrap(), 0xFF); assert_eq!(s.eval::("return bit.bxor(0xFF, 0x0F)").unwrap(), 0xF0); assert_eq!(s.eval::("return bit.lshift(1, 4)").unwrap(), 16); // The pair everyone gets wrong: 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, like the x86 instruction it compiles to. 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. /// /// 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::("return print == nil").unwrap()); assert!(s.eval::("return _VERSION == nil").unwrap()); // 5.0 implements `^` by calling `__pow`; 5.1 made it native and the global vanished. assert_eq!(s.eval::("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::( 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::("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::("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::(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`. #[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" ); } // The 5.0 spellings of both, which the reference has and every 1.12-era addon uses. 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); } /// **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::("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. /// /// Six of the eight, `[0x7027e0, 0x702840)` — `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 — a count is the only check that can tell them apart, and // `arity` is where it is taken now that `select` is gone (2171). 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 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::("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. /// /// This was measured by executing `WoW.exe`'s own bytes (`lua_open` → `luaL_loadbuffer` → /// `lua_pcall` → `lua_next`), not merely derived from the disassembly — 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::( "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::( "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," ); // 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::( "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; /// **Every error message quotes a program element the way 5.0 does — `` `x' ``, not `'x'`` /// (decision 2122).** /// /// 5.1 introduced `LUA_QL` and made it two apostrophes; the fork's `luaconf.h` puts 5.0's /// backquote back. The five formats it feeds are all readable in `WoW.exe`'s own `.rdata`, and /// the assertions below are those strings: /// /// ```text /// bad argument #%d to `%s' (%s) lauxlib.c luaL_argerror /// calling `%s' on bad self (%s) lauxlib.c luaL_argerror's method arm /// attempt to %s %s `%s' (a %s value) ldebug.c luaG_typeerror /// %s:%d: %s near `%s' llex.c luaX_lexerror (the client's own 0x87217c) /// in function `%s' ldblib.c the line debugstack renders /// ``` /// /// **The traceback line is the one this does NOT reach**, and it is the one with teeth. /// `debugstack` delegates to mlua's own traceback, which is built in mlua's C shim rather than /// in this fork, so `LUA_QL` does not reach it: it still renders `in function 'X'`. That /// matters because AceLibrary — shipped inside ~80 corpus addons — reads its own caller back /// out of `debugstack()` with `string.find(debugstack(), "`argCheck'.-([`<].-['>])")`, whose /// two patterns both require the backquote; the nil it gets is then handed to a `%s`, so an /// argument-check *diagnostic* raises inside the error path. Closing that means rendering the /// traceback ourselves in the reference's own shape (`debugstack 0x703760`) rather than /// copied from stock 5.0's `ldblib.c`. #[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 own `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}" ); // And no message anywhere OPENS a quote with an apostrophe — 5.1's spelling puts one // where 5.0 puts the backquote, so the tell is a `'` right after a space or a paren. // (The closing quote is an apostrophe in both dialects, which is why this looks for the // opening one and not for the character.) 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 not a 1.12 global** (decision 2171) — and 5.0's own answer to the same /// question still is. /// /// The pair matters more than the removal. `select('#', …)` was how 177 of our own tests asked /// a binding's arity, and the property those gates rest on is that **zero returns and one /// `nil` are different answers** (`binding_abi` §2) — a distinction no `Option` return type /// can hold. 5.0 answers it with the implicit vararg table's `n`, which this VM hands every /// vararg function since 2101 deleted the parser arm that cleared `VARARG_NEEDSARG`; the host /// answers it with [`UiScript::arity`]. If either ever stops telling those apart, the arity /// gates start silently passing a binding that returns nothing. /// /// The removal's own reach is in the corpus rather than here: `pfUI/libs/libpredict.lua:141` /// gates its **TBC** HealComm parser on `select and UnitCastingInfo`, and pfUI supplies the /// second name itself (`libs/libcast.lua:86`, loaded 3rd against libpredict's 10th), so /// publishing `select` was the whole reason that branch ran. `BuffCheck2.lua:1157` is /// `select = select or function(idx, ...)` — a 1.12 addon's own polyfill that ours suppressed. #[test] fn select_is_not_a_1_12_global_and_arg_n_answers_instead() { let s = UiScript::new().unwrap(); assert!( s.eval::("return select == nil").unwrap(), "`select` is 5.1's base library; `reference/1.12-globals.tsv` has no row for it" ); // 5.0's spelling, in the shape an addon writes it. assert_eq!( s.eval::>( "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, which is what our own tests ask now. 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 installs no metatable on the string type**, so method-call syntax on a string raises /// there and quietly worked here (decision 2171). /// /// The claim is stronger than "nobody installs one": the reference's `lua_setmetatable /// 0x6f4020` accepts exactly two type tags — `LUA_TTABLE` and `LUA_TUSERDATA`, sharing one /// `mov [edx+8], esi` because 5.0 puts `metatable` at offset 8 of both structs — and returns 0 /// without writing for every other tag. 5.0 has no `G(L)->mt[]` array for a string to have a /// slot in, so the type *cannot* carry one; `luaopen_string 0x7fd810`'s four instructions /// are the same fact from the other end. /// /// The **wording** is asserted, not just the raise: it is what a player sees in a script /// error, and 5.0's quoting convention is already load-bearing one file over — `sandbox`'s /// `debugstack` emits 5.0 frames because `AceLibrary.lua:139`'s `argCheck` matches /// ``"([`<].-['>])"`` and cannot see 5.4's `'name'` at all. Both strings are byte-verified /// and executed on the reference: `.data 0x871c10` ``attempt to %s a %s value`` for the /// anonymous form, `.data 0x871c2c` ``attempt to %s %s `%s' (a %s value)`` for the named one — /// **backtick open, apostrophe close**. #[test] fn the_string_type_has_no_metatable() { let s = UiScript::new().unwrap(); assert!( s.eval::(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:?}" ); // The other direction, and the one that makes this a regression if it ever fails: // removing the metatable removes a *dispatch*, never a function. The 12-member string // library and the bare aliases the reference publishes over it are untouched. 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(); } }