our API surface is diffed against the client's 19,572 globals

- `--surface` re-runs the comparison against the captured in-world global table on demand, and `--surface-dump` writes our raw side for any tool to join — a one-shot measurement stops drifting
- CreateFrame with an unresolvable template raises exactly as the client does, instead of handing back a bare frame; `inherits=` is one name, matched case-insensitively
- CreateFontString and CreateTexture honour their third argument — 49 corpus call sites were passing an inherit into the void
- the addon-message inbound byte map is corrected (a test had agreed with the wrong constants), and the group-loot roll template publishes under the reference's own name

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 19:08:30 +07:00
co-authored by Claude Fable 5
parent a6aecb98df
commit d7f4c59fa8
19 changed files with 795 additions and 121 deletions
+10 -4
View File
@@ -989,11 +989,17 @@ impl UiScript {
///
/// A pure query on the VM's live registry, for the corpus harness: an addon naming a template
/// we have not transcribed gets a bare frame and **no load error**, so nothing else can see it.
/// **Folded**, because the resolution it reports on is folded. An exact `contains_key` made this
/// census disagree with the loader the moment `inherits=` became case-insensitive: it went on
/// listing `UIDropdownMenuTemplate`, `CT_RaCheckButtonTemplate` and `MSBTColorSwatchTemplate` as
/// missing while the loader was resolving all three. An instrument that reports a gap the code
/// does not have is worse than no instrument — it is a build queue pointing at finished work
/// (1242/1246, and 1251 §3's rule that a source-derived answer be checked against the runtime
/// artefact).
pub fn has_framexml_template(&self, name: &str) -> bool {
self.model_ref()
.framexml_templates
.borrow()
.contains_key(name)
let model = self.model_ref();
let templates = model.framexml_templates.borrow();
templates.contains_key(name) || templates.keys().any(|k| k.eq_ignore_ascii_case(name))
}
/// Is `name` a registered FONT object — the *other* thing an `inherits=` may legally name?
+39 -2
View File
@@ -350,8 +350,12 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
/// `OnLoad` must be able to see the frame's real name, and `$parent` inside the template must
/// resolve against **the caller's** name, not the template's.
///
/// An unusable template (unknown, non-virtual, the wrong shape) is a warning on the model and a
/// perfectly usable frame — never an error that takes the frame with it.
/// An **unresolvable** template name raises `CreateFrame(): Couldn't find inherited node "%s"` and
/// creates nothing — the reference's own behaviour, byte-verified (`0x7061dd` → `luaL_error`, which
/// never returns). A template that resolves but is *unusable in some other way* — declared without
/// `virtual="true"`, or of a shape that does not fit the kind asked for — is still a warning plus a
/// working frame, because the registry lookup itself succeeded and that is the only thing the miss
/// branch tests.
fn create_frame(
lua: &Lua,
(kind, name, parent, inherits): (String, Option<Value>, Option<Value>, Option<Value>),
@@ -392,6 +396,39 @@ fn create_frame(
}
}
// ── THE TEMPLATE LOOKUP HAPPENS HERE — before anything is constructed or published ──────────
//
// `0x7061dd` calls the registry lookup `0x6ee6f0` and, on NULL, falls into
// `luaL_error(L, "CreateFrame(): Couldn't find inherited node \"%s\"")` at `0x7061ed`. That
// never returns — `luaG_errormsg`/`luaD_throw` contain no `ret` at all and end in `longjmp`, so
// the `xor eax,eax; ret` after the call is dead code. The frame is **not created**, no global
// is published, and the calling statement is abandoned; the error is ordinary Lua propagation
// and `pcall`-catchable, which is how the widget dispatcher (`0x704f10`, every handler under
// `lua_pcall`) turns it into one dead handler rather than a dead client.
//
// We used to warn and hand back a usable bare frame, and the doc above said the reference
// "creates the frame anyway". That claim is refuted at the bytes. It is the same class as 1249:
// a failure the client reports and we swallowed — and swallowing is the worse half, because the
// addon then runs on a frame with none of the art, regions or scripts it asked for and fails
// later, somewhere unrelated.
//
// Ordering is the carved part, not a detail: the miss branch precedes the synthetic-node build
// (`0x706208`) and the `name` store (`0x70622d`), so a miss leaves no partial widget and no
// orphan global behind. Doing this after construction would leave both.
if let Some(t) = template.as_deref().filter(|t| !t.is_empty()) {
let known = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let templates = model.framexml_templates.borrow();
// One name, verbatim, folded — the law `framexml::expand` resolves under.
templates.contains_key(t) || templates.keys().any(|k| k.eq_ignore_ascii_case(t))
};
if !known {
return Err(mlua::Error::runtime(format!(
"CreateFrame(): Couldn't find inherited node \"{t}\""
)));
}
}
// Create in the arena, mint the id, seed a default layout input. All under one write borrow.
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
@@ -134,11 +134,26 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// Regions
// `CreateTexture(name, layer, inheritsFrom)` — the third argument accepted for the same reason
// and with the same resolver, though the demand is a rounding error beside FontString's: of
// 1329 corpus call sites exactly TWO pass a third argument, and one of those passes `nil`
// (CustomNameplates:437, a four-argument later-client form). Accepted rather than skipped
// because dropping it silently is the very class this fixes, and one real site is still a site.
m.set(
"CreateTexture",
lua.create_function(
|lua, (this, name, layer): (Table, Option<String>, Option<String>)| {
create_region(lua, &this, RegionKind::Texture, name, layer)
|lua,
(this, name, layer, inherits): (
Table,
Option<String>,
Option<String>,
Option<String>,
)| {
let wrapper = create_region(lua, &this, RegionKind::Texture, name, layer)?;
if let Some(from) = inherits.as_deref().filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, from)?;
}
Ok(wrapper)
},
)?,
)?;
@@ -222,11 +237,36 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// The THIRD argument is `inheritsFrom`, and we were dropping it on the floor.
//
// 49 corpus call sites across 5 distinct addons pass one — AckisRecipeList (28),
// CustomNameplates (10), _LazyPig (6), LibAboutPanel (4), ColorPickerPlus (1) — and **every one
// of them names a FONT OBJECT**: GameFontNormalSmall (15), GameFontNormal (11),
// GameFontHighlightSmall (10), GameFontHighlight (9), GameFontNormalLarge (3), GameFontDisable.
// Five separate addons, so this is not one library file replicated (1207 checked, not assumed).
// Ignoring it is 1203's class exactly: the addon asks for a font, we accept the call, and the
// string comes out in whatever the default is with no failure anywhere.
//
// The resolution order is carved (`0x773c30`): the FONT-object registry FIRST (`0x773d39`,
// create=0), and only on a font miss the template registry (`0x773d47`). This routes through
// the `SetFontObject` binding rather than reaching into the model, which is the same call the
// XML `<FontString inherits=>` path makes — one implementation of "apply a font object", not
// two that can drift.
m.set(
"CreateFontString",
lua.create_function(
|lua, (this, name, layer): (Table, Option<String>, Option<String>)| {
create_region(lua, &this, RegionKind::FontString, name, layer)
|lua,
(this, name, layer, inherits): (
Table,
Option<String>,
Option<String>,
Option<String>,
)| {
let wrapper = create_region(lua, &this, RegionKind::FontString, name, layer)?;
if let Some(from) = inherits.as_deref().filter(|s| !s.is_empty()) {
apply_region_inherits(lua, &wrapper, from)?;
}
Ok(wrapper)
},
)?,
)?;
@@ -348,6 +388,44 @@ fn get_script(lua: &Lua, this: &Table, name: &str) -> mlua::Result<Value> {
}
}
/// `CreateTexture`/`CreateFontString`'s third argument — `inheritsFrom` — in the carved order.
///
/// **Font-object registry first, template registry second** (`0x773d39` then `0x773d47`). That
/// order is not cosmetic: `inherits=` is one attribute over two namespaces, and every corpus caller
/// of this argument names a font object, so a template-first resolver would miss all 49 of them.
///
/// A name in NEITHER registry raises, which is the same contract 1253 established for `CreateFrame`
/// and the same bytes (`luaL_error`, `0x87957c` / `0x879544`, which never returns).
///
/// A name that IS a registered template but not a font object is a deliberate gap rather than a
/// silent one: the lookup succeeded, so the reference would not raise, and re-interpreting a
/// template node onto an existing region is a different mechanism from applying a font object. It
/// warns and leaves the region usable — the distinction 1253 drew between *the lookup missing* and
/// *the result being unusable*. Zero corpus callers reach it.
fn apply_region_inherits(lua: &Lua, wrapper: &Table, from: &str) -> mlua::Result<()> {
use mlua::ObjectLike;
// The font object is applied through the binding the XML path uses, so there is one
// implementation of "apply a font object" rather than two that can drift apart.
if wrapper.call_method::<()>("SetFontObject", from).is_ok() {
return Ok(());
}
let is_template = {
let model = lua.app_data_ref::<Model>().expect("model");
let templates = model.framexml_templates.borrow();
templates.contains_key(from) || templates.keys().any(|k| k.eq_ignore_ascii_case(from))
};
if is_template {
let mut model = lua.app_data_mut::<Model>().expect("model");
model.warnings.push(format!(
"CreateTexture/CreateFontString: '{from}' is a registered TEMPLATE, not a font object; the region is created but the template's content is not applied (no corpus caller does this)"
));
return Ok(());
}
Err(mlua::Error::runtime(format!(
"Couldn't find inherited node \"{from}\""
)))
}
fn create_region(
lua: &Lua,
this: &Table,
@@ -146,11 +146,17 @@ fn a_nested_frames_child_is_named_against_the_caller() {
assert!(s.take_warnings().is_empty());
}
/// `inherits="A, B"` is a LIST, and the runtime path resolves it through the very same
/// [`crate::framexml::expand`] the XML path uses — including chains, splice order and the cycle
/// guard, none of which is written twice.
/// The template argument is **one name**, on this path exactly as on the XML one — and a chain
/// through a single name still resolves.
///
/// This asserted the opposite until the registry lookup was carved. `"TemplA, TemplB"` is not a
/// list, it is a literal name nothing declared: 1.12's `CreateFrame` reaches the same `0x6ee6f0`
/// the XML loader does (call site `0x7061dd`) with the string Lua handed it, and the loader has no
/// splitter anywhere — comma lists are a **later**-client feature. Corroborated by the corpus:
/// 6842 `inherits=` across 282 vanilla XML files contain **zero** comma lists, while the modern
/// Blizzard UI source is full of them.
#[test]
fn an_inherits_list_picks_up_every_template_in_it() {
fn a_comma_list_is_one_name_and_a_single_name_still_chains() {
let mut s = script();
register(
&s,
@@ -168,60 +174,116 @@ fn an_inherits_list_picks_up_every_template_in_it() {
</Frame>
</Ui>"#,
);
s.run(r#"Both = CreateFrame("Frame", "Both", nil, "TemplA, TemplB")"#)
.unwrap();
// From A (itself inheriting TemplRoot — the chain resolves).
// A single name still walks its whole chain. This is what keeps the narrowing honest: it
// removes the list, not the inheritance.
s.run(r#"One = CreateFrame("Frame", "One", nil, "TemplA")"#)
.unwrap();
assert_eq!(
s.eval::<(f32, f32)>("return Both:GetWidth(), Both:GetHeight()")
s.eval::<(f32, f32)>("return One:GetWidth(), One:GetHeight()")
.unwrap(),
(50.0, 60.0)
);
assert_eq!(s.eval::<f32>("return One:GetAlpha()").unwrap(), 0.25);
assert!(s.take_errors().is_empty());
assert!(s.take_warnings().is_empty());
// The comma form is one literal name, nothing declared it, so it RAISES — naming the whole
// unsplit string, which is the proof it was never split.
let err = s
.run(r#"Both = CreateFrame("Frame", "Both", nil, "TemplA, TemplB")"#)
.expect_err("a comma list is one name, and it misses");
let text = err.to_string();
assert!(
text.contains("TemplA, TemplB"),
"the miss must name the literal it looked up, unsplit: {text}"
);
assert!(
s.eval::<bool>(r#"return getglobal("Both") == nil"#)
.unwrap(),
"a missed template must leave no frame behind"
);
}
/// The case fold, with the four names the corpus actually mis-cases.
///
/// The compare is `SStrCmpI` → `_strnicmp`, and the trap that decides it is the bucket hash:
/// `SStrHash 0x64b3f0` uppercases before mixing, so a mis-cased name lands in the same bucket and
/// the stored-hash pre-check passes rather than short-circuiting. ASCII-only, like every other fold
/// in this engine.
#[test]
fn a_template_name_is_matched_case_insensitively() {
let mut s = script();
register(
&s,
r#"<Ui>
<Frame name="CT_RACheckButtonTemplate" virtual="true" alpha="0.25">
<Size><AbsDimension x="50" y="60"/></Size>
</Frame>
</Ui>"#,
);
// Exactly the shape `CT_RaidAssist/CT_RAOptions.xml` ships: `RA` written `Ra`.
s.run(r#"Cased = CreateFrame("Frame", "Cased", nil, "CT_RaCheckButtonTemplate")"#)
.unwrap();
assert_eq!(
s.eval::<(f32, f32)>("return Cased:GetWidth(), Cased:GetHeight()")
.unwrap(),
(50.0, 60.0)
);
assert_eq!(s.eval::<f32>("return Both:GetAlpha()").unwrap(), 0.25);
// From B.
assert!(s
.eval::<bool>(r#"return getglobal("BothBTex") ~= nil"#)
.unwrap());
assert!(s.take_errors().is_empty());
assert!(s.take_warnings().is_empty());
}
/// An unusable template name never takes the frame with it — the reference creates the frame
/// anyway, and an addon's next line already assumes it did. Both flavours of "unusable" are here:
/// a name nothing declared, and a name declared **without `virtual="true"`** (only virtual
/// elements are ever registered as templates, so from the registry's side the two look alike).
/// An unresolvable template name **raises, and creates nothing**.
///
/// This test asserted the exact opposite — *"the reference creates the frame anyway, and an addon's
/// next line already assumes it did"* — until the bytes were read. `0x7061dd` looks the name up and
/// on NULL falls into `luaL_error(L, "CreateFrame(): Couldn't find inherited node \"%s\"")`, which
/// **never returns**: `luaG_errormsg` and `luaD_throw` contain no `ret` between them and end in
/// `longjmp`, so the `xor eax,eax; ret` that follows the call is dead code. (That trailing `ret` is
/// a fact about `luaL_error`'s `int` return type in C, not about reachability — which is exactly
/// how the old claim was arrived at.)
///
/// Both flavours of "unusable" raise, because the registry only ever holds `virtual="true"`
/// elements, so a name declared without it misses the lookup identically to a name nothing declared.
///
/// The ordering is asserted too: the miss precedes construction and name publication (`0x706208` /
/// `0x70622d`), so nothing partial is left behind — no frame, and no global.
#[test]
fn an_unusable_template_still_returns_a_usable_frame_and_says_so() {
let mut s = script();
fn an_unresolvable_template_raises_and_creates_nothing() {
let s = script();
register(&s, r#"<Ui><Frame name="PlainFrame"/></Ui>"#);
for (frame, template) in [("Orphan", "NoSuchTemplate"), ("Plainer", "PlainFrame")] {
s.run(&format!(
r#"{frame} = CreateFrame("Button", "{frame}", nil, "{template}")
{frame}:SetWidth(5)
{frame}:Show()"#
))
.unwrap_or_else(|e| panic!("{frame} must be usable: {e}"));
assert_eq!(
s.eval::<String>(&format!("return {frame}:GetName()"))
.unwrap(),
frame
);
assert_eq!(
s.eval::<f32>(&format!("return {frame}:GetWidth()"))
.unwrap(),
5.0
let err = s
.run(&format!(
r#"{frame} = CreateFrame("Button", "{frame}", nil, "{template}")"#
))
.expect_err("an unresolvable template must raise");
let text = err.to_string();
assert!(
text.contains("Couldn't find inherited node") && text.contains(template),
"the raise must carry the reference's message and the name: {text}"
);
let warnings = s.take_warnings();
// Nothing partial: no global published, and the name never reached the arena.
assert!(
warnings
.iter()
.any(|w| w.contains(template) && w.contains(frame)),
"a warning must name both the template and the frame it was asked for: {warnings:?}"
s.eval::<bool>(&format!(r#"return getglobal("{frame}") == nil"#))
.unwrap(),
"{frame} was published despite the miss"
);
assert!(s.take_errors().is_empty(), "a bad template is not an error");
}
// And it is ordinary Lua propagation — `pcall` catches it, which is what keeps one bad
// CreateFrame inside a handler from taking the client down (the widget dispatcher runs every
// handler under `lua_pcall`).
assert!(
!s.eval::<bool>(
r#"return (pcall(CreateFrame, "Button", "Caught", nil, "NoSuchTemplate"))"#
)
.unwrap(),
"the raise must be pcall-catchable"
);
}
/// The two shape mismatches, neither of which may panic or drop the frame.
@@ -979,3 +979,53 @@ fn the_font_block_reaches_both_message_frame_tables() {
"creating the style region must not re-justify the frame to the FontString default"
);
}
/// `CreateFontString`'s **third argument** applies a font object — the argument we accepted and
/// then dropped on the floor (decision 1255).
///
/// 49 corpus call sites across 5 distinct addons pass one, and every one names a font object:
/// AckisRecipeList (28), CustomNameplates (10), _LazyPig (6), LibAboutPanel (4), ColorPickerPlus.
/// Five separate addons, so this is not one library file replicated. Ignoring it was 1203's class —
/// the addon asks for a font, the call succeeds, and the text comes out in the default with no
/// failure anywhere to point at.
///
/// The **order** is the part a reimplementation loses: the font-object registry is tried FIRST and
/// the template registry only on a font miss (`0x773d39` then `0x773d47`). A template-first
/// resolver would miss all 49 of these, because `inherits=` is one argument over two namespaces.
#[test]
fn create_font_string_applies_the_font_object_named_by_its_third_argument() {
let s = script();
load(
&s,
r#"<Ui>
<Font name="GameFontNormalSmall" font="Fonts\FRIZQT__.TTF">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1" g="0.82" b="0"/>
</Font>
<Frame name="Host"/>
</Ui>"#,
);
// Exactly `_LazyPig/LazyPigMenu.lua:88`'s line.
s.run(r#"FS = Host:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")"#)
.expect("the font-object form must be accepted");
let (face, height, _flags) = s
.eval::<(String, f32, String)>("return FS:GetFont()")
.expect("the region must carry the font object's face and height");
assert_eq!(face, "Fonts\\FRIZQT__.TTF");
assert_eq!(height, 12.0);
// A name in NEITHER registry raises — the same contract 1253 set for CreateFrame, and the same
// bytes (`luaL_error`, which never returns).
let err = s
.run(r#"Bad = Host:CreateFontString(nil, "ARTWORK", "NoSuchFontOrTemplate")"#)
.expect_err("a name in neither registry must raise");
assert!(
err.to_string().contains("NoSuchFontOrTemplate"),
"the raise must name what was looked up: {err}"
);
// Texture takes the same argument through the same resolver (1 real corpus site).
s.run(r#"TX = Host:CreateTexture(nil, "OVERLAY")"#)
.expect("the two-argument form still works");
}
+4 -1
View File
@@ -598,7 +598,10 @@ fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
.unwrap();
for name in [
// No keyboard index / `EnableKeyboard` yet (wow-re `scripts-auto-enable.md` kinds 0/1).
// The FLAG exists (`EnableKeyboard`/`IsKeyboardEnabled`, 1252); what is still absent is the
// keyboard INDEX and the strata walk that would deliver a key to a frame carrying it, so
// these three stay unfired (wow-re `scripts-auto-enable.md` kinds 0/1). Being enabled is
// not being a handler — the carve is explicit that the two are separable.
"OnKeyDown",
"OnKeyUp",
"OnChar",
+9 -1
View File
@@ -416,7 +416,15 @@ pub(crate) fn token_recognised(token: &str) -> bool {
token.eq_ignore_ascii_case("npc")
|| UNIT_TOKEN_PREFIXES
.iter()
.any(|p| token.len() >= p.len() && token[..p.len()].eq_ignore_ascii_case(p))
// BYTES, not a string slice. `token[..p.len()]` panics when the token is multibyte
// UTF-8 and the prefix length lands mid-character ("byte index N is not a char
// boundary") — an addon passing a non-ASCII token would take the client down. The
// client's own compare is `_strnicmp` over bytes with an ASCII-only fold, so byte
// comparison is both the safe form and the faithful one.
.any(|p| {
token.len() >= p.len()
&& token.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes())
})
}
/// The gate every `Unit*` binding puts an addon-supplied token through.
@@ -919,3 +919,42 @@ fn an_unrecognised_unit_token_raises_and_a_recognised_empty_one_does_not() {
assert!(s.run(r#"UnitIsUnit("bogus", "player")"#).is_err());
assert!(s.run(r#"UnitIsUnit("player", "party3")"#).is_ok());
}
/// **A multibyte unit token must not take the client down.**
///
/// The prefix test used to slice the token as a `&str` — `token[..p.len()]` — which PANICS when the
/// token is multibyte UTF-8 and the prefix length lands mid-character ("byte index N is not a char
/// boundary"). Any addon passing a non-ASCII token would have crashed the process rather than
/// getting the `Unknown unit name` raise the client gives.
///
/// The client compares with `_strnicmp` over BYTES and folds ASCII only, so byte comparison is both
/// the safe form and the faithful one — a token whose bytes differ above 0x7F is simply not one of
/// the nine prefixes.
#[test]
fn a_multibyte_unit_token_raises_rather_than_panicking() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
// Each of these has a multibyte character positioned so that a byte-index slice at one of the
// prefix lengths (3 for `pet`, 4 for `raid`, 5 for `party`, 6 for `player`/`target`) would land
// inside it.
for token in ["pé", "раid", "playér", "targét", "мышь", "日本語のトークン"] {
let err = s
.run(&format!(r#"UnitName("{token}")"#))
.expect_err("a non-ASCII token matches no prefix, so it raises")
.to_string();
assert!(
err.contains("Unknown unit name"),
"it must RAISE, not panic and not answer: {err}"
);
}
// …and an ASCII token that merely shares a prefix's leading bytes still behaves.
assert!(s
.eval::<Option<String>>(r#"return UnitName("playerfoo")"#)
.unwrap()
.is_none());
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Benilla"
);
}