- dialogs carry the reference's toplevel bit, so the stack-split window opens above Bagnon instead of behind it - the failure line fills its blank — "Requires Blacksmith Hammer", not "Requires" — and Opening casts stop printing a label the spell itself marks "no text" - the pet bar's Attack/Follow/Stay tooltips name their keybinding, and typed text reaches the focused frame through the client's own delivery walk - AtlasLoot parses again: our Lua keeps 5.0's compatibility semicolon Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
286 lines
11 KiB
Rust
286 lines
11 KiB
Rust
//! Lua 5.0's iterator-less generic-for, restored in the vendored VM (decision 1215).
|
|
//!
|
|
//! `for k, v in someTable do` is Lua 5.0 syntax that 5.1 removed at the OPCODE level. 183 of 218
|
|
//! corpus addons are reached by it and it is the first session-start error for 60 of them — three
|
|
//! quarters of everything that breaks in a running session. The fix is 5.0's `OP_TFORPREP` folded
|
|
//! into `OP_TFORLOOP` in `third_party/lua-src/lua-5.1.5/lvm.c`.
|
|
//!
|
|
//! Each test below pins one detail that was byte-verified in the real 1.12.1 client (wow-5875-re
|
|
//! `system/ui/scratch/lua-generic-for.md`), because each is a place where a *reasonable* guess
|
|
//! diverges from what the client does. The `__call` case is the sharpest: two of the three
|
|
//! conditions somebody would naturally write for this behave differently there.
|
|
|
|
use super::common::script;
|
|
|
|
/// **The construct works at all** — the whole point, in the shape the corpus uses.
|
|
///
|
|
/// `Ace.lua`'s is the canonical one and Ace is embedded across half the ecosystem:
|
|
/// `for _, lang in self.langs do … end`, with no `pairs` and no iterator function.
|
|
#[test]
|
|
fn a_table_generator_iterates_like_lua_50() {
|
|
let s = script();
|
|
let seen: i64 = s
|
|
.eval(
|
|
r#"
|
|
local t = { a = 1, b = 2, c = 3 }
|
|
local sum = 0
|
|
for k, v in t do sum = sum + v end
|
|
return sum
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(seen, 6, "every pair must be visited exactly once");
|
|
|
|
// The array shape too, and the KEY must be the real key rather than a counter.
|
|
let keys: String = s
|
|
.eval(
|
|
r#"
|
|
local t = { "x", "y" }
|
|
local out = ""
|
|
for i, v in t do out = out .. i .. v end
|
|
return out
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(keys, "1x2y");
|
|
}
|
|
|
|
/// **The condition is a bare type-tag test, so a table carrying `__call` STILL gets `next`.**
|
|
///
|
|
/// This is the discriminating case, and the reason the patch does not test "is not callable" or
|
|
/// "has no `__call`" — both of those are reasonable readings and both diverge from the client here.
|
|
/// The 1.12 handler makes **no** metatable access whatsoever (no `luaT_gettmbyobj`, no
|
|
/// `tryfuncTM`), so the `__call` is never consulted and the table is iterated.
|
|
#[test]
|
|
fn a_table_with_a_call_metamethod_is_still_iterated_not_called() {
|
|
let s = script();
|
|
let out: String = s
|
|
.eval(
|
|
r#"
|
|
local called = false
|
|
local t = { only = "pair" }
|
|
setmetatable(t, { __call = function() called = true return nil end })
|
|
local keys = ""
|
|
for k, v in t do keys = keys .. k end
|
|
return keys .. "|" .. tostring(called)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
out, "only|false",
|
|
"the table must be ITERATED and its __call never invoked"
|
|
);
|
|
}
|
|
|
|
/// **The callee is the global `next`, read fresh at each loop entry — so an addon can replace it.**
|
|
///
|
|
/// Observable behaviour of the real client, not an accident: the lookup is a raw read of the
|
|
/// thread's globals table, performed once per loop entry. An addon that assigns `next = myfn`
|
|
/// changes every later generic-for in the session. Reproduced rather than improved on.
|
|
#[test]
|
|
fn the_substituted_generator_is_the_live_global_next() {
|
|
let s = script();
|
|
let out: String = s
|
|
.eval(
|
|
r#"
|
|
local realnext = next
|
|
local calls = 0
|
|
next = function(t, k) calls = calls + 1 return realnext(t, k) end
|
|
local t = { a = 1, b = 2 }
|
|
local n = 0
|
|
for k, v in t do n = n + 1 end
|
|
next = realnext
|
|
return n .. "|" .. (calls > 0 and "hooked" or "not hooked")
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
out, "2|hooked",
|
|
"a replaced global `next` must drive the loop, as it does on the client"
|
|
);
|
|
}
|
|
|
|
/// **Only a table is substituted.** A function generator is untouched (the ordinary 5.1 path), and
|
|
/// a non-table, non-function generator still raises on the FIRST ITERATION rather than at entry —
|
|
/// the client's tag mismatch falls through to the normal call path.
|
|
#[test]
|
|
fn only_a_table_is_substituted_and_other_types_still_raise() {
|
|
let s = script();
|
|
|
|
// The ordinary iterator form is completely unaffected.
|
|
let via_pairs: i64 = s
|
|
.eval(
|
|
r#"
|
|
local t = { a = 1, b = 2, c = 3 }
|
|
local n = 0
|
|
for k, v in pairs(t) do n = n + 1 end
|
|
return n
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(via_pairs, 3);
|
|
|
|
// A custom iterator function still drives its own loop.
|
|
let custom: i64 = s
|
|
.eval(
|
|
r#"
|
|
local function upto(state, i)
|
|
i = i + 1
|
|
if i <= state then return i end
|
|
end
|
|
local sum = 0
|
|
for i in upto, 3, 0 do sum = sum + i end
|
|
return sum
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
custom, 6,
|
|
"an explicit generator/state/control triple is untouched"
|
|
);
|
|
|
|
// A number generator is not a table: it must still raise, and say so.
|
|
let err = s
|
|
.eval::<i64>("local n = 0 for k, v in 42 do n = n + 1 end return n")
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
err.contains("attempt to call a number value"),
|
|
"a non-table, non-function generator must still raise: {err}"
|
|
);
|
|
}
|
|
|
|
/// **An empty table terminates immediately** rather than looping — `next(t, nil)` on an empty table
|
|
/// returns nil, and the loop's own continue test is unchanged by the substitution.
|
|
#[test]
|
|
fn an_empty_table_generator_terminates() {
|
|
let s = script();
|
|
let n: i64 = s
|
|
.eval("local n = 0 for k, v in {} do n = n + 1 end return n")
|
|
.unwrap();
|
|
assert_eq!(n, 0);
|
|
}
|
|
|
|
/// **Nested and repeated loops each get their own substitution.** The patch rewrites the loop's own
|
|
/// registers, so an inner loop must not disturb an outer one, and a loop entered twice must
|
|
/// substitute twice (the registers are reloaded from the expression each entry).
|
|
#[test]
|
|
fn nested_and_repeated_loops_each_substitute_independently() {
|
|
let s = script();
|
|
let out: i64 = s
|
|
.eval(
|
|
r#"
|
|
local outer = { a = 1, b = 2 }
|
|
local inner = { x = 10, y = 20 }
|
|
local total = 0
|
|
for k, v in outer do
|
|
for k2, v2 in inner do total = total + v2 end
|
|
total = total + v
|
|
end
|
|
return total
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
// Each of the 2 outer pairs runs the full inner loop (30) plus its own value.
|
|
assert_eq!(out, 30 + 30 + 1 + 2);
|
|
|
|
// Entering the same loop a second time must work identically.
|
|
let twice: i64 = s
|
|
.eval(
|
|
r#"
|
|
local t = { a = 1, b = 2 }
|
|
local function count() local n = 0 for k, v in t do n = n + 1 end return n end
|
|
return count() + count()
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(twice, 4);
|
|
}
|
|
|
|
/// **5.0's long-string NESTING, restored** — the second dialect divergence in this file's family.
|
|
///
|
|
/// `[[ ... [[ ... ]] ... ]]` nests in Lua 5.0, which is what 1.12.1 ships. 5.1 kept the machinery
|
|
/// (`llex.c` `read_long_string`'s `cont` counter, guarded by `LUA_COMPAT_LSTR == 2`) and put an
|
|
/// advisory error in front of it — *"nesting of [[...]] is deprecated"* — which is a 5.1 opinion
|
|
/// about a dialect we are not targeting. Two corpus addons stopped LOADING on it.
|
|
///
|
|
/// The fix is Lua's own documented switch, `LUA_COMPAT_LSTR` 1 -> 2 in our vendored `luaconf.h`,
|
|
/// not a patch to the lexer: the code path was already there and already correct.
|
|
#[test]
|
|
fn a_nested_long_string_parses_as_lua_50_does() {
|
|
let s = crate::script::UiScript::new().unwrap();
|
|
// The shape an addon writes: a long comment containing a long string.
|
|
s.run("--[[ outer [[ inner ]] still comment ]]\nNESTED_OK = 1")
|
|
.unwrap();
|
|
assert_eq!(s.eval::<i64>("return NESTED_OK").unwrap(), 1);
|
|
|
|
// And a nested long STRING keeps its inner brackets, 5.0's own semantics: the outer runs to
|
|
// the matching close, so the inner `]]` does not terminate it.
|
|
let v = s
|
|
.eval::<String>("return [[a [[b]] c]]")
|
|
.expect("nested long string must parse");
|
|
assert_eq!(v, "a [[b]] c");
|
|
}
|
|
|
|
/// **5.0's constructor compat-semicolon, restored** — the third dialect divergence (decision 1315).
|
|
///
|
|
/// Lua 5.0's `constructor()` opens its field loop with `testnext(ls, ';') /* compatibility
|
|
/// only */` — a Lua 4.0 leftover (4.0 separated a constructor's list part from its record part
|
|
/// with `;`) that silently eats ONE extra `;` per separator position inside a table constructor.
|
|
/// 5.1 deleted the line. AtlasLoot's `ButtonRegistry.lua` writes `Back_Title = AL["Factions"];;`
|
|
/// twenty times; the real client loads it (director-verified on the live 1.12.1 client), stock
|
|
/// 5.1 answered "unexpected symbol near ';'" and the addon's registry global never existed (B266).
|
|
#[test]
|
|
fn a_double_semicolon_inside_a_constructor_parses_as_lua_50_does() {
|
|
let s = script();
|
|
|
|
// The exact corpus shape: a record field, its `;` separator, and the compat extra.
|
|
let out: String = s
|
|
.eval(
|
|
r#"
|
|
local AL = { Factions = "Factions" }
|
|
local reg = {
|
|
["REP1"] = { Title = "The Aldor"; Back_Page = "REPMENU"; Back_Title = AL["Factions"];; };
|
|
}
|
|
return reg["REP1"].Back_Title
|
|
"#,
|
|
)
|
|
.expect("the ButtonRegistry shape must parse");
|
|
assert_eq!(out, "Factions");
|
|
|
|
// The compat skip runs at loop TOP, so a lone `;` before `}` — legal 5.0, an empty record
|
|
// part in 4.0 terms — is eaten the same way.
|
|
assert_eq!(s.eval::<i64>("local t = {;} return 1").unwrap(), 1);
|
|
|
|
// List context too: the separator may be `;` and the extra one is still absorbed.
|
|
let n: i64 = s.eval("local t = { 1;; 2 } return t[1] + t[2]").unwrap();
|
|
assert_eq!(n, 3);
|
|
}
|
|
|
|
/// **The restoration is exactly one semicolon wide, and constructor-only.** Both halves of the
|
|
/// boundary are the client's: 5.0 eats ONE extra `;` per position (a third dies in `listfield`),
|
|
/// and statement-level `;;` is rejected by 5.0 and 5.1 alike — the half wow-5875-re byte-verified
|
|
/// in the client's `luaL_loadbuffer` first (`system/ui/scratch/lua-dialect.md`).
|
|
#[test]
|
|
fn the_compat_semicolon_is_one_wide_and_statement_level_stays_rejected() {
|
|
let s = script();
|
|
|
|
let err = s
|
|
.eval::<i64>("local t = { 1;;; 2 } return t[1]")
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
err.contains("unexpected symbol"),
|
|
"a THIRD semicolon must still be a syntax error, as on 5.0: {err}"
|
|
);
|
|
|
|
let err = s
|
|
.eval::<i64>("local x = 1;; return x")
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
err.contains("unexpected symbol"),
|
|
"statement-level `;;` must stay rejected — that half is byte-verified: {err}"
|
|
);
|
|
}
|