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
2162 lines
95 KiB
Rust
2162 lines
95 KiB
Rust
//! 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"
|
||
);
|
||
}
|