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
897 lines
32 KiB
Rust
897 lines
32 KiB
Rust
//! The return-shape gate: `reference/1.12-shapes.tsv`, a harvest of every binding the 1.12 client
|
|
//! registers, against what this client answers.
|
|
//!
|
|
//! A 1.12 predicate answers `1` or `nil`, never a boolean (`IsPetAttackActive` aside), so kinds
|
|
//! are gated with arity. The table says per row what to trust: `arity_conf = exact`, kinds only
|
|
//! where `kinds_conf = agree`, never `argc_conf`. Only query verbs are probed, a call that raises
|
|
//! is skipped, and a name registered from several tables (`GetBuildInfo` is 5 in the glue table
|
|
//! `0x8373b8`, 3 in-game at `0x83de68`) is read at its non-glue row.
|
|
|
|
/// Globals known to answer the wrong arity: a name, the reference's arity and the reason. The
|
|
/// list may only shrink.
|
|
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
|
|
|
|
/// Globals that answer **more** than 1.12 because the realm's own FrameXML reads more: a name,
|
|
/// the reference's arity, what this client answers, and why.
|
|
///
|
|
/// A different thing from [`NOT_YET_ASSERTED`], which is a gap to close. These are deliberate and
|
|
/// load-bearing — the realm's interface does not work without them — so they are stated rather
|
|
/// than fixed, and the extra returns must come *after* the reference's, never in place of one.
|
|
/// The gate still fails on any other arity, including a larger one than the row claims.
|
|
const BEYOND_1_12_ARITY: &[(&str, usize, usize, &str)] = &[(
|
|
"GetAuctionSellItemInfo",
|
|
6,
|
|
8,
|
|
"the realm's rewritten CalculateAuctionDeposit reads maxStack as the 7th return \
|
|
(Blizzard_AuctionUI.lua:1123); a nil there raises out of AuctionFrameAuctions_OnLoad and \
|
|
the whole auction window fails to load. The 8th is the item link.",
|
|
)];
|
|
|
|
/// The kind gate's list, same rules: a name, the wrong kinds it answers and the reason. An entry
|
|
/// whose binding comes into agreement fails the gate.
|
|
const KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[];
|
|
|
|
/// The widget kind gate's list, same rules as [`KINDS_NOT_YET_ASSERTED`].
|
|
const WIDGET_KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[];
|
|
|
|
/// The widget arity gate's list, same rules as [`NOT_YET_ASSERTED`].
|
|
const WIDGET_NOT_YET_ASSERTED: &[&str] = &[];
|
|
|
|
/// The glue registrar table. This VM is the in-game one, so a duplicated name is read at its
|
|
/// other row.
|
|
const GLUE_TABLE: &str = "0x8373b8";
|
|
|
|
struct Row {
|
|
name: String,
|
|
table_va: String,
|
|
arity: usize,
|
|
/// The `kinds` column verbatim, `|`-separated alternatives; empty unless trustworthy.
|
|
kinds: String,
|
|
}
|
|
|
|
fn rows() -> Vec<Row> {
|
|
rows_of_kind("global")
|
|
}
|
|
|
|
/// The `arity_conf = exact` rows of one `table_kind`: `global` is the registrar surface,
|
|
/// `baselib` the 36-entry Lua base array looped into `_G` at `0x811e28`.
|
|
fn rows_of_kind(kind: &str) -> Vec<Row> {
|
|
let tsv = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../reference/1.12-shapes.tsv"
|
|
);
|
|
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
|
text.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
|
.filter_map(|l| {
|
|
let f: Vec<&str> = l.split('\t').collect();
|
|
// name fn pair_va table_va table_kind argc argc_conf arity arity_conf kinds …
|
|
if f.len() < 9 || f[4] != kind || f[8] != "exact" {
|
|
return None;
|
|
}
|
|
// A `delegated-push` row's callee pushes on its behalf, unseen by the push trace, so
|
|
// its kinds cover the binding's own ops only; its arity, read from `eax`, holds.
|
|
let delegated = f.len() > 11 && f[11].contains("delegated-push");
|
|
Some(Row {
|
|
name: f[0].to_string(),
|
|
table_va: f[3].to_string(),
|
|
arity: f[7].parse().ok()?,
|
|
// Kinds are advisory unless `kinds_conf` is `agree`, so such a row carries none.
|
|
kinds: if f.len() > 10 && f[10] == "agree" && !delegated {
|
|
f[9].to_string()
|
|
} else {
|
|
String::new()
|
|
},
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every registered global whose arity the reference states exactly, answered by this client.
|
|
#[test]
|
|
fn every_query_binding_answers_the_reference_s_return_arity() {
|
|
let all = rows();
|
|
assert!(
|
|
all.len() > 800,
|
|
"the vendored table looks wrong: {}",
|
|
all.len()
|
|
);
|
|
|
|
let mut by_name: std::collections::HashMap<&str, Vec<&Row>> = std::collections::HashMap::new();
|
|
for r in &all {
|
|
by_name.entry(&r.name).or_default().push(r);
|
|
}
|
|
|
|
let mut s = benilla_ui::script::UiScript::new().expect("VM");
|
|
// A seated player: the `Unit*` bindings raise on a nil token and answer a real one.
|
|
s.set_unit(
|
|
"player",
|
|
Some(benilla_ui::script::UnitState {
|
|
exists: true,
|
|
name: Some("Shapeprobe".into()),
|
|
level: 60,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
let mut checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
|
|
for (name, rs) in &by_name {
|
|
// Query verbs only: the gate calls what it measures.
|
|
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
|
rs.iter().collect()
|
|
} else {
|
|
rs.iter().filter(|r| r.table_va != GLUE_TABLE).collect()
|
|
};
|
|
// Still ambiguous after dropping glue: skip rather than guess.
|
|
let Some(first) = candidates.first() else {
|
|
continue;
|
|
};
|
|
if candidates.iter().any(|r| r.arity != first.arity) {
|
|
continue;
|
|
}
|
|
let want = first.arity;
|
|
|
|
// Counted with 5.0's `arg.n` inside the `pcall`: `select` is not a 1.12 global
|
|
// (`lua50::install` removes it).
|
|
let call = if name.starts_with("Unit") {
|
|
format!(r#"{name}("player")"#)
|
|
} else {
|
|
format!("{name}()")
|
|
};
|
|
let probe = format!(
|
|
"if type({name}) ~= 'function' then return -1 end \
|
|
local ok, n = pcall(function() return (function(...) return arg.n end)({call}) end) \
|
|
if not ok then return -1 end return n"
|
|
);
|
|
let got: i64 = match s.eval(&probe) {
|
|
Ok(n) => n,
|
|
Err(_) => continue,
|
|
};
|
|
if got < 0 {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got as usize != want {
|
|
if let Some((_, ref_arity, ours, _)) =
|
|
BEYOND_1_12_ARITY.iter().find(|(n, ..)| n == name)
|
|
{
|
|
assert_eq!(
|
|
*ref_arity, want,
|
|
"{name} is on the beyond-1.12 list against a stale reference arity — the \
|
|
table now says {want}"
|
|
);
|
|
assert_eq!(
|
|
got as usize, *ours,
|
|
"{name} is allowed {ours} returns for a named reason; it now answers {got}, \
|
|
which is a different deviation and wants its own entry"
|
|
);
|
|
continue;
|
|
}
|
|
if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
|
assert_eq!(
|
|
*known, want,
|
|
"{name} is on the not-yet-asserted list at a stale arity — the table now says \
|
|
{want}"
|
|
);
|
|
continue;
|
|
}
|
|
mismatches.push(format!("{name}: answers {got}, reference states {want}"));
|
|
}
|
|
}
|
|
|
|
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
|
|
assert!(
|
|
checked >= 240,
|
|
"the gate measured only {checked} bindings — it has stopped covering anything"
|
|
);
|
|
// The list may only shrink: an entry that now answers the reference's arity must come off.
|
|
let stale: Vec<&str> = NOT_YET_ASSERTED
|
|
.iter()
|
|
.map(|(n, ..)| *n)
|
|
.filter(|n| {
|
|
by_name.get(n).is_some_and(|rs| {
|
|
let want = rs[0].arity;
|
|
s.eval::<i64>(&format!(
|
|
"local ok, n = pcall(function() return (function(...) return arg.n end)({n}()) end) \
|
|
if not ok then return -1 end return n"
|
|
))
|
|
.is_ok_and(|got| got >= 0 && got as usize == want)
|
|
})
|
|
})
|
|
.collect();
|
|
assert!(
|
|
stale.is_empty(),
|
|
"these now answer the reference's arity and must come off NOT_YET_ASSERTED: {stale:?}"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed bindings answer the wrong number of values:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// The base-library probes, a name and its call arguments: these names follow no query-verb
|
|
/// convention. The arguments are what the 5.0 signatures accept, chosen to discriminate; `setfenv`
|
|
/// is left out because it would change the environment every later probe runs in.
|
|
const BASELIB_PROBES: &[(&str, &str)] = &[
|
|
("collectgarbage", ""),
|
|
("date", ""),
|
|
// `debugbreak`, `debugdump`, `debuginfo`, `debugload`, `debugprint` and `debugtimestamp` are
|
|
// the reference's six `xor eax,eax; ret` stubs (`0x7027e0` to `0x702830`), safe to call.
|
|
("debugbreak", ""),
|
|
("debugdump", ""),
|
|
("debuginfo", ""),
|
|
("debugload", ""),
|
|
("debugprint", ""),
|
|
("debugprofilestart", ""),
|
|
("debugprofilestop", ""),
|
|
("debugstack", ""),
|
|
("debugtimestamp", ""),
|
|
("gcinfo", ""),
|
|
("geterrorhandler", ""),
|
|
("getfenv", ""),
|
|
("time", ""),
|
|
// ── probes that take arguments ──────────────────────────────────────────────────────────
|
|
// Two arguments: only a multi-argument call tells 5.0's `lua_settop(L,1); return 1` from
|
|
// 5.1's `return lua_gettop(L)`.
|
|
("assert", "1, 2"),
|
|
("getglobal", "\"BenillaShapeGateAbsent\""),
|
|
("getmetatable", "{}"),
|
|
("pairs", "{}"),
|
|
("rawequal", "1, 1"),
|
|
("rawget", "{}, 1"),
|
|
("rawset", "{}, 1, 1"),
|
|
// These write, but namespaced or self-restoring, so every later probe sees the same VM.
|
|
("seterrorhandler", "geterrorhandler()"),
|
|
("setglobal", "\"BenillaShapeGateProbe\", 1"),
|
|
("setmetatable", "{}, nil"),
|
|
("tonumber", "\"1\""),
|
|
("tostring", "nil"),
|
|
("type", "nil"),
|
|
];
|
|
|
|
/// The base library's list, same rules as [`NOT_YET_ASSERTED`].
|
|
const BASELIB_NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
|
|
|
|
#[test]
|
|
fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
|
|
let rows = rows_of_kind("baselib");
|
|
assert!(
|
|
rows.len() > 20,
|
|
"the vendored table's baselib rows look wrong: {}",
|
|
rows.len()
|
|
);
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
let mut checked = 0usize;
|
|
let mut kinds_checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
|
|
for r in &rows {
|
|
let Some((_, args)) = BASELIB_PROBES.iter().find(|(n, _)| *n == r.name) else {
|
|
continue;
|
|
};
|
|
let name = &r.name;
|
|
let probe = format!(
|
|
"if type({name}) ~= 'function' then return -1 end \
|
|
local ok, n = pcall(function() return (function(...) return arg.n end)({name}({args})) end) \
|
|
if not ok then return -1 end return n"
|
|
);
|
|
let got: i64 = match s.eval(&probe) {
|
|
Ok(n) => n,
|
|
Err(_) => continue,
|
|
};
|
|
if got < 0 {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got as usize != r.arity {
|
|
if let Some((_, known, _)) = BASELIB_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
|
assert_eq!(*known, r.arity, "{name} is listed at a stale arity");
|
|
continue;
|
|
}
|
|
mismatches.push(format!(
|
|
"{name}: answers {got}, reference states {}",
|
|
r.arity
|
|
));
|
|
continue;
|
|
}
|
|
// Kinds only where `rows_of_kind` kept them as trustworthy.
|
|
if r.kinds.is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(kinds) = s.eval::<String>(&format!(
|
|
"local t = {{ {name}({args}) }} local out = '' \
|
|
for i = 1, {} do out = out .. (i > 1 and ',' or '') .. type(t[i]) end return out",
|
|
r.arity
|
|
)) else {
|
|
continue;
|
|
};
|
|
kinds_checked += 1;
|
|
let got_tuple = format!("({kinds})");
|
|
let acceptable = r.kinds.split('|').map(str::trim).any(|alt| {
|
|
alt == got_tuple
|
|
|| alt
|
|
.trim_matches(|c| c == '(' || c == ')')
|
|
.split(',')
|
|
.map(str::trim)
|
|
.zip(kinds.split(',').map(str::trim))
|
|
// `string?` is string-or-nil; `any`/`value` accept anything.
|
|
.all(|(want, got)| {
|
|
want == got
|
|
|| (want == "string?" && (got == "string" || got == "nil"))
|
|
|| want == "any"
|
|
|| want == "value"
|
|
})
|
|
});
|
|
if !acceptable {
|
|
mismatches.push(format!(
|
|
"{name}: answers kinds {got_tuple}, reference states {}",
|
|
r.kinds
|
|
));
|
|
}
|
|
}
|
|
|
|
// A floor, not a target.
|
|
assert!(
|
|
checked >= 28,
|
|
"the base-library gate measured only {checked} bindings"
|
|
);
|
|
// The kinds comparison has its own floor: the trust narrowing can take it to zero while the
|
|
// arity floor stays green.
|
|
assert!(
|
|
kinds_checked >= 16,
|
|
"the base-library gate compared kinds on only {kinds_checked} bindings"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed base-library bindings diverge:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// The widget registrar tables with their class and an instance name. A method name can sit in
|
|
/// several tables at different arities, so each method is probed on its own table's class; an
|
|
/// unmapped table is skipped.
|
|
const WIDGET_PROBES: &[(&str, &str, &str)] = &[
|
|
("0x878ec0", "Frame", "PGFrame"),
|
|
("0x879d00", "Button", "PGButton"),
|
|
("0x87bf74", "CheckButton", "PGCheck"),
|
|
("0x87bb68", "EditBox", "PGEdit"),
|
|
("0x87b260", "Slider", "PGSlider"),
|
|
("0x87b010", "StatusBar", "PGStatus"),
|
|
("0x87b3c0", "ScrollFrame", "PGScroll"),
|
|
("0x87ba80", "SimpleHTML", "PGHtml"),
|
|
("0x87abb0", "ColorSelect", "PGColor"),
|
|
("0x87b960", "MessageFrame", "PGMessage"),
|
|
("0x87b5c0", "ScrollingMessageFrame", "PGScrollMsg"),
|
|
("0x878948", "Model", "PGModel"),
|
|
("0x854198", "GameTooltip", "PGTip"),
|
|
("0x84c538", "Minimap", "PGMinimap"),
|
|
("0x84ee40", "TabardModel", "PGTabard"),
|
|
("0x84f190", "DressUpModel", "PGDress"),
|
|
// `PlayerModel` is `Model` plus three verbs, in a table of its own.
|
|
("0x84f1fc", "PlayerModel", "PGPlayerModel"),
|
|
("0x87ab4c", "MovieFrame", "PGMovie"),
|
|
("0x847ce4", "LootButton", "PGLoot"),
|
|
// The base every widget falls back to (`REGION_MAP_METHODS`' 19 names), probed on a Frame.
|
|
("0x87c9b8", "Frame", "PGRegionBase"),
|
|
];
|
|
|
|
/// The classes `CreateFrame` cannot make: the two regions, made by a frame, and the font object.
|
|
const REGION_PROBES: &[(&str, &str)] = &[
|
|
("0x87c128", "PGFrame:CreateTexture('PGTex')"),
|
|
("0x87c1d8", "PGFrame:CreateFontString('PGFS')"),
|
|
// The font object (`CreateFont`), not a FontString: 22 methods of its own.
|
|
("0x87c7c8", "CreateFont('PGFontObject')"),
|
|
];
|
|
|
|
/// The widget arity gate: the global gate's rules, with each method called on an instance of its
|
|
/// own registrar table's class.
|
|
#[test]
|
|
fn every_widget_method_answers_the_reference_s_return_arity() {
|
|
let tsv = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../reference/1.12-shapes.tsv"
|
|
);
|
|
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
|
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
|
|
for (table, kind, name) in WIDGET_PROBES {
|
|
if s.run(&format!(
|
|
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
|
|
))
|
|
.is_ok()
|
|
{
|
|
made.insert(table, (*name).to_string());
|
|
}
|
|
}
|
|
for (table, expr) in REGION_PROBES {
|
|
let var = format!("PGR{}", made.len());
|
|
if s.run(&format!("{var} = {expr}")).is_ok() {
|
|
made.insert(table, var);
|
|
}
|
|
}
|
|
assert!(
|
|
made.len() >= 20,
|
|
"only {} widget classes could be instantiated — the probe set is broken",
|
|
made.len()
|
|
);
|
|
|
|
let mut checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
for line in text
|
|
.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
|
{
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
if f.len() < 9 || f[4] != "widget" || f[8] != "exact" {
|
|
continue;
|
|
}
|
|
let (name, table) = (f[0], f[3]);
|
|
if !["Get", "Is", "Has", "Can", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
let Some(obj) = made.get(table) else { continue };
|
|
let Ok(want) = f[7].parse::<usize>() else {
|
|
continue;
|
|
};
|
|
|
|
let probe = format!(
|
|
"if type({obj}.{name}) ~= 'function' then return -1 end \
|
|
local ok, n = pcall(function() return (function(...) return arg.n end)({obj}:{name}()) end) \
|
|
if not ok then return -1 end return n"
|
|
);
|
|
let Ok(got) = s.eval::<i64>(&probe) else {
|
|
continue;
|
|
};
|
|
if got < 0 {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got as usize != want {
|
|
if WIDGET_NOT_YET_ASSERTED.contains(&name) {
|
|
continue;
|
|
}
|
|
mismatches.push(format!(
|
|
"{obj}:{name} answers {got}, reference states {want}"
|
|
));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
// A floor under the coverage of all 23 widget tables.
|
|
checked >= 110,
|
|
"the widget gate measured only {checked} methods — it has stopped covering anything"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed widget methods answer the wrong number of values:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// Splits a `kinds` cell into alternatives of per-slot kinds: `(nil) | (number)` is two of one
|
|
/// slot, and `()` yields none, so the kind gates skip a verb that answers nothing.
|
|
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
|
|
cell.split('|')
|
|
.map(|alt| {
|
|
alt.trim()
|
|
.trim_start_matches('(')
|
|
.trim_end_matches(')')
|
|
.split(',')
|
|
.map(|k| k.trim().to_string())
|
|
.filter(|k| !k.is_empty())
|
|
.collect::<Vec<String>>()
|
|
})
|
|
.filter(|a| !a.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
/// Whether an observed Lua `type()` satisfies one reference slot. `string?` is string-or-nil:
|
|
/// `lua_pushstring` (`0x6f3890`) pushes nil for a NULL pointer, and the harvest writes plain
|
|
/// `string` only for a constant literal. `any` and `value` are slots the trace could not narrow.
|
|
fn slot_accepts(want: &str, got: &str) -> bool {
|
|
match want {
|
|
"any" | "value" => true,
|
|
"string?" => got == "string" || got == "nil",
|
|
w => w == got,
|
|
}
|
|
}
|
|
|
|
/// The Lua helper the kind gates probe through: the `type()` of every returned value, in order,
|
|
/// 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";
|
|
|
|
/// Every registered global whose return kinds the reference states trustworthily, answered by
|
|
/// this client, under the arity gate's narrowings.
|
|
#[test]
|
|
fn every_query_binding_answers_the_reference_s_return_kinds() {
|
|
let all = rows();
|
|
let mut by_name: std::collections::HashMap<&str, Vec<&Row>> = std::collections::HashMap::new();
|
|
for r in &all {
|
|
by_name.entry(&r.name).or_default().push(r);
|
|
}
|
|
|
|
let mut s = benilla_ui::script::UiScript::new().expect("VM");
|
|
s.set_unit(
|
|
"player",
|
|
Some(benilla_ui::script::UnitState {
|
|
exists: true,
|
|
name: Some("Shapeprobe".into()),
|
|
level: 60,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
s.run(KIND_HELPER).expect("kind helper");
|
|
|
|
let mut checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
for (name, rs) in &by_name {
|
|
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
let candidates: Vec<&&Row> = if rs.len() == 1 {
|
|
rs.iter().collect()
|
|
} else {
|
|
rs.iter().filter(|r| r.table_va != GLUE_TABLE).collect()
|
|
};
|
|
let Some(first) = candidates.first() else {
|
|
continue;
|
|
};
|
|
if candidates.iter().any(|r| r.kinds != first.kinds) {
|
|
continue;
|
|
}
|
|
let want = kind_alternatives(&first.kinds);
|
|
if want.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let call = if name.starts_with("Unit") {
|
|
format!(r#"{name}("player")"#)
|
|
} else {
|
|
format!("{name}()")
|
|
};
|
|
let probe = format!(
|
|
"if type({name}) ~= 'function' then return '?' end \
|
|
local ok, s = pcall(function() return PGKinds({call}) end) \
|
|
if not ok then return '?' end return s"
|
|
);
|
|
let Ok(got) = s.eval::<String>(&probe) else {
|
|
continue;
|
|
};
|
|
if got == "?" {
|
|
continue;
|
|
}
|
|
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
|
|
// An arity fault is the arity gate's to report.
|
|
if !want.iter().any(|alt| alt.len() == got.len()) {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
let agrees = want.iter().any(|alt| {
|
|
alt.len() == got.len()
|
|
&& alt
|
|
.iter()
|
|
.zip(&got)
|
|
.all(|(w, g)| slot_accepts(w.as_str(), g))
|
|
});
|
|
let observed = got.join(",");
|
|
if !agrees {
|
|
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
|
|
assert_eq!(
|
|
*known, observed,
|
|
"{name}'s not-yet-asserted row is stale: it now answers ({observed})"
|
|
);
|
|
continue;
|
|
}
|
|
mismatches.push(format!(
|
|
"{name}: answers ({observed}), reference states {}",
|
|
first.kinds
|
|
));
|
|
} else {
|
|
assert!(
|
|
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
|
|
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
|
|
);
|
|
}
|
|
}
|
|
|
|
// A floor, not a target.
|
|
assert!(
|
|
checked >= 150,
|
|
"the kind gate measured only {checked} bindings — it has stopped covering anything"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed bindings answer values of the wrong kind:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// The widget kind gate, on an instance of each method's own registrar class.
|
|
#[test]
|
|
fn every_widget_method_answers_the_reference_s_return_kinds() {
|
|
let tsv = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../reference/1.12-shapes.tsv"
|
|
);
|
|
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
|
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
s.run(KIND_HELPER).expect("kind helper");
|
|
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
|
|
for (table, kind, name) in WIDGET_PROBES {
|
|
if s.run(&format!(
|
|
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
|
|
))
|
|
.is_ok()
|
|
{
|
|
made.insert(table, (*name).to_string());
|
|
}
|
|
}
|
|
for (table, expr) in REGION_PROBES {
|
|
let var = format!("PGR{}", made.len());
|
|
if s.run(&format!("{var} = {expr}")).is_ok() {
|
|
made.insert(table, var);
|
|
}
|
|
}
|
|
|
|
let mut checked = 0usize;
|
|
let mut mismatches: Vec<String> = Vec::new();
|
|
for line in text
|
|
.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
|
{
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
if f.len() < 11 || f[4] != "widget" || f[8] != "exact" || f[10] != "agree" {
|
|
continue;
|
|
}
|
|
let (name, table) = (f[0], f[3]);
|
|
if !["Get", "Is", "Has", "Can", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
let Some(obj) = made.get(table) else { continue };
|
|
let want = kind_alternatives(f[9]);
|
|
if want.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let probe = format!(
|
|
"if type({obj}.{name}) ~= 'function' then return '?' end \
|
|
local ok, s = pcall(function() return PGKinds({obj}:{name}()) end) \
|
|
if not ok then return '?' end return s"
|
|
);
|
|
let Ok(got) = s.eval::<String>(&probe) else {
|
|
continue;
|
|
};
|
|
if got == "?" {
|
|
continue;
|
|
}
|
|
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
|
|
if !want.iter().any(|alt| alt.len() == got.len()) {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
let agrees = want.iter().any(|alt| {
|
|
alt.len() == got.len()
|
|
&& alt
|
|
.iter()
|
|
.zip(&got)
|
|
.all(|(w, g)| slot_accepts(w.as_str(), g))
|
|
});
|
|
let observed = got.join(",");
|
|
if !agrees {
|
|
if let Some((_, known, _)) = WIDGET_KINDS_NOT_YET_ASSERTED
|
|
.iter()
|
|
.find(|(n, ..)| *n == name)
|
|
{
|
|
assert_eq!(
|
|
*known, observed,
|
|
"{name}'s widget not-yet-asserted row is stale: it now answers ({observed})"
|
|
);
|
|
continue;
|
|
}
|
|
mismatches.push(format!(
|
|
"{obj}:{name} answers ({observed}), reference states {}",
|
|
f[9]
|
|
));
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
// A floor under the coverage of all 23 widget tables.
|
|
checked >= 110,
|
|
"the widget kind gate measured only {checked} methods — it has stopped covering anything"
|
|
);
|
|
assert!(
|
|
mismatches.is_empty(),
|
|
"{} of {checked} probed widget methods answer values of the wrong kind:\n {}",
|
|
mismatches.len(),
|
|
mismatches.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// Argument tuples the boolean gate tries in order until a call returns. A plausible wrong
|
|
/// argument can change which kinds come back, which is why the kind gate passes only a unit
|
|
/// token, but it never turns `1` into `true`.
|
|
const ARG_LADDER: &[&str] = &["", "\"player\"", "1", "1, 1", "\"player\", 1"];
|
|
|
|
/// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's
|
|
/// own `rawequal` answer a `boolean`, and every other predicate pushes `1` or `nil`, which
|
|
/// `x == 1`, `tostring(x)` or a saved variable tell apart from `true`.
|
|
#[test]
|
|
fn no_query_binding_answers_a_lua_boolean() {
|
|
// The reference's own two; the list may only shrink.
|
|
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
|
|
|
|
let all = rows();
|
|
let mut s = benilla_ui::script::UiScript::new().expect("VM");
|
|
s.set_unit(
|
|
"player",
|
|
Some(benilla_ui::script::UnitState {
|
|
exists: true,
|
|
name: Some("Shapeprobe".into()),
|
|
level: 60,
|
|
..Default::default()
|
|
}),
|
|
);
|
|
s.run(KIND_HELPER).expect("kind helper");
|
|
|
|
let mut names: Vec<&str> = all
|
|
.iter()
|
|
.map(|r| r.name.as_str())
|
|
.filter(|n| {
|
|
["Get", "Is", "Has", "Can", "Unit", "Num"]
|
|
.iter()
|
|
.any(|p| n.starts_with(p))
|
|
})
|
|
.filter(|n| !REFERENCE_BOOLEANS.contains(n))
|
|
.collect();
|
|
names.sort_unstable();
|
|
names.dedup();
|
|
|
|
let mut checked = 0usize;
|
|
let mut booleans: Vec<String> = Vec::new();
|
|
for name in names {
|
|
for args in ARG_LADDER {
|
|
let probe = format!(
|
|
"if type({name}) ~= 'function' then return '?' end \
|
|
local ok, s = pcall(function() return PGKinds({name}({args})) end) \
|
|
if not ok then return '?' end return s"
|
|
);
|
|
let Ok(got) = s.eval::<String>(&probe) else {
|
|
continue;
|
|
};
|
|
if got == "?" {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got.split(',').any(|k| k == "boolean") {
|
|
booleans.push(format!("{name}({args}) answers ({got})"));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
checked >= 300,
|
|
"the boolean gate measured only {checked} bindings — it has stopped covering anything"
|
|
);
|
|
assert!(
|
|
booleans.is_empty(),
|
|
"{} bindings answer a Lua boolean; 1.12 pushes 1/nil and only {REFERENCE_BOOLEANS:?} are \
|
|
boolean in the reference:\n {}",
|
|
booleans.len(),
|
|
booleans.join("\n ")
|
|
);
|
|
}
|
|
|
|
/// The widget boolean gate. Its argument ladder reaches the predicates the kind gate skips for
|
|
/// raising without one, such as `HasScript("OnClick")` and `IsObjectType("Frame")`.
|
|
#[test]
|
|
fn no_widget_method_answers_a_lua_boolean() {
|
|
let tsv = concat!(
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
"/../../reference/1.12-shapes.tsv"
|
|
);
|
|
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
|
|
|
|
let s = benilla_ui::script::UiScript::new().expect("VM");
|
|
s.run(KIND_HELPER).expect("kind helper");
|
|
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
|
|
for (table, kind, name) in WIDGET_PROBES {
|
|
if s.run(&format!(
|
|
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
|
|
))
|
|
.is_ok()
|
|
{
|
|
made.insert(table, (*name).to_string());
|
|
}
|
|
}
|
|
for (table, expr) in REGION_PROBES {
|
|
let var = format!("PGR{}", made.len());
|
|
if s.run(&format!("{var} = {expr}")).is_ok() {
|
|
made.insert(table, var);
|
|
}
|
|
}
|
|
|
|
// A widget predicate takes a script or object-type name more often than a number.
|
|
const WIDGET_ARGS: &[&str] = &["", "\"OnClick\"", "\"Frame\"", "1", "1, 1"];
|
|
|
|
let mut checked = 0usize;
|
|
let mut booleans: Vec<String> = Vec::new();
|
|
for line in text
|
|
.lines()
|
|
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
|
|
{
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
if f.len() < 9 || f[4] != "widget" || f[8] != "exact" {
|
|
continue;
|
|
}
|
|
let (name, table) = (f[0], f[3]);
|
|
if !["Get", "Is", "Has", "Can", "Num"]
|
|
.iter()
|
|
.any(|p| name.starts_with(p))
|
|
{
|
|
continue;
|
|
}
|
|
// No widget method answers a boolean in the reference, so none is exempt here.
|
|
let Some(obj) = made.get(table) else { continue };
|
|
for args in WIDGET_ARGS {
|
|
let probe = format!(
|
|
"if type({obj}.{name}) ~= 'function' then return '?' end \
|
|
local ok, s = pcall(function() return PGKinds({obj}:{name}({args})) end) \
|
|
if not ok then return '?' end return s"
|
|
);
|
|
let Ok(got) = s.eval::<String>(&probe) else {
|
|
continue;
|
|
};
|
|
if got == "?" {
|
|
continue;
|
|
}
|
|
checked += 1;
|
|
if got.split(',').any(|k| k == "boolean") {
|
|
booleans.push(format!("{obj}:{name}({args}) answers ({got})"));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
checked >= 100,
|
|
"the widget boolean gate measured only {checked} methods — it has stopped covering anything"
|
|
);
|
|
assert!(
|
|
booleans.is_empty(),
|
|
"{} widget methods answer a Lua boolean; every predicate row in the reference's widget \
|
|
tables is `(nil) | (number)`:\n {}",
|
|
booleans.len(),
|
|
booleans.join("\n ")
|
|
);
|
|
}
|