The realm's client is not stock 1.12, and its Lua grammar is not 1.12's either
SoftSpoken (z1xel/SoftSpoken, a 1.12 narration port) failed to load: nine files across six addons would not compile, and the three that did then raised at runtime indexing namespaces the failed chunks never built. Every one of the nine is valid Lua 5.1 and invalid Lua 5.0 — `#`, `%`, `0x3040`, `local NAME = ...`. `third_party/lua-src` is a fork that deliberately 5.0-ifies the grammar: three arms deleted from `lparser.c` (`simpleexp`'s `TK_DOTS`, `getunopr`'s `'#'`, `getbinopr`'s `'%'`) and `read_numeral` replaced with 5.0's, which has no hex. Every deletion is byte-read out of `WoW.exe`'s own parser and every one is right about stock 1.12. No OctoWoW client runs stock 1.12. ClassicAPI.dll is in the realm's bundle and rewrites every chunk at the `luaL_loadbuffer` chokepoint — file scripts, `loadstring` and XML `OnLoad` alike — adding exactly five constructs: `#`, `%`, `...` as an expression, `0x` hex and `[=[ ]=]`. Confirmed three ways: the repo's own highlights line, PR #28, and the DLL's string pool, which twice says "Our 5.1 syntax backport makes the probe pass". `docs/ADDON_COMPAT.md`'s row for this said "free — benilla's VM is mlua/Lua 5.1 already". That was the error. So the target grammar is the union, and the fork now carries it: * the three parser arms restored, as stock 5.1 writes them; * `read_numeral` grown an explicit `0x` arm. It could not take 5.1's version back wholesale: 5.1 reached hex through a trailing `isalnum` slurp, and that slurp has to stay gone or `1then` breaks again (`AdvancedTradeSkillWindow2.lua:3878`, which the client compiles). The prefix arm gives both; * addon file chunks now called with `(addonName, addonPrivateTable)` (`UiScript::run_addon_chunk`, four call sites). Taking `...` without passing the arguments only moves the failure one line later, which is what ClassicAPI's own notes record; * `string.reverse` added to the stdlib backport — SoftSpoken calls it. 1.12's own dialect is untouched. `LUA_COMPAT_VARARG` withholds the `arg` table only from a function that *uses* `...` as a value, so the shipped FrameXML's `function f(...) … arg.n … end` still gets `arg`: the two dialects coexist one function at a time, which is what the client-plus-ClassicAPI does. The cost is the era probe. 170 corpus `loadstring` sites ask `loadstring("return function(...) return ... end")` and 92 library files in 24 folders branch on the answer; they now take the 5.1 branch, as they do on the real client. Two branch wrongly there and ClassicAPI ships a compensation for each — Compost-2.0 rev 17406+'s no-argument `Erase` stub (bundled by BigWigs and QuickHeal; it silently stops clearing in place) and Cartographer_Notes' `WorldMapDeathRelease`. Both are now carried in `lua51_probe_fallout.rs`, probed by behaviour rather than by revision and re-checked on every `ADDON_LOADED`, because AceLibrary can upgrade a library mid-session. Measured on grom's own AddOns folder: SoftSpoken, SoftSpoken_Stories, SoftSpoken_Places, SoftSpoken_Writings, SpokenZonesAudio, SpokenBooksAudio, SpokenQuestsAudio{Horde,Alliance,Gossip,Shared}, SSS_VOFallback and AI_VoiceOverData_Turtle all load with zero load errors and zero session errors, 98 `Spoken*` globals, `/softspoken` registered, and PLAYER_ENTERING_WORLD, QUEST_DETAIL, GOSSIP_SHOW and ITEM_TEXT_BEGIN reaching 108/108, 1/1, 6/6 and 2/2 handlers clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -1645,7 +1645,10 @@ fn load_addon_files(script: &mut UiScript, root: &Path, name: &str, toc: &Toc) -
|
||||
// Named as the 1.12 client names it (`@Interface\AddOns\<Folder>\<File>`, from the
|
||||
// resolved path, as the loader names `<Script file=>`): the FuBar family parses
|
||||
// tracebacks for its own folder.
|
||||
if let Err(e) = script.run_chunk_named(&bytes, &format!("@{}", path.replace('/', "\\")))
|
||||
// `run_addon_chunk`, not `run_chunk_named`: the file is called with its addon's
|
||||
// `(addonName, addonPrivateTable)` pair, as the live walk calls it.
|
||||
if let Err(e) =
|
||||
script.run_addon_chunk(&bytes, &format!("@{}", path.replace('/', "\\")), name)
|
||||
{
|
||||
errors.push(format!("{file}: {e}"));
|
||||
raised = true;
|
||||
|
||||
@@ -146,7 +146,16 @@ impl Addon {
|
||||
if is_lua(file) {
|
||||
// What `<Script file=>` gets: one chunk in the one global state, in manifest
|
||||
// order, as `AddOn_Load 0x51f240` hands every listed file to `0x6edb90`.
|
||||
match script.run_chunk_named(&bytes, &self.chunk_name(file, &path)) {
|
||||
// …called with `(addonName, addonPrivateTable)`, the pair modern addons read
|
||||
// out of `...` (`UiScript::run_addon_chunk`). FrameXML is not an addon and goes
|
||||
// through the unargumented path.
|
||||
let ran = match &self.source {
|
||||
Source::Builtin | Source::Dir(_) => {
|
||||
script.run_addon_chunk(&bytes, &self.chunk_name(file, &path), &self.name)
|
||||
}
|
||||
Source::Chain => script.run_chunk_named(&bytes, &self.chunk_name(file, &path)),
|
||||
};
|
||||
match ran {
|
||||
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
|
||||
Err(e) => {
|
||||
let e = format!("{}/{file}: {e}", self.name);
|
||||
|
||||
@@ -513,8 +513,9 @@ fn slot_accepts(want: &str, got: &str) -> bool {
|
||||
}
|
||||
|
||||
/// The Lua helper the kind gates probe through: the `type()` of every returned value, in order,
|
||||
/// comma-joined. It counts with `arg.n`: a table constructor drops trailing nils, and `...` as a
|
||||
/// value is a syntax error in 5.0 (no `TK_DOTS` arm in `simpleexp`), which this VM's parser keeps.
|
||||
/// comma-joined. It counts with `arg.n`, not `select("#", ...)`: a table constructor drops
|
||||
/// trailing nils, and a vararg function that never uses `...` as a value keeps its 5.0 `arg`
|
||||
/// table under `LUA_COMPAT_VARARG` — so this helper reads the same on either dialect.
|
||||
const KIND_HELPER: &str = "function PGKinds(...) \
|
||||
local o = {} for i = 1, arg.n do o[i] = type(arg[i]) end \
|
||||
return table.concat(o, ',') end";
|
||||
|
||||
@@ -288,6 +288,20 @@ pub(super) struct DeferredAnchor {
|
||||
pub(super) dbg: String,
|
||||
}
|
||||
|
||||
/// The addon folder in an `Interface/AddOns/<Name>/…` path, which is what names the chunk's
|
||||
/// private namespace ([`crate::script::run_source_cached`]'s `addon`). A FrameXML path has no
|
||||
/// `AddOns` component and answers `None`, so the stock files are called with no arguments, as the
|
||||
/// reference calls them.
|
||||
fn addon_of_path(path: &str) -> Option<&str> {
|
||||
let mut parts = path.split(['/', '\\']);
|
||||
while let Some(p) = parts.next() {
|
||||
if p.eq_ignore_ascii_case("AddOns") {
|
||||
return parts.next().filter(|n| !n.is_empty());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Loader<'_> {
|
||||
pub(super) fn lua(&self) -> &mlua::Lua {
|
||||
self.lua
|
||||
@@ -303,14 +317,22 @@ impl Loader<'_> {
|
||||
fn run(&self, chunk: &[u8], path: &str) -> mlua::Result<()> {
|
||||
// The reference's file chunk name, `"@%s"` (`0x8716e0`, `0x704bc0`; a file name to
|
||||
// `luaO_chunkid 0x6f5c40`), with `\` separators as in `crate::script::addon_chunk_name`.
|
||||
self.run_named(chunk, &format!("@{}", path.replace('/', "\\")))
|
||||
// An `<Include>`d file is one of its addon's files, so it is called with the same
|
||||
// `(addonName, addonPrivateTable)` pair a `.toc`-listed file gets: a ported addon splits
|
||||
// `local NAME, ns = ...` across both kinds of file and cannot tell them apart.
|
||||
crate::script::run_source_cached(
|
||||
self.lua,
|
||||
chunk,
|
||||
&format!("@{}", path.replace('/', "\\")),
|
||||
addon_of_path(path),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a chunk under a complete chunk name, for a producer whose name is not a path.
|
||||
fn run_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
|
||||
// Through the chunk cache, like every other file chunk: an XML `<Include>` of a `.lua` is
|
||||
// how the biggest data files in the corpus load (pfQuest's database is 33 MiB of them).
|
||||
crate::script::run_source_cached(self.lua, chunk, name)
|
||||
crate::script::run_source_cached(self.lua, chunk, name, None)
|
||||
}
|
||||
|
||||
fn model(&self) -> mlua::AppDataRefMut<'_, crate::script::Model> {
|
||||
|
||||
@@ -549,6 +549,7 @@ fn load_saved_variables(lua: &Lua, i: usize) {
|
||||
lua,
|
||||
&bytes,
|
||||
&crate::script::addon_chunk_name(&name.to_string(), &format!("{name}.lua")),
|
||||
Some(&name.to_string()),
|
||||
) {
|
||||
// Deviation: the reference fails silently; we report it, as a player needs to know,
|
||||
// and hold the file so the shutdown write keeps it.
|
||||
@@ -581,7 +582,12 @@ fn run_files(lua: &Lua, name: &str, read: &Reader, files: &[String]) {
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("lua"))
|
||||
{
|
||||
if let Err(e) = run_chunk(lua, &bytes, &crate::script::addon_chunk_name(name, file)) {
|
||||
if let Err(e) = run_chunk(
|
||||
lua,
|
||||
&bytes,
|
||||
&crate::script::addon_chunk_name(name, file),
|
||||
Some(name),
|
||||
) {
|
||||
log_error(lua, &format!("{name}/{file}: {e}"));
|
||||
}
|
||||
continue;
|
||||
@@ -641,11 +647,8 @@ fn read_under(root: &std::path::Path, rel: &str) -> Option<Vec<u8>> {
|
||||
|
||||
/// Run a chunk read off disk, BOM stripped: [`crate::script::UiScript::run_chunk`] for the
|
||||
/// demand-load path, which has no `UiScript`.
|
||||
fn run_chunk(lua: &Lua, bytes: &[u8], name: &str) -> mlua::Result<()> {
|
||||
lua.load(crate::source::chunk(bytes))
|
||||
.set_name(name)
|
||||
.set_mode(mlua::ChunkMode::Text)
|
||||
.exec()
|
||||
fn run_chunk(lua: &Lua, bytes: &[u8], name: &str, addon: Option<&str>) -> mlua::Result<()> {
|
||||
crate::script::run_source_cached(lua, crate::source::chunk(bytes), name, addon)
|
||||
}
|
||||
|
||||
/// Record a demand-load error as the startup walk does ([`super::UiScript::report_script_error`]):
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! The two addons that branch *wrongly* on a 5.1-capable Lua, and what ClassicAPI does about each.
|
||||
//!
|
||||
//! benilla's parser takes `...` as an expression because ClassicAPI's 5.1 syntax backport does
|
||||
//! (`third_party/lua-src/BENILLA.md`). That makes the corpus's era probe —
|
||||
//!
|
||||
//! ```lua
|
||||
//! local lua51 = loadstring("return function(...) return ... end") and true or false
|
||||
//! ```
|
||||
//!
|
||||
//! — answer true, here and on a real OctoWoW client alike, and 92 library files in 24 folders
|
||||
//! branch on it. Nearly all of them branch *correctly*: the 5.1 body they then compile is one our
|
||||
//! VM now runs. Two do not, and ClassicAPI carries a compensation for each; this module is the
|
||||
//! same two, read off the DLL's own string pool, so that a player who drops those addons in gets
|
||||
//! what they get on the real client rather than what the probe alone would give them.
|
||||
//!
|
||||
//! Neither fix is speculative and neither is always-on: each is gated on the addon actually being
|
||||
//! present, so a session without it keeps a clean global namespace.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
/// Both compensations, as one chunk.
|
||||
///
|
||||
/// ## Compost-2.0 rev 17406+ — `Erase`
|
||||
///
|
||||
/// Bundled by BigWigs and QuickHeal among others. On a 5.1 verdict it replaces most of its body
|
||||
/// with stubs, reasoning that table recycling is pointless once the collector does it. For
|
||||
/// `GetTable` and `Reclaim` that is harmless — both are pure recycling hints. `Erase` is not:
|
||||
///
|
||||
/// ```lua
|
||||
/// if lua51 then function lib:Erase() return {} end
|
||||
/// else function lib:Erase(t) --[[ clears t in place ]] end
|
||||
/// ```
|
||||
///
|
||||
/// The stub takes **no parameter at all**, so every `compost:Erase(t)` that clears in place
|
||||
/// (rather than assigning the returned table) silently stops clearing. The reported casualty is
|
||||
/// Puppeteer: `PTUnit:ClearAuras` erases its debuff-type and aura-id sets in place, so a dispel
|
||||
/// type never leaves the set once applied — a cursed raid member keeps its curse colour after the
|
||||
/// curse is cleaned, and `HasDebuffID` answers true for auras long gone.
|
||||
///
|
||||
/// Probed **by behaviour**, not by revision or addon name, so a Compost that already clears is
|
||||
/// left alone. Re-checked on every `ADDON_LOADED` rather than once, because AceLibrary arbitrates
|
||||
/// on the highest `$Revision` and ignores load order, so an addon loaded on demand mid-session
|
||||
/// can install a newer copy's functions over ours. Repeating is cheap: the verdict is cached
|
||||
/// against the function that earned it, so a settled check is two identity comparisons and only
|
||||
/// an `Erase` we have never seen costs a probe.
|
||||
///
|
||||
/// ## Cartographer_Notes — `WorldMapDeathRelease`
|
||||
///
|
||||
/// It gates a release-spirit hook behind the same probe, treating a 5.1-capable Lua as a modern
|
||||
/// client that has the `WorldMapDeathRelease` frame. 1.12's WorldMap never had one, so the modern
|
||||
/// branch runs and indexes nil. An inert, hidden stub lets the hook succeed and do nothing —
|
||||
/// which is the outcome vanilla had, where the branch never ran at all.
|
||||
const FALLOUT: &str = r#"
|
||||
local watcher = CreateFrame("Frame")
|
||||
|
||||
-- Compost-2.0 ---------------------------------------------------------------------------------
|
||||
local vetted, replaced
|
||||
|
||||
local function ErasesInPlace(fn, lib)
|
||||
local probe = { a = 1 }
|
||||
local ok = pcall(fn, lib, probe)
|
||||
return ok and probe.a == nil
|
||||
end
|
||||
|
||||
local function InPlaceErase(self, t)
|
||||
if type(t) ~= "table" then return end
|
||||
if self and self.var and self.var.disabled then return {} end
|
||||
setmetatable(t, nil)
|
||||
for k in pairs(t) do t[k] = nil end
|
||||
if table.setn then pcall(table.setn, t, 0) end
|
||||
return t
|
||||
end
|
||||
|
||||
local function CheckCompost()
|
||||
if type(AceLibrary) ~= "function" and type(AceLibrary) ~= "table" then return end
|
||||
local ok, lib = pcall(AceLibrary, "Compost-2.0")
|
||||
if not ok or type(lib) ~= "table" or type(lib.Erase) ~= "function" then return end
|
||||
-- Settled already? Two identity comparisons and out.
|
||||
if lib.Erase == replaced or lib.Erase == vetted then return end
|
||||
if ErasesInPlace(lib.Erase, lib) then
|
||||
vetted = lib.Erase
|
||||
return
|
||||
end
|
||||
lib.Erase = InPlaceErase
|
||||
replaced = lib.Erase
|
||||
end
|
||||
|
||||
-- Cartographer_Notes --------------------------------------------------------------------------
|
||||
local function CheckCartographer()
|
||||
if WorldMapDeathRelease then return end
|
||||
if not IsAddOnLoaded("Cartographer") and not IsAddOnLoaded("Cartographer_Notes") then return end
|
||||
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
|
||||
end
|
||||
|
||||
watcher:RegisterEvent("ADDON_LOADED")
|
||||
watcher:SetScript("OnEvent", function()
|
||||
CheckCompost()
|
||||
CheckCartographer()
|
||||
end)
|
||||
"#;
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.load(FALLOUT)
|
||||
.set_name("benilla:lua51_probe_fallout")
|
||||
.exec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// The probe the whole module exists for answers true, which is the premise.
|
||||
#[test]
|
||||
fn the_era_probe_answers_true_so_the_fallout_is_real() {
|
||||
let s = UiScript::new().unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
r#"return (loadstring("return function(...) return ... end") and true or false) == true"#
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// A stubbed `Erase` is replaced with one that clears in place; the caller's own table is the
|
||||
/// thing that has to change, since that is what Puppeteer relies on.
|
||||
#[test]
|
||||
fn a_stubbed_compost_erase_is_replaced_with_one_that_clears_in_place() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
Compost = { var = {}, Erase = function() return {} end }
|
||||
function AceLibrary(name) if name == "Compost-2.0" then return Compost end end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.fire_event(
|
||||
"ADDON_LOADED",
|
||||
vec![crate::script::ScriptValue::Str("Puppeteer".into())],
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
r#"local t = { cursed = 1, n = 2 }
|
||||
Compost:Erase(t)
|
||||
return tostring(t.cursed) .. "/" .. tostring(t.n)"#
|
||||
)
|
||||
.unwrap(),
|
||||
"nil/nil",
|
||||
"the caller's own table must come back empty — assigning the return value is the \
|
||||
idiom that was never broken"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Compost that already clears is left exactly as it is: the probe is behavioural, so a
|
||||
/// future revision that fixes itself upstream must not be overwritten by ours.
|
||||
#[test]
|
||||
fn a_working_compost_erase_is_left_alone() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
Marker = function(self, t) for k in pairs(t) do t[k] = nil end t.mine = true return t end
|
||||
Compost = { var = {}, Erase = Marker }
|
||||
function AceLibrary(name) if name == "Compost-2.0" then return Compost end end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.fire_event(
|
||||
"ADDON_LOADED",
|
||||
vec![crate::script::ScriptValue::Str("BigWigs".into())],
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return Compost.Erase == Marker").unwrap(),
|
||||
"the vetted function must still be the addon's own"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cartographer's missing frame is stubbed only when Cartographer is there.
|
||||
#[test]
|
||||
fn the_world_map_death_release_stub_is_gated_on_cartographer() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.fire_event(
|
||||
"ADDON_LOADED",
|
||||
vec![crate::script::ScriptValue::Str("pfUI".into())],
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return WorldMapDeathRelease == nil")
|
||||
.unwrap(),
|
||||
"no Cartographer, no stub — the namespace stays clean"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -229,10 +229,9 @@ mod tests {
|
||||
// `select` and `string.gmatch` are no longer on this list: they are restored by
|
||||
// `stdlib_backport.rs`, the maintainer decision this file's header defers to. What remains
|
||||
// is everything that decision deliberately left alone — the members no installed addon
|
||||
// calls, so the surface grew by five names rather than by a whole library.
|
||||
// calls, so the surface grew by six names rather than by a whole library.
|
||||
for expr in [
|
||||
"coroutine",
|
||||
"string.reverse",
|
||||
"table.maxn",
|
||||
// ClassicAPI provides this one, but nothing calls it; `tests/stdlib.rs` pins it too.
|
||||
"strrev",
|
||||
|
||||
@@ -7,15 +7,21 @@
|
||||
//! namespace) or returning an `mlua::Table` (a `C_*` namespace), called from this file's own
|
||||
//! `install`.
|
||||
//!
|
||||
//! **Open question, not resolved here**: ClassicAPI's Lua-5.1-*stdlib* backport (`select`,
|
||||
//! `coroutine`, `string.reverse`/`match`/`gmatch`, `table.maxn`, …) collides with
|
||||
//! `script/lua50.rs`, which deliberately nils every one of those out so a vanilla addon's own
|
||||
//! `string.gmatch or string.gfind`-style feature test takes 1.12's real branch — not a ClassicAPI
|
||||
//! concern, a *every other addon's* 1.12-fidelity one. Restoring them here would be always-on,
|
||||
//! same as everything else in `compat/`, which would break that fidelity test for addons that
|
||||
//! never asked for ClassicAPI. Parsing 5.1 *syntax* (`#`, hex literals, long brackets) is already
|
||||
//! free — that lives in the grammar, not the stdlib, and nothing nils it. Left alone pending a
|
||||
//! maintainer call on how the two should coexist.
|
||||
//! **Both halves of the 5.1 backport are here, and both were maintainer calls:**
|
||||
//!
|
||||
//! * the *stdlib* half (`select`, `string.match`/`gmatch`/`reverse`, `math.fmod`/`modf`) in
|
||||
//! `stdlib_backport.rs`, which hands back the originals `script/lua50.rs` stashed before
|
||||
//! nil-ing them;
|
||||
//! * the *syntax* half (`#`, `%`, `...` as an expression, `0x` hex, `[=[ ]=]`) in the Lua fork
|
||||
//! itself, `third_party/lua-src/BENILLA.md`. It is **not** free, which this comment used to
|
||||
//! claim: the fork had deliberately deleted the first three from 5.1's parser and 5.0-ified
|
||||
//! `read_numeral` to match stock 1.12, and the realm's addons are written against a client
|
||||
//! where ClassicAPI has put them back. SoftSpoken's nine load failures were all of them.
|
||||
//!
|
||||
//! Both are always-on, like everything in `compat/`, and both are the same trade: an addon that
|
||||
//! feature-tests for a modern client now takes the modern branch. On this server, with ClassicAPI
|
||||
//! loaded, that *is* the correct answer. `lua51_probe_fallout.rs` carries the two addons for
|
||||
//! which it is not.
|
||||
//!
|
||||
//! Detection: [`CLASSIC_API_VERSION`] is set, and deliberately so — see its own comment for what
|
||||
//! the number claims and why it is the number it is. Most functions here need no flag at all (an
|
||||
@@ -53,6 +59,7 @@ mod item_location;
|
||||
mod item_mixin;
|
||||
mod loot_history;
|
||||
mod loss_of_control;
|
||||
mod lua51_probe_fallout;
|
||||
mod lua_ext;
|
||||
mod macro_ext;
|
||||
mod macro_icons;
|
||||
@@ -156,6 +163,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
expansion::install(lua)?;
|
||||
input::install(lua)?;
|
||||
lua_ext::install(lua)?;
|
||||
// After the frame and addon surfaces it probes (`CreateFrame`, `IsAddOnLoaded`).
|
||||
lua51_probe_fallout::install(lua)?;
|
||||
math_ext::install(lua)?;
|
||||
// After `math_ext`: its `Round` is the rounding `PixelUtil.GetNearestPixelSize` does.
|
||||
pixel_util::install(lua)?;
|
||||
|
||||
@@ -27,8 +27,12 @@
|
||||
//! | `string.gmatch` | 5 |
|
||||
//! | `math.fmod` | 2 |
|
||||
//! | `math.modf` | 1 |
|
||||
//! | `string.reverse` | 1 |
|
||||
//!
|
||||
//! Deliberately **not** restored, because nothing calls them: `string.reverse`, `strrev`,
|
||||
//! `string.reverse` joined the list when SoftSpoken was installed — it is a ClassicAPI-dependent
|
||||
//! addon, so it writes 5.1 and its one `string.reverse` site is a load-time call.
|
||||
//!
|
||||
//! Deliberately **not** restored, because nothing calls them: `strrev`,
|
||||
//! `table.maxn`, `math.huge`, the hyperbolics, and `coroutine`. Each stays nil, so the surface
|
||||
//! grows by five names rather than by a whole library — and `table.maxn` in particular would
|
||||
//! fight `lua50.rs`'s `n`-based table library, which is 5.0 semantics this client genuinely needs.
|
||||
@@ -55,6 +59,7 @@ const RESTORED: &[(&str, &str)] = &[
|
||||
("string", "gmatch"),
|
||||
("math", "fmod"),
|
||||
("math", "modf"),
|
||||
("string", "reverse"),
|
||||
];
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
@@ -143,10 +148,9 @@ mod tests {
|
||||
/// stay gone — it fights `lua50.rs`'s `n`-based table library, which is 5.0 semantics this
|
||||
/// client relies on.
|
||||
#[test]
|
||||
fn nothing_beyond_the_measured_five_comes_back() {
|
||||
fn nothing_beyond_the_measured_members_comes_back() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for expr in [
|
||||
"string.reverse",
|
||||
"strrev",
|
||||
"table.maxn",
|
||||
"math.huge",
|
||||
|
||||
@@ -63,23 +63,31 @@ mod tests {
|
||||
///
|
||||
/// `gmatch` and `match` used to be on this list; `stdlib_backport.rs` restores them now, by a
|
||||
/// recorded maintainer decision. `reverse` is the member that decision deliberately left out
|
||||
/// (nothing installed calls it), which makes it the one that still proves the property — if
|
||||
/// the metatable were inventing members rather than forwarding to the table, `("x"):reverse()`
|
||||
/// would work here.
|
||||
/// The sugar **forwards** to the `string` table; it does not invent members.
|
||||
///
|
||||
/// The probe used to be `string.reverse`, a real 5.1 addition nothing restored — until
|
||||
/// SoftSpoken arrived and `stdlib_backport.rs` restored it. All three of 5.1's string
|
||||
/// additions over 5.0 (`match`, `gmatch`, `reverse`) are now handed back, so the property
|
||||
/// has to be proved against a name no Lua ever had: if the metatable were synthesising
|
||||
/// methods rather than indexing the table, this would answer a function.
|
||||
#[test]
|
||||
fn the_sugar_does_not_resurrect_a_member_the_string_table_lacks() {
|
||||
let s = UiScript::new().unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(string.reverse)").unwrap(),
|
||||
s.eval::<String>("return type(string.neverWasAMember)")
|
||||
.unwrap(),
|
||||
"nil",
|
||||
"string.reverse is a 5.1 addition nothing restores"
|
||||
"the premise: the table does not have it"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return ("x").reverse == nil"#).unwrap(),
|
||||
"and the metatable must not invent it either — it only forwards to `string`"
|
||||
s.eval::<bool>(r#"return ("x").neverWasAMember == nil"#)
|
||||
.unwrap(),
|
||||
"and neither does the sugar — `__index` is the `string` table itself"
|
||||
);
|
||||
// …while a member the table *does* have reaches through it.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return ("abc"):upper()"#).unwrap(),
|
||||
"ABC"
|
||||
);
|
||||
// The forwarding really is live, so the assertion above is about absence rather than a
|
||||
// broken metatable: a member the table *does* have reaches method syntax.
|
||||
assert_eq!(s.eval::<String>(r#"return ("ab"):upper()"#).unwrap(), "AB");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,13 +523,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_5_1_only_members_are_not_offered() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for expr in [
|
||||
"string.reverse",
|
||||
"table.maxn",
|
||||
"math.huge",
|
||||
"math.cosh",
|
||||
"coroutine",
|
||||
] {
|
||||
for expr in ["table.maxn", "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 nothing restores it"
|
||||
@@ -583,23 +577,37 @@ mod tests {
|
||||
assert_eq!(s.eval::<f64>("return floor(2.7)").unwrap(), 2.0);
|
||||
}
|
||||
|
||||
/// `...` parses in a parameter list and nowhere else; `arg` carries the arguments.
|
||||
/// `...` in a parameter list, and `arg` beside it. Both dialects parse (ClassicAPI's
|
||||
/// backport adds the expression form), and the rule that tells them apart is per function:
|
||||
/// `LUA_COMPAT_VARARG` gives a vararg function its `arg` table unless the function uses
|
||||
/// `...` as a value.
|
||||
#[test]
|
||||
fn vararg_is_a_declaration_only() {
|
||||
fn vararg_takes_both_dialects_one_function_at_a_time() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let parses = |src: &str| s.run(src).is_ok();
|
||||
// Declaration, and 5.0's way to read what it captured.
|
||||
// 5.0's way to read what the declaration captured — what the shipped FrameXML writes.
|
||||
assert!(parses("return function(x, ...) return x end"));
|
||||
assert!(parses("return function(x, ...) return arg.n end"));
|
||||
assert!(parses(
|
||||
"local f = tostring return function(x, ...) return f(x, unpack(arg)) end"
|
||||
));
|
||||
// Every use of `...` as an expression fails, at chunk level too, where 5.1 allows it.
|
||||
assert!(!parses("return function(x, ...) return ... end"));
|
||||
assert!(!parses(
|
||||
// 5.1's, which stock 1.12 rejects and a ClassicAPI client takes.
|
||||
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"));
|
||||
assert!(parses("local a = ... return a"));
|
||||
// The opt-out is the function's own: `arg` is still there for the one that never used
|
||||
// `...`, and gone for the one that did.
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"local a = function(...) return type(arg) end \
|
||||
local u = function(...) local _ = ... return type(arg) end \
|
||||
return a(1) .. '/' .. u(1)"
|
||||
)
|
||||
.unwrap(),
|
||||
"table/nil"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -662,60 +670,66 @@ mod tests {
|
||||
assert_eq!(s.eval::<f64>("return __pow(2, 10)").unwrap(), 1024.0);
|
||||
}
|
||||
|
||||
/// Ace2's `lua51` probe, `loadstring("return function(...) return ... end")`, fails as on
|
||||
/// 1.12: the reference's `simpleexp` (`0x6fd240`) has no `...` arm.
|
||||
/// `...` as an expression, which stock 1.12 has no grammar for and ClassicAPI's 5.1 syntax
|
||||
/// backport adds. Both dialects then live side by side, per function, because
|
||||
/// `LUA_COMPAT_VARARG` only withholds `arg` from a function that actually uses `...`.
|
||||
///
|
||||
/// Ace2's probe — `loadstring("return function(...) return ... end")` — therefore answers
|
||||
/// **true** here, as it does on a real OctoWoW client, and 92 corpus library files take their
|
||||
/// 5.1 branch. Two of them branch wrongly and are compensated in
|
||||
/// `compat/classic_api/lua51_probe_fallout.rs`, which is ClassicAPI's own list.
|
||||
#[test]
|
||||
fn the_vararg_expression_is_a_syntax_error_as_it_is_on_1_12() {
|
||||
fn the_vararg_expression_parses_as_classic_api_backports_it() {
|
||||
let s = UiScript::new().unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
r#"return (loadstring("return function(...) return ... end") and true or false) == false"#
|
||||
r#"return (loadstring("return function(...) return ... end") and true or false) == true"#
|
||||
)
|
||||
.unwrap(),
|
||||
"the Ace2 lua51 probe must answer false, as it does on the 1.12 client"
|
||||
"the Ace2 lua51 probe answers true on a ClassicAPI client, so it answers true here"
|
||||
);
|
||||
// `prefixexp` then accepts only `(` or a name, so this is 5.0's own `unexpected symbol`.
|
||||
let msg: String = s
|
||||
.eval(r#"local f, e = loadstring("return ...") return tostring(e)"#)
|
||||
.unwrap();
|
||||
assert!(
|
||||
msg.contains("unexpected symbol"),
|
||||
"5.0's own message for this, not a bespoke one: {msg}"
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local f = function(...) return select('#', ...) end return f(1, 2, 3)")
|
||||
.unwrap(),
|
||||
3,
|
||||
"`...` forwards as a value, which is the whole point of the construct"
|
||||
);
|
||||
// The 5.0 half of the question: `arg`.
|
||||
// The 5.0 half, unchanged and still the form all of 1.12 FrameXML uses: a vararg function
|
||||
// that never mentions `...` as a value keeps its `arg` table.
|
||||
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"
|
||||
"`arg` is 5.1's compat-vararg table, and `LUA_COMPAT_VARARG` still hands it over"
|
||||
);
|
||||
// Every vararg function gets `arg`, 5.0's rule: without that arm nothing clears
|
||||
// `VARARG_NEEDSARG`.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local f = function(a, ...) return arg.n end return f(1, 2, 3)")
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
// A `...` in a parameter list still parses: the deletion is `simpleexp`'s arm, not
|
||||
// `parlist`'s.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return loadstring("return function(...) return arg.n end") ~= nil"#)
|
||||
.unwrap());
|
||||
// The two in one chunk: using `...` opts that function out of `arg`, and only that one.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
"local u = function(...) return select('#', ...) end \
|
||||
local a = function(...) return arg.n end \
|
||||
return u(1, 2) * 10 + a(1, 2, 3)"
|
||||
)
|
||||
.unwrap(),
|
||||
23,
|
||||
"the opt-out is per function, not per chunk"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's `getunopr` (`0x6fe0a0`) knows only `-` and `not`, its `getbinopr`
|
||||
/// (`0x6fe0c0`) has no `%`, and its metamethod names (`0x871896`) have no `__len` or `__mod`.
|
||||
/// `#` and `%`, the other two operators ClassicAPI's backport adds. 5.0's own spellings
|
||||
/// (`table.getn`, `getn`, `math.mod`, `string.len`) keep working beside them, because nothing
|
||||
/// below the parser was removed to make room.
|
||||
#[test]
|
||||
fn the_length_and_modulo_operators_are_not_in_the_grammar() {
|
||||
fn the_length_and_modulo_operators_parse_as_classic_api_backports_them() {
|
||||
let s = UiScript::new().unwrap();
|
||||
for probe in ["return #t", "return 7 % 3", "local n = #({1,2}) return n"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return loadstring({:?}) == nil", probe))
|
||||
.unwrap(),
|
||||
"{probe} is 5.1-only syntax; 1.12's parser rejects it"
|
||||
);
|
||||
}
|
||||
// 5.0's spellings of both.
|
||||
assert_eq!(s.eval::<i64>("return #({1,2,3})").unwrap(), 3);
|
||||
assert_eq!(s.eval::<i64>("return #'abcd'").unwrap(), 4);
|
||||
assert_eq!(s.eval::<f64>("return 7 % 3").unwrap(), 1.0);
|
||||
// 5.0's spellings of both, which the shipped FrameXML and most of the corpus still use.
|
||||
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);
|
||||
@@ -754,12 +768,18 @@ mod tests {
|
||||
s.eval::<f64>("if 1e2then return 1e2 end return 0").unwrap(),
|
||||
100.0
|
||||
);
|
||||
// The same boundary costs 1.12 its hex literals: the digit loop stops at the `x`, so the
|
||||
// client reads `0` and the name `xFF` and cannot parse the statement. Zero loaded sites in
|
||||
// the corpus (only pfQuest's unlisted `toolbox/pngLua`, which no toc row reaches).
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return loadstring("return 0xFF") == nil"#)
|
||||
.unwrap());
|
||||
// The same boundary is why stock 1.12 has no hex literals at all — the digit loop stops
|
||||
// at the `x`, leaving `0` and the name `xFF`. ClassicAPI's backport adds them, so benilla
|
||||
// takes them on an explicit `0x` prefix, in an arm of its own that does not reopen 5.1's
|
||||
// alnum slurp (the lines above are that slurp's absence).
|
||||
assert_eq!(s.eval::<i64>("return 0xFF").unwrap(), 255);
|
||||
assert_eq!(s.eval::<i64>("return 0X10").unwrap(), 16);
|
||||
// …and the boundary still holds around one: no word is slurped onto a hex tail either.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("if 0x10then return 0x10 end return 0")
|
||||
.unwrap(),
|
||||
16
|
||||
);
|
||||
// A malformed numeral still raises, and names the prefix the client would have named.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return loadstring("return 1.2.3") == nil"#)
|
||||
|
||||
@@ -905,7 +905,14 @@ impl UiScript {
|
||||
/// Run a chunk under the name the 1.12 client gives it ([`addon_chunk_name`] for addon files),
|
||||
/// through the installed [`ChunkCache`] when there is one ([`run_source_cached`]).
|
||||
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
|
||||
run_source_cached(&self.lua, crate::source::chunk(chunk), name)
|
||||
run_source_cached(&self.lua, crate::source::chunk(chunk), name, None)
|
||||
}
|
||||
|
||||
/// [`UiScript::run_chunk_named`] for a file that belongs to an addon, so the chunk is called
|
||||
/// with the `(addonName, addonPrivateTable)` pair modern addons read out of `...`
|
||||
/// ([`addon_chunk_args`]).
|
||||
pub fn run_addon_chunk(&self, chunk: &[u8], name: &str, addon: &str) -> mlua::Result<()> {
|
||||
run_source_cached(&self.lua, crate::source::chunk(chunk), name, Some(addon))
|
||||
}
|
||||
|
||||
/// Load and evaluate a Lua chunk, returning its result: for tests and one-shot queries.
|
||||
@@ -1573,7 +1580,42 @@ pub fn install_chunk_cache(cache: Box<dyn ChunkCache>) {
|
||||
/// another Lua build fails `lua_undump`'s header check and the source is parsed, so the worst a
|
||||
/// bad entry costs is the parse it was meant to save. The dump keeps its debug info, because
|
||||
/// `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines.
|
||||
pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> mlua::Result<()> {
|
||||
/// The chunk arguments an addon's file is called with, as `...`.
|
||||
///
|
||||
/// Modern WoW hands every addon file `(addonName, addonPrivateTable)`, and the idiom
|
||||
/// `local NAME, ns = ...` is written at the top of a great many ported addons. On stock 1.12 the
|
||||
/// line is a syntax error, so the question never arose; ClassicAPI's 5.1 syntax backport makes it
|
||||
/// parse, and then — as ClassicAPI's own notes record — the idiom resolves to nothing and the
|
||||
/// addon dies one line later indexing a nil namespace. Passing the pair is the other half of
|
||||
/// taking the construct.
|
||||
///
|
||||
/// The table is per addon and shared by its files, which is what makes it a namespace rather
|
||||
/// than a scratch table, and it lives in the registry: nothing in `_G` can reach another addon's.
|
||||
fn addon_chunk_args(lua: &mlua::Lua, addon: Option<&str>) -> mlua::Result<mlua::MultiValue> {
|
||||
let Some(addon) = addon else {
|
||||
return Ok(mlua::MultiValue::new());
|
||||
};
|
||||
let key = format!("__benilla_addon_ns.{}", addon.to_lowercase());
|
||||
let ns: mlua::Value = match lua.named_registry_value(&key) {
|
||||
Ok(mlua::Value::Table(t)) => mlua::Value::Table(t),
|
||||
_ => {
|
||||
let t = lua.create_table()?;
|
||||
lua.set_named_registry_value(&key, &t)?;
|
||||
mlua::Value::Table(t)
|
||||
}
|
||||
};
|
||||
Ok(mlua::MultiValue::from_vec(vec![
|
||||
mlua::Value::String(lua.create_string(addon)?),
|
||||
ns,
|
||||
]))
|
||||
}
|
||||
|
||||
pub(crate) fn run_source_cached(
|
||||
lua: &mlua::Lua,
|
||||
source: &[u8],
|
||||
name: &str,
|
||||
addon: Option<&str>,
|
||||
) -> mlua::Result<()> {
|
||||
let Some(cache) = CHUNK_CACHE.get() else {
|
||||
let t = std::time::Instant::now();
|
||||
let f = lua
|
||||
@@ -1582,7 +1624,7 @@ pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> m
|
||||
.set_mode(mlua::ChunkMode::Text)
|
||||
.into_function();
|
||||
chunk_stats().miss(t.elapsed().as_nanos(), 0);
|
||||
return f?.call::<()>(());
|
||||
return f?.call::<()>(addon_chunk_args(lua, addon)?);
|
||||
};
|
||||
let t = std::time::Instant::now();
|
||||
let key = cache.key(source, name);
|
||||
@@ -1597,7 +1639,7 @@ pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> m
|
||||
{
|
||||
Ok(f) => {
|
||||
chunk_stats().hit(t.elapsed().as_nanos(), keyed);
|
||||
return f.call::<()>(());
|
||||
return f.call::<()>(addon_chunk_args(lua, addon)?);
|
||||
}
|
||||
// Not the player's error: drop the entry and parse.
|
||||
Err(_) => cache.forget(&key),
|
||||
@@ -1612,7 +1654,7 @@ pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> m
|
||||
let dump = f.dump(false);
|
||||
chunk_stats().miss(t.elapsed().as_nanos(), keyed);
|
||||
cache.put(&key, &dump);
|
||||
f.call::<()>(())
|
||||
f.call::<()>(addon_chunk_args(lua, addon)?)
|
||||
}
|
||||
|
||||
/// What the chunk cache did this process: hits and misses, and the time each spent turning bytes
|
||||
|
||||
@@ -84,10 +84,9 @@ pub(crate) struct RaiseProf {
|
||||
/// The chunk is a builder taking the two host functions as named parameters rather than reading
|
||||
/// them out of `_G`, so nothing new is published as a global and an addon cannot reach them.
|
||||
///
|
||||
/// Varargs are 5.0's: `...` is not an expression in this dialect (the client's parser has no
|
||||
/// `TK_DOTS` arm in `simpleexp`, `third_party/lua-src/BENILLA.md`), so a vararg function reads its
|
||||
/// arguments from `arg` and `arg.n` — which is exact, where `#arg` would stop at the first nil a
|
||||
/// caller passed. `collect` captures the protected call's own returns the same way, so a `pcall`
|
||||
/// Varargs are read 5.0's way: this wrapper never mentions `...` as a value, so it keeps its
|
||||
/// `arg` table under `LUA_COMPAT_VARARG` (`third_party/lua-src/BENILLA.md`) and reads `arg` and
|
||||
/// `arg.n` — which is exact, where `#arg` would stop at the first nil a caller passed. `collect` captures the protected call's own returns the same way, so a `pcall`
|
||||
/// that returned `true, nil, 5` still returns three values through the wrapper.
|
||||
const WRAP: &str = r#"
|
||||
return function(mark, note)
|
||||
|
||||
@@ -431,10 +431,10 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
|
||||
local n, first, second = varargs("a", "b")
|
||||
assert(n == 2 and first == "a" and second == "b")
|
||||
|
||||
-- 5.1's parser synthesizes `arg` only for a vararg function that does NOT also mention
|
||||
-- `...` in its body, so mixing the two spellings in one function would leave `arg` nil.
|
||||
-- `...` as a value is not in this grammar at all, so nothing can clear the flag and
|
||||
-- EVERY vararg function has its `arg`.
|
||||
-- `LUA_COMPAT_VARARG` synthesizes `arg` for a vararg function that does NOT also mention
|
||||
-- `...` as a value in its body. Since ClassicAPI's backport put that spelling back in the
|
||||
-- grammar, the two dialects now coexist one function at a time: this one never says
|
||||
-- `...`, so it has its `arg`, exactly as all of 1.12's FrameXML does.
|
||||
local function fixed_and_varargs(a, ...) return a, arg.n, arg[1] end
|
||||
local a, n, first = fixed_and_varargs("a", "b", "c")
|
||||
assert(a == "a" and n == 2 and first == "b")
|
||||
@@ -443,11 +443,12 @@ fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
|
||||
assert(table.getn({ 1, 2, 3 }) == 3)
|
||||
assert(string.gfind ~= nil) -- 5.1 renamed this to string.gmatch
|
||||
assert(math.mod(7, 3) == 1)
|
||||
-- ...and the 5.1 OPERATORS 5.0 lacks are not in the grammar: `%`, `#`, and `...` as a
|
||||
-- value all fail to compile, exactly as they do on the 1.12 client.
|
||||
assert(loadstring("return 7 % 3") == nil)
|
||||
assert(loadstring("return #({1})") == nil)
|
||||
assert(loadstring("return function(...) return ... end") == nil)
|
||||
-- ...and 5.0's spellings still answer beside the 5.1 OPERATORS ClassicAPI's syntax
|
||||
-- backport adds, which is the shape of the whole dialect: 1.12's way never stops
|
||||
-- working, and the realm's way also parses (`third_party/lua-src/BENILLA.md`).
|
||||
assert(loadstring("return 7 % 3") ~= nil)
|
||||
assert(loadstring("return #({1})") ~= nil)
|
||||
assert(loadstring("return function(...) return ... end") ~= nil)
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+67
-1
File diff suppressed because one or more lines are too long
Vendored
+39
-16
@@ -5,10 +5,18 @@ the version mlua 0.11 resolves to. Wired in through `[patch.crates-io]` in the w
|
||||
|
||||
## Why a fork exists at all
|
||||
|
||||
benilla's Lua has to accept exactly the grammar the 1.12.1 client's Lua accepts, no more and no
|
||||
less. Every hunk below is that one rule: three restore 5.0 constructs 5.1 deleted, three delete 5.1
|
||||
constructs 5.0 never had, and one puts a token boundary back where the client has it. A grammar difference in either direction is invisible to every instrument
|
||||
until an addon trips over it, and the addon corpus asks about the difference directly.
|
||||
benilla's Lua has to accept exactly the grammar **the OctoWoW client** accepts, no more and no
|
||||
less — and no OctoWoW client runs stock 1.12.1. `ClassicAPI.dll` ships with the realm's client
|
||||
bundle and rewrites every chunk at the `luaL_loadbuffer` chokepoint (file scripts, `loadstring`
|
||||
and XML `OnLoad` alike), adding five constructs Lua 5.0 never had: `#`, `%`, `...` as an
|
||||
expression, `0x` hex literals and `[=[ ]=]` long brackets. benilla bakes the DLLs in, so that is
|
||||
the grammar its addon corpus is written against.
|
||||
|
||||
So the target is a **union**, and the hunks below are that union: five restore 5.0 constructs 5.1
|
||||
deleted (which the client takes and stock 5.1 rejects), one takes hex the way ClassicAPI does
|
||||
without reopening 5.1's word-slurp, and one puts a token boundary back where the client has it.
|
||||
A grammar difference in either direction is invisible to every instrument until an addon trips
|
||||
over it, and the addon corpus asks about the difference directly.
|
||||
|
||||
The 1.12 addon corpus is Lua 5.0 code. It uses the iterator-less generic-for:
|
||||
|
||||
@@ -49,20 +57,35 @@ Six hunks restore what 5.1 changed or removed:
|
||||
| `lparser.c` | `recfield`'s `cc->nh++` moved back inside its `TK_NAME` arm, 5.0's own placement | a `[expr] = value` constructor field credits neither `OP_NEWTABLE` size hint in 5.0, so the table is born on the dummy node |
|
||||
| `llex.c` | `read_numeral`'s trailing `while (isalnum(c) \|\| c == '_')` slurp replaced by 5.0's own exponent-digit loop | the client ends a numeral at its digits, so `if Item.ReplaceWords == 1then` (no space, `AdvancedTradeSkillWindow2.lua:3878`) compiles there and raised `malformed number` here, costing a 4000-line file and every global it defines |
|
||||
|
||||
Three delete what 5.1 added, all in `lparser.c`, all byte-read out of the client's own parser
|
||||
(`simpleexp 0x6fd240`, `getunopr 0x6fe0a0`, `getbinopr 0x6fe0c0`), all landing on 5.0's own
|
||||
`unexpected symbol` at `prefixexp 0x6fde40`:
|
||||
### The three that used to be deleted, and are not any more
|
||||
|
||||
| hunk | why |
|
||||
|---|---|
|
||||
| `simpleexp`'s `case TK_DOTS` deleted: `...` is not an expression | the Ace2 corpus asks which interpreter it is on by compiling `return function(...) return ... end`; 170 `loadstring` sites in the corpus are that question, and 92 library files in 24 folders branch on the answer |
|
||||
| `getunopr`'s `case '#'` deleted: no length operator | the client's `getunopr` tests exactly two tokens (`-`, `not`) and its `OPR_NOUNOPR` is 2, a three-member enum; 5.0 asks a table with `table.getn` and a string with `string.len` |
|
||||
| `getbinopr`'s `case '%'` deleted: no modulo operator | the client's `getbinopr` switch is based at `'*'` (0x2A), so `%` (0x25) is below its range and reaches `OPR_NOBINOPR` = 14, a fifteen-member `BinOpr`; 5.0 spells it `math.mod` |
|
||||
`simpleexp`'s `case TK_DOTS`, `getunopr`'s `case '#'` and `getbinopr`'s `case '%'` were removed
|
||||
from this fork, because stock 1.12 rejects all three — byte-read out of the client's own parser
|
||||
(`simpleexp 0x6fd240` has seven arms and no `TK_DOTS`; `getunopr 0x6fe0a0` tests exactly two
|
||||
tokens, `OPR_NOUNOPR` = 2; `getbinopr 0x6fe0c0` bases its switch at `*` 0x2A so `%` 0x25 falls
|
||||
below it, `OPR_NOBINOPR` = 14). All three are **restored**, as stock 5.1 writes them, because
|
||||
ClassicAPI adds all three to the client benilla emulates. The byte-reads stay in the comments:
|
||||
they are what says *why the question was ever open*, and they are what a strict-1.12 build would
|
||||
delete again.
|
||||
|
||||
`...` is the one with a cost, and it is ClassicAPI's cost too. 170 corpus `loadstring` sites ask
|
||||
which interpreter they are on with
|
||||
|
||||
```lua
|
||||
local lua51 = loadstring("return function(...) return ... end") and true or false
|
||||
```
|
||||
|
||||
and 92 library files in 24 folders branch on the answer. They now take the 5.1 branch, as they do
|
||||
on a real OctoWoW client. Two of them branch *wrongly* there, and ClassicAPI carries a
|
||||
compensation for each; benilla carries the same two, in
|
||||
`crates/benilla-ui/src/script/compat/classic_api/lua51_probe_fallout.rs`.
|
||||
|
||||
What restoring the arm does **not** cost is 1.12's `arg` table: `LUA_COMPAT_VARARG` clears
|
||||
`VARARG_NEEDSARG` only for a function that actually uses `...` as a value, so the shipped
|
||||
FrameXML's `function f(...) ... arg.n ... end` still gets `arg`, and only 5.1-era code opts out.
|
||||
The two dialects coexist one function at a time —
|
||||
`script::lua50::tests::vararg_takes_both_dialects_one_function_at_a_time`.
|
||||
|
||||
The deletions are safe because nothing the reference runs uses those constructs: a comment- and
|
||||
string-stripping scan of the 1.12 FrameXML (177 files), GlueXML, Blizzard's own addons and the
|
||||
corpus finds zero sites of all three. No chunk of benilla's own Lua may use a construct the client's
|
||||
parser rejects.
|
||||
|
||||
To verify the Lua sources against upstream at any time:
|
||||
|
||||
|
||||
Vendored
+25
-3
@@ -193,9 +193,30 @@ static void trydecpoint (LexState *ls, SemInfo *seminfo) {
|
||||
/* LUA_NUMBER */
|
||||
static void read_numeral (LexState *ls, SemInfo *seminfo) {
|
||||
lua_assert(isdigit(ls->current));
|
||||
do {
|
||||
/* benilla: hexadecimal literals, one of the five constructs ClassicAPI's 5.1 syntax backport
|
||||
** adds to the OctoWoW client (`third_party/lua-src/BENILLA.md'). Stock 1.12's read_numeral
|
||||
** (0x6ffee0) has a digit loop and nothing else, so it reads `0xFF' as the number 0 followed by
|
||||
** the name `xFF' and the statement around it does not parse. This arm is its own because it
|
||||
** cannot share the decimal loop: 5.1's way of reaching hex was a trailing
|
||||
** `while (isalnum(c) || c == '_')' slurp, and that slurp is the very thing that has to stay
|
||||
** deleted -- it would also swallow the word glued to a numeral's tail, costing us `1then'
|
||||
** (`AdvancedTradeSkillWindow2.lua:3878'), which the client's own lexer compiles. Taking hex on
|
||||
** an explicit `0x' prefix instead gives both: `0xFF' is 255 and `1then' is still two tokens.
|
||||
** luaO_str2d does the conversion (5.1's own hex path, `strtoul' base 16). */
|
||||
if (ls->current == '0') {
|
||||
save_and_next(ls);
|
||||
if (ls->current == 'x' || ls->current == 'X') {
|
||||
save_and_next(ls);
|
||||
while (isxdigit(ls->current))
|
||||
save_and_next(ls);
|
||||
save(ls, '\0');
|
||||
if (!luaO_str2d(luaZ_buffer(ls->buff), &seminfo->r))
|
||||
luaX_lexerror(ls, "malformed number", TK_NUMBER);
|
||||
return;
|
||||
}
|
||||
}
|
||||
while (isdigit(ls->current) || ls->current == '.')
|
||||
save_and_next(ls);
|
||||
} while (isdigit(ls->current) || ls->current == '.');
|
||||
/* benilla: 5.1's trailing `while (isalnum(ls->current) || ls->current == '_')
|
||||
** save_and_next(ls);' is replaced by 5.0's own exponent-digit loop, which is what the client's
|
||||
** read_numeral (0x6ffee0) has: it saves the `E', takes an optional sign (the 0x2b/0x2d test at
|
||||
@@ -203,7 +224,8 @@ static void read_numeral (LexState *ls, SemInfo *seminfo) {
|
||||
** the ctype digit mask 0x4 at 0x6fff47, the only character-class test in the whole routine --
|
||||
** and stops. Nothing slurps the following word, so the client reads `1then' as the number 1 and
|
||||
** the name `then', which is why `if Item.ReplaceWords == 1then' compiles there; and nothing
|
||||
** slurps an `x' either, so 1.12 has no hex literals, reading `0xFF' as 0 and the name `xFF'.
|
||||
** slurps an `x' either -- which is why stock 1.12 has no hex literals at all, and why the
|
||||
** `0x' arm above this loop is the one place benilla takes them.
|
||||
** 5.1 gave both runs to the numeral and then failed it. Its slurp also carried the exponent's
|
||||
** own digits, so deleting it alone would have cost us `1e2' -- hence the loop, not a deletion.
|
||||
*/
|
||||
|
||||
+36
-40
@@ -761,32 +761,35 @@ static void simpleexp (LexState *ls, expdesc *v) {
|
||||
init_exp(v, VFALSE, 0);
|
||||
break;
|
||||
}
|
||||
/* BENILLA: 5.1's vararg EXPRESSION arm, deleted (see
|
||||
** third_party/lua-src/BENILLA.md). Lua 5.0 has no grammar for `...' as a
|
||||
** value: a vararg function reads its extra arguments out of the implicit
|
||||
** `arg' table. `...' as an expression is how the Ace2 corpus asks which
|
||||
** interpreter it is on:
|
||||
/* BENILLA: 5.1's vararg EXPRESSION arm, restored -- ClassicAPI's 5.1 syntax backport.
|
||||
** Stock 1.12's parser has no `...' as a value (simpleexp 0x6fd240 has seven arms and no
|
||||
** TK_DOTS), but no OctoWoW client runs stock: ClassicAPI.dll rewrites every chunk at the
|
||||
** `luaL_loadbuffer' chokepoint, and `...' as an expression is one of the five constructs it
|
||||
** adds. The realm's addon corpus is written against that grammar, so it is the grammar
|
||||
** benilla has to accept (`third_party/lua-src/BENILLA.md').
|
||||
**
|
||||
** `LUA_COMPAT_VARARG' is what keeps 1.12's own code working beside it: the line below clears
|
||||
** VARARG_NEEDSARG only for a function that actually *uses* `...' as a value, so the shipped
|
||||
** FrameXML's `function f(...) ... arg.n ... end' still gets its `arg' table and only 5.1-era
|
||||
** code opts out of it. The two dialects coexist per function, which is exactly what the
|
||||
** backported client does.
|
||||
**
|
||||
** The cost is real and is ClassicAPI's too: 170 corpus `loadstring' sites ask which
|
||||
** interpreter they are on with
|
||||
**
|
||||
** local lua51 = loadstring("return function(...) return ... end") and true or false
|
||||
**
|
||||
** and 92 library files in 24 folders branch on the answer, taking a
|
||||
** client-2.0 path on a 1.12 client when it is wrong.
|
||||
**
|
||||
** Byte-read in the client: simpleexp is 0x6fd240; it switches on
|
||||
** `token - 0x7B' bounded at 0xA3, indexes the byte table at 0x6fd338 and
|
||||
** jumps through the 8-entry table at 0x6fd318. TK_DOTS is 0x118, so its
|
||||
** index byte is 0x9D, whose value is 7: the DEFAULT arm, 0x6fd30a, which
|
||||
** tail-jumps to primaryexp 0x6fdb00. The seven real arms are `{', TK_FALSE,
|
||||
** TK_FUNCTION, TK_NIL, TK_TRUE, TK_NUMBER and TK_STRING: stock Lua 5.0's
|
||||
** simpleexp exactly, with no vararg case. So `...' as a value reaches
|
||||
** prefixexp 0x6fde40, whose head accepts only `(' or TK_NAME and otherwise
|
||||
** raises "unexpected symbol" (0x871e80); deleting the arm reproduces that
|
||||
** error here, verbatim.
|
||||
**
|
||||
** It also restores 5.0's `arg' rule: the deleted line cleared
|
||||
** VARARG_NEEDSARG for any function that mentioned `...', so with the arm
|
||||
** gone every vararg function gets its `arg' table, which is what 5.0 does
|
||||
** and what the shipped 1.12 FrameXML reads. */
|
||||
** and 92 library files in 24 folders branch on the answer. They now take the 5.1 branch, as
|
||||
** they do on the real client. ClassicAPI compensates for the two that branch *wrongly*, and
|
||||
** so do we -- `compat/classic_api/lua51_probe_fallout.rs'. */
|
||||
case TK_DOTS: { /* vararg */
|
||||
FuncState *fs = ls->fs;
|
||||
check_condition(ls, fs->f->is_vararg,
|
||||
"cannot use " LUA_QL("...") " outside a vararg function");
|
||||
fs->f->is_vararg &= ~VARARG_NEEDSARG; /* don't need 'arg' */
|
||||
init_exp(v, VVARARG, luaK_codeABC(fs, OP_VARARG, 0, 1, 0));
|
||||
break;
|
||||
}
|
||||
case '{': { /* constructor */
|
||||
constructor(ls, v);
|
||||
return;
|
||||
@@ -809,14 +812,11 @@ static UnOpr getunopr (int op) {
|
||||
switch (op) {
|
||||
case TK_NOT: return OPR_NOT;
|
||||
case '-': return OPR_MINUS;
|
||||
/* BENILLA: 5.1's `#' length operator, deleted. 5.0 asks a table for its
|
||||
** size with table.getn and a string with string.len; the operator does not
|
||||
** exist and neither does its __len metamethod. Byte-read in the client:
|
||||
** getunopr is 0x6fe0a0, a leaf with exactly two token tests, `cmp ecx,0x2D'
|
||||
** (`-') then `cmp ecx,0x10E' (TK_NOT), so its OPR_NOUNOPR is 2: a
|
||||
** three-member enum, not 5.1's four. The metamethod-name pool at 0x871896
|
||||
** has no __len. A `#' therefore reaches simpleexp's default arm and raises
|
||||
** "unexpected symbol near `#'". */
|
||||
/* BENILLA: 5.1's `#' length operator, restored -- ClassicAPI's 5.1 syntax backport.
|
||||
** Stock 1.12's getunopr (0x6fe0a0) tests exactly two tokens and its OPR_NOUNOPR is 2, so a
|
||||
** `#' there raises "unexpected symbol". ClassicAPI adds it, the corpus uses it, and 5.1's own
|
||||
** OPR_LEN / OP_LEN / TM_LEN below this layer were never removed. */
|
||||
case '#': return OPR_LEN;
|
||||
default: return OPR_NOUNOPR;
|
||||
}
|
||||
}
|
||||
@@ -828,15 +828,11 @@ static BinOpr getbinopr (int op) {
|
||||
case '-': return OPR_SUB;
|
||||
case '*': return OPR_MUL;
|
||||
case '/': return OPR_DIV;
|
||||
/* BENILLA: 5.1's `%' modulo operator, deleted. 5.0 spells it math.mod
|
||||
** (and the bare global `mod'), which this VM publishes. Byte-read in the
|
||||
** client: getbinopr is 0x6fe0c0 and its switch is based at `ecx-0x2A' (`*',
|
||||
** the lowest token it handles). `%' is 0x25, below the base, so it is
|
||||
** outside the range test (`cmp eax,0xF2; ja') and falls to the default,
|
||||
** 0x6fe129, `mov eax,0xE' = OPR_NOBINOPR 14: a 15-member BinOpr, 5.0's, not
|
||||
** 5.1's 16-member one with OPR_MOD. The metamethod pool has no __mod either.
|
||||
** priority[] is indexed by the BinOpr enum, which is untouched; only the
|
||||
** token that reaches OPR_MOD is gone. */
|
||||
/* BENILLA: 5.1's `%' modulo operator, restored -- ClassicAPI's 5.1 syntax backport.
|
||||
** Stock 1.12's getbinopr (0x6fe0c0) bases its switch at `*' (0x2A), so `%' (0x25) falls
|
||||
** below the range and reaches a 15-member BinOpr with no OPR_MOD; 5.0 spells it math.mod,
|
||||
** which this VM still publishes. ClassicAPI adds the operator, so both spellings work. */
|
||||
case '%': return OPR_MOD;
|
||||
case '^': return OPR_POW;
|
||||
case TK_CONCAT: return OPR_CONCAT;
|
||||
case TK_NE: return OPR_NE;
|
||||
|
||||
Reference in New Issue
Block a user