Files
benilla-octo/crates/benilla-ui/src/script/tests/reference_surface.rs
T
gromandClaude Opus 5 cf0e157f34 ui+app: the bag walk, the modifier fold, and two shapes the DLL's pool corrected
Built: `GetBagItems`, `GetSpellModifiers`, `GetSpellIdCooldown` — plus
fixes to two verbs that already existed and had the wrong *shape*,
found the same way `GetItemIconTexture`'s wrong argument was: by
comparing what we answer against nampower's own string pool.

`GetBagItems()` hands back every occupied bag slot as
`{ [bagIndex] = { [slot] = row } }`. DoiteAuras asks seven times over
and walks both levels with `pairs`, reading `itemId` and `stackCount` —
it is how that addon answers "do I have this item" without 100
`GetContainerItemInfo` calls per condition evaluation. Empty slots and
empty bags are absent, because a nil value in a Lua table *is* absence
and `pairs` is how both levels are read.

`GetSpellModifiers(spellId, modifierType)` answers
`flat, percent, hasModifier`, where percent is the final multiplier (90
means 90% of the original) — the consumer says so in its own words and
multiplies by `percent / 100`. The 26 modifier types come from the
DLL's usage string. The fold is `spell/mods.rs`' existing reader,
reached through a new `spell_modifier_probe` the lifecycle reinstalls
whenever the wire moves the tables: a VM-side reimplementation of a
four-gate 64-bit fold would be free to drift from the mana cost the
action bar greys a button on. The third return is the point — the
consumer tests it before touching either number, and the duration path
multiplies, so `0, 0` for an unmodified spell would zero every debuff
timer that asks.

`GetSpellIdCooldown(spellId)` answers the twenty-key table, from a new
`Cooldowns::legs`: `info`'s three legs kept apart, same walk and same
arms, so the folded number a swirl draws and the legs an addon reads
cannot describe different timers.

**`GetBagItem`'s row had the wrong key names** — `bag`, `slot`,
`count`, `quality`, `link`, `locked`, this client's own
`GetContainerItemInfo` spellings. nampower's row is contiguous at
`0xa8a68` and is the block `GetEquippedItem` already used, so a caller
reading `.stackCount` got nil and no error. One `bag_slot_table` now
serves both, with three columns absent rather than zeroed: this client
resolves a bag slot's enchants to names at the app boundary and keeps
no random-properties id for a container slot, and a zeroed enchant id
would be a claim.

**`GetItemIdCooldown` returned the wrong kind of value** — the stock
`start, duration, enable` triple. At the version we report it is a
table, and the addon layer settles it: `API.GetItemCooldownInfo` and
`API.GetSpellCooldownInfo` return these calls' results *directly* and
their callers index them. `cd.gcdCategoryRemainingMs` is the primary
path of `API.GetGCDRemainingMs`, which the `[gcd]`/`[nogcd]` macro
conditionals divide by 1000 — against a triple that is
`(0.0).gcdCategoryRemainingMs`, which raises. Latent rather than live
(nothing calls those wrappers today), still wrong.

Two things the schedule gates caught and improved:

- The probe refresher started as an exclusive system, which is
  ambiguous against everything in Update it is not ordered against:
  +274 pairs over the ceiling. `NonSendMut` is all it needs.
- Its memo started as a bare `Local`, which `session.rs`' own gate
  refuses — a memo about what this VM has been told must die with the
  VM. As a `VmMemo` it now reinstalls on the first frame after a
  `/reload`, which is better than the reasoning I had written for the
  `Local`: a fresh VM has no closure at all until something installs
  one.

One count the earlier sweep got wrong, corrected in the doc:
`GetSpellPower`'s "7 call sites" are BetterCharacterStats' own method
`BCS:GetSpellPower(school)`, not nampower's global. Nothing in the
corpus calls the global at all. A bare name match is not a call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-24 09:00:17 -06:00

2162 lines
95 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 `<Include>` 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",
"GetBagItems",
"GetSpellModifiers",
"GetItemIdCooldown",
"GetSpellIdCooldown",
"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<String> = 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<String> = reference().into_iter().map(|(n, _)| n).collect();
let ours: HashSet<String> = script()
.eval::<Vec<String>>(
"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::<Option<String>>("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::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Spell_Fire_FlameBolt"
);
s.run(r#"GTFTex:SetTexture("Interface\\Icons\\Foo.blp")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return GTFTex:GetTexture()").unwrap(),
"Interface\\Icons\\Foo"
);
s.run("GTFFill:SetTexture(1, 0, 0, 1)").unwrap();
assert_eq!(
s.eval::<String>("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::<String>("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::<f32>("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<crate::layout::Rect> {
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::<bool>("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::<bool>("local f = SFLabelled:GetFont() return f == nil")
.unwrap());
s.run(r#"SFBare:SetFont("Fonts\\SKURRI.TTF", 12, "THICKOUTLINE")"#)
.unwrap();
assert_eq!(
s.eval::<String>("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::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
Some(1),
"default on, answered as 1"
);
assert!(
!s.eval::<bool>("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::<Option<i64>>("return NSFText:CanNonSpaceWrap()")
.unwrap(),
None,
"off answers nil, not 0 and not false"
);
s.run("NSFText:SetNonSpaceWrap()").unwrap();
assert_eq!(
s.eval::<Option<i64>>("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::<String>(&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::<bool>(&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::<String>(r#"return type(EBShape:SetFont("Fonts\\SKURRI.TTF", 12))"#)
.unwrap(),
"number",
"the number 1, never the boolean true"
);
assert!(s
.eval::<bool>(r#"return EBShape:SetFont("Fonts\\SKURRI.TTF", 12) == 1"#)
.unwrap());
// An unloadable (empty) path answers nil, not an error.
assert!(s
.eval::<bool>(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::<String>("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::<bool>("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::<String>("return EBJustify:GetJustifyH()").unwrap(),
"LEFT"
);
assert_eq!(
s.eval::<String>("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::<String>("return EBJustify:GetJustifyH()").unwrap(),
"UNKNOWN",
"a vertical token on SetJustifyH clears the axis rather than centering it"
);
assert_eq!(
s.eval::<String>("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::<String>("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::<String>("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::<bool>("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::<String>(r#"return UnitCreatureType("target")"#)
.unwrap(),
"Beast"
);
assert_eq!(
s.eval::<String>(r#"return UnitCreatureType("player")"#)
.unwrap(),
"Humanoid"
);
// An unresolved token is nil; a missing argument fails `lua_isstring` and raises.
assert!(s
.eval::<bool>(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::<i64>(&format!("return GetInventorySlotInfo('{name}')"))
.unwrap(),
0,
"{name} must fold to AmmoSlot"
);
}
assert_eq!(
s.eval::<i64>("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::<bool>("local _,_,r = GetInventorySlotInfo('RangedSlot') return r == 1")
.unwrap());
assert!(s
.eval::<bool>("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::<i64>("return GetInventorySlotInfo('Bag1')")
.unwrap(),
64
);
assert_eq!(
s.eval::<i64>("return GetInventorySlotInfo('bag12')")
.unwrap(),
75,
"the new rows fold case like every other"
);
assert_eq!(
s.eval::<String>("local _,a = GetInventorySlotInfo('Bag6') return a")
.unwrap(),
s.eval::<String>("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::<i64>("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<String> = Vec::new();
for kind in ["RMTex", "RMStr"] {
for name in REGION_MAP {
if !s
.eval::<bool>(&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::<bool>("return RRPlate:GetParent() == RRFrame")
.unwrap(),
"a region's parent is the frame that created it"
);
assert!(
s.eval::<bool>("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::<i64>("return RRPlate:GetNumPoints()").unwrap(), 1);
assert_eq!(
s.eval::<i64>("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::<bool>("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::<bool>("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::<String>("return TITex:GetObjectType()").unwrap(),
"Texture"
);
assert_eq!(
s.eval::<String>("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::<i64>(&format!("return {obj}:IsObjectType('{leaf}')"))
.unwrap(),
1,
"{obj} is its own leaf type"
);
assert_eq!(
s.eval::<i64>(&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::<Option<i64>>(&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::<Option<i64>>("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::<i64>(&format!("return TITex:IsObjectType('{spelling}')"))
.unwrap(),
1,
"'{spelling}' must match — the compare folds case"
);
}
for partial in ["Tex", "TextureX", "Regio", ""] {
assert!(
s.eval::<Option<i64>>(&format!("return TITex:IsObjectType('{partial}')"))
.unwrap()
.is_none(),
"'{partial}' must NOT match — whole-string, not a prefix"
);
}
assert_eq!(
s.eval::<String>("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::<Option<i64>>("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: <unnamed>:IsObjectType("TYPE")"#),
"an anonymous region reports <unnamed>, 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::<String>(&format!("return {obj}:GetFrameType()"))
.unwrap(),
leaf
);
assert_eq!(
s.eval::<String>(&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::<bool>(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::<i64>("return FTButton:IsFrameType('Button')")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return FTButton:IsFrameType('frame')")
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return FTButton:IsFrameType('REGION')")
.unwrap(),
1
);
for absent in ["LayoutFrame", "ScriptObject", "Object", "Slider", "Fram"] {
assert!(
s.eval::<Option<i64>>(&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::<bool>("return FTTex.GetFrameType == nil").unwrap());
assert!(s.eval::<bool>("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::<String>("return TC:GetObjectType()").unwrap(),
leaf,
"{kind}:GetObjectType()"
);
for want in *chain {
assert_eq!(
s.eval::<i64>(&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::<Option<i64>>(&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::<String>("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<String> = Vec::new();
for pair in full.pairs::<String, mlua::Value>() {
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::<i64>("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::<String>("return type(GetWorldDetail())").unwrap(),
"number"
);
// The getter ignores an argument; pfUI's hook passes one.
assert_eq!(s.eval::<i64>("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::<i64>("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::<i64>("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"
);
}