The Lua VM keeps no surface the 1.12.1 client lacks. - **Removed:** the nine Era-only globals: `UnitAura`, `CancelUnitBuff`, `GetCursorInfo`, `GetInventoryItemID`, `GetPlayerFacing`, `GetTradePartnerName`, `IsGossipOptionCoded`, `UnitIsAFK`, `UnitIsDND`. - **Hooks off Lua:** `GameTooltip:BenillaSetItemById` and `:BenillaGetTooltipOwner` are replaced by Rust-side calls. The uncalled `Benilla*` hooks are deleted. - **Frame strata:** `SetFrameStrata` knows the reference's eight strata and raises its message for any other (`0x774450`). - **`SetDrawLayer`** reads the layer name alone (`0x79a780`). - **`Set<State>Texture`** reads a number as a path (`0x781b23`); there is no colour form. - **`PlaySound`** names a kit and returns nothing (`0x4586d0`). `PlaySoundFile` returns one value (`0x458780`); benilla answers 1, a recorded deviation, since it plays after the call. - **`OnClick`** sets only `arg1` (`0x779540`). - **Quest getters:** `GetQuestItemInfo` and the quest-log reward and choice getters answer the reference's five values and its miss behaviour (`0x501f80`, `0x4e0b00`, `0x4e0cf0`). - **`PickupContainerItem`** returns nothing (`0x4f9b30`). - **Surface gates:** the bare VM's globals and every widget class's methods are held to `reference/1.12-globals.tsv` with one commented allowlist. A new gate on the full production load (core plus layer) allows beyond that only the layer's `Benilla`-prefixed globals. Verified: - A test per removal and per corrected form; a scripted undo of each fails its test. - Each gate fails on a re-added name. - `scripts/gates.sh` and `scripts/smoke.sh` are green. - A live run showed the same warnings and 0 script errors before and after. - In the 219-addon corpus, AckisRecipeList's bundled AceGUI-3.0 and Roid-Macros' Grid extension now behave as they do on 1.12.1. Built on macOS; no platform seam touched. Fixes #171 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
354 lines
13 KiB
Rust
354 lines
13 KiB
Rust
//! The widget-method surface gate, the widget sibling of [`super::reference_surface`], with no
|
|
//! prefix that excuses a method: a host seam is a Rust call, never a method an addon reaches. The
|
|
//! reference's 23 widget registrar tables are disjoint, so a class's surface is its own table plus
|
|
//! those it inherits ([`WIDGET_CHAINS`]): `0x87c9b8` is the base `Region` map (the 19
|
|
//! `REGION_MAP_METHODS`), and `LootButton`'s own `0x847ce4` holds only `SetSlot`.
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use crate::script::{widget_method_census, UiScript};
|
|
|
|
/// `(class, its own registrar table, the tables it inherits)`. `WorldFrame` is a `Frame` to Lua
|
|
/// and `TitleRegion` a bare `Region`; like `TaxiRouteFrame`, neither has a table of its own.
|
|
const WIDGET_CHAINS: &[(&str, &str, &[&str])] = &[
|
|
("Frame", FRAME, &[REGION]),
|
|
("WorldFrame", FRAME, &[REGION]),
|
|
("Button", BUTTON, FRAME_CHAIN),
|
|
("LootButton", "0x847ce4", &[BUTTON, FRAME, REGION]),
|
|
("CheckButton", "0x87bf74", &[BUTTON, FRAME, REGION]),
|
|
("EditBox", "0x87bb68", FRAME_CHAIN),
|
|
("StatusBar", "0x87b010", FRAME_CHAIN),
|
|
("Slider", "0x87b260", FRAME_CHAIN),
|
|
("ScrollFrame", "0x87b3c0", FRAME_CHAIN),
|
|
("Model", MODEL, FRAME_CHAIN),
|
|
("PlayerModel", PLAYER_MODEL, &[MODEL, FRAME, REGION]),
|
|
(
|
|
"DressUpModel",
|
|
"0x84f190",
|
|
&[PLAYER_MODEL, MODEL, FRAME, REGION],
|
|
),
|
|
(
|
|
"TabardModel",
|
|
"0x84ee40",
|
|
&[PLAYER_MODEL, MODEL, FRAME, REGION],
|
|
),
|
|
("MessageFrame", "0x87b960", FRAME_CHAIN),
|
|
("ScrollingMessageFrame", "0x87b5c0", FRAME_CHAIN),
|
|
("ColorSelect", "0x87abb0", FRAME_CHAIN),
|
|
("SimpleHTML", "0x87ba80", FRAME_CHAIN),
|
|
("MovieFrame", "0x87ab4c", FRAME_CHAIN),
|
|
("GameTooltip", "0x854198", FRAME_CHAIN),
|
|
("Minimap", "0x84c538", FRAME_CHAIN),
|
|
("Texture", "0x87c128", &[REGION]),
|
|
("FontString", "0x87c1d8", &[REGION]),
|
|
// The font object (`CreateFont`) inherits nothing.
|
|
("Font", "0x87c7c8", &[]),
|
|
("TitleRegion", REGION, &[]),
|
|
];
|
|
const REGION: &str = "0x87c9b8";
|
|
const FRAME: &str = "0x878ec0";
|
|
const BUTTON: &str = "0x879d00";
|
|
const MODEL: &str = "0x878948";
|
|
const PLAYER_MODEL: &str = "0x84f1fc";
|
|
const FRAME_CHAIN: &[&str] = &[FRAME, REGION];
|
|
|
|
/// Reference methods this client does not answer yet: one row per name, its classes and why. The
|
|
/// list only shrinks; a name with live callers belongs implemented.
|
|
const NOT_YET_ANSWERED: &[(&str, &str, &str)] = &[
|
|
// ── The MovieFrame trio ──
|
|
// The stock glue `MovieFrame.lua` calls all three; benilla plays no movie yet.
|
|
(
|
|
"StartMovie",
|
|
"MovieFrame",
|
|
"the intro-movie screen's own verb; benilla plays no movie yet",
|
|
),
|
|
("StopMovie", "MovieFrame", "the same, on the skip path"),
|
|
(
|
|
"EnableSubtitles",
|
|
"MovieFrame",
|
|
"the same — `MovieFrame.lua:35` passes `GetMovieSubtitles()`",
|
|
),
|
|
// ── The Minimap's four content setters ──
|
|
// The app draws the minimap's blips and arrow itself, never through Lua.
|
|
(
|
|
"SetArrowModel",
|
|
"Minimap",
|
|
"app-side art; no caller in the stock UI or either corpus",
|
|
),
|
|
("SetBlipTexture", "Minimap", "the same"),
|
|
("SetIconTexture", "Minimap", "the same"),
|
|
("SetPlayerModel", "Minimap", "the same"),
|
|
// ── The Button font-object and text-colour family ──
|
|
// `button.rs` keeps this font state but publishes none of it; nothing calls these.
|
|
(
|
|
"GetDisabledFontObject",
|
|
"Button CheckButton LootButton",
|
|
"the button font-state family; no caller anywhere measured",
|
|
),
|
|
(
|
|
"GetDisabledTextColor",
|
|
"Button CheckButton LootButton",
|
|
"the same family",
|
|
),
|
|
(
|
|
"GetHighlightFontObject",
|
|
"Button CheckButton LootButton",
|
|
"the same family",
|
|
),
|
|
(
|
|
"GetHighlightTextColor",
|
|
"Button CheckButton LootButton",
|
|
"the same family",
|
|
),
|
|
(
|
|
"GetTextFontObject",
|
|
"Button CheckButton LootButton",
|
|
"the same family",
|
|
),
|
|
(
|
|
"GetPushedTextOffset",
|
|
"Button CheckButton LootButton",
|
|
"the same family",
|
|
),
|
|
(
|
|
"SetPushedTextOffset",
|
|
"Button CheckButton LootButton",
|
|
"the same family",
|
|
),
|
|
// ── Line spacing, on the five classes that carry text ──
|
|
(
|
|
"GetSpacing",
|
|
"EditBox Font FontString MessageFrame ScrollingMessageFrame",
|
|
"line spacing is not modelled by `ui_text`'s layout; no caller anywhere measured",
|
|
),
|
|
(
|
|
"SetSpacing",
|
|
"EditBox Font FontString MessageFrame ScrollingMessageFrame",
|
|
"the same — the setter is a layout change, not a stored value",
|
|
),
|
|
// ── EditBox mode read-backs ──
|
|
(
|
|
"IsAutoFocus",
|
|
"EditBox",
|
|
"the mode is set and not readable back; no caller anywhere measured",
|
|
),
|
|
("IsMultiLine", "EditBox", "the same"),
|
|
("IsNumeric", "EditBox", "the same"),
|
|
("IsPassword", "EditBox", "the same"),
|
|
// ── ScrollingMessageFrame's line-position surface ──
|
|
(
|
|
"GetCurrentLine",
|
|
"ScrollingMessageFrame",
|
|
"the buffer's position is internal; no caller measured",
|
|
),
|
|
("GetCurrentScroll", "ScrollingMessageFrame", "the same"),
|
|
("GetNumLinesDisplayed", "ScrollingMessageFrame", "the same"),
|
|
("SetScrollFromBottom", "ScrollingMessageFrame", "the same"),
|
|
// ── The colour picker's HSV pair ──
|
|
(
|
|
"GetColorHSV",
|
|
"ColorSelect",
|
|
"the RGB pair is published; nothing measured asks for HSV",
|
|
),
|
|
("SetColorHSV", "ColorSelect", "the same"),
|
|
// ── One texture read-back ──
|
|
(
|
|
"IsDesaturated",
|
|
"Texture",
|
|
"`SetDesaturated` is here and not readable back; no caller measured",
|
|
),
|
|
];
|
|
|
|
/// Methods answered here that 1.12 does not register on that class, each with why it stays.
|
|
const ALLOWED_BEYOND: &[(&str, &str, &str)] = &[];
|
|
|
|
/// The reference's widget rows, `table_va -> {name}`.
|
|
fn reference_tables() -> BTreeMap<String, BTreeSet<String>> {
|
|
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 mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for line in text.lines() {
|
|
if line.starts_with('#') || line.starts_with("name\t") {
|
|
continue;
|
|
}
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
if f.len() < 5 || f[4] != "widget" {
|
|
continue;
|
|
}
|
|
out.entry(f[3].to_string())
|
|
.or_default()
|
|
.insert(f[0].to_string());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `class -> the effective reference surface` (its own table and everything it inherits).
|
|
fn reference_surface_by_class() -> BTreeMap<&'static str, BTreeSet<String>> {
|
|
let tables = reference_tables();
|
|
assert!(
|
|
tables.len() == 23,
|
|
"the shapes table gave {} widget registrar tables, not the reference's 23 — it changed \
|
|
shape, and every count below is against the wrong denominator",
|
|
tables.len()
|
|
);
|
|
let mut out = BTreeMap::new();
|
|
for (class, own, bases) in WIDGET_CHAINS {
|
|
let mut set = tables.get(*own).cloned().unwrap_or_default();
|
|
for base in *bases {
|
|
set.extend(tables.get(*base).cloned().unwrap_or_default());
|
|
}
|
|
assert!(!set.is_empty(), "{class}: empty reference surface");
|
|
out.insert(*class, set);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `class -> what this VM answers`, asked of a live instance of each.
|
|
fn ours_by_class() -> BTreeMap<String, BTreeSet<String>> {
|
|
let script = UiScript::new().expect("VM");
|
|
let rows = widget_method_census(&script).expect("census");
|
|
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for (class, method) in rows {
|
|
assert_ne!(
|
|
method, "!NOT-INSTANTIABLE",
|
|
"{class} could not be instantiated — the census cannot speak for a class it never made"
|
|
);
|
|
out.entry(class).or_default().insert(method);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A declared list, `name -> (classes, reason)`.
|
|
fn declared(list: &[(&str, &str, &str)]) -> BTreeMap<String, BTreeSet<String>> {
|
|
list.iter()
|
|
.map(|(name, classes, _)| {
|
|
(
|
|
(*name).to_string(),
|
|
classes.split_whitespace().map(str::to_string).collect(),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Render a `class -> names` map as declared-list rows, ready to paste in.
|
|
fn as_rows(diff: &BTreeMap<String, BTreeSet<String>>) -> String {
|
|
let mut by_name: BTreeMap<&String, Vec<&String>> = BTreeMap::new();
|
|
for (class, names) in diff {
|
|
for n in names {
|
|
by_name.entry(n).or_default().push(class);
|
|
}
|
|
}
|
|
by_name
|
|
.iter()
|
|
.map(|(name, classes)| {
|
|
format!(
|
|
" (\"{name}\", \"{}\", \"WHY\"),",
|
|
classes
|
|
.iter()
|
|
.map(|c| c.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
/// Every widget method the 1.12 client registers on a class is answered by ours, or listed.
|
|
#[test]
|
|
fn every_1_12_widget_method_is_answered() {
|
|
let reference = reference_surface_by_class();
|
|
let ours = ours_by_class();
|
|
let listed = declared(NOT_YET_ANSWERED);
|
|
|
|
let mut missing: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
let mut total = 0usize;
|
|
for (class, want) in &reference {
|
|
let have = ours.get(*class).cloned().unwrap_or_default();
|
|
total += want.len();
|
|
let gap: BTreeSet<String> = want
|
|
.iter()
|
|
.filter(|n| !have.contains(*n))
|
|
.filter(|n| !listed.get(*n).is_some_and(|c| c.contains(*class)))
|
|
.cloned()
|
|
.collect();
|
|
if !gap.is_empty() {
|
|
missing.insert((*class).to_string(), gap);
|
|
}
|
|
}
|
|
assert!(
|
|
total > 2_000,
|
|
"only {total} class/method pairs on the reference side — the chain stopped resolving"
|
|
);
|
|
assert!(
|
|
missing.is_empty(),
|
|
"the 1.12 client registers these on a class benilla does not answer them on, and they are \
|
|
not listed:\n{}\n\nImplement it, or add the row to NOT_YET_ANSWERED with the reason it \
|
|
has not been built.",
|
|
as_rows(&missing)
|
|
);
|
|
|
|
// A listed name that is answered now fails, so the list cannot outlive its fix.
|
|
let mut healed: Vec<String> = Vec::new();
|
|
for (name, classes, _) in NOT_YET_ANSWERED {
|
|
for class in classes.split_whitespace() {
|
|
if ours.get(class).is_some_and(|h| h.contains(*name)) {
|
|
healed.push(format!("{class}:{name}"));
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
healed.is_empty(),
|
|
"these are answered now — take them out of NOT_YET_ANSWERED so the list keeps meaning what \
|
|
it says: {healed:?}"
|
|
);
|
|
}
|
|
|
|
/// Every widget method benilla answers is one 1.12 registers on that class, or a listed exception.
|
|
#[test]
|
|
fn our_widget_methods_stay_inside_the_1_12_surface() {
|
|
let reference = reference_surface_by_class();
|
|
let ours = ours_by_class();
|
|
let listed = declared(ALLOWED_BEYOND);
|
|
|
|
let mut beyond: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for (class, have) in &ours {
|
|
let Some(want) = reference.get(class.as_str()) else {
|
|
panic!("{class} is instantiable here but has no row in WIDGET_CHAINS");
|
|
};
|
|
let extra: BTreeSet<String> = have
|
|
.iter()
|
|
.filter(|n| !want.contains(*n))
|
|
.filter(|n| !listed.get(*n).is_some_and(|c| c.contains(class)))
|
|
.cloned()
|
|
.collect();
|
|
if !extra.is_empty() {
|
|
beyond.insert(class.clone(), extra);
|
|
}
|
|
}
|
|
assert!(
|
|
beyond.is_empty(),
|
|
"benilla answers these on a class the 1.12 client does not register them on:\n{}\n\n\
|
|
1.12 is the target. Either remove it, give it its 1.12 spelling, or add \
|
|
the row to ALLOWED_BEYOND WITH the reason it has to stay — an addon that \
|
|
feature-detects an unexplained superset takes a path we cannot \
|
|
honour.",
|
|
as_rows(&beyond)
|
|
);
|
|
|
|
let mut gone: Vec<String> = Vec::new();
|
|
for (name, classes, _) in ALLOWED_BEYOND {
|
|
for class in classes.split_whitespace() {
|
|
if !ours.get(class).is_some_and(|h| h.contains(*name)) {
|
|
gone.push(format!("{class}:{name}"));
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
gone.is_empty(),
|
|
"these are no longer exposed — take them out of ALLOWED_BEYOND: {gone:?}"
|
|
);
|
|
}
|