//! The 1.12 surface as a gate: every global benilla's VM exposes is one the 1.12.1 client has //! (`reference/1.12-globals.tsv`) or a listed exception, since addons branch on a global's //! presence; what 1.12 has and benilla lacks is `scripts/api-coverage.sh`'s count. 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() } /// Globals benilla exposes that 1.12 does not. `Benilla*` (the host bridge our own UI files call) /// and `__benilla_*` (the tick's pushed state) pass by prefix. fn allowed_beyond_1_12() -> HashSet<&'static str> { [ // ── our Lua runtime is 5.1 where 1.12's is 5.0 ── // 1.12's base library does not export `_G` (an addon reaches the globals with // `getfenv(0)`); ours does, as our `getglobal`/`setglobal` are written over it. "_G", // ── 2.0+ names a MODIFIED 1.12 UI calls ── // 1.12 has none of these (absent from the reference table and from the stock chain), but // TurtleWoW's `patch-4.mpq` ships 2.0-era `UIParent.lua` and `FriendsFrame.lua` whose // `UIFrameFade_CreateInfo` calls `wipe` and whose guild-roster filter calls `trim`; both // raise and take the UI down. `strsplit`/`strjoin` ride along because the modified FrameXML // and the addon corpus behind it reach for the same string family. `trim` is TurtleWoW's own // name for `strtrim` semantics — 1.12 has `strtrim` neither. "wipe", "strsplit", "strjoin", "trim", // TurtleWoW's `Globals.lua:111` table-entry count, which its patched FrameXML calls for // every list length (Turtle_TransmogUI's available-items/outfits, the guild bank, arena // rows, binding search). `sizeof` is not 1.12's name and the exception is its `Globals.lua` // ship — same reason the four above. "sizeof", // TurtleWoW's addon-message split and delimiters: its `HonorFrame.lua:46` and // `WorldStateFrame.lua:233-335` parse the `TW_HONOR`/`TW_BG` addon payloads with them. "explode", "ADDON_MSG_FIELD_DELIMITER", "ADDON_MSG_ARRAY_DELIMITER", "ADDON_MSG_SUBFIELD_DELIMITER", // TurtleWoW's native arena-score request: a no-op here (its reply is parsed by TurtleWoW's // client, and benilla has no arena scoreboard), so its WorldStateFrame.lua:217 cannot raise. "RequestArenaScoreInfo", // ── WoW API past 1.12 ── // Only `SubmitChatInput` is called from our UI files (`ScriptLogFrame.xml`'s slash // commands); each of the rest awaits its 1.12 equivalent, removal or a reason. "CancelUnitBuff", "GetCursorInfo", "GetInventoryItemID", "GetPlayerFacing", "GetTradePartnerName", "IsGossipOptionCoded", "SubmitChatInput", "UnitAura", "UnitIsAFK", "UnitIsDND", // ── addon compat: vanilla-private-server client extensions (docs/ADDON_COMPAT.md) ── // SuperWoW's own detection globals (script/compat/superwow.rs): this install has no // WoW.exe to inject into, so SuperWoW's Lua-facing contract is reimplemented here // natively and always on; addons that feature-detect it off these two need to find them. "SUPERWOW_STRING", "SUPERWOW_VERSION", // SuperWoW's GetPlayerBuffID (script/aura/player_buff.rs): not a 1.12 verb, the aura's // spell id at a `GetPlayerBuff` cache position. "GetPlayerBuffID", // ClassicAPI's UnitGUID/UnitTokenFromGUID (script/compat/classic_api.rs): later-client // additions 1.12 has neither of; an addon checks `if UnitGUID then` directly, no version // flag needed or set. "UnitGUID", "UnitTokenFromGUID", // nampower v3's spelling of UnitGUID (script/compat/nampower.rs), over the *same* body so // the two verbs cannot disagree about one fact. SuperCleveRoidMacros calls it by this name // on its macro-resolution path ("via Nampower GetUnitGUID (v3.0+)", // `Utility.lua:78-83`), which was 11 raises in one session. "GetUnitGUID", // nampower v3+'s window onto the raw `UNIT_FIELD_AURA` block // (script/compat/nampower.rs). Not a 1.12 verb and not expressible with one: every 1.12 // aura getter walks the COMPACTED list, so its indices shift whenever an earlier aura // falls off. These slots do not, which is the whole point — pfUI's libdebuff is built on // it ("GetUnitField Edition") and falls back to manual shifting without it, and DoiteAuras // raises outright. "GetUnitField", // ClassicAPI's right-click unit dropdown BY UNIT (compat/classic_api/unit_menu.rs). 1.12 // has the menu but no way to ask for one by unit: every stock frame carries its own // dropdown with a hardcoded `which`. pfUI's unit frames route `SetAttribute("type2", // "menu")` here and call it directly on the Clique path, so without it right-clicking any // of them reached a nil and did nothing. "ClassicAPI_ToggleUnitMenu", // Its dropdown frame is deliberately NOT listed: it is created on first right-click, not // at install, so it is not a live global here — and the exception list refuses dead // entries, which is the gate doing its job. // nampower v3+'s `SpellRec` column reader (script/compat/nampower.rs). Partial: this // client reaches a spell's name, rank and icon path and no other column of that row, so // the rest answer nil rather than a plausible zero. DoiteAuras guards each non-name read. "GetSpellRecField", // nampower v4's spellbook index helpers (script/compat/nampower.rs), all four of them the // fast paths DoiteAuras takes before falling back to a 200-slot `GetSpellName` scan per // lookup — it does one of those per tracked aura per condition evaluation. // // None is a 1.12 verb: that client can walk a book by index and read a name, but it has no // id for what it finds and no way back from a name to either. `GetSpellInfo` is the modern // client's answer and ClassicAPI backports it; these are nampower's, and the two differ in // what they are keyed by, so an addon written against one does not reach the other. "GetSpellNameAndRankForId", "GetSpellIdForName", "GetSpellSlotTypeIdForName", // 1.12 draws a passive spell's name grayed, so it knows the bit; it just never exposes a // test for it. Indexed like `GetSpellName`, not by id, which is the pair DoiteAuras has // in hand where it asks. "IsPassiveSpell", // nampower v2.38's `SpellDuration.dbc` reader and v2.30's raw-slot aura query // (script/compat/nampower.rs). 1.12 exposes a *running* aura's remaining time // (`GetPlayerBuffTimeLeft`) and nothing else: no way to ask a spell how long its aura // lasts before casting it, and no way to read a slot the compacted buff list has skipped. // Both are what an aura tracker needs to draw a bar before the first application. "GetSpellDuration", "GetPlayerAuraDuration", // nampower v2.12's live spell gates and v2.29's ammo query (script/compat/nampower.rs). // 1.12 answers all three only *through an action bar slot* (`IsUsableAction`, // `IsActionInRange`): a spell the player knows but has not placed on a bar cannot be // asked about at all, and ammo has no getter beyond the doll slot's item. These are // keyed by spell id over the player's own book, which is what a condition engine has in // hand. `IsSpellUsable`'s absence is why DoiteAuras' `_SafeSpellUsable` answered a flat // "usable" and every aura gated on one showed always. "IsSpellUsable", "IsSpellInRange", "GetAmmo", // nampower's item->Use:-spell lookup. 1.12 can only reach it by building a tooltip and // reading its lines back, which is what every addon without this did. "GetItemSpell", // nampower's equipment and item-cache getters (v2.14+/v2.18+), the five // BetterCharacterPanel is built on: `GetEquippedItem` was 8 raises in one session of his // log, and the item-level column, the quality colours and the missing-enchant warning were // all dead behind the addon's own `pcall`s. 1.12 reaches none of this — its item cache has // no binding at all, and `GetItemInfo`'s fourth value is the *required* level, not the item // level (later clients are the ones that insert `itemLevel` there). Field names, the 1..19 // slot band and the `Item not found in DBCache` miss are read off the installed // `nampower.dll`'s own string pool, not inferred. "GetEquippedItem", "GetEquippedItems", "GetItemLevel", "GetItemStats", "GetItemStatsField", // nampower's `SpellIcon.dbc` reader, the resolver half of `GetSpellRecField(id, // "spellIconID")`. 1.12 reaches an icon only through a spellbook slot, so a spell the // player does not know has no texture an addon can draw. "GetSpellIconTexture", // ClassicAPI's `GetSpellInfo` as a bare global (script/compat/classic_api/spell.rs), the // positional sibling of the table-returning `C_Spell.GetSpellInfo` — upstream lists "both // forms". Ten returns, the WotLK signature plus the appended spellID. // SuperCleveRoidMacros walks its whole spellbook with it to build a slot → spellID map // (`Extensions/Tooltip/Generic.lua:50`), which was 4 raises in one session. "GetSpellInfo", // ClassicAPI's GetPlayerGuid/IsPlayerGuid (script/compat/classic_api/unit.rs): two lines // of ClassicAPI's own embedded Lua (`strings ClassicAPI.dll` quotes both bodies), not 1.12 // verbs and not under a namespace. Built because every nampower aura-event handler in this // install calls one of them unguarded — pfUI's libdebuff on its caster line, its // innervatecall module on its first — so the events cannot be fired without them. "GetPlayerGuid", "IsPlayerGuid", // ClassicAPI's token-half unit getters and minion/feign predicates // (script/compat/classic_api/unit_extra.rs). `UnitClassBase`/`UnitRaceBase` answer the // second value stock `UnitClass`/`UnitRace` already return, split out as their own verb by // later clients; pfUI calls `UnitClassBase` 33 times at file scope, so its absence stops // the addon loading. `UnitSubName`, `UnitIsMinion` and `UnitIsFeignDeath` read // `UnitState`'s subtitle, owner and dynamic-flags fields — all data this client already // feeds, none of them a 1.12 verb. "UnitClassBase", "UnitRaceBase", "UnitSubName", "UnitIsMinion", "UnitIsFeignDeath", // ClassicAPI's UnitCreatureTypeID (same module): the creature type as a number, where // 1.12's `UnitCreatureType` answers only the localized word. pfUI's nameplate module calls // it per plate per frame with a GUID and retries on nil, so its absence was 1,014 raises in // one session — each aborting that plate's styling partway. "UnitCreatureTypeID", // ClassicAPI's Item/ItemMixin (script/compat/classic_api/item_mixin.rs), the sibling of // ItemLocation: modern WoW's deferred item-data object. 1.12 has no such wrapper — items // are read by id through bare verbs. Built because it is load-blocking: pfQuest's // `database.lua` calls `Item:CreateFromItemID` at main-chunk level (`:2241`), so a nil // `Item` aborted its `` and showed up as that addon's one load error. "Item", "ItemMixin", // The poll bound `ContinueOnItemLoad` reads; a global only so the number can live beside // its reasoning in Rust rather than as a literal in the chunk. "__benilla_item_poll_ticks", // ClassicAPI's UnitPower/UnitPowerMax (same module): the later-client spelling of // `UnitMana`/`UnitManaMax`, plus an optional `powerType` that reads a slot the unit is not // currently on. That second argument is the point — pfUI draws a shapeshifted druid's mana // with `UnitPower(unit, Enum.PowerType.Mana)` while the active slot is energy or rage, and // 1.12 exposes no way to ask. Backed by `UnitState::powers`/`max_powers`, all five // `UNIT_FIELD_POWER1..5` slots read through the same dead gate and display divisors the // stock pair uses. "UnitPower", "UnitPowerMax", // ClassicAPI's GetSpellBonusDamage (script/char_stats.rs, beside the `UnitDamage` that // reads the same wire field): a school's spell power. 1.12 has no character-sheet line // for it and no global to ask, but the number is streamed all the same — a vanilla // server folds item spell power into `PLAYER_FIELD_MOD_DAMAGE_DONE_POS`, the field // `UnitDamage` already reports index 0 of. pfUI's player module polls schools 2..7 from // a 0.25s OnUpdate to draw its "+N SP" readout, so its absence raised on every tick for // any caster. Its sibling `GetSpellBonusHealing` stays unbuilt: vanilla has no healing // player field at all (0x40A is `PLAYER_FIELD_BYTES`), so there is nothing to read. "GetSpellBonusDamage", // ClassicAPI's UnitSpellHaste (script/compat/classic_api/unit_extra.rs). Answers 0 for // any unit that resolves, which is the *true* figure rather than a stub: 1.12 has no // haste stat anywhere — not in a `UNIT_FIELD`, not on an item, not as a spell modifier — // so every unit on this realm has 0% spell haste, and the real DLL has nothing else to // read either. Built because it is in ClassicAPI's own surface and the realm ships that // DLL: pfUI's player module calls it unguarded whenever its haste readout is on // (`modules/player.lua:87`), so the absent global raised once per 0.25s tick and took // the neighbouring "+N SP" line down with it. "UnitSpellHaste", // ClassicAPI's Focus namespace (script/compat/classic_api/focus.rs): the persistent // secondary selection, which 1.12 has no verb, no token and no event for — the whole // feature arrived with 2.0. `ClearFocus` is a bare global here and does not collide with // 1.12's EditBox method of the same name, which lives in the widget method tables. // pfUI's `modules/focus.lua` calls `FocusUnit` from three slash commands and reads // `UnitGUID("focus")`, and its unit frames read the `"focus"`/`"focustarget"` tokens the // pair maintains; the `/focus` and `/clearfocus` aliases are registered after FrameXML // loads (benilla-app's `ui_script::manifest`), since the stock `ChatFrame.lua` assigns // `SlashCmdList` outright and would drop entries made before it. "FocusUnit", "ClearFocus", // SuperWoW's UnitPosition/CursorPosition (script/compat/superwow.rs): world XYZ, not a // 1.12 verb. "UnitPosition", "CursorPosition", // SuperWoW's option-panel verbs (script/compat/superwow.rs), the four SuperAPI calls: // `SetAutoloot` routes to the real `autoLootDefault` CVar; `Clickthrough` stores a // preference nothing reads yet; `SetMouseoverUnit` overrides the `"mouseover"` token; // `SpellInfo` reads the player's own spellbook. None is a 1.12 verb. "SetAutoloot", "Clickthrough", "SetMouseoverUnit", "SpellInfo", // ClassicAPI's detection pair (script/compat/classic_api/mod.rs): pfUI gates its whole // load on `CLASSIC_API_VERSION`, so this one is load-bearing, not cosmetic. "CLASSIC_API_VERSION", "INTERFACE_VERSION", // ClassicAPI's EventUtil (script/compat/classic_api/event_util.rs): run-once-after-login // and run-once-after-addon-loaded helpers. pfUI calls `ContinueOnPlayerLogin` at file // scope, so its absence stopped that file and cascaded through every skin below it. "EventUtil", // ClassicAPI's CallbackRegistryMixin (script/compat/classic_api/callback_registry.rs): // the named-callback bus pfUI builds `pfUI.events` on; 1.12 has frame events only. "CallbackRegistryMixin", // ClassicAPI's ColorMixin/CreateColor (script/compat/classic_api/color_mixin.rs): the // modern colour object; 1.12 passes colours as loose r,g,b numbers. "ColorMixin", "CreateColor", // ClassicAPI's tInvert (script/compat/classic_api/lua_ext.rs): value->key table flip, a // Blizzard utility 1.12 has no counterpart for. "tInvert", // ClassicAPI's token -> display-name class tables (script/class_info.rs), published from // the same `ChrClasses` columns `C_CreatureInfo.GetClassInfo` answers from. The pair this // tree's `tInvert` exists to flip: pfUI inverts the male one to key its class colours. "LOCALIZED_CLASS_NAMES_MALE", "LOCALIZED_CLASS_NAMES_FEMALE", // ClassicAPI's backport of the modern client's bare math helpers // (script/compat/classic_api/math_ext.rs). 1.12's Lua has `math.floor`/`ceil`/`min`/`max` // and nothing else of the kind — no bare `Round`, no `Clamp` — so every name here is new. // `Round` and `Clamp` are load-blocking in the installed set (pfUI's colour-cache key and // every slider/scroll clamp); the rest travel with them because each is a one-line // definition with exactly one possible reading. "Round", "Clamp", "Saturate", "Lerp", "PercentageBetween", "ClampedPercentageBetween", "Square", "Negate", // ClassicAPI's C_DateAndTime (script/compat/classic_api/date_and_time.rs): a date-aware // server epoch; 1.12's Lua reaches only `GetGameTime()`'s hour and minute, with no date. "C_DateAndTime", // pepo's nampower v2 (script/compat/nampower.rs): the version getter its `NampowerSettings` // addon branches on to decide which of its rows to show. Not a 1.12 verb, and not // namreeb/nampower's either — that fork has no Lua surface at all. "GetNampowerVersion", // nampower v2's three posture reads (script/compat/nampower.rs). 1.12 publishes no // movement getter of any kind — the client reads its own movement word and tells Lua // nothing — so there is no 1.12 spelling to prefer. `1`-or-nil, as upstream's own doc // lines state. `SuperCleveRoidMacros`'s `NampowerAPI.lua` registers each in its // `VERSION_REQUIREMENTS` table and reaches them through `HasFeature`, which tests the // version *and* `_G[name] ~= nil`, so the honest way to make that feature live is to // define the global rather than to raise the version claim. // nampower v2's `GetItemIconTexture(itemId)` and `GetTrinkets()` (same file). 1.12 has // neither: its only icon path for an item is `GetContainerItemInfo` on a bag slot you // already hold, and it has no way at all to ask about an arbitrary item id or to read the // two trinket slots as a pair. Both are in `SuperCleveRoidMacros`'s `HasFeature` table. // nampower v2's bag group (same file), all three sharing one lookup so they cannot // disagree about which of two identical stacks they mean. 1.12 can ask about a bag slot // it names (`GetContainerItemInfo`) but has no search by item id or name, and no // cooldown read that is not already slot-addressed. // nampower v2's outbound group (same file). 1.12 cancels an aura only by its 1-based // buff *index* (`CancelPlayerBuff`), casts only by book slot (`CastSpell`), and uses an // item only by a bag slot it already names — so each of these is a different key into a // send that exists, not a new send. The `*NoQueue` pair is the plain cast here: there is // no spell queue to bypass (`docs/ADDON_COMPAT.md`). // SuperWoW's two quest-log getters (script/compat/superwow.rs). 1.12's // `GetQuestLogTitle` answers the title and never the id, so neither the id nor a chat // link for a log row is derivable from the stock API. The id agrees with // `C_QuestLog.GetQuestIDForLogIndex`, which ClassicAPI publishes over the same rows. // ClassicAPI's `GetPlayerInfoByGUID(guid)` and `CastSpellNoToggle(spellId)` // (script/compat/classic_api/unit.rs). 1.12 has no verb that takes a GUID at all, and no // cast path that skips its own toggle — benilla's queue does not toggle, so the second is // the plain cast by id, the same reasoning nampower's `CastSpellNoQueue` carries. // ClassicAPI's `GetCraftReagentItemID(craftIndex, reagentIndex)` (same file): the one // column 1.12's `GetCraftReagentInfo` does not answer — it gives the name, icon and the // two counts and no id at all, so nothing keying a table on an item can use it. "GetCraftReagentItemID", "CastSpellNoToggle", "GetPlayerInfoByGUID", "GetQuestIDForLogIndex", "GetQuestLinkForLogIndex", "CancelPlayerAuraSlot", "CancelPlayerAuraSpellId", "CastSpellByNameNoQueue", "CastSpellNoQueue", "UseItemIdOrName", "FindPlayerItemSlot", "GetBagItem", "GetItemIdCooldown", "GetItemIconTexture", "GetTrinkets", "PlayerIsMoving", "PlayerIsRooted", "PlayerIsSwimming", // nampower v2's `IsAuraHidden(spellId)` (same file): whether an aura is kept out of the // Lua aura functions. 1.12 has no way to ask — the client applies the attribute test // inside `UnitBuff`/`UnitDebuff` and exposes neither the test nor the attributes. Answers // from the same predicate `benilla-app`'s `ui_aura::shown_in_aura_ui` applies to the // lists, so it cannot contradict the aura list an addon reads beside it. "IsAuraHidden", // nampower's own cast state (same file), the pair every other cast question in grom's // corpus goes through — 25 and 20 call sites outside the API wrapper, which makes them // the most-called nampower functions installed here. // // 1.12 has no spelling for either and cannot grow one: what they report is the client's // own cast globals (the inflight slot, the cast that owns the bar, the auto-repeat key, // the queued strike, the melee attack lock, the global cooldown's end), and that client // publishes *events* about its cast — `SPELLCAST_START`, `SPELLCAST_STOP` — and not one // getter that answers what is in flight. `GetCastInfo` folds the same timing a stock // cast bar draws from `SPELLCAST_START`'s own arguments, which is why its table cannot // disagree with the bar: both come off this frame's one cast window. "GetCurrentCastingInfo", "GetCastInfo", // ClassicAPI's Enum (script/compat/classic_api/enums.rs): the modern client's named // constants, so an addon can write `Enum.PowerType.Mana` for 0. Pure data, no 1.12 // counterpart — 1.12 spells the same numbers as bare literals or `LE_*`/`ITEM_QUALITY_*` // globals, never under a namespace. "Enum", // ClassicAPI's C_Timer (script/compat/classic_api.rs's `timer` module): `After`/ // `NewTimer`/`NewTicker`, not a 1.12 verb. "C_Timer", // ClassicAPI's RunNextFrame (script/compat/classic_api/timer.rs): "do this once at the // start of the next frame", a zero-delay `C_Timer.After` row through the same queue. 1.12 // has no counterpart — the nearest thing is an `OnUpdate` script that unhooks itself. "RunNextFrame", // ClassicAPI's object pools (script/compat/classic_api/pools.rs), modern WoW's // `SharedXML/Pools.lua`: recycle frames/textures instead of leaking one per refresh. 1.12 // has no pooling of any kind — an addon that wanted it kept its own array. pfUI creates its // tooltip buff-icon pool at module-load scope, so the missing `CreateObjectPool` aborted // the rest of that module. `TexturePool_HideAndClearAnchors` is a bare global upstream too // (it is the documented default resetter, passed by name). "ObjectPoolMixin", "CreateObjectPool", "TexturePoolMixin", "CreateTexturePool", "TexturePool_HideAndClearAnchors", // ClassicAPI's Totem namespace (script/compat/classic_api/totem.rs): the slot constants // and a `GetTotemInfo` that honestly answers "no totem". 1.12 has no totem bar and no // `PLAYER_TOTEM_UPDATE` — the whole concept arrived in 2.0 — so none of these is a 1.12 // name. Built because pfUI keys a table with the four constants at *module scope* and // `pfUI.lua:391` loops its modules with no `pcall`, so a nil among them aborted every // module after `totems` and the whole skins pass. The constants are marked inferred in // `docs/ADDON_COMPAT.md`; `GetTotemInfo` is a floor, not a getter, until benilla tracks // totems. "EARTH_TOTEM_SLOT", "FIRE_TOTEM_SLOT", "WATER_TOTEM_SLOT", "AIR_TOTEM_SLOT", "MAX_TOTEMS", "GetTotemInfo", // ClassicAPI's EventRegistry (script/compat/classic_api/callback_registry.rs): the one // shared CallbackRegistryMixin instance, and the bridge from real frame events into the // callback bus. 1.12 has only frame events and no registry of any kind. "EventRegistry", // ClassicAPI's GameTooltip_Hide (script/compat/classic_api/tooltip.rs): a later-client // FrameXML helper meant to be passed by name as an OnLeave handler. // `reference/1.12-globals.tsv` lists six `GameTooltip_*` functions and this is not one of // them. Five call sites in the installed set; nil there raised inside `HookScript`. "GameTooltip_Hide", // Lua 5.1's `select`, restored by compat/classic_api/stdlib_backport.rs — a maintainer // decision recorded in that file's header. 1.12's Lua 5.0 has no row for it (a vararg // function counts with `arg.n`), but ClassicAPI backports the 5.1 stdlib and this build // announces `CLASSIC_API_VERSION`, so an addon on this server sees it on a real install. // pfUI calls it 12 times. The library members restored alongside it (`string.match`, // `string.gmatch`, `math.fmod`, `math.modf`) are not listed here — they live inside their // library tables, not in `_G`, so this bare-global surface never saw them. "select", // ClassicAPI's C_Container (script/compat/classic_api/container.rs): modern WoW's // table/named-return shape over the same bag state the stock 1.12 verbs already expose. "C_Container", // ClassicAPI's C_CreatureInfo (script/compat/classic_api/creature_info.rs): the race // table and a creature GUID's template entry, neither of which 1.12 shows Lua at all — // a race's name reaches the stock UI only through `UnitRace` on a live unit. "C_CreatureInfo", // ClassicAPI's C_Spell/C_SpellBook (script/compat/classic_api/spell.rs, // spellbook.rs): modern spell-id-first wrappers over the stock slot-indexed spellbook. "C_Spell", "C_SpellBook", // ClassicAPI's C_UnitAuras (script/compat/classic_api/unit_auras.rs): modern slot/filter // shape over the stock index-based aura bindings. "C_UnitAuras", // ClassicAPI's C_CVar (script/compat/classic_api/cvar.rs): typed and metadata-bearing // reads (bool coercion, bitfields, temp overrides) over the same registry the stock // `GetCVar`/`SetCVar` use. "C_CVar", // ClassicAPI's C_NamePlate (script/compat/classic_api/nameplate.rs): the modern way an // addon finds a nameplate frame, over the plate pool `script/nameplate.rs` already drives. "C_NamePlate", // ClassicAPI's C_NewItems (script/compat/classic_api/new_items.rs): the freshly-looted // bag-slot marker, which 1.12 has no notion of. Built as an honest floor — `IsNewItem` // answers false — reversing an earlier decision not to: the reasoning about *function* // was right (benilla never fires `BAG_NEW_ITEMS_UPDATED`, so the glow would never // refresh anyway), but it did not weigh the cost of the absence. `newitem.lua:16` indexes // the namespace from a bag-slot update that runs on hover/click/drag, so a nil raised and // took the rest of that handler with it: 7 of 13 addons dead to the touch on this one // name. See the doc row. "C_NewItems", // ClassicAPI's C_Macro (script/compat/classic_api/macro_ext.rs): the string-icon and // name-indexed macro verbs, over the same table the stock macro globals drive. "C_Macro", // ClassicAPI's Action namespace (script/compat/classic_api/action.rs): one bare global, // the `actionType, id, subType` descriptor for an action-bar slot. 1.12 answers the same // facts only as the separate `GetActionTexture`/`GetActionCount`/`IsConsumableAction`. "GetActionInfo", // ClassicAPI's State namespace (script/compat/classic_api/state.rs): bare globals, not a // `C_*` table. 1.12's Lua has no path to any of these postures at all — no mounted, swim, // indoor, falling or form-id getter exists on the reference client — so each is fed from // the engine through `SelfMovement`, not read off a field some stock verb exposes. // `IsInGroup`/`IsInRaid` are the modern spellings of facts 1.12 answers only as the // `GetNumPartyMembers`/`GetNumRaidMembers` counts. "IsSwimming", "IsMounted", "IsIndoors", "IsOutdoors", "IsFalling", "GetShapeshiftFormID", "IsInGroup", "IsInRaid", // VanillaHelpers (this install's build, script/compat/vanilla_helpers.rs): per-blip // minimap art overrides, from that binary's own usage strings. 1.12 has no generic // per-unit minimap blip to retarget at all, so neither name is a reference one. "SetUnitBlip", "SetObjectTypeBlip", // ClassicAPI's pure-logic namespaces (script/compat/classic_api/): `C_ColorUtil` colour // maths and `|c` escape builders, `C_ClassColor` over FrameXML's own `RAID_CLASS_COLORS`, // `C_CurrencyInfo` delegating to FrameXML's `GetCoinTextureString`, and `C_EventUtils` // (empty — `IsEventValid` has no valid-event set to answer from, see `misc.rs`). "C_ColorUtil", "C_ClassColor", "C_CurrencyInfo", "C_EventUtils", // ClassicAPI's `Expansion` namespace: era feature-detection constants, vanilla == 0. "GetClassicExpansionLevel", "ClassicExpansionAtLeast", "ClassicExpansionAtMost", // ClassicAPI's `Input` namespace: the left/right modifier split. 1.12 exposes only the // sideless `IsShiftKeyDown`/`IsControlKeyDown`/`IsAltKeyDown`, and the engine tracks one // bit per modifier, so each pair answers that bit (`input.rs`'s header). "IsLeftShiftKeyDown", "IsRightShiftKeyDown", "IsLeftControlKeyDown", "IsRightControlKeyDown", "IsLeftAltKeyDown", "IsRightAltKeyDown", "IsModifierKeyDown", // ClassicAPI's `Lua` namespace, the additive half only (`lua_ext.rs`): names 1.12 has in // neither library nor global, so nothing `lua50.rs` nils is un-nilled. "Mixin", "CreateFromMixins", // `CreateAndInitFromMixin(mixin, ...)` — `CreateFromMixins` then `:Init(...)`, the // one-liner modern addon code constructs a mixin instance with, and the global the addon // harness ranked among its missing ones. pfUI's icon picker is the installed caller // (`api/ui-widgets.lua:1288`). "CreateAndInitFromMixin", "strreplace", // The macro icon picker's data layer (`macro_icons.rs`). 1.12 answers the same catalog // through `GetNumMacroIcons`/`GetMacroIconInfo`, one icon per call; these four are the // modern *mutator* surface — each takes a table and appends to it — and they are the only // thing `IconDataProviderMixin` reads, so they come as a set or not at all. // // The split is `loose` (dropped on disk) against `mpq` (in the archives), crossed with // spell (`ABILITY_*`/`SPELL_*`) against item (`INV_*`), read off upstream's own // `src/macro/Icons.cpp` rather than guessed. benilla reads only the archive chain and has // no loose-file scan anywhere, so the two `GetLoose*` halves honestly append nothing — // which is exact, not an under-report, and invisible to the one consumer because the mixin // calls all four and concatenates. They are installed rather than left nil precisely // because it calls them unguarded. "GetMacroIcons", "GetMacroItemIcons", "GetLooseMacroIcons", "GetLooseMacroItemIcons", // `IconDataProviderMixin` and its two enums (`macro_icons.rs`), upstream's own Lua file // (`AddOns/!!!ClassicAPI/Util/IconDataProvider.lua`) over the four mutators above. The // mixin *is* the feature: `docs/ADDON_COMPAT.md` twice declined `IconDataProviderExtraType` // alone for exactly that reason, since the constant on its own only moves pfUI's raise from // module scope into `EnsureProvider`. The values are upstream's, read not inferred — // `{ Spell = 1, Item = 2 }` and `{ Spellbook = 1, Equipment = 2, None = 3 }`, which that // file states in its own comment about `EnumUtil.MakeEnum`'s 1-based numbering. Without // these, pfUI's macro icon picker (`modules/macroicons.lua:36`) and TurtleWoW's // `UpdateMacroIconFilenames` rebuild (`modules/turtle-wow.lua:361`) both raise. "IconDataProviderMixin", "IconDataProviderIconType", "IconDataProviderExtraType", // ClassicAPI's `Events`/`System`/`Combat` singles (`misc.rs`). "GetFramesRegisteredForEvent", "GetPhysicalScreenSize", "InCombatLockdown", // ClassicAPI's `Hooks` namespace (`classic_api/hooks.rs`): a post-hook helper 1.12 has no // equivalent of at all — an addon there writes the `local old = Foo` wrap by hand. "hooksecurefunc", // ClassicAPI's player-facing-window namespaces (`classic_api/quest_log.rs`, // `faction.rs`, `friend_list.rs`, `gossip.rs`, `merchant_frame.rs`): questID-, factionID- // and name-keyed wrappers over the same log/reputation/friends/gossip/vendor state the // stock index-keyed verbs expose. 1.12's own API never hands Lua a questID or factionID at // all, which is the gap each one closes. "C_QuestLog", "C_Reputation", "C_FriendList", "C_GossipInfo", // ClassicAPI's C_ChatBubbles (script/compat/classic_api/chat_bubbles.rs). Answers an // empty list: this client's bubbles are drawn natively by `chat_bubble.rs` as a 2-D // world overlay, exactly as the reference's own `CGChatBubbleFrame` is, so there are no // Lua frames to hand an addon and zero is the truthful count. Present rather than absent // because pfUI's `ScanBubbles` runs `ipairs(C_ChatBubbles.GetAllChatBubbles())` the frame // after **every** say/yell/monster-say within earshot (`modules/bubbles.lua:31`), so the // missing namespace raised on anyone speaking nearby — repeatedly, for a cosmetic // restyle. The cost of the empty answer is that bubbles keep this client's own look. "C_ChatBubbles", "C_MerchantFrame", // The same namespaces' bare globals: `Faction`'s three id-keyed getters, and the two mail // attachment-link getters (which SuperWoW also adds, `docs/ADDON_COMPAT.md`). "GetFactionIDByIndex", "GetFactionInfoByID", "GetFactionParentID", "GetSendMailItemLink", "GetInboxItemLink", // ClassicAPI's C_Item (script/compat/classic_api/item.rs): modern id-first and // ItemLocation-keyed readers over the same item-template cache and bag/equipment state the // stock 1.12 item verbs already read. "C_Item", // The `ItemLocation` object those location-keyed readers take, and its mixin // (script/compat/classic_api/item_location.rs). 1.12 names a slot as two loose arguments // and has no such object at all. Load-bearing well past its size: pfQuest's quest-item // watcher builds one per bag slot and per equipment slot from an `OnUpdate`, so the // missing global was 12,570 errors in one session — the largest single source in the log. "ItemLocation", "ItemLocationMixin", // ClassicAPI's C_Map (script/compat/classic_api/map.rs), the three members backed by the // world-map catalog `ui_world_map.rs` already pushes. 1.12 answers only about the map being // *viewed* and only about overlays already explored, so the shipped table — zone yard // sizes, map-folder area ids, every sub-area rect fogged or not — has no 1.12 spelling at // all. This install's pfQuest has deleted its own zone-size and subzone tables in favour // of these. "C_Map", // ClassicAPI's C_TaxiMap (script/compat/classic_api/taxi_map.rs), its one installed // member: `GetTaxiNodesForMap`, the whole `TaxiNodes.dbc` table resolved to zones and map // percentages. 1.12 has no spelling for it even in principle — its taxi verbs // (`NumTaxiNodes` and friends) answer only about the window a flight master has open, from // the player's known mask, so "where are the flight masters" is unaskable. This install's // pfQuest has deleted its shipped flight table in favour of this call. "C_TaxiMap", // ClassicAPI's C_PlayerInfo (script/compat/classic_api/player_info.rs), its one installed // member: `CanUseItem`, the same requirement gate `C_Item.IsUsableItem` and the tooltip's // red lines read (`item_stats::item_usable_by_id`). 1.12 answers the fact only as the // merchant getters' `isUsable` column, never for an arbitrary item id. "C_PlayerInfo", // Blizzard's other FrameXML helper this install's pfQuest calls at file scope // (script/compat/classic_api/player_util.rs): `PlayerUtil.GetClassFile`, the player's // class token. 1.12 answers the same fact only as `UnitClass("player")`'s second return, // which is the pair this reads, so the two cannot drift. `database.lua:886` — the second // of two walls in that one file, after `C_CreatureInfo.GetClassInfo` at :408. "PlayerUtil", // Blizzard's own FrameXML helper, NOT a ClassicAPI function and not 1.12's // (script/compat/classic_api/table_util.rs): `TableUtil.Execute`, call a function per // table value. This install's pfQuest calls it at file scope (`map.lua:21`), so its // absence aborted `map.lua` and left `pfMap` undefined — which is what the harness ranked // as a first-load error for pfQuest-octo. The body is upstream's, fetched not remembered. "TableUtil", // The bare globals upstream lists in that same `Item` section (same file's // `install_globals`). `INVSLOT_FIRST_EQUIPPED`/`INVSLOT_LAST_EQUIPPED` are the 1 and 19 // ends of the equipment band, which `char_stats.rs`'s own slot table already fixes; 1.12 // has no `INVSLOT_*` global at all, spelling each slot as `GetInventorySlotInfo("HeadSlot")` // instead. Load-bearing as a pair: pfUI uses them as `for` bounds at module scope four // times, and a nil bound raises "'for' initial value must be a number" and takes the // enclosing block with it. `GetInventoryItemDurability` is the one member of the // `GetInventoryItem*` family 1.12 lacks — it ships the `GetInventoryItemBroken` predicate // and never the numbers — over the durability pair `InvSlotView` already carries. "INVSLOT_FIRST_EQUIPPED", "INVSLOT_LAST_EQUIPPED", "GetInventoryItemDurability", // `GetInventoryItemRepairCost(slot)`, the same section's third bare global. 1.12 computes // this number and always did — `GameTooltip:SetInventoryItem`'s *third return* is it // (`0x532ee0`), and stock `PaperDollFrame.lua` prints the "Repair Cost" line off that // return under `InRepairMode()`, out of `DurabilityCosts.dbc` × `DurabilityQuality.dbc` // with no packet behind it. What 1.12 has no spelling for is a slot-keyed *getter*: the // only 1.12 route to the figure is driving a tooltip, which the panel widget asking for it // cannot do. Backed by `ui_merchant::item_repair_cost`, already the merchant window's own // `0x4faf30`, so a quote and the vendor's bill cannot disagree. pfUI's durability panel // sums it across the equipment band (`modules/panel.lua:417`) and a nil global raised // before its `or 0` could run — the one raise in the addon harness's "survived being used" // column. "GetInventoryItemRepairCost", // The bare global modern FrameXML declares beside `C_Container.PlayerHasHearthstone`, and // so installed beside those two verbs (script/compat/classic_api/container.rs's `install`). // 1.12 has no `HEARTHSTONE_ITEM_ID` row in `reference/1.12-globals.tsv` — the era's UI // never needed the id in Lua. The value is not taken from memory: `pfQuest`'s own shipped // table has `[6948] = "Hearthstone"` (`db/enUS/items.lua:5642`) and is indexed *by* this // global, and `container.rs` already held the same const for its hearthstone verbs. The // most load-bearing of all the entries in this list: pfQuest gates its entire `OnUpdate` // — quest-log scan, map nodes, minimap icons — behind a locale check that indexes that // table with it, and a nil key reads as nil without raising, so the addon's tracker was // simply off with nothing in the error log. "HEARTHSTONE_ITEM_ID", // ClassicAPI's bare spell globals (script/compat/classic_api/spell.rs's `install_globals`), // whose upstream heading reads "Spell (`C_Spell`, plus several bare globals)". Each is // literally the same function value as its `C_Spell` member, lifted out of that table so // the two spellings cannot drift. 1.12 asks "do I know this spell" only by walking the // book with `GetSpellName(i, "spell")`, never by id. "IsSpellKnown", // ClassicAPI's bare `GetMacroSpell` (script/compat/classic_api/macro_ext.rs): the spell a // macro casts, as `(name, rank)`. 1.12 keeps that binding on the macro record // (`[rec+0x564]`) and shows Lua only `GetMacroInfo`'s name/texture/body, so there is no // 1.12 spelling. Backed by the app's existing traced derivation of that very field // (`ui_macro::run::bound_spell`), already driving the action bar, fed in as // `Model::macro_bound_spells`. "GetMacroSpell", // ClassicAPI's C_AddOns (script/compat/classic_api/addons.rs): the modern per-field // spelling of what 1.12 answers as one GetAddOnInfo tuple, delegating to those globals. "C_AddOns", // ClassicAPI's C_PlayerCache (script/compat/classic_api/player_cache.rs): a name-keyed // class/race/sex store 1.12 keeps internally and exposes to Lua not at all — `UnitClass` // needs a unit token and a chat line has only a name. Its writer and its reader are the // same addon (pfUI's `libs/libunitscan.lua:46` fills it from every roster scan, // `modules/chat.lua:830` reads it back to colour a player link), so nothing here is // derived — the values are the caller's own, round-tripped. 7 raises in one session. "C_PlayerCache", // ClassicAPI's GetQuestLogLeaderBoardID (script/compat/classic_api/quest_log.rs): the id // half of what 1.12's GetQuestLogLeaderBoard answers only as a localized sentence. The // whole point is that 1.12 withholds it, so every quest-helper addon parses // QUEST_MONSTERS_KILLED back into an identity instead. Built because pfQuest calls it // UNGUARDED inside pfDatabase:SearchQuestID (database.lua:1470) — the one function that // turns a quest in the log into map and minimap nodes — and again on the map tooltip's // active-quest lines (map.lua:367). Backed by the new QuestLogObjectiveView::id, which // ui_quest_log.rs's line builders already had in hand (ReqCreatureOrGOId, ReqItemId) and // were discarding after formatting the sentence. "GetQuestLogLeaderBoardID", ] .into_iter() .collect() } /// Every global benilla's VM exposes is one 1.12 has, or a listed exception. #[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. 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 addon that \ feature-detects an unexplained superset takes a path we cannot honour.", unlisted.len(), unlisted.join(" ") ); } /// The exception list is exact: no entry outlives its global, and none excuses a 1.12 name. #[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(" ") ); 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()` (`0x79ba70`/`0x79baf0`/`0x835708`): one value, nil when unset, the path /// without its extension, and the literal `"Solid Texture"` for a colour fill. #[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(); assert_eq!( s.eval::>("return GTFTex:GetTexture()") .unwrap(), None ); assert_eq!( s.arity("GTFTex:GetTexture()").unwrap(), 1, "exactly one return value" ); s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Spell_Fire_FlameBolt")"#) .unwrap(); assert_eq!( s.eval::("return GTFTex:GetTexture()").unwrap(), "Interface\\Icons\\Spell_Fire_FlameBolt" ); s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Foo.blp")"#) .unwrap(); assert_eq!( s.eval::("return GTFTex:GetTexture()").unwrap(), "Interface\\Icons\\Foo" ); 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" ); s.run(r#"GTFFill:SetTexture("Interface\\Buttons\\UI-Quickslot2")"#) .unwrap(); assert_eq!( s.eval::("return GTFFill:GetTexture()").unwrap(), "Interface\\Buttons\\UI-Quickslot2" ); } /// A FontString's shadow accessors; `GetShadowColor` returns four values, alpha included /// (`0x79dd2f`, `mov eax,0x4`). #[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.arity("SHFText:GetShadowColor()").unwrap(), 4, "GetShadowColor returns FOUR values — three drops the alpha" ); assert_eq!(s.arity("SHFText:GetShadowOffset()").unwrap(), 2); 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) ); 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` (`0x7a1550`, reached by both leaf lookups' fallback: `0x79c650`, `0x79ee50`) /// moves the region's draw-list membership and leaves its anchors alone; a non-Frame argument /// raises (`IsA(FrameTag)` at `0x7a16ea`). #[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:SetWidth(100); A:SetHeight(50) B = CreateFrame("Frame", "SPOwnerB") B:SetPoint("BOTTOMLEFT", 300, 0) B:SetWidth(100); B:SetHeight(50) Spark = A:CreateTexture("SPSpark", "ARTWORK") Spark:SetTexture("Interface\\SPGlow") Spark:SetWidth(24); Spark:SetHeight(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)); assert_eq!( s.arity("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" ); 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" ); 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 `TNONE`, which never reaches the nil branch. assert!(s.run("Spark:SetParent()").is_err(), "no argument raises"); // nil detaches the region without destroying it. 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` (`0x780880`) returns nothing, discarding the shared impl's `1`/nil /// (`xor eax,eax`), and sets the button's normal font without touching the label pointer /// (`+0x338`), so a bare Button makes no label; `GetFont` returns three values off that font. #[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(); assert_eq!( s.arity(r#"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.arity("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()) ); // Nothing set: still three values, the path and height nil. assert_eq!(s.arity("SFLabelled:GetFont()").unwrap(), 3); assert!(s .eval::("local f = SFLabelled:GetFont() return f == nil") .unwrap()); 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 the normal state's font object. 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) ); // A local SetFont outranks that object on the label's paint too. 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" ); } /// FontString's `SetNonSpaceWrap`/`CanNonSpaceWrap` (`0x79e9f0`/`0x79ead0`): on by default, the /// getter answers `1` or nil, and a bare `SetNonSpaceWrap()` turns it on. #[test] fn font_string_spacing_round_trips_and_defaults_to_zero() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" local f = CreateFrame("Frame", "SpacingHost") local t = f:CreateFontString(nil, "OVERLAY") assert(t:GetSpacing() == 0, "zero before any set, and a number not nil") t:SetSpacing(20) assert(t:GetSpacing() == 20, "round-trips") -- DirectedByRobertBWeide's own call shape, which used to abort its Initialize at this -- line and leave the addon half-built. t:SetSpacing(0) assert(t:GetSpacing() == 0) t:SetSpacing() assert(t:GetSpacing() == 0, "a missing argument reads as zero") "#, ) .unwrap(); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } #[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(); 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" ); s.run("NSFText:SetNonSpaceWrap(false)").unwrap(); assert_eq!( s.eval::>("return NSFText:CanNonSpaceWrap()") .unwrap(), None, "off answers nil, not 0 and not false" ); s.run("NSFText:SetNonSpaceWrap()").unwrap(); assert_eq!( s.eval::>("return NSFText:CanNonSpaceWrap()") .unwrap(), Some(1), "a bare SetNonSpaceWrap() turns it ON" ); } /// EditBox's registrar table (`.data 0x87bb68`, 48 entries, `mov edx,0x30` at `0x799ab5`) opens /// with the font block each text-bearing type re-declares over the shared impl `0x79f210`: its /// fourteen font verbs answer, its `SetSpacing`/`GetSpacing` (#10, #11) are not installed as /// nothing models line spacing, and other tables' names do not leak onto it. #[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 table, not installed: nothing models line spacing. "SetSpacing", "GetSpacing", // Not on EditBox's table. "CopyFontObject", "SetNonSpaceWrap", "CanNonSpaceWrap", "SetTextHeight", "GetStringWidth", ] { assert!( s.eval::(&format!("return EBFont.{name} == nil")) .unwrap(), "'{name}' must NOT answer on an EditBox" ); } } /// Each EditBox font binding returns what the shared implementation returns (no `mov eax,N` or /// `xor eax,eax` after the call), so unlike `Button:SetFont` (`0x780880`), `EditBox:SetFont` /// (`0x797210` → `0x79f210`) answers the number `1` or nil (`0x79f345`/`0x79f361`), and /// `GetShadowColor` returns four values (`0x79f9b3 mov eax,0x4`). #[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(); 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.arity(&format!("EBShape:{call}")).unwrap(), 0, "EditBox:{call} must return ZERO values" ); } for (call, want) in [ ("GetFontObject()", 1), ("GetFont()", 3), ("GetTextColor()", 4), ("GetShadowColor()", 4), ("GetShadowOffset()", 2), ("GetJustifyH()", 1), ("GetJustifyV()", 1), ] { assert_eq!( s.arity(&format!("EBShape:{call}")).unwrap(), want, "EditBox:{call} must return {want} value(s)" ); } assert_eq!( s.arity(r#"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 answers nil, not an error. 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()); 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, not its name. assert_eq!( s.eval::("return type(EBShape:GetFontObject())") .unwrap(), "table" ); } /// `Dewdrop-2.0.lua:1673-1675`'s shape: an anonymous EditBox under a frame, then /// `SetFontObject(ChatFontNormal)`. #[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(); assert!(s .eval::("return DewdropEBFrame.editBox:GetFontObject() == ChatFontNormal") .unwrap()); // The object's paint reaches the box's own FontString (`[this+0x324]`, what the shim passes). 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)); // `AceGUIWidget-Slider.lua:204-210` ends the same shape with this. s.run(r#"DewdropEBFrame.editBox:SetJustifyH("CENTER")"#) .unwrap(); } /// The justify enum (`.rdata 0x811ad0`) keeps both axes in one dword, bits 0-2 horizontal and 3-5 /// vertical: an unknown token raises (`0x87c77c`), and a token from the other axis masks to /// nothing, so `GetJustifyH()` answers `"UNKNOWN"` (`0x6f1a00`, `.data 0x838044`). #[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(); // A fresh EditBox is LEFT: its ctor turns the font default `0x212` (CENTER | MIDDLE | 0x200) // into `0x211` (`0x779bcd`, stored at `0x779be4`). assert_eq!( s.eval::("return EBJustify:GetJustifyH()").unwrap(), "LEFT" ); assert_eq!( s.eval::("return EBJustify:GetJustifyV()").unwrap(), "MIDDLE" ); 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()) ); 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" ); assert!(s.run(r#"EBJustify:SetJustifyH("SIDEWAYS")"#).is_err()); } /// `EditBox:SetJustifyV` reaches the getter (`0x79fd73` reads the font instance) but never the /// pixels: `SetMultiLine 0x77a4a0`, which the ctor always calls (`0x779c2f`), clears the vertical /// inherit bits (`CSimpleFontString+0x124`, over the drawn justify `+0x120`) and writes TOP or /// MIDDLE itself; nothing sets those bits back, so `0x77086e` masks the setter's value out. #[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); 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" ); assert_eq!( drawn_v(&s), crate::script::JustifyV::Middle, "SetJustifyV is masked out of the rendered justify — multiLine alone decides it" ); // Multi-line moves it, the `0x77a509` leg. s.run("EBJV:SetMultiLine(true)").unwrap(); assert_eq!(drawn_v(&s), crate::script::JustifyV::Top); assert_eq!( s.eval::("return EBJV:GetJustifyV()").unwrap(), "TOP" ); } /// `Texture:SetDesaturated` (`0x79c1e0`) answers `shaderSupported`, `1` or nil; benilla's renderer /// greys, so it answers `1`, and stock `ItemButtonTemplate.lua:69` keeps the caller's tint rather /// than its 0.5 fallback. #[test] fn set_desaturated_reports_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(); s.run("DsTex:SetDesaturated(true)").unwrap(); assert!( s.eval::("return DsTex:SetDesaturated(true) == 1") .unwrap(), "shaderSupported must be 1 (1|nil C shape), not true" ); assert_eq!( s.arity("DsTex:SetDesaturated(true)").unwrap(), 1, "one return value" ); // `SetItemButtonDesaturated`'s branch, transcribed: a truthy answer keeps the caller's tint. s.run( r#" local shaderSupported = DsTex:SetDesaturated(true) local r, g, b = 0.65, 0.65, 0.65 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!( (r - 0.65).abs() < 1e-6 && (g - 0.65).abs() < 1e-6 && (b - 0.65).abs() < 1e-6, "the shader arm keeps the caller's tint, got {r},{g},{b}" ); s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)") .unwrap(); } /// `UnitCreatureType` (`0x51a280`, stages 2 and 3 of three built): a creature's cached record, /// else (`0x605570`) the race's `ChrRaces.dbc` column 9, which is 7, "Humanoid", for every race. #[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() }), ); assert_eq!( s.eval::(r#"return UnitCreatureType("target")"#) .unwrap(), "Beast" ); assert_eq!( s.eval::(r#"return UnitCreatureType("player")"#) .unwrap(), "Humanoid" ); // An unresolved token is nil; a missing argument fails `lua_isstring` and raises. assert!(s .eval::(r#"return UnitCreatureType("party4") == nil"#) .unwrap()); let err = s .run("UnitCreatureType()") .expect_err("a missing arg must raise"); assert!( format!("{err}").contains("Usage: UnitCreatureType"), "got {err}" ); } /// `GetInventorySlotInfo` (`0x4c81b0`) matches the whole name case-insensitively (`0x4c8215`, /// `_strnicmp`) and returns three values, the third the number 1 for `RangedSlot` alone; a miss /// raises the reference's message, with no `Usage:` and no name. #[test] fn get_inventory_slot_info_folds_case_and_flags_only_the_ranged_slot() { let s = script(); 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 ); assert_eq!(s.arity("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 `.blp`: the binding pushes `[esi+4]` as is. assert_eq!(art, "interface\\paperdoll\\UI-PaperDoll-Slot-Head.blp"); assert!(s .eval::("local _,_,r = GetInventorySlotInfo('RangedSlot') return r == 1") .unwrap()); assert!(s .eval::("local _,_,r = GetInventorySlotInfo('HeadSlot') return r == nil") .unwrap()); // `Bag1`..`Bag12` are SlotNumbers 64..75 (64..69 the bank bags) and share one string offset // with `Bag0Slot`..`Bag3Slot`, so all sixteen 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" ); // `Bag1` (64) and `Bag1Slot` (21) are different rows. assert_eq!( s.eval::("return GetInventorySlotInfo('Bag1Slot')") .unwrap(), 21 ); 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}" ); } /// Every Region-map name (`0xcf54b4`, 19 entries) is callable on a Texture and a FontString, whose /// lookups fall back to it (FontString's `0x79ee20` chains its own `0xcf5400`); a Font object's /// lookup (`0x7a1100`) has no fallback. #[test] fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() { /// All 19, `GetObjectType`/`IsObjectType` (`0x7a11d0`/`0x7a1290`) among them. 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:SetWidth(100); RMOwner:SetHeight(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 Region-map readers where a region differs from a frame: `GetParent` is the owner frame /// (`TheoryCraftUI.lua:720` calls it on a FontString), and `GetPoint` answers a sibling region. #[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:SetWidth(200); RRFrame:SetHeight(100) RRPlate = RRFrame:CreateTexture("RRPlate", "ARTWORK") RRPlate:SetPoint("TOPLEFT", 10, -10) RRPlate:SetWidth(50); RRPlate:SetHeight(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(); 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, so none of them is scaled alone. 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)); 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" ); // Frames and regions share an id space, so `relativeTo` must come back as the region handle, // not a frame wrapper onto its id. 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" ); assert_eq!( s.arity("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" ); } /// A region's `GetObjectType`/`IsObjectType` (`0x7a11d0`/`0x7a1290`). #[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(); // One value each (`lua_pushstring`); extra arguments are ignored. assert_eq!( s.eval::("return TITex:GetObjectType()").unwrap(), "Texture" ); assert_eq!( s.eval::("return TIStr:GetObjectType()").unwrap(), "FontString" ); assert_eq!( s.arity("TITex:GetObjectType('ignored')").unwrap(), 1, "one value, and a stray argument is ignored rather than an arity error" ); // The chain is two deep: the leaf, then Region. 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" ); // 1.12.1 has no LayoutFrame, ScriptObject or Object type at all. 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" ); } } assert!(s .eval::>("return TITex:IsObjectType('FontString')") .unwrap() .is_none()); // Case-insensitive and whole-string (`SStrCmpI`). 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" ); } 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.arity(&format!("TITex:IsObjectType({arg})")).unwrap(), 1, "exactly one value on both the hit and the miss path" ); } // A number passes `lua_isstring` and never matches a type name. assert!( s.eval::>("return TITex:IsObjectType(5)") .unwrap() .is_none(), "a number argument is accepted and quietly answers nil, NOT a raise" ); 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}" ); } /// Frames also carry 1.12's frame-side pair, `GetFrameType 0x773640`/`IsFrameType 0x773700` /// (`CSimpleFrameScript.cpp`), beside `CScriptRegion`'s `GetObjectType`/`IsObjectType`. #[test] fn the_frame_side_type_identity_verbs_are_1_12s_own_names() { let s = crate::script::UiScript::new().unwrap(); s.run( r#" FTFrame = CreateFrame("Frame", "FTFrame") FTButton = CreateFrame("Button", "FTButton") FTModel = CreateFrame("Model", nil, FTFrame) FTTex = FTFrame:CreateTexture("FTTex", "ARTWORK") "#, ) .unwrap(); // Both read the same per-class type-name slot (`[edx+0x1c]`). for (obj, leaf) in [ ("FTFrame", "Frame"), ("FTButton", "Button"), ("FTModel", "Model"), ] { assert_eq!( s.eval::(&format!("return {obj}:GetFrameType()")) .unwrap(), leaf ); assert_eq!( s.eval::(&format!("return {obj}:GetObjectType()")) .unwrap(), leaf, "the two names must never disagree — they read one slot" ); } // How Cartographer's `LookNFeel.lua:368` finds the world map's player arrow. assert!( s.eval::(r#"return FTModel:GetFrameType() == "Model" and not FTModel:GetName()"#) .unwrap(), "an anonymous Model must answer its type AND a nil name — LookNFeel.lua:368" ); assert_eq!( s.eval::("return FTButton:IsFrameType('Button')") .unwrap(), 1 ); assert_eq!( s.eval::("return FTButton:IsFrameType('frame')") .unwrap(), 1 ); assert_eq!( s.eval::("return FTButton:IsFrameType('REGION')") .unwrap(), 1 ); for absent in ["LayoutFrame", "ScriptObject", "Object", "Slider", "Fram"] { assert!( s.eval::>(&format!("return FTButton:IsFrameType('{absent}')")) .unwrap() .is_none(), "{absent} is not in a Button's chain" ); } // A region has no frame-side pair: the two registrars are distinct. assert!(s.eval::("return FTTex.GetFrameType == nil").unwrap()); assert!(s.eval::("return FTTex.IsFrameType == nil").unwrap()); } /// A frame's type chain follows the reference's 23-class roster, a hardcoded list per class 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"]), ( "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, unlike the `SimpleHtml` variant; addons compare the string with `==`. ( "SimpleHTML", "SimpleHTML", &["SimpleHTML", "Frame", "Region"], ), ( "ColorSelect", "ColorSelect", &["ColorSelect", "Frame", "Region"], ), ( "GameTooltip", "GameTooltip", &["GameTooltip", "Frame", "Region"], ), ("Minimap", "Minimap", &["Minimap", "Frame", "Region"]), // `PlayerModel` derives from `Model` (`0x505830`/`0x5057c0`), so a `SetUnit` portrait pane // has `SetCamera` too; `DressUpModel`, like `TabardModel`, derives from `PlayerModel`: the // roster's maximum depth, 5. ( "PlayerModel", "PlayerModel", &["PlayerModel", "Model", "Frame", "Region"], ), ( "DressUpModel", "DressUpModel", &["DressUpModel", "PlayerModel", "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}" ); } 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})" ); } } 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 name on the full region table the VM holds is in a leaf list: `script::region` copies /// names out of it into the Texture and FontString leaves, and one in neither is unreachable. #[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:?}" ); } /// The Environment Detail pair, `SetWorldDetail 0x488dd0`/`GetWorldDetail 0x488d70`. #[test] fn set_world_detail_writes_the_stop_table_and_validates_like_the_reference() { let s = script(); s.register_cvars([ (crate::script::CVAR_WORLD_DETAIL, "1"), (crate::script::CVAR_FRILL_DENSITY, "32"), ]); let frill = |s: &crate::script::UiScript| s.cvar(crate::script::CVAR_FRILL_DENSITY); let stop = |s: &crate::script::UiScript| s.cvar(crate::script::CVAR_WORLD_DETAIL); // The preset table at `0x804518`, verbatim: {16, 32, 48}. for (n, want) in [(0, "16"), (1, "32"), (2, "48")] { s.run(&format!("SetWorldDetail({n})")).unwrap(); assert_eq!( frill(&s).as_deref(), Some(want), "stop {n} writes frillDensity" ); assert_eq!( s.eval::("return GetWorldDetail()").unwrap(), n, "and the getter reads that stop back in the SAME tick — the host has not drained yet" ); } assert_eq!( crate::script::WORLD_DETAIL_STOPS, [16, 32, 48], "the table is the reference's, not a transcription that can drift" ); // Truncation is toward zero (`0x40a2b0` sets RC = chop), so -0.5 is stop 0 and accepted. s.run("SetWorldDetail(2.9)").unwrap(); assert_eq!(frill(&s).as_deref(), Some("48")); s.run("SetWorldDetail(-0.5)").unwrap(); assert_eq!( frill(&s).as_deref(), Some("16"), "-0.5 truncates to 0, which is in range: only <= -1 raises" ); // `lua_isnumber` accepts a numeric string. s.run(r#"SetWorldDetail("2")"#).unwrap(); assert_eq!(frill(&s).as_deref(), Some("48")); for bad in [ "SetWorldDetail(3)", "SetWorldDetail(-1)", "SetWorldDetail(99)", ] { let e = s.run(bad).unwrap_err().to_string(); assert!( e.contains("value must be in the range 0, 2"), "{bad} must raise 0x8423b8 verbatim, got: {e}" ); } for bad in [ "SetWorldDetail()", "SetWorldDetail(nil)", r#"SetWorldDetail("x")"#, "SetWorldDetail({})", ] { let e = s.run(bad).unwrap_err().to_string(); assert!( e.contains("Usage: SetWorldDetail(value)"), "{bad} must raise 0x8423e8 verbatim, got: {e}" ); } s.run("SetWorldDetail(1)").unwrap(); assert_eq!( s.arity("SetWorldDetail(1)").unwrap(), 0, "every `ret` in 0x488dd0 leaves eax = 0" ); assert_eq!(s.arity("GetWorldDetail()").unwrap(), 1); assert_eq!( s.eval::("return type(GetWorldDetail())").unwrap(), "number" ); // The getter ignores an argument; pfUI's hook passes one. assert_eq!(s.eval::("return GetWorldDetail(7)").unwrap(), 1); assert_eq!(stop(&s).as_deref(), Some("1")); } /// pfUI's `hdgraphic` module (`modules/hdgraphic.lua:4-39`), which hooks both verbs to drive /// `frillDensity` past the top stop, run against the bindings. #[test] fn the_pfui_hdgraphic_extended_arm_runs() { let s = script(); s.register_cvars([ (crate::script::CVAR_WORLD_DETAIL, "1"), (crate::script::CVAR_FRILL_DENSITY, "32"), ]); s.run( r#" local HookSetWorldDetail = SetWorldDetail function SetWorldDetail(arg) HookSetWorldDetail((arg > 2 and 2 or arg)) if arg > 2 then ConsoleExec("frillDensity " .. (arg+1)*16) else ConsoleExec("frillDensity 24") end end local HookGetWorldDetail = GetWorldDetail function GetWorldDetail(arg) local frill = tonumber(GetCVar("frillDensity")) return frill > 48 and frill/16-1 or HookGetWorldDetail() end "#, ) .unwrap(); assert_eq!( s.eval::("SetWorldDetail(9); return GetWorldDetail()") .unwrap(), 9, "the extended stop round-trips through frillDensity, which is the module's whole point" ); assert_eq!( s.cvar(crate::script::CVAR_FRILL_DENSITY).as_deref(), Some("160"), "(9+1)*16 — inside the reference's own [1, 256], which is why its range is wider than its slider" ); // The low arm falls through to the stock getter. assert_eq!( s.eval::("SetWorldDetail(2); return GetWorldDetail()") .unwrap(), 2 ); } /// `ShowNameplates 0x489450`, `HideNameplates 0x489460`, `ShowFriendNameplates 0x489470` and /// `HideFriendNameplates 0x489480` read no argument and return nothing: four 10-byte bodies over /// two setters, differing only in an `or`/`and` mask. #[test] fn the_nameplate_verbs_ignore_their_arguments_and_return_nothing() { let s = script(); s.register_cvars([ (crate::script::CVAR_NAMEPLATE_ENEMIES, "1"), (crate::script::CVAR_NAMEPLATE_FRIENDS, "0"), ]); let get = |s: &crate::script::UiScript, n: &str| s.cvar(n); assert_eq!( get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(), Some("1") ); s.run("HideNameplates()").unwrap(); assert_eq!( get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(), Some("0") ); assert_eq!( get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(), Some("0"), "the friendly bit is a separate setter — hiding enemies must not touch it" ); s.run("ShowFriendNameplates()").unwrap(); assert_eq!( get(&s, crate::script::CVAR_NAMEPLATE_FRIENDS).as_deref(), Some("1") ); assert_eq!( get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(), Some("0"), "...and back the other way" ); s.run("ShowNameplates(false)").unwrap(); assert_eq!( get(&s, crate::script::CVAR_NAMEPLATE_ENEMIES).as_deref(), Some("1"), "the verb IS the value — a falsy argument does not invert it" ); for call in ["HideNameplates(1, 2, 3)", "ShowFriendNameplates({})"] { s.run(call) .unwrap_or_else(|e| panic!("{call} must not raise: {e}")); } assert_eq!( s.arity("ShowNameplates()").unwrap(), 0, "zero values — observably different from nil for a caller that counts" ); }