The host's state leaves `_G`: the eleven `__benilla_*` globals, which any addon could read and overwrite, move into the host's model (and, for the error handler, the Lua registry, as the reference keeps it), and the getters over them are Rust bindings answering the reference's shapes. - `GetTime` returns one number (`0x515ea0`), and `GetGameTime` returns hour then minute (`0x515ee0`). The tick advances the model clock, and every cooldown and buff reader reads it. - `GetZoneText`, `GetRealZoneText`, `GetSubZoneText` and `GetMinimapZoneText` always answer a string, `""` when unset (`0x48a0a0`-`0x48a100`). `GetZonePVPInfo` answers three values (`0x48d540`). - `seterrorhandler`/`geterrorhandler` work over a registry slot that starts empty, as the reference's does (`0x7039e0`, `0x702950`, `0x702900`). A non-function raises `Usage: seterrorhandler(errfunc)`. The engine dispatches caught errors through the slot, not through the global (`0x703b40`). The stock `BasicControls.xml` installs `_ERRORMESSAGE` before any addon, so play is unchanged. - The engine's surface allowlist now holds only `_G`. `scripts/api-coverage.sh` no longer excuses `__` names. Verified: - New tests show each of the eleven names absent from `_G` and that writing it changes nothing. On main, `__benilla_now = 0` rewound `GetTime` and every buff and cooldown. - The getter shapes and the error-handler slot are pinned. The surface gate fails when a name is re-added. - `scripts/gates.sh` and `scripts/smoke.sh` are green. - A live run read the clock, zone texts, PvP info and game time, then showed that writing the old names changes nothing. Built on macOS; no platform seam touched. Fixes #576 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
867 lines
31 KiB
Rust
867 lines
31 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)] = &[];
|
|
|
|
/// 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((_, 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 inert, so every later probe sees the same VM. The bare VM's
|
|
// handler slot is empty and takes a function alone (`0x702900`), so the probe hands it a no-op,
|
|
// which nothing here dispatches to.
|
|
("seterrorhandler", "function() end"),
|
|
("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`: 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.
|
|
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 ")
|
|
);
|
|
}
|