//! **The 1.12 surface is the contract** (decisions 1188 §2, 1189) — enforced here rather than //! trusted to memory. //! //! benilla targets the 1.12.1 API. Anything beyond it is a *listed, justified* exception, and the //! point of this file is that the list cannot go unknown: every global our VM exposes must either //! be one 1.12 has, or appear below with a reason. A session that adds an Era-shaped verb has to //! delete or extend an assertion that says why not — which is exactly what did not happen when //! 1187 shipped eight Era globals on Era's authority and 1189 had to take them back out. //! //! **Why a superset is not free.** Lua branches on presence. An addon writing `if strmatch then` //! takes a path we cannot honour, and the failure surfaces far from the cause. Extra functions are //! harmless only if nothing feature-detects, which is not true of this ecosystem. //! //! The reference side is `reference/1.12-globals.tsv` — the running 1.12.1 client's own in-world //! `_G` (see `reference/README.md`). This test reads the `engine` and `lua` rows: the surface a VM //! is responsible for. It deliberately does **not** assert the converse — that we have everything //! 1.12 has — because that is a multi-year backlog, not a regression gate; //! `scripts/api-coverage.sh` is where that number is read. use std::collections::HashSet; use super::common::script; /// `reference/1.12-globals.tsv`, as `name -> origin`. fn reference() -> Vec<(String, String)> { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../reference/1.12-globals.tsv" ); let text = std::fs::read_to_string(path).unwrap_or_else(|e| { panic!("reading {path}: {e} — regenerate with scripts/gen-reference-globals.py") }); text.lines() .filter(|l| !l.starts_with('#')) .filter_map(|l| { let mut f = l.split('\t'); Some((f.next()?.to_string(), f.nth(1)?.to_string())) }) .collect() } /// Names benilla exposes that 1.12 does not, each with the reason it is allowed to stay. /// /// **`Benilla*` and `__benilla_*` are covered by prefix**, not enumerated: the first is our host /// bridge (verbs only our own transcribed FrameXML calls — a paperdoll model's facing, an item's /// stat block) and the second is the tick's pushed state. Neither is an API-target claim, and /// neither is reachable by accident from an addon that means to call a WoW function. fn allowed_beyond_1_12() -> HashSet<&'static str> { [ // ── our Lua runtime is 5.1 where 1.12's is 5.0 ──────────────────────────────────────── // `select` is load-bearing and stays on purpose — our transcribed FrameXML uses it in 16 // files as the 5.1 spelling of 5.0's implicit `arg` table. // // `_G` is the last one inherited rather than chosen: 1.12's base library does not export // it (an addon reaches the globals with `getfenv(0)`, which is what AceLibrary does), but // our own `getglobal`/`setglobal` are written over it. Closing it means rewriting those // against the registry first. // // **This list has been shrinking as the dialect got measured**: `coroutine` left in 1194 // with the 5.1-only members of `string`/`table`/`math`; `print` and `_VERSION` left in // 1197, when the RE dispatch read the base library's 36-entry array and neither was in it. // The list only ever covered globals — the members needed `dump_globals --members` before // anyone could see them at all. "_G", "select", // ── WoW API past 1.12 — 1188 phase 5's list, and the reason that phase exists ───────── // Every one of these predates 1189 and is used by our own transcribed FrameXML today. // Resolving each means either replacing it with its 1.12 equivalent (`UnitPower` → // `UnitMana`, which 1.12 has and we do not) or recording why it stays. "CancelUnitBuff", "GetCursorInfo", "GetGossipQuestInfo", "GetInventoryItemID", "GetNumGossipQuests", "GetPlayerFacing", "GetTradePartnerName", "IsGossipOptionCoded", "SelectGossipQuest", "SubmitChatInput", "UnitAura", "UnitIsAFK", "UnitIsDND", "strconcat", "strjoin", "strsplit", "strtrim", "tostringall", "wipe", ] .into_iter() .collect() } /// Every global benilla's VM exposes is one 1.12 has, or a listed exception. /// /// The failure message is the point: it names what is new and tells you the two ways out, so the /// next session cannot resolve it by deleting a bare assertion. #[test] fn our_globals_stay_inside_the_1_12_surface() { let reference = reference(); let known: HashSet<&str> = reference.iter().map(|(n, _)| n.as_str()).collect(); assert!( known.len() > 19_000, "the reference table looks truncated ({} names) — regenerate it", known.len() ); let allowed = allowed_beyond_1_12(); let ours: Vec = script() .eval( "local out = {} \ for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \ return out", ) .expect("dump _G"); let mut unlisted: Vec<&str> = ours .iter() .map(String::as_str) .filter(|n| !known.contains(n)) .filter(|n| !allowed.contains(n)) .filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_")) .collect(); unlisted.sort_unstable(); assert!( unlisted.is_empty(), "benilla exposes {} global(s) the 1.12.1 client does not, and they are not listed as \ exceptions:\n {}\n\n\ 1.12 is the target (decision 1188). Either give it its 1.12 spelling, or add it to \ `allowed_beyond_1_12` in this file WITH the reason it has to stay — an unexplained \ superset is what 1189 had to roll back.", unlisted.len(), unlisted.join(" ") ); } /// The exception list is exact — no entry outlives the global it excuses. /// /// Without this, a removed global leaves its excuse behind, and the list slowly stops describing /// the code. That is how a "listed, justified exception" decays into residue nobody can explain. #[test] fn the_exception_list_has_no_dead_entries() { let known: HashSet = reference().into_iter().map(|(n, _)| n).collect(); let ours: HashSet = script() .eval::>( "local out = {} \ for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \ return out", ) .expect("dump _G") .into_iter() .collect(); let mut dead: Vec<&str> = allowed_beyond_1_12() .into_iter() .filter(|n| !ours.contains(*n)) .collect(); dead.sort_unstable(); assert!( dead.is_empty(), "these are excused as beyond-1.12 but benilla no longer exposes them — drop them from \ `allowed_beyond_1_12`:\n {}", dead.join(" ") ); // An entry that 1.12 turns out to have is a different bug: the excuse is wrong, not stale. let mut wrong: Vec<&str> = allowed_beyond_1_12() .into_iter() .filter(|n| known.contains(*n)) .collect(); wrong.sort_unstable(); assert!( wrong.is_empty(), "these are excused as beyond-1.12 but the 1.12.1 client DOES have them — they need no \ excuse:\n {}", wrong.join(" ") ); } /// **`Texture:GetTexture()` — the three contract details that a plausible implementation gets /// silently wrong.** Verified in wow-re's widget-method batch (`0x79ba70`/`0x79baf0`/`0x835708`, /// `system/ui/scratch/widget-api-batch-benilla.md`). /// /// Four corpus addons reach it: `AtlasQuest.lua:228` is `AQATLASMAP = AtlasMap:GetTexture()` and /// `FuBarPlugin-2.0.lua:343` is `return self.iconFrame:GetTexture()`, each behind two addons. /// /// The colour case is the one worth a test of its own. `SetTexture(r,g,b)` synthesizes an 8x8 solid /// and the getter reports the literal name `"Solid Texture"` — so an addon's `if not tex then` /// guard passes straight through. Returning nil there would read as tidier and would be wrong in /// exactly the direction callers test for. #[test] fn get_texture_returns_the_stripped_path_and_solid_texture_for_a_fill() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" f = CreateFrame("Frame", "GTF") tex = f:CreateTexture("GTFTex", "ARTWORK") fill = f:CreateTexture("GTFFill", "ARTWORK") "#, ) .unwrap(); // Never set: nil, and exactly one value. assert_eq!( s.eval::>("return GTFTex:GetTexture()") .unwrap(), None ); assert_eq!( s.eval::("local a,b = GTFTex:GetTexture() return select('#', GTFTex:GetTexture())") .unwrap_or(1), 1, "exactly one return value" ); // A path with no extension comes back unchanged. s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Spell_Fire_FlameBolt")"#) .unwrap(); assert_eq!( s.eval::("return GTFTex:GetTexture()").unwrap(), "Interface\\Icons\\Spell_Fire_FlameBolt" ); // ...and one WITH an extension is stripped at the last `.` — the loader appends it, the getter // takes it back off. s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Foo.blp")"#) .unwrap(); assert_eq!( s.eval::("return GTFTex:GetTexture()").unwrap(), "Interface\\Icons\\Foo" ); // The colour form: the literal name, NOT nil. s.run("GTFFill:SetTexture(1, 0, 0, 1)").unwrap(); assert_eq!( s.eval::("return GTFFill:GetTexture()").unwrap(), "Solid Texture", "a colour-filled region reports a NAME — `if not tex then` must not fire" ); // And a plain SetTexture makes it ordinary again. s.run(r#"GTFFill:SetTexture("Interface\\Buttons\\UI-Quickslot2")"#) .unwrap(); assert_eq!( s.eval::("return GTFFill:GetTexture()").unwrap(), "Interface\\Buttons\\UI-Quickslot2" ); } /// **The shadow accessors on a REGION, and `GetShadowColor`'s four values.** /// /// They existed on the font-object table; a FontString from `CreateFontString` had none, which is /// exactly where the corpus calls them. `FuBar_NavigatorFu/NavigatorFu.lua:31` is /// `coordText:SetShadowColor(GameFontNormal:GetShadowColor())` — getter on a font object, setter on /// a fresh region, in one line — and `KLHThreatMeter/.../KTM_Gui.lua:404` is /// `fontstring:SetShadowColor(0,0,0,0.3)`. /// /// **Four values, not three** (`0x79dd2f`, `mov eax,0x4` — wow-re's widget-method batch). Three is /// the plausible wrong answer and it silently drops the alpha NavigatorFu round-trips: the whole /// point of its line is that whatever `GameFontNormal` carries arrives intact. #[test] fn region_shadow_accessors_round_trip_four_values() { let s = crate::script::UiScript::new().unwrap(); s.run(r#"f = CreateFrame("Frame", "SHF") fs = f:CreateFontString("SHFText", "ARTWORK")"#) .unwrap(); assert_eq!( s.eval::("return select('#', SHFText:GetShadowColor())") .unwrap(), 4, "GetShadowColor returns FOUR values — three drops the alpha" ); assert_eq!( s.eval::("return select('#', SHFText:GetShadowOffset())") .unwrap(), 2 ); // NavigatorFu's line, in shape: a font object's shadow piped straight into a region's. s.run("SHFText:SetShadowColor(0, 0, 0, 0.3) SHFText:SetShadowOffset(1, -1)") .unwrap(); let (r, g, b, a) = s .eval::<(f32, f32, f32, f32)>("return SHFText:GetShadowColor()") .unwrap(); assert_eq!((r, g, b, a), (0.0, 0.0, 0.0, 0.3), "the alpha survives"); assert_eq!( s.eval::<(f32, f32)>("return SHFText:GetShadowOffset()") .unwrap(), (1.0, -1.0) ); // Either half may be set first: setting only the offset must not blank the colour. s.run("SHFText:SetShadowOffset(2, -2)").unwrap(); assert_eq!( s.eval::("local _,_,_,a = SHFText:GetShadowColor() return a") .unwrap(), 0.3, "setting the offset alone keeps the colour" ); } /// **`Region:SetParent(frame)` — it exists on a Texture/FontString, and we were the ones missing /// it.** `SetParent` is in the Region method table (`0x7a1550`) and both leaf lookups fall back to /// Region's (`0x79c650` / `0x79ee50`), so `FuBar_FuXPFu.lua:210`'s /// `self.Spark:SetParent(self.XPBar)` — a texture from `XPBar:CreateTexture` — is a working line on /// the real client (wow-re `system/ui/scratch/widget-api-batch-benilla.md` Q7). /// /// The two traps pinned here are the ones a plausible implementation gets wrong in opposite /// directions. **A non-Frame argument RAISES** (`IsA(FrameTag)` at `0x7a16ea`, message /// `"…Wrong parent object type, expected frame"`) rather than no-opping; and **anchors are NOT /// touched** by the re-link, which moves draw-layer/region-list membership only — so a re-parented /// texture keeps resolving against whatever `SetPoint` named, and FuXPFu's re-point on the next /// line is the correct pattern rather than a workaround. #[test] fn region_set_parent_relinks_the_draw_owner_and_leaves_anchors_alone() { /// The resolved rect of the one texture quad whose path contains `needle`, if it draws at all. fn tex_rect(s: &crate::script::UiScript, needle: &str) -> Option { s.extract().iter().find_map(|q| match &q.content { crate::script::QuadContent::Texture { path: Some(p), .. } if p.contains(needle) => { Some(q.rect.expect("resolved rect")) } _ => None, }) } let mut s = crate::script::UiScript::new().unwrap(); s.set_screen_size(800.0, 600.0); s.run( r#" A = CreateFrame("Frame", "SPOwnerA") A:SetPoint("BOTTOMLEFT", 0, 0) A:SetSize(100, 50) B = CreateFrame("Frame", "SPOwnerB") B:SetPoint("BOTTOMLEFT", 300, 0) B:SetSize(100, 50) Spark = A:CreateTexture("SPSpark", "ARTWORK") Spark:SetTexture("Interface\\SPGlow") Spark:SetSize(24, 24) Spark:SetPoint("TOPLEFT", 4, -4) "#, ) .unwrap(); s.resolve(); // A is [bottom 0, left 0, top 50, right 100]: TOPLEFT +(4,-4), 24x24 ⇒ 4..28 x 22..46. let before = tex_rect(&s, "SPGlow").expect("the spark draws under A"); assert_eq!(before, crate::layout::Rect::new(22.0, 4.0, 46.0, 28.0)); // FuXPFu's line, and it returns nothing at all. assert_eq!( s.eval::("return select('#', Spark:SetParent(B))") .unwrap(), 0, "SetParent returns zero values on every path" ); s.resolve(); assert_eq!( tex_rect(&s, "SPGlow"), Some(before), "the anchors are untouched — the spark still resolves against A, 300px from B" ); // …and the re-link is real, not merely recorded: the spark now draws with B. Hiding B takes it // off the screen; hiding A (which it is still anchored to) does not. s.run("B:Hide()").unwrap(); assert_eq!( tex_rect(&s, "SPGlow"), None, "the spark hides with its NEW owner" ); s.run("B:Show() A:Hide()").unwrap(); s.resolve(); assert_eq!( tex_rect(&s, "SPGlow"), Some(before), "and not with the frame it is merely anchored to" ); // A non-Frame argument raises. A texture is the case the reference names, and it is the one an // addon actually hits (a mixed table of frames and textures walked in a loop). for bad in ["Spark:SetParent(Spark)", "Spark:SetParent(5)"] { let e = s .run(bad) .expect_err("a non-frame parent must raise") .to_string(); assert!( e.contains("expected frame"), "wanted the reference's 'expected frame' rejection, got: {e}" ); } // A MISSING argument is not the nil form: TNONE never reaches the nil branch. assert!(s.run("Spark:SetParent()").is_err(), "no argument raises"); // The nil form detaches without error: orphaned and unrendered, not destroyed — a later // re-parent brings the same region back. s.run("A:Show() Spark:SetParent(nil)").unwrap(); s.resolve(); assert_eq!( tex_rect(&s, "SPGlow"), None, "a detached region draws nothing" ); s.run("Spark:SetParent(A)").unwrap(); s.resolve(); assert_eq!( tex_rect(&s, "SPGlow"), Some(before), "and re-parenting restores it" ); } /// **`Button:SetFont(file, height [, flags])` — it exists, it returns NOTHING, and it never /// touches the label.** `0x780880`, wow-re's widget-method batch Q8 (§5-verified). /// `_LazyPig/LazyPigMenu.lua:214` calls it straight on a `CreateFrame("Button", …)` and is blocked /// on it today. /// /// Three traps, each pinned here because each is a plausible implementation's silent divergence. /// **Zero return values**: the shared impl pushes `1`/nil (a real font-load probe on a Font object) /// and Button *discards* it (`xor eax,eax`), so answering `true` would hand an addon a probe the /// client does not have. **`GetFont` returns three values**, off the normal embedded font — which /// resolves through what that state inherits when nothing set it locally. And **a bare Button with /// no `` is a silent no-op**: `SetFont`/`GetFont` never dereference the FontString /// pointer `+0x338`, so there is no error and — the observable half — **no lazy label creation**, /// which `GetFontString()` would report. #[test] fn button_set_font_returns_nothing_and_is_a_no_op_without_a_label() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" Bare = CreateFrame("Button", "SFBare") Labelled = CreateFrame("Button", "SFLabelled") Labelled:SetText("Show Keybinds") "#, ) .unwrap(); // _LazyPig's line, on a Button that has no label at all. assert_eq!( s.eval::(r#"return select('#', SFBare:SetFont("Fonts\\FRIZQT__.TTF", 8))"#) .unwrap(), 0, "SetFont returns ZERO values — the delegate's 1/nil is discarded by a Button" ); assert!( s.eval::("return SFBare:GetFontString() == nil") .unwrap(), "no label existed and none was lazily created" ); assert_eq!( s.eval::("return select('#', SFBare:GetFont())") .unwrap(), 3, "GetFont returns THREE values" ); assert_eq!( s.eval::<(String, f32, String)>("return SFBare:GetFont()") .unwrap(), ("Fonts\\FRIZQT__.TTF".to_string(), 8.0, String::new()) ); // Still three when nothing has ever set a font — path and height are nil, not absent. assert_eq!( s.eval::("return select('#', SFLabelled:GetFont())") .unwrap(), 3 ); assert!(s .eval::("local f = SFLabelled:GetFont() return f == nil") .unwrap()); // The flags argument is normalized to its OUTLINETYPE token, like every other GetFont. s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF", 12, "THICKOUTLINE")"#) .unwrap(); assert_eq!( s.eval::("local _, _, flags = SFBare:GetFont() return flags") .unwrap(), "THICKOUTLINE" ); // Both arguments are required (`lua_isstring` + `lua_isnumber`, else the usage error). assert!(s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF")"#).is_err()); // Unset locally, GetFont reads through what the NORMAL state inherits — how a // GameMenuButtonTemplate button answers its template's face before anything calls SetFont. s.run( r#" CreateFont("SFInherited") SFInherited:SetFont("Fonts\\MORPHEUS.TTF", 14) SFLabelled:SetTextFontObject(SFInherited) "#, ) .unwrap(); assert_eq!( s.eval::<(String, f32)>("local p, h = SFLabelled:GetFont() return p, h") .unwrap(), ("Fonts\\MORPHEUS.TTF".to_string(), 14.0) ); // …and a local SetFont outranks that inherited object on the label's PAINT, not only in the // getter — the whole point of the call `_LazyPig` makes. s.run(r#"SFLabelled:SetFont("Fonts\\SKURRI.TTF", 9)"#) .unwrap(); let (font, height) = s .extract() .iter() .find_map(|q| match &q.content { crate::script::QuadContent::Text { text: Some(t), font, font_height, .. } if t == "Show Keybinds" => Some((font.clone(), *font_height)), _ => None, }) .expect("the labelled button draws its text"); assert_eq!( (font.as_deref(), height), (Some("Fonts\\SKURRI.TTF"), Some(9.0)), "the button's own font repaints the label over the inherited font object" ); } /// **`SetNonSpaceWrap` / `CanNonSpaceWrap`** — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's /// widget-method batch). `oRA2/Leader/Item.lua:561` is `f.textname:SetNonSpaceWrap(false)`, reached /// by two addons. /// /// Two contract details, both easy to get wrong and both pinned here: the getter is /// **`CanNonSpaceWrap`**, not `GetNonSpaceWrap`, and it answers **`1` or nil** rather than a /// boolean — 1.12 predates that convention and an addon may compare against 1. And a **no-argument /// call ENABLES** it; the default is on, so it is not a query. #[test] fn non_space_wrap_defaults_on_and_answers_one_or_nil() { let s = crate::script::UiScript::new().unwrap(); s.run(r#"f = CreateFrame("Frame", "NSF") fs = f:CreateFontString("NSFText", "ARTWORK")"#) .unwrap(); // Default is ON, and the answer is the NUMBER 1 — not `true`. assert_eq!( s.eval::>("return NSFText:CanNonSpaceWrap()") .unwrap(), Some(1), "default on, answered as 1" ); assert!( !s.eval::("return NSFText:CanNonSpaceWrap() == true") .unwrap(), "it must NOT be a boolean — an addon comparing against 1 would break" ); // oRA2's line. s.run("NSFText:SetNonSpaceWrap(false)").unwrap(); assert_eq!( s.eval::>("return NSFText:CanNonSpaceWrap()") .unwrap(), None, "off answers nil, not 0 and not false" ); // A no-argument call ENABLES rather than querying. s.run("NSFText:SetNonSpaceWrap()").unwrap(); assert_eq!( s.eval::>("return NSFText:CanNonSpaceWrap()") .unwrap(), Some(1), "a bare SetNonSpaceWrap() turns it ON" ); } /// **The EditBox font block — the first sixteen entries of its own registrar table, and the largest /// single gap the per-kind widget-method census found** (decision 1229, whose ranking opens /// `63 EditBox:SetFontObject (on Texture, FontString)`). /// /// `EditBox`'s table is `.data 0x87bb68`, **48 entries** — the count read from the `mov edx,0x30` at /// the registering site `0x799ab5`, never from a run-length scan. There is no `FontInstance` class /// in the 1.12 Lua chain (wow-re `widget-api-batch-benilla.md`): each of the six text-bearing types /// re-declares the block in its own flat table, so membership is a per-table fact and **a name we /// add that the table does not carry is exactly as wrong as one we miss**. /// /// The two halves pinned here are therefore both directions. Present: the fourteen we wire. /// Absent: `SetSpacing`/`GetSpacing`, which the table *does* carry (#10–#11) and we deliberately /// withhold because nothing here models line spacing — a stored-but-undrawn setter is the silent /// divergence of 1203/1205/1211, while a nil-value call names itself, and corpus demand is zero. /// Absent too: the names that belong to *other* tables and must not leak onto an EditBox through /// ours — `CopyFontObject` (Font object only), `SetNonSpaceWrap`/`CanNonSpaceWrap` and /// `SetTextHeight`/`GetStringWidth` (FontString only). #[test] fn editbox_carries_the_font_block_its_registrar_table_declares() { let s = crate::script::UiScript::new().unwrap(); s.run(r#"EB = CreateFrame("EditBox", "EBFont")"#).unwrap(); for name in [ "SetFontObject", "GetFontObject", "SetFont", "GetFont", "SetTextColor", "GetTextColor", "SetShadowColor", "GetShadowColor", "SetShadowOffset", "GetShadowOffset", "SetJustifyH", "GetJustifyH", "SetJustifyV", "GetJustifyV", ] { assert_eq!( s.eval::(&format!("return type(EBFont.{name})")) .unwrap(), "function", "EditBox table entry '{name}' is missing" ); } for name in [ // On EditBox's real table, withheld on purpose (nothing models spacing). "SetSpacing", "GetSpacing", // Not on EditBox's table at all — adding one is the same class of error as missing one. "CopyFontObject", "SetNonSpaceWrap", "CanNonSpaceWrap", "SetTextHeight", "GetStringWidth", ] { assert!( s.eval::(&format!("return EBFont.{name} == nil")) .unwrap(), "'{name}' must NOT answer on an EditBox" ); } } /// **Every EditBox font binding is a type-guard shim that returns whatever the shared implementation /// returned**, so the arities below are the shared ones verbatim — verified at the bytes: each /// binding ends `call ` then `pop edi; pop esi; pop ebx; ret`, with **no `mov eax,N` and no /// `xor eax,eax`** after the call. /// /// The trap is `SetFont`, and it points the opposite way to the one already pinned for Button. /// `Button:SetFont` (`0x780880`) ends `xor eax,eax` and returns **nothing** — /// [`button_set_font_returns_nothing_and_is_a_no_op_without_a_label`]. `EditBox:SetFont` /// (`0x797210` → `0x79f210`) does not discard, so the shared `lua_pushnumber(1.0)` (`0x79f345`) / /// `lua_pushnil` (`0x79f361`) passes straight through: **one value, the number `1`, not `true` and /// not zero values**. A probe written `if not eb:SetFont(f, s) then` needs the nil half; a probe /// written `== 1` needs the number half. /// /// `GetShadowColor` returns **four** values (`0x79f9b3 mov eax,0x4`); three is the plausible wrong /// answer and silently drops the alpha. #[test] fn editbox_font_block_return_shapes_are_the_shared_implementations() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" EB = CreateFrame("EditBox", "EBShape") CreateFont("EBProbeFont") EBProbeFont:SetFont("Fonts\\FRIZQT__.TTF", 14) "#, ) .unwrap(); // The mutators push nothing at all. for call in [ "SetFontObject(EBProbeFont)", "SetTextColor(1, 0, 0)", "SetShadowColor(0, 0, 0, 0.3)", "SetShadowOffset(1, -1)", r#"SetJustifyH("CENTER")"#, r#"SetJustifyV("TOP")"#, ] { assert_eq!( s.eval::(&format!("return select('#', EBShape:{call})")) .unwrap(), 0, "EditBox:{call} must return ZERO values" ); } // The getters' exact widths. for (call, want) in [ ("GetFontObject()", 1), ("GetFont()", 3), ("GetTextColor()", 4), ("GetShadowColor()", 4), ("GetShadowOffset()", 2), ("GetJustifyH()", 1), ("GetJustifyV()", 1), ] { assert_eq!( s.eval::(&format!("return select('#', EBShape:{call})")) .unwrap(), want, "EditBox:{call} must return {want} value(s)" ); } // SetFont: ONE value, and it is the number 1 — not `true`, and not nothing (the Button shape). assert_eq!( s.eval::(r#"return select('#', EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#) .unwrap(), 1, "EditBox:SetFont returns one value, unlike Button:SetFont" ); assert_eq!( s.eval::(r#"return type(EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#) .unwrap(), "number", "the number 1, never the boolean true" ); assert!(s .eval::(r#"return EBShape:SetFont("Fonts\\SKURRI.TTF", 12) == 1"#) .unwrap()); // An unloadable (empty) path is the FALSEY answer, not an error — `!OmniCC`'s probe idiom. assert!(s .eval::(r#"return EBShape:SetFont("", 12) == nil"#) .unwrap()); // Both arguments are gated (`lua_isstring` + `lua_isnumber`, else the usage error). assert!(s.run(r#"EBShape:SetFont("Fonts\\SKURRI.TTF")"#).is_err()); // The shadow round-trips all four channels — three would drop the alpha. assert_eq!( s.eval::<(f32, f32, f32, f32)>("return EBShape:GetShadowColor()") .unwrap(), (0.0, 0.0, 0.0, 0.3) ); assert_eq!( s.eval::<(f32, f32)>("return EBShape:GetShadowOffset()") .unwrap(), (1.0, -1.0) ); // GetFontObject answers the OBJECT, never its name — the corpus indexes the result immediately. assert_eq!( s.eval::("return type(EBShape:GetFontObject())") .unwrap(), "table" ); } /// **The real `Dewdrop-2.0` shape, which is the whole reason this landed.** /// /// `Dewdrop-2.0.lua:1673-1675` is verbatim: /// ```lua /// local editBox = CreateFrame("EditBox", nil, editBoxFrame) /// editBoxFrame.editBox = editBox /// editBox:SetFontObject(ChatFontNormal) /// ``` /// — an **anonymous** EditBox parented to a frame, then `SetFontObject` on it two lines later. That /// file is vendored into **63 addon folders** of the 218-addon corpus (65 copies: `FuBar` plus ~50 /// FuBar plugins, `BigWigs`, `AtlasLoot`, `oRA2`, `SnaFu`, …), so the census's `63` is **one library /// replicated**, not 63 independent addons (decision 1207). /// /// `AceGUIWidget-Slider.lua:204-210` is the same shape with `SetJustifyH("CENTER")` on the end. #[test] fn the_dewdrop_editbox_font_shape_resolves_and_paints() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" CreateFont("ChatFontNormal") ChatFontNormal:SetFont("Fonts\\ARIALN.TTF", 11) ChatFontNormal:SetTextColor(0.9, 0.9, 0.9) editBoxFrame = CreateFrame("Frame", "DewdropEBFrame") local editBox = CreateFrame("EditBox", nil, editBoxFrame) editBoxFrame.editBox = editBox editBox:SetFontObject(ChatFontNormal) editBox:SetWidth(160) editBox:SetHeight(13) "#, ) .unwrap(); // The link is live and readable back as the object. assert!(s .eval::("return DewdropEBFrame.editBox:GetFontObject() == ChatFontNormal") .unwrap()); // …and the object's paint really reached the box's implicit FontString (the `[this+0x324]` the // reference's shim hands the shared implementation). assert_eq!( s.eval::<(String, f32)>("local p, h = DewdropEBFrame.editBox:GetFont() return p, h") .unwrap(), ("Fonts\\ARIALN.TTF".to_string(), 11.0) ); let (r, g, b, _) = s .eval::<(f32, f32, f32, f32)>("return DewdropEBFrame.editBox:GetTextColor()") .unwrap(); assert_eq!((r, g, b), (0.9, 0.9, 0.9)); // The AceGUI slider's tail, on the same box. s.run(r#"DewdropEBFrame.editBox:SetJustifyH("CENTER")"#) .unwrap(); } /// **`SetJustifyH`/`SetJustifyV` mask to their own axis, and a token from the other axis silently /// clears rather than erroring.** The enum is `.rdata 0x811ad0`, `{bits, token}`: `LEFT` 0x01, /// `CENTER` 0x02, `RIGHT` 0x04, `TOP` 0x08, `MIDDLE` 0x10, `BOTTOM` 0x20, stored in one dword with /// bits 0–2 horizontal and 3–5 vertical. /// /// Two traps, both of which our older FontString/Font copies of these verbs get wrong by coercing /// anything unrecognised to CENTER/MIDDLE: /// · **an unknown token RAISES** `Usage: %s:SetJustifyH("justify")` (`0x87c77c`); /// · **a valid token from the wrong axis parses and then masks to nothing** — `SetJustifyH("TOP")` /// yields 0x08 and `0x08 & 0x07 == 0`, so justifyH is CLEARED and `GetJustifyH()` answers the /// literal `"UNKNOWN"` (`0x6f1a00`, `.data 0x838044`). No error is raised either way. #[test] fn editbox_justify_masks_to_its_axis_and_answers_unknown() { let s = crate::script::UiScript::new().unwrap(); s.run(r#"EB = CreateFrame("EditBox", "EBJustify")"#) .unwrap(); // **LEFT, not the generic font default CENTER.** The `CSimpleFont` ctor really does default to // `0x212` (CENTER | MIDDLE | 0x200), but the EditBox ctor overrides the horizontal axis right // after linking its font instance (`0x779bcd … and eax,~6; or eax,1;` stored at `0x779be4`), so // a fresh box starts at `0x211`. Taking the generic default is the plausible wrong answer, and // it is exactly what this test asserted until wow-re's §5 trio read that ctor // (`system/ui/scratch/editbox-font-surface.md` §6.2). assert_eq!( s.eval::("return EBJustify:GetJustifyH()").unwrap(), "LEFT" ); assert_eq!( s.eval::("return EBJustify:GetJustifyV()").unwrap(), "MIDDLE" ); // Each axis is replaced independently — setting V leaves H alone. s.run(r#"EBJustify:SetJustifyH("LEFT") EBJustify:SetJustifyV("BOTTOM")"#) .unwrap(); assert_eq!( s.eval::<(String, String)>("return EBJustify:GetJustifyH(), EBJustify:GetJustifyV()") .unwrap(), ("LEFT".to_string(), "BOTTOM".to_string()) ); // The cross-axis trap: "TOP" parses, masks to 0, and CLEARS justifyH. s.run(r#"EBJustify:SetJustifyH("TOP")"#).unwrap(); assert_eq!( s.eval::("return EBJustify:GetJustifyH()").unwrap(), "UNKNOWN", "a vertical token on SetJustifyH clears the axis rather than centering it" ); assert_eq!( s.eval::("return EBJustify:GetJustifyV()").unwrap(), "BOTTOM", "and it leaves the vertical axis untouched" ); // An unrecognised token is the usage error, never a silent fallback to CENTER. assert!(s.run(r#"EBJustify:SetJustifyH("SIDEWAYS")"#).is_err()); } /// **`EditBox:SetJustifyV` echoes through its getter and never reaches the pixels — on the real /// client too, permanently and by construction.** wow-re `editbox-font-surface.md` §6 (§5 trio + /// byte arbitration): `CSimpleFontString+0x124` is a per-bit *inherit* mask over the rendered /// justify `+0x120`, and `SetMultiLine 0x77a4a0` clears the whole vertical group `0x38` from it on /// **both** legs while writing the V bits locally — multi-line → TOP, single-line → MIDDLE. The /// EditBox ctor calls `SetMultiLine` unconditionally at birth (`0x779c2f`, with the ctor's zero /// register), and a census of all 256 `+0x124` operands image-wide found every `CSimpleFontString` /// writer to be an AND: **nothing ever ORs an inherit bit back in.** So the value written by /// `SetJustifyV` is masked out at `0x77086e`, while `GetJustifyV` reads the font *instance* /// (`0x79fd73`) and answers it faithfully. /// /// This pins the shape rather than the accident: a future change that "fixes" the getter by wiring /// V justify through to the region would match neither the client nor our own draw law. #[test] fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() { let mut s = crate::script::UiScript::new().unwrap(); s.set_screen_size(800.0, 600.0); s.run( r#" EB = CreateFrame("EditBox", "EBJV") EBJV:SetPoint("CENTER", 0, 0) EBJV:SetWidth(120) EBJV:SetHeight(40) EBJV:SetText("typed") "#, ) .unwrap(); /// The vertical justification the box's text actually draws with. fn drawn_v(s: &crate::script::UiScript) -> crate::script::JustifyV { s.extract() .iter() .find_map(|q| match &q.content { crate::script::QuadContent::Text { text: Some(t), justify_v, .. } if t == "typed" => Some(*justify_v), _ => None, }) .expect("the box draws its text") } // A single-line box renders MIDDLE, the `0x77a599` leg. assert_eq!(drawn_v(&s), crate::script::JustifyV::Middle); // SetJustifyV("TOP") is echoed by the getter… s.run(r#"EBJV:SetJustifyV("TOP")"#).unwrap(); assert_eq!( s.eval::("return EBJV:GetJustifyV()").unwrap(), "TOP", "the getter reads the font instance and echoes faithfully" ); // …and does NOT move the pixels. This is the disagreement, and it is faithful. assert_eq!( drawn_v(&s), crate::script::JustifyV::Middle, "SetJustifyV is masked out of the rendered justify — multiLine alone decides it" ); // multiLine is what actually moves it, on the `0x77a509` leg. s.run("EBJV:SetMultiLine(true)").unwrap(); assert_eq!(drawn_v(&s), crate::script::JustifyV::Top); // …and the getter still echoes whatever was last set, unaffected. assert_eq!( s.eval::("return EBJV:GetJustifyV()").unwrap(), "TOP" ); } /// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.** /// /// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is /// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5 /// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the /// verb reporting "no" is a real machine's answer, not a stub. /// /// We have no desaturating shader, so nil is the honest reply: claiming support would suppress /// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong. /// The test pins the return shape rather than the state, because the return is what callers branch /// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes /// `not shaderSupported`. /// /// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via /// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded. #[test] fn set_desaturated_reports_no_shader_support_and_does_not_raise() { let s = crate::script::UiScript::new().unwrap(); s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#) .unwrap(); // Dewdrop's exact call — the one 98 addons reach. It must not raise. s.run("DsTex:SetDesaturated(true)").unwrap(); // ...and it answers nil, so the reference's `not shaderSupported` branch is taken. assert!( s.eval::("return DsTex:SetDesaturated(true) == nil") .unwrap(), "shaderSupported must be nil (1|nil C shape), not false" ); assert_eq!( s.eval::("return select('#', DsTex:SetDesaturated(true))") .unwrap(), 1, "one return value" ); // The reference's own consumer, transcribed: a falsy answer greys via vertex colour. s.run( r#" local shaderSupported = DsTex:SetDesaturated(true) local r, g, b = 1, 1, 1 if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end DsTex:SetVertexColor(r, g, b) "#, ) .unwrap(); let (r, g, b) = s .eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()") .unwrap(); assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys"); // Both spellings of "off" are off — nil and false. s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)") .unwrap(); } /// `UnitCreatureType(unit)` — `0x51a280`'s three-stage resolver, of which we model stages 2 and 3. /// /// The load-bearing assertion is the **player** one. `0x605570` falls through the (never populated) /// creature record to a `ChrRaces.dbc` col-9 lookup that is **7 for all nine shipped races**, and /// `CreatureType[7]` is `"Humanoid"` — so a player answers a word, not nil. Reading only the /// creature record, which is what our data alone suggested, would have been wrong for every /// `UnitCreatureType("player")` call in the corpus. #[test] fn unit_creature_type_answers_the_record_then_falls_back_to_humanoid() { let mut s = script(); s.set_unit( "target", Some(crate::script::UnitState { exists: true, creature_type_name: Some("Beast".into()), ..Default::default() }), ); s.set_unit( "player", Some(crate::script::UnitState { exists: true, is_player: true, ..Default::default() }), ); // Stage 2: a creature's cached record wins. assert_eq!( s.eval::(r#"return UnitCreatureType("target")"#) .unwrap(), "Beast" ); // Stage 3: a player has no record and falls through to race -> Humanoid. assert_eq!( s.eval::(r#"return UnitCreatureType("player")"#) .unwrap(), "Humanoid" ); // An unresolved TOKEN is nil — not a raise. assert!(s .eval::(r#"return UnitCreatureType("party4") == nil"#) .unwrap()); // A non-string ARGUMENT is a raise, which is a different failure from the nil above: // `lua_isstring` gates it and `luaL_error` longjmps, so a missing arg abandons the statement. let err = s .run("UnitCreatureType()") .expect_err("a missing arg must raise"); assert!( format!("{err}").contains("Usage: UnitCreatureType"), "got {err}" ); } /// `GetInventorySlotInfo(slotName)` — `0x4c81b0`, three returns and a **case-insensitive**, /// full-string name match. /// /// The case-insensitivity is the whole point: two 1.12-era corpus addons died at session start on /// case variants of real names — `FuBar_AmmoFu` passes `"ammoSlot"`, `FuBar_PoisonFu` /// `"MAINHANDSLOT"` — and both worked on the real client, because `0x4c8215` reaches the CRT /// `_strnicmp`, which folds both operands. /// /// The other two assertions are things a plausible implementation gets wrong and nothing notices: /// the third return is the **number 1**, only for `RangedSlot`, never a boolean; and a miss /// **raises** with the reference's own message, which carries no `Usage:` prefix and does not /// interpolate the offending name. #[test] fn get_inventory_slot_info_folds_case_and_flags_only_the_ranged_slot() { let s = script(); // The exact spelling, and the two case variants the corpus actually ships. for name in ["AmmoSlot", "ammoSlot", "AMMOSLOT"] { assert_eq!( s.eval::(&format!("return GetInventorySlotInfo('{name}')")) .unwrap(), 0, "{name} must fold to AmmoSlot" ); } assert_eq!( s.eval::("return GetInventorySlotInfo('MAINHANDSLOT')") .unwrap(), 16 ); // Three values, and the second is the empty-slot background art the paper-doll buttons use. assert_eq!( s.eval::("return select('#', GetInventorySlotInfo('HeadSlot'))") .unwrap(), 3 ); let (id, art) = s .eval::<(i64, String)>("return GetInventorySlotInfo('HeadSlot')") .unwrap(); assert_eq!(id, 1); // The DBC string verbatim: LOWERCASE directory and the `.blp` extension. The binding pushes // `[esi+4]` with no normalisation, so a caller that keys a table by this sees these bytes. assert_eq!(art, "interface\\paperdoll\\UI-PaperDoll-Slot-Head.blp"); // checkRelic: the NUMBER 1 for the ranged slot alone, nil everywhere else — not `false`, which // is falsey like nil but the wrong type for a caller that compares it against 1. assert!(s .eval::("local _,_,r = GetInventorySlotInfo('RangedSlot') return r == 1") .unwrap()); assert!(s .eval::("local _,_,r = GetInventorySlotInfo('HeadSlot') return r == nil") .unwrap()); // The twelve rows this table was short of — `Bag1`..`Bag12` at SlotNumbers 64..75, of which // 64..69 is the bank-bag band. They share ONE string offset with Bag0Slot..Bag3Slot, so all // sixteen bag rows answer the same art. assert_eq!( s.eval::("return GetInventorySlotInfo('Bag1')") .unwrap(), 64 ); assert_eq!( s.eval::("return GetInventorySlotInfo('bag12')") .unwrap(), 75, "the new rows fold case like every other" ); assert_eq!( s.eval::("local _,a = GetInventorySlotInfo('Bag6') return a") .unwrap(), s.eval::("local _,a = GetInventorySlotInfo('Bag0Slot') return a") .unwrap(), "all sixteen bag rows share one string-block offset" ); // ...and `Bag1` (64) is NOT `Bag1Slot` (21): different names, different ids, both real rows. assert_eq!( s.eval::("return GetInventorySlotInfo('Bag1Slot')") .unwrap(), 21 ); // A miss raises — there is no nil path — with the reference's own string. let err = s .run("GetInventorySlotInfo('NoSuchSlot')") .expect_err("an unknown slot name must raise"); let err = format!("{err}"); assert!( err.contains("Invalid inventory slot in GetInventorySlotInfo"), "got {err}" ); assert!( !err.contains("NoSuchSlot"), "the reference does not interpolate the offending name: {err}" ); } /// **The whole Region method map, on both leaves that chain to it.** /// /// `SetParent` above landed as one name because one addon line named it. That is how this table has /// always grown, and it is why `GetParent` — its own getter — was still absent while `SetParent` /// worked. wow-re carves the map as a SET, not as names: FontString's lookup `0x79ee20` chains its /// own map `0xcf5400` to the Region map `0xcf54b4`, whose 19 entries are /// /// ```text /// GetObjectType IsObjectType GetName GetParent SetParent GetCenter GetLeft GetRight GetTop /// GetBottom GetWidth SetWidth GetHeight SetHeight GetNumPoints GetPoint SetPoint SetAllPoints /// ClearAllPoints /// ``` /// /// (`system/ui/scratch/font-object-lua-surface.md` — the same note whose point is that a `` /// object does NOT chain and so has none of these. Texture reaches the identical map through its /// own leaf lookup, which is why both are asserted here.) /// /// So this asserts membership rather than behaviour: each name is *present and callable* on a /// Texture and on a FontString. Behaviour belongs in the focused tests around it — what is pinned /// here is that the set cannot quietly lose a member again, which is the failure `GetParent` was. #[test] fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() { /// All 19. `GetObjectType`/`IsObjectType` were held out of this list when 1244 landed the other /// four — dispatched rather than guessed — and joined it when wow-re answered /// (`system/ui/scratch/widget-type-identity.md`). The list is the whole map again. // The one list, shared with the title region's narrower table (`script::REGION_MAP_METHODS`) // so the two can never disagree about what "the Region map" is. const REGION_MAP: [&str; 19] = crate::script::REGION_MAP_METHODS; let mut s = crate::script::UiScript::new().unwrap(); s.set_screen_size(800.0, 600.0); s.run( r#" RMOwner = CreateFrame("Frame", "RMOwner") RMOwner:SetPoint("BOTTOMLEFT", 0, 0) RMOwner:SetSize(100, 50) RMTex = RMOwner:CreateTexture("RMTex", "ARTWORK") RMStr = RMOwner:CreateFontString("RMStr", "ARTWORK") "#, ) .unwrap(); let mut absent: Vec = Vec::new(); for kind in ["RMTex", "RMStr"] { for name in REGION_MAP { if !s .eval::(&format!("return type({kind}.{name}) == 'function'")) .unwrap_or(false) { absent.push(format!("{kind}:{name}")); } } } assert!( absent.is_empty(), "the Region map 0xcf54b4 is missing from our regions: {absent:?}" ); } /// **The four Region-map readers, on the cases a frame-shaped copy gets wrong.** /// /// `TheoryCraftUI.lua:720` is the line that found them: `buttontext:GetParent():GetID()`, where /// `buttontext` is a FontString — a working line on the real client, and `attempt to call method /// 'GetParent' (a nil value)` here, every session. #[test] fn the_region_map_readers_answer_the_way_the_edges_do() { let mut s = crate::script::UiScript::new().unwrap(); s.set_screen_size(800.0, 600.0); s.run( r#" RRFrame = CreateFrame("Frame", "RRFrame") RRFrame:SetPoint("BOTTOMLEFT", 100, 200) RRFrame:SetSize(200, 100) RRPlate = RRFrame:CreateTexture("RRPlate", "ARTWORK") RRPlate:SetPoint("TOPLEFT", 10, -10) RRPlate:SetSize(50, 20) -- the sibling-region anchor the real XML uses everywhere RRLabel = RRFrame:CreateFontString("RRLabel", "OVERLAY") RRLabel:SetPoint("LEFT", RRPlate, "RIGHT", 4, 0) "#, ) .unwrap(); s.resolve(); // GetParent is the OWNER frame — the identity TheoryCraft then calls :GetID() on. assert!( s.eval::("return RRPlate:GetParent() == RRFrame") .unwrap(), "a region's parent is the frame that created it" ); assert!( s.eval::("return RRLabel:GetParent():GetName() == 'RRFrame'") .unwrap(), "…and it is a real frame handle, not a bare id" ); // GetCenter agrees with the edge readers BY CONSTRUCTION — the invariant that forbids scaling // one and not the others. let (cx, cy): (f64, f64) = s.eval("return RRPlate:GetCenter()").unwrap(); let (l, r, t, b): (f64, f64, f64, f64) = s .eval("return RRPlate:GetLeft(), RRPlate:GetRight(), RRPlate:GetTop(), RRPlate:GetBottom()") .unwrap(); assert_eq!((cx, cy), ((l + r) * 0.5, (t + b) * 0.5)); // GetNumPoints counts what SetPoint wrote, and ClearAllPoints takes it back to 0. assert_eq!(s.eval::("return RRPlate:GetNumPoints()").unwrap(), 1); assert_eq!( s.eval::("return RRLabel:GetNumPoints()").unwrap(), 1, "the sibling-anchored label carries its one point" ); // GetPoint's relativeTo must come back as the SIBLING REGION, not a frame wrapper onto the same // id — the one place regions genuinely differ from frames, since both share an id space. let (p, rp, x, y): (String, String, f64, f64) = s .eval("local p, _, rp, x, y = RRLabel:GetPoint(1) return p, rp, x, y") .unwrap(); assert_eq!((p.as_str(), rp.as_str(), x, y), ("LEFT", "RIGHT", 4.0, 0.0)); assert!( s.eval::("local _, rel = RRLabel:GetPoint(1) return rel == RRPlate") .unwrap(), "relativeTo is the sibling REGION handle itself" ); // Out of range is five nils, like the frame twin. assert_eq!( s.eval::("return select('#', RRPlate:GetPoint(7))") .unwrap(), 5, "an out-of-range index still answers five values, all nil" ); assert!( s.eval::("return RRPlate:GetPoint(7) == nil").unwrap(), "…and the first of them is nil" ); } /// **`GetObjectType`/`IsObjectType` — every detail wow-re had to answer, asserted.** /// /// 1244 shipped four Region-map members and left these two out rather than guess them, because /// each of the four traps below is a coin-flip a reimplementation loses (1203/1205/1211 are three /// records of losing it). The carve is `system/ui/scratch/widget-type-identity.md`; this is that /// answer turned into a gate, so the next person to touch these has to disagree with the binary /// rather than with me. #[test] fn the_type_identity_verbs_answer_what_the_binary_answers() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" TIOwner = CreateFrame("Frame", "TIOwner") TITex = TIOwner:CreateTexture("TITex", "ARTWORK") TIStr = TIOwner:CreateFontString("TIStr", "ARTWORK") TIAnon = TIOwner:CreateTexture(nil, "ARTWORK") "#, ) .unwrap(); // The leaf names, one value each (`lua_pushstring`, no arity check on extra args). assert_eq!( s.eval::("return TITex:GetObjectType()").unwrap(), "Texture" ); assert_eq!( s.eval::("return TIStr:GetObjectType()").unwrap(), "FontString" ); assert_eq!( s.eval::("return select('#', TITex:GetObjectType('ignored'))") .unwrap(), 1, "one value, and a stray argument is ignored rather than an arity error" ); // The chain is TWO deep and stops there. for (obj, leaf) in [("TITex", "Texture"), ("TIStr", "FontString")] { assert_eq!( s.eval::(&format!("return {obj}:IsObjectType('{leaf}')")) .unwrap(), 1, "{obj} is its own leaf type" ); assert_eq!( s.eval::(&format!("return {obj}:IsObjectType('Region')")) .unwrap(), 1, "{obj} is a Region" ); // **The invented root.** 1.12.1 has no LayoutFrame/ScriptObject/Object type at all — those // strings live only in __FILE__ paths and allocator tags. Knowing later clients is exactly // what would put them here. for absent in ["LayoutFrame", "ScriptObject", "Object", "Frame", "Font"] { assert!( s.eval::>(&format!("return {obj}:IsObjectType('{absent}')")) .unwrap() .is_none(), "{obj}:IsObjectType('{absent}') must be nil — 1.12 has no such type in the chain" ); } } // …and the two leaves are not each other. assert!(s .eval::>("return TITex:IsObjectType('FontString')") .unwrap() .is_none()); // Case-INSENSITIVE and WHOLE-string (SStrCmpI folds both operands; the compare stops at the // first NUL, so no prefix or substring match either). for spelling in ["texture", "TEXTURE", "TeXtUrE", "region", "REGION"] { assert_eq!( s.eval::(&format!("return TITex:IsObjectType('{spelling}')")) .unwrap(), 1, "'{spelling}' must match — the compare folds case" ); } for partial in ["Tex", "TextureX", "Regio", ""] { assert!( s.eval::>(&format!("return TITex:IsObjectType('{partial}')")) .unwrap() .is_none(), "'{partial}' must NOT match — whole-string, not a prefix" ); } // A hit is the NUMBER 1, never a boolean; a miss is nil. Both paths push exactly one value. assert_eq!( s.eval::("return type(TITex:IsObjectType('Texture'))") .unwrap(), "number", "the reference pushes tag 3 (number); tag 1 (boolean) is never written" ); for arg in ["'Texture'", "'nope'"] { assert_eq!( s.eval::(&format!("return select('#', TITex:IsObjectType({arg}))")) .unwrap(), 1, "exactly one value on both the hit and the miss path" ); } // A NUMBER is accepted (lua_isstring takes tags 4 and 3) and stringified in place — it simply // never matches, because no type name is numeric. assert!( s.eval::>("return TITex:IsObjectType(5)") .unwrap() .is_none(), "a number argument is accepted and quietly answers nil, NOT a raise" ); // Everything else RAISES with the reference's own Usage text, naming the region — or // `` for one declared anonymously. for bad in ["", "nil", "true", "{}", "print"] { let err = s .run(&format!("TITex:IsObjectType({bad})")) .expect_err(&format!("IsObjectType({bad}) must raise")) .to_string(); assert!( err.contains(r#"Usage: TITex:IsObjectType("TYPE")"#), "the raise carries the reference's Usage text and the region's name: {err}" ); } let anon = s .run("TIAnon:IsObjectType(nil)") .expect_err("anonymous region raises too") .to_string(); assert!( anon.contains(r#"Usage: :IsObjectType("TYPE")"#), "an anonymous region reports , as GetName()'s absence does: {anon}" ); } /// **The frame side of the type identity, and the three chains a guess gets wrong.** /// /// `_Nameplates.lua` is why this half exists: it asks `Region:GetObjectType()` (the region twin) /// AND `Nameplate:GetObjectType() ~= "Button"` / `Frame:GetObjectType() == "StatusBar"` in the same /// file. The chains come from wow-re's 23-class roster, which is a hardcoded straight-line list per /// class in the binary rather than a runtime parent walk. #[test] fn a_frames_type_chain_matches_the_roster() { let s = crate::script::UiScript::new().unwrap(); // (CreateFrame kind, GetObjectType string, full chain) let cases: &[(&str, &str, &[&str])] = &[ ("Frame", "Frame", &["Frame", "Region"]), ("Button", "Button", &["Button", "Frame", "Region"]), // Depth 4 — the longest chain we can build. ( "CheckButton", "CheckButton", &["CheckButton", "Button", "Frame", "Region"], ), ("EditBox", "EditBox", &["EditBox", "Frame", "Region"]), ("StatusBar", "StatusBar", &["StatusBar", "Frame", "Region"]), ("Slider", "Slider", &["Slider", "Frame", "Region"]), ( "ScrollFrame", "ScrollFrame", &["ScrollFrame", "Frame", "Region"], ), ( "MessageFrame", "MessageFrame", &["MessageFrame", "Frame", "Region"], ), // **NOT via MessageFrame**, despite the name (roster `0x787940`). ( "ScrollingMessageFrame", "ScrollingMessageFrame", &["ScrollingMessageFrame", "Frame", "Region"], ), // **Capital HTML** — the enum variant is `SimpleHtml`, and `GetObjectType` is compared with // `==` by addons, so a variant-derived string would be silently wrong here. ( "SimpleHTML", "SimpleHTML", &["SimpleHTML", "Frame", "Region"], ), ( "ColorSelect", "ColorSelect", &["ColorSelect", "Frame", "Region"], ), ( "GameTooltip", "GameTooltip", &["GameTooltip", "Frame", "Region"], ), ("Minimap", "Minimap", &["Minimap", "Frame", "Region"]), // **Our Era-shaped divergence reports what 1.12's cooldown IS**: a Model. 1.12.1 has no // `Cooldown` type name at all (the roster is 23 and none is that). ("Cooldown", "Model", &["Model", "Frame", "Region"]), ]; for (kind, leaf, chain) in cases { s.run(&format!(r#"TC = CreateFrame("{kind}")"#)) .unwrap_or_else(|e| panic!("CreateFrame(\"{kind}\"): {e}")); assert_eq!( &s.eval::("return TC:GetObjectType()").unwrap(), leaf, "{kind}:GetObjectType()" ); for want in *chain { assert_eq!( s.eval::(&format!("return TC:IsObjectType('{want}')")) .unwrap(), 1, "{kind} must be a {want}" ); } // Every OTHER leaf in the roster must be absent from this chain — the assertion that // catches a chain built by "sounds related". for (other, other_leaf, _) in cases { if chain.contains(other_leaf) { continue; } assert!( s.eval::>(&format!("return TC:IsObjectType('{other_leaf}')")) .unwrap() .is_none(), "{kind}'s chain must NOT contain {other_leaf} (via {other})" ); } } // The frame verbs share the region twin's contract: number 1 / nil, one value, and a raise // naming the frame. s.run(r#"TCNamed = CreateFrame("Button", "TCNamed")"#) .unwrap(); assert_eq!( s.eval::("return type(TCNamed:IsObjectType('button'))") .unwrap(), "number", "case-folded hit is the number 1" ); let err = s .run("TCNamed:IsObjectType({})") .expect_err("a table argument raises") .to_string(); assert!( err.contains(r#"Usage: TCNamed:IsObjectType("TYPE")"#), "the frame's Usage text names the frame: {err}" ); } /// **Every region method belongs to a leaf — no name may be installed and invisible to both.** /// /// The split (`script::region`) copies names out of the full table into a Texture leaf and a /// FontString leaf. A name in neither list is still installed, still costs a closure, and is /// reachable from NOTHING — the silent half of a wrong partition. /// /// This is not hypothetical: the partition was first built from a grep, and that grep missed /// `SetGradient`/`SetGradientAlpha` because they are installed from a LOOP rather than a literal /// `m.set("…")`. One test caught one of them; this gate catches the whole class, and it reads the /// table the VM actually holds rather than the source that builds it. #[test] fn every_installed_region_method_lands_in_a_leaf() { use crate::script::{ FONTSTRING_ONLY_METHODS, REGION_LEAF_SHARED, REGION_MAP_METHODS, TEXTURE_ONLY_METHODS, }; let s = crate::script::UiScript::new().unwrap(); let full: mlua::Table = s .lua() .named_registry_value(crate::script::REG_REGION_METHODS_FOR_TEST) .expect("the full region method table"); let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new(); known.extend(REGION_MAP_METHODS); known.extend(REGION_LEAF_SHARED); known.extend(TEXTURE_ONLY_METHODS); known.extend(FONTSTRING_ONLY_METHODS); let mut orphans: Vec = Vec::new(); for pair in full.pairs::() { let (name, _) = pair.expect("region method entry"); if !known.contains(name.as_str()) { orphans.push(name); } } orphans.sort(); assert!( orphans.is_empty(), "installed on the region table but in NO leaf list, so no region can call them: {orphans:?}" ); }