Files
ClassicAPI/docs/API.md
T
Brues 6000df8009 feat(cvar): add C_CVar.SetTempCVar and RemoveTempCVar
SetTempCVar changes a cvar for the session without saving it to
Config.wtf; RemoveTempCVar restores the value from before the first
temp set.

The temp set passes a6 = 0 to the engine setter, the only argument that
marks the config dirty, so it applies the value and runs the change
callback without asking for a save. When something else dirties the
config, a co-hook on the config writer (0x0063D980) points each temp
cvar's value at its saved string for the write and restores it after.
A plain SetCVar over a temp value wins: the entry drops itself, so the
user's set is saved and never rolled back.

Errors mirror Script_SetCVar. Registered in-game and on glue.
2026-09-25 09:59:27 -05:00

808 KiB
Raw Blame History

ClassicAPI — Lua Reference

Per-function reference for the calls ClassicAPI adds to the WoW 1.12.1 Lua environment. See the project README for installation and build instructions.

Contents

Account

SaveAccount(name, password) / DeleteAccount(name) / GetSavedAccounts() / LoginWithSavedAccount(name) — GlueXML only

Persists account credentials in Windows Credential Manager (per-user, DPAPI-encrypted) and dispatches login from C so the plaintext password never crosses the C↔Lua boundary on the way out. Designed for GlueXML rewrites of AccountLogin.lua that want a selection list of remembered accounts without persisting plaintext passwords to SavedVariables.

Only callable from the login screen (the "glue" Lua state). Calling them from an in-world addon errors with "attempt to call nil" — they aren't registered there.

  • SaveAccount(name, password) — encrypt and save under the current realmlist. Returns true on success, false if name or password is empty, no realmlist is set, or the OS rejects the write.

  • DeleteAccount(name) — remove the saved entry. Returns true if a matching entry existed and was deleted.

  • GetSavedAccounts() — returns a numeric-keyed table of entries for the current realmlist. Other realmlists' entries are invisible. Empty table if no realmlist is set. Each entry is a table:

    Field Type Notes
    name string Account name as saved.
    lastUsed number Unix epoch seconds of the last write to the vault entry. Refreshed automatically by LoginWithSavedAccount, so this functions as a "last used" timestamp. 0 if Windows didn't supply a timestamp (extremely rare, e.g. credentials manually injected).
    for i, entry in ipairs(GetSavedAccounts()) do
        print(entry.name, date("%Y-%m-%d %H:%M", entry.lastUsed))
    end
    
  • LoginWithSavedAccount(name) — decrypt internally and feed the credentials to the engine's login function. Returns true if credentials were found and dispatched, false if no such entry exists. Plaintext is never returned to Lua. Equivalent to what DefaultServerLogin(name, password) does, but with the password fetched from the vault rather than passed in. Also re-saves the credential (same value) to refresh the lastUsed timestamp.

Scoping per realmlist

Every operation is scoped by the current realmList CVar (the address from realmlist.wtf, e.g. "logon.turtle-wow.org"), not the friendly display name. The same account name on two different private servers therefore gets two distinct vault entries:

ClassicAPI/WoW/logon.turtle-wow.org/MYACCT
ClassicAPI/WoW/logon.other-server.com/MYACCT

Switching realmlists changes the visible account list automatically; no separate "select realm" step. If you need a refresh while still on the login screen, call Autologin_Load() (or GetSavedAccounts() directly) after the realmlist changes.

Storage

Entries live in the OS Credential Manager under the ClassicAPI/WoW/<realmlist>/ namespace. Users can inspect and wipe them via Windows' control /name Microsoft.CredentialManager → Generic Credentials, the same way they'd manage any other Windows- stored credential.

Security caveats

Realistic about what this defends against:

  • Defeats: casual file inspection of WTF, sharing the WTF folder, cloud-syncing it, another Windows user on the same machine, malware running as a different Windows user.
  • Does not defeat: any process running as the current Windows user can decrypt these via DPAPI — it's per-user, not per-process. No "decrypt and return plaintext" path is exposed to Lua, so a hostile addon can trigger a login with a saved account but can't extract the password. Plaintext is also unavoidably in process memory while the engine sends SRP.

Addon example

-- GlueXML, e.g. AccountLogin.lua: save credentials on first manual
-- login, one-click login on subsequent shows.

local function OnLoginClicked()
    local name = AccountLoginAccountEdit:GetText()
    local password = AccountLoginPasswordEdit:GetText()
    if not name or name == "" then return end

    if password and password ~= "" then
        -- Fresh login or password change: persist + log in.
        SaveAccount(name, password)
        LoginWithSavedAccount(name)
    else
        -- Reuse stored credentials.
        LoginWithSavedAccount(name)
    end
end

-- Populate a selection list from the vault, sorted by recency.
local accounts = GetSavedAccounts()
table.sort(accounts, function(a, b) return a.lastUsed > b.lastUsed end)
for i = 1, table.getn(accounts) do
    local e = accounts[i]
    print(i, e.name, "last used:", date("%m/%d/%Y", e.lastUsed))
end

-- Forget an account.
DeleteAccount("OLDACCT")

Action

GetActionInfo(slot)

Returns the action descriptor for a 1-based action-bar slot, in the shape actionType, id, subType. Returns nil for empty slots.

local actionType, id, subType = GetActionInfo(1)
Slot contents Returns
empty nil
spell "spell", spellID, "spell"
macro "macro", macroSlot
item (by-itemID) "item", itemID
item (by bag-instance) "item", nil

The 120-slot action table at 0x00BC6980 packs each entry as a uint32 where the top 4 bits are the type tag:

  • 0x0 — spell action. Entry is the spellID. The third return is always "spell" for entries on this table; the engine helper hardcodes pet flag to 0 here. Pet-bar actions live in a separate table — use PetHasActionBar + the pet-action helpers for pet slots.
  • 0x4 — macro or bag-item. Ambiguous tag, disambiguated by walking the 36-entry macro-slot map at 0x00BDCC60 and checking whether entry & 0xBFFFFFFF matches any of those macro IDs. On a hit, the slot is a macro and the matching index becomes the 1-based macroSlot return. On a miss, the slot is a bag-item reference; we surface the type as "item" but return nil for the id — extracting the itemID from the bag-action hash struct needs more reversing than we've done.
  • 0x8 — item by itemID. entry & 0x7FFFFFFF is the itemID.

This is the same discrimination SuperWoWhook uses in its replacement of GetActionText (which returns name, type, id). Without SuperWoWhook the engine's stock GetActionText only handles items in bags; ClassicAPI's GetActionInfo provides the full name, type, id shape independently of any other DLL patches.

Subtype is always "spell" for spell entries (no pet differentiation on this table) and the bag-item itemID path is currently incomplete — see the comment in src/action/Info.cpp.

AddOns

C_AddOns.* getters that splat the GetAddOnInfo(arg) 7-tuple (name, title, notes, enabled, loadable, reason, security) into single-field accessors, plus a getter for the addon's optional dependencies. Most bypass GetAddOnInfo entirely and call the engine's per-field helpers directly.

All accept either a 1-based index (1..GetNumAddOns()) or an addon directory name string.

C_AddOns.GetAddOnName(indexOrName)

Returns the addon's directory name as the engine sees it (the folder name on disk). For numeric input, returns the engine's canonical casing; for string input, echoes the input verbatim once existence is confirmed. Returns nil for missing addons.

C_AddOns.GetAddOnName(1)            -- "Atlas-TW"
C_AddOns.GetAddOnName("DebugTools") -- "DebugTools"
C_AddOns.GetAddOnName("garbage")    -- nil

C_AddOns.GetAddOnTitle(indexOrName)

Returns the ## Title: from the addon's .toc file, with WoW color-code escapes applied. nil for missing addons or addons without a title field.

C_AddOns.GetAddOnTitle("DebugTools") -- "UI Debug Tools"

C_AddOns.GetAddOnNotes(indexOrName)

Returns the ## Notes: from the .toc. nil for missing addons or addons without notes.

C_AddOns.GetAddOnNotes("DebugTools")
-- "Tools for developing addons (backport of Blizzard_DebugTools 3.3.5 to 1.12.1 / Lua 5.0)"

C_AddOns.IsAddOnLoadable(indexOrName)

Returns loadable, reason — a real boolean and a status string (or nil).

reason comes from a small status table the engine consults when populating GetAddOnInfo's 6th return: "DISABLED", "BANNED", "CORRUPT", "INSECURE", "NOT_DEMAND_LOADED", "INTERFACE_VERSION", "MISSING". nil when the addon is loadable. The full signature accepts optional character and demandLoaded arguments — those are ignored here, since there is no per-character addon enable state.

C_AddOns.IsAddOnLoadable("DebugTools")        -- true, nil
C_AddOns.IsAddOnLoadable("HardcoreTooltips")  -- false, "DISABLED"
C_AddOns.IsAddOnLoadable("garbage")           -- false, nil

C_AddOns.IsAddOnLoaded(indexOrName)

Returns loadedOrLoading, loaded — two booleans.

C_AddOns.IsAddOnLoaded("DebugTools")    -- true, true
C_AddOns.IsAddOnLoaded("garbage")       -- false, false
C_AddOns.IsAddOnLoaded(1)               -- true, true   (first addon by index)

C_AddOns.LoadAddOn(indexOrName)

Loads a LoadOnDemand addon. Returns loaded, value:

  • loaded — 1 when the addon loaded, or was already loaded. nil otherwise.
  • value — a locale-independent reason the load failed, such as "DISABLED", or "UNKNOWN_ERROR" when no specific reason is available. nil on success.
local loaded, reason = C_AddOns.LoadAddOn("Blizzard_AuctionUI")
if not loaded then
    print("could not load:", reason)
end

Test loaded for truth rather than comparing it to true — it is the number 1.

The two returns distinguish "load-in-progress" from "fully loaded", and they really do differ while an addon is loading. An addon's own files run part-way through its load, so code at file scope sees itself as loading but not yet loaded:

-- at file scope in MyAddon.lua
C_AddOns.IsAddOnLoaded("MyAddon")       -- true, false
-- and once the load has finished
C_AddOns.IsAddOnLoaded("MyAddon")       -- true, true

The same holds for anything else running inside that window, such as a dependency loaded on the way in: it sees the addon that pulled it in as true, false.

Unknown addons (numeric index past GetNumAddOns(), or string name not in the registry) return false, false.

An addon asking about itself from its own ADDON_LOADED handler gets true, false, because that event fires just before its load call returns. Asking about any other addon there is accurate.

C_AddOns.GetAddOnSecurity(indexOrName)

Returns an Enum.AddOnSecurityStatus integer (not a string):

Value Enum.AddOnSecurityStatus.* When
0 Secure Blizzard-signed addons (Blizzard_*).
1 Insecure Every user addon loaded from Interface/AddOns/. The default for any registered addon not in the secure or banned override sets.
2 Banned Entries the engine has explicitly disqualified — set by server addon-banlist responses, rare in practice.
3 NotAvailable No addon by that name / index exists.
C_AddOns.GetAddOnSecurity("Blizzard_TalentUI") -- 0 (Secure)
C_AddOns.GetAddOnSecurity("DebugTools")        -- 1 (Insecure)
C_AddOns.GetAddOnSecurity("Nonexistent")       -- 3 (NotAvailable)

if status == Enum.AddOnSecurityStatus.Secure then ...

C_AddOns.DoesAddOnExist(indexOrName)

Returns true iff the engine's addon registry has a matching entry, false otherwise. Cheap existence probe used by addons doing soft-dependency checks.

The implementation goes through the registry directly rather than dispatching to GetAddOnInfo — the engine echoes its input name back as ret1 unconditionally (before the lookup), so a GetAddOnInfo("garbage") ~= nil heuristic returns true for any string. This wrapper avoids that.

C_AddOns.DoesAddOnExist("DebugTools")  -- true
C_AddOns.DoesAddOnExist("garbage")     -- false

C_AddOns.GetAddOnOptionalDependencies(indexOrName)

Returns the addon's ## OptionalDeps: names as multiple return values, in declared order — the counterpart to the stock GetAddOnDependencies (required deps). An addon with no ## OptionalDeps: field returns nothing.

Arg and error handling match stock GetAddOnDependencies: a numeric index out of range (or a non-string/non-number argument) raises the usage error; an unknown name string returns nothing without erroring.

-- ## OptionalDeps: Atlas, pfQuest
C_AddOns.GetAddOnOptionalDependencies("MyAddon")  -- "Atlas", "pfQuest"
C_AddOns.GetAddOnOptionalDependencies("Atlas")    -- (nothing — no OptionalDeps)

C_AddOns.GetAddOnLocalTable(name)

Returns the private table of the addon named name. This is the same table the addon's own files receive as the second value of ...:

-- inside MyAddon's files
local addonName, addonTable = ...

Another addon reads that table only when both conditions are true:

  1. MyAddon is loaded, and
  2. MyAddon's .toc file declares ## AllowAddOnTableAccess: 1.

If either condition is false, the function returns nil. An addon that does not opt in keeps its table private. The check protects each addon's table from access it did not permit.

-- MyAddon.toc contains: ## AllowAddOnTableAccess: 1
local t = C_AddOns.GetAddOnLocalTable("MyAddon")   -- MyAddon's table
t.sharedValue = 5                                  -- MyAddon sees this too

C_AddOns.GetAddOnLocalTable("DebugTools")   -- nil (no opt-in directive)
C_AddOns.GetAddOnLocalTable("DoesNotExist") -- nil (not loaded)

Pass the addon name as a string. A numeric index returns nil — one addon refers to another by name, not by load order.

Conditional and multi-flavor TOC loading

Some addons support several game versions from one folder. ClassicAPI reads the TOC conventions those addons use, so they load here.

Flavor TOC files. An addon that supports several clients can name its TOC after the client it targets. The plain <Name>.toc is then optional. ClassicAPI reads two of these names:

  • <Name>_Turtle.toc — on a Turtle client only.
  • <Name>_ClassicAPI.toc — on any client.

ClassicAPI looks for _Turtle first, so it wins when both exist. Either one also wins over a plain <Name>.toc. An addon can therefore keep its plain TOC for other clients and put its ClassicAPI build in a suffixed file.

ClassicAPI does not read _Vanilla or _Classic. Those names belong to the Classic Era client, which is a separate program with its own engine and its own API. An addon's _Vanilla files are written for that client. Such a file can start the addon and then fail inside it, on a function or a template this client does not provide. So an addon that ships only _Vanilla and _Mainline TOCs does not load at all. Add a _ClassicAPI.toc to support this client.

Note the word vanilla. The two TOC conventions reuse it for different things. As a file suffix, _Vanilla names the Classic Era client, so ClassicAPI ignores it. As a directive value, vanilla names this client's game type, so [AllowLoadGameType vanilla] loads the line.

Flavor Bindings files. The same selection applies to an addon's keybinding file:

  • Bindings_Turtle.xml — on a Turtle client only.
  • Bindings_ClassicAPI.xml — on any client.

The order matches the TOC files: _Turtle first, and either one wins over a plain Bindings.xml. An addon that ships no plain Bindings.xml still loads its bindings from a flavor file. ClassicAPI does not read _Vanilla or _Classic here either.

Multi-flavor ## Interface: version. A TOC can list several interface versions on one line:

## Interface: 120100, 50504, 38002, 20506, 11200

The addon loads when the list contains this client's interface version, 11200. The position of that entry in the list does not matter. A list without it stays out of date, and the addon does not load.

Per-line directives. Inside a TOC, gate individual file lines with a condition, or expand a path variable:

# Loads on this client (game type is vanilla):
Vanilla.lua              [AllowLoadGameType vanilla]

# Dropped on this client:
Mainline.lua             [AllowLoadGameType mainline]

# Loads only under a matching client locale:
Localization\deDE.lua    [AllowLoadTextLocale deDE]

# Path variables expand before the file loads:
[Family]\Init.lua                # -> Classic\Init.lua
[Game]\Init.lua                  # -> Vanilla\Init.lua
Localization\[TextLocale].lua    # -> Localization\enUS.lua  (your locale)

How each token resolves on this client:

Directive Result
[AllowLoadGameType ...] Loads the line when the list contains vanilla.
[AllowLoadTextLocale ...] Loads the line when the list contains your client locale (the GetLocale() code).
[AllowLoad ...] Loads on game, drops on glue.
[Family] Classic
[Game] Vanilla
[TextLocale] Your client locale code, for example enUS.

A line loads only when every condition on it passes. An unknown condition drops the line. A file gated by a rule this client does not know stays unloaded.

Limits.

  • Conditions on ## metadata lines are not supported. Only file-reference lines are gated.
  • [AllowLoad glue] never loads. Addon files load in-game only.

SavedVariables loaded first

Normally an addon's files run first, then its SavedVariables load, then ADDON_LOADED fires. So file-scope code sees its SavedVariables as nil. An addon avoids this with one TOC line:

## SavedVariables: MyAddonDB
## LoadSavedVariablesFirst: 1

ClassicAPI honors ## LoadSavedVariablesFirst. For a flagged addon, its SavedVariables load before its files run, so file-scope code sees them:

-- With the directive, in MyAddon's file:
local db = MyAddonDB       -- the restored table, not nil

This covers both ## SavedVariables and ## SavedVariablesPerCharacter. File-scope reads and writes both work. A SavedVariables file exists only after the first save, so the first-ever login still sees nil — there is nothing on disk to load yet.

/reload picks up new addons and new files

A stock client fixes its view of the game folder at launch. A file you add while the game runs does not load until you restart the client. Only edits to files that already existed at launch take effect on /reload.

ClassicAPI removes the restart requirement. On every /reload:

  • A new folder under Interface\AddOns\ loads as a normal addon. It appears in GetNumAddOns() / GetAddOnInfo(), fires ADDON_LOADED, and its dependencies, SavedVariables, keybindings, and flavor TOC all work as usual.
  • New files added to an existing addon load (add the file and its TOC line, then /reload).
  • A first-time SavedVariables file survives /reload. On a stock client, the first save of a newly installed addon is written to disk but cannot be read back until a restart, so the settings appear lost. That quirk is fixed.
  • ## metadata edits take effect. Change any ## line — for example the ## SavedVariables: or ## Dependencies: list, the ## Title:, or the ## Interface: version — and /reload applies it. GetAddOnMetadata returns the new values.
  • Deleting an addon folder removes it from the addon list on the next /reload.

APIDocumentation

ClassicAPI describes its own surface. Each function it adds carries a typed signature: the name and type of every argument and return value, which of them can be nil, and one sentence that says what the function does. Events, enumerations, and the table shapes functions return are described the same way.

You read this in the game with /classicapi, or from code through C_APIDocumentation.

/classicapi

Browse the API from the chat box. /capi is the short form.

/classicapi                        usage
/classicapi stats                  how much is documented
/classicapi system list            every system
/classicapi spell                  one system
/classicapi spell list             every function, event and table in a system
/classicapi search cooldown        search everything
/classicapi spell search cooldown  search one system

search also answers to s. Every search takes a Lua pattern.

Results are clickable. Click a name to print its full signature, with each argument and return value on its own line. Right-click copies a ready-to-paste call to the clipboard. Shift-click puts a /dump of the function in the chat box, with the cursor between the parentheses.

Functions are grouped into systems. A system is a namespace such as C_Spell, a group of plain globals such as SpellGlobals, or a widget method set such as SimpleTextureAPI. Find a system by its name or its namespace: /classicapi spell and /classicapi c_spell both work, and case does not matter.

The browser holds what ClassicAPI adds. The functions the client already ships are not in it.

C_APIDocumentation.GetSystems()

Returns an array of system names, sorted.

local systems = C_APIDocumentation.GetSystems()
-- { "Item", "ItemGlobals", "Spell", "SpellBook", "SpellGlobals", ... }

C_APIDocumentation.GetSystem(system)

Returns one system's documentation table, or nil if no system has that name. Match on the name or the namespace; case does not matter. Each call builds a new table, so the caller may keep it and change it.

local spell = C_APIDocumentation.GetSystem("C_Spell")
spell.Name        -- "Spell"
spell.Namespace   -- "C_Spell"
spell.Functions[1].Name
spell.Functions[1].Returns[1].Type
Field Meaning
Name The system's name.
Type "System" for a namespace or a group of globals, "ScriptObject" for a widget method set.
Namespace The table the functions live in, such as C_Spell. Absent for globals and widget methods.
Environment "All", "Game", or "Glue" — where the functions exist.
Functions Array of functions. Each has Name, Type, and where documented Documentation, Arguments, and Returns.
Events Array of events. Each has Name, LiteralName, and where documented Documentation and Payload.
Tables Array of table shapes. A Structure has Fields; an Enumeration also has NumValues, MinValue, and MaxValue.

An argument, return value, payload value, or field is described by Name, Type, and Nilable, plus Default and Documentation when they apply. Type is a Lua type such as number or string, or the name of a table shape in some system's Tables.

The layout matches the one Blizzard's own documentation uses, so a tool written against that data reads this without change.

_classicapi_UndocumentedAPI()

Returns an array of the registered names that carry no description yet, and its length. Names are being described one namespace at a time, so this list shrinks with each release.

local missing, count = _classicapi_UndocumentedAPI()

AuctionHouse

C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)

Posts numStacks auctions of quantity each, from a single source stack, in one call — a ClassicAPI extension modelled on the multi-sell flow. The engine's StartAuction only ever posts one whole item object, so this handles the splitting and the sequence of posts for you.

  • itemLocation — a { bagID = B, slotIndex = S } table identifying the source stack (0 = backpack, 1–4 = equipped bags; slotIndex 1-based).
  • duration — 1/2/3 for 2h/8h/24h (the three durations); the raw minute values 120/480/1440 are also accepted.
  • quantity — items per auction (1–255).
  • numStacks — number of auctions to post.
  • bid — minimum bid per stack, in copper (≥ 1).
  • buyout — buyout per stack, in copper (0 / omitted = no buyout).

Returns true when the job is accepted and posting has started, or false, reason on immediate rejection (AH not open, source empty, not enough items for numStacks × quantity, no free general bag slot to split into, a job already in progress, or bad arguments).

It's asynchronous. CMSG_AUCTION_SELL_ITEM has no count field — the server posts the entire item object a GUID points at. So to post a partial quantity the DLL first splits that amount into a bag slot (a server round-trip), then posts the resulting stack (another round-trip), repeating for each stack. Progress is reported through events rather than the return value:

Event Payload When
AUCTION_MULTISELL_START — job accepted, posting begins
AUCTION_MULTISELL_UPDATE postedCount, totalCount after each stack lands (final one has postedCount == totalCount)
AUCTION_MULTISELL_FAILURE — aborted (source changed under it, no free slot, AH closed, or a step timed out ~10s)

Splitting reuses one general-purpose bag slot (each post empties it for the next split); the final stack posts the source object directly with no split. Posting a whole existing stack (quantity == the source count, numStacks 1) needs no free slot at all. Every individual post goes through the engine's own StartAuction, so its validation (damaged-item reject, buyout normalization, duration check) applies to each stack.

-- Watch progress
local f = CreateFrame("Frame")
for _, e in ipairs({"AUCTION_MULTISELL_START", "AUCTION_MULTISELL_UPDATE",
                    "AUCTION_MULTISELL_FAILURE"}) do f:RegisterEvent(e) end
f:SetScript("OnEvent", function() print(event, arg1, arg2) end)

-- At an open auction house: post 3 stacks of 5, 2h, 100 bid / 200 buyout each
C_AuctionHouse.PostItem({ bagID = 0, slotIndex = 1 }, 1, 5, 3, 100, 200)

Limitations (v1): the source is the single stack at itemLocation (stacks aren't aggregated across bags — quantity × numStacks must fit in that one stack); temp splits use general-purpose bags only, so family-restricted items (arrows/shards that live in specialty bags) aren't supported; one job runs at a time.

AuraUtil

Helper library over C_UnitAuras, backported from FrameXML. Its purpose on 1.12 is the allocation-free aura scan: the non-packed path builds no table per aura, which matters under Lua's garbage collector when you scan many units each frame.

AuraUtil.ForEachAura

AuraUtil.ForEachAura(unit, filter, batchSize, func [, usePackedAura]) calls func for each aura on unit matching filter, in order, until func returns a truthy value or the auras run out.

  • Non-packed (default): func receives the 15 positional values of C_UnitAuras.UnitAuraBySlot. No table is built per aura.
  • usePackedAura true: func receives the AuraData table from GetAuraDataBySlot.

The scan fetches slot ids in batches through C_UnitAuras.GetAuraSlots, then reads each aura by slot id. So the cost grows with the aura count, not with its square. batchSize sets how many slot ids one batch fetches. With nil, one batch fetches all of them. The value never caps how many auras func visits. A batchSize of 0 or less does nothing.

AuraUtil.ForEachAura("target", "HARMFUL", nil, function(name, icon, count)
    print(name, count)
end)

AuraUtil.FindAura

AuraUtil.FindAura(predicate, unit, filter [, arg1, arg2, arg3]) returns the positional values of the first aura for which predicate returns truthy, or nil. predicate receives the (up to three) caller arguments, then the aura's positional values:

local function castByMe(_, _, _, name, icon, count, dispelType, duration, exp, source)
    return source == "player"
end
local name = AuraUtil.FindAura(castByMe, "target", "HARMFUL")

AuraUtil.FindAuraByName

AuraUtil.FindAuraByName(auraName, unit, filter) returns the positional values of the first aura whose name matches auraName, or nil. Names are localized and not unique, so this returns the first match.

AuraUtil.UnpackAuraData

AuraUtil.UnpackAuraData(auraData) returns an AuraData table's fields as 15 positional values in the classic UnitAura order, or nil when auraData is nil. (Position 13 is the table's isFromPlayerOrPlayerPet.)

CharacterList

GetSavedCharacterOrder(realm) / SetSavedCharacterOrder(realm, order) — GlueXML only

Persists a user-chosen ordering for the character-select list, per realm, per account. Designed for GlueXML mods that add drag-to-reorder to the character-select screen — but since the storage is a plain text file you can also pre-populate it by hand.

Only callable from the login / character-select screens (the "glue" Lua state). Calling them from an in-world addon errors with "attempt to call nil" — they aren't registered there.

  • GetSavedCharacterOrder(realm) — returns the saved order for realm as a string, or "" if none has been saved. Never nil.
  • SetSavedCharacterOrder(realm, order) — writes the order string. Pass "" to clear the saved entry for that realm.

The order value is a pipe-delimited list of character names, e.g. "Thrall|Jaina|Sylvanas". Names are matched case-sensitively against what GetCharacterInfo returns.

Storage file

Saved to WTF\Account\<ACCOUNT>\ClassicAPI.txt, one line per realm:

CharacterOrder.Octo=Thrall|Jaina|Sylvanas
CharacterOrder.AnotherRealm=Foo|Bar

<ACCOUNT> is the account name the launcher logged in with (uppercase, the same folder name WoW uses for SavedVariables). The realm key on each line is whatever string was passed as realm — typically the result of GetServerName(). This file is shared with other ClassicAPI account-scoped settings, so don't be surprised to see unrelated Key=Value lines in it; leave them alone.

You can pre-populate the file by hand if you don't have a GlueXML mod that calls SetSavedCharacterOrder. Just create or edit ClassicAPI.txt in the correct account folder before launching the game — any GlueXML code that reads GetSavedCharacterOrder(realm) will see your saved order.

Addon example

-- GlueXML, e.g. CharacterSelect.lua: persist the user's drag-reordered list
local realm = GetServerName() or ""
local names = {}
for i = 1, GetNumCharacters() do
    table.insert(names, (GetCharacterInfo(translationTable[i])))
end
SetSavedCharacterOrder(realm, table.concat(names, "|"))

-- On the next CHARACTER_LIST_UPDATE: read the saved order back
local saved = GetSavedCharacterOrder(realm)
if saved ~= "" then
    for name in string.gfind(saved, "([^|]+)") do
        -- reconcile against the current GetCharacterInfo names
    end
end

Chat

GetCurrentChatGUID()

Returns the sender's GUID for whichever CHAT_MSG_* event is currently being dispatched, or nil if called outside a chat event or for a synthetic chat with no real sender (e.g. CHAT_MSG_SYSTEM).

Format matches UnitGUID: "0xHHHHHHHHLLLLLLLL" (16 hex digits, hi dword first).

local f = CreateFrame("Frame")
f:RegisterEvent("CHAT_MSG_CHANNEL")
f:RegisterEvent("CHAT_MSG_SAY")
f:RegisterEvent("CHAT_MSG_WHISPER")
f:SetScript("OnEvent", function()
    local guid = GetCurrentChatGUID()
    if guid then
        local _, class, _, race = GetPlayerInfoByGUID(guid)
        DEFAULT_CHAT_FRAME:AddMessage(string.format(
            "%s: %s [%s %s]", arg2, arg1, class or "?", race or "?"))
    end
end)

The CHAT_MSG_* events don't include the sender GUID in their payload. Addons that need to identify chatters reliably (rather than by sender name, which is locale-fragile and ambiguous across realms) currently strcmp against an addon-maintained name cache. This function lets them skip that work.

Implementation: hooks the engine's chat dispatcher at FUN_CHAT_DISPATCH (0x0049A870), which receives the sender GUID as args 11/12 of a 12-arg __fastcall. The hook stashes the GUID into a static global before forwarding to the original function; the engine then fires the appropriate CHAT_MSG_* event synchronously inside that window, so GetCurrentChatGUID() called from the addon's OnEvent sees the right GUID. The global is restored to its prior value (not cleared) on hook return, so nested chat dispatch (e.g. an addon calling SendChatMessage from its handler) doesn't lose the outer context's GUID.

The function returns nil rather than "0x0000000000000000" for synthetic chat (system messages, login banners, etc.) where the GUID args are zero — matches the idiomatic if GetCurrentChatGUID() then check.

Class

FillLocalizedClassList(table [, isFemale])

Fills the passed-in table with [classToken] = localizedClassName pairs for every class in ChrClasses.dbc, and returns the same table for chaining.

The table is mutated in place. Existing keys are overwritten; unrelated keys are preserved.

local classes = FillLocalizedClassList({})
-- classes.WARRIOR = "Warrior"
-- classes.MAGE    = "Mage"
-- classes.PRIEST  = "Priest"
-- ...

An optional isFemale boolean can fetch female-form names. ChrClasses.dbc has no separate female-name array — Name[9] (one localized string per locale) sits exactly between offsets +0x14 and +0x38, with the class token immediately after, leaving no room. The arg is accepted for signature parity but ignored; the same names are returned either way. Most locales (English included) wouldn't differentiate the two anyway, so callers won't typically notice.

Sparse class IDs (classID 6 and a few others are skipped) have NULL records and are silently skipped.

ClassColor

C_ClassColor.GetClassColor(className)

Returns the color of a class as a ColorMixin. Returns nil if className is not a class.

className is the class file name, not the localized name. It is the uppercase token that UnitClass returns as its second value.

local _, classFile = UnitClass("target")     -- "WARRIOR"
local color = C_ClassColor.GetClassColor(classFile)
if color then
    print(color:WrapTextInColorCode(UnitName("target")))
end

The color is the one the whole interface uses for that class, and every caller gets the same object. Do not write to its fields — the change applies everywhere. Use CreateColor(color:GetRGBA()) for a copy you can edit.

An addon that recolors a class changes what this function returns.

ClassicAPI namespace

Every function on this page is bound twice: under the name it documents, and under the same name inside the ClassicAPI table. Namespaced calls keep their namespace as a subtable.

ClassicAPI.GetServerTime()                       -- == GetServerTime
ClassicAPI.C_Item.IsBound(itemLocation)          -- == C_Item.IsBound
ClassicAPI.coroutine.create(fn)                  -- == coroutine.create

Both names hold the same function, so ClassicAPI.X == X is true. That equality is the point of the table. A Lua global belongs to whoever writes it last, and FrameXML and every addon load after ClassicAPI registers, so any of these names can be taken later with no error and no warning. The mirrored name cannot be taken, which gives you two things:

-- Reach a function whose global was replaced
local now = ClassicAPI.GetServerTime()

-- Detect that it was replaced
if GetServerTime ~= ClassicAPI.GetServerTime then
    -- something else owns this global now
end

Prefer the documented name. Code written against GetServerTime() runs on other clients; code written against ClassicAPI.GetServerTime() runs only here. Reach for the table when a name is known to be taken, or to test whether it is — not as a default calling style.

Two kinds of addition are absent from the table. Frame methods (tooltip:SetSpellByID(...)) are not globals, so nothing mirrors them. Enum tables and value globals such as CLASSIC_API_VERSION are values rather than functions, and a mirrored value would be a stale copy.

ColorUtil

The C_ColorUtil color-space and text-color-code helpers. All are pure functions (no engine state); conventions verified in-game.

Ranges: hue is in degrees, [0, 360); saturation / value / lightness are [0, 1]. RGB channels are [0, 1] (not 0–255). Achromatic (gray) inputs return a hue of -1 — a sentinel, not 0 — which the reverse conversions accept as "no hue" (equivalent to saturation == 0).

The color argument is a table with r, g, b fields in [0, 1] (ColorMixin-shaped). An optional a is honored (default 1), though the color code always encodes 8 hex digits with alpha.

C_ColorUtil.ConvertRGBToHSV(r, g, b)

Returns (h, s, v). Achromatic input → h == -1.

C_ColorUtil.ConvertRGBToHSV(0, 1, 0)         -- 120, 1, 1   (pure green)
C_ColorUtil.ConvertRGBToHSV(0.5, 0.5, 0.5)   -- -1, 0, 0.5  (gray)

C_ColorUtil.ConvertHSVToRGB(h, s, v)

Returns (r, g, b). A negative hue (or s == 0) is treated as achromatic and yields r == g == b == v.

C_ColorUtil.ConvertHSVToRGB(120, 1, 1)   -- 0, 1, 0

C_ColorUtil.ConvertHSVToHSL(h, s, v)

Returns (h, s, l). Hue passes through unchanged.

C_ColorUtil.ConvertHSLToHSV(h, s, l)

Returns (h, s, v). Hue passes through unchanged.

C_ColorUtil.ConvertHSLToRGB(h, s, l)

Returns (r, g, b). A negative hue (or s == 0) is achromatic → r == g == b == l.

C_ColorUtil.GenerateTextColorCode(color)

Returns the bare 8-hex color string "AARRGGBB" (alpha first), not including the |c escape.

C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0})   -- "ffff0000"

C_ColorUtil.WrapTextInColor(text, color)

Wraps text in the color's escape sequence: "|c" .. AARRGGBB .. text .. "|r".

C_ColorUtil.WrapTextInColor("Hi", {r = 1, g = 0, b = 0})   -- "|cffff0000Hi|r"

C_ColorUtil.WrapTextInColorCode(text, colorCode)

Wraps text in the given bare color code: "|c" .. colorCode .. text .. "|r". Pair with GenerateTextColorCode (which produces colorCode).

local code = C_ColorUtil.GenerateTextColorCode({r = 1, g = 0, b = 0})
C_ColorUtil.WrapTextInColorCode("Hi", code)   -- "|cffff0000Hi|r"

Combat

InCombatLockdown()

Always returns false. Combat lockdown gates secure-frame UI manipulation, and there is no secure-frame system here — there's nothing to lock down. This function is a no-op stub, so addons that call it don't error on a missing global.

For "is the player actually in combat?", use UnitAffectingCombat("player"), which the engine ships.

if not InCombatLockdown() then
    -- always true in 1.12 — falls through unconditionally
end

if UnitAffectingCombat("player") then
    -- this is the real check
end

StartAttack([target])

Starts your melee auto-attack. The AttackTarget() global turns the attack on or off with each call. StartAttack only starts it — a call while you attack your current target does not toggle the attack off. So a /startattack macro can use it safely.

With no argument, it attacks your current target. With a unit token ("target", "focus", "party1"), it attacks that unit. A name or an unknown token uses the current target instead. If there is no valid target, nothing happens.

StartAttack()            -- attack the current target; never cancels an attack
StartAttack("focus")     -- attack your focus unit

StopAttack()

Stops your melee auto-attack. If you are not attacking, the call does nothing.

Both functions also exist as slash commands: /startattack [target] and /stopattack. The commands accept the same [conditions] that /focus accepts, and each client language has its own command names next to the English ones (for example /angriffstart and /angriffstop on a German client).

/startattack
/startattack focus
/startattack [harm] target
/stopattack

Console

The developer console is the overlay you open with ~ when the client is started with -console. These functions run commands in it, write to it, and read its state.

ConsoleExec(command)

Runs a console command line, the same way typing it into the console does.

ConsoleExec("ExportDBCFiles")
ConsoleExec("gxRestart")

Every command ConsoleGetAllCommands lists can be run this way, so anything the console can do is now reachable from Lua. The command is added to the console's history, and an unknown command writes Unknown command to the console rather than raising.

The console's own set command is not the same as SetCVar. It writes any cvar, including one SetCVar refuses as read-only and one no Lua function can read. ConsoleExec("set …") is therefore a way around the limits every other cvar function keeps, and it changes settings that persist. Prefer SetCVar unless you specifically want the console's behaviour.

Works whether or not the console is open, and whether or not -console was used.

ConsoleEcho(message [, colorType])

Writes one line to the console. colorType is 0 to 8 and picks the colour, as ConsoleGetColorFromType describes; it defaults to 0, white.

ConsoleEcho("something happened")
ConsoleEcho("something went wrong", 3)   -- red

The line is kept whether or not the console is open, so it is there when you next open it.

ConsolePrintAllMatchingCommands(prefix)

Writes every command whose name begins with prefix to the console, the way its own completion lists candidates. The match ignores case. An empty prefix prints nothing; use ConsoleGetAllCommands to get everything.

ConsoleIsActive()

Returns whether the console is open right now. Always false when the client was started without -console, because the key that opens it does nothing then.

ConsoleGetColorFromType(colorType)

Returns r, g, b, a for one of the console's nine line colours, each 0 to 1, or nil outside that range.

Type Colour Type Colour
0 white 5 white
1 white 6 white
2 grey 7 white, half alpha
3 red 8 black
4 yellow

ConsoleGetFontHeight()

Returns the console's text height as a fraction of the screen height. The default is 0.02.

There is no matching setter. The font is built once while the client starts, and rebuilding it afterwards leaves every line already in the console pointing at a font that no longer exists.

SetConsoleKey(keyCode)

Sets the key that opens and closes the console.

keyCode is the client's own numeric key code, not a key name — the console compares raw key codes and nothing in it reads names. The key still only works when the client was started with -console.

CalculateStringEditDistance(a, b)

Returns how many single-character insertions, deletions or substitutions turn one string into the other. The console uses it to suggest a command when one is mistyped.

CalculateStringEditDistance("kitten", "sitting")   -- 3
CalculateStringEditDistance("help", "hepl")        -- 2

Returns nil if the shorter string is 256 characters or longer.

ConsoleGetAllCommands()

Returns a list of every console command and console variable the client has, each as a table:

Field Type Meaning
command string the name you type in the console
help string the description, or "" when it has none
category number an Enum.ConsoleCategory value
commandType number an Enum.ConsoleCommandType value
scriptContents string always ""
scriptParameters string always ""
for _, info in ipairs(ConsoleGetAllCommands()) do
    if info.commandType == Enum.ConsoleCommandType.Cvar then
        print(info.command, GetCVar(info.command))
    end
end

Console variables are included, and most of them carry no description, so help is often "" for them. Console macros and scripts do not exist on this client, so commandType is only ever Cvar or Command, and the two script fields are always empty.

Enum.ConsoleCategory holds Debug 0, Graphics 1, Console 2, Combat 3, Game 4, Default 5, Net 6, Sound 7, Gm 8, Reveal 9 and None 10. Every command reports one of Debug through Gm. Enum.ConsoleCommandType holds Cvar 0, Command 1, Macro 2 and Script 3.

The list is live: a command added while the game runs, such as one of the export commands below, appears in the next call.

ExportInterfaceFiles art|code (console command)

A developer-console command (not a Lua function) — type it into the ~ console, which is available when the client is launched with -console. It extracts Blizzard's stock UI files out of the game's MPQ archives onto disk. Useful for reading the FrameXML / GlueXML source you're backporting addons against, or pulling out the default art.

  • ExportInterfaceFiles code — writes .lua / .xml / .toc / .xsd files to BlizzardInterfaceCode\…
  • ExportInterfaceFiles art — writes .blp / .tga files to BlizzardInterfaceArt\…

Files are written relative to the client's working directory (next to WoW.exe), preserving their Interface\… subtree under the destination folder — e.g. Interface\FrameXML\FrameXML.toc becomes BlizzardInterfaceCode\FrameXML\FrameXML.toc. On completion the command prints a wrote N file(s) line back to the console.

> ExportInterfaceFiles code
ExportInterfaceFiles: wrote 1234 code file(s) to BlizzardInterfaceCode\

Notes:

  • It enumerates the mounted MPQs' (listfile), so it surfaces whatever the archives actually ship — including Blizzard's own UI addons under Interface\AddOns\Blizzard_* (AuctionUI, TalentUI, TradeSkillUI, etc.), which are part of the stock UI source. Files present in multiple archives (base + patches) are written once.
  • Your own loose, on-disk addons are not exported — they aren't in any archive's listfile, so they're never enumerated.
  • It runs synchronously and briefly freezes the client while it walks Interface\ — expected for a one-shot extraction.

ExportDBCFiles (console command)

A ClassicAPI-original companion to ExportInterfaceFiles that dumps every .dbc table the client loads to DBFilesClient\ under the working directory (next to WoW.exe). No subcommand — there's only one thing to export.

> ExportDBCFiles
ExportDBCFiles: wrote 155 .dbc file(s) to DBFilesClient\

Unlike the MPQ (listfile) alone (which omits ~18 of the tables the client actually loads — mostly cosmetic animation/sound/lookup DBCs that Blizzard left out of the index), this unions two sources for a complete set:

  1. the MPQ (listfile) under DBFilesClient\ — catches files that ship in the archives without a loader (e.g. wowerror_strings.dbc);
  2. a .text scan for the DBC path-getter pattern (mov eax, &"DBFilesClient\X.dbc"; ret) — the authoritative list of what this build loads. The master DBC init (FUN_0053f8b0) is straight-line with no iterable name table, so the per-DBC getters are the registry; scanning for them is the same technique that built docs/DBCs.md.

Results are deduped case-insensitively. Handy for a local DBC dump to inspect schemas/values directly — e.g. verifying a record's column layout against a known row, the way C_Item.GetEnchantInfo's effect columns were confirmed — without a standalone MPQ extraction tool.

ExportSoundFiles [subpath] (console command)

A ClassicAPI-original companion that dumps the client's sound files out of the MPQ archives to BlizzardSound\ under the working directory (next to WoW.exe). Every sound the client can play lives under one Sound\ root, and the export preserves that subtree — e.g. Sound\Creature\Ragnaros\RagnarosAggro01.wav becomes BlizzardSound\Creature\Ragnaros\RagnarosAggro01.wav.

> ExportSoundFiles Creature\Ragnaros
ExportSoundFiles: wrote 27 file(s) under Sound\Creature\Ragnaros to BlizzardSound\

The optional subpath narrows the export to a path prefix under Sound\. It is plain prefix matching, so Creature\Rag also catches Creature\Ragnaros. Use it: the whole tree is roughly a gigabyte, and the command runs synchronously, freezing the client until it finishes. Bare ExportSoundFiles exports everything.

Notes:

  • Every file under Sound\ is exported, whatever its extension. The tree is entirely audio (.wav, .mp3, .ogg), and a few files are named inconsistently, so filtering by extension would only hide them.
  • The export covers every sound the client can play, not only the ones its archives index: several hundred files it ships are named by no index at all, and the client's own sound table supplies them instead. That table also names files this build does not ship (sounds belonging to later content); those are skipped, so the written count lands under the number of names.
  • A narrowed export writes into the same layout a full one would, so running it repeatedly with different subpaths builds up one tree.
  • The destination is BlizzardSound\, deliberately not Sound\. The client prefers a loose file over the archived copy of the same path, so exporting into Sound\ would shadow the archives with a gigabyte of duplicates that get re-indexed at every launch.

Container

C_Container.GetContainerItemID(bagIndex, slotIndex)

Returns the itemID at the given bag/slot, or nil if the slot is empty or the indices are out of range. Positional-arg form of the same lookup C_Item.GetItemID({bagID=B, slotIndex=S}) performs.

  • bagIndex = 0 — the player's main backpack.
  • bagIndex = 1..4 — the player's equipped bag slots.
  • slotIndex — 1-based, capped at the bag's actual slot count (the engine's PackBagSlot rejects out-of-range slots and returns nil cleanly).
for slot = 1, 16 do
    local id = C_Container.GetContainerItemID(0, slot)
    if id then
        local _, type, subtype = C_Item.GetItemInfoInstant(id)
        -- ...
    end
end

C_Container.GetContainerItemInfo(containerIndex, slotIndex)

Returns a ContainerItemInfo table for the item in the given bag slot, or nil if the slot is empty / the indices are out of range. The structured-table form of the flat global GetContainerItemInfo, which only returned texture, itemCount, locked, quality, readable.

  • containerIndex = 0 — main backpack; 1..4 — equipped bag slots.
  • slotIndex — 1-based.

Table fields:

Field Type Notes
iconFileID string Icon path (no fileID system here, same as GetItemIcon). Absent if the item's static data isn't cached yet.
stackCount number Current stack size.
isLocked boolean Item is in a pending transaction (pickup/trade/mail in flight).
quality number|nil Enum.ItemQuality (0=Poor … 5=Legendary); nil until the item is cached.
isReadable boolean Has readable page text (books/letters).
hasLoot boolean Static LOOTABLE flag (best-effort — post-loot emptiness can't be tracked client-side).
hyperlink string Fully-decorated per-instance item link (enchant + random suffix), same as GetContainerItemLink.
isFiltered boolean Always false — there is no bag search-filter system.
hasNoValue boolean true when the item's vendor sell price is 0.
itemID number Item ID.
isBound boolean Soulbound.
itemName string Per-instance decorated display name (falls back to the base name).

Live per-instance fields (stackCount, isLocked, isBound, itemID, hyperlink) are always present; cache-derived fields (iconFileID, quality, hasLoot, hasNoValue, itemName) are omitted/nil until the item's static data arrives (listen for GET_ITEM_INFO_RECEIVED). Passive — does not warm the item cache.

local info = C_Container.GetContainerItemInfo(0, 1)
if info then
    print(info.itemName, info.stackCount, info.quality, info.hyperlink)
end

C_Container.HasContainerItem(bagIndex, slotIndex)

Returns true if the given bag slot currently holds an item, false otherwise (empty slot or out-of-range indices). The occupancy sibling of GetContainerItemID — same slot resolution, but a plain presence check.

  • bagIndex = 0 — the player's main backpack; 1..4 — equipped bag slots.
  • slotIndex — 1-based.

Presence only: it does not require the item's static data to be cached, so it returns true for a just-looted item even before GetContainerItemID / GetItemInfo resolve for it.

if C_Container.HasContainerItem(0, 1) then
    -- backpack slot 1 is occupied
end

GetItemCooldown(itemInfo) / C_Container.GetItemCooldown(itemID)

Returns (startTime, duration, enable) for the cooldown of the spell triggered by the item's ON_USE effect. Direct-by-ID variant of the stock GetContainerItemCooldown(bag, slot) — no slot reference required.

-- Hearthstone (6948), right after using it:
/dump GetItemCooldown(6948)
-- 732120.014, 3600, 1  (startTime in GetTime() seconds, 60 min duration, ready)

-- Item with no ON_USE effect (or invalid itemID) returns all zeros.
/dump GetItemCooldown(2589)   -- Linen Cloth → 0, 0, 0
Field Notes
startTime GetTime()-compatible seconds when the cooldown started. 0 if no cooldown.
duration Cooldown length in seconds. 0 if no cooldown.
enable 1 for "ready or counting down" (normal state); 0 for "used but cooldown hasn't started yet" (the potion-in-combat case).

Input shapes:

  • GetItemCooldown(itemInfo) — accepts itemID, item:N / chat-link hyperlink, numeric string, or item name (resolved via the shared Item::Arg helper, same chain C_Item.GetItemCount etc. use).
  • C_Container.GetItemCooldown(itemID) — the namespaced signature accepts number / hyperlink but not spell name (per the spec "will not accept an itemlink or name", but link parsing falls out of the shared Item::Arg::Resolve for free, so we accept it).

Routes through FUN_ITEM_QUERY_COOLDOWN (0x006E2ED0) which finds the item's ON_USE spell slot in its ItemStats_C record and queries that spell's cooldown via the same manager player-spell cooldowns use.

C_Container.GetContainerItemDurability(containerIndex, slotIndex)

Bag/bank variant of GetInventoryItemDurability. Same (current, maximum) return shape and the same "nothing for items without durability" rule.

current, maximum = C_Container.GetContainerItemDurability(containerIndex, slotIndex)
  • containerIndex = 0 — the player's main backpack.
  • containerIndex = 1..4 — the player's equipped bag slots.
  • containerIndex = -1, 5..11 — bank-frame slots, only addressable while the bank window is open.
  • slotIndex — 1-based, capped at the bag's actual slot count.
for slot = 1, GetContainerNumSlots(0) do
    local cur, max = C_Container.GetContainerItemDurability(0, slot)
    if cur then
        -- item at backpack slot has durability
    end
end

Goes through the same bag-resolve chain C_Container.GetContainerItemID uses (engine's PackBagSlot → GetItemBySlot), then reads the same durability fields off the descriptor.

C_Container.GetContainerItemRepairCost(containerIndex, slotIndex)

Bag/bank variant of GetInventoryItemRepairCost. Same copperCost single-int return and the same "0 for nothing to repair" semantics.

copperCost = C_Container.GetContainerItemRepairCost(containerIndex, slotIndex)

containerIndex accepts the same values as C_Container.GetContainerItemDurability (0 = backpack, 1..4 = equipped bags, -1/5..11 = bank, etc.).

for slot = 1, GetContainerNumSlots(0) do
    local cost = C_Container.GetContainerItemRepairCost(0, slot)
    if cost > 0 then
        -- broken/damaged item sitting in the backpack
    end
end

Useful for "repair only items above N copper" smart-repair logic without scanning tooltips. A ClassicAPI extension.

C_Container.GetContainerItemCharges(containerIndex, slotIndex)

Per-slot equivalent of C_Item.GetItemCount's includeCharges=true mode — returns the total uses available in this single slot, where GetItemCount totals the same value across every matching slot.

uses = C_Container.GetContainerItemCharges(containerIndex, slotIndex)

containerIndex accepts the same values as C_Container.GetContainerItemDurability (0 = backpack, 1..4 = equipped bags, -1/5..11 = bank, etc.). slotIndex is 1-based.

Math is stack * usesPerItem, where usesPerItem is abs(SPELL_CHARGES[0]) for items with a negative charges field (consume-on-use: wands, healthstones, mana gems, sapper charges) and 1 for everything else. Worked examples:

Slot contents Stack Raw charges Returns
Wand of Decay at 50 charges 1 -50 50
Wand at 1 charge 1 -1 1
Healthstone 1 -1 1
Stack of 20 water 20 -1 20
Stack of 5 mana potions 5 -1 5
Hearthstone (cooldown-only) 1 0 1
Any other item in the slot 1 0 1

Returns nil only when the slot is empty.

-- "how many charges does my wand have left?"
local charges = C_Container.GetContainerItemCharges(0, 1)
print("Wand charges remaining: " .. charges)

-- "how many drinks across the bags?" — same as GetItemCount(water, false, true)
local total = 0
for bag = 0, 4 do
    for slot = 1, GetContainerNumSlots(bag) do
        if C_Container.GetContainerItemID(bag, slot) == waterID then
            total = total + C_Container.GetContainerItemCharges(bag, slot)
        end
    end
end

A ClassicAPI extension (addons otherwise read charges off tooltip text). Useful when you need a per-slot number rather than GetItemCount's rollup — e.g. to display "X charges" on the slot UI for a wand without re-walking every other matching item.

C_Container.GetContainerNumFreeSlots(bagID)

Returns the number of empty slots in the given bag, plus the bag's BagFamily bitfield (the type of items the bag is restricted to).

numberOfFreeSlots, bagType = C_Container.GetContainerNumFreeSlots(bagID)
  • bagID = 0 — the player's main backpack. Always reports (freeCount, 0) — the backpack is unfamilied (general-purpose).
  • bagID = 1..4 — the player's equipped bag slots. Slot count and bagType come from the bag item's cached ItemStats_C record — m_containerSlots and m_bagFamily respectively. If no bag is equipped (or the item somehow isn't cached): (0, 0).
  • Other bagID values (bank, keyring, out-of-range): (0, 0). Always returns two values, never nil.
local free, bagType = C_Container.GetContainerNumFreeSlots(0)
-- free = number of empty slots in backpack, bagType = 0

for bag = 0, 4 do
    local free = C_Container.GetContainerNumFreeSlots(bag)
    -- ...
end

bagType is derived for specialty bags. Bags leave the raw BagFamily field empty in Turtle's data (Soul Bag, Herb Bag, quiver, ammo pouch, … all read 0). We fall back to deriving the family from the bag's class + subclass, so a Soul Bag reports 0x4 here, a quiver 0x1, an ammo pouch 0x2 — the same values C_Item.GetItemFamily reports for both the bag and the items it holds. This also means CalculateTotalNumberOfFreeBagSlots now correctly excludes specialty bags from the general-purpose free-slot total (it previously counted them, since the raw field was 0). Note the Turtle custom families (Meat/Fish/Leather/Mining, 0x200–0x1000) collide with the standard bit meanings — see the GetItemFamily family table for the caveat.

The bitmask encoding. bagType is the bit position (1 << (familyID - 1)), not the raw stored familyID, so callers can bitwise-AND with itemFamily values from C_Item.GetItemFamily directly. We convert internally — see that function's notes for the encoding-shift story.

Implementation walks the bag using Item::Location::ResolveBag internally and counts slots that resolve to a null CGItem * (i.e., empty). Cross-checked in-game against a manual C_Container.GetContainerItemID walk; counts match.

C_Container.GetContainerFreeSlots(bagID)

Returns which slots in a bag are empty, as a table of slot numbers in ascending order.

freeSlots = C_Container.GetContainerFreeSlots(bagID)
  • bagID = 0 — the player's main backpack.
  • bagID = 1..4 — the player's equipped bag slots. If no bag is equipped there, the call returns nothing.
  • Other bagID values (bank, keyring, out of range) return nothing.

A full bag returns an empty table. A bag that is not there returns nothing at all, so an empty table and a missing bag stay easy to tell apart:

local freeSlots = C_Container.GetContainerFreeSlots(bagID)
if freeSlots then
    for _, slot in ipairs(freeSlots) do
        -- slot is empty
    end
end

Use C_Container.GetContainerNumFreeSlots when you only need how many there are.

C_Container.GetContainerItemQuestInfo(containerIndex, slotIndex)

Returns the quest state of one bag slot, as a single table.

questInfo = C_Container.GetContainerItemQuestInfo(containerIndex, slotIndex)

The table holds three fields:

  • isQuestItem — true when the item is a quest item.
  • questID — the quest the item begins, or nil when it begins no quest.
  • isActive — true when the quest in questID is already in the player's quest log.

questID is the quest the item starts, not a quest the item counts towards. Together the three fields say which marker a bag button should show: a questID the player has not accepted yet takes the exclamation mark, and either an accepted questID or a plain quest item takes the question-mark border.

local questInfo = C_Container.GetContainerItemQuestInfo(bag, slot)
if questInfo.questID and not questInfo.isActive then
    -- this item starts a quest the player has not picked up
elseif questInfo.questID or questInfo.isQuestItem then
    -- quest-related item
end

The table always comes back, so the fields are safe to read directly. An empty slot reports isQuestItem = false, no questID, and isActive = false.

The first read of an item can be blank. An item whose data has not arrived from the server yet reports the empty answer above, and asks for that data. Read it again after GET_ITEM_INFO_RECEIVED or the next BAG_UPDATE and it will be right.

C_Container.GetContainerItemEquipmentSetInfo(containerIndex, slotIndex)

Returns whether the item in a bag slot belongs to an equipment set, and the names of the sets it belongs to.

inSet, setList = C_Container.GetContainerItemEquipmentSetInfo(containerIndex, slotIndex)
  • inSet — true when the item is part of at least one equipment set.
  • setList — the set names joined with ", ", in the order the sets are stored. nil when the item is in no set.
local inSet, setList = C_Container.GetContainerItemEquipmentSetInfo(bag, slot)
if inSet then
    GameTooltip:AddLine("Equipment Sets: " .. setList)
end

The match is on the exact item, not on the kind of item. An equipment set remembers the individual item that was saved into it, so a second copy of the same item in another slot reports false while the saved one reports true. Reorganising your bags does not change the answer: the item keeps its identity wherever it is stored.

An empty slot, and a slot in a bag you do not have, both report false.

C_Container.CalculateTotalNumberOfFreeBagSlots()

Returns the total number of free slots across the player's general-purpose bags — backpack plus any equipped bags 0..4 whose BagFamily is 0. Specialty bags (quivers, soul bags, herb bags, …) are excluded, matching FrameXML's implementation:

local total = 0
for bag = 0, 4 do
    local free, family = C_Container.GetContainerNumFreeSlots(bag)
    if family == 0 then total = total + free end
end
-- total == C_Container.CalculateTotalNumberOfFreeBagSlots()

Takes no arguments.

Same sparse-BagFamily caveat as GetContainerNumFreeSlots: because the server data leaves most bags' family field at 0, specialty bags that would normally be excluded are counted here. The result stays consistent with the per-bag GetContainerNumFreeSlots values it sums.

C_Container.IsContainerItemOpenable(containerIndex, slotIndex)

Positional-arg wrapper for C_Item.IsItemOpenable against a bag/slot pair. Same (isOpenable, canOpen) tuple — see the linked section for full semantics. Both returns are nil for empty slots or items whose data hasn't been cached yet.

-- Sweep main backpack for openables
for slot = 1, GetContainerNumSlots(0) do
    if C_Container.IsContainerItemOpenable(0, slot) then
        -- ...
    end
end

Same cache-warming caveat as the underlying C_Item.IsItemOpenable — a freshly-seen item may report false for one call while the SMSG_ITEM_QUERY_SINGLE round-trips. Bag-resident items are almost always already cached.

C_Container.PlayerHasHearthstone()

Returns the itemID of the hearthstone if one is in the player's bags, or nil otherwise.

itemID = C_Container.PlayerHasHearthstone()
if C_Container.PlayerHasHearthstone() then
    -- player can hearth
end

Match logic. An item counts as a hearthstone if either:

  1. Its itemID is 6948 (the standard Hearthstone — fast path, no cache lookup needed), or
  2. Its on-use spell is spell 8690 (the "Hearthstone" cast itself).

Rule 2 lets custom servers (Turtle WoW, etc.) ship reskinned hearthstone items with different itemIDs and still have them recognized — as long as their on-use is the Hearthstone spell. The return is the actual matched itemID, not a hardcoded constant, so code that wants to do something with the found id (SetItemRef for chat-linking, GetItemInfo for the item's name, etc.) gets the right value for any variant.

Cache dependency for rule 2. The on-use-spell check requires the item's ItemStats_C record to be in the local cache. Items currently in the player's bags are always cached (the engine pre-fills the cache during bag sync), so the check is reliable for this code path. The fallback to rule 1 (itemID equality) covers the moment-of-login window before the cache is fully populated.

Walks bags 0..4 via the same chain C_Container.GetContainerItemID uses internally. Stops on first match.

C_Container.UseHearthstone()

Locates the hearthstone in bags and uses it. Returns true if the hearthstone was found and the use call dispatched, false if no hearthstone is in bags.

used = C_Container.UseHearthstone()
if not C_Container.UseHearthstone() then
    print("No hearthstone in bags!")
end

true doesn't guarantee the cast started. The return reflects "we had a hearthstone to try with and called the engine's UseContainerItem on it." Whether the cast actually starts depends on cooldown, combat, movement, etc. — same downstream rules as calling UseContainerItem(bag, slot) manually. If you need to know whether the hearth completed, listen for the appropriate cast events (SPELLCAST_START / SPELLCAST_STOP).

Internally locates the hearthstone with the same walk PlayerHasHearthstone uses and hands the resulting CGItem * pointer to the engine's by-pointer use primitive at 0x005D8D00 — the same fallback path Script_UseContainerItem ends up at after every special cursor-mode branch (repair vendor, spell-cast targeting, drop-on-bag) is skipped. Bypassing the dispatcher avoids the Lua-stack roundtrip of pushing (bag, slot) only for Script_UseContainerItem to re-parse and re-resolve the same item.

C_Container.SwapItems(srcBag, srcSlot, dstBag, dstSlot)

Atomically swaps two bag slots on the server, no cursor involved. Returns true on send, false for bad args (missing bag, out-of-range slot, empty source).

-- Move slot 3 of bag 1 to slot 7 of the backpack
C_Container.SwapItems(1, 3, 0, 7)

-- Swap two backpack slots
C_Container.SwapItems(0, 1, 0, 2)

Bag IDs match the rest of C_Container.*:

  • 0 — backpack
  • 1..4 — equipped bags
  • -1 — main bank (24 slots; requires the bank window to be open)
  • 5..10 — equipped bank bags (requires the bank window to be open)

Slots are 1-based. The engine accepts both directions:

  • Same container (e.g. backpack ↔ backpack, bag1 ↔ bag1, bank ↔ bank) sends opcode 0x10D CMSG_SWAP_INV_ITEM with the two linear slot bytes.
  • Cross-container (e.g. backpack ↔ bag1, bag2 ↔ bank, bank ↔ bank bag) sends opcode 0x10C CMSG_SWAP_ITEM with (srcBag, srcSlot, dstBag, dstSlot).

Destination empty becomes a move; destination occupied becomes an atomic swap.

Bank slots require the bank window to be open. The engine doesn't sync bank-side CGContainers or bank inventory entries before the first bank interaction, so any bank EncodeBagSlot path returns false until then. Same constraint as C_Container.GetContainerItemInfo on bank IDs.

ClassicAPI-only. There is no other direct swap-two-slots call — addons otherwise drive the cursor with two PickupContainerItem calls in sequence. This single-call form bypasses the cursor entirely (same path C_EquipmentSet.UseEquipmentSet uses for its swap chain).

Send is fire-and-forget. Server confirms via the normal BAG_UPDATE / SMSG_INVENTORY_CHANGE_FAILURE flow; this call does not wait for that round-trip.

C_Container.MoveItem(srcBag, srcSlot, dstBag, dstSlot, count)

Atomic split-and-place: move exactly count items from src to dst, no cursor involvement. Returns true on send, false on bad args (missing bag, out-of-range slot, empty source, count < 1, or count > 255).

-- Split 5 items off backpack slot 1 into backpack slot 2
C_Container.MoveItem(0, 1, 0, 2, 5)

-- Top up a partial stack by moving 3 items from another stack
C_Container.MoveItem(1, 4, 0, 7, 3)

Server semantics are all-or-nothing — there is no partial-move form:

  • dst empty → places count there (clean split).
  • dst holds the same item & dstCount + count ≤ maxStack → merges count into dst.
  • dst holds a different item, or the merge would overflow → server rejects entirely (source untouched, SMSG_INVENTORY_CHANGE_FAILURE fires).

Use this instead of C_Container.SwapItems when you want to consolidate partial stacks — SwapItems swaps the whole slots, which isn't what you want when both slots hold the same item but one has a partial stack.

Bank IDs (-1, 5..10) work here too with the same bank-window constraint as SwapItems.

vs. the cursor approach. There is no other direct one-call move — addons otherwise string SplitContainerItem(bag, slot, count) + PickupContainerItem(dstBag, dstSlot) together to drive the cursor. This bundles the same two-step into one packet (CMSG_SPLIT_ITEM, opcode 0x10E), so the cursor is never touched.

Send is fire-and-forget (same as SwapItems).

C_Container.AutoStoreItem(srcBag, srcSlot [, dstBag])

Moves an item and lets the server pick the destination slot. Returns true on send, false for bad args (missing bag, out-of-range slot, empty source, or a source and destination the call cannot serve).

The server does not just look for an empty slot. It first merges the item into existing stacks of the same item, and splits it across several of them when that is what fits. It places a remainder in a free slot only if some is left over. So one call consolidates a partial stack:

-- Two partial stacks of item 12662: slot 1 holds 5, slot 2 holds 3.
C_Container.AutoStoreItem(0, 2)
-- Slot 1 now holds 8, and slot 2 is empty.

-- Confine the item to bag 2
C_Container.AutoStoreItem(0, 5, 2)

dstBag says where you want the item, and defaults to 0:

  • 0 — anywhere in the main inventory that it fits. This means the whole inventory, not the backpack. The backpack cannot be named separately as a destination.
  • 1..4 — that equipped bag only.
  • -1 — anywhere in the bank that it fits.

Deposits and withdrawals both work, and both merge on arrival:

-- Deposit, merging into stacks already in the bank
C_Container.AutoStoreItem(0, 3, -1)

-- Withdraw bank slot 5 back into the bags
C_Container.AutoStoreItem(-1, 5, 0)

A bank item always travels back to your bags, so you cannot ask for a new slot inside the bank. These three combinations are accepted, and every other pair returns false:

Source dstBag Result
inventory 0..4 stays in the inventory
inventory -1 goes to the bank
bank 0 comes back to the inventory

To consolidate stacks inside the bank, use C_Container.MoveItem for each pair. Individual bank bags (5..10) are not valid destinations.

Bank slots need the bank window open, the same as SwapItems and MoveItem.

Prefer this over merging stacks yourself. This call needs no stack-size lookup, because the server decides what fits. A merge you compute in Lua has to size each stack first with C_Item.GetItemMaxStackSizeByID, which returns nil until that item's data has arrived. A merge written that way silently skips any stack it cannot size.

There is no way to name the destination slot, which is the point of the call. Use C_Container.SwapItems or C_Container.MoveItem when you need to choose it.

Send is fire-and-forget (same as SwapItems and MoveItem).

C_Container.SortBags()

Arranges the items in your bags. Takes no arguments and returns nothing.

The work spans several frames, because every item move is a request to the server. A call made while a sort is still running is ignored, so a held keybind cannot stack them up.

Partial stacks are combined first. Then items are placed in this order, starting from the first slot:

  1. Hearthstone
  2. Weapons and armor, best quality first
  3. Consumables
  4. Reagents
  5. Trade goods
  6. Quest items
  7. Everything else, best quality first
  8. Junk (gray items)

Junk fills from the last slot backward, so it collects away from everything else. Within each group, items sort by type, then subtype, then name, and fuller stacks come first.

A specialty bag keeps only what it accepts, so a quiver holds ammunition and nothing else. Items that do not fit a specialty bag go to your general bags.

C_Container.SortBags()

An item that is still loading stays put. Until an item's data arrives from the server, it has no type, quality or name to group it by. The sort leaves such an item alone, and keeps other items out of its slot. Call C_Container.SortBags() again once the data arrives. This is most likely just after you log in.

This order is ClassicAPI's own. To sort a different way, build it from C_Container.SwapItems, C_Container.MoveItem and C_Container.AutoStoreItem.

C_Container.SortBankBags()

Arranges the items in your bank. Uses the same order as C_Container.SortBags(). Takes no arguments and returns nothing.

Open the bank first. The server refuses bank moves while the bank is closed, so a call made with the bank closed does nothing at all.

Only one sort runs at a time. A call made while a bag sort or another bank sort is still running is ignored.

C_Container.SortBankBags()

Stacks combine less reliably here than in your bags. To combine stacks inside the bank, the client has to work out what fits, and that needs each item's max stack size. Until an item's data arrives from the server that number is unknown, so a stack the client cannot measure is left alone. Sorting your bags does not have this limit.

C_Container.GetSortBagsRightToLeft() / C_Container.SetSortBagsRightToLeft(enable)

Sets which end of your bags a sort fills from. Get returns isEnabled. Set returns nothing, and applies to the next sort rather than re-sorting straight away.

When it is on, the sorted items start at the last slot of the last bag, and junk collects at the first slot of the first bag. Both ends move together, so junk stays at the opposite end from everything else.

C_Container.SetSortBagsRightToLeft(true)
C_Container.SortBags()

The setting is saved, so it survives logging out. It is stored as the sortBagsRightToLeft console variable, which GetCVar also reads.

C_Container.GetBackpackAutosortDisabled() / C_Container.SetBackpackAutosortDisabled(disable)

Keeps the backpack out of C_Container.SortBags(). Get returns whether it is disabled. Set returns nothing, and applies to the next sort rather than re-sorting straight away.

While it is on, a sort leaves every item in the backpack where it is and arranges only the equipped bags.

C_Container.SetBackpackAutosortDisabled(true)
C_Container.SortBags() -- bags 1..4 only

The setting is saved, so it survives logging out. It is stored as the backpackAutosortDisabled console variable, which GetCVar also reads.

C_Container.GetBankAutosortDisabled() / C_Container.SetBankAutosortDisabled(disable)

The same setting for the bank: it keeps the main bank container out of C_Container.SortBankBags(), leaving only the bank bags to be arranged.

C_Container.SetBankAutosortDisabled(true)
C_Container.SortBankBags() -- bank bags only

Stored as the bankAutosortDisabled console variable.

Creature

C_CreatureInfo.GetCreatureID(guid)

Returns the creature template / NPC id for a unit GUID, or nil. The id is the same one the game uses to look up the creature's name, and the same one Wowhead and Turtle's database show in their NPC pages.

C_CreatureInfo.GetCreatureID(UnitGUID("target"))   -- 1842 for Hogger
C_CreatureInfo.GetCreatureID(UnitGUID("pet"))      -- pet's creature template ID
C_CreatureInfo.GetCreatureID(UnitGUID("player"))   -- nil (no entry on players)

While the unit is in view, the id is read live from the unit, so it always matches the name you see. Some spawn points pick one of several templates, and a respawn can pick a different one. When such a unit is out of view, the id comes from the GUID instead, and that id is the spawn point's first template, which can differ from the unit's current name.

Accepts creature GUIDs (0xF130xxxx…) and pet GUIDs (0xF140xxxx…). Returns nil for:

  • non-string input or malformed GUIDs
  • player GUIDs — a player carries no template
  • a pet that is out of view — a pet GUID does not carry its template
  • game-object / dynamic-object / corpse / item GUIDs — C_CreatureInfo doesn't surface entry IDs for these even though the bits are in the same range; addons that need them can shift the raw GUID themselves ((guid >> 24) & 0xFFFFFF)
  • entry IDs of 0 — the engine never assigns 0; treated as "no info"

The standard 16-digit ("0xHHHHHHHHLLLLLLLL") and 8-digit ("0xLLLLLLLL") GUID-string forms are both accepted, the same forms the C_PlayerInfo.GUIDIs* functions take.

The creatureID this returns is exactly what C_CreatureInfo.GetCreatureInfoByID takes — GetCreatureInfoByID(GetCreatureID(UnitGUID("target"))) gives the target's cached name/type/family/rank.

C_CreatureInfo.GetCreatureInfoByID(creatureID)

Reads the client-side creature cache (creaturecache.wdb, fed by SMSG_CREATURE_QUERY_RESPONSE) for an NPC/creature by ID and returns a table — or nil if it isn't cached:

local info = C_CreatureInfo.GetCreatureInfoByID(61332)
-- {
--   creatureID = 61332,
--   name       = "Misthoof Stag",
--   subName    = "",     -- title/subtitle ("" if none)
--   type       = 1,      -- CreatureType: 1=Beast, 7=Humanoid, …
--   family     = 0,      -- CreatureFamily
--   rank       = 0,      -- 0=normal, 1=elite, 2=rareelite, 3=worldboss, 4=rare
--   displayID  = 10957,  -- model display ID
-- }

This is a synchronous peek — it returns data only for creatures already cached (seen this session, or loaded from creaturecache.wdb at login), and nil otherwise. Unlike UnitCreatureType/ UnitClassification, which only work on a live unit token, this answers for any creatureID you have cached — even one not currently in the world.

Reads through the engine's generic cache _GetRecord (with no callback, so it's a pure lookup — no network query) and pulls the fields straight off the cached data block. Field offsets verified against the binary (rank read by the engine's classification helper at [block+0x20]) and real creaturecache.wdb rows (Misthoof Stag→type 1, Nordrassil Nymph→type 7 rank 1, Greathorn Hunter→family 26).

For a creature that isn't cached yet, call RequestLoadCreatureByID first and read it once CREATURE_DATA_LOAD_RESULT fires.

C_CreatureInfo.RequestLoadCreatureByID(creatureID)

Asynchronously fetches a creature that isn't in the cache yet — issues SMSG_CREATURE_QUERY and fires CREATURE_DATA_LOAD_RESULT(creatureID, success) when the response lands, at which point GetCreatureInfoByID returns its data. The same shape as C_Item.RequestLoadItemData.

local f = CreateFrame("Frame")
f:RegisterEvent("CREATURE_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
    -- 1.12 passes event args as globals: arg1 = creatureID, arg2 = success
    if arg2 then
        local info = C_CreatureInfo.GetCreatureInfoByID(arg1)
        -- info.name, info.type, info.rank, …
    end
end)

C_CreatureInfo.RequestLoadCreatureByID(10184)   -- Onyxia → fires the event,
                                                --  then GetCreatureInfoByID works

Returns true if the request was accepted (false only on bad input or a full pending set). If the creature is already cached, the event fires synchronously with success = true. On a query that never resolves it fires once with success = false after a timeout.

Implementation: a shared Cache::QueryLoad dispatcher hooks the engine's generic cache response parser (FUN_00556E20) once and routes by cache instance — the same hook serves the gameobject cache too. The creature/gameobject caches use this generic parser; the item and quest caches have their own, so there's no hook collision. Verified in-game: RequestLoadCreatureByID(10184) on an uncached Onyxia fired the event and populated the cache (type 2 Dragonkin, rank 3 world boss).

C_CreatureInfo.GetRaceInfo(raceID)

Localized race name + non-localized token for a race id, read from ChrRaces.dbc (always loaded, so this works for any id at any time — no unit token required, unlike UnitRace). Returns a RaceInfo table, or nil for a non-numeric / non-positive id or one with no row.

local info = C_CreatureInfo.GetRaceInfo(4)
-- { raceName = "Night Elf", clientFileString = "NightElf", raceID = 4 }
Field Type Notes
raceName string Localized display name (client's active locale).
clientFileString string Non-localized token ("Human", "NightElf", "Scourge") — the same string UnitRace returns as its 2nd value, used for texture paths.
raceID number Echo of the input id.

C_CreatureInfo.GetClassInfo(classID)

Localized class name + locale-independent token for a class id, read from ChrClasses.dbc. Returns a ClassInfo table, or nil for a non-numeric / non-positive id or one with no row.

local info = C_CreatureInfo.GetClassInfo(1)
-- { className = "Warrior", classFile = "WARRIOR", classID = 1 }
Field Type Notes
className string Localized display name (client's active locale).
classFile string Locale-independent token ("WARRIOR", "MAGE") — the same string UnitClass returns as its 2nd value, and the key used for class colors / icon coords.
classID number Echo of the input id.

C_CreatureInfo.GetCreatureFamilyInfo(creatureFamilyID)

Info for a pet/beast family (CreatureFamily.dbc row), read from the always-loaded client DBC. Returns a CreatureFamilyInfo table, or nil for a non-numeric / non-positive / unused id.

local info = C_CreatureInfo.GetCreatureFamilyInfo(27)
-- { id = 27, name = "Wind Serpent",
--   iconFile = "Interface\\Icons\\Ability_Hunter_Pet_WindSerpent" }
Field Type Notes
id number Echo of the input id.
name string Localized family name (client's active locale).
iconFile string Icon texture path. The DBC stores the path, so this is a string usable directly with texture:SetTexture(...). "" for families with no icon (warlock pets: Imp, Voidwalker, Succubus, Felhunter, …).

C_CreatureInfo.GetCreatureFamilyIDs()

Array of every populated CreatureFamily.dbc id, in ascending order. The id space is sparse (unused rows like 10, 13, 14, 22 are skipped), so this is the load-bearing piece for iterating families — each element round-trips with GetCreatureFamilyInfo.

local ids = C_CreatureInfo.GetCreatureFamilyIDs()
-- { 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 15, 16, ... }
for _, id in ipairs(ids) do
    local fam = C_CreatureInfo.GetCreatureFamilyInfo(id)
    -- fam.name, fam.iconFile
end

C_CreatureInfo.GetFactionInfo(raceID)

Faction-group info for a race (Alliance or Horde), read from the client DBCs. Returns a FactionInfo table, or nil for a non-numeric / non-positive / unknown race, or a race with no named group.

local info = C_CreatureInfo.GetFactionInfo(1)   -- Human
-- { name = "Alliance", groupTag = "Alliance" }
C_CreatureInfo.GetFactionInfo(2)   -- Orc  → { name = "Horde", groupTag = "Horde" }
Field Type Notes
name string Localized faction-group name ("Alliance", "Horde", or the locale's translation).
groupTag string Locale-independent tag: "Alliance" or "Horde".

This is the by-raceID form of the stock UnitFactionGroup(unit) (which returns groupTag, name for a live unit) — same resolution, no unit required. It mirrors that engine function's chain exactly: race → FactionTemplate group mask → FactionGroup.dbc, selecting the row whose bit the mask sets and whose localized name is non-empty (that non-empty test is how the engine skips the always-present "Player" bit and the nameless "Monster" row to land on Alliance / Horde).

C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)

Info for a creature type (CreatureType.dbc row), read from the always-loaded client DBC. Returns a CreatureTypeInfo table, or nil for a non-numeric / non-positive / unused id.

local info = C_CreatureInfo.GetCreatureTypeInfo(7)
-- { id = 7, name = "Humanoid" }
Field Type Notes
id number Echo of the input id.
name string Localized type name (client's active locale).

The type ids: 1 Beast, 2 Dragonkin, 3 Demon, 4 Elemental, 5 Giant, 6 Undead, 7 Humanoid, 8 Critter, 9 Mechanical, 10 Not specified, 11 Totem. Pairs with UnitCreatureTypeID (id → name) and GetCreatureTypeIDs.

C_CreatureInfo.GetCreatureTypeIDs()

Array of every CreatureType.dbc id (contiguous 1..11), each round-tripping with GetCreatureTypeInfo.

local ids = C_CreatureInfo.GetCreatureTypeIDs()
-- { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }

Currency

GetCoinTextureString(amount [, fontHeight]) / C_CurrencyInfo.GetCoinTextureString(amount [, fontHeight])

Returns the amount of copper as a money string with real coin icons — for example "12<gold> 34<silver> 56<copper>". The icons are inline |T…|t markup over the UI-MoneyIcons sprite sheet, rendered by ClassicAPI's inline-texture backport. Both names call the same C function.

Only the non-zero denominations appear, in gold → silver → copper order. An amount of 0 returns "0<copper>". A negative or fractional amount is clamped to 0 / rounded.

fontHeight sets the icon height in UI pixels. When omitted (or 0), each coin sizes itself to the font of the fontstring that shows the string — the default.

GetCoinTextureString(123456)   -- "12g 34s 56c" with coin icons
GetCoinTextureString(5)        -- "5c" with a copper icon
GetCoinTextureString(5, 24)    -- same, with a 24px coin

The per-denomination formats are the GlobalStrings GOLD_AMOUNT_TEXTURE / SILVER_AMOUNT_TEXTURE / COPPER_AMOUNT_TEXTURE, backported in the embedded !!!ClassicAPI addon's locale layer — a locale can override them.

CVar

C_CVar.GetCVarInfo(name)

Returns a cvar's value, its default, and what may be done to it. Seven values:

value, defaultValue, isStoredServerAccount, isStoredServerCharacter,
isLockedFromUser, isSecure, isReadOnly = C_CVar.GetCVarInfo(name)
Return Type Meaning
value string the current value
defaultValue string the value it was registered with
isStoredServerAccount boolean always false
isStoredServerCharacter boolean always false
isLockedFromUser boolean always false
isSecure boolean always false
isReadOnly boolean true when SetCVar will refuse to change it
local value, default = C_CVar.GetCVarInfo("gxResolution")
-- "2560x1080", "640x480"

local _, _, _, _, _, _, readOnly = C_CVar.GetCVarInfo("realmList")
-- readOnly is true

isReadOnly is the useful one: it tells you in advance whether SetCVar will work, and realmList, realmName and scriptMemory are read-only. Cvars are stored on your own machine, so the two isStoredServer returns are always false, and no cvar is locked or secure.

Returns nil for a name that is not a cvar, so you can tell "no such cvar" from "set to an empty string". Console commands are not cvars, so a command name returns nil too — C_CVar.GetCVarInfo("help") is nil, while ConsoleGetAllCommands lists both.

Any line left in Config.wtf becomes a cvar the next time the client starts, whether or not a setting by that name exists. The client keeps it, saves it again on exit, and lets you change it from the console, but Lua cannot read it. GetCVar, SetCVar and this function all return nil for one of these, so the Lua cvar functions agree with each other. ConsoleGetAllCommands does list them, because the console can reach them.

Available on the login screen as well as in-game.

C_CVar.DoesCVarExist(name)

Returns whether a cvar by that name can be read and written from Lua. A ClassicAPI extension.

C_CVar.DoesCVarExist("gxResolution")   -- true
C_CVar.DoesCVarExist("doesNotExist")   -- false
C_CVar.DoesCVarExist("help")           -- false: a console command, not a cvar

It answers the question worth asking before you call GetCVar or SetCVar, so it is true exactly when those work:

C_CVar.DoesCVarExist(name) == (C_CVar.GetCVarInfo(name) ~= nil)

That includes the Config.wtf case described above: a name the client keeps but does not implement is reported as false, because no Lua function can read it. Returns false for anything that is not a string.

C_CVar.AreCVarsLoaded()

Returns whether the client has finished loading its settings.

This is always true by the time any addon runs. Settings are read while the client starts, before there is any Lua to ask, and none of them are fetched over the network, so there is nothing to wait for. It is here so that ported code which checks it keeps working.

C_CVar.GetCVarBitfield(name, index) / C_CVar.SetCVarBitfield(name, index, value)

Read or write one bit of a cvar, so a single cvar can hold many separate true/false settings. The index counts from 1.

RegisterCVar("myAddonFlags", "0")

C_CVar.SetCVarBitfield("myAddonFlags", 1, true)
C_CVar.SetCVarBitfield("myAddonFlags", 5, true)
GetCVar("myAddonFlags")                          -- "17", bits 1 and 5
C_CVar.GetCVarBitfield("myAddonFlags", 5)        -- true
C_CVar.GetCVarBitfield("myAddonFlags", 2)        -- false

The index may be 1 to 64. GetCVarBitfield returns nil outside that range, or for a name that is not a cvar, so "no such bit" stays apart from "the bit is false".

SetCVarBitfield returns whether it worked. It returns false for an index outside the range, for a name that is not a cvar, and for a read-only cvar — the same cvars GetCVarInfo reports isReadOnly for. A successful write saves the cvar the same way SetCVar does.

Any cvar works, and a cvar you register yourself is the usual way to use these. Every bit is independent, so setting one leaves the rest alone.

C_CVar.SetTempCVar(name, value) / C_CVar.RemoveTempCVar(name)

SetTempCVar changes a cvar for this session only. The game uses the new value at once, but it does not save the value to Config.wtf. RemoveTempCVar puts back the value that the cvar had before the first SetTempCVar. Neither function returns a value.

-- Saved value of cameraDistanceMax is "15".
C_CVar.SetTempCVar("cameraDistanceMax", "40")
GetCVar("cameraDistanceMax")      -- "40"
-- After /reload or logout, Config.wtf still holds "15".
C_CVar.RemoveTempCVar("cameraDistanceMax")
GetCVar("cameraDistanceMax")      -- "15"

Use this pair for a mode that changes many cvars and later turns off, for example a gamepad mode. The saved settings of the user do not change.

  • If you call SetTempCVar two times on one cvar, RemoveTempCVar puts back the value from before the first call.
  • If SetCVar changes a cvar that has a temp value, the SetCVar value replaces the temp value. The game saves it, and RemoveTempCVar then does not change the cvar.
  • If value is nil, the cvar gets an empty string, as with SetCVar.
  • RemoveTempCVar does nothing for a cvar that has no temp value.

Both functions give the same errors as SetCVar: for a name that is not a cvar, and for a read-only cvar. Both are also available on the login and character-select screens.

C_CVar.GetCVarBool(cvar)

Returns the cvar's value coerced to a boolean, or nil if no cvar by that name is registered. The stock GetCVar returns a string.

C_CVar.GetCVarBool("gxMaximize")    -- true if the window is set fullscreen
C_CVar.GetCVarBool("CombatDamage")  -- false if floating damage text is off
C_CVar.GetCVarBool("doesNotExist")  -- nil — distinguishable from false

Coercion rules (only applied when the cvar exists):

Cvar string value Result
"1" or any non-zero numeric ("42", "-3", …) true
"true" (case-insensitive) true
"0" or empty false
Non-numeric, non-"true" ("on", "yes") false
Cvar name not registered nil

Implementation reads the cvar registry directly (engine's FUN_FIND_CVAR at 0x0063DEC0), bypassing GetCVar's Lua-error path for unknown cvars. The nil return lets callers distinguish "the cvar exists and is falsy" from "no such cvar."

Glue-state availability

C_CVar.GetCVarBool is also registered on the glue Lua state, and the engine's stock GetCVar / SetCVar / RegisterCVar / GetCVarDefault — which are otherwise exposed only in-game — are mirrored onto glue too. GlueXML patches at the login / realm / char-select screens can now read and write CVars directly. CVar storage is process-global, so writes on either state are immediately visible on the other (a SetCVar("foo", "1") from a GlueXML script will read back as "1" on the in-world side, and vice versa).

The interface memory limit

The scriptMemory cvar caps how much memory the interface may use, counted in kilobytes. Its own value is 49152, which is 48 MB.

Stage What happens
Interface memory goes over the limit The client frees what it can.
It is still over A popup asks you to disable add-ons and restart.
15 seconds later The client closes.

A value of 0 turns the check off. ClassicAPI sets 0 for you, so a large add-on set does not meet the popup, and it does that only while the cvar still holds its own value. If you picked a value yourself, your value stands.

SetCVar cannot change this cvar while you are in the world, because the client makes it read-only there. To pick your own limit, close the game and add the line yourself in WTF\Config.wtf:

SET scriptMemory "65536"

That asks for a 64 MB limit, and any value above 0 turns the check back on.

Cursor

GetCursorInfo()

Returns a tuple describing whatever the player has picked up on the cursor (item, money, spell, macro, merchant slot), or nothing when the cursor is empty. Reads the same engine globals CursorHasItem / CursorHasSpell / GetCursorMoney consult, so the answer always matches what the rest of the engine sees.

Return shape per cursor state:

Cursor holds Returns
nothing nil
a bag item (PickupContainerItem, mail/trade detach with GUID) "item", itemID, itemLink
a drag-source item (equipment slot, action bar, trade, mail) "item", itemID
money (gold-split UI, PickupPlayerMoney) "money", copperAmount
a spell from the spellbook "spell", spellbookSlot, bookType, spellID
a macro "macro", macroIndex
a merchant slot "merchant", merchantIndex

bookType is "spell" for player spells, "pet" for pet spells. spellbookSlot is the 1-based slot the spell lives at in the spellbook arrays, found by walking the player/pet books. 0 if the spellID isn't in either book (rare — passive talent-granted spells).

local kind, a, b, c = GetCursorInfo()
if kind == "item" then
    print(("cursor item: %d"):format(a))
elseif kind == "money" then
    print(("cursor money: %d copper"):format(a))
elseif kind == "spell" then
    print(("cursor spell: id=%d slot=%d (%s book)"):format(c, a, b))
elseif kind == "macro" then
    print(("cursor macro index: %d"):format(a))
end

Returned only when the cursor holds a bag item (type 1) — PickupContainerItem / PickupInventoryItem from a bag slot, mail or trade detach. The cursor stores the item's GUID in this case, so we resolve back to the live CGItem * and feed it to the engine's link builder, which produces the full per-instance form including enchant ID and random-suffix decoration:

|cff1eff00|Hitem:16791:255:0:0|h[Silkstream Cuffs]|h|r
|cff1eff00|Hitem:9877:0:767:0|h[Sorcerer Cloak of the Owl]|h|r

Not returned for drag-source items (equipment slot, action bar slot, trade slot, mail attachment — types 5/6/7/9). In those cases the cursor only stores the itemID (no GUID, no live CGItem *), so the engine's link builder isn't reachable. Derive a basic link from the itemID via GetItemInfo if you need one for display:

local kind, itemID, link = GetCursorInfo()
if kind == "item" and not link then
    link = (select(2, GetItemInfo(itemID))) or ("item:" .. itemID)
end

Types we don't surface

The engine has cursor types beyond what GetCursorInfo advertises — pet action drag, trade-slot drag, mail-attach drag, ability-bar drag from PickupAction, etc. These don't map cleanly to any GetCursorInfo string, so we return nil for them. Consumers needing to detect those can use the engine-native CursorHasItem / CursorHasSpell / CursorHasMoney and the source-side PickupX calls.

Changes to what the cursor holds are reported by the CURSOR_CHANGED event.

EquipmentSet

C_EquipmentSet.* — named sets of gear you can save and equip again with one call.

Sets belong to one character. Each character keeps its own sets in a file on disk, so a set does not follow you to another character, and a copy of your WTF folder carries its sets with it.

Overview & file format

Sets are stored in WTF\Account\<account>\<realm>\<character>\ClassicAPI_EquipmentSets.txt in a line-oriented, human-readable format:

# ClassicAPI Equipment Sets v1
set 1
  name=Tanking
  icon=INV_Shield_06
  slot 1 guid=0x0000000040000123
  slot 2 ignored
  slot 5 guid=0x0000000040000789
set 2
  name=Healing
  icon=Spell_Holy_HolyBolt
  ...

Identity is by item GUID, snapshotted at CreateEquipmentSet / SaveEquipmentSet time. UseEquipmentSet searches every player-owned container for those GUIDs and dispatches pickup→equip pairs for items that aren't already where they belong. The search reads the underlying GUID arrays directly (same trick C_Item.GetItemCount uses for its includeBank=true path), so bank items resolve even without the bank window being open — the bank gate at VAR_BANK_GATE_GUID only suppresses GetItemBySlot, not the underlying data.

One limitation worth knowing about:

  • Equipping from the bank is not supported by the protocol — UseEquipmentSet skips bank items rather than try and fail. Retrieve the items first, then re-run.

We accept icon path strings (e.g. "INV_Shield_06"), since there is no fileDataID system here. Missing or unknown icons fall back to a default.

No cap on the number of sets per character. The full list re- serializes on every mutation; a corrupted file is harmless (parse errors leave the in-memory list empty and the next save rewrites the file from scratch).

Note: this is a fresh client-side namespace. It mirrors the C_EquipmentSet.* shape where it can, but anything that requires server-side state (cross-character sharing, the "Equipment Manager" specialization tab) isn't supported.

C_EquipmentSet.CanUseEquipmentSets()

Returns true unconditionally. There is no banker/feature gate on equipment-set storage; the feature ships for every character.

C_EquipmentSet.GetNumEquipmentSets()

Returns the count of sets stored for the current character. Loads the file on first call after login.

C_EquipmentSet.GetEquipmentSetIDs()

Returns a numeric-keyed table of every setID in storage order (insertion-order; not alphabetical). Empty table when nothing's saved.

C_EquipmentSet.GetEquipmentSetID(name)

Returns the numeric setID for a set with the given name, or nil if no set by that name exists. Names are exact (case-sensitive, no trimming).

C_EquipmentSet.GetEquipmentSetInfo(setID)

Returns nine values:

name, icon, setID, isEquipped,
numItems, numEquipped, numInInventory, numMissing, numIgnored

isEquipped is true when every resolvable item in the set is in its target slot (missing items don't disqualify — useful so a set that includes a bank-stored cloak still shows as equipped after you swap in the rest). numItems excludes ignored slots; numIgnored counts them separately. Returns nothing for an unknown setID.

C_EquipmentSet.GetIgnoredSlots(setID)

Returns a numeric-keyed table of slot indices (1..19) that the set has flagged ignored. Empty table when no slots are ignored. Ignored slots are recorded per-set at save time, by reading the global IgnoreSlotForSave state — not retroactively editable on a saved set.

C_EquipmentSet.GetItemIDs(setID)

Returns a hash table { [slot] = itemID } for every set slot whose item is currently resolvable. Missing items (GUID stored but client can't find a CGItem) are omitted because the engine doesn't keep an itemID separate from the live CGItem record.

C_EquipmentSet.GetItemLocations(setID)

Returns a hash table { [slot] = locationCode }. Location codes use the same bit-packed encoding Blizzard's FrameXML EquipmentManager_UnpackLocation decodes (constants in Blizzard_FrameXMLBase/Classic/Constants.lua):

Bit/field Meaning
0x00100000 (PLAYER) Item is in a player slot (equipped or in a player bag)
0x00200000 (BAGS) Item is inside a bag (player or bank)
0x00400000 (BANK) Item is in the bank (main or bank bag)
bits 0..7 Slot (1-based) within the container
bits 8..15 Bag index — present only when BAGS bit is set

PLAYER and BANK are mutually exclusive in the encoding (the unpack uses if player elseif bank). Bank bags subtract 4 from the bagID before storing so the field fits cleanly; unpack reverses this to give back bag IDs 5..10. Composition:

Location Bits Example
Equipped (paperdoll slot 1..19) `PLAYER slot`
Backpack / player bag 1..4 `PLAYER BAGS
Main bank slot 1..28 `BANK slot`
Bank bag 5..10 `BANK BAGS

Special values returned in the table entry:

  • 1 — slot is ignored (GetIgnoredSlots lists these)
  • -1 — item is missing (was in the set, can't find now)

The packed codes pass cleanly through Blizzard's EquipmentManager_UnpackLocation if you want to use the shared-FrameXML helpers in your addon UI.

C_EquipmentSet.CreateEquipmentSet(name [, icon])

Snapshots the player's currently-equipped items into a new set and returns its setID. Honors IgnoreSlotForSave — slots flagged ignored at call time get the ignored marker instead of the equipped item's GUID.

Returns the new setID on success. Returns nothing if the name is empty or already in use.

icon defaults to "INV_Misc_QuestionMark" if omitted.

C_EquipmentSet.SaveEquipmentSet(setID [, icon])

Overwrites an existing set's contents with the player's currently- equipped items. Same ignored-slot handling as CreateEquipmentSet. If icon is provided it replaces the set's previous icon; otherwise the icon is left unchanged.

C_EquipmentSet.ModifyEquipmentSet(setID, newName)

Renames an existing set. Fails silently if the new name is empty, already in use by a different set, or the setID doesn't exist.

C_EquipmentSet.DeleteEquipmentSet(setID)

Removes the set. SetIDs are not reused — the next Create call allocates one higher than any seen.

C_EquipmentSet.IgnoreSlotForSave(slot) / UnignoreSlotForSave / IsSlotIgnoredForSave / ClearIgnoredSlotsForSave

Global "skip this slot the next time CreateEquipmentSet or SaveEquipmentSet runs" state, indexed by 1-based slot (1..19). Persists for the rest of the session; not written to the WTF file. Use it when building a set that should leave (say) the tabard slot free — set the ignore flag, then call CreateEquipmentSet, then optionally ClearIgnoredSlotsForSave() afterward.

C_EquipmentSet.EquipmentSetContainsLockedItems(setID)

Returns true if any item in the set is currently flagged "locked" by the engine — a pending pickup or use is in flight that UseEquipmentSet would race with. Reads bit 2 (0x04) of ITEM_FIELD_FLAGS for each resolvable item in the set.

C_EquipmentSet.UseEquipmentSet(setID)

Walks the set and dispatches one atomic server-side swap per item that isn't already in its target slot. Items in the bank are skipped silently (the protocol can't equip from bank). Missing items are skipped silently. Returns true if the call ran (the set existed), false otherwise.

Implementation uses the same FUN_INVENTORY_SWAP primitive C_Item.EquipItemByName uses for its explicit-slot path. Each swap is a single CMSG_SWAP_INV_ITEM (or CMSG_AUTOEQUIP_ITEM) packet that the server applies atomically — the two-cycle "ring A in slot 11, ring B in slot 12, set swaps them" case resolves in one packet because the opcode swaps both slots in a single server transaction. The cursor is never touched; an item held on the cursor when UseEquipmentSet is called stays on the cursor.

Longer dependency chains (3+ items rotating) are rare with 1.12's slot set — the realistic conflicts are paired slots (rings 11/12, trinkets 13/14, weapons 16/17), all 2-cycles.

CURSOR_UPDATE fires per item moved. The engine's swap primitive (FUN_005E0C40) runs a generic cursor-state cleanup at the end of each call, which fires CURSOR_UPDATE regardless of whether the cursor was actually touched. So one UseEquipmentSet call that moves N items will fire N CURSOR_UPDATE events in quick succession. Addons that react to CURSOR_UPDATE (cursor-attached tooltips, drag-state tracking) should debounce — or check CursorHasItem() before doing work — since most of those fires won't reflect a real cursor change. This is engine behavior, not a bug in the implementation; the old cursor-based path actually fired more (one per pickup, one per equip, one per cursor-clear).

Events

C_EventUtils.IsEventValid(eventName)

Returns true if the named event exists in the engine's event-name table and can be registered for, false otherwise. Useful for addons that gate code behind feature detection (e.g. registering for events that exist in some client builds but not others).

if C_EventUtils.IsEventValid("PLAYER_LOGIN") then ... end       -- true
if C_EventUtils.IsEventValid("COMBAT_LOG_EVENT") then ... end   -- false (not a registered event)

The check walks the engine's static event-name table at runtime, so it sees events added by any loaded DLL that injects names by overwriting .rdata strings in place — for example, on a Turtle WoW client running SuperWoWhook, IsEventValid("UNIT_CASTEVENT") returns true because SuperWoWhook patches the binary's event names with its own. This matches what frame:RegisterEvent will actually accept, which is what addon code needs to know.

GetFramesRegisteredForEvent(event)

Returns the frames currently registered for event, as multiple return values (not a table). Returns nothing when no frame is registered or the event name is unknown.

local f1, f2 = GetFramesRegisteredForEvent("PLAYER_LOGIN")
for i = 1, select("#", GetFramesRegisteredForEvent("BAG_UPDATE")) do
    local frame = select(i, GetFramesRegisteredForEvent("BAG_UPDATE"))
    print(frame:GetName())
end

Reads the engine's own subscriber chain — the exact list frame:RegisterEvent appends to — so it stays in sync with real registrations, including our AutoReserve-backed custom events. Frames created purely in C++ (nameplates, etc.) come back as their canonical wrapper, so :GetName() and other methods work; an anonymous frame's :GetName() is nil.

PLAYER_ENTERING_WORLD payload (isInitialLogin, isReloadingUi)

Adds a payload to the otherwise arg-less PLAYER_ENTERING_WORLD:

local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_ENTERING_WORLD")
f:SetScript("OnEvent", function()
    local isInitialLogin, isReloadingUi = arg1, arg2
    if isInitialLogin then       -- fresh character login (from char-select)
    elseif isReloadingUi then    -- /reload
    else                          -- zone / instance transition
    end
end)
  • isInitialLogin — true on a fresh login (including logging out to char-select and back in), nil otherwise.
  • isReloadingUi — true when the fire is caused by a /reload, nil otherwise.
  • Both nil on an instance/zone transition.

Delivered as real event args (arg1/arg2), so positional handlers work unchanged. Values are true→1 / false→nil (the engine dispatcher has no boolean), so if isInitialLogin then … reads naturally. Existing handlers that ignore the args are unaffected.

PEW is a no-arg event, so the engine broadcasts it through the FUN_FIRE_EVENT_NO_ARGS dispatcher; we intercept that (via the shared Event::SignalHook) and re-broadcast through the variadic dispatcher with the payload. The flags are derived from whether the in-game Lua state was just (re)built (login/reload) and whether we just came through the glue/login screen — no PLAYER_ENTERING_WORLD-specific engine hook.

PLAYER_TOTEM_UPDATE event

Fires with arg1 = totemSlot (1 Fire, 2 Earth, 3 Water, 4 Air) whenever a totem is dropped, expires, or is destroyed early (killed / Totemic Recall) — the companion event to GetTotemInfo.

local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_TOTEM_UPDATE")
f:SetScript("OnEvent", function()
    local slot = arg1
    local have, name, start, duration, icon = GetTotemInfo(slot)
    -- re-read the changed slot
end)

Driven by the same GetTotemInfo tracker: the drop is detected from SMSG_SPELL_GO, removal from the WorldTick object-manager scan, and the event is flushed from the tick (so handlers run in a safe context, not mid-packet). Removal detection carries up to the tracker's scan interval (~250 ms) of latency; drops fire on the next frame.

BAG_UPDATE_DELAYED event

Fires (with no payload) once per frame in which any BAG_UPDATE fired. Register for BAG_UPDATE_DELAYED instead of BAG_UPDATE and rescan once per frame regardless of how many updates fired.

local f = CreateFrame("Frame")
f:RegisterEvent("BAG_UPDATE_DELAYED")
f:SetScript("OnEvent", function() RescanMyInventory() end)

A trade with 6 stacks (12 BAG_UPDATEs, all processed in one frame) produces exactly 1 BAG_UPDATE_DELAYED at the end of the frame.

Implemented by three hooks, none in regions other DLLs touch:

  • FUN_004F91A0 / FUN_004F9370 (bag subsystem at 0x004F9xxx) — each just sets a g_pending flag, no fire
  • FUN_0066FD50 (engine's per-frame world-subsystem update at 0x0066xxxx, deep in the rendering pipeline, only one caller in the entire binary) — drains the flag at the tail of the world tick

The world-tick hook is a tail hook (run original first, then fire DELAYED), so the fire happens after every other per-frame world work completes. Addon callbacks see DELAYED after their normal event handlers.

Coverage limitation. The keyring BAG_UPDATE(-2) path goes through a separate function (FUN_004F8DB0) that uses __thiscall with an awkward register-arg shape. Keyring updates currently won't trigger BAG_UPDATE_DELAYED. Player bag (0..4) and bank (5..10) updates work normally — the 95% case.

PLAYER_EQUIPMENT_CHANGED event

Fires once per paperdoll slot change — equip, unequip, or swap:

PLAYER_EQUIPMENT_CHANGED: equipmentSlot, hasCurrent
  • equipmentSlot (number) — the 1-based inventory slot that changed (1 = head … 19 = tabard; same numbering as GetInventoryItemLink("player", slot)).
  • hasCurrent — 1 when the slot now holds an item, nil when it's now empty (write if hasCurrent then — the event dispatcher can't push real booleans).

The stock UNIT_INVENTORY_CHANGED("player") doesn't say which slot changed; this event lets gear trackers, stat sheets, and tooltip decorators refresh exactly one slot instead of rescanning all 19.

local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
f:SetScript("OnEvent", function()
    print("slot", arg1, arg2 and "equipped" or "now empty")
end)

Fully event-driven — no polling. The DLL registers change observers for the 19 equipment GUID fields of the player descriptor through the engine's own field-observer system (the same mechanism the engine uses to watch bag slots), so the event fires exactly when the server's update packet writes a new item GUID into a slot, once per changed slot. Swapping two items (e.g. weapon ⇄ bag) fires once per affected equipment slot.

UPDATE_INVENTORY_DURABILITY event

Fires — with no payload — when an equipped item's durability changes: combat damage, resurrection penalty, repairs. The standard consumer pattern works unchanged: rescan the 19 slots with GetInventoryItemDurability on each fire.

local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_INVENTORY_DURABILITY")
f:SetScript("OnEvent", RefreshMyDurabilityHUD)

Event-driven, no polling: piggybacks on the engine's own inventory-alerts recompute — the routine behind UPDATE_INVENTORY_ALERTS (the red/yellow "broken armor man"), which the engine runs whenever an owned item's fields change, on equip changes, and at enter-world. On each recompute the DLL diffs a per-slot {item, durability} snapshot and fires once when anything actually changed — a death dinging all your armor produces one event, not nineteen. Also fires when the set of equipped items changes (swapping to a differently-damaged item updates durability UI, so consumers want the refresh anyway). Bag-carried items don't trigger it: durability UI is for equipped gear only.

HEARTHSTONE_BOUND event

Fires (with no payload) every time the player binds their hearthstone at an innkeeper — including a rebind at the same inn. Addons listen to HEARTHSTONE_BOUND and re-read GetBindLocation() to refresh whatever bind-location UI they show.

Note: the server re-sends the bind packet on map/zone transitions (behind the loading screen). Those resyncs are suppressed by gating on the engine's in-world flag, so the event fires only for an actual innkeeper bind, never on a map change.

local f = CreateFrame("Frame")
f:RegisterEvent("HEARTHSTONE_BOUND")
f:SetScript("OnEvent", function()
    local newLocation = GetBindLocation()
    -- update UI...
end)

Fires every time the player confirms a bind, including when the new bind is at the same inn as before. The event is driven by the bind ACTION (server's SMSG_BINDPOINTUPDATE), not by the area name changing.

Implemented by hooking the BINDPOINTUPDATE packet handler at FUN_005ED3C0. The handler runs in two distinct cases:

  1. Initial post-login sync — the engine has just zeroed the "bind valid" flag during character-entry init (FUN_005E2510), and this packet repopulates it. We detect this by reading the flag before the original handler runs; if it's 0, this is the sync and we suppress the event.
  2. Innkeeper rebind — the flag is already 1. Fire.

Character switch is handled automatically: the character-entry init re-zeroes the flag, so each new character's first update is also treated as sync and suppressed.

Player input-state events

Three pairs of zero-payload events fired from a single per-frame Tick::WorldTick callback that reads input state once and edge- detects each pair.

Event Fires on Source
PLAYER_STARTED_MOVING / PLAYER_STOPPED_MOVING WASD or autorun toggle UI-input controller flags at *0x00BE1148 + 0x04 (0x10 W | 0x20 S | 0x40 A | 0x80 D | 0x1000 autorun)
PLAYER_STARTED_TURNING / PLAYER_STOPPED_TURNING Mouselook bit held AND character body yaw is changing Mouselook bit 0x01 + per-frame delta on CGPlayer + 0x9C4 (body yaw, radians)
PLAYER_STARTED_LOOKING / PLAYER_STOPPED_LOOKING Free-look bit held AND camera-relative yaw is changing Free-look bit 0x02 + per-frame delta on [*0x00B4B2BC + 0x65B8] + 0xF0 (camera yaw, radians)
local f = CreateFrame("Frame")
for _, ev in ipairs({
    "PLAYER_STARTED_MOVING", "PLAYER_STOPPED_MOVING",
    "PLAYER_STARTED_LOOKING", "PLAYER_STOPPED_LOOKING",
    "PLAYER_STARTED_TURNING", "PLAYER_STOPPED_TURNING",
}) do
    f:RegisterEvent(ev)
end
f:SetScript("OnEvent", function(_, event) print(event) end)

Key-state semantics for MOVING: STOPPED_MOVING fires the instant the movement keys release, even if the character is still airborne from a jump (verified empirically). For an "is the character actually displacing this frame" signal, use GetUnitSpeed("player") and watch the first return.

Latched semantics for TURNING and LOOKING: each pair fires exactly once per RMB / LMB hold. STARTED waits until both the mouse-button bit is held AND the relevant yaw has actually changed ("camera has moved" semantics — clicking without dragging doesn't fire). STOPPED fires when the button bit clears (RMB / LMB release). The latch stays on through any drag- stop-drag motion within a single hold; no spurious flapping.

The camera-relative yaw at [camera + 0xF0] stays at 0 during RMB-mouselook (camera rotates with the character, so its offset from the character doesn't change), so TURNING and LOOKING are cleanly separable: RMB only fires TURNING, LMB only fires LOOKING.

Implementation: single subscriber on the shared Tick::WorldTick registry (same MinHook on FUN_0066FD50 shared with BAG_UPDATE_DELAYED). When any of the input controller / player / camera pointers is null (pre-login, loading screen, character select), all states reset so the next valid tick doesn't fire spurious STOPPED transitions.

GLOBAL_MOUSE_DOWN / GLOBAL_MOUSE_UP events

Fires on every raw mouse-button press or release while WoW has focus. Payload is the button identifier string.

local f = CreateFrame("Frame")
f:RegisterEvent("GLOBAL_MOUSE_DOWN")
f:RegisterEvent("GLOBAL_MOUSE_UP")
f:SetScript("OnEvent", function(_, event, button)
    print(event, button)  -- "GLOBAL_MOUSE_DOWN", "LeftButton"
end)
Payload Source
"LeftButton" VK_LBUTTON
"RightButton" VK_RBUTTON
"MiddleButton" VK_MBUTTON
"Button4" VK_XBUTTON1
"Button5" VK_XBUTTON2

Distinct from per-frame OnMouseDown scripts — these fire even when the click misses every UI frame (clicks in the world / on nothing). Distinct from PLAYER_STARTED_LOOKING / PLAYER_STARTED_TURNING — those are camera-rotation events; these are raw input events.

Focus-gated. Polls Win32 GetAsyncKeyState per frame on the shared Tick::WorldTick registry. DOWN transitions are only honored when WoW is the foreground window — clicking in another app while alt-tabbed doesn't fire. UP transitions always fire, even if the user alt-tabbed mid-click, so an addon never gets left in a "button is held" state.

CURSOR_CHANGED event

Fires when what the cursor holds changes, and says what it held before:

CURSOR_CHANGED: isDefault, newCursorType, oldCursorType, oldCursorVirtualID
  • isDefault — 1 when the cursor is now empty, nil when it is holding something (write if isDefault then — the event dispatcher cannot push real booleans).
  • newCursorType (number) — what the cursor holds now, as an Enum.UICursorType value.
  • oldCursorType (number) — what it held before, same enum.
  • oldCursorVirtualID (number) — the previous content's identifying number: itemID, spellID, macro slot, merchant slot, or a money amount in copper. 0 when the cursor was empty.
local f = CreateFrame("Frame")
f:RegisterEvent("CURSOR_CHANGED")
f:SetScript("OnEvent", function()
    if arg2 == Enum.UICursorType.Item then
        -- an item was just picked up; GetCursorInfo() has the details
    end
end)

CURSOR_UPDATE is untouched and still fires as it always has, so nothing that listens to it needs changing. Prefer CURSOR_CHANGED for new code, because it reports every kind of cursor. CURSOR_UPDATE announces a pickup only for items: picking up money, a spell, a macro, a pet action or a stabled pet gives you one CURSOR_UPDATE that describes an empty cursor, and nothing at all once the new content is in place. CURSOR_UPDATE also fires in cases where the cursor did not change, such as one per item while a stack is moved.

CURSOR_CHANGED compares the cursor against the last state it reported, so it fires once per real change and stays quiet otherwise. It is checked once a frame, which means it arrives on the frame after the change rather than during it.

Two holdings that look alike still count as a change: picking up one of two identical stacks, dropping it, and picking up the other reports each pickup separately.

EQUIPMENT_SETS_CHANGED event

Fires (with no payload) after any mutation: Create, Save, Modify, Delete, and the four *IgnoredSlot* calls. Addon UI should re-read its set list / button state when this fires.

EQUIPMENT_SWAP_PENDING event

Fires with a single payload arg — setID — at the start of UseEquipmentSet, right after the set-exists check passes and before any pickup/equip work begins. Addons use this to gate "swap in progress" UI state (grey out the set button, show a spinner, etc.) until EQUIPMENT_SWAP_FINISHED arrives.

Doesn't fire if UseEquipmentSet is called with an unknown setID — in that case only EQUIPMENT_SWAP_FINISHED(false, setID) fires.

EQUIPMENT_SWAP_FINISHED event

Fires at the end of every UseEquipmentSet call with two payload args: success (1 if the set existed and we dispatched the swap, 0 if the setID was unknown) and setID. Note this is "we ran the dispatch" success — not "every item ended up in its target slot." Items that were in the bank or that couldn't complete a swap cycle in one pass still report success=1. Listen for this if you want to re-paint the character pane / refresh tooltips after a swap.

FACTION_STANDING_CHANGED event

Fires once per reputation change with (factionID, newStanding, repGained):

| arg1 (factionID) | Faction.dbc row ID of the faction whose standing changed | | arg2 (newStanding) | New total standing value (post-change barValue) | | arg3 (repGained) | Signed delta — positive on gain, negative on loss |

The engine exposes only CHAT_MSG_COMBAT_FACTION_CHANGE, whose arg1 is the localized chat string ("Your Stormwind reputation has increased by 100.") — addons have to parse the text and reverse-resolve the faction name back to an ID. This event lets addons skip that work.

Does not fire for the initial faction sync at login or /reload. Only fires when a real reputation gain or loss arrives from the server (SMSG_SET_FACTION_STANDING).

local f = CreateFrame("Frame")
f:RegisterEvent("FACTION_STANDING_CHANGED")
f:SetScript("OnEvent", function()
    -- in vanilla 1.12 OnEvent receives no args; engine sets `event`
    -- and `arg1..argN` as globals before invoking the handler.
    if event == "FACTION_STANDING_CHANGED" then
        local name = GetFactionInfoByID(arg1)
        print(string.format("%s: %+d  (now %d)", name, arg3, arg2))
    end
end)

repGained is signed: positive for gains, negative for the rare loss case (e.g. killing a Goblin Commodity Exchange NPC drops your rep with Booty Bay).

Implementation notes

Hooks the engine's per-rep-change notify dispatcher at 0x0062C5F0 — the chokepoint called once per (factionID, signedDelta) from the per-slot setter at 0x004D6330, gated by "value-actually-changed AND notify-flag-set". The setter has already written the new delta into the per-slot storage by the time the hook fires, so we read the total back via FUN_REPUTATION_GET_STANDING(factionID) (0x004D6370) to produce the newStanding payload.

The hook calls the original before firing our event so the engine's CHAT_MSG_COMBAT_FACTION_CHANGE still fires first — no behavior change for addons that depended on the chat text. A per-call snapshot of (factionID, newStanding, repGained) is captured before forwarding, which C_Reputation.GetLastStandingChange exposes — so addons can read the structured payload from inside the chat event without re-parsing the localized string.

LOOT_HISTORY_ROLL_CHANGED / LOOT_HISTORY_ROLL_COMPLETE / LOOT_HISTORY_FULL_UPDATE events

Fire as group-loot rolls progress, so a loot-history UI can update without polling. Register like any engine event (frame:RegisterEvent("LOOT_HISTORY_ROLL_COMPLETE")), then read the item via C_LootHistory.GetItem / C_LootHistory.GetPlayerInfo.

Event Args When
LOOT_HISTORY_ROLL_CHANGED itemIndex, playerIndex (numbers) A player rolled or passed on an item.
LOOT_HISTORY_ROLL_COMPLETE itemIndex (number) The item was decided — won or all-passed.
LOOT_HISTORY_FULL_UPDATE — The item set changed structurally — a new item appeared (roll opened) or the 128-item ring evicted the oldest (every index shifted). Re-read the whole list. Also fired by C_LootHistory.Clear().

See the LootHistory section for the reconstruction and read API.

LEARNED_SPELL_IN_SKILL_LINE event

Fires when the player learns a spell that enters the spellbook.

-- arg1 = spellID, arg2 = skillLineIndex, arg3 = isGuildPerkSpell
local f = CreateFrame("Frame")
f:RegisterEvent("LEARNED_SPELL_IN_SKILL_LINE")
f:SetScript("OnEvent", function()
    local tab = C_SpellBook.GetSpellBookSkillLineInfo(arg2)
    print("learned", C_Spell.GetSpellName(arg1), "in", tab.name)
end)
Arg Type Notes
spellID number The spell that was learned.
skillLineIndex number The 1-based spellbook tab that now holds it. Feed it to C_SpellBook.GetSpellBookSkillLineInfo.
isGuildPerkSpell nil Always nil (false).

The event fires next to the stock LEARNED_SPELL_IN_TAB, under the same rule: the game announced the new spell (a trainer, a quest reward, a talent) and the spell got a spellbook slot. The spells restored at login do not fire it. Profession recipes do not enter the spellbook, so they do not fire it.

LOOT_SCAN_COMPLETED event

Fires (no payload) once a C_Loot.ScanNearbyLoot() call has finished walking every reachable corpse. Handler should call C_Loot.GetLastScanResults() to read the collected { guid, coin, items } array.

local f = CreateFrame("Frame")
f:RegisterEvent("LOOT_SCAN_COMPLETED")
f:SetScript("OnEvent", function()
    local results = C_Loot.GetLastScanResults()
    -- ... process results
end)
C_Loot.ScanNearbyLoot()

Fires exactly once per ScanNearbyLoot() call regardless of outcome — even if every corpse timed out and the results table is empty. Doesn't fire spuriously for scans triggered by other addons (only one scan runs at a time; concurrent attempts return false from ScanNearbyLoot). See the Loot section for the full scan flow.

LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE events

Fire as loss-of-control effects change, so a UI can react without polling. Register like any engine event (frame:RegisterEvent("LOSS_OF_CONTROL_ADDED")).

Event Args When
LOSS_OF_CONTROL_ADDED eventIndex (number) A new effect was applied — eventIndex is its 1-based index for C_LossOfControl.GetActiveLossOfControlData.
LOSS_OF_CONTROL_UPDATE unitToken (string) The active set changed — an effect was added, fell off, or expired. Re-scan with GetActiveLossOfControlData. Always "player" (only the local player is tracked), but passed so the event carries a unit token and leaves room for per-unit tracking.

Detection is a per-frame diff of the active set (effect expiry has no engine packet to hook), so events land within a frame of the change and coalesce multiple simultaneous changes into a single LOSS_OF_CONTROL_UPDATE. A counterspelled cast, for instance, fires two ADDED (the school lockout and, with Improved Counterspell, the silence) then an UPDATE as each expires. See the LossOfControl section for the effect data.

MODIFIER_STATE_CHANGED event

Fires on every modifier-key press and release with (key, down):

| arg1 (key) | LSHIFT, RSHIFT, LCTRL, RCTRL, LALT, RALT | | arg2 (down) | 1 on press, 0 on release |

Only transitions fire — key autorepeat does not.

Releases that happen while WoW is not the focused window are also handled: keyboard messages only reach WoW while it has focus, so a modifier let go in the background is never seen live. On regaining focus the cached state is reconciled against the true physical key state and any missed transition fires then (see the focus-regain note below).

local f = CreateFrame("Frame")
f:RegisterEvent("MODIFIER_STATE_CHANGED")
f:SetScript("OnEvent", function()
    -- in vanilla 1.12 OnEvent receives no args; engine sets `event` and
    -- `arg1..argN` as globals before invoking the handler.
    if event == "MODIFIER_STATE_CHANGED" and arg1 == "LSHIFT" then
        print("left shift", arg2 == 1 and "down" or "up")
    end
end)

Implementation notes

L/R modifier distinction doesn't exist anywhere in the engine's own state — its IsShiftKeyDown chain bottoms out at GetKeyState(VK_SHIFT) (the merged virtual key, 0x10), never the L/R-aware VK_LSHIFT / VK_RSHIFT. The OS-level keystate does have the distinction; we capture it by installing a WH_GETMESSAGE Win32 thread hook on the engine's message-pump thread, decoding each WM_KEY{,SYS}{DOWN,UP} message (using MapVirtualKeyA(scancode, MAPVK_VSC_TO_VK_EX) to resolve VK_SHIFT to L/R and the KF_EXTENDED bit in lParam for VK_CONTROL / VK_MENU), and maintaining a 6-bit cached bitmap that the seven query functions read.

The thread-message hook is per-thread, not per-HWND — it survives renderer-state changes that recreate WoW's main window (e.g. toggling vertical sync), where an SetWindowLongPtr-style WNDPROC subclass would be left dangling.

Focus-regain reconciliation. WM_KEY* messages are only delivered while WoW is the foreground window, so a modifier released while WoW is in the background (alt-tabbed away) produces no message — the cached bit would stay stuck down and the release event would never fire. A second thread hook (WH_CALLWNDPROC, needed because WM_ACTIVATEAPP is sent rather than posted and so is invisible to WH_GETMESSAGE) watches for app activation; on regaining focus it polls GetAsyncKeyState for each L/R modifier — which reads global physical key state regardless of focus — and fires MODIFIER_STATE_CHANGED for any bit that changed while WoW was away. So the release surfaces the moment you tab back, and IsLeft/RightShiftKeyDown etc. read correct state immediately.

Note: the engine's own IsShiftKeyDown / IsControlKeyDown / IsAltKeyDown bottom out at the synchronous GetKeyState, which has the same background-release blind spot and is not reconciled by this hook — only ClassicAPI's L/R query functions and the event reflect the corrected state. Prefer IsModifierKeyDown / the L/R functions if you need the un-stuck value.

NAME_PLATE_CREATED / NAME_PLATE_UNIT_ADDED / NAME_PLATE_UNIT_REMOVED events

Fire when nameplate state actually changes. Payloads:

Event arg1 Notes
NAME_PLATE_CREATED nameplate Frame Fires once per unique CGNamePlateFrame pointer — same frame re-used via pool recycle does NOT refire.
NAME_PLATE_UNIT_ADDED "nameplateN" unit token Pass straight to UnitName / UnitGUID / UnitClass / etc., or to GetNamePlateForUnit for the frame. The token is positional — see Unit tokens for ordering semantics.
NAME_PLATE_UNIT_REMOVED "nameplateN" unit token Same as above. Computed from the plate's slot before it shifts out of the ordered list, so the token still resolves to the leaving unit during the event handler.
local f = CreateFrame("Frame")
f:RegisterEvent("NAME_PLATE_CREATED")
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
f:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
f:SetScript("OnEvent", function()
    if event == "NAME_PLATE_CREATED" then
        -- arg1 = the nameplate Frame itself
        arg1:SetAlpha(0.8)
    elseif event == "NAME_PLATE_UNIT_ADDED" then
        -- arg1 = "nameplate1" / "nameplate2" / ...
        local name = UnitName(arg1)
        local plate = C_NamePlate.GetNamePlateForUnit(arg1)
        -- ... style based on the unit ...
    end
end)

CREATED timing with nameplate addons (pfUI / TidyPlates / etc). The event fires when the engine allocates the underlying CGNamePlateFrame. Nameplate-mod addons typically decorate the frame on their own per-frame update — so arg1 at CREATED time is a bare frame with no addon-side decorations yet. For unit-specific work after the addon has decorated, use NAME_PLATE_UNIT_ADDED (fires next tick at the latest) or fetch the current frame on-demand via GetNamePlateForUnit(arg1).

Token stability gotcha. A plate keeps its token for its whole life, and the other plates never renumber when one plate is removed. But a removed plate frees its slot, and a later plate can reuse that slot. So "nameplate3" can name a different unit at a later time. For a key that stays with one unit, call UnitGUID(arg1) and store the GUID. See Unit tokens for the slot rules.

Implementation notes

Detected by per-frame polling, not engine hooks. Each world tick we walk the object hash for nameplated units and diff against the previous tick's snapshot. The engine has no event for "plate state changed", so the events are synthesized via diffing. The cost is ~20-50µs/frame even in busy raids — well below noise.

The diff approach absorbs the engine's transient hide/reshow cycle: there are ~7 code paths that briefly zero unit + 0xE60 (z-order rebuilds, anchor changes, flag-change re-eval) and the next frame's show path re-allocates from the pool. Those transient zeroes never become events because the unit appears in both the previous and current tick's snapshot.

PLAYER_FOCUS_CHANGED event

Fires whenever the player's focus changes — assignment via FocusUnit, clear via ClearFocus, or a FocusUnit(token) call where the token resolves to no GUID (implicit clear). No payload args; the new focus GUID is read off UnitGUID("focus") in the handler.

local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_FOCUS_CHANGED")
f:SetScript("OnEvent", function()
    local guid = UnitGUID("focus")
    if guid then
        print("focusing", UnitName("focus"))
    else
        print("focus cleared")
    end
end)

Identity-checked: assigning the same GUID twice is a no-op (no event refires). Fired whenever the player's focus target is changed, including when the focus target is lost or cleared.

PLAYER_SWING event

Fires each time one of your attack timers resets.

PLAYER_SWING: swingDuration, swingType
  • swingDuration (number) — the number of seconds from now until the next swing of this type.
  • swingType — an Enum.PlayerSwingType value: which weapon reset.
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_SWING")
f:SetScript("OnEvent", function()
    if arg2 == Enum.PlayerSwingType.MainHand then
        MainHandBar_Start(arg1)
    end
end)

A landed main-hand or off-hand hit resets that weapon's timer. An on-next-swing ability (Heroic Strike, Maul) resets the main-hand timer in its place. A ranged shot resets the ranged timer. Several melee hits can land at the same moment, such as an extra attack from Windfury. Even then, this event fires once per weapon, not once per hit. A parried hit can shorten swingDuration below the weapon's normal speed. Which spells interrupt the melee timers can differ by realm.

PLAYER_SWING does not fire for an attack that you declare while out of melee range. It fires once a swing is close enough to be real.

PLAYER_SWING_RANGE_UPDATE event

Fires when your current target moves into or out of range for a swing type that has range checking on. See C_SwingTimer.EnableRangeCheck.

PLAYER_SWING_RANGE_UPDATE: swingType, isInRange, checksRange
  • swingType — the Enum.PlayerSwingType value this update is for.
  • isInRange — 1 when the target is in range, nil when it is not. Ignore this value when checksRange is nil.
  • checksRange — 1 when a range check was possible, nil when it was not: for example, there is no current target, the target cannot be attacked, or no weapon is equipped for this swing type.

QUEST_ACCEPTED event

Fires once per quest the player just accepted, with two payload args: the 1-based quest log index and the questID. The signature is QUEST_ACCEPTED(questLogIndex, questID).

local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_ACCEPTED")
f:SetScript("OnEvent", function()
    -- 1.12: event payload is in `arg1`, `arg2`, ... globals
    if event == "QUEST_ACCEPTED" then
        local questLogIndex, questID = arg1, arg2
        -- ...
    end
end)

The event fires for every path that adds a quest to the local quest log. These paths include an accept from an NPC, a quest shared by a party member, and an auto-grant from a quest item. The quest is in the quest log before the event fires, so GetQuestLogTitle(questLogIndex) is valid inside the handler.

The client prints the "Quest accepted" system message first, then fires this event. A handler for CHAT_MSG_SYSTEM therefore also sees a current quest log.

The event fires again when a quest returns from complete to incomplete, because the client announces that quest a second time.

Does not fire on login or character entry. The bulk sync at that point is not an accept, and the client does not announce those quests either.

When the static data for a quest is not in the client cache yet, the event waits for the server to answer. The delay is one round trip. The system message waits with it, so the order does not change.

QUEST_REMOVED event

Fires once per quest leaving the local quest log, with the questID as the single payload arg. Covers both turn-ins and abandons — the two are distinguishable by whether a QUEST_TURNED_IN accompanies the removal. The signature is the bare questID.

local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_REMOVED")
f:SetScript("OnEvent", function()
    if event == "QUEST_REMOVED" then
        local questID = arg1
        -- ...
    end
end)

A QUEST_TURNED_IN event for the same quest identifies a turn-in. An abandon has no such event. For a turn-in, QUEST_TURNED_IN fires first and QUEST_REMOVED follows, because the turn-in reply does not change the quest log by itself.

When a quest log slot takes a different quest directly, QUEST_REMOVED fires for the old quest and QUEST_ACCEPTED fires for the new one.

Does not fire on login or character entry. The bulk sync at that point is not a removal.

QUEST_TURNED_IN event

Fires when the server confirms a quest turn-in (SMSG_QUESTGIVER_QUEST_COMPLETE, opcode 0x191). Payload is (questID, xpReward, moneyReward) — xpReward is the experience awarded (0 at max level or for non-XP-bearing quests), moneyReward is in copper.

local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_TURNED_IN")
f:SetScript("OnEvent", function()
    if event == "QUEST_TURNED_IN" then
        local questID, xp, money = arg1, arg2, arg3
        -- ...
    end
end)

Polyfilled by hooking the per-opcode SMSG handler at FUN_005DC400 (found via the CMSG side: Script_GetQuestReward → FUN_005015B0 → FUN_005EADC0 sends opcode 0x18E CMSG_QUESTGIVER_CHOOSE_REWARD; SMSG response is opcode 0x191). The hook saves the packet stream's read cursor (stream+0x14), peeks the first 4 uint32s of the body (questID, unknown, xp, money), restores the cursor, then calls the original — which re-reads the same data without seeing our peek. Side-effect-free.

Does NOT fire on quest abandon, on QUEST_FINISHED window transitions (which fire on Detail → Progress → Reward → Close — not a clean turn-in signal), or on server-reject paths (bag-full, quest invalidated, etc.). Only fires on a real successful turn-in.

Why not key off QUEST_FINISHED? The stock QUEST_FINISHED fires on every quest-window state transition, including reads, aborts, and player-side close. The SMSG_QUESTGIVER_QUEST_COMPLETE packet, by contrast, is server-authoritative — the server only sends it after committing the turn-in (XP / money / item awards done, quest removed from log). Hooking the packet handler gives us a clean turn-in signal.

SOUNDKIT_FINISHED event

Fires when a sound finishes playing.

local f = CreateFrame("Frame")
f:RegisterEvent("SOUNDKIT_FINISHED")
f:SetScript("OnEvent", function()
    print("finished:", arg1)   -- the handle of the sound that ended
end)

C_Sound.PlaySound(8959, nil, nil, true)
Argument Meaning
arg1 soundHandle — the handle C_Sound.PlaySound returned for the sound that just ended.

It fires only for sounds played with runFinishCallback set to true. Sounds played any other way report nothing — every footstep and button click would otherwise raise an event.

The event arrives on the frame after the sound ends, which is when the client releases it.

UNIT_FACTION event (fire-coverage fix)

UNIT_FACTION already exists in the event table — addons can register for it — but the engine never fires it on several paths where it should. We add the missing fires so addons that observe UNIT_FACTION stop missing state changes.

Payload: arg1 is the unit token string, always "player" for these paths (you can only toggle / sync rep on the local player).

local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_FACTION")
f:SetScript("OnEvent", function()
    if event == "UNIT_FACTION" and arg1 == "player" then
        RefreshFactionUI()
    end
end)

Paths that now fire UNIT_FACTION("player"):

Path Trigger Detection
FactionToggleAtWar Lua call to toggle AT_WAR Slot's flag byte changed (skips PEACE_FORCED no-ops, looting-blocked attempts, standing-blocked attempts)
SetFactionInactive / SetFactionActive Lua call to toggle INACTIVE Slot's flag byte changed
SMSG_SET_FACTION_STANDINGS Server rep-change push (kills, quest turn-ins, etc.) Any byte in the 64-entry flag array changed — catches AT_WAR side-effect flips when standing crosses the -3000 threshold
SMSG_SET_FACTION_ATWAR Server force-change of AT_WAR (faction reset, server-side state push) Unconditional after byte write
SMSG_INITIALIZE_FACTIONS Server initial faction sync at login Unconditional after table populated

The last one fixes the case where addons relying on UNIT_FACTION (rather than PLAYER_LOGIN / VARIABLES_LOADED) miss the initial load entirely.

Not yet covered:

  • Unit-faction-template changes for non-player units (mind control, charm, server-side faction scripts). UNIT_FACTION should fire on the affected unit token; the broad helper isn't hooked here yet. Mostly affects nameplate / threat addons in rare encounter mechanics.

Implementation notes

FactionToggleAtWar / SetFactionInactive / SetFactionActive's inner setters (FUN_004D5FD0, FUN_004D60F0) write to the rep slot's flag byte and send the corresponding CMSG, but never call the engine's "fire UNIT_FACTION on this unit" dispatcher.

Since FactionToggleAtWar only ever runs for the player and the only unit token resolving to the player is "player", we fire UNIT_FACTION("player") directly via the engine's printf-style dispatcher (FUN_FIRE_EVENT) — same observable result without re-deriving the broad helper. We resolve UNIT_FACTION's event-table slot lazily by name (Event::Custom::LookupByName) so we stay correct against any DLL combination that reshuffles the table.

For the two SMSG-bound flag-flip paths (SetFactionStandings, SetFactionInactive-but-from-server), we snapshot the 64-entry rep slot flag-byte array before the original handler runs and compare after — single fire if any byte changed. The bulk snapshot is 64 byte reads (cheap) and naturally handles the case where one packet carries multiple rep updates whose threshold crossings ripple AT_WAR state across unrelated slots.

UPDATE_MOUSEOVER_UNIT event (loss-fire fix)

UPDATE_MOUSEOVER_UNIT exists in the event table and fires when a mouseover unit is gained, but the engine never fires it on loss — moving the cursor off a unit clears the mouseover and signals nothing. We add the loss fire, so addons observing UnitExists("mouseover") can react when it becomes false.

Payload: the event carries no args; handlers read UnitExists("mouseover"), which is nil/false at fire time on the loss path.

local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
f:SetScript("OnEvent", function()
    if event == "UPDATE_MOUSEOVER_UNIT" then
        if UnitExists("mouseover") then
            -- gained (or changed to) a mouseover unit
        else
            -- mouseover unit lost (the vanilla gap this fills)
        end
    end
end)

Transitions and who fires them:

From To Fires? Source
unit nothing yes ClassicAPI (stock is silent here)
unit gameobject yes ClassicAPI (mouseover unit genuinely lost)
gameobject nothing no — (never fired a gain; not a unit)
anything unit yes engine's own inline fire (gain / unit→unit)

Implementation notes

The mouseover GUID lives in two globals written only by the engine's mouseover set/clear chokepoint (0x00492890), so every gain / loss / change flows through it — on a unit gain it fires UPDATE_MOUSEOVER_UNIT itself, on loss it clears the GUID silently. We co-hook that function: resolve whether the mouseover is a unit before and after the original, and fire on the unit→non-unit transition. Gating on unit type (not just "GUID present → absent") is load-bearing — the same GUID slot also holds gameobjects and items, which never fire a gain, so the type check is what keeps moving off a herb node or chest from spuriously firing. The !hasUnit half prevents a double-fire on any →unit transition the engine already covered.

The target is change-gated by its callers (if it ran per-frame the gain event would spam while hovering), so it's a cool hook — no per-frame overhead.

UPDATE_SHAPESHIFT_FORM event

Fires whenever the local player's shapeshift form changes — entering or leaving cat / bear / travel / stance / shadowform / stealth / ghost wolf / etc. No payload; call GetShapeshiftFormID() from the handler to read the new form.

The engine has only the plural UPDATE_SHAPESHIFT_FORMS (fires when the list of available forms changes — learning a new form, not changing into one). This singular event fires when the current form changes.

local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_SHAPESHIFT_FORM")
f:SetScript("OnEvent", function()
    if event == "UPDATE_SHAPESHIFT_FORM" then
        print("now in form", GetShapeshiftFormID())
    end
end)

Implementation notes

Hooks the engine's bonus-action-bar refresh helper at 0x004E4FC0, which the engine calls every time the local player's UNIT_BYTES_1 descriptor field is broadcast and from FUN_004908C0 at post-login init. After the original runs, the post-hook reads byte 2 of UNIT_BYTES_1 (descriptor +0x212 — the form ID) and fires UPDATE_SHAPESHIFT_FORM only when the value differs from the last seen — filtering out the spurious recomputes for other UNIT_BYTES_1 bytes (standstate, etc.) the engine also routes through this helper.

The cached "last form" sentinel uses -1 for "player descriptor not yet resolvable" so transient resolution failures during early login don't get misread as leaving a form.

UNIT_SPELLCAST_* events

Cast/channel events for the local player and other units. Cast-bar / rotation addons register these instead of the arg-less SPELLCAST_* events and read unit, castGUID, spellID directly. Thirteen events are provided; six also fire for non-player units:

Event Fires when Units Args
UNIT_SPELLCAST_SENT CMSG_CAST_SPELL leaves the client (earliest point) player unit, target, castGUID, spellID, spellName, rank
UNIT_SPELLCAST_START a cast-time spell begins all unit, castGUID, spellID, spellName, rank
UNIT_SPELLCAST_STOP a cast-time spell ends (any reason) all same
UNIT_SPELLCAST_DELAYED pushback extends the cast player same
UNIT_SPELLCAST_SUCCEEDED the spell goes off (SMSG_SPELL_GO) — incl. instants all same
UNIT_SPELLCAST_INTERRUPTED a started cast is interrupted (kick, movement, LoS) — never for channels all same
UNIT_SPELLCAST_FAILED a cast is rejected before it starts (range, mana, cooldown) with an error shown player same
UNIT_SPELLCAST_FAILED_QUIET a cast fails with no error shown (spammy retry, reticle cancel, …) player same
UNIT_SPELLCAST_CHANNEL_START a channel begins all same
UNIT_SPELLCAST_CHANNEL_UPDATE pushback shortens a channel player same
UNIT_SPELLCAST_CHANNEL_STOP a channel ends all same
UNIT_SPELLCAST_RETICLE_TARGET a ground-target reticle appears (AoE placement — Blizzard, Flare, …) player unit, "", spellID, spellName, rank
UNIT_SPELLCAST_RETICLE_CLEAR the reticle is placed or cancelled player unit, "", spellID, spellName, rank

unit (arg1) is the token of the casting unit — "player" for your own casts, or a unit token ("target", "focus", "party3", "nameplate2", "pet", "mouseover", …) for another unit. spellName / rank are ClassicAPI tail extensions; addons reading only the first three positional args are unaffected.

Per-token fan-out. A caster GUID can map to several tokens at once (your target is also party2 and nameplate1). The event fires once per token currently pointing at the caster, so a target-frame cast bar, a party2 frame, and a nameplate cast bar each get their own event with the same castGUID. Tokens are resolved fresh at fire time (they shift frame-to-frame). If you run SuperWoW, its raw-GUID token ("0x…") is deliberately filtered out — only standard tokens are fanned out.

Non-player limits. Only the six events above fire for other units, and they're best-effort — driven purely by the packets an observer receives:

  • SENT never fires (only your own outgoing casts are visible).
  • DELAYED / CHANNEL_UPDATE never fire (pushback is sent only to the caster, so another unit's bar can't stretch/shrink from damage).
  • FAILED never fires (a pre-cast requirement failure is client-local).
  • A remote cast/channel only has timing from the moment its SMSG_SPELL_START was observed; casters who were already casting when they came into range have no start time.

Channels never fire INTERRUPTED. Only CHANNEL_STOP fires when a channel ends, whether it completed or was cut short, for both the player and other units. INTERRUPTED is a cast-only event.

Reticle events fire for ground-targeted (AoE) spells only, always for the player: RETICLE_TARGET when the placement reticle comes up, RETICLE_CLEAR when it's placed or cancelled. There's no cast yet, so the castGUID slot (arg2) is empty — the engine's event dispatcher can't emit a nil mid-argument-list, so arg2 is "" here. unit (arg1) and spellID (arg3) are exact; arg2 is inconsequential for a reticle.

castGUID. A synthesized string of the shape Cast-<type>-<serverID>-<instanceID>-<zoneUID>-<spellID>-<castUID>. Server / instance / zone aren't known here, so those three fields are 0; the load-bearing parts are the spellID (field 6, which addons strsplit("-") out) and a unique-per-cast castUID (field 7). Every event of one cast carries the same castGUID, so SENT → START/CHANNEL_START → SUCCEEDED → STOP/CHANNEL_STOP all pair up — including across the caster and observers (they converge on the same value), and a chained same-spell recast gets its own castUID. The type and castUID follow the spell-cast-GUID spec:

  • Type 3 (real casts — the common case): castUID is time-based — the low 23 bits are the cast's UNIX-epoch second, the higher bits a per-second counter.
  • Type 2 (UNIT_SPELLCAST_FAILED — a local-only cast that never reached the server): castUID is a plain locally-incrementing integer.

Ordering:

  • Cast-time spell: SENT → START → SUCCEEDED → STOP.
  • Channel: SENT → CHANNEL_START → SUCCEEDED → CHANNEL_STOP (CHANNEL_START before SUCCEEDED).
  • Instant: SENT → SUCCEEDED.

INTERRUPTED vs FAILED vs FAILED_QUIET: a spell that never started (out of range, not enough mana, on cooldown, LoS to a target) fires FAILED, except for a fixed whitelist of "quiet" SpellCastResult codes that fire FAILED_QUIET instead — SPELL_IN_PROGRESS (casting while already casting / a spell-queue rejection), DONT_REPORT (fake fails, a cancelled ground reticle), and CHARMED. A spell that was already casting and gets stopped (an enemy kick, moving to cancel, breaking LoS mid-cast) fires INTERRUPTED. Holding the cast key while running fires INTERRUPTED repeatedly (once per retry), each reusing the interrupted cast's castGUID.

Channel pushback (player). Taking damage while channeling shortens the channel; CHANNEL_UPDATE fires on each hit and C_Spell.UnitChannelInfo's endTimeMs re-anchors to the server's new remaining time, so cast bars shrink correctly. (The event carries no time — it's a "re-read now" trigger; timing is read back from UnitChannelInfo.)

Every fire is gated on whether any frame is registered for that event, so the whole system costs one pointer-compare per state transition when no addon uses it (no arg synthesis, no DBC lookups, no per-token fan-out).

local f = CreateFrame("Frame")
for _, e in ipairs({
    "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP",
    "UNIT_SPELLCAST_SUCCEEDED", "UNIT_SPELLCAST_CHANNEL_START",
}) do f:RegisterEvent(e) end
f:SetScript("OnEvent", function()
    -- vanilla passes event/arg1/... as globals, not function params
    if arg1 == "target" then print(event, arg3) end  -- arg3 = spellID
end)

Additive to the stock SPELLCAST_* events. The engine's own arg-less SPELLCAST_START / SPELLCAST_CHANNEL_UPDATE / … still fire as before; these UNIT_-prefixed events sit on top. The empowered-cast events (UNIT_SPELLCAST_EMPOWER_*) are not implemented — there are no empowered casts here.

WEAPON_SLOT_CHANGED event

Fires (with no payload) when the item in a weapon slot changes: main hand (16), off hand (17), or ranged (18). Equip, unequip, and swap all fire it.

Slot 18 holds relics (Libram, Idol, Totem) for Paladins, Shamans, and Druids. A relic is not a weapon, so the event does not fire for one. UnitHasRelicSlot("player") reports which kind of slot 18 the player has.

Use this instead of PLAYER_EQUIPMENT_CHANGED when only the weapons matter — a swing-timer bar, or any code that re-reads UnitAttackSpeed and UnitRangedDamage.

The event collapses to one fire per frame. A single action that changes two weapon slots fires the event one time, not two.

local f = CreateFrame("Frame")
f:RegisterEvent("WEAPON_SLOT_CHANGED")
f:SetScript("OnEvent", function()
    SwingBars_ReadWeapons()
end)

Expansion

Helpers for addons that want to gate code on which expansion the client targets. We always answer as LE_EXPANSION_CLASSIC (0) — there's nothing to detect. The matching number constants (LE_EXPANSION_*) are in the Globals section.

GetClassicExpansionLevel()

Returns the live expansion level as a number. Always 0 (LE_EXPANSION_CLASSIC) here.

if GetClassicExpansionLevel() >= LE_EXPANSION_BURNING_CRUSADE then
    -- never taken on 1.12
end

ClassicExpansionAtLeast(expansionLevel)

Returns true iff GetClassicExpansionLevel() >= expansionLevel. That reduces to expansionLevel <= 0, so only ClassicExpansionAtLeast(LE_EXPANSION_CLASSIC) (and any negative argument) are true; every later expansion answers false.

Errors if expansionLevel is missing or non-numeric.

if ClassicExpansionAtLeast(LE_EXPANSION_WRATH_OF_THE_LICH_KING) then
    -- WotLK+ code path; never taken on 1.12
end

ClassicExpansionAtMost(expansionLevel)

Returns true iff GetClassicExpansionLevel() <= expansionLevel. That reduces to expansionLevel >= 0, so the only false answer is for negative input.

Errors if expansionLevel is missing or non-numeric.

if ClassicExpansionAtMost(LE_EXPANSION_CLASSIC) then
    -- vanilla / Classic Era only code path
end

Faction

GetFactionIDByIndex(factionIndex)

Returns the factionID (Faction.dbc row ID) for the entry at the given 1-based displayed-faction index. The engine uses this internally to look up Faction.dbc, but does not expose it from Lua.

  • Returns the factionID for real factions.
  • Returns 0 for header / category rows ("Other", "Inactive", etc.).
  • Returns nil if the index is out of range.

The engine actually returns 0 for some header types ("Other") and -1 for others ("Inactive"-style pseudo-rows); we collapse both to 0 so the user-facing convention is consistent.

for i = 1, GetNumFactions() do
    local name, _, _, _, _, _, _, _, isHeader = GetFactionInfo(i)
    if not isHeader then
        local factionID = GetFactionIDByIndex(i)
        -- ...
    end
end

GetFactionInfoByID(factionID)

Returns the same eleven values as GetFactionInfo(factionIndex), keyed by factionID instead of displayed index:

name, description, standingID, barMin, barMax, barValue,
atWarWith, canToggleAtWar, isHeader, isCollapsed, hasRep

Works for any factionID present in Faction.dbc, not just factions the player has rep with:

  • In the player's reputation list — full data, identical to GetFactionInfo(displayedIndex).
  • Not in the reputation list — name and description from Faction.dbc, Neutral defaults for the rep fields: standingID = 4, barMin = 0, barMax = 3000, barValue = 0, all flags nil.
  • Invalid factionID (out of range or empty DBC slot) — nil.
local name, _, standing = GetFactionInfoByID(69)  -- Darnassus
-- name = "Darnassus", standing = 5 (Friendly), etc. (encountered)

local name = GetFactionInfoByID(574)  -- Caer Darrow (faction that can't be encountered in standard Vanilla)
-- name = "Caer Darrow" — works even if you never had rep with it

GetFactionParentID(factionID)

Returns the parent factionID for a faction in a hierarchy (e.g. Stormwind's parent is Alliance Forces; The Defilers's parent is Horde Forces). Returns 0 for top-level factions with no parent, or nil for invalid factionIDs.

GetFactionParentID(72)     -- 469 (Stormwind → Alliance)
GetFactionParentID(469)    -- 0   (Alliance is top-level)
GetFactionParentID(99999)  -- nil

We expose this as its own getter, since GetFactionInfo doesn't have the slot.

Reads Faction.dbc ParentFactionID at record +0x48 directly — no displayed-list dependency, works for any faction in the DBC regardless of whether the player has rep with it.

C_Reputation.GetFactionStandings()

Returns a flat { [factionID] = currentStanding } table covering every faction in the player's reputation list. currentStanding is the same value GetFactionInfo puts in its barValue slot — `base

  • delta` from the rep slot, signed.

Always returns a table (possibly empty); never nil.

local standings = C_Reputation.GetFactionStandings()
for factionID, standing in pairs(standings) do
    print(GetFactionInfoByID(factionID), standing)
end

Unlike a GetNumFactions + GetFactionInfo walk, this skips header rows entirely and doesn't depend on the player having opened the reputation pane recently — it reads straight out of the per-faction rep-slot array, which the engine keeps populated for every faction the player has rep with.

If you need names instead of IDs, layer GetFactionInfoByID on top:

local byName = {}
for factionID, standing in pairs(C_Reputation.GetFactionStandings()) do
    byName[GetFactionInfoByID(factionID)] = standing
end

This is a ClassicAPI-only call. It returns a flat { [factionID] = standing } map rather than an array of struct tables.

C_Reputation.GetWatchedFactionData()

Returns a table describing the faction shown above the XP bar, or nil if no faction is being watched. The stock GetWatchedFactionInfo() returns the same data as a 5-tuple without the factionID; this struct accessor includes it.

Returns the same FactionData table shape as GetFactionDataByIndex — see that section for the full field list. isWatched is forced true (this faction IS the watched one by definition).

local data = C_Reputation.GetWatchedFactionData()
if data then
    print(string.format("%s (%d): %d / %d",
        data.name, data.factionID,
        data.currentStanding - data.currentReactionThreshold,
        data.nextReactionThreshold - data.currentReactionThreshold))
end

Implementation reads the watched RepListID from the player's +0xE68 sub-struct, indexes the per-faction rep slot array at 0x00B73290 to recover the factionID, then runs the shared ReadFactionData chain (Faction.dbc lookup, reaction band, rep slot flags, header/collapsed checks).

C_Reputation.GetFactionDataByID(factionID)

Returns a FactionData table for a faction by ID, or nil when the ID has no Faction.dbc record.

The table has the same shape as C_Reputation.GetFactionDataByIndex — see that section for the full field list.

The faction does not have to be in the player's reputation list. One the character has never encountered fills cleanly, with currentStanding 0 and atWarWith false, so you can read any faction's name, description, and standing thresholds without walking the displayed list.

local d = C_Reputation.GetFactionDataByID(69)   -- Darnassus
if d then
    print(d.name, d.currentStanding, d.nextReactionThreshold)
end

C_Reputation.GetFactionDataByIndex(factionSortIndex)

Returns a FactionData table for the faction at the given 1-based displayed-list index, or nil for an out-of-range index or a pseudo-row ("Other" / "Inactive" placeholders that don't have a Faction.dbc record). The index range matches what GetNumFactions covers — real factions plus category headers.

Fields:

Field Type Notes
factionID number Faction.dbc record ID.
name string Locale-applied.
description string Locale-applied (may be "").
reaction number 1=Hated .. 8=Exalted.
currentReactionThreshold number Band min standing value.
nextReactionThreshold number Band max standing value.
currentStanding number Current standing (base + delta).
atWarWith boolean Rep slot flags bit 0x02.
canToggleAtWar boolean currentStanding ≥ -3000 AND not peace-forced (flags bit 0x10).
isHeader boolean Faction is a category header in the displayed list.
isHeaderWithRep boolean Always false — parent factions don't aggregate rep.
isCollapsed boolean UI state: user has collapsed this header.
isWatched boolean This faction is shown above the XP bar.
canSetInactive boolean True when !isHeader && repListIndex ≥ 0 — i.e. the engine's SetFactionInactive/SetFactionActive will accept this faction.
isChild boolean Always false — no parent-child rep here.
hasBonusRepGain boolean Always false.
isAccountWide boolean Always false.
for i = 1, GetNumFactions() do
    local d = C_Reputation.GetFactionDataByIndex(i)
    if d and not d.isHeader and d.currentStanding < d.nextReactionThreshold then
        -- still grindable rep
    end
end

Implementation: resolves the 0-based index to a factionID via the engine's FUN_RESOLVE_FACTION_INDEX at 0x004D5FA0, then runs the shared ReadFactionData chain — no Lua-side round-trip through Script_GetFactionInfo. isHeader / isCollapsed come from the displayed-list header array at 0x00B736C0 (count at 0x00B736B0) and the per-character bitmask at 0x0084A0A4.

C_Reputation.SetSelectedFactionByID(factionID)

ClassicAPI extension. Selects a faction in the reputation pane by ID — the same convenience C_Reputation.SetWatchedFactionByID gives over the stock index-based call.

The selection is held as a faction ID, so this sets it directly and the faction does not have to be in the displayed list at the time. GetSelectedFaction() maps the selection back to a 1-based displayed-list index, and reports 0 while the faction is not listed.

Passing 0 clears the selection. Negative IDs are ignored.

C_Reputation.SetSelectedFactionByID(69)      -- Darnassus
print(GetFactionInfo(GetSelectedFaction()))  -- "Darnassus"

C_Reputation.SetWatchedFactionByID(factionID)

Sets the faction shown above the XP bar by ID. The stock SetWatchedFactionIndex(displayedIndex) accepts only a 1-based displayed-list index, which forces addons to walk the index list themselves. This wrapper takes a factionID directly.

  • factionID > 0 — sets the watched faction. Works even for factions the player hasn't yet encountered (the rep bar will show nothing until the player gains rep, then displays normally).
  • factionID == 0 — clears the watched faction.
  • factionID < 0 — silent no-op.

Returns nothing. The engine's UI / event machinery refreshes automatically — GetWatchedFactionInfo() reflects the new state on the next call.

C_Reputation.SetWatchedFactionByID(72)  -- Stormwind
print(GetWatchedFactionInfo())          -- "Stormwind", ...

C_Reputation.SetWatchedFactionByID(0)   -- clear
print(GetWatchedFactionInfo())          -- (empty)

Implementation calls the engine's inner watched-faction setter directly, bypassing Script_SetWatchedFactionIndex's displayed-index round-trip.

C_Reputation.ToggleFactionAtWarByID(factionID)

ClassicAPI extension. Flips a faction's at-war state by ID rather than by displayed-list position.

Every rule the stock index-based call applies still applies here. The toggle is refused when the faction is peace-forced (your own capitals, some quest factions), and peace can't be declared while standing is below -3000. Read C_Reputation.GetFactionDataByID first to see whether a faction will accept the change:

local d = C_Reputation.GetFactionDataByID(87)   -- Bloodsail Buccaneers
if d.canToggleAtWar then
    C_Reputation.ToggleFactionAtWarByID(87)
end

Non-positive IDs are ignored. The change is sent to the server, so it sticks the same way the reputation pane's checkbox does, and UNIT_FACTION fires for "player" when the state actually changes — a refused toggle stays silent.

C_Reputation.IsFactionActive(factionSortIndex) / C_Reputation.IsFactionActiveByID(factionID)

Returns whether a faction is not filed under the "Inactive" heading in the reputation pane. IsFactionActive takes a 1-based displayed-list position; the ByID form (a ClassicAPI extension) takes a faction ID.

Both report false for anything that isn't a real faction with a reputation slot — an out-of-range position, a category header, or an ID the character has no slot for.

C_Reputation.IsFactionActiveByID(87)        -- true until you park it
C_Reputation.SetFactionInactiveByID(87)
C_Reputation.IsFactionActiveByID(87)        -- now false

C_Reputation.SetFactionInactiveByID(factionID) / C_Reputation.SetFactionActiveByID(factionID)

ClassicAPI extension. Moves a faction into or out of the "Inactive" category by ID rather than by displayed-list position.

Marking a faction inactive files it under the collapsible "Inactive" heading in the reputation pane, which is how players park factions they have finished with. The displayed list is rebuilt, so positions of the other factions shift — read them again afterwards rather than reusing an index from before the call.

C_Reputation.SetFactionInactiveByID(87)   -- park Bloodsail Buccaneers
C_Reputation.SetFactionActiveByID(87)     -- and bring it back

Non-positive IDs are ignored. A faction with no reputation slot is left alone. The change is sent to the server, and UNIT_FACTION fires for "player" when the state actually changes.

canSetInactive on C_Reputation.GetFactionDataByID tells you whether a faction can take the change.

C_Reputation.GetLastStandingChange()

Returns factionID, newStanding, repGained for the rep change currently being fired, or nil if called outside that window.

Companion to FACTION_STANDING_CHANGED. The triple is the same payload as the event's arg1, arg2, arg3, but the getter is also live during the engine's CHAT_MSG_COMBAT_FACTION_CHANGE dispatch on the same hook — useful for addons that want to enrich the chat line with the factionID (which the chat-event payload doesn't carry) without double-registering for FACTION_STANDING_CHANGED:

local f = CreateFrame("Frame")
f:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE")
f:SetScript("OnEvent", function()
    local factionID, newStanding, repGained = C_Reputation.GetLastStandingChange()
    if factionID then
        -- factionID is the real ID, not a parsed-out name string
        print(factionID, newStanding, repGained)
    end
end)

Outside the in-flight FireNotify hook stack (i.e. anywhere except inside a CHAT_MSG_COMBAT_FACTION_CHANGE or FACTION_STANDING_CHANGED handler), returns nothing. There is no "last change" memory — the state is cleared as soon as the dispatch returns.

This is a ClassicAPI-only call. Addons can otherwise get the factionID from FACTION_STANDING_CHANGED directly.

Focus

A focus-target system: a single sticky GUID that addons can pin to a unit and address via the "focus" unit token across the entire UnitX API surface. Backed by a hook on FUN_TOKEN_TO_GUID (shared with the nameplateN tokens — see unit/TokenExtensions.cpp).

State is session-only — drops on /reload, /logout, and zone loads that recreate Lua state. Addons that want persistence have to re-FocusUnit from SavedVariables at ADDON_LOADED.

Auto-clear on despawn. When the focused unit leaves the client's object table — out of rendering range, full despawn, dies and decays — focus drops and PLAYER_FOCUS_CHANGED fires. The unit re-entering range does NOT auto-refocus. Implementation: per-tick ObjectByGUID(g_focusGUID) probe; on null result, Set(0).

FocusUnit(unit)

Sets focus to the given unit. Argument is a unit token — "target", "mouseover", "party1", "nameplate1", anything the resolver accepts. Calling with no argument is shorthand for FocusUnit("target") (the /focus slash command default).

FocusUnit("target")        -- pin the current target
FocusUnit("nameplate1")    -- pin the unit behind nameplate1
FocusUnit()                -- same as FocusUnit("target")

Fires PLAYER_FOCUS_CHANGED if the resolved GUID differs from the current focus. No-op (no event) if the same unit is already focused (identity-checked first).

Passing a token that doesn't resolve to a unit (e.g. "target" with nothing targeted, or "party5" solo) clears focus — same as calling ClearFocus().

ClearFocus()

Drops the focus. Fires PLAYER_FOCUS_CHANGED if there was one; no-op otherwise.

ClearFocus()

Unit token (focus / focustarget)

"focus" resolves to whatever GUID FocusUnit last stashed. Accepted by every UnitX function:

local name = UnitName("focus")             -- nil if no focus
local hp   = UnitHealth("focus")
local _, class = UnitClass("focus")

Chains compose through the engine's standard suffix walker: "focustarget", "focustargettarget", etc. — same behavior as "targettarget", mirrored instruction-for-instruction in our hook so addons get the engine semantics they expect.

Returns nil cleanly when no focus is set; doesn't raise the "Unknown unit name" error.

Unit events fire for "focus". UNIT_HEALTH, UNIT_MANA, UNIT_AURA, UNIT_LEVEL, UNIT_MODEL_CHANGED, … fire with arg1 == "focus" whenever the focused unit's corresponding descriptor field changes — even when it isn't your target, so a focus frame keeps updating while you fight something else. Backed by Unit::TokenObserver, which registers the engine's own unit-event descriptor-field observers for the focus unit (the exact watch the engine gives target/party/raid). A unit that is simultaneously your target and focus fires both "target" and "focus". (OnEvent handlers read the arg1 global, not a function parameter.)

UnitTokenFromGUID scans "focus" right after "target", so a focused unit's GUID reverse-resolves to "focus" only if it isn't already addressable as "player" / "party*" / "raid*" / "nameplate*" / "target".

Bindings

The direct-action and temporary override binding APIs. The permanent helpers write action command strings into the native binding table; the override helpers add a separate, session-only layer in front of that table.

Permanent direct-action bindings

Function Equivalent command
ok = SetBindingSpell(key, spell) SetBinding(key, "SPELL " .. spell)
ok = SetBindingItem(key, item) SetBinding(key, "ITEM " .. item)
ok = SetBindingMacro(key, macroNameOrIndex) SetBinding(key, "MACRO " .. macroNameOrIndex)
ok = SetBindingClick(key, buttonName [, mouseButton]) SetBinding(key, "CLICK " .. buttonName .. (mouseButton and ":" .. mouseButton or ""))

key is any binding string accepted by the client, such as "CTRL-F", "SHIFT-BUTTON4", or "F8". spell, item, macroNameOrIndex, and buttonName identify the action to perform. macroNameOrIndex accepts either a saved macro's name or its decimal index. mouseButton is passed to the button's click handler and defaults to "LeftButton"; when supplied, it is stored as the :MouseButton suffix of the CLICK command.

Each helper returns 1 if the stock SetBinding changed the binding and nil otherwise. It changes whichever binding set is currently loaded, fires the normal UPDATE_BINDINGS notification, and is not persisted until the addon calls SaveBindings. Use SetBinding(key) to unbind a key; the typed helpers always require an action argument.

The command hook also understands manually constructed SPELL, ITEM, MACRO, and CLICK strings passed directly to SetBinding.

Temporary override bindings

SetOverrideBinding(owner, isPriority, key [, command])
SetOverrideBindingSpell(owner, isPriority, key, spell)
SetOverrideBindingItem(owner, isPriority, key, item)
SetOverrideBindingMacro(owner, isPriority, key, macroNameOrIndex)
SetOverrideBindingClick(owner, isPriority, key, buttonName [, mouseButton])
ClearOverrideBindings(owner)

owner must be a frame and isPriority must be a Boolean. The typed helpers construct the same four action strings shown above. The generic SetOverrideBinding also accepts ordinary Bindings.xml command names, which are delegated to the engine's command executor. Override setters and ClearOverrideBindings return no values.

Passing no command (or nil) to SetOverrideBinding removes that owner's override for key. ClearOverrideBindings(owner) removes every override belonging to that owner. Overrides never change or save the native binding table and are cleared whenever the FrameScript Lua state is recreated, such as on /reload or logout.

Resolution order is:

  1. Priority override bindings.
  2. Non-priority override bindings.
  3. The normal native binding.

When multiple owners set the same key at the same priority, the most recently set override wins. Removing it immediately reveals the next matching override or the unchanged native binding.

key is matched case-insensitively, and its modifiers are accepted in any order: "ctrl-alt-F", "ALT-CTRL-F", and "Alt-Ctrl-F" all bind the same key. The override layer normalizes the key to the engine's own keypress form (ALT-CTRL-SHIFT- before the base key) before matching, so the override fires regardless of how the caller spelled it.

A generic SetOverrideBinding command that is an ordinary Bindings.xml command runs through the engine's own command executor with the same execution context a native keypress establishes, so it behaves like the equivalent permanent binding.

Action execution

Command Execution path
SPELL name Calls the stock CastSpellByName(name). The usual /cast spell-name syntax is accepted.
ITEM item Calls C_Item.UseItemByName(item), accepting the names, item strings, and item IDs supported by that API.
MACRO nameOrIndex Resolves a saved macro by name or decimal index. An addon-provided RunMacro is used when available; otherwise ClassicAPI resolves it with GetMacroInfo and dispatches its body through ChatEdit_ParseText.
CLICK ButtonName[:MouseButton] Resolves the named global frame and calls its ordinary :Click(mouseButton) method. The default is LeftButton.

SetBindingMacro and SetOverrideBindingMacro take a saved macro identifier, not literal macro text. To bind literal macro text, bind a named button whose ClassicAPI attributes describe a macro action:

local button = CreateFrame("Button", "MyMacroTextBindingButton")
button:SetAttribute("type", "macro")
button:SetAttribute("macrotext", "/say Bound macro text")

SetBindingClick("CTRL-F7", button:GetName())

1.12 compatibility notes

  • There is no combat lockdown or secure execution. These functions remain callable in combat, and CLICK invokes the frame's ordinary click path.
  • Direct actions run on key-down. The corresponding key-up is consumed; a click binding does not deliver separate mouse-down and mouse-up events.
  • The override layer is not reflected by GetBindingAction. The GetBindingAction(key, true) override query is not backported.
  • Override ownership is explicit. Call ClearOverrideBindings(owner) when the addon's owner is no longer needed; all remaining overrides are also cleared when the Lua state is recreated.

Predefined focus bindings (FOCUSTARGET / TARGETFOCUS)

Two key bindings appear in the keybind UI (Esc → Key Bindings) under the Targeting Functions group, between PETATTACK and the Interface section:

Binding Lua action Use
FOCUSTARGET FocusUnit("target") Pin current target as focus
TARGETFOCUS TargetUnit("focus") Switch target to the focus

No default key — assign one in the keybind UI like any other binding. The two together give you a quick "set focus → swap to focus" loop common in PvP / heal-tank-while-killing-add scenarios.

Implementation note: addon-side Bindings.xml files always render at the bottom of the keybind list (orphaned from any header="..." they declare) because the engine's binding registry is linearly indexed by insertion order and addons load after FrameXML. To land inside the TARGETING block, the DLL splices the two <Binding> entries into the engine's Interface\FrameXML\Bindings.xml at file-read time via a hook on FUN_FILE_READ — see src/bindings/Inject.cpp.

Frame

Region/Frame method backports. Addon ports routinely call these methods unconditionally — they're the C-level frame surface addons take for granted. This section is that surface. Methods are registered on the engine's own per-frame-type method registries (SetSize/GetSize on the Region base, so they resolve on frames, buttons, textures and fontstrings alike), and each delegates to the engine's own implementations — UI-scale conversion, object resolution and type checking are all the engine's code.

region:SetPoint("point") (one-argument form)

The stock SetPoint parser accepts every call shape — (point, region), (point, region, relativePoint [, x, y]), (point, x, y), even an explicit nil region — except the bare one-argument form, which raises the usage error (a fully-omitted third argument fails its type validation). A co-hook on the engine's Script_SetPoint normalizes region:SetPoint("RIGHT") to (point, 0, 0) — the parent-relative form (anchor to the parent's same point, zero offsets).

region:SetSize(width, height) / region:GetSize()

The combined setter/getter for SetWidth+SetHeight / GetWidth+GetHeight. Works on any region type — frames, buttons, textures (including engine-created ones like button:GetNormalTexture()), fontstrings.

region:IsMouseOver([topOffset, bottomOffset, leftOffset, rightOffset])

true when the mouse cursor is within the region's rectangle, false otherwise. The four optional offsets shift the corresponding edge before the test (added to that edge: positive topOffset / rightOffset and negative bottomOffset / leftOffset enlarge the hit area). Registered on the Region base, so it works on any region type — frames, buttons, textures, fontstrings.

Uses the engine's own rect getters and cursor position, dividing the cursor by the region's effective scale — the exact computation addons have long open-coded as MouseIsOver(). Returns false for a region with no resolved rect (unpositioned / zero-size).

ItemRefTooltip:IsMouseOver()                 -- true while hovering the tooltip
Minimap:IsMouseOver(20, -20, -20, 20)        -- true within a 20px halo around it

region:GetRect()

Returns the region's left, bottom, width, height in one call (the combined form of GetLeft+GetBottom+GetWidth+GetHeight). Composed from the engine's own getters, so all UI-scale conversion is its code. On the Region base — works on any region type. Returns nothing for a region with no resolved rect (unpositioned).

UIParent:GetRect()   -- 0, 0, <screenWidth>, <screenHeight>
Minimap:GetRect()    -- <left>, <bottom>, 140, 140

region:IsDragging()

true while the region is the frame currently being moved or resized by the mouse — i.e. after StartMoving/StartSizing and before StopMovingOrSizing — false otherwise. Reads the engine's single global drag target (the same slot StopMovingOrSizing checks to decide whether it owns the active drag). On the Region base; non-frame regions (textures, fontstrings) always report false, since only frames can be dragged.

-- in a frame's OnUpdate while the user drags it:
if self:IsDragging() then ... end

GetMouseFoci()

Returns a table of the mouse-enabled frames under the cursor, from top to bottom. Index 1 is the frame that GetMouseFocus() returns, so GetMouseFocus() == GetMouseFoci()[1] is always true.

local foci = GetMouseFoci()
-- foci[1] == GetMouseFocus()
for i = 1, table.getn(foci) do
    print(foci[i]:GetName() or "<anonymous>")
end

frame:SetShown(shown)

Show() if shown is truthy, Hide() otherwise. Registered for frames, textures and fontstrings (each branch has its own engine Show/Hide implementation).

fontstring:GetStringHeight()

This is the companion to GetStringWidth. It returns the height of the rendered text, in UI pixels. The result includes word wrap: wrapped text measures lines × fontHeight + (lines − 1) × spacing (the SetSpacing value). Empty or unset text returns 0.

local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(100)
f:SetText("a long line that wraps a few times")
frame:SetHeight(f:GetStringHeight() + 16)  -- size the box to the text

Related: the stock fontstring:GetStringWidth() includes inline |T…|t icon widths, so the measured width matches the rendered width. Editbox text is not adjusted — an editbox shows and measures raw markup.

fontstring:GetUnboundedStringWidth()

Returns the width of the text on one line, in UI pixels, with no wrap or size limit applied. Note: GetStringWidth ALSO ignores word wrap — that is a classic API trap. Here the two methods always agree. The width of the text AS RENDERED (the widest wrapped line) is GetWrappedWidth, below. Inline |T…|t icon widths are included. Empty or unset text returns 0.

fontstring:GetWrappedWidth()

Returns the width of the text as it renders, in UI pixels: the width of the widest wrapped line. This is the method to use for the visible size of wrapping text — GetStringWidth and GetUnboundedStringWidth both measure the string on one line and ignore wrapping. Text that does not wrap returns the same value as GetStringWidth. Inline |T…|t icons are counted. Empty or unset text returns 0.

The wrapped width can carry a ≤1px difference from a direct measure of the same line text — the same rounding noise GetStringWidth itself carries.

local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(100)
f:SetText("a long line that wraps a few times")
-- f:GetStringWidth()   -> ~165 (one line, ignores wrap)
-- f:GetWrappedWidth()  -> ~94  (the widest rendered line)

fontstring:GetNumLines()

Returns the number of wrapped lines the text renders as. It works whether or not the text has rendered yet. Empty or unset text returns 0.

fontstring:GetLineHeight()

Returns the height of one text line in UI pixels — the font height, without the SetSpacing value. Use it with GetNumLines and GetSpacing to reconstruct GetStringHeight per line.

fontstring:IsTruncated()

Returns true when the engine cut the text off with an ellipsis (...). The engine truncates only when the text does not fit a bounded box. The fontstring needs a width and a height, and the text must overflow that box. Fontstrings always wrap at spaces. So a box that is only one line tall truncates, but a taller box wraps the text and fits it.

The result reflects what is on screen, so the fontstring must have drawn at least once. GetText still returns the full text, not the cut-off line.

local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(60)
f:SetHeight(14)             -- one line tall
f:SetText("a name too long to fit")
-- after it draws: f:IsTruncated() -> true  (draws "a name too...")

fontstring:SetMaxLines(maxLines) / fontstring:GetMaxLines()

Caps the fontstring to maxLines wrapped lines. Text past the cap is cut off with an ellipsis (...). Pass 0 (or nothing) for no cap.

This is the way to make a truncating label. Fontstrings always wrap at spaces, and there is no SetWordWrap. So SetMaxLines(1) gives a single-line label that ends in ... when the text is too wide, even inside a tall box.

The cap needs a width to take effect: the text must have an edge to overflow. GetMaxLines returns the current cap (0 when unset). Use IsTruncated to check whether the cap actually cut the text.

local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(120)
f:SetMaxLines(1)                 -- one line, then "..."
f:SetText("a really long guild or player name that will not fit")
-- draws "a really long gu..."   ; f:IsTruncated() -> true

fontstring:SetFormattedText(format [, ...])

Sets the text to string.format(format, ...) — a convenience over SetText(format(...)). A bad format string raises a normal Lua error. The set goes through the same engine path as SetText, so escape handling is identical.

f:SetFormattedText("%d/%d (%.1f%%)", cur, max, cur / max * 100)

texture:SetRotation(angle [, cx, cy])

Rotates a texture by angle radians. A positive angle turns the texture counter-clockwise.

The optional cx, cy set the pivot point, as a normalized position inside the texture from 0 to 1. The default pivot is the center, (0.5, 0.5). An angle of 0 clears the rotation and returns the texture to upright.

The method turns the four corners of the drawn quad, so the whole texture stays visible and no corner is clipped. It works on any texture, including engine-created ones. The rotation holds across moves and resizes, and costs nothing per frame while it stays still. GetRotation() returns the current angle in radians.

local t = frame:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(frame)
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
t:SetRotation(math.rad(45))      -- 45 degrees counter-clockwise
t:SetRotation(math.pi, 0, 1)     -- half turn around the top-left corner

texture:SetVertexOffset(vertexIndex, offsetX, offsetY)

Moves one corner of a texture by offsetX, offsetY pixels. vertexIndex picks the corner: UPPER_LEFT_VERTEX, LOWER_LEFT_VERTEX, UPPER_RIGHT_VERTEX, or LOWER_RIGHT_VERTEX (values 1 to 4). +x is right and +y is up.

Moving the corners warps the whole quad, so the texture and its background move together. It suits skew, trapezoid, and fake-perspective effects, and waving animations. It composes with SetRotation: the texture rotates first, then the offsets apply. The offset holds across moves, resizes, and SetTexture, and costs nothing per frame while it stays still.

GetVertexOffset(vertexIndex) returns the current offsetX, offsetY for a corner (0, 0 if unset).

Note: an offset that turns the quad inside-out — mirroring it past its opposite edge — is not drawn. The engine skips a back-facing quad, so keep the warp within a non-mirrored shape.

local t = frame:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(frame)
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
-- pull the top two corners inward: a trapezoid (fake perspective)
t:SetVertexOffset(UPPER_LEFT_VERTEX, 20, 0)
t:SetVertexOffset(UPPER_RIGHT_VERTEX, -20, 0)

texture:SetColorTexture(colorR, colorG, colorB [, a])

Fills the texture with a solid color. Each channel is 0 to 1. The alpha a is optional and defaults to 1 (opaque). This is a second name for a fill the engine already does — SetTexture(r, g, b [, a]) with numbers instead of a path. It is the same engine call under a second name, so the clamping and the opaque default match the engine.

local t = frame:CreateTexture(nil, "BACKGROUND")
t:SetAllPoints(frame)
t:SetColorTexture(0.1, 0.6, 1.0, 0.8)  -- semi-transparent blue fill

texture:SetMask(path)

Clips a texture to the shape of a mask — for round portraits, round minimap buttons, and other shaped art.

path is a mask texture. The mask's ALPHA channel is the shape: where the mask is opaque the texture shows, where the mask is transparent the texture is hidden. The mask stretches across the texture's full display area, so it always lines up with the texture. Pass nil or "" to remove the mask; the texture returns to its full rectangle.

A mask file must be one the client can load and must hold its shape in the alpha channel:

  • A BLP, or an UNCOMPRESSED 32-bit TGA. The client cannot decode an RLE-compressed TGA.
  • The shape must be in the alpha channel, with white color. A mask that is fully opaque does nothing; a mask whose shape is only in its color does not clip.
  • Bottom-origin. A top-origin TGA loads upside down.

Two masks ship in the client and need no file of your own: Textures\MinimapMask (round) and Interface\CharacterFrame\TempPortraitAlphaMask (round portrait). For any other shape, put your own mask texture in your addon folder and pass its path, the same way you ship any custom texture.

local t = frame:CreateTexture(nil, "ARTWORK")
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
t:SetMask("Textures\\MinimapMask")               -- clip the icon to a circle
t:SetMask("Interface\\AddOns\\MyAddon\\round")   -- or your own mask texture
t:SetMask(nil)                                    -- remove the mask

The mask ignores SetTexCoord. If you crop the texture to one sprite of a sprite sheet, the full mask shape still clips that sprite. The mask follows texture:SetRotation. It also combines with masks from texture:AddMaskTexture: the texture shows only where every mask is opaque.

frame:CreateMaskTexture([name, layer, ...])

Creates a MaskTexture and returns it. A MaskTexture is a texture that never draws itself. Instead it clips other textures to its shape.

Give the mask a shape with SetTexture, place and size it like any texture (SetPoint, SetSize, SetAllPoints), then attach it with texture:AddMaskTexture. Because the mask has its own position and size, it can cover only part of the target, sit to one side, or move each frame.

The arguments match frame:CreateTexture. The mask shape lives in the texture's alpha channel and must load from a BLP or an uncompressed 32-bit TGA — the same rule as texture:SetMask.

local mask = frame:CreateMaskTexture()
mask:SetTexture("Interface\\AddOns\\MyAddon\\circle")
mask:SetAllPoints(icon)      -- cover the whole icon → a round icon
icon:AddMaskTexture(mask)

texture:AddMaskTexture(mask)

Clips this texture to mask's shape and position. Where the mask is opaque the texture shows; where the mask is transparent the texture is hidden.

Call it again with a different mask to add another. A texture with several masks shows only where ALL of them are opaque — the masks intersect. So two circles show their overlapping lens, and a square plus a circle show a square with rounded corners. Up to 7 masks apply at once. A mask set with texture:SetMask uses one of the 7 slots. Masks cannot subtract, so you cannot cut a hole with this.

A mask honors texture:SetRotation: rotate the mask and its clip shape turns with it. The mask can also move or resize each frame (drive it from an OnUpdate), so the clip animates.

texture:RemoveMaskTexture([mask])

Removes mask from this texture. With no argument, removes every mask, so the texture returns to its full rectangle.

texture:GetNumMaskTextures()

Returns the number of masks attached to this texture.

texture:GetMaskTexture(index)

Returns the mask at index (1 is the first one added), or nil when there is no mask at that index.

texture:SetAtlas(atlas [, useAtlasSize])

Points the texture at a named piece of art. This sets the texture file and the coordinates of the rectangle inside it, so one call replaces a SetTexture and a SetTexCoord. See Texture for the atlas names and for C_Texture.RegisterAtlas.

Pass true for useAtlasSize to also resize the texture to the art's own pixel size. Any further arguments are accepted and ignored.

After SetAtlas, texture coordinates address the art and not the file it sits in, so 0 to 1 covers the whole of the art. SetTexCoord(0, 0.5, 0, 0.5) shows the top-left quarter of the art, and GetTexCoord reads back what you asked for. SetTexture points the texture at a file again and clears the atlas.

When the name is not known, the texture keeps whatever it was showing. Call _classicapi_DumpAtlasMisses() to list the names that were asked for and not found.

local t = frame:CreateTexture(nil, "ARTWORK")
t:SetAtlas("MinimapArrow", true)   -- draws the art at its own 32x32 size

texture:GetAtlas()

Returns the atlas name last set on this texture, or nil when it is showing something else. Pointing the texture at a file with SetTexture clears the name.

texture:ResetTexCoord()

Drops a crop made with SetTexCoord or SetSpriteSheetCell and shows the whole of what the texture points at.

For a texture set with SetAtlas, the whole of what it points at is the atlas's own rectangle, so the art comes back and the atlas name stays set. For any other texture it is the whole file, the same as SetTexCoord(0, 1, 0, 1).

local t = frame:CreateTexture(nil, "ARTWORK")
t:SetAtlas("MinimapArrow")
t:SetTexCoord(0, 0.5, 0, 0.5)   -- shows the top-left quarter of the art
t:ResetTexCoord()               -- shows the art again

texture:SetSpriteSheetCell(cell, numRows, numColumns)

Crops the texture to one cell of an evenly divided grid. A sprite sheet holds several images in one file, and this works out the share of the file that one image occupies, so you do not write the coordinates yourself.

  • cell — which image to show. The first cell is 1. Cells count left to right, then top to bottom.
  • numRows, numColumns — the shape of the grid.

Two further arguments, cellWidth and cellHeight, are accepted and ignored.

The texture keeps what it was showing when cell falls outside the grid.

-- The eight raid markers share one 4x4 sheet. Cell 8 is the skull.
t:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons")
t:SetSpriteSheetCell(8, 4, 4)

texture:SetDesaturation(amount)

Removes some or all of the color from a texture. amount is a number from 0 (full color) to 1 (grayscale). The method clamps values outside this range. At every amount, the texture keeps the tint that SetVertexColor gives it.

SetDesaturated(true) is the same as SetDesaturation(1), and SetDesaturated(false) is the same as SetDesaturation(0). All three methods change the same setting, so SetDesaturated(false) also clears a partial amount.

icon:SetDesaturation(0.5)          -- half of the color is gone
icon:SetVertexColor(1, 0.5, 0.5)   -- the gray keeps a red tint
icon:SetDesaturated(false)         -- full color again

texture:GetDesaturation()

Returns the current desaturation amount as a number from 0 to 1. A texture that SetDesaturated(true) made gray returns 1.

The texture stores the amount in 255 steps, so the value that you read back can differ a little from the value that you set. For example, 0.5 reads back as 0.50196.

Texture size and shape

A texture can have any width and any height. The two sides do not need to be powers of two, and they do not need to be equal. A 397x385 photo, a 1919x1079 screenshot, and a 32x1024 strip all load and draw correctly.

There is no fixed upper limit on texture size. The only limit is the maximum texture size of your graphics card, which is 16384 on most modern cards. A texture larger than this limit does not load.

This applies to every path that loads a texture: SetTexture, SetTexCoord sprite sheets, inline texture markup, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one. An addon that uses small textures costs nothing extra.

Two limits remain. Both are about the BLP format, not the image size:

  • A very large uncompressed BLP file does not load. The limit is the file size, about 2 MB, or 32 MB with VanillaHelpers. The texture stays blank. This affects only uncompressed BLP. A DXT-compressed BLP is about eight times smaller and stays under the limit. A TGA file has no size limit.
  • A non-power-of-two BLP with DXT compression or a mip chain is untested.

For a large or non-power-of-two image, use an uncompressed 32-bit TGA, or a DXT-compressed BLP for a smaller file.

local t = frame:CreateTexture(nil, "ARTWORK")
t:SetSize(397, 385)
t:SetTexture("Interface\\AddOns\\MyAddon\\photo")   -- a 397x385 uncompressed TGA

fontstring:SetRotation(angle [, cx, cy])

Rotates a FontString's text by angle radians. A positive angle turns the text counter-clockwise.

The optional cx, cy set the pivot point, as a normalized position inside the text from 0 to 1. The default pivot is the center, (0.5, 0.5). An angle of 0 clears the rotation and returns the text to upright.

The rotation is visual only. GetStringWidth, GetStringHeight, GetRect, and SetPoint all stay axis-aligned, so a rotated label measures and anchors as if it were upright. GetRotation() returns the current angle in radians.

The method turns the glyph vertices, so the whole text stays visible. It works on any FontString, and the rotation holds across text changes, moves, and resizes. It costs nothing per frame while the text stays still.

Inline |T…|t icons inside a rotated FontString do not turn with the text. They draw as separate anchored regions. Plain text rotates correctly.

local fs = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
fs:SetPoint("CENTER")
fs:SetText("Rotate me")
fs:SetRotation(math.rad(45))     -- 45 degrees counter-clockwise
fs:SetRotation(0)                -- back to upright

editBox:SetCursorPosition(position)

Moves the text cursor to position in the edit box, and clears any selected text. position is a 0-based offset in bytes. For plain ASCII text a byte offset is the same as a character index. The engine limits an out-of-range value to the text length, so editBox:SetCursorPosition(string.len(text)) always lands at the end.

editBox:GetCursorPosition()

Returns the cursor's current offset in the edit box, in bytes.

local box = ChatFrameEditBox
box:SetText("hello world")
box:SetCursorPosition(5)          -- cursor after "hello"
print(box:GetCursorPosition())    -- 5

editBox:GetUTF8CursorPosition()

Returns the cursor position as a character count, not a byte count. For text with only ASCII characters this equals GetCursorPosition. For text with multibyte characters it counts characters, so the two values differ.

editBox:ClearHighlightText()

Clears the selected text, leaving the cursor where it is.

editBox:HasFocus()

Returns true if this edit box has keyboard focus.

editBox:HasText()

Returns true if the edit box holds any text.

editBox:SetHighlightColor(r, g, b [, a])

Sets the color of the text-selection highlight. Each value is 0 to 1. Alpha is optional and defaults to 1. The new color shows the next time the box paints a selection.

ChatFrameEditBox:SetHighlightColor(1, 0, 0)   -- red selection

editBox:GetHighlightColor()

Returns the selection-highlight color as four numbers: r, g, b, a, each 0 to 1.

editBox:ClearHistory()

Removes every line from the up/down input history. The history line limit (SetHistoryLines) stays, so the box keeps recording new lines afterward. Does nothing when the box has no history.

frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])

A rename of the SetMinResize / SetMaxResize pair. The max pair is applied only when both values are given.

frame:HookScript(scriptType, handler)

Adds HookScript to every frame. It chains: the previously-set handler runs first, globals-style (no arguments — it reads the this/event/argN globals like every handler), then handler is invoked with positional arguments: (frame, event, arg1..arg9) for OnEvent scripts and (frame, arg1..arg9) for everything else. So handlers written as function(self, ...) ... end work unmodified. Globals-style handlers passed to HookScript also keep working — the globals are set as usual and extra arguments are simply ignored.

button:HookScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
    GameTooltip:SetText("hello")
    GameTooltip:Show()
end)

frame:IsEventRegistered(event)

Returns true if the frame is registered for event, else false (also false for an unknown event name). If the registration came from frame:RegisterUnitEvent, the unit tokens follow as extra returns, in the order they were given. Works on any frame.

local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:IsEventRegistered("PLAYER_LOGIN")   -- true
f:IsEventRegistered("PLAYER_LOGOUT")  -- false

f:RegisterUnitEvent("UNIT_HEALTH", "player", "target")
f:IsEventRegistered("UNIT_HEALTH")    -- true, "player", "target"

frame:RegisterUnitEvent(event, ...units)

Registers the frame for event, but the frame's OnEvent runs only when the event's first argument (the unit token) is one of units. Use it in place of RegisterEvent for UNIT_* events, so a handler for one unit does not receive every other unit's events. Returns whether the frame is now registered.

local f = CreateFrame("Frame")
f:RegisterUnitEvent("UNIT_HEALTH", "player")
f:SetScript("OnEvent", function(self, event, unit)
    -- unit is always "player" here
end)

Unit tokens compare without regard to case. Every token that names a unit works, including focus, nameplateN, and markN. A unit that is several tokens at once (your target who is also party1) fires the event once for each token that matches.

Pass as many units as you need. Other clients take at most four, so keep to four for code you also run elsewhere — accepting more is a ClassicAPI extension.

f:RegisterUnitEvent("UNIT_HEALTH", "player", "party1", "party2", "party3")

Behavior notes:

  • A registration keeps its kind until you unregister it. RegisterEvent on a unit-filtered registration keeps the filter. RegisterUnitEvent on a plain registration keeps it plain. RegisterUnitEvent on a unit-filtered registration replaces the units. To switch kinds, call UnregisterEvent first.
  • If you give no unit, or no unit argument is a string, the call acts as RegisterEvent.
  • The filter applies only when the event's first argument is a string. For an event whose first argument is a number, or that has no arguments, the frame receives the event as with RegisterEvent.
  • UnregisterEvent and UnregisterAllEvents remove the filter with the registration.
  • frame:IsEventRegistered(event) returns the unit tokens after the boolean.

FrameUtil.RegisterFrameForUnitEvents(frame, events, ...units) calls RegisterUnitEvent for each event in the events table.

frame:GetEffectiveAlpha()

Returns the frame's effective alpha — its own GetAlpha() multiplied by every ancestor's, i.e. the opacity WoW actually composites at render time (fading UIParent fades all of its children). The engine exposes only GetAlpha (a frame's own alpha); this walks self → parent → … via the engine's own GetAlpha/GetParent, so it sees exactly what the engine does — including the 8-bit quantization of alpha (SetAlpha(0.5) stores 127/255 ≈ 0.498, and effective alpha is the product of those).

UIParent:GetEffectiveAlpha()   -- 1
-- with UIParent at 0.5 and Minimap's own alpha 1:
Minimap:GetEffectiveAlpha()    -- ~0.498 (0.5 truncates to 127/255)

frame:SetAttribute(name, value) / frame:SetAttributeNoHandler(name, value) / frame:ClearAttribute(name) / frame:GetAttribute(...)

Adds the frame attribute system — a per-frame, case-insensitive key→value store — as native methods on every frame. value can be any Lua type and round-trips exactly.

cleared = frame:ClearAttribute(name) removes an attribute and returns whether it was set. Unlike SetAttribute, it does not fire OnAttributeChanged.

f:SetAttribute("unit", "party1")
f:GetAttribute("unit")            -- "party1"
f:ClearAttribute("unit")          -- true  (was set; now removed)
f:ClearAttribute("unit")          -- false (nothing to clear)
f:SetAttribute("count", 3)
f:GetAttribute("count")           -- 3
f:GetAttribute("missing")         -- nil

GetAttribute also has the modifier form GetAttribute(prefix, name, suffix), which tries, in order, and returns the first match (the precedence used for type1/*type1-style resolution):

  1. prefix..name..suffix
  2. "*"..name..suffix
  3. prefix..name.."*"
  4. "*"..name.."*"
  5. name

SetAttributeNoHandler sets the value without firing OnAttributeChanged; SetAttribute fires it (see below).

Unit-frame mouseover — the headline use. Setting a string unit attribute makes the frame a mouseover source: while the cursor is over it, the mouseover unit token resolves to that frame's unit. This is the piece of SecureUnitButton behavior unit-frame addons rely on — and there is no combat lockdown or taint here, so no secure machinery is needed to provide it.

local f = CreateFrame("Button", "MyUnitFrame", UIParent)
f:SetWidth(120); f:SetHeight(40)
f:SetPoint("CENTER")
f:SetAttribute("unit", "party1")
-- Hover it → the `mouseover` token resolves to party1:
--   UnitName("mouseover"), UnitHealth("mouseover"), GameTooltip:SetUnit("mouseover"),
--   mouseover-cast, and UPDATE_MOUSEOVER_UNIT all work.

How it works, and why it's robust: rather than installing OnEnter/OnLeave on the frame (which an addon's own SetScript("OnEnter", …) would overwrite — pfUI sets unit before its scripts, for instance), this mirrors SuperWoW's SetMouseoverUnit. The engine's mouse-focus frame is watched once per frame; when a hovered frame carries a unit attribute, the engine's real mouseover setter is invoked for that unit — 1:1 with hovering the unit's 3D model: the model highlights, the mouseover tooltip builds, and UPDATE_MOUSEOVER_UNIT fires, in addition to the GUID slot being set. So everything that reads mouseover — the resolver's mouseover branch, GameTooltip:SetUnit("mouseover"), UnitX("mouseover"), mouseover-cast — sees it natively. Because nothing touches the frame's scripts, an addon setting its own handlers can't break it; and it's stomp-proof (the engine's 3D-hover setter is event-driven, so while the cursor is over UI nothing overwrites the slot). It follows live token changes (a unit="target" frame tracks your current target while hovered). Setting a string unit also EnableMouses the frame so a bare frame becomes hoverable at all (real unit frames already are). The unit value may be any token the resolver understands — "party1", "target", "focus", "nameplateN", or a raw GUID literal. Set unit to a non-string (e.g. nil) to stop the binding.

Click actions (type1 / type2 / …). A type attribute makes clicking the frame perform one action on its unit — the secure-button model: exactly one verb per click, resolved from the attributes.

f:SetAttribute("type1", "target")        -- left-click targets the unit
f:SetAttribute("type2", "focus")         -- right-click sets ClassicAPI focus to it
-- click-casting, expressed purely as attributes:
f:SetAttribute("shift-type1", "spell")
f:SetAttribute("shift-spell1", "Flash Heal")   -- shift-left-click heals the unit

Resolution. The verb is read from [prefix]type[suffix], where the prefix is the held modifiers (alt-, ctrl-, shift-, in that order) and the suffix is the button number (1=Left, 2=Right, 3=Middle, 4/5=side). Precedence is prefix..type..suffix → type..suffix → type, so type1 applies under any modifier unless a modifier-specific attribute (shift-type1) overrides it — and type (no suffix) is the catch-all.

Verbs. The resolved verb reads its own extra attributes (also modifier/button-qualified, same precedence as type):

Verb Extra attributes Action
target — Targets the unit (or clears the target if unit is "none"). Respects the engine's default-interaction precedence: with a spell on the cursor it casts on the unit, with an item on the cursor it drops it on the unit, instead of switching target.
assist — Targets the unit's target.
focus — Sets the ClassicAPI focus to the unit.
spell spell Casts the spell. With a unit, it casts on that unit via C_Spell.CastAtUnit — the unit's GUID goes straight to the cast dispatcher (no target juggling), and a ground-target spell lands at the unit's feet. With no unit, it casts on the current target through CastSpellByName, like a plain /cast.
item item, or bag+slot Uses an item on the unit. item may be a name / itemID / link (used via C_Item.UseItemByName, unit as the target) or a "bag slot" string like "0 1" (used via UseContainerItem). The deprecated bag+slot attributes are used when item is unset.
macro macrotext or macro Runs the macro text. To give the macro a unit, the handler selects the clicked unit, runs the macro, then restores the target from before the click. It prefers an addon RunMacro (for example, SuperCleveRoidMacros — named macros and extended conditionals). If no addon RunMacro exists, it runs the text natively, line by line, through the stock ChatEdit_ParseText.
stopcasting — Stops the current cast.
menu / togglemenu — Pops the standard unit dropdown at the cursor (whisper / inspect / trade / invite / …, the same menu PlayerFrame / TargetFrame / PartyMemberFrame show).

Every verb that acts on a unit uses the frame's unit attribute (resolved with the same modifier/button precedence).

One verb per click. Setting a type* attribute installs a chained OnClick on that frame only (nothing global). When a verb resolves, our handler owns the click and does not run the frame's previous OnClick — so a configured type1 never fires alongside the addon's own click handler. Unconfigured clicks (no matching type, or an unrecognized verb) fall through to the frame's own OnClick, so an addon can keep custom behavior there. The frame must be a Button registered for the click (left is the Button default; right needs RegisterForClicks("RightButtonUp")).

Clobbering. The handler self-heals: addons that re-SetScript("OnClick", …) (pfUI on every raid relayout) replace our closure, so re-set a type* attribute afterward to reinstall — re-wiring detects and skips its own closure, so it never double-chains.

OnAttributeChanged is a real SetScript / GetScript / HookScript-able frame script, on every frame — SetAttribute fires it after setting the value, SetAttributeNoHandler doesn't. As with all frame scripts, the handler takes no parameters and reads its context from globals: this = the frame, arg1 = the (lowercased) attribute name, arg2 = the new value.

f:SetScript("OnAttributeChanged", function()
    if arg1 == "unit" then
        MyFrame_Update(this, arg2)   -- react to unit changes
    end
end)
f:SetAttribute("unit", "party1")     -- fires the handler (arg1="unit", arg2="party1")
f:SetAttributeNoHandler("unit", "party2")  -- sets it silently, no handler

It's implemented the same way as the tooltip-side OnTooltipSet* scripts — a co-hook on the base-frame script-name resolver that hands out an external per-frame handler slot for this one name (frames are never destroyed, so the slot never goes stale). Recursion-guarded, so a handler may itself call SetAttribute.

SetModernScriptArgs(enable) / GetModernScriptArgs()

Global toggle (not a frame method) for positional script arguments. The engine always invokes a frame-script handler with zero Lua arguments — the handler reads this / arg1..argN / event as globals. With this enabled, every handler additionally receives its values as real positional arguments, so handlers written as function(self, delta) … end work unmodified:

  • most scripts: (self, arg1..argN) — e.g. OnMouseWheel(self, delta), OnClick(self, button), OnValueChanged(self, value), OnUpdate(self, elapsed).
  • OnEvent: (self, event, arg1..argN).

The this / arg1 / event globals stay set, so a handler that reads them still works. A handler that declares no parameters is unaffected — it cannot see the positional arguments. SetModernScriptArgs(enable) sets the state and returns it. GetModernScriptArgs() returns the current state.

Caveat — a parameter that was always nil now gets a value. With zero arguments passed, any parameter a handler declared was always nil. When this feature is on, a declared parameter gets its real value. A function(self, delta) handler needs this behavior. But it also changes a handler that declared a parameter and expected it to be nil. One example is a function used both as a direct call (with a real argument) and as a script handler. If such a handler misbehaves, disable the feature with SetModernScriptArgs(false).

Default ON. Positional handler signatures are a core Lua 5.1 feature, so ports that use them work with no setup. It reimplements the tail of the engine's hottest Lua path (the runner that fires for every OnUpdate, every frame); if you ever need the exact stock dispatch, SetModernScriptArgs(false) turns it off and both runners become a straight passthrough.

-- On by default; SetModernScriptArgs(false) would turn it off.
local f = CreateFrame("Frame")
f:EnableMouseWheel(true)
f:SetScript("OnMouseWheel", function(self, delta)
    -- `self` and `delta` are bound; `this` / `arg1` still work too
end)

GetModernScriptArgs()       -- true (default)

SecureCmdOptionParse(options [, quiet])

Parses a macro conditional string. Returns the value of the first clause that matches. This is the parser behind /cast [combat] Spell-style options.

The options string is a list of clauses, separated by ;. A clause is zero or more [...] condition groups, then a value:

SecureCmdOptionParse("[combat] Attack; [nostealth] Prowl; Rest")

Rules:

  • A clause with no group always matches.
  • Groups in one clause are OR'd. The first group that passes wins.
  • Conditions in a group are separated by , and are AND'd.
  • A no prefix negates a condition. A :a/b/c suffix adds arguments, which are OR'd.
  • A @unit or target=unit piece sets the group's target. Conditions that need a unit use it, and default to "target".
  • The unit can be a character name as well as a token, so [target=Feral] Rejuvenation heals the player called Feral. The name matches on its start and picks the nearest match, and it has to be someone around you. See UnitTokenFromName.
  • A group whose only piece is a @unit matches only while that unit exists. So [@mouseover][] Spell uses the mouseover unit when there is one, and falls through to [] (your target) when there is not. A bare [] always matches. A group with conditions leaves the existence test to them, so [@focus,noexists] works as written.
  • @none and @cursor name no unit, so they always match. @none asks for no unit: /target clears your target, and the other commands act with no unit named. @cursor names the position under your mouse, which /cast and /use use to place a ground-target spell or item.

Returns the matched value, plus the passing group's target token as a second value (nil when the group set no target). Returns nil when no clause matches.

A condition this parser does not know cannot pass, so its group fails. The name of the first such condition comes back as a third value, and the parser also prints it once. Pass quiet to get the third value without the message.

Use quiet when you read options that somebody else wrote. Another macro addon has its own conditions, and a macro that works under that addon names conditions this parser never heard of. The third value tells you those options belong to that addon, so you can leave them to it. The #showtooltip reader does exactly this: a macro whose conditions it cannot evaluate stays as the engine left it.

SecureCmdOptionParse("hello")                -- "hello"
SecureCmdOptionParse("[combat] a; b")        -- "a" in combat, else "b"
SecureCmdOptionParse("[@focus,harm] a; b")   -- "a", "focus" when focus is hostile
SecureCmdOptionParse("[nocombat] rest")      -- "rest" out of combat, else nil

Supported conditions. Each maps to real 1.12 state:

Condition Meaning
combat the player is in combat
exists / dead the target exists / is dead or a ghost
help / harm the target is friendly / hostile
party / raid the target is in your party / raid
group / group:party / group:raid you are in a group / party or raid / raid
stance / stance:N you are shapeshifted / in bar form N (form is the same)
stealth / mounted / swimming / indoors / outdoors player state
mod / mod:shift / mod:ctrl / mod:alt a modifier key is held (modifier is the same)
button:N the click running now used button N — 1 left, 2 right, 3 middle, 4, 5 (btn is the same)
bar:N / actionbar:N the current action bar page is N
bonusbar / bonusbar:N a bonus bar is active / bonus bar N is active
pet / pet:name you have a pet / a pet with that name or family
channeling / channeling:spell you are channeling / channeling that spell
equipped:type an item of that type, subtype, or slot is equipped (worn is the same)
cursor the cursor holds an item, a spell, or money
spec / spec:1 always true (there is one spec)
known:spellID / known:name you know that spell

Always false. flying, flyable, vehicleui, and unithasvehicleui describe state that does not exist here, so they never match.

Unknown condition. An unrecognized keyword never matches, and prints a warning once. Keywords are case-sensitive: [Combat] is unknown, [combat] is not.

The [stance:N] number is the shapeshift bar slot, not the form ID that GetShapeshiftFormID() returns. On a druid, bar slot 1 is Bear and slot 3 is Cat.

[button:N] reads the button of the click the macro is running inside, the same value GetMouseButtonClicked() returns. You can give a button name in place of the number: [button:rightbutton] and [button:2] are the same test, and names are not case-sensitive.

When no click is running, [button:N] answers as if the left button were used. That is the case for a state driver poll and for the re-evaluation behind a macro's #showtooltip icon, neither of which comes from a click.

known is a ClassicAPI extension, not a Blizzard conditional. A numeric argument uses IsPlayerSpell, so it matches any known spell — a spellbook spell, a talent, a profession recipe, or a racial. A name argument matches only a spellbook spell, because there is no name-to-ID resolver. Use the spell ID for a talent or a recipe.

RegisterStateDriver / UnregisterStateDriver

RegisterStateDriver(frame, state, values) drives a frame's state from a macro conditional string. The driver re-runs SecureCmdOptionParse(values) on a 0.2 second poll and applies the result.

For the state "visibility", the value "show" or "hide" shows or hides the frame:

RegisterStateDriver(myFrame, "visibility", "[combat] hide; show")

For any other state, the driver sets the attribute "state-"..state to the value. An OnAttributeChanged handler then reacts to it.

UnregisterStateDriver(frame, state) removes one driver. UnregisterStateDriver(frame) with no state removes every driver on the frame.

The driver also rescans at once on combat, target, focus, form, pet, group, action-bar, and modifier-key changes, so state that a poll would lag catches up without delay.

RegisterAttributeDriver / UnregisterAttributeDriver

RegisterAttributeDriver(frame, attribute, values) is the same as the state-driver pair, but it sets attribute directly instead of adding the "state-" prefix. An attribute name that starts with _ is ignored.

The driver coerces a numeric value to a number, and the literal string "nil" to nil, before it sets the attribute.

UnregisterAttributeDriver(frame, attribute) removes one driver. UnregisterAttributeDriver(frame) with no attribute removes every driver on the frame.

RegisterUnitWatch / UnregisterUnitWatch / UnitWatchRegistered

RegisterUnitWatch(frame [, asState]) shows or hides a frame as its unit attribute comes into and out of existence. Set the unit attribute first:

myUnitFrame:SetAttribute("unit", "target")
RegisterUnitWatch(myUnitFrame)     -- shown only while you have a target

With asState true, the watch sets the boolean attribute "state-unitexists" instead of calling Show/Hide, so an OnAttributeChanged handler drives the visibility.

The Show/Hide form also sets a "statehidden" attribute (true when hidden), so unit-frame code can tell a driver-hidden frame from one the user hid.

UnregisterUnitWatch(frame) stops the watch. The frame stays in its last shown or hidden state. UnitWatchRegistered(frame) returns whether a watch is active.

SecureButton_GetAttribute / SecureButton_GetUnit

SecureButton_GetAttribute(frame, name) reads an attribute. When the frame opts in with a "useparent-"..name or "useparent*" attribute, it walks up to the parent frame to find the value.

SecureButton_GetUnit(frame) returns the frame's unit attribute, with an optional unitsuffix attribute appended. The unit-watch and click systems use it to find a frame's unit.

No taint. These are functional copies of the secure templates. There is no combat lockdown or taint here, so nothing is protected — the names exist for addon compatibility.

PreClick / PostClick button scripts

Two button scripts that run immediately before and after a click. They are real scripts: set them with SetScript, read them with GetScript, and hook them with HookScript.

One click runs the handlers in this order:

PreClick  ->  OnClick  ->  PostClick

PreClick and PostClick fire on every click. The button does not need an OnClick handler.

Each handler gets arg1 — the mouse button name ("LeftButton", "RightButton", …) — the same value OnClick gets. With positional arguments on (the default), the handler signature is function(self, button).

A CheckButton changes its checked state before PreClick runs, so GetChecked() inside PreClick already returns the new state.

A double-click runs OnDoubleClick only. It does not run PreClick or PostClick.

local btn = CreateFrame("Button", "MyButton", UIParent)
btn:SetScript("PreClick",  function() print("before the click:", arg1) end)
btn:SetScript("OnClick",   function() print("the click") end)
btn:SetScript("PostClick", function(self, button) print("after the click:", button) end)

Button:RegisterForClicks("AnyUp" | "AnyDown" | ...)

RegisterForClicks accepts two collective names:

  • "AnyUp" — the button responds to the release of every mouse button.
  • "AnyDown" — the button responds to the press of every mouse button.

"AnyUp" stands for the five *Up names and "AnyDown" for the five *Down names. You can mix them with the explicit names.

btn:RegisterForClicks("AnyUp")
btn:RegisterForClicks("AnyDown", "LeftButtonUp")

The explicit names are LeftButtonUp, LeftButtonDown, RightButtonUp, RightButtonDown, MiddleButtonUp, MiddleButtonDown, Button4Up, Button4Down, Button5Up, and Button5Down. Names are not case-sensitive.

A name that is not in this list counts as no click type. A call with only unknown names makes the button respond to no click at all. Button:Click() ignores the click types, so a button in that state still responds to Click().

GetClickFrame(name)

Returns the frame with the given global name. The /click command uses it to change a frame name into the frame.

The function returns the frame only when _G[name] holds a real frame, and the GetName() of that frame is equal to name. If the global holds a different value, a plain table, or a frame with a different name, the function returns nil. This name test makes sure that a changed global does not return the wrong frame.

GetClickFrame("MyButton")     -- the frame named "MyButton", or nil

FriendList

C_FriendList.SendWhoQueryByName(name)

Issues a /who name-filter query for a specific player. Results buffer into the engine's WhoList so a normal WHO_LIST_UPDATE + GetWhoInfo(i) flow can read them — no chat output, no "Found N players matching..." system message.

The engine exposes SendWho(query) and SetWhoToUI(flag) only as separate primitives, so addons that want to silently look up a single player's class/level/zone (e.g. to color an unknown name in chat) have to manage state, cooldown timing, and friends-panel suppression themselves. This collapses the invocation half of that dance to one call with a clean true/false return.

Returns true if the query was sent, false if any of:

  • the name was empty / nil
  • the call is within the 5-second cooldown of a previous send
  • the engine's WhoSystem isn't initialized yet (pre-login)

A false return is a no-op — the engine state isn't touched, no pending flag is set, and the cooldown isn't extended. Safe to call on every chat line that mentions an unknown player; the call will naturally rate-limit.

The cooldown matches the server's: CMSG_WHO is silent-dropped server-side at roughly 5-second granularity, so a faster client just wastes queries that won't get a response.

if C_FriendList.SendWhoQueryByName("Bob") then
    -- query sent; result will arrive via WHO_LIST_UPDATE within ~1s
end

This call alone does not suppress the friends-panel popup on response — for that, wrap FriendsFrame_OnEvent and gate on C_FriendList.IsWhoQueryPending():

local original = FriendsFrame_OnEvent
_G.FriendsFrame_OnEvent = function()
    if event == "WHO_LIST_UPDATE" and C_FriendList.IsWhoQueryPending() then
        return
    end
    return original()
end

(Auto-suppressing the popup from C++ would require hooking the engine's FrameScript_SignalEvent or the SMSG_WHO opcode handler — both high-traffic / high-risk sites. The wrap above is reliable and takes 5 lines, so it stays addon-side for now.)

Implementation reads the WhoSystem singleton from 0x00C28168 and calls the inner sender at 0x005AEBB0 (__thiscall(this = WhoSystem, queryStr)), the same chokepoint Script_SendWho tail-calls. The whoToUI flag at 0x00C2A12C is flipped to 1 first so the SMSG_WHO handler routes results into the list (WHO_LIST_UPDATE path) instead of printing to chat.

C_FriendList.IsWhoQueryPending()

Returns true within ~5 seconds of the most recent SendWhoQueryByName that returned true; otherwise false. Time-based — the engine doesn't expose a "response arrived" hook to C++ yet, so the window is a conservative upper bound on the response RTT, which in practice lands within a few hundred ms.

The intended use is gating an addon-side FriendsFrame_OnEvent wrap so the user-issued /who window still works while addon- issued silent queries don't pop the panel — see the example under SendWhoQueryByName.

Concurrent callers see a shared pending flag — if pfUI and another addon both call SendWhoQueryByName close in time, both queries contribute to the same pending window. This is acceptable for the "suppress popup for any in-flight DLL-issued query" use case; addons that need per-call tracking should manage their own ticket state on top.

C_FriendList.GetNumWhoResults()

Returns two numbers: how many /who results you can read, and a server-reported count.

local shown, serverCount = C_FriendList.GetNumWhoResults()

shown is the number of entries you can index with GetWhoInfo. The server caps the list (50, or 49 on some cores), so shown is at most that. Run a /who (or SendWhoQueryByName) first to populate the list.

serverCount is the second header value the server sends, and its meaning depends on the server core:

  • Mangos and cmangos derived cores send the number of characters that matched your query.
  • Turtle WoW (tortoise-wow) sends the whole realm's online population when more than 49 players are online, ignoring your filter. Below that count it sends the match count.

Do not rely on one meaning across servers. On Turtle it is a live population counter for any query. On other cores it is a match total.

C_FriendList.GetWhoInfo(index)

Returns a table for the /who result at index (1-based), or nil when the index is out of range.

local info = C_FriendList.GetWhoInfo(1)
-- {
--   fullName = "Nadamage", fullGuildName = "",
--   level = 26, raceStr = "Troll",
--   classStr = "Mage", filename = "MAGE",
--   area = "Hillsbrad Foothills",
-- }

Table fields:

  • fullName — the character name.
  • fullGuildName — the guild name, or "" when the player has no guild.
  • level — the character level.
  • raceStr — localized race name, or nil when unknown.
  • classStr — localized class name, or nil when unknown.
  • filename — locale-independent class token ("WARRIOR", "MAGE", …), or nil when unknown. This is the key for RAID_CLASS_COLORS. The stock GetWhoInfo does not provide it, which is the main reason to use this table form.
  • area — the zone name.

There is no gender field — the /who result stores no sex.

The global GetWhoInfo(index) is also kept, which returns positional values and no class token. This namespaced form returns a table and adds filename.

C_FriendList.IsFriend(guid)

Returns true if the player with the given GUID is on your friends list, false if not.

C_FriendList.IsFriend(UnitGUID("target"))    -- true if the target is a friend
C_FriendList.IsFriend("0x00000000000ABCDE")

guid is a GUID string — the "0x…" form that UnitGUID returns. You can also pass a plain character name, because the friends list is keyed by name (AddFriend(name)).

The check reads the engine's friends list directly. It matches the input GUID against each friend's stored GUID. The server sends that GUID for both online and offline friends. As a fallback it also compares character names, so the answer holds for any friend the client saw this session.

C_FriendList.IsIgnored(token)

Returns true if the player is on your ignore list, false if not.

C_FriendList.IsIgnored("Bob")
C_FriendList.IsIgnored("0x00000000000ABCDE")

token is a character name or a GUID string. The ignore list stores GUIDs, not names. A GUID always matches. A name matches only a player the client has seen this session — the same players GetIgnoreName can name — because the name comes from the client's name cache.

C_FriendList.IsIgnoredByGuid(guid)

Returns true if the player with the given GUID is on your ignore list, false if not.

C_FriendList.IsIgnoredByGuid(UnitGUID("target"))
C_FriendList.IsIgnoredByGuid("0x00000000000ABCDE")

guid is a GUID string — the "0x…" form that UnitGUID returns. This is the exact key the ignore list uses, so it works for every ignored player.

C_FriendList.GetNumFriends()

Returns the number of players on your friends list. The stock global GetNumFriends() returns the same count — this is the namespaced form.

C_FriendList.GetNumFriends()   -- e.g. 12

C_FriendList.GetNumOnlineFriends()

Returns how many friends on your list are online. This is a subset of GetNumFriends, which counts online and offline friends together.

C_FriendList.GetNumOnlineFriends()   -- e.g. 3

There is no stored online count, so the value is the number of friends whose connected flag is set — the same flag GetFriendInfo reports.

C_FriendList.GetFriendInfo(name)

Returns a FriendInfo table for the friend with the given name, or nil if that name is not on your list. The name match is case-insensitive.

local info = C_FriendList.GetFriendInfo("Sarahnity")
-- {
--   name = "Sarahnity", connected = true, level = 60,
--   className = "Druid", classFilename = "DRUID", area = "Stratholme",
--   guid = "0x000000003B9CB7BF",
--   afk = false, dnd = false,
--   mobile = false, referAFriend = false, rafLinkType = 0,
-- }

Table fields:

  • name — the friend's character name.
  • connected — true when the friend is online.
  • level — the friend's level, or 0 when unknown.
  • className — localized class name, or nil when unknown.
  • classFilename — locale-independent class token ("WARRIOR", "MAGE", …), or nil when unknown. Present whenever className is. This is the key for RAID_CLASS_COLORS and other class tables, so you can color a friend's name without matching the localized class name.
  • area — localized zone name, resolved to the parent zone, or nil when unknown.
  • guid — the friend's GUID string.
  • notes — your note for this friend, or nil if none. Set it with SetFriendNotes.
  • afk / dnd — the friend's status flags.
  • mobile / referAFriend / rafLinkType — always false / false / 0. There is no mobile app, Recruit-A-Friend, or RAF link data.

Notes are a ClassicAPI addition — there is no stock note field. ClassicAPI persists them per character in WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt, local to this client (not stored on the server, not shared with other machines).

C_FriendList.GetFriendInfoByIndex(index)

The same FriendInfo table, addressed by a 1-based list index instead of a name. Returns nil for an index below 1 or above GetNumFriends.

for i = 1, C_FriendList.GetNumFriends() do
    local info = C_FriendList.GetFriendInfoByIndex(i)
    print(info.name, info.connected, info.className, info.area)
end

See C_FriendList.GetFriendInfo for the field list.

C_FriendList.SetFriendNotes(name, notes)

Sets your note for the friend with the given name. Pass an empty string or nil to clear it. There is no return value, and the name must already be on your friends list.

C_FriendList.SetFriendNotes("Sarahnity", "raid healer")
C_FriendList.SetFriendNotes("Sarahnity", "")   -- clears the note

The note is stored client-side and read back through the notes field of GetFriendInfo. Setting a note fires FRIENDLIST_UPDATE so the friends UI and note-aware addons refresh. See the persistence note under GetFriendInfo.

C_FriendList.SetFriendNotesByIndex(index, notes)

The same as SetFriendNotes, addressed by a 1-based list index instead of a name. An out-of-range index does nothing.

C_FriendList.SetFriendNotesByIndex(1, "tank")

GameObject

The gameobject analog of C_CreatureInfo — reads the client-side gameobject cache (gameobjectcache.wdb, fed by SMSG_GAMEOBJECT_QUERY_RESPONSE) by GO entry ID.

C_GameObjectInfo.GetGameObjectInfoByID(gameObjectID)

Synchronous peek — returns a table for a cached gameobject, nil otherwise:

local info = C_GameObjectInfo.GetGameObjectInfoByID(47297)
-- { gameObjectID = 47297, name = "Mesa Elevator", type = 11, displayID = 360 }

type is the GameObjectType (0=door, 1=button, 3=chest, 5=generic, 6=trap, 10=goober, …). Field offsets verified against the binary (name [block+0x08]) and real gameobjectcache.wdb rows (Mesa Elevator 47297→type 11; Windrunner 176250→type 15 displayID 7087).

C_GameObjectInfo.RequestLoadGameObjectByID(gameObjectID)

Asynchronously fetches an uncached gameobject — issues SMSG_GAMEOBJECT_QUERY and fires GAMEOBJECT_DATA_LOAD_RESULT( gameObjectID, success) when it lands, after which GetGameObjectInfoByID returns its data. Returns true if accepted; fires synchronously on a cache hit. Same shape as C_CreatureInfo.RequestLoadCreatureByID, and rides the same shared Cache::QueryLoad dispatcher (the gameobject cache is a sibling class of the creature cache with its own _GetRecord/parser, hooked separately).

local f = CreateFrame("Frame")
f:RegisterEvent("GAMEOBJECT_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()  -- arg1 = gameObjectID, arg2 = success
    if arg2 then local info = C_GameObjectInfo.GetGameObjectInfoByID(arg1) end
end)
C_GameObjectInfo.RequestLoadGameObjectByID(7000032)

ClosestGameObjectPosition(gameObjectID)

Returns xPos, yPos, distance — the world position of the nearest game object with the given GO-template ID, and its distance from the player in yards. Returns nothing (nil) when no matching object is in range.

local x, y, dist = ClosestGameObjectPosition(1617)   -- nearest Herb Bush, etc.
if x then
    -- x, y = world coords; dist = yards from the player
end

The game-object counterpart of ClosestUnitPosition — same visible- object-manager scan, filtered to game objects (GUID prefix 0xF110). Same caveat: this finds the nearest currently-visible object of that entry rather than reading a static starting-zone database (there is no such database here).

GameTooltip

GameTooltip:SetItemByID(itemID)

Renders an item tooltip from just an itemID. The alternative is constructing an item hyperlink and calling SetHyperlink — tooltip:SetHyperlink("item:" .. id .. ":0:0:0:0:0:0:0") — which works but forces every caller to know the hyperlink format.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetItemByID(6948)  -- Hearthstone
GameTooltip:Show()

Implementation: snprintf the hyperlink string and dispatch to the existing Script_GameTooltip_SetHyperlink (registry slot 12).

Item cache caveat. 1.12 lazy-loads item data into the client-side cache at 0x00C0E2A0 — the cache is fed by SMSG_ITEM_QUERY_SINGLE_RESPONSE only when the player encounters an item. For an itemID the player has never seen, the tooltip renders only the name; full data appears once the cache is warm.

The fix is to ensure the item is cached before opening the tooltip:

if C_Item.IsItemDataCachedByID(itemID) then
    GameTooltip:SetItemByID(itemID)
else
    C_Item.RequestLoadItemDataByID(itemID)
    -- register for ITEM_DATA_LOAD_RESULT(loadedItemID, success)
    -- and call SetItemByID once the matching itemID arrives
end

This caching behavior matches what C_Item.GetItemInfoInstant documents — same underlying cache. The same caveat applies with C_Item.RequestLoadItemData(itemLocation) / Item:OnItemLoad-style continuation.

GameTooltip:SetItemByGUID(itemGUID)

Renders the tooltip for a specific item instance identified by GUID — same string C_Item.GetItemGUID returns ("0xHHHHHHHHLLLLLLLL"). Distinct from SetItemByID: this path goes through the live CGItem, so the tooltip includes enchant lines, random-suffix-decorated name + bonuses, and locked/broken state — the same depth SetBagItem / SetInventoryItem would produce for that specific copy.

local guid = C_Item.GetItemGUID({ equipmentSlotIndex = 10 }) -- legs
GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetItemByGUID(guid)
GameTooltip:Show()

Resolves through the engine's own FUN_OBJECT_RESOLVE_BY_GUID (same path C_Item.GetItemLocation uses), builds the full item:N:enchant:gem:gem:gem:gem:suffix:unique-style hyperlink via the engine's link builder, then dispatches to Script_GameTooltip_SetHyperlink. The link round-trip is what gives GameTooltip:GetItem() the dressed link back when called after — same shape GetInventoryItemLink / GetContainerItemLink emit for the same slot.

Silent no-op when the GUID is malformed, the item isn't currently loaded in the client (e.g. items only the server knows about), or the GUID resolves to a non-item object (creature/gameobject GUIDs go to a different resolver and won't match).

GameTooltip:SetUnitAura(unit, index, [filter])

Unified-aura method. index is in the same HELPFUL / HARMFUL index space as C_UnitAuras.GetAuraDataByIndex, so an index you got from C_UnitAuras always opens the matching tooltip. That includes a debuff that the server parked in a buff slot. The tooltip itself comes from the engine's own SetUnitBuff or SetUnitDebuff. filter defaults to helpful when omitted. Only its HELPFUL or HARMFUL token is read, so pass an index from the same plain list.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetUnitAura("player", 1, "HELPFUL")  -- first buff
GameTooltip:SetUnitAura("player", 1, "HARMFUL")  -- first debuff
GameTooltip:SetUnitAura("player", 1)              -- defaults to HELPFUL
GameTooltip:Show()

The engine methods index their own slot ranges, which differ from the C_UnitAuras index space once a debuff sits in a buff slot. This method translates between the two, so you never need to without conditionally splitting on filter.

GameTooltip:SetSpellByID(spellID)

Renders a spell tooltip for any spellID, including spells the player has not learned. The stock 1.12 GameTooltip:SetSpell(slot, bookType) only works for entries in the player's spellbook (or pet book). SetSpellByID bypasses the spellbook indirection and calls WoW's internal tooltip builder directly.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetSpellByID(133)  -- Fireball
GameTooltip:Show()

GameTooltip:AddSpellByID(spellID)

The append counterpart to SetSpellByID: adds a spell's full tooltip (name, cast time, cost, range, description) to the current tooltip without clearing the existing lines. Works for any spellID, learned or not. Lets you compose tooltips — e.g. a header line plus a spell block.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:AddLine("Rank 3 grants:")
GameTooltip:AddSpellByID(133)  -- Fireball, appended below the header
GameTooltip:Show()

Unlike SetSpellByID, this does not update what GetSpell reports — the appended spell isn't the tooltip's "primary" spell, so GetSpell keeps reflecting whatever SetX call (if any) built the base tooltip. Silent no-op for spellID <= 0 or an unknown spell.

The engine's tooltip builder only exposes "append" via its internal talent "next rank" preview, which emits a Next rank: header instead of the spell name; we overwrite that header line with the real name so the appended block reads normally.

GameTooltip:GetItem()

Returns (name, link, itemID) for whichever item the tooltip is currently displaying, or nothing if it isn't showing an item. The standard call returns only (name, link); we extend with itemID as a third return so callers don't have to gsub-extract it from the link.

The engine stashes two fields per Set* item call:

  • tooltip+0x398 ← itemID (always populated)
  • tooltip+0x380/+0x384 ← item GUID (only when there's a real CGItem — SetBagItem, SetInventoryItem, SetLootItem, SetMerchantItem, etc. Zero for SetItemByID / SetHyperlink which have no instance.)

Both fields are zeroed by the per-tooltip Clear on Hide / before the next Set*.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")

-- SetItemByID — no per-instance data, basic link returned
GameTooltip:SetItemByID(6948)
local name, link, id = GameTooltip:GetItem()
-- name = "Hearthstone"
-- link = "|cffffffff|Hitem:6948:0:0:0:0:0:0:0|h[Hearthstone]|h|r"
-- id   = 6948

-- SetInventoryItem — full dressed link with enchant + random suffix
GameTooltip:SetInventoryItem("player", INVSLOT_BACK)
local name, link, id = GameTooltip:GetItem()
-- name = "Superior Cloak of the Eagle"   -- decorated name, agrees with the link
-- link = "|cff1eff00|Hitem:9805:247:843:0|h[Superior Cloak of the Eagle]|h|r"
-- id   = 9805

Two link paths:

Set* path Link form Dressing
SetBagItem, SetInventoryItem, SetLootItem, SetMerchantItem, … Full dressed link via the engine's own builder at 0x0052AE00 Enchant ID, random-suffix factor, unique ID, suffix-decorated name
SetItemByID, SetHyperlink (item:N with no instance data) Basic colored link itemID + cached quality + cached name only

The dressed-link path works for items not in player inventory (merchant, loot, mailbox, trade window, etc.) — the engine's resolver finds any CGItem the client has loaded.

Returns nothing for: non-item tooltip, uncached itemID on the no-GUID path (fires a background cache warmup), or empty name.

GameTooltip:GetSpell()

Returns (name, rank, spellID) for whichever spell the tooltip is currently displaying, or nothing if it isn't showing a spell. The engine stashes the spellID on the tooltip frame at +0x39C whenever any SetX spell path runs (SetSpell, SetSpellByID, SetUnitBuff/SetUnitDebuff, SetTalent, etc.) and zeroes it on the next Clear / Hide.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetSpellByID(25306)
local name, rank, spellID = GameTooltip:GetSpell()
-- name = "Fireball"
-- rank = "Rank 12"
-- spellID = 25306

rank is the empty string (not nil) for spells whose Spell.dbc rank slot is blank — most racials, talent passives, and proc-triggered spells. The rank position is always populated.

GameTooltip:HasItem() / GameTooltip:HasSpell()

Boolean companions to GetItem / GetSpell. Return true if the tooltip is currently displaying an item / spell, false otherwise. Cheaper than GetItem / GetSpell when all you need is the predicate — single uint32 read against OFF_TOOLTIP_ITEM_ID / OFF_TOOLTIP_SPELL_ID, no DBC lookup or link-build.

if GameTooltip:HasItem() then
    local _, link = GameTooltip:GetItem()
    -- only do the GetItem work when we actually need it
end

GameTooltip:GetUnitGUID() / GameTooltip:HasUnit()

GetUnitGUID() returns (name, guidString) for whichever unit the tooltip is currently displaying, or nothing if it isn't showing a unit. Return order mirrors GameTooltip:GetUnit() (name first) — so addons porting from local name, unit = ttip:GetUnit() can swap to GetUnitGUID and keep their existing destructuring. name is the unit's display name — the same string that appears in the tooltip header, or one of the engine's "UNKNOWNOBJECT" / "Unknown Being" fallbacks for a remote unit whose info hasn't been queried yet. guidString is the canonical "0xHHHHHHHHLLLLLLLL" format returned by UnitGUID(unit).

HasUnit() is a boolean companion — returns true if the tooltip is currently displaying a unit.

GameTooltip:SetUnit("target")
local name, guid = GameTooltip:GetUnitGUID()
-- name = "Hogger"
-- guid = "0xF130001234..." (Creature) or "0x000000...ABC123" (Player)

if GameTooltip:HasUnit() then
    -- cheap predicate, no name-resolution work
end

Why GetUnit() returns a GUID, not a token. A (name, unitToken) form would return the exact "target" / "focus" / "mouseover" / etc. string passed to SetUnit. But the engine drops the token at the Script_GameTooltip_SetUnit boundary — it converts the token to a 64-bit GUID and discards the original string. Reconstructing a plausible token by walking known tokens and reverse-matching by GUID is possible but lossy (multiple tokens can refer to the same GUID — "target" and "raid1" simultaneously, for instance), so we expose the GUID directly instead, which is what addons actually need for cross-referencing with UnitGUID, the NameCache, etc.

GameTooltip:GetGameObject() / GameTooltip:HasGameObject()

GetGameObject() returns (name, id, guid) for whichever gameobject the tooltip is currently displaying, or nothing if it isn't showing one. name is the cached display name (or "" until the gameobject cache has loaded the record). id is the gameobject's template / entry — same key gameobjectcache.wdb uses, and what the server sent in the spawn packet. guid is the canonical "0xHHHHHHHHLLLLLLLL" format.

HasGameObject() is the boolean companion — returns true if the tooltip is currently displaying a gameobject (single uint32 read, no resolution work).

-- Mouse over a chest, vein, signpost, etc. then:
local name, id, guid = GameTooltip:GetGameObject()
-- name = "Iron Deposit"
-- id   = 1731
-- guid = "0xF110000006C30000..."

if GameTooltip:HasGameObject() then
    -- cheap predicate
end

There is no Lua-callable SetGameObject method — gameobjects only populate the tooltip via in-world mouseover. The engine's hover handler at FUN_00492890 dispatches to a tooltip populator (0x0052AA20) that writes the GUID into tooltip+0x370/+0x374, right between the unit slot (+0x368) and the item slot (+0x380). The shared tooltip-clear zeroes the same slot on every subsequent SetX, so these methods follow the same gating pattern as HasUnit / HasSpell / HasItem.

Returns nothing for: tooltip not showing a gameobject, or the gameobject has left the engine's visibility window (object manager evicted it). HasGameObject() stays true in the latter case — the tooltip-frame slot still has the GUID, even though the live object has been freed.

GameTooltip:GetOwner()

Returns the frame that called tooltip:SetOwner(frame, anchor), or nil if the tooltip hasn't been owned by anyone since its last Clear / Hide. The engine ships SetOwner and IsOwned but never the matching reader; this adds it.

GameTooltip:SetOwner(SomeFrame, "ANCHOR_TOPLEFT")
local owner = GameTooltip:GetOwner()
-- owner == SomeFrame

Returns only the owner frame, not (owner, anchorPoint). The anchor string is reachable via the stock GameTooltip:GetAnchorType() (slot 5 in the GameTooltip method registry).

GameTooltip:SetTalentByID(talentID)

Renders a tooltip for the talent identified by Talent.dbc primary key — the natural pair to GetTalentIDByIndex. Works for any class's talents, not just the player's.

Two-tier resolution:

Tier When it applies Tooltip rendered
Player class (rich) talentID belongs to one of the player's loaded tabs Full talent tooltip — name, "Rank N/M", description, prereqs, "click to learn" prompts
Cross-class (fallback) talentID is from another class Spell tooltip for the talent's rank-1 spellID — name, cast time, range, mana cost, description

The fallback exists because the engine only loads the local player's class talent data into its per-player TabInfo arrays. For other classes, we look up the talent in Talent.dbc directly and dispatch the rank-1 spell tooltip — functionally "what does this talent do?" without the rank counter. Talent name and "Rank 0/N" decorations on top of the spell description for cross-class are not replicated here yet.

Silent no-op (no tooltip change) when:

  • talentID doesn't match any record in Talent.dbc
  • talentID is nil, non-numeric, or non-positive
-- Player's own class — rich tooltip
local talentID = GetTalentIDByIndex(1, 9)        -- player's tab 1, talent 9
GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetTalentByID(talentID)
GameTooltip:Show()

-- Other class — spell tooltip for the talent's primary ability
GameTooltip:SetTalentByID(2065)                   -- works regardless of player class
GameTooltip:Show()

GameTooltip:SetInventoryItemByID(itemID)

Renders the tooltip for the equipped instance of itemID — walks character-pane slots 1..19, finds the matching item, and shows it with its actual enchants, random-suffix stats, and broken/locked state. Distinct from SetItemByID, which shows the clean ItemSparse data with no instance-specific decorations.

For example, on a pair of boots with a run-speed enchant equipped:

Method Renders
SetItemByID(<bootsID>) Base boots tooltip — name, armor, durability, level req. No enchant.
SetInventoryItemByID(<bootsID>) Same plus Enchanted: Minor Speed and any random-suffix lines.

Silent no-op if the item isn't currently equipped — fall back to SetItemByID for unworn items, or check via C_Item.IsEquippedItem first.

When the player has duplicates of the same itemID equipped (matched MH/OH weapons, identical rings, identical trinkets), the lower-numbered slot wins — MAINHAND before OFFHAND, FINGER1 before FINGER2, TRINKET1 before TRINKET2 (verified empirically).

local _, _, _, _, _, _, _, _, _, _, _, _, _, link = GetItemInfo(itemID)
GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
if C_Item.IsEquippedItem(itemID) then
    GameTooltip:SetInventoryItemByID(itemID)  -- shows enchants/suffix
else
    GameTooltip:SetItemByID(itemID)            -- shows base stats
end
GameTooltip:Show()

Fills the tooltip with the item currently equipped in the slot the given item would occupy — a grey Currently Equipped header, the equipped item's own tooltip, then (when shiftButton is set) a green/red per-stat delta breakdown of how the given item compares. This is the method behind item-comparison ("shopping") tooltips; FrameXML's GameTooltip_ShowCompareItem drives it.

Call it on the tooltip that should show the comparison (typically ShoppingTooltip1/ShoppingTooltip2, but any GameTooltipTemplate frame works).

Arg Meaning
itemLink The item being compared (chat link, item: string, or bare itemID). Optional if comparisonTooltip is given.
offset 1-based slot selector for two-slot items — rings, trinkets, and one-hand weapons expose offset 1 and 2 (Finger1/2, Trinket1/2, MainHand/OffHand). Default 1.
shiftButton Gates the stat-change breakdown. Deltas show by default (true/1/nil/omitted) and are hidden only for an explicit false/0 (header + equipped item only). Both the boolean and 1/nil conventions are accepted.
comparisonTooltip Optional. When itemLink is omitted, the compared item is taken from whatever this tooltip is displaying (a tooltip-to-tooltip call).

Returns the number of comparison slots for the item (1, or 2 for rings/trinkets/one-hand weapons; 0 if it isn't equippable, isn't cached, or nothing is equipped in the chosen slot) — so a caller knows whether a second offset is worth querying.

The stat deltas cover the same keys as C_Item.GetItemStats (base stats, resistances, weapon DPS, and on-equip-spell bonuses like crit / attack power / spell power), colored with the client's INCREASE_STAT_COLOR / DECREASE_STAT_COLOR. The method only populates the tooltip; the caller shows/anchors it (as SetHyperlinkCompareItem does).

-- Compare a hovered bag item against what's equipped:
local link = GetContainerItemLink(bag, slot)
ShoppingTooltip1:SetOwner(GameTooltip, "ANCHOR_NONE")
local slots = ShoppingTooltip1:SetHyperlinkCompareItem(link, 1, true)
ShoppingTooltip1:Show()
if slots == 2 then  -- ring/trinket/1H: also compare the other slot
    ShoppingTooltip2:SetOwner(GameTooltip, "ANCHOR_NONE")
    ShoppingTooltip2:SetHyperlinkCompareItem(link, 2, true)
    ShoppingTooltip2:Show()
end

GameTooltip:IsEquippedItem()

Returns true when the item the tooltip is currently displaying is equipped in one of the player's character-pane slots (1..19).

Works regardless of how the tooltip was populated — link paths (SetItemByID, SetHyperlink) and CGItem paths (SetInventoryItem, SetBagItem, SetItemByGUID, …) both resolve to the displayed item. Returns false for a tooltip that isn't showing an item (e.g. a spell tooltip) with no false positives.

GameTooltip:SetInventoryItem("player", INVSLOT_HAND)
if GameTooltip:IsEquippedItem() then ... end  -- true

GameTooltip:SetBagItem(0, 1)
if GameTooltip:IsEquippedItem() then ... end  -- false unless that item is also worn

OnTooltipSet* scripts

OnTooltipSetItem / OnTooltipSetSpell / OnTooltipSetUnit / OnTooltipSetGameObject — real frame scripts, settable with the standard SetScript / GetScript / HookScript — that fire whenever a tooltip's item, spell, unit, or gameobject is set. These tooltip scripts let addons annotate tooltips by hooking one script per object type instead of wrapping every Set* method. Available on all GameTooltip-type frames (GameTooltip, ItemRefTooltip, ShoppingTooltip1/2, AtlasLootTooltip, …).

Script Fires after any of
OnTooltipSetItem SetBagItem, SetInventoryItem, SetHyperlink (item:), SetMerchantItem, SetAuctionItem, SetItemByID, …
OnTooltipSetSpell SetSpell, SetSpellByID, SetTalent, SetShapeshift
OnTooltipSetUnit SetUnit and unit mouseover
OnTooltipSetGameObject gameobject mouseover (herb/ore nodes, chests, mailboxes, signs)

The engine ships only OnTooltipAddMoney / OnTooltipCleared / OnTooltipSetDefaultAnchor; these four are added.

The handler receives the tooltip as the global this, the frame-script convention — not a self argument (like every built-in script: OnShow, OnEvent, OnTooltipCleared, …). A function(self) self:… end handler will see self == nil; use this.

GameTooltip:HookScript("OnTooltipSetItem", function()
    local name, link, id = this:GetItem()   -- the tooltip is the global `this`
    if id then this:AddLine("ID: " .. id, 0.6, 0.6, 0.6) end
end)

GameTooltip:HookScript("OnTooltipSetUnit", function()
    local name, unit = this:GetUnit()
    if unit then this:AddLine(UnitClassBase(unit), 0.6, 0.6, 0.6) end
end)

Auras don't fire OnTooltipSetSpell — SetUnitBuff / SetUnitDebuff / SetPlayerBuff / SetUnitAura use a separate aura-tooltip builder, so hovering a buff/debuff does not trigger the spell script. Unit auras are a distinct tooltip data type rather than a spell.

Caveat — the event fires from inside the tooltip build. Lightweight handler work is fine: reading this:GetItem() / this:GetUnit(), this:AddLine(...), printing, etc. But avoid re-entrant tooltip rebuilds from the handler — e.g. GameTooltip_ShowCompareItem(), or a Set* call on another tooltip. Because the handler runs mid-build, re-entering the tooltip / FrameScript machinery can collide with other DLLs that hook the same Lua paths (nampower, SuperWoW, weirdutils, …) and crash the client. If you need heavy work like that, defer it to the next frame:

GameTooltip:HookScript("OnTooltipSetItem", function()
    C_Timer.After(0, function() GameTooltip_ShowCompareItem() end)
end)

GameTooltip:SetEquipmentSet(name)

Fills the tooltip with a summary of the named equipment set: header, total item count, and per-bucket counts (equipped / in inventory / ignored / missing). Each missing item is listed on its own line by name.

GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:SetEquipmentSet("MyTank")
GameTooltip:Show()
MyTank                     (header, white)
14 items
12 equipped                (green)
1 in inventory
1 slots ignored            (gray)
Missing: Crown of the Endless Conqueror   (red)

Silent no-op if no set with that name exists. The owner anchor must be set before the call — SetEquipmentSet only fills the lines, it doesn't re-anchor the tooltip frame.

Item classification reuses Locations::FindGUID (the same walk GetItemCount uses), so items in the bank count as "in inventory" without requiring the bank window to be open.

Localization. The count lines are formatted through Game::Lua::PushLocalizedFormatInt — Blizzard's ITEMS_VARIABLE_QUANTITY, ITEMS_EQUIPPED, ITEMS_IN_INVENTORY, ITEM_SLOTS_IGNORED, and ITEM_MISSING FrameXML globals are tried first, with English C-string fallbacks for servers stripped of standard GlobalStrings. The companion !!!ClassicAPI addon ships English defaults for these keys so the tooltip works on bare-bones builds where FrameXML hasn't populated them.

Missing-item names. The on-disk equipment-set format (WTF\Account\<acct>\<realm>\<char>\ClassicAPI_EquipmentSets.txt) persists each slot as both a 64-bit GUID and the itemID at save time:

set 1
  name=MyTank
  icon=INV_Helmet_03
  slot 1 guid=0xC00000000A1B2C3D item=51220
  slot 5 ignored

The GUID is the live handle for items currently in inventory; the itemID is the type identifier used when the live CGItem isn't findable (item sold, mailed, on another character, etc.) — without it we couldn't recover the name for missing items. Sets saved before the itemID field was added load fine but render their missing slots with the count summary %d missing instead of named lines; re-saving a set repopulates itemIDs.

GameTooltip:SetTotem(slot)

Fills the tooltip with the shaman totem currently active in slot (1 Fire, 2 Earth, 3 Water, 4 Air).

GameTooltip:SetOwner(TotemButton, "ANCHOR_BOTTOMRIGHT")
GameTooltip:SetTotem(1)   -- Fire slot; shows itself, no :Show() needed
Searing Totem              (name, yellow)
45 Sec                     (time remaining, white)

Two lines: the totem name (NORMAL_FONT_COLOR) and the time remaining (white). Built natively with the engine's own per-tooltip clear and raw add-line — the same path SetSpellByID and SetHyperlinkCompareItem use — and shows itself at the end, so no trailing :Show() is required. Set the owner/anchor before the call.

Silent no-op when the slot has no active totem, so a totem-button OnEnter can call it unconditionally.

Localization. The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute, minutes rounded up above) and formats through the FrameXML GlobalStrings SPELL_TIME_REMAINING_SEC / SPELL_TIME_REMAINING_MIN (English %d Sec / %d Min fallbacks for servers stripped of those keys). The totem name comes from the summon spell's localized Spell.dbc name. Slot data is the same tracker behind GetTotemInfo.

Globals

CLASSIC_API_VERSION

Defined once FrameScript has booted. Addons can use this to detect that the DLL is loaded and which version is in use. The value is X*10000 + Y*100 + Z for a tag of vX.Y.Z passed to CMake at configure time via -DCLASSICAPI_TAG=vX.Y.Z.

Untagged builds (local dev, CI without a release tag) get a sentinel value of 99999999 — encoded as v9999.99.99, chosen to be higher than every plausible real release so addon-side feature gates like CLASSIC_API_VERSION >= 10200 don't reject the dev build.

if CLASSIC_API_VERSION and CLASSIC_API_VERSION >= 10200 then
    -- ClassicAPI v1.2.0 or newer is loaded (or untagged dev build)
end

If you specifically want to detect a dev build:

if CLASSIC_API_VERSION == 99999999 then
    -- running against a locally-built, untagged DLL
end

INTERFACE_VERSION

The client's interface (TOC) version number — 11200 on this client. This is the number the addon loader checks each addon's ## Interface: line against, so it's the right value to compare a required version to. Read from the engine and re-published on every /reload.

if INTERFACE_VERSION >= 11200 then ... end

LE_EXPANSION_*

The expansion-level enum, exposed as Lua globals so addons don't have to gate on if LE_EXPANSION_CLASSIC then (the constant being defined is itself the probe). Values match the Enum.ExpansionLevel table. The matching helper functions (GetClassicExpansionLevel / ClassicExpansionAtLeast / ClassicExpansionAtMost) live in the Expansion section.

Constant Value
LE_EXPANSION_LEVEL_CURRENT 0 (this is Classic)
LE_EXPANSION_CLASSIC 0
LE_EXPANSION_BURNING_CRUSADE 1
LE_EXPANSION_WRATH_OF_THE_LICH_KING 2
LE_EXPANSION_CATACLYSM 3
LE_EXPANSION_MISTS_OF_PANDARIA 4
LE_EXPANSION_WARLORDS_OF_DRAENOR 5
LE_EXPANSION_LEGION 6
LE_EXPANSION_BATTLE_FOR_AZEROTH 7
LE_EXPANSION_SHADOWLANDS 8
LE_EXPANSION_DRAGONFLIGHT 9
LE_EXPANSION_WAR_WITHIN 10
LE_EXPANSION_MIDNIGHT 11
-- Classic version check using modern idiom
if LE_EXPANSION_LEVEL_CURRENT < LE_EXPANSION_BURNING_CRUSADE then
    -- pure-1.x (vanilla / Classic Era) code path
end

-- Probe for ClassicAPI presence
if LE_EXPANSION_LEVEL_CURRENT then
    -- the constants are defined → ClassicAPI is loaded
end

LE_ITEM_QUALITY_*

The item-quality enum (Enum.ItemQuality), exposed as Lua globals so addons can do if quality >= LE_ITEM_QUALITY_RARE then ... against the integer quality returned by GetItemInfo / C_Item.GetItemInfoInstant.

Constant Value Color in tooltip
LE_ITEM_QUALITY_POOR 0 gray (junk)
LE_ITEM_QUALITY_COMMON 1 white
LE_ITEM_QUALITY_UNCOMMON 2 green
LE_ITEM_QUALITY_RARE 3 blue
LE_ITEM_QUALITY_EPIC 4 purple
LE_ITEM_QUALITY_LEGENDARY 5 orange
LE_ITEM_QUALITY_ARTIFACT 6 gold
LE_ITEM_QUALITY_HEIRLOOM 7 light blue
LE_ITEM_QUALITY_WOWTOKEN 8 orange

Values 0..5 (POOR..LEGENDARY) correspond to actual qualities present here. Higher values are exposed for source compatibility — items here will never carry those quality values, so a comparison like quality == LE_ITEM_QUALITY_HEIRLOOM trivially never matches and the rest of the code path is unreachable.

local _, _, quality = GetItemInfo(itemID)
if quality and quality >= LE_ITEM_QUALITY_RARE then
    -- highlight in UI
end

LE_UNIT_STAT_*

The primary-stat enum (Enum.UnitStat), exposed as Lua globals so addons can index UnitStat(unit, statIndex) symbolically:

Constant Value Stat
LE_UNIT_STAT_STRENGTH 1 Strength
LE_UNIT_STAT_AGILITY 2 Agility
LE_UNIT_STAT_STAMINA 3 Stamina
LE_UNIT_STAT_INTELLECT 4 Intellect
LE_UNIT_STAT_SPIRIT 5 Spirit

Values are stable across every WoW expansion — UnitStat("player", 1) has always returned strength.

local _, effective = UnitStat("player", LE_UNIT_STAT_AGILITY)

Enum.AddOnSecurityStatus

The integer enum C_AddOns.GetAddOnSecurity returns. Matches Blizzard's Enum.AddOnSecurityStatus:

Value Field Notes
0 Secure Blizzard-signed addons.
1 Insecure User addons; default for any registered addon.
2 Banned Server-disqualified entries.
3 NotAvailable No addon by that name / index.
if C_AddOns.GetAddOnSecurity(name) == Enum.AddOnSecurityStatus.Secure then
    -- it's a Blizzard_* addon
end

Enum.InventoryType

The equip-type enum — the numeric inventoryType reported by C_Item.GetItemInfoInstant / C_Item.GetItemInventoryType, and the argument the C_Item.GetItemInventorySlot* functions take.

Value Field Value Field
0 IndexNonEquipType 15 IndexRangedType
1 IndexHeadType 16 IndexCloakType
2 IndexNeckType 17 Index2HweaponType
3 IndexShoulderType 18 IndexBagType
4 IndexBodyType 19 IndexTabardType
5 IndexChestType 20 IndexRobeType
6 IndexWaistType 21 IndexWeaponmainhandType
7 IndexLegsType 22 IndexWeaponoffhandType
8 IndexFeetType 23 IndexHoldableType
9 IndexWristType 24 IndexAmmoType
10 IndexHandType 25 IndexThrownType
11 IndexFingerType 26 IndexRangedrightType
12 IndexTrinketType 27 IndexQuiverType
13 IndexWeaponType 28 IndexRelicType
14 IndexShieldType

Values 29..34 (IndexProfessionToolType, IndexProfessionGearType, IndexEquipablespell{Offensive,Utility,Defensive,Weapon}Type) are included for parity — items here never report them.

if C_Item.GetItemInventoryType(loc) == Enum.InventoryType.IndexHeadType then ...

Enum.ItemClass

The item-class enum — the numeric classID reported as the 12th return of GetItemInfo and taken by GetItemClassInfo / GetItemSubClassInfo. The obsolete slots keep their key names even though ItemClass.dbc labels them "…(OBSOLETE)".

Value Field Value Field
0 Consumable 8 ItemEnhancement
1 Container 9 Recipe
2 Weapon 10 CurrencyTokenObsolete
3 Gem 11 Quiver
4 Armor 12 Questitem
5 Reagent 13 Key
6 Projectile 14 PermanentObsolete
7 Tradegoods 15 Miscellaneous

Values 16..19 (Glyph, Battlepet, WoWToken, Profession) are included for parity — items here never report them.

if select(12, GetItemInfo(id)) == Enum.ItemClass.Weapon then ...

Enum.ItemQuality

The item-quality enum — the numeric quality reported by GetItemInfo / C_Item.GetItemQuality.

Value Field Value Field
0 Poor 4 Epic
1 Common 5 Legendary
2 Uncommon 6 Artifact
3 Rare

The Heirloom (7) and WoWToken (8) tiers are omitted — no such items exist here.

if select(3, GetItemInfo(id)) == Enum.ItemQuality.Epic then ...

Enum.PlayerSwingType

The weapon that a PLAYER_SWING or C_SwingTimer value applies to:

Value Field Meaning
0 MainHand The main-hand melee weapon.
1 OffHand The off-hand melee weapon, when one is equipped.
2 Ranged The ranged weapon: a bow, gun, crossbow, or wand.

Enum.PowerType

The integer enum UnitPowerType returns and UnitPower / UnitPowerMax accept. Only slots 0..4 are defined. Slot 4 is Happiness (pet happiness).

Value Field Notes
-2 HealthCost Sentinel for "use HEALTH instead of POWER".
-1 None Sentinel for "unit's primary power" (= omit the arg).
0 Mana
1 Rage
2 Focus
3 Energy
4 Happiness Pet happiness.
local rage = UnitPower("player", Enum.PowerType.Rage)

Enum.SpellBookSpellBank

Selects which spellbook a C_SpellBook.* slot query reads. Passed as the spellBank argument to C_SpellBook.GetSpellBookItemInfo.

Value Field
0 Player
1 Pet

Enum.SpellBookItemType

Categorizes a spellbook slot. The spellbook only ever holds real spells, so C_SpellBook.GetSpellBookItemInfo returns Spell for a player-book slot and PetAction for a pet-book slot. The other values exist for signature parity but never occur.

Value Field Notes
0 None Empty slot. Never returned — an empty slot yields nil instead.
1 Spell A player-book spell.
2 FutureSpell A not-yet-learned trainer spell. Never occurs.
3 PetAction A pet-book spell.
4 Flyout A flyout group. Never occurs.

Enum.UICursorType

What the cursor is holding. Reported as the newCursorType and oldCursorType arguments of CURSOR_CHANGED.

Value Field Notes
0 Default Cursor empty.
1 Item
2 Money
3 Spell
4 PetAction
5 Merchant
6 ActionBar Never occurs.
7 Macro
8 Ammo Never occurs.
9 Pet A pet dragged in the stable.
10-20 GuildBank … PerksProgramVendorItem Never occur.

The values marked "never occur" name content this client does not have. They are still defined, so comparing against any field is valid.

PetAction and Pet are the two types GetCursorInfo returns nil for — no type string fits them, but the enum names them, so the event reports them.

Glue

Backports of two C_Glue session helpers. C_Glue is registered on the glue (login / character-select) Lua state; IsOnGlueScreen is also registered in-game, so code that runs in either environment can branch on it. Neither needs an engine flag — both answers fall out of which glue boot / Lua state is asking.

C_Glue.IsFirstLoadThisSession()

Returns true only while the first glue screen since the process launched is showing, false on every later return to the glue screen. Login ↔ character-select transitions stay within the first glue session (they never enter the world), so it stays true across them — it flips false only once you've entered the world and logged back out. Glue state only.

if C_Glue.IsFirstLoadThisSession() then
    -- one-time-per-launch startup (intro cinematic, news, …)
end

Backed by a process-static count of glue boots (the glue script-registration pass fires once per boot: initial launch + each world→glue logout); boot 1 is the first load.

C_Glue.IsOnGlueScreen()

Returns true when a GlueXML screen is showing (login or character select — no character in the world), false in the world. Registered on both Lua states, so it's callable from glue and in-game code alike. The two states are mutually exclusive (the glue state exists only when no character is in the world), so the answer is fixed per state — true from the glue registration, false from the in-game one.

if C_Glue.IsOnGlueScreen() then
    -- at login / character select
end

Gossip

Struct-shaped wrappers around the flat GetGossipText / GetGossipOptions / GetGossipAvailableQuests / GetGossipActiveQuests / SelectGossipOption / SelectGossipAvailableQuest / SelectGossipActiveQuest / CloseGossip surface. The data is the same — these calls just read the engine's two gossip-state arrays (0x00BBBE90 for options, 0x00BB74C0 for quests, both filled by the SMSG_GOSSIP_MESSAGE handler at 0x004E26E0) and shape it into the struct-tables that addons expect.

Fields the server simply doesn't send — and therefore aren't in any of the returned tables — include rewards and spellID, per-option status (Available / Unavailable / Locked / AlreadyComplete — the server doesn't compute this), and UX hints like overrideIconID and selectOptionWhenOnlyOption.

The icon field is the raw icon-type byte from SMSG_GOSSIP_MESSAGE (server-extensible — pservers can add custom NPC types past the Blizzard 0..10 range). Default Blizzard types resolve to Interface\GossipFrame\<Type>GossipIcon:

Value Type Value Type
0 Gossip 6 Banker
1 Vendor 7 Petition
2 Taxi 8 Tabard
3 Trainer 9 BattleMaster
4 Healer 10 Auctioneer
5 Binder (innkeeper)

C_GossipInfo.GetText()

Returns the gossip greeting string the engine resolved from the NPC's referenced NPC_TEXT.dbc row. Same value GetGossipText() returns; provided so addons that prefer the C_GossipInfo namespace don't have to mix the two surfaces.

print(C_GossipInfo.GetText())

C_GossipInfo.GetOptions()

Returns an array of gossip-option tables in display order. Each entry contains:

Field Type Notes
gossipOptionID number The engine's optionIndex. The same value C_GossipInfo.SelectOption expects; arbitrary integer assigned by the server.
name string Option text (locale-applied by the server).
icon number Raw icon-type byte from SMSG_GOSSIP_MESSAGE — passed through unmapped so pserver-added types (anything past the default 0..10 range) survive. See the type table above for the default Blizzard categories and the Interface\GossipFrame\<Type>GossipIcon path each one resolves to.
flags number Bit 0 set = boxCoded (the option asks for confirmation text — Are you sure? boxes).
orderIndex number 1-based position in the emitted list. Matches the index SelectGossipOption (legacy) expects.
for _, opt in ipairs(C_GossipInfo.GetOptions()) do
    print(opt.gossipOptionID, opt.name, "icon", opt.icon)
end

C_GossipInfo.GetAvailableQuests()

Returns an array of deliverable-quest tables (quests the giver is offering to start), in display order. Filter mirrors the engine's GetGossipAvailableQuests — status field at +0x008 not in {3, 4}.

Field Type Notes
questID number Quest.dbc row ID.
title string Localized quest title.
questLevel number Quest level.

C_GossipInfo.GetActiveQuests()

Returns an array of active-quest tables (quests the giver wants you to turn in, in-progress or complete). Status field at +0x008 in {3, 4}.

Field Type Notes
questID number Quest.dbc row ID.
title string Localized quest title.
questLevel number Quest level.
isComplete boolean true when the engine's status byte is 4 (ready to turn in); false when it's 3 (still in progress).

C_GossipInfo.GetNumOptions() / GetNumAvailableQuests() / GetNumActiveQuests()

Convenience counters — return the length of each of the three lists above without building the table.

C_GossipInfo.SelectOption(gossipOptionID) / SelectOptionByIndex(orderIndex)

Picks a gossip option. SelectOption resolves the gossipOptionID to the engine's 1-based slot, then tail-calls the engine's native SelectGossipOption. SelectOptionByIndex is a thin passthrough for callers that already have the slot index (e.g. from opt.orderIndex on a GetOptions() entry, or from a UI button bound directly to the slot).

Both end in the same CMSG; the option-ID variant exists so addons can drive selections off the gossipOptionID key without keeping their own slot mapping. Returns nothing.

The stock SelectGossipOption doesn't accept a confirmation-text argument, so for boxCoded options the engine's own confirm dialog runs as usual — there is no way to send the password from the script.

C_GossipInfo.SelectAvailableQuest(questID)

Picks a deliverable quest by questID. Walks the active-vs-available filter the same way GetAvailableQuests does, locates the matching 1-based slot, and tail-calls the engine's SelectGossipAvailableQuest. Returns nothing.

C_GossipInfo.SelectActiveQuest(questID)

Picks an in-progress quest by questID. Same shape as SelectAvailableQuest but walks the active filter and tail-calls SelectGossipActiveQuest. Returns nothing.

C_GossipInfo.CloseGossip()

Closes the gossip window. Direct passthrough to the engine's CloseGossip. Returns nothing.

Hooks

hooksecurefunc(name, callback) / hooksecurefunc(table, name, callback)

Post-call hook: the original function runs first, then callback runs with the same args (return values discarded). The original's return values propagate to the caller.

hooksecurefunc("GetSpellInfo", function(spellID)
    print("GetSpellInfo called with " .. tostring(spellID))
end)

hooksecurefunc(GameTooltip, "SetInventoryItem", function(self, unit, slot)
    -- runs after the engine fills the tooltip
    print("inventory tooltip:", unit, slot)
end)

The "secure" label refers to taint-propagation behavior for protected-frame manipulation. There is no taint system here, so the function is equivalent to a plain "after-hook" — the name is kept for API parity.

Implemented in pure C: builds a Lua C closure with (orig, callback) as upvalues; the wrapper calls orig with LUA_MULTRET, then callback, then returns orig's full result list. No return-count cap — works correctly for functions returning any number of values.

Errors via lua_error on:

  • Non-string name
  • Non-function callback
  • target[name] not resolvable to a function (covers typos and hooking unknown frame methods)
  • The two-argument form naming a function that cannot be hooked on _G (error hooksecurefunc: function is unhookable): getfenv, getmetatable, hooksecurefunc, ipairs, issecurevalue, issecurevariable, next, pairs, pcall, pcallwithenv, rawget, rawset, scrub, securecall, securecallfunction, secureexecuterange, select, setfenv, setmetatable, type, unpack, wipe, xpcall. This is the same list the current client enforces. The three-argument form is not restricted, even with _G as the table.

Input

The engine ships IsShiftKeyDown / IsControlKeyDown / IsAltKeyDown but they only report "any shift/ctrl/alt" — there's no built-in way to tell left from right. These seven functions add the missing distinction plus an IsModifierKeyDown() rollup.

IsLeftShiftKeyDown() / IsRightShiftKeyDown()

IsLeftControlKeyDown() / IsRightControlKeyDown()

IsLeftAltKeyDown() / IsRightAltKeyDown()

Each returns 1 when the corresponding key is physically down, nil otherwise — matching the convention the stock IsShiftKeyDown etc. use.

if IsLeftShiftKeyDown() and not IsRightShiftKeyDown() then
    -- left-shift-only binding
end

IsModifierKeyDown()

Returns 1 if any of the six modifier keys is down, nil otherwise. Equivalent to IsShiftKeyDown() or IsControlKeyDown() or IsAltKeyDown() but in one call.

IsMouseButtonDown([button])

Returns true if the given mouse button is currently held, false otherwise. With no argument (or nil), returns true if any mouse button is held.

button is either a 1-based ID or a name string:

ID Name
1 "LeftButton"
2 "RightButton"
3 "MiddleButton"
4 "Button4" (XBUTTON1 / first side button)
5 "Button5" (XBUTTON2 / second side button)

Unrecognized IDs / names return false (the named button just isn't held — bad input doesn't fall through to the any-button check).

State is maintained from the same WH_GETMESSAGE hook that drives GLOBAL_MOUSE_DOWN / GLOBAL_MOUSE_UP, so press/release transitions register at exactly the moment the corresponding event fires. Hook scopes to WoW's message queue — clicks made while the game is alt-tabbed out don't deliver, which means the state can stay "down" if a button was held while focus left the window. Most uses of IsMouseButtonDown are gated behind a frame that just received mouse focus, so this rarely matters in practice.

if IsMouseButtonDown("RightButton") then
    -- right-button-held bindings
end

GetMouseButtonClicked()

Returns the name of the mouse button that started the handler that is now running. It works inside these handlers, on any frame: OnMouseDown, OnMouseUp, PreClick, OnClick, PostClick, and OnDoubleClick. At any other time it returns nil.

A helper function that the handler calls can also read the name. arg1 is not in scope there.

The name is one of "LeftButton", "MiddleButton", "RightButton", "Button4", or "Button5". A Button:Click() call with no argument gives "LeftButton". A Button:Click(name) call with any other name gives "UNKNOWN".

OnDragStart is not in the list above. In OnDragStart this function returns nil, and arg1 holds the button.

The name is valid only while the handler runs. A held mouse button does not keep it set. If a handler clicks a second button, the name of that inner click applies until the inner handler ends. Then the outer name comes back.

button:SetScript("OnClick", function()
    if GetMouseButtonClicked() == "RightButton" then
        -- right-click behavior
    end
end)

Instance

GetInstanceInfo()

Returns a 9-value tuple, with degenerate values for the fields this client doesn't actually track:

name, instanceType, difficultyID, difficultyName, maxPlayers,
dynamicDifficulty, isDynamic, instanceID, instanceGroupSize
  • name — localized instance/zone name from Map.dbc.
  • instanceType — "none" (open world), "party" (5-man dungeon), "raid", "pvp" (battleground), or "arena" (unused).
  • difficultyID — always 1. There is no heroic mode.
  • difficultyName — always "Normal".
  • maxPlayers — type-default cap: 5 for dungeons, 40 for raids, 40 for battlegrounds, 0 for open world. See caveat below.
  • dynamicDifficulty — always 0.
  • isDynamic — always false.
  • instanceID — current map ID. The field is named "instanceID" but it's really the Map.dbc row ID.
  • instanceGroupSize — mirrors maxPlayers (no per-group-config variants).
/dump GetInstanceInfo()
-- In Stormwind:    "Kalimdor",   "none", 1, "Normal",  0, 0, false,   1,  0
-- In Deadmines:    "Deadmines",  "party",1, "Normal",  5, 0, false,  36,  5
-- In Molten Core:  "Molten Core","raid", 1, "Normal", 40, 0, false, 409, 40

Caveat on maxPlayers. There is no per-instance cap data client-side. The server enforces caps via SMSG_TRANSFER_ABORTED when entry is denied, but that information never reaches the client otherwise. So we return the type's canonical max. Zul'Gurub and AQ20 return 40 instead of their true 20; non-AV battlegrounds (WSG 10, AB 15) return 40 instead of their true cap; any custom raid on a private server (e.g. Turtle WoW) returns 40 regardless of its real cap. Addons that need exact caps must supply their own per-mapID table.

Item

itemLocation argument shapes

Every C_Item.* function on this page that takes itemLocation accepts any of three forms — the ItemLocation mixin shapes plus a GUID-string convenience form:

{ equipmentSlotIndex = N }     -- 1-based, character pane
{ bagID = B, slotIndex = S }   -- both required
"0xHHHHHHHHLLLLLLLL"           -- a GUID string from C_Item.GetItemGUID

Table forms are O(1) — the engine knows the slot. The GUID-string form is O(~80) — the function walks equipment slots 1..19 and bags 0..4 comparing each CGItem's stored GUID against the requested one. Bank and keyring are not walked. Fine for sporadic addon use; avoid for per-frame polling.

C_Item.DoesItemExist(itemLocation) / C_Item.DoesItemExistByID(item)

Existence checks straight off the engine's inventory manager (location form) and item cache (ID form). No GetItemInfo chaining — both functions read directly from the structures they need.

  • C_Item.DoesItemExist(itemLocation) — true iff the equipment-slot or (bagID, slotIndex) location resolves to a populated inventory slot on the active player. Empty slots, missing bags, or malformed tables return false.
  • C_Item.DoesItemExistByID(item) — true iff the cache currently has data for item. Cache miss returns false and kicks off the network query in the background; a follow-up call after GET_ITEM_INFO_RECEIVED will succeed. Accepts a numeric itemID, a bare "item:NNN..." string, or a full chat link.
if C_Item.DoesItemExist({equipmentSlotIndex = INVSLOT_HEAD}) then ... end
if C_Item.DoesItemExistByID(6948) then ... end

C_Item.EquipItemByName(item [, dstSlot])

Equips item. With dstSlot (a 1-based character-pane slot, 1..19), equips to that specific slot; without, the engine auto-picks based on the item's inventory type.

item takes either of two forms:

  • An item reference: an itemID number, bare "item:N" string, a full chat link, or a localized item name — the shapes C_Item.IsEquippedItem takes. The first matching item in your bags is equipped. Name matching is case-insensitive against each candidate's decorated name (random suffix included — a suffixed item matches its full name, not the base), the same shared predicate C_Item.IsEquippedItem uses.
  • An item location: {bagID = B, slotIndex = S}, {equipmentSlotIndex = N}, or an item GUID string — the shapes C_Item.GetItemID takes. A location names one exact item, so use it when you hold two of the same item with different enchants or suffixes and which one you equip matters.

An {equipmentSlotIndex = N} location names an item you are already wearing, so it needs a dstSlot to move to: that is how you swap rings (11 ↔ 12), trinkets (13 ↔ 14) or weapons (16 ↔ 17). Without dstSlot there is nothing for the engine to pick, and the call does nothing.

Returns nothing. Silently no-ops when:

  • the input is nil, an empty string, or otherwise unparseable
  • you do not have the item; a reference searches your bags only, so an item you are already wearing is only reachable by location
  • the engine refuses the equip — combat, locked item, type mismatch with dstSlot, locked equipment slot, etc.

Two paths based on dstSlot:

  • Explicit dstSlot (1..19): cursor-free direct swap. Calls the engine's own FUN_INVENTORY_SWAP (0x005E0C40) — the same primitive Script_EquipCursorItem dispatches to after resolving cursor state. We hand it the source item GUID, source container GUID, source linear slot, and target paperdoll slot; the engine builds and sends the CMSG_SWAP_INV_ITEM (or CMSG_AUTOEQUIP_ITEM for cross-container) packet through its normal pipeline. The cursor is never read or written. An item already on the cursor stays on the cursor.
  • No dstSlot (engine auto-picks slot from inventory type): falls back to the cursor-pickup + AutoEquipCursorItem path because the auto-pick logic reads off cursor state.

Both paths first return anything on the cursor to the slot it came from, so a held item is neither clobbered nor left visually locked. The item is resolved after that, which means a location naming the slot a held item came from still finds it — while held, that slot reads empty.

-- By itemID, auto-pick slot:
C_Item.EquipItemByName(2589)

-- By name, force into off-hand (slot 17):
C_Item.EquipItemByName("Linen Cloth", 17)

-- From a chat link:
C_Item.EquipItemByName(itemLink)

-- That exact bag slot, auto-pick where it goes:
C_Item.EquipItemByName({ bagID = 0, slotIndex = 1 })

-- Move the main-hand weapon to the off-hand:
C_Item.EquipItemByName({ equipmentSlotIndex = 16 }, 17)

C_Item.GetCurrentItemLevel(itemLocation) / C_Item.GetDetailedItemLevelInfo(item)

Returns the item's base ilvl from m_itemLevel (cache record +0x38). There is no per-instance scaling (no upgrades, no warforging), so "current" and "base" item level are always identical — both APIs return the same single value. GetDetailedItemLevelInfo is spec'd to return (current, isPreview, base); we push only the current level, callers that care about the extra returns will see nil for them.

local ilvl = C_Item.GetCurrentItemLevel({equipmentSlotIndex = INVSLOT_HEAD})

C_Item.GetItemCount(itemInfo, [includeBank], [includeUses])

Returns the player's total count of itemInfo — equipped items + bags, and optionally bank.

count = C_Item.GetItemCount(itemInfo [, includeBank [, includeUses]])
  • itemInfo — a numeric itemID, a string that contains "item:NNN" (full chat links work), or an item name. A name is compared, without regard to case, with the full name of each item that you carry (the suffix included, so "Foo of the Owl" matches and "Foo" does not).
  • includeBank (optional, default false) — also walk bank slots (bag -1 for the main bank, bags 5..10 for bank-bag slots).
  • includeUses (optional, default false) — when true, multiplies each match by the item's spell-charges count. A wand with 50 charges contributes 50 instead of 1. Items without charges pass their plain stack count through unchanged, so the flag is a no-op for them.
local n = C_Item.GetItemCount(2589)               -- Linen Cloth in bags + equipped
local n = C_Item.GetItemCount(2589, true)         -- + bank
local n = C_Item.GetItemCount("item:2589")        -- string form works too
local n = C_Item.GetItemCount("Linen Cloth")      -- by name

-- Equipped items count toward the total:
local trinketID = GetInventoryItemID("player", INVSLOT_TRINKET1)
C_Item.GetItemCount(trinketID)                    -- 1

-- includeUses multiplies by charges for charged items:
C_Item.GetItemCount(wandID, false, true)          -- 50 for one 50-charge wand
C_Item.GetItemCount(linenID, false, true)         -- same as stack count (no charges)

Bank works cold — no banker visit required. The server sends bank inventory at login alongside the rest of the player's data; only the engine's own GetItemBySlot gates bank slots until the window opens. We bypass that gate by reading the GUID array directly out of the player invMgr and resolving each entry via the engine's object resolver — same path GetItemBySlot would take internally if the gate let us through. Counts are correct from session start.

Walks the 19 equipment slots via Item::Location::ResolveEquipmentSlot, then bags via the same Item::Location::ResolveBag chain C_Container.GetContainerItemID uses. Slot counts come from the engine's Script_GetContainerNumSlots so custom server bag sizes work transparently. Stack counts read directly off the CGItem's m_objectFields descriptor at +0x20 (ITEM_FIELD_STACK_COUNT, verified by decoding Script_GetContainerItemInfo at 0x004F9670).

Equivalent to the global GetItemCount and C_Item.GetItemCount.

C_Item.GetItemData(itemLocation) / C_Item.GetItemDataByID(item)

ClassicAPI-only kitchen-sink reader: returns a single table with every field we can extract from the cached ItemStats_C record, so addons that need more than a couple of fields don't have to chain a dozen C_Item.* calls. Saves both stack churn and the cost of redoing the cache lookup for every accessor.

data = C_Item.GetItemDataByID(item)        -- numeric id / "item:NNN" / chat link
data = C_Item.GetItemData(itemLocation)    -- {bagID,slotIndex} or {equipmentSlotIndex}

Returns nil if the input doesn't resolve to a valid itemID or the item isn't cached yet. Does not warm the cache — same passive- reader contract as C_Item.GetItemInfoInstant. Callers that need to wait for cache fill should call C_Item.RequestLoadItemDataByID and listen for ITEM_DATA_LOAD_RESULT.

Returned table:

Key Type Source / notes
itemID number Echoed from input.
name string m_name[0] — localized display name.
description string | nil m_description. Omitted when empty.
icon string | nil "Interface\\Icons\\<name>". Omitted when no icon.
displayInfoID number m_displayInfoID → ItemDisplayInfo.dbc row.
quality number 0..5 — see LE_ITEM_QUALITY_* enum.
classID number m_class — ItemClass.dbc row.
subclassID number m_subClass — ItemSubClass.dbc row.
className string Localized class name (e.g. "Weapon").
subclassName string Localized subclass name (e.g. "One-Handed Swords").
inventoryType number m_inventoryType — raw integer.
equipLoc string "INVTYPE_*" constant or "" for non-equippable.
bindType number m_bonding — 0=none, 1=BoP, 2=BoE, 3=BoU, 4=Quest.
flags number Raw m_flags u32.
isConjured bool Flag bit 0x2.
isOpenable bool Flag bit 0x4. Same bit C_Item.IsItemOpenable reads.
isLootable bool Flag bit 0x10 — has loot generators (right-click loot).
isWrapper bool Flag bit 0x200 — gift-wrappable.
maxStackSize number m_stackable.
maxCount number m_maxCount — 0=unlimited, 1=unique, otherwise per-character cap.
containerSlots number m_containerSlots — bag slot count, 0 for non-bags.
bagFamily number m_bagFamily converted to a bitmask (1 << (id-1)).
buyPrice number Vendor buy price in copper.
sellPrice number Vendor sell price in copper.
itemLevel number Base ilvl from ItemSparse.
requiredLevel number Minimum character level.
requiredSkill number SkillLine.dbc row, 0=none.
requiredSkillRank number Skill rank required.
requiredSpell number Spell.dbc row required to learn / use, 0=none.
requiredHonorRank number PvP honor rank required, 0=none.
requiredCityRank number Reserved; always 0 here.
requiredFaction number Faction.dbc row, 0=none.
requiredFactionRank number Reputation tier (Friendly/Honored/…).
allowableClass number Class bitmask, -1 = all classes.
allowableRace number Race bitmask, -1 = all races.
armor number Armor value.
block number Shield block value.
maxDurability number 0 for items without durability.
stats table Sparse { [statType] = statValue, … }. Empty for items without stat allocations.
resistanceHoly number Holy resistance.
resistanceFire number Fire resistance.
resistanceNature number Nature resistance.
resistanceFrost number Frost resistance.
resistanceShadow number Shadow resistance.
resistanceArcane number Arcane resistance.
damageMin table 5-element float array — weapon min damage per damage slot.
damageMax table 5-element float array.
damageType table 5-element integer array — damage school per slot.
delay number Weapon swing time in ms.
ammoType number Ammo subclass for ranged weapons.
rangedModRange number Range modifier for bows/guns.
spells table Sparse array of {id, trigger, charges, cooldown, category, categoryCooldown} records — one per non-empty spell slot (up to 5).
useSpellID number | nil Convenience — the spellID of the first slot with trigger=ON_USE (0). Omitted when the item has none. Same value C_Item.GetItemSpell returns.
lockID number Lock.dbc row for the item's lock, 0=none.
itemSet number ItemSet.dbc row, 0=not part of a set.
pageText number PageText.dbc row for readable items.
pageMaterial number Book page material.
languageID number Language for in-game-readable books.
startQuest number Quest row started by right-clicking this item.
material number Material type (cloth/leather/metal etc. for hit sounds).
sheath number Weapon sheath style.
randomProperty number Random property template id.
area number AreaTable.dbc — bound area for area-locked items, 0=none.
map number Map.dbc — bound map for map-locked items, 0=none.
local data = C_Item.GetItemDataByID(6948)  -- Hearthstone
-- data.name == "Hearthstone"
-- data.quality == 1, data.classID == 15, data.subclassID == 0
-- data.bindType == 1   (BoP)
-- data.useSpellID == 8690   (the recall spell)
-- data.maxStackSize == 1, data.maxCount == 1

local sword = C_Item.GetItemDataByID(2092)  -- Worn Shortsword
-- sword.equipLoc == "INVTYPE_WEAPONMAINHAND"
-- sword.damageMin[1] == 1.0, sword.damageMax[1] == 2.0, sword.delay == 1900
-- sword.stats == {}   (no stat allocations on the starter sword)

Cache state. The function reads the same DBCache_ItemStats network cache every other C_Item.* reader consults. Items already in the player's inventory are typically populated by the engine's natural prefetch before PLAYER_ENTERING_WORLD returns; for other items (quest rewards, AH items, link hovers, etc.) the data lands once a SMSG_ITEM_QUERY_SINGLE_RESPONSE arrives. Use C_Item.RequestLoadItemDataByID if you specifically need to trigger the load.

Stats encoding. The stats table is keyed by the ItemModType enum:

Key Meaning
0 Mana
1 Health
3 Agility
4 Strength
5 Intellect
6 Spirit
7 Stamina

Higher-numbered slots (consumable per-second-regen, defense rating, hit/crit/dodge/parry chance, weapon/spell-power) only appear on items the server actually carries — items here mostly use 0..7. Empty slots (both type and value zero) are omitted, so iterating stats yields only the stats actually allocated.

A ClassicAPI-only reader — GetItemInfo returns its tuple piecemeal and C_Item.GetItemInfo adds a few more. GetItemData is a single-call superset useful for tooltip addons and item-data caches.

C_Item.GetItemFamily(item)

Returns the BagFamily bitmask for an item — i.e., what kind of specialty bag it belongs in. 0 means "general-purpose" (no specialty bag preference). Returns nil if the item isn't in the cache.

familyBitmask = C_Item.GetItemFamily(item)

item accepts a numeric itemID or a string containing "item:NNN" (the bare shorthand and full chat links both parse correctly), same as C_Item.GetItemInfoInstant.

C_Item.GetItemFamily(2512)   -- 1   (Wooden Arrow → quiver-family)
C_Item.GetItemFamily(2516)   -- 2   (Light Shot → ammo pouch-family)
C_Item.GetItemFamily(6265)   -- 4   (Soul Shard → soul bag-family)
C_Item.GetItemFamily(2447)   -- 32  (Peacebloom → herb bag-family)
C_Item.GetItemFamily(6948)   -- 0   (Hearthstone → general-purpose)

Bitmask values follow the ItemBagFamily.dbc IDs converted to the bitmask form (1 << (familyID - 1)). The IDs 1–9 are the standard families; 10–13 are Turtle WoW custom families (the stock client stops at Keys):

Bit Value Raw ID Family Notes
0 1 1 Quiver (arrows)
1 2 2 Ammo Pouch (bullets)
2 4 3 Soul Bag
5 32 6 Herb Bag
6 64 7 Enchanting Bag
7 128 8 Engineering Bag
8 256 9 Keyring
9 512 10 Meat Bag (Turtle) ⚠ collides with the standard Gem Bag bit (0x200)
10 1024 11 Fish Bag (Turtle) ⚠ collides with the standard Mining Bag bit (0x400)
11 2048 12 Leather Bag (Turtle) no standard equivalent bit
12 4096 13 Mining Bag (Turtle) ⚠ not the standard Mining Bag bit (0x400)

Turtle custom families & the bit collision. Turtle added four cooking/gathering bag families (Meat, Fish, Leather, Mining) as ItemBagFamily.dbc rows 10–13, rather than reusing the standard later IDs 4 (Leatherworking) / 5 (Inscription), which this client left unused. Because we convert the raw ID straight through 1 << (rawID - 1), these land on bits 9–12 — the same numeric values assigned to Gem Bag (0x200) and Mining Bag (0x400) elsewhere. So an addon that hard-codes family == 0x200 to mean "Gem Bag" will misread a Turtle Meat Bag. The values are self-consistent within Turtle (a Meat Bag and the meat items it holds both report 0x200, which is all the "does this item fit this bag" test needs) — just don't assume the standard bit meanings for anything above 0x100.

Encoding under the hood. The engine stores the raw 1-based BagFamily ID (arrow=1, bullet=2, soul shard=3, herb=6, …). We convert on the way out via bitmask = 1 << (rawID - 1), so callers see the bitmask encoding they expect — addons can band(family, FAMILY_BAG_HERB_BAG) directly.

Legacy Blizzard bags derive from (class, subClass). The stock bags shipped with an empty m_bagFamily field — the four profession bags (Soul Bag, Herb Bag, Enchanting Bag, Engineering Bag, all Container class) and quivers/ammo pouches (Quiver class). For a bag whose field is 0 we recover the family from its class + subclass — the same signal the "24 Slot Soul Bag" tooltip line is built from. A Felcloth Bag reports 0x4 (Soul Shards), a quiver 0x1 (arrows), an ammo pouch 0x2 (bullets) — each matching the items it holds. Keyed on class because subclass numbers repeat across classes (Container subclass 2 = Herb Bag, Quiver subclass 2 = Quiver). This is automatic; you no longer need to read subClass yourself. (Turtle's own custom bags — Meat/Fish/Leather/Mining — populate the field directly; a Turtle-specific resolver module supplies their mapping as a safety net, kept separate from the stock table.)

nil vs 0. We return nil (not 0) for items the cache lookup fails on, so callers can distinguish "item not cached, retry after the event lands" from "item exists but has no family preference." Both are safe to treat as 0 for routing-logic purposes; the distinction helps debugging.

Auto-warmup on cache miss. First call for an uncached item returns nil AND triggers the engine's cache fill in the background. Listen for GET_ITEM_INFO_RECEIVED(itemID, success) and retry once the matching itemID arrives — "nil first call, value second call." We fire GET_ITEM_INFO_RECEIVED when the cache lands, to stay consistent with our other implicit-warmup paths (GetItemInfo, SetItemByID). Addons that already listen for that event get the notification for free.

Equivalent to the global GetItemFamily and C_Item.GetItemFamily.

C_Item.GetItemGUID(itemLocation)

Returns the per-instance 64-bit GUID of the item at the location, formatted as "0xHHHHHHHHLLLLLLLL" (16 hex digits, hi dword first). Same format UnitGUID uses — GUIDs are plain qwords with no "Item-Server-..."-style prefix scheme. Returns nil for empty / invalid locations.

local guid = C_Item.GetItemGUID({equipmentSlotIndex = INVSLOT_HEAD})
-- "0x40000000000DEFCA"

Reads CGItem instance block at +0x08 → GUID qword at +0x00. The GUID is stable per-character-session and survives moves between bags / character pane, so it's the right identifier for "this exact item instance" — equipment-set tracking, soulbind matching, or any addon code that needs to follow a single item across slot moves.

C_Item.GetItemID(itemLocation)

Returns the itemID of the item at the given location, or nil if the slot is empty or the location is malformed. Useful as the input to GetItemInfoInstant/GetItemInfo when you only know which slot an item came from (rather than its link or ID).

Accepts the same itemLocation shapes as IsBound:

local id = C_Item.GetItemID({equipmentSlotIndex = INVSLOT_HEAD})
if id then
    local _, type, subtype = C_Item.GetItemInfoInstant(id)
    -- ...
end

C_Item.GetItemInfo(itemInfo)

The full GetItemInfo tuple, sourced from the client-side item cache plus the class/subclass/inventory-type DBC name lookups. The namespaced counterpart to the stock global GetItemInfo (which returns only a short tuple); this returns the wide set.

Accepts a numeric itemID, an "item:NNN" string, or a full chat link.

Returns 18 values:

# Return Notes
1 itemName
2 itemLink basic item:ID hyperlink (colored, bracketed name)
3 itemQuality 0–5
4 itemLevel
5 itemMinLevel required level
6 itemType localized class name (e.g. "Weapon")
7 itemSubType localized subclass name (e.g. "Sword")
8 itemStackCount max stack size
9 itemEquipLoc INVTYPE_* token, "" for non-equippable
10 itemTexture icon path (no fileID system here)
11 sellPrice vendor sell price in copper
12 classID
13 subclassID
14 bindType 0 none / 1 BoP / 2 BoE / 3 BoU / 4 quest
15 expansionID always 0 (classic)
16 setID item-set ID, nil if none
17 isCraftingReagent always false (no such flag in the item data)
18 itemDescription item flavor/description text ("" if none)

Asynchronous on a cache miss: if the item isn't cached yet it returns nothing (nil) and warms the cache; the value lands on a retry after GET_ITEM_INFO_RECEIVED fires — same contract as the stock GetItemInfo. Fields 4–18 are the ones the stock global never returned.

local name, link, quality, ilvl, minLevel, itype, isub, stack, equipLoc,
      tex, sell, classID, subID, bind, expac, setID, reagent, desc
    = C_Item.GetItemInfo(6948)   -- Hearthstone

C_Item.GetItemInfoInstant(item)

Accessor for the always-available subset of item info — the fields that depend only on classification, not on player-specific state. Synchronous, side-effect-free: peeks the client-side item cache and returns whatever it has without warming or queueing.

Accepts a numeric itemID or a string containing "item:NNN" (matches both the bare "item:1234" shorthand and full chat links like "|cff...|Hitem:1234:...|h[Name]|h|r"). Item names are not accepted — there is no name → ID resolver, and it's rarely the form addon code actually has on hand.

Returns seven values:

itemID, itemType, itemSubType, itemEquipLoc, icon, classID, subClassID

The itemID return is always populated for any input that resolves to a positive integer (so it's safe to use the function as an ID extractor on a link without first warming the cache). The remaining six fields come from the cache record when present — when the item isn't cached yet they're all nil, but the call still returns a 7-tuple so the positional shape is preserved.

  • itemType / itemSubType are the localized class / subclass names (e.g. "Weapon" / "One-Handed Swords"), read from ItemClass.dbc and ItemSubClass.dbc.
  • itemEquipLoc is the "INVTYPE_*" constant (e.g. "INVTYPE_HEAD"), or "" for non-equippable items.
  • icon is a path string ("Interface\\Icons\\..."), matching what the rest of the API returns. There is no fileID system here, so a path is the only meaningful value.
  • classID / subClassID are the raw enum integers (e.g. 2, 7 for one-handed swords).
local id, type, subtype, equipLoc, icon, classID, subClassID
    = C_Item.GetItemInfoInstant(6948)  -- Hearthstone, cached
-- type="Miscellaneous", subtype="Junk", equipLoc="",
-- icon="Interface\\Icons\\INV_Misc_Rune_01", classID=15, subClassID=0

local id = C_Item.GetItemInfoInstant("|cff...|Hitem:6948:0:0:0|h[Hearthstone]|h|r")
-- id == 6948 even when the cache is cold — the call parses the link
-- to extract the ID without consulting the cache.

The actual class/subclass values reflect this client's data. For example, there is no Cloth subclass under Trade Goods — Silk Cloth lives at (7, 0) in this client, not (7, 5).

No auto-warmup. Unlike GetItemInfo or C_Item.GetItemNameByID, GetItemInfoInstant does not trigger a network query on a cache miss. The "Instant" name is contractual — callers that need the cache populated should use C_Item.RequestLoadItemDataByID explicitly, or just call GetItemInfo (which warms via the Script_GetItemInfo hook).

C_Item.GetItemInventorySlotInfo(inventorySlot)

Returns the localized display name for an Enum.InventoryType value — e.g. C_Item.GetItemInventorySlotInfo(1) → "Head". Returns nil for the non-equip slot (0) or out-of-range values.

The engine's INVTYPE table stores the equipLoc key ("INVTYPE_HEAD"); the display name is the FrameXML global string of that key (INVTYPE_HEAD = "Head"), so the result is correctly localized on non-English clients.

C_Item.GetItemInventorySlotKey(inventorySlot)

Returns the equipLoc key string for an Enum.InventoryType value — e.g. C_Item.GetItemInventorySlotKey(1) → "INVTYPE_HEAD". This is the same INVTYPE_* token C_Item.GetItemInfoInstant returns as its itemEquipLoc. Returns nil for the non-equip slot (0) or out-of-range values. Pair it with GetItemInventorySlotInfo to turn the key into a localized label.

C_Item.GetItemInventoryType(itemLocation) / C_Item.GetItemInventoryTypeByID(item)

Returns the numeric Enum.InventoryType straight off the cache record's m_inventoryType field (+0x2C) — the integer sibling of GetItemInfoInstant's 4th return (which gives the INVTYPE_* string).

Value Constant Slot
0 INVTYPE_NON_EQUIP_IGNORE non-equippable
1 INVTYPE_HEAD head
2 INVTYPE_NECK neck
… … …
20 INVTYPE_ROBE chest (full-body robes)
26 INVTYPE_RANGEDRIGHT ranged
27 INVTYPE_QUIVER quiver/ammo pouch

Items here only produce values 0..28; the higher constants (INVTYPE_PROFESSION_*, INVTYPE_EQUIPABLESPELL_*, etc.) are never returned. Code that compares against the higher enum values still resolves correctly because items here just don't carry those types.

local t = C_Item.GetItemInventoryTypeByID(19019)  -- Thunderfury: 17 = INVTYPE_2HWEAPON
if t == 1 then -- head
    ...
end

C_Item.GetItemLink(itemLocation)

Returns the fully-decorated per-instance hyperlink for the item at the location — same string GetContainerItemLink(bag, slot) or GetInventoryItemLink("player", slot) would return for the same slot. Enchant ID, random-suffix, and any other per-instance data attached to the CGItem are preserved.

local link = C_Item.GetItemLink({bagID = 0, slotIndex = 1})
-- "|cffa335ee|Hitem:16539:911:::::::70::::::::::|h[General's Silk Boots]|h|r"
--                          ^^^ — enchant ID (Speed +15%) preserved

Implemented by reading the location table's fields and tail-calling the engine's link script function (Script_GetContainerItemLink at 0x004F9930 for bag locations, Script_GetInventoryItemLink at 0x004C8C10 for equipment slots). The link string is built by the engine off the live CGItem, so it always matches what other addons see via the older positional-arg APIs.

C_Item.GetItemLocation(itemGUID)

Reverse lookup from a GUID string (the format C_Item.GetItemGUID returns) to an itemLocation table identifying the slot currently holding that item. Returns nil if the GUID isn't resident in the walked scope.

local guid = C_Item.GetItemGUID({equipmentSlotIndex = INVSLOT_HEAD})
-- player moves item to bag, drops it, etc. — guid stays valid across
-- bag/equipment rearrangements until the item leaves the session
local loc = C_Item.GetItemLocation(guid)
-- loc might now be { bagID = 1, slotIndex = 4 }, or nil if sold

We return a plain table with the same field shape every other C_Item.* API in ClassicAPI accepts as input ({equipmentSlotIndex=N} or {bagID=B, slotIndex=S}), so the result pipes straight into C_Item.GetItemQuality(loc), C_Item.GetItemLink(loc), etc.

Walked scope. Character-pane equipment (slots 1..19) + backpack (bagID 0) + the four equipped bags (bagIDs 1..4). Bank and keyring are NOT walked — same scope as the rest of the C_Container.* family. Worst case is ~80 slot lookups; fine for sporadic use, not appropriate for per-frame polling.

Implementation. We don't go through the engine's typed object-by-GUID helper at 0x00468460 (the one used for the auction- sell slot) because that path asserts on bad input — stale GUIDs from addon-side caching would crash the client. Instead we walk equipment + bag slots, read each CGItem's GUID, and compare. Misses return nil; hits never need a fallback.

C_Item.GetItemMaxStackSize(itemLocation) / C_Item.GetItemMaxStackSizeByID(item)

Returns the item type's max stack size — what you'd find as the 7th return of GetItemInfo(item). 1 for non-stackable items; 5, 20, 200, etc. for stackables. Different from C_Item.GetStackCount / the engine's GetContainerItemInfo, which return the current count in a specific slot.

local cap = C_Item.GetItemMaxStackSizeByID(2589)  -- Linen Cloth → 20

Single uint32 read at cache record +0x60 (m_stackable). By-ID form fires a background cache fill on miss and returns nil; re-call after GET_ITEM_INFO_RECEIVED.

C_Item.GetItemName(itemLocation) / C_Item.GetItemNameByID(item)

Returns the item's display name as a string, or nil for empty / uncached / invalid inputs.

The location form points at a live item instance, so it returns the decorated name — random suffix included ("Iridium Chain of the Owl", not the base "Iridium Chain") — built off the CGItem via the engine's own name builder, matching the bracketed name in C_Item.GetItemLink. (It falls back to the base ItemStats_C.m_name[0] name if the instance build yields nothing.)

The ByID form has no instance — an itemID can't carry a random suffix — so it returns the base m_name[0] name only. Cache miss on the ByID form returns nil and fires the cache fill so the next call (after GET_ITEM_INFO_RECEIVED) succeeds.

-- location form → decorated name (random suffix included)
local name = C_Item.GetItemName({equipmentSlotIndex = 2})  -- "Iridium Chain of the Owl"
-- ByID form → base name only (no instance → no suffix)
local name = C_Item.GetItemNameByID(6948)                  -- "Hearthstone"

C_Item.GetItemQuality(itemLocation) / C_Item.GetItemQualityByID(item)

Returns the item's quality as an integer (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary), matching the LE_ITEM_QUALITY_* constants. Single uint32 read at cache record +0x1C.

if C_Item.GetItemQualityByID(itemID) >= LE_ITEM_QUALITY_RARE then
    -- highlight rare-or-better drop
end

C_Item.GetItemSellPrice(itemLocation) / C_Item.GetItemSellPriceByID(item)

Returns the vendor sell price in copper, per unit (multiply by stack count for the per-stack value). Matches the 11th return of GetItemInfo. Returns nil on cache miss / invalid input. Cache miss fires a background fill so a follow-up call after GET_ITEM_INFO_RECEIVED returns the value.

local unit = C_Item.GetItemSellPriceByID(2589)   -- Linen Cloth → 25 (copper)
local stack = unit * C_Item.GetItemMaxStackSizeByID(2589)

Single uint32 read at cache record +0x28 (m_sellPrice). Tooltips don't surface this — the field is populated on every sellable item but the engine's tooltip code never reads it — so this function exposes data that's been sitting in the cache unused.

C_Item.GetItemSetID(itemLocation) / C_Item.GetItemSetIDByID(item)

Returns the ItemSet.dbc ID for the item if it's part of an equipment set (e.g., 181 for any Magister's Regalia piece), or nil if the item isn't part of any set / the item isn't cached / the slot is empty.

local setID = C_Item.GetItemSetIDByID(16682)   -- Magister's Regalia: 181
local setID = C_Item.GetItemSetIDByID(6948)    -- Hearthstone: nil

-- by itemLocation (worn or in-bag)
local chestSet = C_Item.GetItemSetID({ equipmentSlotIndex = 5 })

This is the 16th return of GetItemInfo (the setID field). Single uint32 read at cache record +0x1C0 (m_itemSet). Same cache-warm pattern as the other *ByID getters — cache misses return nil and fire a background load; call C_Item.RequestLoadItemDataByID(itemID) if you need synchronous- after-event resolution.

C_Item.GetItemSetInfo(setID)

Returns a table describing the item set, or nil if setID doesn't resolve to an ItemSet.dbc row.

local info = C_Item.GetItemSetInfo(181)
-- {
--   setID = 181,
--   name = "Magister's Regalia",
--   requiredSkill = 0,
--   requiredSkillRank = 0,
--   items = { 16685, 16683, 16686, 16684, 16687, 16689, 16688, 16682 },
--   bonuses = {
--     { spellID = 29091, threshold = 2 },   -- 2-piece bonus
--     { spellID = 27867, threshold = 6 },   -- 6-piece bonus
--     { spellID = 18679, threshold = 8 },   -- 8-piece bonus
--     { spellID = 30777, threshold = 4 },   -- 4-piece bonus
--   },
-- }
Field Type Notes
setID number Echo of the input.
name string Localized set name.
requiredSkill number Skill line ID required to use the set; 0 if none.
requiredSkillRank number Required rank in that skill line; 0 if none.
items array itemIDs in the set. Empty slots filtered, so #info.items is the real item count. Order is the engine's own (matches the set's order in the DBC; not necessarily slot-sorted).
bonuses array { spellID, threshold } tables for each non-empty set-bonus slot. threshold is the number of equipped set pieces required to grant spellID. Order is the DBC's; bonuses with the same threshold are not normalized.

Reads ItemSet.dbc directly — no cache warm-up needed (DBCs load at boot). Returns nil for setID == 0, out-of-range IDs, or rows that don't exist.

C_Item.GetItemSpell(item)

Returns (spellName, spellID) for the on-use spell attached to an item (potions, trinkets, scrolls, hearthstone, food/drink, etc.), or nil for items without one (vendor trash, regular gear, weapons with passive procs).

item accepts the same input shapes as GetItemInfo — a numeric itemID, a chat-link "|cffffffff|Hitem:6948:0:0:0|h[...]|h|r" fragment, or a "item:NNN" short form.

C_Item.GetItemSpell(6948)
-- "Hearthstone", 8690

C_Item.GetItemSpell(13442)
-- "Mighty Rage Potion", 17528

C_Item.GetItemSpell(11288)  -- a soulstone (trigger=4, not ON_USE)
-- nil

C_Item.GetItemSpell(2589)   -- Linen Cloth, no spell
-- nil

Returns the ON_USE spell only. Items can carry up to 5 spell entries in their ItemStats_C record, each with its own trigger code:

Trigger Meaning Surfaced by GetItemSpell?
0 ON_USE yes
1 ON_EQUIP (passive aura on gear) no
2 CHANCE_ON_HIT (weapon procs) no
4 SOULSTONE (on-death) no
5 ON_USE_NO_DELAY no (TODO: should we add this?)
6 LEARN_SPELL (recipes) no

GetItemSpell reports on-use triggers only. Addons that need the other trigger types (proc auras, recipe targets) should reach into the cache directly — the spell slots are at ItemStats +0x11C (5 spell IDs) and +0x130 (5 trigger codes).

Auto-warmup on cache miss. Items not yet in the local cache return nil and silently kick off an SMSG_ITEM_QUERY_SINGLE request. A second call after GET_ITEM_INFO_RECEIVED lands the data. Same warmup pattern as C_Item.GetItemFamily and the rest of our cache-backed accessors.

C_Item.GetItemStatDelta(itemLink1, itemLink2)

Returns a table of the per-stat difference between two items, computed as item2 - item1 — a positive value means the second item has more of that stat. Only stats whose delta is non-zero appear.

-- Compare an equipped ring against one in your bags:
local delta = C_Item.GetItemStatDelta(
    C_Item.GetItemLink({equipmentSlotIndex = 11}),
    C_Item.GetItemLink({bagID = 0, slotIndex = 3}))
-- delta = { ITEM_MOD_INTELLECT_SHORT = 6, ITEM_MOD_SPIRIT_SHORT = 2 }
-- (the bag ring has +6 Int / +2 Spirit relative to the equipped one)

Same key set, accepted input forms, random-suffix handling, and caching caveat as C_Item.GetItemStats — see there for the details. Returns nil if either item is uncached.

Returns a table of an item's stats, keyed by the FrameXML global-string names WoW's own GetItemStats uses (so _G[key] yields the display label — _G["ITEM_MOD_STRENGTH_SHORT"] → "Strength"). Only stats the item actually carries are present.

C_Item.GetItemStats("item:2244")            -- Krol Blade
-- { ITEM_MOD_STRENGTH_SHORT = 7, ITEM_MOD_STAMINA_SHORT = 5,
--   ITEM_MOD_DAMAGE_PER_SECOND_SHORT = 40.89,
--   ITEM_MOD_CRIT_MELEE_RATING = 1, ITEM_MOD_CRIT_RANGED_RATING = 1 }

C_Item.GetItemStats("item:12022:0:769")     -- "of the Owl" random suffix
-- { ITEM_MOD_INTELLECT_SHORT = 6, ITEM_MOD_SPIRIT_SHORT = 6 }

Sources. The table is assembled from three places, so it reflects what the item actually grants — not just its stored stat slots:

  1. Base record — the stored attributes, armor, resistances, and (for weapons) DPS.
  2. On-equip spells (SpellTrigger == ON_EQUIP) — the engine stores the "special" bonuses (crit, attack power, spell power, hit, mp5, defense, …) as an equip spell whose aura effect carries the value, not as a stat slot. Their auras are decoded into the keys below.
  3. Random suffix — the link's third item:id:enchant:SUFFIX:unique field (ItemRandomProperties.dbc → SpellItemEnchantment.dbc): stat suffixes ("of the Owl") are themselves equip spells, armor suffixes ("of Toughness") are direct resistance enchants. A bare itemID or a suffix-less link contributes nothing here.

Keys.

Key Stat Source
ITEM_MOD_STRENGTH_SHORT / _AGILITY_ / _STAMINA_ / _INTELLECT_ / _SPIRIT_SHORT Base attributes record / MOD_STAT
ITEM_MOD_MANA_SHORT / ITEM_MOD_HEALTH_SHORT Mana / health on equip record / MOD_INCREASE_ENERGY (35) / MOD_INCREASE_HEALTH (34)
RESISTANCE0_NAME Armor record / MOD_RESISTANCE bit 0
RESISTANCE1_NAME … RESISTANCE6_NAME Holy / Fire / Nature / Frost / Shadow / Arcane resistance record / MOD_RESISTANCE / type-4 enchant
ITEM_MOD_DAMAGE_PER_SECOND_SHORT Weapon DPS (float) avg damage ÷ swing time
ITEM_MOD_ATTACK_POWER_SHORT Attack power MOD_ATTACK_POWER (99)
ITEM_MOD_RANGED_ATTACK_POWER_SHORT Ranged attack power (ranged-only items) MOD_RANGED_ATTACK_POWER (124)
ITEM_MOD_CRIT_MELEE_RATING + ITEM_MOD_CRIT_RANGED_RATING Crit % weapon-crit aura (52)
ITEM_MOD_HIT_MELEE_RATING + ITEM_MOD_HIT_RANGED_RATING Hit % hit aura (54)
ITEM_MOD_HIT_SPELL_RATING Spell hit % spell-hit aura (55)
ITEM_MOD_CRIT_SPELL_RATING Spell crit % spell-crit aura (57 / 71)
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT Spell damage MOD_DAMAGE_DONE (13)
ITEM_MOD_SPELL_HEALING_DONE_SHORT Healing MOD_HEALING_DONE (135)
ITEM_MOD_MANA_REGENERATION mp5 (mana) MOD_POWER_REGEN (85)
ITEM_MOD_DEFENSE_SKILL_RATING Defense skill MOD_SKILL (30, misc 95)
ITEM_MOD_DODGE_RATING Dodge % MOD_DODGE_PERCENT (49)
ITEM_MOD_PARRY_RATING Parry % MOD_PARRY_PERCENT (47)
ITEM_MOD_BLOCK_RATING Block chance % MOD_BLOCK_PERCENT (51)
ITEM_MOD_BLOCK_VALUE Shield block value record (m_block)

Values are native percentages, not ratings. There is no rating system here, so the percent-based stats (crit / hit / defense) report the raw percentage under the *_RATING key — e.g. Krol Blade's "+1% crit" is ITEM_MOD_CRIT_MELEE_RATING = 1. A native percent is level-independent. Flat stats (attack power, spell damage, healing, mp5) are the item's actual magnitudes.

Rating stats that don't exist here (haste, mastery, versatility, expertise, resilience) never appear. A generic "+N attack power" item carries both a melee and a ranged AP aura; the generic ITEM_MOD_ATTACK_POWER_SHORT key subsumes it, so ITEM_MOD_RANGED_ATTACK_POWER_SHORT shows only for ranged-only items (scopes, ranged weapons). Rarer auras with no clean single key — percent attack power, per-weapon-line skill, damage-taken, on-hit / on-use procs — are skipped.

Input. Accepts a full chat hyperlink, an item:N… link string, or a bare itemID. Returns nil if the item isn't cached yet — warm it via GetItemInfo and retry.

GetItemClassInfo(classID)

Returns className — the localized ItemClass.dbc name for an item class ID (the classID from Enum.ItemClass / the 12th GetItemInfo return). Also available as C_Item.GetItemClassInfo(classID).

GetItemClassInfo(2)   -- "Weapon"
GetItemClassInfo(4)   -- "Armor"
GetItemClassInfo(1)   -- "Container"

Returns nil for an unknown class. Obsolete slots return the client's literal string (GetItemClassInfo(3) → "Jewelry(OBSOLETE)") — use Enum.ItemClass for the key names.

GetItemSubClassInfo(classID, subClassID) / C_Item.GetItemSubClassInfo(classID, subClassID)

Returns subClassName, subClassUsesInvType for an item class/subclass pair (the global form documents the second value as isArmorType — same value).

GetItemSubClassInfo(2, 7)   -- "One-Handed Swords", false
GetItemSubClassInfo(4, 1)   -- "Cloth", true
  • subClassName (string) — the localized ItemSubClass.dbc name (verbose form, e.g. "One-Handed Swords"; falls back to the short form for subclasses that only populate it, e.g. "Consumable").
  • subClassUsesInvType (boolean) — true for subclasses whose items are labeled by inventory slot rather than by subclass name. Here that's exactly the armor material types (Miscellaneous, Cloth, Leather, Mail, Plate); false for weapons, shields, librams, idols, totems, and non-equipment. Read from the ItemSubClass.dbc flags bit 0x200.

Returns nil if the (classID, subClassID) pair has no row.

C_Item.GetItemUniqueness(itemLocation) / C_Item.GetItemUniquenessByID(item)

The two functions have different signatures:

C_Item.GetItemUniqueness(itemLocation) returns (limitCategory, limitMax):

Field Source
limitCategory Always 0 — there is no LimitCategory.dbc.
limitMax ItemStats_C.m_maxCount — 0 = unlimited, 1 = "Unique", higher = inventory cap.

C_Item.GetItemUniquenessByID(itemID) returns (isUnique, limitCategoryName, limitCategoryCount, limitCategoryID):

Field Source
isUnique m_maxCount > 0 — true for any unique-tagged item.
limitCategoryName Always nil — no categories here.
limitCategoryCount m_maxCount when isUnique, else nil.
limitCategoryID Always nil.

Both functions return nothing (zero Lua return values) if the item record isn't cached. The by-ID variant fires a background cache fill on miss; re-call after GET_ITEM_INFO_RECEIVED.

local _, max = C_Item.GetItemUniqueness({equipmentSlotIndex = 13})
-- max = 1 for typical trinkets (Unique)
local isUnique, _, count = C_Item.GetItemUniquenessByID(81013)
-- isUnique = true, count = 10  (Southern Sand Crawler Leg, "Unique (10)")

The category half is for items with a shared unique-equipped limit ("Unique-Equipped: Eye of Azshara"). There are no such items here, so the category fields stay nil/0.

C_Item.GetStackCount(itemLocation)

Returns the current stack count in a specific slot — distinct from C_Item.GetItemCount(item), which sums every stack of that itemID across the player's inventory. Useful when an addon needs to know "this specific stack has 13/20", not "I have 47 total".

local n = C_Item.GetStackCount({bagID = 0, slotIndex = 1})
-- 13 (the bag's first slot has 13 of whatever item)

Reads ITEM_FIELD_STACK_COUNT directly off the item's m_objectFields — same field GetContainerItemInfo returns as itemCount. Returns 0 for empty / unresolvable locations.

C_Item.GetWeaponEnchantInfo()

Returns a 12-tuple, including the temp-enchant IDs that the stock 8-return global omits.

hasMain, mainExpire, mainCharges, mainEnchantID,
hasOff,  offExpire,  offCharges,  offEnchantID,
hasRanged, rangedExpire, rangedCharges, rangedEnchantID
   = C_Item.GetWeaponEnchantInfo()
-- Apply Brilliant Mana Oil to mainhand, then:
local has, expireMs, charges, enchantID = C_Item.GetWeaponEnchantInfo()
-- has = true, expireMs = 1800000, charges = 0, enchantID = 2629

-- Apply Instant Poison instead:
-- has = true, expireMs = 1800000, charges = 40, enchantID = 323

An oil runs on time alone and reports charges = 0. A poison reports a charge count as well as a time.

Reads the temporary enchant slot (ITEM_FIELD_ENCHANTMENT slot 1 at descriptor +0x4C) — the same slot oils, sharpening stones, and poisons populate and the engine drains as they expire. This is what GetWeaponEnchantInfo measures.

The permanent enchant (Crusader, Mongoose, etc., in slot 0 at +0x40) is not reported here — that's a separate field and GetWeaponEnchantInfo doesn't expose it either. Get the permanent enchant ID by parsing GetInventoryItemLink("player", slot) (the link includes it as the 2nd numeric field).

The stock global GetWeaponEnchantInfo is unchanged — old addons reading positions 4..8 by index still work.

Equivalent to the extended form of GetWeaponEnchantInfo.

C_Item.GetItemTempEnchantInfo(itemLocation)

Returns the temporary enchant on any item you own — a poison, a weapon oil, a sharpening stone, or a shaman imbue:

hasEnchant, expirationMs, charges, enchantID
   = C_Item.GetItemTempEnchantInfo(itemLocation)
-- The poison on a swap weapon that sits in a bag
local has, expireMs, charges, enchantID =
    C_Item.GetItemTempEnchantInfo({ bagID = 0, slotIndex = 3 })
if has then
    local info = C_Item.GetEnchantInfo(enchantID)
    print(info.name, charges .. " charges")
end

The location takes the same forms as the other C_Item location calls: a table ({equipmentSlotIndex=N} or {bagID=B, slotIndex=S}), or the GUID string from C_Item.GetItemGUID.

The four returns match one weapon slot of C_Item.GetWeaponEnchantInfo, so the two read the same way. enchantID goes straight into C_Item.GetEnchantInfo for the name and the effects.

A weapon keeps its poison while it rests in a bag. The equipped-slot calls cannot see that weapon. This call can.

This call never returns the permanent enchant (Crusader, Mongoose). That enchant is a separate field.

An empty slot returns false, 0, 0, 0. An item you do not own returns the same, and so does a location that resolves to nothing.

This call is a ClassicAPI extension.

C_Item.GetEnchantInfo(enchantID)

Resolves an item-enchantment ID — the enchantID returned by C_Item.GetWeaponEnchantInfo for a weapon's temporary enchant, and the same IDs item permanent enchants use — into a table:

local info = C_Item.GetEnchantInfo(enchantID)
-- info.enchantID = <id>
-- info.name      = "Crusader"        -- localized display name
-- info.effects   = { {type=1, amount=0, arg=20007} }
-- info.spellID   = 20007             -- spell-type enchants only
-- A proc/equip enchant → chain its spellID into C_Spell:
local info = C_Item.GetEnchantInfo(1900)          -- "Crusader"
if info.spellID then
    print(C_Spell.GetSpellDescription(info.spellID))  -- the proc's text
end

effects is an array of the record's non-empty effect slots, each { type, amount, arg } where type is the standard ITEM_ENCHANTMENT_TYPE:

type meaning carries
1 combat-proc spell arg = spellID
2 weapon damage amount = +damage
3 equip spell / aura arg = spellID
4 resistance / armor amount = +value
5 stat arg = stat index, amount = value
6 totem —
7 use spell arg = spellID

For spell types (1/3/7) arg is a spellID feedable into C_Spell.GetSpellInfo / GetSpellDescription; the first such id is also surfaced at top level as spellID for convenience (absent for non-spell enchants like sharpening stones).

Returns nil for a non-numeric / non-positive id, an out-of-range id, or a record with no name.

Reads SpellItemEnchantment.dbc (records 0x00C0D7D8, count 0x00C0D7DC) — the 24-column table every enchant ID indexes: Type[3]@+0x04, Amount[3]@+0x10, EffectArg[3]@+0x28, Name[8]@+0x34 (locale-indexed). The layout was verified by parsing the on-disk DBC against known records (Crusader 1900 → type 1, arg 20007; Sharpened +3 → type 2, amount 3). Fully resident from boot, so it answers for any enchant ID with no caching or round-trip.

Lives in C_Item (not C_Spell) because the id originates from C_Item.GetWeaponEnchantInfo and the concept is an item enchantment — SpellItemEnchantment is just the DBC's internal name (enchants are implemented via spell effects).

Not derivable: the source item. The enchant record holds no back-reference to the item that applied it, and there is no client-side reverse index (enchantID → item). Finding it would need an external scraped DB (pfQuest/Questie-style).

C_Item.IsBound(itemLocation)

Returns true if the item at the given location is soulbound, false otherwise (including when the slot is empty or the location is malformed). The client tracks the soulbound bit on each item instance directly; previously the only way to read it from Lua was a scan-tooltip hack (SetBagItem + string-compare against the localized ITEM_SOULBOUND constant) — slow, locale-fragile, and one of the hottest paths during bag updates.

if C_Item.IsBound({equipmentSlotIndex = INVSLOT_HEAD}) then ... end
if C_Item.IsBound({bagID = 0, slotIndex = 1}) then ... end
if C_Item.IsBound(itemGUID) then ... end

IsConsumableItem(item) / C_Item.IsConsumableItem(item)

Returns true if the item is a consumable, false otherwise. An item counts as consumable when its class is Consumable (0) or its inventory type is INVTYPE_AMMO (24) or INVTYPE_THROWN (25) — ammo and thrown weapons being consumed in use. Registered as both the bare global and the namespaced C_Item form, like IsUsableItem.

item is an itemID number or "item:N..." link. Item names aren't accepted (there is no name→ID resolver). Returns false for uncached items (no async load fired) — same cache contract as C_Item.IsEquippableItem.

IsConsumableItem(118)              -- Minor Healing Potion → true  (class Consumable)
C_Item.IsConsumableItem(1251)     -- Linen Bandage → true          (class Consumable)
C_Item.IsConsumableItem(2512)     -- Rough Arrow → true            (INVTYPE_AMMO)
C_Item.IsConsumableItem(6948)     -- Hearthstone → false
C_Item.IsConsumableItem(18820)    -- Talisman of Ephemeral Power → false (on-use trinket)

This is a class/ammo check, not a "has a usable effect" check. An on-use trinket with a numeric Use: effect (Talisman of Ephemeral Power) returns false, while class-Consumable items (potions, bandages, the class-0 "Faintly Glowing Skull") and ammo return true.

C_Item.IsEquippableItem(item)

Returns true if item can be equipped in any character-pane slot, false otherwise. Reads m_inventoryType from the cached ItemStats record — INVTYPE_NON_EQUIP (value 0) is the only "not equippable" value, so any non-zero inventory type passes (head, neck, weapon, shield, holdable, …).

item is an itemID number or "item:N..." link. Item names aren't accepted — there is no name→ID resolver, and equippability is an itemID-keyed property anyway.

Returns false for uncached items (no async load fired). If you need it to wait for the cache, call C_Item.RequestLoadItemDataByID first and re-check on ITEM_DATA_LOAD_RESULT.

C_Item.IsEquippableItem(12640)  -- Lionheart Helm → true
C_Item.IsEquippableItem(6948)   -- Hearthstone → false

IsUsableItem(item) / C_Item.IsUsableItem(item)

Returns (usable, noMana) for an item's on-use ability — the item analog of IsUsableSpell. This asks "can I click this item right now?", not "can I equip it". Armor proficiency, class-restricted gear, and the like are not this function's concern (that's the tooltip's red requirement lines); only an item's usable/right-click effect matters here.

The global IsUsableItem(item) returns 1/nil pairs (matching stock IsUsableAction); C_Item.IsUsableItem(item) returns proper booleans per the C_Item.* convention. item is an itemID number or "item:N..." link.

IsUsableItem(6948)                     -- Hearthstone → 1, nil
local usable, noMana = C_Item.IsUsableItem(13446)  -- Major Healing Potion
-- usable=true,  noMana=false → click it
-- usable=false, noMana=false → no on-use effect, below required level,
--                              class/race-restricted, dead, on a form
--                              that blocks it, etc.

What this function checks:

  1. The item has an on-use spell (an ItemStats spell slot with trigger ON_USE). Items with no usable effect — plain gear, materials, on-equip procs — return (nil, nil).
  2. The item's use requirements are met: RequiredLevel, and the AllowableClass / AllowableRace masks. (These gate activation — a too-low-level potion, a class-specific trinket — so they belong here, unlike equip-only requirements.)
  3. The on-use spell is castable now — delegated to the engine's own spell-castability helper (FUN_SPELL_IS_USABLE, 0x006E3D60), which folds in shapeshift/form, mechanic immunities, aura state, and the power check. Insufficient power is the only condition that sets noMana=true.

Like C_Item.IsEquippableItem, this is a synchronous cache peek: uncached items return (nil/false, nil/false) with no async load fired. Call C_Item.RequestLoadItemDataByID first and re-check on ITEM_DATA_LOAD_RESULT if you need it to wait.

The on-use spell lookup is shared with C_Item.GetItemSpell (Item::Spell::FindOnUseSpellIDInRecord).

C_Item.IsEquippedItem(item)

Returns true if item is currently equipped in any of the 19 character-pane slots, false otherwise. Walks slots 1..19 in order and short-circuits on the first match.

item accepts the same shapes as GetItemInfo:

Form Example Match strategy
itemID 2589 exact itemID equality
bare link "item:2589:0:0:0" parses the first numeric field
chat link \124cffffffff\124Hitem:2589:0:0:0\124h[Linen Cloth]\124h\124r extracts itemID after \124Hitem:
name "Linen Cloth" case-insensitive match against each equipped item's decorated name (random suffix included)

Returns false (no Lua error) for invalid input — nil, empty string, or a string that doesn't parse as any of the above.

Name matching is against the item's decorated name: a random-suffix item matches only its full name ("Krol Blade of the Bear"), not the base ("Krol Blade"). Unsuffixed items match their plain name. The candidate must be in the client item cache; equipped items always are, so that's a non-issue in practice. This match logic is shared with the by-name action APIs (C_Item.UseItemByName / EquipItemByName) — one predicate, one set of semantics.

if C_Item.IsEquippedItem(2589) then
    -- Linen Cloth is equipped (silly example — pick a wearable item)
end

-- From a chat link click:
if C_Item.IsEquippedItem(itemLink) then ...

-- By localized name:
if C_Item.IsEquippedItem("Thunderfury, Blessed Blade of the Windseeker") then ...

C_Item.IsItemDataCachedByID(item) / C_Item.IsItemDataCached(itemLocation)

Returns true if the item's static data is currently in the client-side item cache, false otherwise. The "ByID" variant takes an itemID or "item:NNN"-style string; the location variant takes the {equipmentSlotIndex=} / {bagID=, slotIndex=} table.

These read the cache without firing a server query — pair with RequestLoadItemData(ByID) if you need to ensure the data is loaded before checking.

if not C_Item.IsItemDataCachedByID(itemID) then
    C_Item.RequestLoadItemDataByID(itemID)
    -- (poll IsItemDataCachedByID on a timer until true)
end

C_Item.IsItemGUIDInInventory(itemGUID)

Returns whether the item with the given "0x…" GUID is currently carried by the player — the 19 equipment slots plus the bags (backpack + the four equipped bags). Equipped items count; the bank does not.

local g = C_Item.GetItemGUID({bagID = 0, slotIndex = 1})
C_Item.IsItemGUIDInInventory(g)   -- true
C_Item.IsItemGUIDInInventory(
    C_Item.GetItemGUID({equipmentSlotIndex = 1}))   -- true (equipped counts)

Accepts the GUID string C_Item.GetItemGUID / UnitGUID return; returns false for a malformed or zero GUID. To also cover the bank, use C_Item.GetItemCount(itemID, true) > 0 instead.

C_Item.IsItemInRange(item, targetUnit)

Returns true if the item is in range of targetUnit, false if out of range, and nil when the range check doesn't apply — the item has no on-use spell, its on-use spell is rangeless, or the item / unit can't be resolved. Returns nil for items with no range restriction.

item is an itemID, "item:NNN" string, or item link (item names aren't resolvable — there is no name→ID map — and return nil). targetUnit is a unit token.

-- a targeted on-use item (net / bomb / thrown / targeted quest item):
C_Item.IsItemInRange(itemID, "target")   -- true / false
-- rangeless / self-use item:
C_Item.IsItemInRange(118, "target")       -- Minor Healing Potion → nil

An item's range comes from the spell it fires on use, so this resolves the item to its on-use spell (C_Item.GetItemSpell's spell) and runs the exact same range test as C_Spell.IsSpellInRange — the two agree for the same underlying spell. Range-only like the spell version: it ignores line of sight and doesn't reject wrong-faction targets. Passive reader — an item not yet in the client item cache returns nil with no background fetch (items you'd range-check are normally in bags / on the action bar and already cached). Absent tokens (e.g. "target" with no target) return nil; a genuinely unrecognized token string raises a Lua error, same contract as C_Spell.IsSpellInRange.

C_Item.IsItemOpenable(itemLocation)

Returns two values: (isOpenable, canOpen).

  • isOpenable — true if the item type is right-click-openable (sack, clam, simple chest, quest box, lockbox, etc. — anything whose tooltip could show <Right Click to Open>). Intrinsic to the item type. From ItemStats.Flags & 0x4.
  • canOpen — true if the player can right-click this specific instance and trigger the open action right now. For unlocked-by- default items (clams, sacks) this matches isOpenable. For items with a lock (lockboxes), it's only true after the lock has been removed (rogue Pick Lock, key item, etc.) — so a priest looking at a fresh lockbox sees (true, false), while a rogue who just picked it sees (true, true).

Both values are nil when the item data isn't cached yet (background SMSG_ITEM_QUERY_SINGLE fired) or when the location is empty / invalid. Lets callers distinguish "data unknown" from "definitely not openable".

There's no ByID variant — the canOpen check depends on a specific instance's ITEM_FIELD_FLAGS, which you can only have via an itemLocation. If you only have an itemID, use C_Item.GetItemInfoInstant to inspect the item type instead.

-- Loop a bag and gather every openable the player can act on now
for slot = 1, GetContainerNumSlots(0) do
    local _, canOpen = C_Container.IsContainerItemOpenable(0, slot)
    if canOpen then
        -- pickup + use; this slot is good to open
    end
end

-- Show a different hint depending on the lock state
local isOpenable, canOpen = C_Item.IsItemOpenable({bagID = 0, slotIndex = 1})
if isOpenable and not canOpen then
    print("This item is locked.")
end

Implementation: isOpenable reads ItemStats.Flags & 0x4 from the client-side ItemSparse cache. canOpen adds a check that either ItemStats.LockID == 0 (no lock) or the CGItem instance's ITEM_FIELD_FLAGS & 0x4 (UNLOCKED) bit is set. Same conditions the engine's tooltip builder uses at 0x0052E323 to gate the ITEM_OPENABLE line.

C_Item.IsLocked(itemLocation)

Returns true if the item is in the client-side "in-transaction" lock state — picked up onto the cursor, mail-attached, trade-attached, mid-swap, etc. Reads the per-CGItem instance flag at +0x314 bit 0 (not the ITEM_FIELD_FLAGS descriptor field — the actual lock is on the instance, not in m_objectFields).

if C_Item.IsLocked({bagID = 0, slotIndex = 1}) then
    -- skip the action; slot is mid-transaction
end

The lock is client-managed, server-confirmed:

  • Engine sets the bit optimistically in pickup/equip/attach paths, before the transaction packet goes to the server. UI greys immediately, no round-trip wait.
  • Engine clears the bit when the matching SMSG_UPDATE_OBJECT confirms the transaction.

Listen for the engine's ITEM_LOCK_CHANGED event (no payload — the engine fires it on every set/clear and addons re-poll the items they care about) to react without timed polling.

C_Item.LockItem(itemLocation)

Set the client-side lock on a single item by location. Calls the same engine primitive (FUN_ITEM_LOCK_BY_GUID = 0x004953E0) the engine itself uses optimistically before sending pickup/equip transactions. Fires ITEM_LOCK_CHANGED the same way.

C_Item.LockItem({bagID = 0, slotIndex = 1})

Setting the lock doesn't make anything real happen on the server. It's a pure client-side flag — the player's UI greys the item out until something clears it. No transaction packet is sent, no cancel-on-failure mechanism kicks in. Treat this as a UI hint for custom workflows (batch-equip preview, drag-target highlighting, etc.), not real transaction state.

The lock stays until you call UnlockItem / UnlockAllItems, OR the engine receives an SMSG_UPDATE_OBJECT for the item from the server, which clears it as a side effect.

C_Item.LockItemByGUID(itemGUID)

Same as LockItem but takes a GUID string in the canonical "0xHHHHHHHHLLLLLLLL" format C_Item.GetItemGUID returns. The engine resolves through the object manager, so this can mark items the player doesn't currently have in bags/equipment — trade window contents, mail attachments, freshly looted but not yet bagged — as long as the CGItem is loaded.

local guid = C_Item.GetItemGUID({equipmentSlotIndex = INVSLOT_HEAD})
C_Item.LockItemByGUID(guid)

Same caveats as LockItem — purely client-side, doesn't affect any real engine transaction state.

C_Item.PickupItem(itemInfo)

Finds the item matching itemInfo and picks it up onto the cursor — the C-side equivalent of clicking it. From there the held item can be dropped into a bag/equipment slot, deleted (DeleteCursorItem), sold at a merchant, etc.

itemInfo accepts the same shapes as C_Item.EquipItemByName — itemID number, bare "item:N" string, full chat link, or a localized item name (matched case-insensitively against each candidate's decorated name, random suffix included). Unlike the by-name equip/use functions, the search covers equipment (slots 1..19, checked first) and then bags (0..4), so PickupItem picks up a worn item as readily as a bagged one.

Returns nothing. Silently no-ops when:

  • the input is nil, an empty string, or otherwise unparseable
  • no matching item is found in equipment or bags
  • the cursor is already holding something (CursorHasItem() is true)
  • the matched item is locked (mid-transaction)

Drives the engine's cursor primitives directly — the paperdoll-slot pickup (FUN_004C7300) for an equipped match, the container pickup (FUN_00494B60 + client-side item lock) for a bag match — not the Lua PickupInventoryItem / PickupContainerItem globals. Same shared Item::Cursor wrappers EquipItemByName's auto-slot path uses.

-- Pick up a bagged item by name, then drop it into the off-hand slot:
C_Item.PickupItem("Linen Cloth")
PickupInventoryItem(17)  -- or EquipCursorItem(), etc.

-- Pick up a worn item by ID (unequips onto the cursor):
C_Item.PickupItem(2589)

-- From a chat link:
C_Item.PickupItem(itemLink)

C_Item.RequestLoadItemDataByID(item) / C_Item.RequestLoadItemData(itemLocation)

Asks the engine to fetch the item's data from the server if not already cached. Returns true if the request was initiated (or the input was parseable to an itemID), false for malformed input. Fire-and-forget — the engine handles the round-trip; the data lands in the cache when the server responds.

Fires ITEM_DATA_LOAD_RESULT(itemID, success) when the data lands in the cache. Synchronously fired when the item was already cached (so polling code paths still work), asynchronously fired when the engine's SMSG response handler completes a network fetch.

success is 1 or nil. The engine's printf-style event dispatcher has no %b token, so we encode the boolean as 1 for success / nil for failure (leaning on lua_pushstring(NULL) → lua_pushnil). if success then … distinguishes them correctly — nil is falsy, 1 is truthy. Same encoding as GET_ITEM_INFO_RECEIVED and QUEST_DATA_LOAD_RESULT.

local f = CreateFrame("Frame")
f:RegisterEvent("ITEM_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
    -- vanilla 1.12: event payload is in `arg1`, `arg2`, ... globals
    if event == "ITEM_DATA_LOAD_RESULT" and arg2 then
        local _, type = C_Item.GetItemInfoInstant(arg1)
        -- ...
    end
end)
C_Item.RequestLoadItemDataByID(2589)

Engine-aligned cache strategy. RequestLoadItemData does NOT eagerly create a cache entry. Empirically (e.g. pfQuest calling it at addon-load / PLAYER_ENTERING_WORLD against a cleared WDB), pre- creating the entry races the engine's natural inventory prefetch and can leave the item permanently pending — the engine sees the entry exists and skips its own SMSG_ITEM_QUERY_SINGLE, while our early query gets dropped. Instead we track the itemID and hook the engine's SMSG_ITEM_QUERY_SINGLE_RESPONSE handler at 0x0055BDB0: when the engine fills any entry, we sweep tracked items and fire ITEM_DATA_LOAD_RESULT for matches. For items the engine doesn't prefetch (quest rewards, AH browse targets, etc.) we escalate after ~60 ticks by calling CacheFetch ourselves — by then engine state has settled and our query goes through normally. Hard timeout at ~1200 ticks (~20–40s) fires ITEM_DATA_LOAD_RESULT(itemID, nil).

C_Item.UnlockAllItems()

Sweep clear of every CGItem the engine knows about — same primitive the engine itself runs on PLAYER_LEAVING_WORLD. One ITEM_LOCK_CHANGED fires at the end.

C_Item.UnlockAllItems()

Cheapest recovery if you don't know which item is stuck. Same caveat as UnlockItem: this is purely client-side state — it won't cancel any pending server-side transactions.

C_Item.UnlockItem(itemLocation)

Force-clear the client-side lock on one item. Recovery primitive — fires ITEM_LOCK_CHANGED so the UI refreshes.

C_Item.UnlockItem({bagID = 0, slotIndex = 1})

Useful when a transaction packet was sent but the server's confirmation never arrived: the item stays visually greyed indefinitely (the only built-in trigger for the unlock-all sweep is logout). This call gives you an in-session escape hatch.

Cursor / server state isn't touched. Unlocking doesn't tell the server "cancel my transaction" — it only clears the local visual lock. If the item is genuinely mid-flight, the server's next update will set the lock right back. For cursor-cancel semantics, pair with the engine's ClearCursor().

C_Item.UseAtCursor(item)

Uses item at the player's current cursor world position — ClassicAPI's [@cursor] analog for ground-target on-use items (Iron Grenade, Bombling, demolition charges, etc.). Returns true when the cursor-placement leg landed (the item fires at terrain); false for items that aren't ground-target (the item still fires normally with no implicit target), unparseable input, items you do not have, cursor over UI / off-screen, etc.

item takes either of two forms:

  • An item reference: an itemID, bare "item:N", a full chat link, or a localized name — the shapes C_Item.UseItemByName takes. The first matching item in your bags is used.
  • An item location: {bagID = B, slotIndex = S}, {equipmentSlotIndex = N}, or an item GUID string — the shapes C_Item.GetItemID takes. A location names one exact item, so use it when you hold two stacks of the same thing and the one you mean matters.
C_Item.UseAtCursor(4068)            -- Iron Grenade at cursor
C_Item.UseAtCursor("Iron Grenade")
C_Item.UseAtCursor({ bagID = 0, slotIndex = 1 })

When the item fires a non-ground-target spell, the cursor leg no-ops and returns false; the item still uses normally (any implicit target — current selection, etc. — applies).

Cancels placement automatically when the cursor isn't on terrain — the item-use packet is never sent, so an off-screen click doesn't waste the grenade.

C_Item.UseAtUnit(item, unit)

Unit-position analog of C_Item.UseAtCursor: uses item at unit's feet rather than the cursor. ClassicAPI's [@unit] for ground-target on-use items. item accepts the same two forms as UseAtCursor — an item reference or an item location; unit is any unit token ("player", "target", "mouseover", "party1", …).

C_Item.UseAtUnit(4068, "target")            -- Iron Grenade at the target's feet
C_Item.UseAtUnit("Iron Grenade", "player")
C_Item.UseAtUnit({ bagID = 0, slotIndex = 1 }, "player")

Returns true when the placement landed at the unit. Returns false for unparseable input, an item you do not carry, or a unit that cannot be resolved. The unit is resolved before the item fires, so an absent unit fails without consuming the item.

An item with no ground effect also returns false, and is used on the unit: the item fires with that unit as its target rather than with whatever you have selected. So this covers both kinds of on-use item, the same way C_Spell.CastAtUnit covers both kinds of spell.

A unit token that names nothing right now, such as "party3" while solo, returns false. A string that is not a unit token at all raises the engine's standard "Unknown unit" error, as UnitHealth("garbage") does and as C_Spell.CastAtUnit does. That differs from C_Item.UseItemByName, whose contract is to no-op on anything it cannot use.

C_Item.UseItemByName(item [, unit])

Uses item. Returns nothing; silently no-ops when:

  • the input is nil, an empty string, or otherwise unparseable
  • the player does not have the item
  • the engine refuses the use — cooldown, locked item, level requirement, etc.

item takes either of two forms:

  • An item reference: an itemID number, bare "item:N" string, a full chat link, or a localized item name — the shapes C_Item.EquipItemByName takes. The first matching item in your bags is used. Name matching is case-insensitive against each candidate's decorated name (random suffix included), the same shared predicate C_Item.IsEquippedItem uses.
  • An item location: {bagID = B, slotIndex = S}, {equipmentSlotIndex = N}, or an item GUID string — the shapes C_Item.GetItemID takes. A location names one exact item, so use it when you hold two stacks of the same thing and the one you mean matters.

The optional unit argument is a unit token ("player", "target", "focus", "partyN", "raidN", "nameplateN", …) used as the cast target for items that fire a spell (scrolls, traps, on-use targeted effects). For self-use items (hearthstone, potions, food) the engine overwrites the target with the item's own GUID before dispatch, so passing a unit to those is harmless and has no effect. Unrecognized strings are treated as "no target" rather than raising, matching the silently-no-op contract of item.

C_Item.UseItemByName("Hearthstone")                       -- hearth home
C_Item.UseItemByName(6948)                                -- same thing, by ID
C_Item.UseItemByName("Major Healing Potion")
C_Item.UseItemByName("Scroll of Stamina IV", "target")    -- buff your tank
C_Item.UseItemByName({ bagID = 0, slotIndex = 1 })        -- that exact slot

Locates the item directly, then hands the CGItem * to the engine's by-pointer use primitive at 0x005D8D00. That primitive dispatches internally based on item type (food, potion, on-use spell, scroll, quiver, ...) so a single call covers every item category. We skip Script_UseContainerItem entirely — its branches for repair vendor, spell-cast targeting, and drop-on-bag cursor modes don't apply to an addon-issued call from a clean cursor.

The engine ships ~14 Get*ItemLink functions covering every frame that lets you mouse over an item (loot, merchant, quests, auction, trade, mail, tradeskill, craft). To get the itemID, the standard pattern is to call the Link function and scrape the number out of the returned string with gsub / match. Direct-ID accessors exist for only a handful of these (GetLootSlotItemID, GetInboxItemID, GetQuestItemID, GetMerchantItemID, …).

These add the missing ID companions, so the whole Get*ItemLink family has a 1-to-1 ID companion. Each reads the same itemID the engine reads when building the link, with no string parsing required.

All return nil for an empty slot, an out-of-range index, or when the relevant UI frame isn't open.

Function Companion to Notes
GetLootRollItemID(rollID) GetLootRollItemLink Group-loot roll ID (from START_LOOT_ROLL). Walks the engine's in-progress roll list.
GetLootSlotItemID(slot) GetLootSlotLink 1-based slot. Returns nil for coin slots.
GetMerchantItemID(index) GetMerchantItemLink 1-based; the active merchant's inventory.
GetQuestItemID(type, index) GetQuestItemLink type is "reward" or "choice"; active quest-offer / completion UI.
GetQuestLogItemID(type, index) GetQuestLogItemLink Same args; reads the currently selected quest-log entry.
GetTradePlayerItemID(slot) GetTradePlayerItemLink 1..7; your side of the trade window.
GetTradeTargetItemID(slot) GetTradeTargetItemLink 1..7; the other player's side.
GetAuctionItemID(type, index) GetAuctionItemLink type is "list", "owner", or "bidder".
GetAuctionSellItemID() GetAuctionSellItemInfo The item currently in the sell slot. No args.
GetInboxItemID(mailID) GetInboxItem 1-based mail index; nil for gold-only mail.
GetTradeSkillItemID(index) GetTradeSkillItemLink The recipe's created item itemID.
GetTradeSkillReagentItemID(index, reagentIndex) GetTradeSkillReagentItemLink reagentIndex is 1-based; reagents are densely packed (no skipping empty slots).
GetCraftReagentItemID(craftIndex, reagentIndex) GetCraftReagentItemLink Craft frame (enchanting / beast training) — same shape as tradeskill reagents.
GetCraftSpellID(craftIndex) GetCraftItemLink The craft frame's link is Hspell:, not Hitem: — so the companion is a spellID, not an itemID. Addons scraping for itemID get nil today; this returns the actual identifier.
-- Drop-in for the typical scrape:
--   local id = tonumber((GetLootRollItemLink(rollID)):match("item:(%d+)"))
local id = GetLootRollItemID(rollID)

-- Plays nice with the existing data APIs:
if id then
    local _, _, _, _, _, classID = C_Item.GetItemInfoInstant(id)
    -- ...
end

Quest UI distinction. GetQuestItemID reads the active quest offer (the QuestFrame you see when accepting/turning in a quest); GetQuestLogItemID reads the currently-selected entry in the quest log. The two cover different states intentionally — the same split as GetQuestItemLink / GetQuestLogItemLink.

GetTradeSkillItemID vs GetCraftSpellID. Recipes are split across two UI frames: TradeSkill (smithing / alchemy / tailoring / etc., which always produce a finished item) and Craft (enchanting formulas / beast-training tomes, where the spell IS the deliverable). GetTradeSkillItemID returns the produced itemID; GetCraftSpellID returns the recipe spellID — because there's no produced item to point at.

GetAverageItemLevel()

Returns (avgItemLevel, avgItemLevelEquipped). avgItemLevelEquipped is the arithmetic mean over the player's currently-worn equipment slots; avgItemLevel is the same metric but extended to include best-per-slot upgrades found anywhere in the player's bags and bank.

Slots considered: INVSLOT_HEAD..INVSLOT_TABARD (1..19) minus shirt (4) and tabard (19) = 17 candidate slots. Bag-walk uses the same invType → slotMask mapping as GetInventoryItemsForSlot.

local overall, equipped = GetAverageItemLevel()
-- overall:  best-per-slot ilvl across equipped + bags + bank
-- equipped: ilvl of currently-worn items only

Fixed denominator. Empty slots count toward the denominator (contributing 0 to the sum). Removing a piece of gear always lowers avgItemLevelEquipped. (A populated-only count would keep the average up when a slot empties; this implementation deliberately differs.)

Max-of-two-divisors fairness. A 2H weapon wielder has an intentionally empty offhand (slot 17) — counting it as 0 in a 17-divisor sum would unfairly penalize them. We compute a second candidate excluding slot 17 from both numerator and denominator (16 slots), and return the max. Sword+shield characters typically win the all-17 path; 2H wielders win the no-OH path.

No double-counting. Each bag/bank item is assigned to one candidate slot via greedy fit — a trinket in your bag fills one trinket slot (the empty/lower-best one), not both. Without this gate, moving a trinket from equipped to bag would inflate the "overall" count and drag the average down (because the same item would count for both trinket slots).

Bank is walked even if the bank window has never been opened this session — the GUID array at invMgr + 0x04 is populated from server data at login. Bypasses the bank-window gate on GetItemBySlot the same way C_Item.GetItemCount does.

Limitations:

  • Items not yet in the ItemStats cache are skipped from the running totals and a warmup is queued; the next call after ITEM_DATA_LOAD_RESULT lands picks them up.
  • A full weapon qsort dance — handling 2H-equipped vs. 1H + OH-in-bag comparisons — is not replicated. The edge case where you'd notice a difference is narrow.

GetInventoryItemDurability(invSlot)

Returns (current, maximum) durability for the player's equipped item at invSlot (1-based, character-pane slots 1..19), or nothing if the slot is empty or the item has no durability concept (rings, trinkets, necks, backs, shirts, tabards, etc.).

current, maximum = GetInventoryItemDurability(invSlot)
local cur, max = GetInventoryItemDurability(INVSLOT_CHEST)
if cur then
    -- cur, max are positive integers (e.g. 65, 65 for full chest)
end

-- Items without durability return nothing — both locals are nil:
local cur, max = GetInventoryItemDurability(INVSLOT_FINGER1)
-- cur == nil, max == nil

Player-only. GetInventoryItemDurability takes only the slot, no unit token. Inspect targets / party members' equipment durability isn't broadcast, so we couldn't expose it for other units even if we wanted to.

(0, max) vs nothing. Items that have a durability concept but are currently broken (current == 0, max > 0) still return (0, max). The "nothing" return is reserved for items that have no durability fields populated at all — max is the discriminator, matching the engine's own GetInventoryItemBroken logic.

Reads ITEM_FIELD_DURABILITY (+0xA0) and ITEM_FIELD_MAXDURABILITY (+0xA4) directly off the CGItem's m_objectFields descriptor at +0x114 — same descriptor C_Item.IsBound reads FLAGS from. No DBC indirection.

GetInventoryItemID(unit, slot)

Returns the itemID of the item equipped at slot (1-based) on unit, or nil if the slot is empty / the unit isn't valid / the unit doesn't expose its equipment to the local client. Same arg shape as 1.12's GetInventoryItemLink(unit, slot) — drop-in for code that just needs the ID and would otherwise have to parse the link string.

  • For "player" (and any token resolving to the local player, e.g. "target" when self-targeted): walks the private inventory manager. Supports the full slot range (equipment 1..19, bag slots 20..23, bank slots, etc.) — same range GetItemBySlot accepts internally.
  • For other player-controlled units ("target", "party1"..party4", inspect targets): reads the unit's broadcast visible-items array. Equipment slots 1..19 only.
  • For NPCs / creatures: returns nil. The visible-items array isn't populated for non-player-controlled units in 1.12, so we gate this on UnitPlayerControlled to avoid the engine crash that GetInventoryItemLink itself would trigger on the same input.
local id = GetInventoryItemID("player", INVSLOT_HEAD)
if id then
    local _, type, subtype = C_Item.GetItemInfoInstant(id)
    -- ...
end

-- Inspect a party member without parsing a hyperlink:
local headID = GetInventoryItemID("party1", INVSLOT_HEAD)

GetInventoryItemRepairCost(invSlot)

Returns the cost in copper to repair the player's equipped item at invSlot. Same value GameTooltip:SetInventoryItem returns as its third out-parameter.

copperCost = GetInventoryItemRepairCost(invSlot)
local cost = GetInventoryItemRepairCost(INVSLOT_CHEST)
if cost > 0 then
    -- e.g. cost == 12345
end

Returns 0 for slots that are empty, items without a durability concept, fully-repaired items, or items whose stats aren't cached yet (rare — happens briefly after login before SMSG_ITEM_QUERY_RESPONSE arrives for newly-seen items). The "no return" path is reserved for invalid input (non-numeric slot).

Player-only, same constraint as GetInventoryItemDurability — other units' durability isn't broadcast in 1.12.

Discount is vendor-context-dependent. The faction-rep + PvP rank discount is only applied when the player has a merchant window open (i.e. has received SMSG_LIST_INVENTORY). Called from anywhere else, you get the raw, undiscounted base cost.

The engine tracks the current merchant via globals DAT_00BDDFA0/A4 (the merchant's GUID), set when the merchant frame opens and zeroed when it closes. The helper short-circuits the discount path when those globals are zero.

For consistent "what will I pay" semantics inside addons, only trust this value when MerchantFrame:IsShown() is true — or call it once per merchant-visit and cache.

Calls the engine's own per-item repair-cost helper at 0x004FAF30, which is the same function Script_GameTooltip_SetInventoryItem calls for its repairCost return. The raw cost comes from DurabilityCosts.dbc (indexed by item subclass and slot type) × DurabilityQuality.dbc (indexed by item quality); the discount — when applicable — stacks faction reputation with the merchant (Friendly+ unlocks a base 5%) and PvP rank (Sergeant Major+ adds another 5%, Knight-Lieutenant+ stacks one more).

This is a ClassicAPI addition — there is no standalone Lua function for per-item repair cost otherwise; the only other way to read it is the tooltip's third return value. We expose it directly because the underlying calculation is already in the engine and a Lua function is the natural surface.

GetInventoryItemsForSlot(slot, returnTable [, transmogrify])

Populates returnTable with every item in the player's equipment and bags that's eligible to be equipped in slot (1..19). Returned table is keyed by the packed ItemLocation bitfield — same encoding C_EquipmentSet.GetItemLocations uses — with the itemLink as the value. Returns the same returnTable reference for chaining.

local t = {}
GetInventoryItemsForSlot(16, t)   -- mainhand
for loc, link in pairs(t) do
    -- loc is a number like 0x00300103; link is "|c…|Hitem:…|h[…]|h|r"
end
slot Eligible InventoryTypes
1 HEAD, 2 NECK, …, 10 HANDS The slot's matching InventoryType
11, 12 FINGER INVTYPE_FINGER (11) — items appear under both ring slots
13, 14 TRINKET INVTYPE_TRINKET (12)
15 BACK INVTYPE_CLOAK (16)
16 MAINHAND INVTYPE_WEAPON (13), INVTYPE_2HWEAPON (17), INVTYPE_WEAPONMAINHAND (21)
17 OFFHAND INVTYPE_WEAPON (13), INVTYPE_SHIELD (14), INVTYPE_WEAPONOFFHAND (22), INVTYPE_HOLDABLE (23)
18 RANGED INVTYPE_RANGED (15), INVTYPE_THROWN (25), INVTYPE_RANGEDRIGHT (26)
5 CHEST INVTYPE_CHEST (5), INVTYPE_ROBE (20)
19 TABARD INVTYPE_TABARD (19)

Compatibility check is a single bitwise AND against a static invType → slotMask table, with two adjustments:

  • 2H weapons (INVTYPE_2HWEAPON) confined to the mainhand slot only. There is no titanic-grip mechanic, and the server would reject an offhand equip anyway.
  • INVTYPE_WEAPONMAINHAND / WEAPONOFFHAND confined to their literal slot rather than "either hand".

The transmogrify arg is accepted and ignored. There is no transmog system to query against, and the flag's effect (broader cross-eligibility for visual swaps) reduces to the regular equip check here. Private servers like Turtle WoW layer their own transmog systems on top; this function doesn't currently plumb the flag through to any of them.

Bank items aren't included. The engine's GetItemBySlot gates bank slots on a banker GUID that's only set while the bank window is open (bank shows up only when the bank UI is open). We don't walk bank slots — addons that want bank inclusion need their own bank scan with the bank UI open.

GetItemIcon(itemID) / C_Item.GetItemIcon(itemLocation) / C_Item.GetItemIconByID(item)

Three accessors that all return the icon path for an item, differing only in how you address the item:

Function Input
GetItemIcon(itemID) numeric itemID (global)
C_Item.GetItemIcon(itemLocation) {equipmentSlotIndex=N} or {bagID=B, slotIndex=S}
C_Item.GetItemIconByID(item) numeric itemID OR "item:NNN" string (full chat links work)

All three return the icon path string (e.g. "Interface\\Icons\\INV_Misc_Rune_01"), or nil if the item isn't in the client-side cache, the slot is empty, or the display info record is missing. The path is suitable for direct use with texture:SetTexture(...).

local path = GetItemIcon(6948)                                -- "Interface\\Icons\\INV_Misc_Rune_01"
local path = C_Item.GetItemIcon({equipmentSlotIndex = INVSLOT_HEAD})
local path = C_Item.GetItemIconByID("|cff...|Hitem:6948:0:0:0|h[Hearthstone]|h|r")

iconID-vs-path deviation. This value can be a fileID:number elsewhere. There is no fileID system here — same situation as C_Spell.GetSpellTexture and C_Spell.GetSpellInfo's iconID field. We surface the path string everywhere for consistency.

Equivalent to the global GetItemIcon and the C_Item.GetItemIcon / GetItemIconByID family.

OffhandHasWeapon()

Returns true if the player has a one-handed weapon (or off-hand-only weapon) equipped in the off-hand slot, false otherwise. Used by dual-wield checks and weapon-equipment logic.

Returns false for:

  • Empty off-hand
  • Shields (INVTYPE_SHIELD)
  • Held items — tomes, orbs, librams (INVTYPE_HOLDABLE)
  • Off-hand item data not yet cached (typically only on first login, before the engine has the item record; warms up after a peek)

Returns true for INVTYPE_WEAPON (any one-handed weapon — sword, axe, mace, dagger, fist) and INVTYPE_WEAPONOFFHAND (off-hand-only weapons). Two-handers occupy the main-hand slot exclusively, so they never apply.

if OffhandHasWeapon() then
    -- Apply mainhand+offhand poison, refresh dual-wield rotation, etc.
end

Launch

Options you add to the command line that starts the game, alongside -console. They are not Lua functions — put them on the launcher shortcut, or on whichever executable you start the client with:

VanillaFixes.exe -console -config Config2.wtf

An option name is matched without regard to case, and -name value, -name=value and /name value all work. Put double quotes around a value that contains spaces.

-config <name> (launch switch)

Reads and writes WTF\<name> in place of WTF\Config.wtf, so one install can hold several settings profiles.

VanillaFixes.exe -console -config Config-raid.wtf

The name must be a plain filename with no folder in it. A name that contains \, / or : is refused, and the client uses Config.wtf instead. That way a setting is never written somewhere you cannot find it.

The client both loads and saves through this one name. Everything the client keeps in that file follows the profile: video and sound settings, console variables, and the account name the login screen remembers. The addon enable list does not, because it lives in WTF\Account\ instead.

On first use the file does not exist yet. The client starts from its defaults and writes the file when you quit.

-gluescript / -gluescriptFile / -gamescript / -gamescriptFile (launch switches)

Run Lua as the client starts. Four options, in two pairs: one pair for the login and character-select screens, one pair for in-game. In each pair, the plain option takes the code itself and the File option takes the path to a file that contains it.

Option Runs
-gluescript <code> at the login and character-select screens
-gluescriptFile <path> the file's contents, at those same screens
-gamescript <code> one time, in-game
-gamescriptFile <path> the file's contents, one time in-game
VanillaFixes.exe -console -gamescript "print('hello from the command line')"
VanillaFixes.exe -console -gamescriptFile C:\scripts\startup.lua

When each one runs. Every script runs after the interface for its screen has loaded. So it can call interface functions, read and change frames, and use anything an addon has set up.

A glue script runs each time the login and character-select screens appear. That includes the first time and every return from the world when you log out. A script that logs in for you therefore keeps working after a logout.

A game script runs one time only, on the first frame after you enter the world. It does not run again for /reload, and it does not run again if you log out and back in on another character.

Files. A path may be absolute or relative to the folder the client runs in. The file is read at the moment the script runs, so you can edit it between launches without touching the shortcut. A file the client cannot read is reported in Logs\classicapi_debug.log and the rest of the launch continues.

Both at once. If you pass both options of a pair, both run: the code first, then the file.

Errors. A script that fails to compile, or that raises while it runs, is reported the same way any other script error is. It does not stop the client, and it does not stop the other scripts.

Loot

Programmatic loot operations that the stock Lua surface doesn't expose: enumerate nearby lootable corpses, open a loot session against one by GUID, take a specific item out of one without going through the visible LootFrame, and pre-scan every reachable corpse to build a picker table before any cursor work. Foundation for AoE-loot, auto-loot-by-filter, and similar addons that the stock LootSlot / LootSlotInfo flow can't drive.

All guid args are hex-string GUIDs in the same form UnitGUID returns ("0xF130..."). All itemID args are integers.

C_Loot.GetNearbyLootableUnits()

Returns a 1-indexed array of { guid = "0x..." } subtables, one per visible unit that the server has flagged lootable AND that's within the engine's right-click-loot interact range. Empty table pre-login, when no lootable units are in range, or when the local player has no loot rights on visible corpses (the server only sets the lootable flag for clients with rights).

for _, unit in ipairs(C_Loot.GetNearbyLootableUnits()) do
    C_Loot.LootUnit(unit.guid)
end

The interact-range check matches what the engine uses for the hover-to-loot cursor — slightly tighter than melee range, looser than "on top of." Server still enforces real range on the resulting CMSG_LOOT; in-client OOR requests just silently get no response.

C_Loot.LootUnit(guid)

Opens a loot session against the given corpse. The loot window appears asynchronously via the normal LOOT_OPENED event once SMSG_LOOT_RESPONSE arrives — same flow as right-clicking the corpse, just dispatched programmatically.

No return value. Silent no-op when:

  • The object manager is NULL (glue / character-select).
  • The GUID doesn't resolve to a visible unit (out of render range, despawned, wrong typemask).
  • The local player pointer can't be resolved.

Errors only on bad Lua usage (missing arg, unparseable GUID).

-- Loot everything in range, one corpse at a time. With autoloot on,
-- the engine drains each window automatically; without it, you
-- still get the visible loot frame per corpse.
for _, unit in ipairs(C_Loot.GetNearbyLootableUnits()) do
    C_Loot.LootUnit(unit.guid)
end

useDistanceCheck is hardcoded off — the engine won't try to auto-walk the player toward a corpse just out of range. That'd be a surprise side effect for an AoE-loot loop.

C_Loot.LootUnitItem(guid, itemID)

Takes a specific item from a specific corpse without using the visible loot frame's slot list. Dispatches CMSG_LOOT_ITEM directly.

Two paths, picked automatically:

  1. Fast path — the corpse's loot window is already open for the requested target. Synchronously finds the slot, sends the take packet, returns true if the slot was found / false if not.
  2. Async path — opens the corpse's window first (same wire as C_Loot.LootUnit), waits for the response, then sends the take packet automatically. Returns true once the request is queued. No "did it succeed" event; observe BAG_UPDATE / ITEM_PUSH to confirm.

false immediately when:

  • Args missing or wrong types (raises an error, not returns false).
  • Another LootUnitItem is mid-flight (one at a time).
  • The world isn't loaded.
  • The target GUID doesn't resolve to a visible unit.
  • (sync path only) No slot in the open window holds the itemID.

The send bypasses the BoP-confirmation dialog. Server still enforces all real permissions (loot rights, distance, master-loot rules); the BoP prompt is purely a client-side courtesy that callers of a programmatic API have already opted out of.

-- Scan, then take just one specific item out of one specific corpse.
C_Loot.ScanNearbyLoot()  -- LOOT_SCAN_COMPLETED → results below
local results = C_Loot.GetLastScanResults()
local guid = results[1].guid
local itemID = results[1].items[1].itemID
C_Loot.LootUnitItem(guid, itemID)

C_Loot.ScanNearbyLoot()

Initiates a pre-scan: opens each lootable corpse in range one at a time, scrapes its slot contents (itemID, count, item link), closes the window, advances. The LOOT_OPENED / LOOT_CLOSED events are suppressed for the duration of the scan so LootFrame and other listeners don't react — the scan is invisible to addons that aren't opted in.

Returns true if the scan was queued, false if another scan is already in progress or the world isn't loaded. Completion is signaled via the LOOT_SCAN_COMPLETED event; results are read via C_Loot.GetLastScanResults().

local f = CreateFrame("Frame")
f:RegisterEvent("LOOT_SCAN_COMPLETED")
f:SetScript("OnEvent", function()
    for _, corpse in ipairs(C_Loot.GetLastScanResults()) do
        for _, item in ipairs(corpse.items) do
            ChatFrame1:AddMessage(item.link or ("item:"..item.itemID))
        end
    end
end)
C_Loot.ScanNearbyLoot()

Per-corpse step timeout is ~3-6 seconds (180 WorldTick frames) so a single dropped server response doesn't hang the whole scan. Failed corpses simply don't appear in the results.

C_Loot.LootAllCorpses([max])

Loots every nearby lootable corpse in sequence — the same corpse walk as ScanNearbyLoot, but it actually takes the loot: coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM) from each window before releasing and advancing to the next corpse. The optional max caps how many corpses are visited (default: all in range).

Returns true if the loot walk was queued, false if a walk (this or ScanNearbyLoot) is already in progress or the world isn't loaded — the two share one state machine, so IsScanInProgress reports either, and only one runs at a time.

Like the scan, the walk is silent: LOOT_OPENED / LOOT_CLOSED are suppressed so LootFrame never flickers open per corpse. The normal item-received and money events still fire (CHAT_MSG_LOOT, CHAT_MSG_MONEY, inventory updates), so the player sees what they got.

-- Loot everything within interact range
C_Loot.LootAllCorpses()

-- Loot at most 5 corpses
C_Loot.LootAllCorpses(5)

Completion is signaled by LOOT_SCAN_COMPLETED; afterward GetLastScanResults() reports what each corpse held — i.e. what was looted.

Range and permissions. Only corpses inside the engine's own right-click-loot range are queued (same distance test as GetNearbyLootableUnits), and only those the server flagged lootable for you. The item sends bypass the client-side BoP/unique confirm dialog (appropriate for a programmatic loot-all), but the server still enforces every real rule — loot rights, distance, master-loot. Unlike a right-click, this does not change your target or auto-walk you toward out-of-range corpses.

C_Loot.IsScanInProgress()

Returns true while a ScanNearbyLoot call is mid-flight, false otherwise. Use to gate re-scan attempts so addons don't spam-call during the engine's per-corpse round-trip.

C_Loot.GetLastScanResults()

Returns the most-recent completed-scan results as a 1-indexed array:

{
    [1] = {
        guid = "0x...",    -- corpse GUID
        coin = 1234,       -- copper amount; 0 if no coin
        items = {
            [1] = {
                itemID = 2589,
                count  = 4,
                link   = "|cff...|Hitem:2589:0:0:0|h[Linen Cloth]|h|r",
                                   -- omitted if cache entry wasn't loaded
            },
            ...
        },
    },
    ...
}

Empty table before the first scan completes. Replaced wholesale by each new scan — re-read after LOOT_SCAN_COMPLETED rather than caching the table.

The link field is the engine's full hyperlink (color + payload + display name + reset). Random-suffix items like "Stringy Wolf Meat of the Bear" survive round-trip — the link encodes the per-instance enchant / suffix / unique fields, not just the base itemID. For tooltip display, extract the payload form via string.match(link, "|H(item:[^|]+)|h") — SetHyperlink requires the literal "item:" prefix and rejects full |cff...|Hitem...|h input.

LootHistory

The C_LootHistory namespace — group-loot roll history. There is no loot-history store, but the client does receive the live group-loot roll traffic (SMSG_LOOT_ROLL per player, SMSG_LOOT_ROLL_WON, SMSG_LOOT_ALL_PASSED) — which the stock UI only turns into chat lines. This reconstructs the history client-side: packet co-hooks accumulate a ring of rolled items with per-player Need/Greed/Pass results and the winner, exposed through the namespace. Rollers' name and class come from the engine NameCache captured at roll time, so they resolve even for a player who has left the group by the time their roll arrives.

History is in-memory (last 128 rolled items) and resets on /reload or logout. The master-loot management functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are intentionally omitted — they drive server support this build doesn't have.

Progress is signalled by the LOOT_HISTORY_ROLL_CHANGED / LOOT_HISTORY_ROLL_COMPLETE events — see the Events section.

C_LootHistory.GetNumItems()

Returns the number of rolled items currently in the history.

C_LootHistory.GetItem(itemIndex)

1-based. Returns rollID, itemLink, numPlayers, isDone, winnerIdx, timestamp (timestamp is a ClassicAPI extension):

  • rollID — a stable monotonic ID for the roll. Unlike itemIndex (which shifts as the 128-item ring saturates), rollID never changes, so key "keep this row expanded" / "highlight this roll" state on it.
  • itemLink — item:<id>:0:<suffix>:<unique> (valid for GetItemInfo / GameTooltip:SetHyperlink), carrying the roll's random suffix so "of the X" items keep their affix.
  • numPlayers — how many players have rolled/passed so far.
  • isDone — true once the item is decided (won or all-passed).
  • winnerIdx — the winner's 1-based player index, for C_LootHistory.GetPlayerInfo(itemIndex, winnerIdx) (name, class token, winning roll). nil if undecided / all passed. The winner is guaranteed to be in the player list even if their individual roll packet never arrived.
  • timestamp — the roll's creation time in seconds, directly comparable to GetTime() (e.g. GetTime() - timestamp for "N seconds ago"). A ClassicAPI extension.

Returns nothing for an out-of-range index.

C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)

Both 1-based. Returns name, class, rollType, roll, isWinner, isMe:

  • name — the roller's name (from the NameCache).
  • class — the class token ("WARRIOR", "MAGE", …) for RAID_CLASS_COLORS, or nil if unknown.
  • rollType — 0 = pass, 1 = need, 2 = greed.
  • roll — the 1–100 roll, or 0 if they passed.
  • isWinner — true if this player won the item.
  • isMe — true if this roller is the local player.

Returns nothing for an out-of-range item or player index.

C_LootHistory.Clear()

Wipes the accumulated roll history (returns nothing). A ClassicAPI extension, for a "clear history" button. Fires LOOT_HISTORY_FULL_UPDATE so any open display rebuilds empty.

LossOfControl

C_LossOfControl — the active crowd-control / interrupt effects on the local player. There is no such aggregation layer, so it's synthesized: CC types from the player's debuffs, and the school-interrupt lockout from the SMSG_SPELL_COOLDOWN the server sends on a Counterspell / Kick.

C_LossOfControl.GetActiveLossOfControlDataCount()

Returns the number of active loss-of-control effects on the player (0 when none).

C_LossOfControl.GetActiveLossOfControlData(index)

Returns a LossOfControlData table for the index-th active effect (1-based, up to the count above), or nil if the index is out of range.

for i = 1, C_LossOfControl.GetActiveLossOfControlDataCount() do
    local d = C_LossOfControl.GetActiveLossOfControlData(i)
    print(d.locType, d.displayText, d.timeRemaining)
end
Field Type Notes
locType string "STUN", "FEAR", "ROOT", "CONFUSE", "CHARM", "POSSESS", "SILENCE", "PACIFY", "PACIFYSILENCE", "DISARM", or "SCHOOL_INTERRUPT".
spellID number The spell causing the effect. 0 for SCHOOL_INTERRUPT (the interrupting spell isn't in the lockout packet).
displayText string Effect name — the spell name for CC, "Interrupted" for SCHOOL_INTERRUPT.
iconTexture string The spell's icon path (there are no FileDataIDs here; the path is the functional equivalent). "" for SCHOOL_INTERRUPT.
startTime number? GetTime()-epoch seconds. Present for SCHOOL_INTERRUPT and for CC whose applying cast ClassicAPI observed; nil otherwise.
timeRemaining number? Seconds remaining.
duration number? Effect duration, in seconds.
lockoutSchool number The locked spell-school mask for SCHOOL_INTERRUPT (feed to C_Spell.GetSchoolString for the name); 0 for CC effects.
priority number Always 0 — no equivalent here.
displayType number Always 2 (show for the effect's full duration).

Fidelity notes:

  • SCHOOL_INTERRUPT is derived from the server's own lockout packet (Player::ProhibitSpellSchool sends a single SMSG_SPELL_COOLDOWN listing every spell of the interrupted school at one uniform duration), so the school and duration are authoritative. The interrupting spell itself isn't transmitted, hence spellID = 0 and displayText = "Interrupted".
  • CC timing is best-effort — startTime / timeRemaining / duration are filled when ClassicAPI observed the cast that applied the aura (the Aura::Source cache) and are nil otherwise. These fields are nullable.
  • auraInstanceID is omitted (nil) — no equivalent here.

C_LossOfControl.GetSchoolLockout([filterMask])

Returns lockedMask, secondsRemaining for the school-interrupt lockout — the SCHOOL_INTERRUPT slice of the list above, without building the list.

local mask, remaining = C_LossOfControl.GetSchoolLockout()
if mask ~= 0 then
    print(C_Spell.GetSchoolString(mask), "locked for", remaining)
end

-- One school's own remaining time. Masks are 1 << schoolIndex:
-- physical 1, holy 2, fire 4, nature 8, frost 16, shadow 32, arcane 64.
local locked, fireRemaining = C_LossOfControl.GetSchoolLockout(4)
if locked ~= 0 and fireRemaining < 0.5 then
    -- fire clears within half a second
end
Return Type Notes
lockedMask number Every currently locked school OR'd together, as 1 << schoolIndex — the same shape as lockoutSchool. 0 when nothing is locked.
secondsRemaining number? Until every school in lockedMask is clear, i.e. the lockout ending last. nil when lockedMask is 0.

filterMask narrows the scan to those schools, so one school's own remaining time is GetSchoolLockout(1 << schoolIndex). Omitted or 0 means all schools.

Two schools can be locked at the same time — each SMSG_SPELL_COOLDOWN batch locks one, so being kicked on fire and then on frost leaves both active. That is why this returns a mask rather than a single school, and why walking the list above and stopping at the first SCHOOL_INTERRUPT entry is wrong.

Prefer this over the list walk whenever only the school lockout matters: it reads the lockout state directly, allocating no table and skipping the debuff scan that every GetActiveLossOfControlData call performs. Cheap enough to call per frame from a macro conditional.

The LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE events fire as these effects change — see Events. Note that they are driven by a diff of which effects are active, so re-locking an already-locked school extends its lockout without firing either event. Read the remaining time when you need it rather than caching it on the events.

For the effect that blocks one specific spell, shaped as a cooldown, see C_Spell.GetSpellLossOfControlCooldown and its spellbook-slot variants.

Lua

Standard-library functions that this Lua 5.0 is missing — restored by ClassicAPI so addons find them. Most are single-function additions (several are just the 5.0→5.1 renames — string.gmatch←gfind, math.fmod←math.mod); coroutine.* restores the whole stripped coroutine library.

Lua 5.1 syntax

1.12 runs Lua 5.0. It cannot compile five pieces of Lua 5.1 syntax that many addons use. These are the length operator #, the modulo operator %, ... used as an expression, 0x hexadecimal number literals, and leveled long brackets ([=[ ... ]=]). ClassicAPI rewrites addon source to the 5.0 equivalent before it compiles, so all five work:

local n = #myTable          -- length operator
local r = a % b             -- modulo operator
local args = { ... }        -- ... as an expression, not only in a parameter list
local mask = 0xFF00         -- hex number literal
local doc = [=[ has ]] in it ]=]   -- leveled long bracket

The rewrite is transparent. You do not call anything. It runs on every chunk the client compiles — addon files, loadstring, and XML <OnLoad> handlers.

What each form does:

  • #x gives the length of a string, or a border of a table (the value Lua 5.1 # returns). It ignores table.setn.
  • a % b uses the Lua 5.1 result a - floor(a/b)*b, which takes the sign of b. This differs from math.mod (C fmod, truncated toward zero): -1 % 3 is 2, while math.mod(-1, 3) is -1.
  • ... as an expression yields all the varargs. The ... in a function parameter list stays as the vararg declaration.
  • 0xFF00 becomes its decimal value (65280) — the same number Lua 5.1 produces. The 5.0 lexer rejects 0x literals, so without this an addon needs tonumber("0xFF00", 16).
  • [=[ ... ]=] (a leveled long bracket, with any number of =) holds text a plain [[ ... ]] cannot, such as text that contains ]]. ClassicAPI rewrites a leveled long string to a plain long string, or to a quoted string when its body needs one. It removes a leveled long comment (--[=[ ... ]=]).

Addon file arguments. An addon can read its name and its private table from the file arguments:

local addonName, addonTable = ...

Stock 1.12 never passed these to an addon file. ClassicAPI supplies them to any file under Interface\AddOns\ that reads .... addonName is the folder name. addonTable is one table shared by every file of that addon. To read another addon's table, use C_AddOns.GetAddOnLocalTable, which needs that addon's opt-in.

Limits.

  • The rewrite reads strings and comments correctly. A % in "%d" or a # in --[[ # ]] is left alone.
  • A plain long string that contains [[ nests and closes by depth, the same as the 5.0 engine. Lua 5.1 does not nest. For text that contains [[ or ]], use a leveled bracket ([=[ ... ]=]).
  • ClassicAPI rewrites a leveled long string to a plain [[ ... ]] when it can, which keeps the exact value. A body that contains [[ or ]], or ends with ], becomes a quoted string instead. A quoted string keeps a leading newline in the value. Lua 5.1 drops that newline.
  • Only integer hex is converted. Hex floats (0x1.8p3) and literals wider than 64 bits are left as-is. Both are almost nonexistent in addon code.
  • Error line numbers stay correct. The rewrite adds no new lines.
  • The globals __len and __mod are the rewrite's helper functions. Treat them as internal. Do not call them directly.
  • To turn a rewrite off for diagnosis, call _classicapi_SetTranspileOption(name, false), where name is "Length", "Modulo", "VarargExpansion", "HexLiterals", or "LongBrackets". This reverts affected chunks to the state that fails to compile, so use it only to answer "is the rewrite breaking this addon?".
  • _classicapi_TranspileStats() returns five numbers: the chunks loaded, their bytes, the chunks that needed a full token pass, those bytes, and the milliseconds the rewrite has used since the client started.

Upvalue limit

A function can use up to 60 upvalues — the outer locals it refers to.

Lua 5.0 stops at 32 and rejects the whole file with too many upvalues (limit=32). A large handler that reads many file-level locals can be valid Lua 5.1, which allows 60, and still fail to load here for that reason alone. ClassicAPI raises the limit to the same 60.

Nothing changes for a function within the old limit. Past 60 the error is the same message with the new number: too many upvalues (limit=60).

String methods (s:upper(), s:format(...))

In Lua 5.1, every string value accepts method calls: s:method(...) resolves through the string table. Lua 5.0 has no string methods — on stock 1.12, any s:upper() fails with attempt to index a string value. ClassicAPI restores the 5.1 behavior:

("asd"):upper()              -- "ASD"
("%d gold"):format(price)    -- method call on a literal
msg:match("^!(%w+)")         -- method call on a variable
link:sub(1, 5)
  • Both call forms work and give the same result: s:upper() and string.upper(s).
  • Every function in the string table is reachable as a method. That includes the ClassicAPI additions (match / gmatch, reverse) and any function an addon adds to string.
  • Reading an unknown key gives nil, the Lua 5.1 result: ("x").nope is nil, and ("x"):nope() fails with attempt to call, not attempt to index.
  • Writing to a string (("x").y = 1) still fails, the same as Lua 5.1.
  • Works everywhere strings do: in the world, on the login screen, and inside coroutines.
  • To turn method resolution off for diagnosis, call _classicapi_SetStringMethods(false). Strings then error on index, the stock 1.12 behavior. Use it only to answer "is this backport breaking this addon?".

getfenv / setfenv environment protection

A sandbox can protect the environment table it gives to restricted code. Put a metatable on that table with an __environment field:

local view = setmetatable({}, { __environment = "protected" })
setfenv(restrictedFn, view)

From then on, code inside the sandbox sees the protection:

  • getfenv() returns the __environment value, not the real table. The restricted code cannot reach or change the true environment.
  • setfenv() on that environment raises cannot change a protected environment.

This is the Lua 5.1 form. 1.12 already protected environments, but it read a raw __fenv field on the table itself. ClassicAPI adds the 5.1 metatable form and keeps __fenv as a fallback. The change is additive: an environment with neither marker behaves as before.

select(index, ...)

The Lua 5.1 vararg helper, backported to 1.12's Lua 5.0. Two forms:

  • select('#', ...) → the number of extra arguments passed after the index.
  • select(n, ...) → all arguments from position n onward. n may be negative to count back from the end (-1 is the last argument).

Errors on a non-numeric, non-'#' first argument or an index of 0 / below the negative range (matching Lua 5.1's luaL_checkint + index out of range behavior). Registered on both Lua states, so it's available to in-game addons and glue-screen (login/char-select) code alike.

select('#', 'a', 'b', 'c')   -- 3
select(2, 'a', 'b', 'c')     -- 'b', 'c'
select(-1, 'a', 'b', 'c')    -- 'c'

-- Idiomatic vararg count inside a function:
local function log(...)
    for i = 1, select('#', ...) do
        print(i, (select(i, ...)))
    end
end

Ported from Lua 5.1's luaB_select (lbaselib.c). The numeric form pushes nothing — the varargs are already on the stack, so it just returns the count of the slice from n+1 onward.

unpack(list [, i [, j]])

The Lua 5.1 range form. The stock 5.0 unpack(list) takes no range arguments and ignored extras silently — unpack(t, 2) returned the whole table: wrong values, no error. ClassicAPI replaces unpack with the 5.1 version (a port of luaB_unpack):

unpack({10, 20, 30})        -- 10, 20, 30
unpack({10, 20, 30}, 2)     -- 20, 30
unpack({10, 20, 30}, 2, 2)  -- 20

i defaults to 1 and j to the table length — the table.getn length, so a vararg arg table's n field is honored and embedded nils keep their slots. Errors on a non-table first argument, exactly like 5.1.

xpcall(f, msgh [, arg1, ...])

Calls f under the message handler msgh, passing the trailing arguments to f. Returns true plus everything f returned, or false plus the handler's return.

Stock xpcall takes only (f, msgh) and calls f with no arguments. It does not error on extra arguments — it discards them, so a function that reads its parameters receives nil. ClassicAPI forwards them instead:

local ok, sum = xpcall(function(a, b) return a + b end, geterrorhandler(), 2, 3)
-- ok = true, sum = 5

A two-argument call behaves exactly as before, so the capturing-closure form stays correct and needs no change:

xpcall(function() return f(a, b) end, handler)

Available to in-game addons and glue-screen code alike.

Note that error("...") does not reach the handler. WoW replaces Lua's error with one that reports through geterrorhandler() and then returns normally, so f looks like it finished and you get true with the message printed to chat. Real runtime errors — indexing or calling nil, arithmetic on a non-number — do reach the handler. To abort into it deliberately, cause one of those: (nil)() is the shortest.

collectgarbage(opt [, arg])

Accepts Lua 5.1's string options. The stock 5.0 collector takes only an optional numeric threshold and errored on every string option (bad argument #1 (number expected, got string)).

  • "count" — returns the KB of Lua memory in use (the gcinfo() value).
  • "collect" — runs a full garbage-collection cycle. Returns 0.
  • "step" — accepted no-op returning true. The 5.0 collector cannot step incrementally; true means "cycle finished", so repeat until collectgarbage("step") loops exit immediately.
  • "stop" / "restart" / "setpause" / "setstepmul" — accepted no-ops returning 0. The 5.0 collector has no toggle or tuning; callers lose the optimization, nothing breaks.
  • A number (or no argument) keeps the stock threshold behavior unchanged.

table.wipe(t)

Removes every key from t, leaving it empty but preserving its internal hash and array capacity. Returns t for chaining.

local cache = {}
-- ... fill it up ...
table.wipe(cache)              -- cache is now {}
local also_t = table.wipe(t2)  -- also_t === t2 after wipe

Uses Lua 5.0's lua_next entry points. lua_next walks the hash node array linearly, so the canonical lua_next + rawset(k, nil) "during-iteration removal" pattern works in practice even though it's technically undefined per the Lua reference manual.

Errors on non-table input.

table.count(tbl)

Returns (numTableNodes, numArrayNodes, maxArrayIndex) describing how a table is populated (a diagnostic). These are counts of live entries, not the table's allocated capacity:

  • numTableNodes — total number of key/value pairs.
  • numArrayNodes — how many of those have an integer key in the range [1..numTableNodes] (the "contiguous array part" heuristic).
  • maxArrayIndex — the largest positive integral key (>= 1), or 0 if there is none. Negative/zero integral keys don't count.
table.count({ 10, 20, 30 })           -- 3, 3, 3
table.count({ a = 1, b = 2 })         -- 2, 0, 0
table.count({ [1] = "x", foo = "y" }) -- 2, 1, 1
table.count({ [100] = "x" })          -- 1, 0, 100
table.count({ [-3] = "x" })           -- 1, 0, 0

Pure iteration over the table (lua_next), so no dependency on Lua's internal storage layout. Errors on non-table input.

table.maxn(t)

Returns the largest positive numeric key of t, or 0 when it has none (Lua 5.1, luaB_maxn). Unlike table.getn it scans every key, so it sees past nil holes in sparse arrays:

table.maxn({ 10, 20, 30 })          -- 3
table.maxn({ [1] = "x", [9] = "y" }) -- 9  (getn would say 1)
table.maxn({ a = 1 })               -- 0

Errors on non-table input.

Stale table lengths (5.1 healing)

Lua 5.0 stores a table's table.insert / table.getn length out of band. Clearing a table's keys (for k in pairs(t) do t[k] = nil end) does not reset it — 5.0 code must also call table.setn(t, 0). Lua 5.1 removed setn entirely; lengths are computed from the table itself.

ClassicAPI gives you the 5.1 behavior — code written for Lua 5.1 (or that detects it) can skip setn and still get correct lengths.

When the stored length points past the last value, and the table has no explicit n field, table.getn — and everything built on it: table.insert, table.remove, table.concat, table.sort, table.foreachi, unpack — returns the true border, the same answer Lua 5.1 gives. This includes a table that is stale by exactly one slot (a one-element table that was cleared, or a recycled table whose previous use was one slot longer). Four things deliberately keep their old behavior:

  • A table with an explicit numeric n field (the vararg arg contract) keeps its stored count, so trailing nils survive (unpack({10, nil, 30, n = 3}) still returns all three slots).
  • A trailing nil appended by table.insert(t, nil) is a valid empty slot, not a stale length. ClassicAPI remembers the append itself, so the stored length stays as it is and the next table.insert adds after the nil rather than writing over it. This is the Lua 5.0 behavior that Ace2-era argument builders depend on. Only the two-arg append form is remembered; table.insert(t, pos, nil) is not.
  • A table with weak values (__mode = "v" or "kv") keeps its stored length. The garbage collector clears those slots when nothing else holds the value, so a nil slot does not show that the length is stale. Table-recycling pools depend on this.
  • table.setn still works; the heal only changes the answer when the stored length points past the last value and the nil was not put there by table.insert.

Mixin(object, ...) / CreateFromMixins(...)

The FrameXML table-mixin primitives, provided as engine C functions.

  • Mixin(object, ...) — shallow-copies every key/value from each table argument onto object (later arguments win on key collisions) and returns object. Non-table arguments are skipped.
  • CreateFromMixins(...) — Mixin({}, ...): returns a new table seeded from the given mixins.
local Greeter = {}
function Greeter:Hello() return "hi, "..self.name end
local obj = CreateFromMixins(Greeter)
obj.name = "Bob"
obj:Hello()   -- "hi, Bob"

ClassicAPI provides these as C functions (rather than leaving them in the !!!ClassicAPI addon) so they exist whenever the DLL is injected — even if the addon is disabled. The composite CreateAndInitFromMixin(mixin, ...) (which calls a :Init method) remains a Lua helper in the addon. The taint-based SecureMixin family is intentionally omitted — there is no execution taint system, so there is nothing for it to guard.

string.match / string.gmatch

The Lua 5.1 pattern helpers, backported to 1.12's Lua 5.0 (which ships only find / gfind / gsub).

  • string.match(s, pattern [, init]) — returns the captures of the first match, the whole match when the pattern has no captures, or nil when it doesn't match. Same pattern engine as string.find — it's find returning the captures instead of the start, end indices, so it reuses the engine's own matcher rather than reimplementing it.
  • string.gmatch(s, pattern) — the match iterator, for for x in string.gmatch(s, pat) do ... end. This is exactly Lua 5.0's string.gfind (renamed in 5.1); it's registered as a direct alias.
local y, m, d = string.match("2024-01-15", "(%d+)-(%d+)-(%d+)")  -- "2024","01","15"
string.match("no digits", "%d+")                                  -- nil

for word in string.gmatch("a,bb,ccc", "[^,]+") do print(word) end -- a / bb / ccc

Both call forms work: string.match(s, p) and s:match(p). See String methods.

string.gsub table replacement

Lua 5.1 allows a table as gsub's replacement: every match looks up the first capture (the whole match when the pattern declares no captures) as a key and substitutes the value. A nil or false value keeps the original match. The stock 5.0 gsub accepts only a string or function replacement and errors on a table (string or function expected).

("$name the $class"):gsub("%$(%w+)", { name = "Bob", class = "Druid" })
-- "Bob the Druid", 2

("$name and $unknown"):gsub("%$(%w+)", { name = "Bob" })
-- "Bob and $unknown", 2   (a missing key keeps the match, as in 5.1)

Both string.gsub and the bare gsub global have the upgrade; string and function replacements behave exactly as before. One residual: when the pattern itself contains a %1..%9 back-reference, a missing table key removes the match instead of keeping it.

strsplit(sep, str [, pieces])

The WoW global; splits str on any character in sep and returns the pieces as multiple values.

  • sep — a set of delimiter characters (not a pattern); each character is a delimiter. "," splits on commas; " -" splits on spaces and dashes.
  • pieces (optional) — caps the number of results. After pieces - 1 splits the rest of the string (delimiters and all) is returned as the final piece. 0 or omitted = unlimited. 1 returns the whole string unsplit.

Consecutive/trailing delimiters produce empty pieces.

strsplit(",", "a,b,c")        -- "a", "b", "c"
strsplit(",", "a,b,c,d", 2)   -- "a", "b,c,d"     (capped at 2)
strsplit(" -", "a b-c")       -- "a", "b", "c"     (space OR dash)
strsplit(",", "a,,b")         -- "a", "", "b"      (empty middle piece)
local zone, x, y = strsplit(":", "Durotar:52:38")

Errors strsplit(): Stack overflow if a string splits into more pieces than the Lua stack can grow to hold (lua_checkstack guards every push).

strjoin(delimiter, ...)

The inverse of strsplit: concatenate the vararg pieces with delimiter between each.

strjoin(",", "a", "b", "c")   -- "a,b,c"
strjoin("", "a", "b", "c")    -- "abc"
strjoin(",")                  -- ""   (no pieces)

A nil / non-string-coercible piece is treated as an empty string (so the delimiters still line up) rather than erroring.

strtrim(str [, chars])

Remove any of the characters in chars from both ends of str. chars is a literal set of characters (not a Lua pattern); it defaults to whitespace (space, tab, \n, \v, \f, \r).

strtrim("  hello  ")        -- "hello"
strtrim("xxhelloxx", "x")   -- "hello"
strtrim("[a]", "[]")        -- "a"

strreplace(str, find, replace)

Replace every occurrence of the literal substring find with replace, returning (result, count). This is a plain-text replace — no pattern magic-character escaping, unlike string.gsub. An empty find returns str unchanged with count 0.

strreplace("a.b.c", ".", "-")        -- "a-b-c", 2   (no pattern escaping)
strreplace("hello world", "o", "0")  -- "hell0 w0rld", 2

Not a stock WoW global — a ClassicAPI convenience. For pattern-based replacement use string.gsub.

string.reverse(s) / strrev(s)

Returns s with its bytes reversed. Lua 5.1 added string.reverse; 5.0's string library lacks it. Byte-wise (not UTF-8 aware), matching stock Lua. Registered both on the string table and as the strrev global.

string.reverse("abc")   -- "cba"
strrev("hello")         -- "olleh"

math.fmod(x, y)

The floating-point remainder of x / y (the quotient truncated toward zero), same as C's fmod. A pure 5.0→5.1 rename: Lua 5.0 ships the identical C function as math.mod, and 5.1 renamed the Lua-facing name to math.fmod. Registered as a direct alias — math.mod remains available too, so code written for either name works.

math.fmod(7, 3)     -- 1
math.fmod(-7, 3)    -- -1   (result takes the sign of the dividend)
math.fmod(5.5, 2)   -- 1.5

math.modf(x)

Returns (integral, fractional) — the integral part truncated toward zero and the fractional part carrying x's sign. Lua 5.1 function, genuinely missing from 1.12's math library (not just renamed like fmod).

math.modf(3.7)    -- 3, 0.7
math.modf(-3.7)   -- -3, -0.7
math.modf(5)      -- 5, 0

math.huge

Positive infinity, as the numeric constant math.huge (Lua 5.1). Handy as a "larger than anything" sentinel in min/max scans.

local best = math.huge
for _, v in ipairs(costs) do best = math.min(best, v) end

coroutine.*

Restores Lua 5.0's stripped coroutine.* library. The C-level coroutine machinery (lua_resume, lua_yield, lua_newthread, and the thread type at tag 8) is linked into the engine, but the script-facing library was never registered as a global table. ClassicAPI rewires the standard five entries on coroutine, plus Lua 5.1's coroutine.running, matching Lua 5.0 semantics with two WoW-specific quirks called out below.

coroutine.create(fn)

Creates a new coroutine with fn as its body and returns the resulting thread. fn must be a Lua function — passing a C function (any engine global like GetTime or UnitName) raises bad argument #1 to 'create' (Lua function expected).

The new coroutine starts suspended; nothing runs until coroutine.resume is called on it. The args passed to the first resume become the body's arguments.

coroutine.resume(co, ...)

Resumes a suspended coroutine, passing the trailing args to either the body (first resume) or back as the return values of the coroutine.yield that paused the coroutine.

Returns:

  • true, ...values on success, where values are whatever the coroutine yielded (mid-execution) or returned (on completion).
  • false, errMsg on error. Two error sources:
    • Coroutine isn't suspended — e.g. resuming a dead one; errMsg is a fixed string like "cannot resume dead coroutine".
    • The body hit a Lua VM error; errMsg is the formatted error string with file:line prefix.

Caveat — error() doesn't propagate. WoW replaces Lua's error() global with a soft handler that calls geterrorhandler()(msg) and returns normally instead of longjmp'ing. So error("msg") inside a coroutine body looks like a clean return to resume — the message gets printed to chat as a side effect and resume returns true with no values. Real VM errors (calling nil, indexing nil, etc.) still propagate through the standard false, msg path.

coroutine.yield(...)

Suspends the current coroutine. Whatever's passed to yield becomes the return values of the matching coroutine.resume call. When the coroutine is next resumed, the args passed to resume become the return values of yield.

Raises an error if called outside a coroutine (attempt to yield across metamethod/C-call boundary) or from a C function (cannot yield a C function).

coroutine.status(co)

Returns the coroutine's state as a string:

Value Meaning
"running" co is the currently-running coroutine (i.e. the same thread that called status).
"suspended" co is paused — either initial (never resumed) or mid-yield.
"dead" co's body has returned or errored.

The standard Lua "normal" state — a coroutine that resumed a deeper coroutine and is now an ancestor on the call chain — is collapsed into "suspended". Detecting it cleanly requires walking the global thread list, and the case is near-useless for addon-level logic.

coroutine.wrap(fn)

Equivalent to coroutine.create(fn) followed by returning a closure that calls coroutine.resume(co, ...) on each invocation — but propagates errors as raised exceptions (via lua_error) instead of returning false, msg. Lets you use a coroutine as if it were an ordinary function:

local gen = coroutine.wrap(function()
    for i = 1, 5 do coroutine.yield(i) end
end)
print(gen())  -- 1
print(gen())  -- 2
print(gen())  -- 3

Calling the wrapped function after the body has completed raises cannot resume dead coroutine. The canonical "iterator that terminates cleanly through for ... in" idiom is to yield an explicit nil at the end so the for-loop's nil sentinel fires:

local function range(from, to)
    return coroutine.wrap(function()
        for i = from, to do coroutine.yield(i) end
        coroutine.yield(nil)
    end)
end

for n in range(1, 5) do print(n) end

coroutine.running()

Returns the coroutine that is currently running, or nil when called from the main thread (Lua 5.1's contract — the 5.2 second return, ismain, is not provided).

coroutine.running()   -- nil (main thread)
coroutine.wrap(function()
    print(coroutine.status(coroutine.running()))   -- "running"
end)()

Async pattern (RunAsync + C_Timer.After)

Coroutines pair naturally with C_Timer.After for chunked "do some work, wait a frame, do more" loops. The canonical scheduler:

local function RunAsync(fn)
    local co = coroutine.create(fn)
    local function step()
        local ok, delay = coroutine.resume(co)
        if not ok or coroutine.status(co) == "dead" then return end
        C_Timer.After(delay or 0, step)
    end
    step()
end

RunAsync(function()
    for i = 1, 100 do
        DoExpensiveWork(i)
        coroutine.yield(0)     -- next frame
    end
    coroutine.yield(0.5)        -- pause half a second
    DoFinalWork()
end)

coroutine.yield(n) returns n to the scheduler, which schedules the next step after that many seconds. yield(0) is the "resume on the next frame" form (since C_Timer.After(0, ...) fires on the next OnUpdate tick). RunNextFrame(cb) in AddOns/!!!ClassicAPI/Util/FunctionUtil.lua wraps the same primitive for the non-coroutine case.

Macros

Macro features: the /cast and /use commands with [conditions], the #showtooltip and #show directives, and extensions to how the engine reads cast lines. Macro authors get them once ClassicAPI.dll is loaded.

/cast and /use

Both commands accept [conditions] and a @unit target. The first clause that matches gives the value. See SecureCmdOptionParse for the syntax and the list of conditions.

/cast Frostbolt
/cast [mod:shift] Frostbolt; Fireball
/cast [@player] Renew
/cast [@mouseover,help] Flash Heal; Flash Heal
/use Healthstone
/use 13
/use 0 1

The value is read in this order:

  • bag slot (for example 0 1) uses the item in that bag slot.
  • A number from 1 to 19 uses the item equipped in that inventory slot.
  • item:N, or a pasted item link, uses that item by ID.
  • The name of an item that you carry uses that item. An item name wins over a spell name.
  • Every other value is cast as a spell. A number is a spellID (/cast 5019), see Numeric spellIDs.

A ! in front of a spell name starts the spell but never turns it off. Use it for the abilities that toggle: auto-repeat shots (Shoot, Auto Shot) and the self-buffs (stance, aspect, seal, form, tracking). If the ability is already on, the line does nothing, so you can press the button again without stopping it.

/cast !Shoot
/cast !Auto Shot
/cast [@focus] !Auto Shot
/cast !Battle Stance

With a @unit target other than target, a spell is cast on that unit through C_Spell.CastAtUnit, and the item is used on that unit. [@player] uses the item on yourself. This works for an item named by bag slot or by inventory slot as well as by name.

/use always uses the item. Clicking a bag slot sells the item while a merchant window is open, and repairs it under the repair cursor, but the command does neither.

[@cursor] places a ground-target spell or item at the position under your mouse, through C_Spell.CastAtCursor and C_Item.UseAtCursor. A spell or item with no ground effect is cast or used normally.

[@player] drops a ground-target spell or item at your own feet. A spell or item with no ground effect is cast or used on you.

Your own feet and the cursor are the only two positions a ground-target spell or item can be aimed at this way. [@target] and the other units cast the spell on that unit when it takes a unit, and otherwise bring up the reticle for you to click, which is aim you already had.

/cast [@cursor] Blizzard
/use [@cursor] item:4390
/use [@player] item:4390

/use is /cast under a second name. Each client language has its own command names next to the English ones (for example /benutzen on a German client).

If SuperCleveRoidMacros is loaded, it handles /cast lines that contain conditions and all /use lines. Plain /cast Name lines still go through ClassicAPI.

/castsequence

Casts one action from a list, and moves to the next one each time a cast succeeds. The list is separated by commas and takes the same [conditions] and @unit syntax as /cast.

/castsequence Insect Swarm, Moonfire, Wrath
/castsequence [@focus] Insect Swarm, Moonfire, Wrath
/castsequence reset=combat Immolate, Corruption, Shadow Bolt

Each step is read the same way a /cast value is, so a step can name a spell, an item you carry, a bag slot, or an inventory slot. A step that names an item you can equip, and are not wearing, equips it instead of using it.

The sequence moves on only when a cast succeeds. A cast that fails or is interrupted leaves the sequence where it is, so the next press tries the same step again. A step that names nothing castable is stepped past.

reset= restarts the sequence. It takes one or more of these, joined by /:

Value Restarts when
target Your target changes.
combat You leave combat.
shift, ctrl, alt You press the button with that key held.
A number That many seconds pass without a press.
/castsequence reset=target/3 Rend, Thunder Clap

The sequence is keyed by its text. Two macros with the same list share one position and advance together, and the same list in one macro on two action bars is one sequence.

With #showtooltip, the action button follows the step the sequence is on.

/castrandom and /userandom

Casts one action picked at random from a comma-separated list. Both names are the same command, the way /use is /cast under a second name, so either one casts a spell or uses an item.

/castrandom Moonfire, Starfire, Wrath
/userandom [@player] Healing Potion, Rejuvenation Potion

The pick is held until a cast succeeds. A cast that fails or is interrupted keeps the same pick, so the next press tries that action again rather than picking another one.

A list names no single action, so these commands give the action button nothing to show. The button has no tooltip, no cooldown and no usable state from the list. Name a value on the #showtooltip line to give it one:

#showtooltip Moonfire
/castrandom Moonfire, Starfire, Wrath

A /castrandom line above a /cast line also takes the icon from it. Put the /cast line first, or name the value on the #showtooltip line.

More commands with [conditions]

These commands take the same [conditions] and @unit syntax as /cast. Each client language has its own command names next to the English ones.

Targeting. @unit sets what is acted on. A value that is not a unit token is matched against character names, nearest first, by the start of the name.

Command Does
/target Targets a unit or a name.
/targetexact Targets only a full name match.
/cleartarget Drops the target.
/targetlasttarget Targets your previous target.
/targetlastenemy Targets your previous enemy.
/targetenemy Steps through nearby enemies.
/targetfriend Steps through nearby friendly units.
/targetenemyplayer Steps through nearby hostile players.
/targetfriendplayer Steps through nearby friendly players.
/targetparty Steps through your party.
/targetraid Steps through your raid.
/assist Targets what another unit has targeted.
/follow Follows a unit.

The stepping commands treat their value as a reverse flag, so /targetenemy [mod:shift] 1 steps backwards while shift is held.

/target [@mouseover,harm] [] Bob
/targetexact Bobby
/assist [@focus]
/cleartarget [dead]

Casting and player state.

Command Does
/stopcasting Stops the current cast.
/stopmacro Stops the rest of the macro.
/cancelaura Removes one of your buffs by name.
/cancelform Leaves your current shapeshift form.
/dismount Dismounts you.
/cancelaura Power Word: Shield
/cancelform [stance:1]
/stopcasting [mod:alt]

/stopmacro stops the macro that is running it. The lines after it do not run. A line that runs a second macro is not affected: a /stopmacro in that second macro stops the second one, and the first one goes on.

/cast Moonfire
/stopmacro [mod:shift]
/cast Wrath

Equipment. /equip takes an item name, and /equipslot takes an inventory slot number from 1 to 19 followed by an item name.

/equip Thunderfury
/equipslot 16 Thunderfury

Action bars. /changeactionbar takes a page from 1 to 6. /swapactionbar takes two pages and moves to whichever one you are not on.

/changeactionbar 2
/swapactionbar 1 2

Pet. /petattack, /petfollow, /petstay, /petpassive, /petdefensive and /petaggressive take conditions only. /petautocaston, /petautocastoff and /petautocasttoggle take a pet spell name.

/petattack [harm]
/petautocaston Growl
/petfollow [@player,noharm]

/petattack always sends the pet at your current target. Conditions still decide whether it runs, so /petattack [@mouseover] cannot aim at your mouseover.

#showtooltip and #show

Put #showtooltip on the first line of a macro. The action button then shows the spell or item that the macro is about: its tooltip, cooldown, usable state, count, range, and auto-repeat highlight. If the macro icon is the question mark, the button, the macro window, and the cursor while you drag the macro also show the icon of that spell or item. The icon selector still shows the question mark, because that is the icon the macro keeps. #show does the same, but keeps the macro name as the tooltip.

#showtooltip
/cast Frostbolt

#showtooltip [mod:shift] Frostbolt; Fireball
/cast [mod:shift] Frostbolt; Fireball

#showtooltip Healthstone
/use Healthstone

#showtooltip spell:1539
/cast 1539

#showtooltip
/cast !Shoot

#show 13
/use 13
  • #showtooltip <value> shows that value. The value takes the same forms as /cast: a spell name, a spellID, an item name, item:N, an item link, an inventory slot, or bag slot. It also takes spell:N, which names a spell by ID and nothing else. It accepts the same [conditions]. An item that you carry wins over a spell of the same name, as in /cast. A spell name can carry the ! prefix, and the button shows that spell.
  • A spell you name by ID shows even when you have not learned it. The icon and the tooltip come from the spell itself, so #showtooltip spell:1539 and #showtooltip 1539 both show spell 1539. The button is still greyed out, because you cannot cast it. This applies to a value the directive names itself. The bare form below reads your /cast lines, and a spell there resolves only as far as the cast does.
  • An item named by ID shows even when you carry none of it, since the icon and tooltip come from the item itself. An item named by name has to be on you or equipped for the button to find it.
  • #showtooltip with no value reads the /cast and /use lines of the macro, in order, up to the first line without conditions. It shows the first line whose clause matches. The line without conditions is the default.
  • Conditions are evaluated again when a modifier key, your target, your mouseover unit, or your combat state changes, and about five times per second otherwise. So the button follows [mod:...], [combat], [@target,harm] and the other conditions.
  • If no clause matches, the button shows the macro's own icon and name. A group that only names a unit ([@mouseover]) matches only while that unit exists, so [@mouseover] Rejuvenation alone shows ? with nothing under the cursor, and [@mouseover][] Rejuvenation shows the spell and casts on your target instead. If the value names a spell by name that you do not know, nothing shows. Name it by ID to show it anyway.

Lines that start with # are comments. They never run, and they never reach chat.

GetMacroSpell and GetMacroItem return what the directive resolved to.

If SuperCleveRoidMacros is loaded, it controls macro display, and ClassicAPI does not evaluate #showtooltip. A build that hands its own results over through C_Macro.SetMacroDisplay keeps #showtooltip working for the macros it does not claim.

A macro that names a condition SecureCmdOptionParse does not know belongs to another macro addon. Its conditions decide what the macro does, and ClassicAPI cannot evaluate them, so the button stays as the engine left it. The addon that owns those conditions can still drive the button through C_Macro.SetMacroDisplay. Nothing is printed about the condition: the macro works, and the player has nothing to correct. An edit to the macro tests the conditions again.

Numeric spellIDs in /cast and CastSpellByName

Pure-numeric input is now accepted anywhere a spell name would be:

/cast 5019                     -- in a macro line; casts Shoot if known
CastSpellByName("5019")        -- same effect from Lua / /run / chat

The spellID is resolved through Spell.dbc to the locale-resolved name, then handed to the engine's existing name resolver — so all the normal downstream behavior applies: the spellbook lookup gates on "player knows this spell," action-bar UI sees the cast as if it had been named, macros containing /cast 5019 get tagged with the right spellID in the engine's spell-state cache (so IsCurrentAction(slot) and IsAutoRepeatAction(slot) work for the macro slot).

Input Outcome
/cast 5019 Casts Shoot if you know it (any rank, highest known)
/cast 1234 Falls through as "unknown spell," same as /cast Foo
/cast Shoot Unchanged — names still work
/cast 5019(Rank 1) Falls through — rank suffix with a numeric stem isn't supported (use the name form if you need a specific rank)

Implemented as a single hook on the engine's name → spellbook-slot resolver at 0x004B3950 — covers /cast, /castsequence aliases, CastSpellByName, the macro parser's tagging path, and anywhere else the engine resolves a spell name. Numeric input with garbage trailing characters falls through unchanged.

CastSpellNoToggle as a macro cast line

Putting CastSpellNoToggle("Shoot") in a macro body now tags the macro with Shoot's spellID the same way /cast Shoot or CastSpellByName("Shoot") would. Without this, the macro casts correctly but the macro parser doesn't know to associate the slot with any spell — so action-bar UIs that call IsCurrentAction(slot) / IsAutoRepeatAction(slot) (pfUI's actionbar, ShaguTweaks, etc.) never light up the slot.

Recognized forms in a macro body:

CastSpellNoToggle("Shoot")        -- by name (string)
CastSpellNoToggle("Cat Form")     -- shapeshift / aspect / stance all tag the same way
CastSpellNoToggle(5019)           -- not yet — macro parser only recognizes the string form

The numeric form works for the cast itself (via the function's runtime resolution), but the macro parser only scans for the string form when tagging. To get the slot to highlight when using the spellID, write CastSpellNoToggle("Shoot") instead.

The engine's first-match-wins rule still applies — if your macro is:

/cast Wand
CastSpellNoToggle("Shoot")

…the macro is tagged with Wand (the engine recognized /cast Wand on line 1; our additional pattern is only consulted when the engine didn't find any of its own patterns).

Macro tagging happens at macro edit/save time. Existing macros need to be opened in the Macro UI and re-saved once after dropping in the new DLL to pick up the new parser behavior.

StopMacro()

Stops the macro body that is running right now. The lines after the call do not run. This is what /stopmacro calls once its conditions pass.

StopMacro()

The call applies to the macro that is running it. If a macro line runs a second macro, a StopMacro in that second macro stops the second one only, and the first one goes on. A call made while no macro is running does nothing, and leaves nothing behind for the next macro you press.

GetMacroSpell(macroSlot)

Returns (name, rank, spellID) for the spell that a macro shows. When the macro has a #showtooltip or #show directive, this is the spell that the directive resolved to. Otherwise it is the spell that the first /cast / /castsequence / CastSpellByName(...) line resolves to. Returns nothing when the macro slot is empty, the macro has no cast, the shown value is an item, or the name does not resolve to a spell that the player knows.

-- macro slot 1's body: "/cast Fireball(Rank 5)"
local name, rank, id = GetMacroSpell(1)
-- name = "Fireball", rank = "Rank 5", id = 25306

-- macro slot 2's body: "/cast Fireball"  (no explicit rank)
local name, rank, id = GetMacroSpell(2)
-- name = "Fireball", rank = "Rank 5", id = 25306  (the highest known rank)

-- macro slot 3's body: only /script lines or no cast
GetMacroSpell(3)
-- (no returns)

CastSpellNoToggle("<name>") macros are also recognized — the parser hook from the CastSpellNoToggle section tags them with the same spellID a /cast line would, so GetMacroSpell sees them too.

Re-save existing macros once after dropping in the DLL. The spellID cache is populated at edit time. Macros that existed before ClassicAPI loaded (or that use CastSpellNoToggle and were last edited under stock 1.12) will have a stale 0 cache — opening them in the Macro UI and clicking Okay re-runs the parser and the new behavior takes effect.

GetMacroItem(macroSlot)

Returns (name, link) for the item that a macro's #showtooltip or #show directive resolved to. Returns nothing when the macro has no directive, or when the directive resolved to a spell. If you carry the item, the name and the link are those of your item, with its suffix and enchant.

-- macro slot 4's body: "#showtooltip Healthstone" then "/use Healthstone"
local name, link = GetMacroItem(4)
-- name = "Major Healthstone", link = "|cffffffff|Hitem:9421:0:0:0|h[Major Healthstone]|h|r"

GetMacroIcons / GetMacroItemIcons / GetLooseMacroIcons / GetLooseMacroItemIcons

A 4-function append-to-table icon enumeration surface. Each takes a Lua table as its only argument and appends icon basenames; returns nothing (mutation is the contract).

local spellList, itemList = {}, {}

-- Modern's canonical call order (mirrors IconDataProvider.lua):
GetLooseMacroIcons(spellList)
GetLooseMacroItemIcons(itemList)
GetMacroIcons(spellList)
GetMacroItemIcons(itemList)

-- spellList now has `Ability_*` / `Spell_*` basenames;
-- itemList now has `INV_*` basenames.

The split is loose (icons you dropped into Interface\Icons\ on disk) vs mpq (icons that ship inside the game's MPQ archives), crossed with Spell (basenames that start with Ability_ or Spell_) vs Item (basenames that start with INV_).

Quirk worth noting: GetMacroIconInfo(i) lists only the Ability_* and Spell_* icons. The archives hold thousands of INV_* icons that it never shows. These four functions do return them, so GetMacroItemIcons gives you the item icons that GetMacroIconInfo cannot reach.

An INV_* file that you put in Interface\Icons\ yourself is different: GetMacroIconInfo does list it, after a restart of the client.

Each appended entry is the uppercase basename stripped of the Interface\Icons\ prefix and any .blp/.tga extension (e.g. "INV_SWORD_25", "ABILITY_KICK"). Callers concat the prefix themselves before texture:SetTexture(...). Output is alphabetically sorted within each function (sorted lazily on first read).

Capture-time dedup ensures each unique basename only appears once per source/prefix combination even though most files flow through multiple scan callbacks. There is no cross-dedup between loose and mpq, or between spell and item (a file that exists both as a loose drop-in and inside an MPQ will appear in both the loose and mpq lists).

The legacy globals GetNumMacroIcons / GetMacroIconInfo remain available unchanged.

C_Macro.CreateMacro / C_Macro.EditMacro

These functions create or edit a macro. You give the icon as a texture string — a full Interface\Icons\<name> path or a bare <name> basename — instead of a GetMacroIconInfo index.

C_Macro.CreateMacro(name, iconTexture, body, isCharacterMacro) -> index
C_Macro.EditMacro(index, name, iconTexture, body) -> index

Why this exists: the stock CreateMacro / EditMacro globals take an icon index into the GetMacroIconInfo list. That list does not include INV_* item icons. As a result, the stock API cannot set an item icon, even though the engine stores and shows it correctly. These two functions write the icon basename directly. Any texture under Interface\Icons\, including an item icon, can be saved.

CreateMacro(name, iconTexture, body, isCharacterMacro):

  • name — the macro name. This argument is necessary.
  • iconTexture — a string, for example "INV_Sword_25" or "Interface\\Icons\\INV_Sword_25". The function removes the Interface\Icons\ prefix, and adds it again when a caller reads the icon. A nil value sets no icon.
  • body — the macro text. A nil value sets an empty body.
  • isCharacterMacro — a true value puts the macro in the per-character tab. A false or nil value uses the general tab.
  • The function returns the 1-based index of the new macro. It returns nil when the list is full (18 macros per tab).

EditMacro(index, name, iconTexture, body):

  • index — a 1-based slot number or a macro name.
  • name / iconTexture / body — a nil argument keeps that field unchanged.
  • The function returns the 1-based index of the macro. It returns nil when index does not match a macro that exists.
-- item icon that legacy CreateMacro cannot set:
local i = C_Macro.CreateMacro("Bag", "INV_Misc_Bag_08", "/say hi", false)
-- change only the icon; name and body stay:
C_Macro.EditMacro(i, nil, "INV_Potion_51", nil)
-- edit by name:
C_Macro.EditMacro("Bag", nil, nil, "/wave")

The iconTexture argument must be a string. CreateMacro reads a number as no icon. EditMacro reads a number as no change. For the numeric-index path, use the legacy globals.

The legacy CreateMacro / EditMacro globals do not change. They stay index-only. String-icon callers use the C_Macro namespace instead. Edits persist across sessions, the same as edits in the Macro UI.

C_Macro.GetMacroIcon(macroSlot)

Returns the icon the macro currently shows, as Interface\Icons\<name>. Returns nothing when the slot is empty.

C_Macro.GetMacroIcon(1)   -- "Interface\\Icons\\Spell_Nature_Rejuvenation"

This is the icon on the action button: the spell's or item's when a #showtooltip resolved and the macro's own icon is the question mark, and the macro's own icon otherwise.

GetMacroInfo returns the icon the macro stores, which is the icon its icon selector edits. Use this function when you want the icon a macro shows, and GetMacroInfo when you want the one the player picked.

C_Macro.SetMacroDisplay(macroSlot, value)

Tells the client what a macro is about, so the action button shows it. For an addon that parses macros itself and wants the button to follow, instead of replacing GetActionTexture, GetActionCooldown, IsUsableAction and the rest in Lua.

C_Macro.SetMacroDisplay(1, "Frostbolt")   -- show this
C_Macro.SetMacroDisplay(1, false)         -- mine, nothing matched, show ?
C_Macro.SetMacroDisplay(1, nil)           -- released, `#showtooltip` resumes

macroSlot is the macro index, the same one GetMacroInfo and GetMacroSpell take. value takes every form a #showtooltip value takes: a spell name or ID, spell:N, an item name, item:N, an item link, an inventory slot, or bag slot. An item you carry wins over a spell of the same name, as in /cast. A spell you name by ID shows even when the player has not learned it, the same as a directive that names its own value.

Returns true when the value named a spell or an item.

What the button picks up. The spell is written where the client keeps each macro's spell, which is the field its own buttons read. So cooldown, range, usable, out-of-mana greying, the current-cast highlight and auto-repeat all follow, with no function replaced. The count, cooldown and consumable state for an item follow too. The tooltip follows only when the first line of the macro is #showtooltip. A macro with #show, or with no directive, keeps its own name as the tooltip. The icon appears on the action button, on the cursor while you drag the macro, and in the macro window. C_Macro.GetMacroIcon reads it back.

The icon replaces the macro's own only when that is the question mark, which is the same rule #showtooltip follows. A macro with a chosen icon keeps it.

Call it whenever your answer changes. Nothing is re-evaluated for you. The value stands until you publish another, and it survives the client re-reading the macro, so an edit elsewhere cannot quietly replace it.

Publishing takes the macro over: #showtooltip is not parsed for it while you hold it, and nil gives it back.

For macro addons that currently take over the action bar. ClassicAPI stands down from macro display entirely when SuperCleveRoidMacros is loaded, since it owns the bar with its own conditionals. A build that drives this instead sets CleveRoids.ClassicAPIMacroDisplay = true, which lifts that. Ownership is then per macro: published ones show what you published, and ones you do not claim fall back to #showtooltip. Forks that do not set the flag keep the old all-or-nothing behavior, so an older one cannot end up fighting for the same buttons.

Mail

Mail-attachment link getters. Both accept the optional attachmentIndex arg, even though only one attachment per message is supported here — pass-through compatibility. Any attachmentIndex other than 1 is treated as no-op.

Both functions return (link, itemID). itemID is cheap to surface at the same lookup site and saves callers a string.match on the link.

GetSendMailItemLink([attachmentIndex])

Returns the fully-decorated per-instance hyperlink for the item currently attached to the outgoing-mail slot, plus that item's itemID. Same enchant / random-suffix / unique-ID decoration as GetContainerItemLink would return for the same item back in the player's bag — the attached item still exists as a live CGItem until the mail is sent, so the link builder has full instance state to draw from.

Returns nothing when:

  • No item is attached.
  • attachmentIndex is anything other than nil or 1.
  • The attachment's CGItem can't be resolved (shouldn't normally happen — engine writes the GUID on attach and clears it on detach).
local link, itemID = GetSendMailItemLink()
if link then
    ChatFrame1:AddMessage("Attached: " .. link .. " (id " .. itemID .. ")")
end

GetInboxItemLink(messageIndex[, attachmentIndex])

Returns the basic itemID-only hyperlink (|cff…|Hitem:N:0:0:0|h[Name]|h|r) for the item attached to inbox message messageIndex (1-based), plus the itemID.

Why basic-link instead of per-instance: inbox entries do store per-instance modifiers inline (enchant at +0x12C, suffix factor at +0x134, random property at +0x138 — the same fields GameTooltip:SetInboxItem copies onto the tooltip to render a fully- decorated preview). We intentionally ignore them in the link: the itemID-only link builder (FUN_0061E290(itemID)) ignores the per-instance fields. Per-instance data only fully manifests on the client side once the player takes the item out of the mail and the engine spawns a real CGItem; the inbox-entry fields are display-only hints used by the tooltip builder.

Errors on missing or non-numeric messageIndex. Returns nothing when:

  • messageIndex is out of range (< 1 or > GetInboxNumItems()).
  • The message slot is empty / has no attached item.
  • attachmentIndex is anything other than nil or 1.
for i = 1, GetInboxNumItems() do
    local link, itemID = GetInboxItemLink(i)
    if link then
        ChatFrame1:AddMessage(i .. ": " .. link .. " (id " .. itemID .. ")")
    end
end

Map

C_Map.GetAreaInfo(areaID)

Returns the localized AreaTable.dbc name for an area id — zones and subzones alike — or nil for non-numeric / non-positive input or an id with no row. AreaTable.dbc is a client DBC (always loaded, synchronous, localized), so the name is available for any id with no async/cache step — unlike creature/item names.

C_Map.GetAreaInfo(12)   -- "Elwynn Forest"
C_Map.GetAreaInfo(9)    -- "Northshire Valley"

The name is in the client's active locale — the same source as GetRealZoneText and the paperdoll zone name — so it matches what the game displays everywhere.

C_Map.GetAreas()

ClassicAPI extension. Returns { [areaID] = name, … } — every AreaTable.dbc row with a non-empty localized name, as an id→name map. Same "surface the hidden DBC" pattern as C_Map.GetAreaTriggers.

The enumeration — not the per-id GetAreaInfo — is what name databases need: name→id resolution, reverse search, and iterating the full id set. Every key round-trips with GetAreaInfo; empty-name rows are omitted. The map includes every named area (technical/internal ones too), so consumers wanting only "real" zones should filter.

local areas = C_Map.GetAreas()
print(areas[12])                 -- "Elwynn Forest"

-- build a name -> id index
local byName = {}
for id, name in pairs(areas) do byName[name] = id end

Lets a name-DB addon drop its shipped zone-name tables (pfQuest's db/<locale>/zones.lua): repoint pfDB["zones"]["loc"] = C_Map.GetAreas() (merging so the fork's overrides still apply). Live client data in the active locale — no locale/rename drift vs a scraped table.

C_Map.GetBestMapForUnit(unitToken)

Returns the AreaTable.dbc area ID the given unit is currently in, or nil if the unit isn't trackable. There is no UiMap.db2 concept here, so the closest equivalent to a "best map" is the zone-level AreaTable ID — exactly what the engine itself tracks for the local player (GetRealZoneText reads it) and what gets broadcast for party/raid members via SMSG_PARTY_MEMBER_STATS_FULL.

Coverage:

  • "player" — always works.
  • "party1".."party4" — works even for members in other zones / on other continents (slot-indexed GUID table stays populated regardless of local CGUnit availability).
  • "raid1".."raid40" — same; covers the full raid roster.
  • "target", "mouseover", "focus" and any other token that resolves to the player or a group member — works.
  • NPCs, other players not in your group — nil.
/dump C_Map.GetBestMapForUnit("player")
-- 1519                                            (Stormwind City)
/dump C_Map.GetBestMapForUnit("party3")
-- 876                                             (party member on GM Island)
/dump C_Map.GetBestMapForUnit("target")
-- nil                                             (target is an NPC)

The returned ID is an AreaTable.dbc area ID, not a UI map ID. Stormwind City here is 1519. Addons that hardcode UI map IDs need a translation table (or, simpler: compare against IDs this same function produces).

C_Map.GetMapAreaIDs()

ClassicAPI extension. Returns { [mapName] = areaID, … } — each WorldMapArea.dbc map's name (the dir the engine uses for its map textures) mapped to its AreaTable areaID.

The stock client has no GetCurrentMapAreaID, and GetMapZones() enumerates only a continent's outdoor zones — so a browsed city or instance map (Orgrimmar, Undercity, Alterac Valley) can't be resolved to a zone id, which means addons can't match zone-keyed content (a flight master, a POI) to it. This maps those names to their zone ids:

-- resolve a browsed map to an areaID, cities included
local areaID = C_Map.GetMapAreaIDs()[GetMapInfo()]

The key is the WorldMapArea name, which matches GetMapInfo()'s returned dir for the vast majority of maps — but a few Blizzard map folders are spelled differently from the area name (the Orgrimmar map's folder is "Ogrimmar"), so GetMapAreaIDs()[GetMapInfo()] can miss for those. For those, resolve by zone name instead (GetMapZones → name → areaID).

Continent-spanning rows (areaID 0) and unnamed rows are omitted. Pairs with C_Map.GetAreas (areaID → name) and C_Map.GetMapWorldSize (areaID → yards).

C_Map.GetMapOverlays([areaID])

ClassicAPI extension. Returns every WorldMapOverlay.dbc entry for a zone — explored or not — with the tile grid fully resolved. The engine's own GetNumMapOverlays / GetMapOverlayInfo only expose overlays the character has discovered (the exploration gate is a server-fed list), which is why map-reveal addons traditionally ship hand-measured overlay tables; this replaces those with a live DBC read that works for every zone on any client.

With no argument, returns the overlays of the world map's currently viewed zone (the same continent/zone selection GetMapInfo resolves — so it drops straight into a WorldMapFrame_Update hook). With a numeric areaID (AreaTable.dbc id, the same identity C_Map.GetBestMapForUnit returns), returns that zone's overlays from anywhere.

Each overlay table:

field meaning
textureName bare DBC name ("GOLDSHIRE")
texturePath Interface\WorldMap\<dir>\GOLDSHIRE (engine format)
areaID primary AreaTable id the overlay reveals (first nonzero)
areaIDs array of all AreaTable ids it reveals (usually one; 52 of 707 overlays span 2–3 subzones)
textureWidth / textureHeight the DBC placement rect
offsetX / offsetY placement on the zone canvas
mapPointX / mapPointY DBC map-point fields
hitRectTop / hitRectLeft / hitRectBottom / hitRectRight hit rectangle in world-map canvas px (≈1002×668); the tight clickable bounds of the landmass
tileCols / tileRows / upscaled the resolved tile grid
tiles ready-to-draw tile array (below)
fileDataIDs ordered tile texture paths (redundant with tiles[].file)

The hitRect* fields are the source addons use to place a subzone center (the texture rect includes transparent padding, so its center is off; the hit rect is the tight landmass bounds). Normalize against the ≈1002×668 world-map canvas:

local cx = (ov.hitRectLeft + ov.hitRectRight) / 2 / 1002 * 100  -- center X %
local cy = (ov.hitRectTop  + ov.hitRectBottom) / 2 /  668 * 100  -- center Y %
local w  = (ov.hitRectRight  - ov.hitRectLeft) / 1002 * 100      -- width  %
local h  = (ov.hitRectBottom - ov.hitRectTop)  /  668 * 100      -- height %

Overlays with areaID > 0 cover the [subzone → parent-zone + center/size] mapping that map addons hand-scrape (pfQuest's pfDB["zones"]["data"]). Reading it live matches this client's maps — on a modified client (Turtle) those differ from the scraped numbers, which is the point.

A row may be texture-less — a clickable subzone region with no distinct art. It still appears, with textureName == "", zero textureWidth/ textureHeight, and an empty tiles table (so ipairs(ov.tiles) iterates zero times), carrying its areaID/areaIDs + hitRect*. Filter art consumers on ov.textureName ~= "". (Stock 1.12 and Octo happen to give every overlay a texture, but the schema permits texture-less rows.)

Each tiles entry: file (texture path including the tile number, SetTexture-ready), width / height (draw size in map pixels), texCoordX / texCoordY (right/bottom texcoords), offsetX / offsetY (absolute canvas position). Drawing an overlay is a dumb loop:

for _, ov in ipairs(C_Map.GetMapOverlays()) do
    for _, t in ipairs(ov.tiles) do
        local tex = pool:Acquire()
        tex:SetTexture(t.file)
        tex:SetWidth(t.width)  tex:SetHeight(t.height)
        tex:SetTexCoord(0, t.texCoordX, 0, t.texCoordY)
        tex:SetPoint("TOPLEFT", WorldMapDetailFrame, "TOPLEFT", t.offsetX, -t.offsetY)
        tex:Show()
    end
end

Why tiles exists (don't derive the grid yourself). The canonical layout is ceil(w/256) × ceil(h/256) tiles numbered row-major, and on stock 1.12 data that's always right — but Octo's shipped data breaks it in three distinct ways: DBC rects that round away a narrow sliver column (Icepoint's Kaneq'nuun has a real 8px third column; trusting ceil shears the whole overlay into misplaced strips), unrelated foreign tiles appended to an overlay's number sequence (drawing by file count scatters duplicate landmass), and same-rect re-exports at higher resolution (Stonetalon's Windshear Crag: 4 real tiles for a 1-tile rect, all of which must be kept and scaled). ClassicAPI disambiguates per overlay from the actual BLP header dimensions via the engine's own VFS (a sweep of all 707 overlays found 108 with such art/DBC mismatches), caches the result for the session, and hands back tiles that are simply correct. textureWidth/textureHeight remain the raw DBC rect — reliable for placement; it's only the tile count they imply that lies.

C_Map.GetMapWorldSize([areaID])

Returns a zone's world size in yards as width, height — the physical extent the map represents, read straight from the WorldMapArea.dbc placement rect. Addons use it to turn map-relative percents into yard distances (range rings, "N yards away" readouts, proximity checks).

Takes an areaID (AreaTable.dbc id — the same identity C_Map.GetBestMapForUnit returns and C_Map.GetMapOverlays accepts), returns that zone's size; with no argument, the world map's currently viewed zone (same resolution GetMapOverlays() uses with no arg). Returns nil, nil when the zone can't be resolved.

width is the east-west span (world Y), height the north-south span (world X) — the same denominators behind the C_Map.GetAreaTriggerInfo map-percent transform, so mapX/100 * width and mapY/100 * height give a point's offset in yards within the zone.

local w, h = C_Map.GetMapWorldSize(85)   -- Tirisfal: ~4518.7, 3012.5

C_Map.GetMapInfo(uiMapID)

Returns a map's details table, or nil for an id that names no map:

field meaning
mapID the id you passed
name localized map name
mapType Enum.UIMapType: 1 world, 2 continent, 3 zone, 4 dungeon
parentMapID the map one level up, or 0 at the top
flags always 0

What a uiMapID is. Maps form a three-level tree:

World
 +-- Continent (Kalimdor, Eastern Kingdoms)
      +-- Zone (Durotar, Elwynn Forest, ...)

A zone is named by its positive AreaTable.dbc area id — the same identity C_Map.GetBestMapForUnit returns. The world and continent levels have no area id, so they use negative ids: Kalimdor is -13, Eastern Kingdoms is -14, and the world map is -694. Instance maps (dungeons, raids, battlegrounds) are addressed the same negative way. The two ranges never overlap, so every function in this section accepts either kind.

Read the ids from GetMapChildrenInfo, GetFallbackWorldMapID, or GetBestMapForUnit rather than writing them in by hand.

/dump C_Map.GetMapInfo(14)
-- mapID=14, name="Durotar", mapType=3, parentMapID=-13
/dump C_Map.GetMapInfo(-13)
-- mapID=-13, name="Kalimdor", mapType=2, parentMapID=-694

The world map names the whole world but carries no coordinate rect, so the coordinate functions return nil for it.

C_Map.GetMapChildrenInfo(uiMapID)

Returns an array of C_Map.GetMapInfo tables for the maps one level below uiMapID — the continents of the world map, or the zones of a continent. A zone has no children, so it gives an empty table. Always returns a table.

local continents = C_Map.GetMapChildrenInfo(C_Map.GetFallbackWorldMapID())
-- 2 entries: Kalimdor (-13), Eastern Kingdoms (-14)

local zones = C_Map.GetMapChildrenInfo(-13)
-- 35 entries: Durotar (14), Mulgore (215), ...

The zone list covers every map a continent has a world map for, city maps included.

C_Map.GetMapInfoAtPosition(uiMapID, x, y)

Returns the C_Map.GetMapInfo table for the zone at a position on uiMapID, or nil when the point is on no zone. x and y are map-relative, each 0..1.

Pass a continent id to answer "which zone sits under this point on the continent map" — the hit test behind clicking a continent.

/dump C_Map.GetMapInfoAtPosition(-13, 0.589, 0.52)
-- Durotar

C_Map.GetFallbackWorldMapID()

Returns the uiMapID of the whole-world map — the safe default to show when no specific map is known. Returns 0 if the client has no world map.

local worldID = C_Map.GetFallbackWorldMapID()   -- -694

C_Map.MapHasArt(uiMapID)

Returns whether the map has a drawable background — that is, whether the client ships the map's tile art.

This is independent of whether a map has coordinates. The whole-world map has art but no coordinate rect, while an instance map can have a rect and no art.

C_Map.MapHasArt(14)     -- true   (Durotar)
C_Map.MapHasArt(-13)    -- true   (Kalimdor)
C_Map.MapHasArt(-600)   -- false  (an instance map with no art)

C_Map.GetMapArtLayers(uiMapID)

Returns the map's art layers, as an array of layer descriptions. Always returns a table; a map with no art gives an empty one.

This client draws a map's background as one fixed layer, so there is at most a single entry, and it reads the same for every map that has art:

Field Value
layerWidth / layerHeight 1002, 668 — the map canvas in pixels
tileWidth / tileHeight 256, 256
minScale / maxScale 1, 1
additionalZoomSteps 0

Dividing the canvas by the tile size gives the grid the background is cut into — four tiles wide by three tall, the twelve GetMapArtLayerTextures returns:

local layer = C_Map.GetMapArtLayers(14)[1]
local cols = math.ceil(layer.layerWidth / layer.tileWidth)    -- 4
local rows = math.ceil(layer.layerHeight / layer.tileHeight)  -- 3

The scale range is 1 to 1 with no extra zoom steps because a map does not zoom here — changing zoom switches between the continent and zone maps instead of scaling one.

C_Map.GetMapArtLayerTextures(uiMapID, layerIndex)

Returns the background tile textures for one of a map's art layers, in draw order — left to right, then top to bottom. Always returns a table.

A map here has a single art layer, so layerIndex 1 gives the background and any other index gives an empty table. A map with no art gives an empty table too.

local tiles = C_Map.GetMapArtLayerTextures(14, 1)
-- 12 entries, "Interface\WorldMap\Durotar\Durotar1" through "...Durotar12"

for i, path in ipairs(tiles) do
    _G["MyMapTile" .. i]:SetTexture(path)
end

The tiles form a grid four wide and three tall of 256-pixel squares — the layout the world map itself draws.

Entries are texture paths, ready to pass to SetTexture, in place of the numeric file ids: a texture is named by its path here. The same substitution appears in C_Map.GetMapOverlays for its fileDataIDs.

C_Map.GetPlayerMapPosition(uiMapID, unitToken)

Returns a unit's position inside a zone as a Vector2DMixin. Call :GetXY() for the two map-relative coordinates (each 0..1), or read .x / .y. Returns nil when the unit's world position is outside that zone — a different zone, or a map with no world rect.

uiMapID is a zone or continent id (see C_Map.GetMapInfo) — pass a continent to get the unit's position on the continent map. The result comes from the unit's live world coordinates, so it is current the moment you call it and does not depend on which map the world map frame shows.

local uiMapID = C_Map.GetBestMapForUnit("player")
local pos = C_Map.GetPlayerMapPosition(uiMapID, "player")
local x, y = pos:GetXY()   -- e.g. 0.595, 0.600

Works for the player and any unit whose object is loaded (in range or targeted). Other units return nil. An unknown unit token raises an error, the same as UnitHealth and the other unit functions. In a city or subzone, GetBestMapForUnit can return a subzone id that has no world rect — pass a zone-level id for those.

C_Map.GetWorldPosFromMapPos(uiMapID, mapPosition)

Converts a map-relative position on a zone to absolute world coordinates. Returns continentID, worldPosition:

  • continentID — the Map.dbc map the zone sits on (0 Eastern Kingdoms, 1 Kalimdor, or an instance map).
  • worldPosition — a Vector2DMixin of the world coordinates (.x north, .y west).

uiMapID is a zone or continent id (see C_Map.GetMapInfo). mapPosition is any table with .x / .y in 0..1 (a Vector2DMixin, or the result of C_Map.GetPlayerMapPosition). Returns nil when the zone has no world rect. This is the inverse of C_Map.GetMapPosFromWorldPos.

local uiMapID = C_Map.GetBestMapForUnit("player")
local pos = C_Map.GetPlayerMapPosition(uiMapID, "player")
local continentID, world = C_Map.GetWorldPosFromMapPos(uiMapID, pos)

C_Map.GetMapPosFromWorldPos(continentID, worldPosition[, overrideUiMapID])

Converts absolute world coordinates to a map-relative position. Returns uiMapID, mapPosition:

  • uiMapID — the AreaTable.dbc zone the point falls in.
  • mapPosition — a Vector2DMixin with .x / .y in 0..1.

continentID is a Map.dbc map id. worldPosition is a table with .x / .y world coordinates. Without overrideUiMapID, the result is the zone the point falls in. Pass overrideUiMapID (a zone or continent id) to force the result into that specific map. The call then returns nil when the point lies outside it. This is the inverse of C_Map.GetWorldPosFromMapPos.

local uiMapID, pos = C_Map.GetMapPosFromWorldPos(continentID, world)

C_Map.GetMapRectOnMap(uiMapID, topUiMapID)

Returns where a zone sits on its continent map, as four numbers: minX, maxX, minY, maxY — the zone's bounding rectangle in 0..1 continent coordinates. Returns nothing (nil) when the zone has no world rect.

uiMapID is the zone. topUiMapID is the map to measure against, normally the zone's continent (see C_Map.GetMapInfo). When topUiMapID names no usable map, the zone's own continent is used.

local minX, maxX, minY, maxY = C_Map.GetMapRectOnMap(14, 1)  -- Durotar on Kalimdor

A zone placed off the edge of the standard continent map can return values outside 0..1.

C_Map.SetUserWaypoint(uiMapPoint)

Places the user waypoint — the single player-set map pin — and returns whether it was set. Fires USER_WAYPOINT_UPDATED.

uiMapPoint is a table with these fields:

field meaning
uiMapID the map the pin sits on
position a vector2 with .x / .y in 0..1
z optional height

Build one with UiMapPoint.CreateFromCoordinates(uiMapID, x, y[, z]) or UiMapPoint.CreateFromVector2D(uiMapID, position[, z]).

C_Map.SetUserWaypoint(UiMapPoint.CreateFromCoordinates(14, 0.5, 0.6))

Returns false for a malformed point, or for a map that cannot hold a pin (see C_Map.CanSetUserWaypointOnMap). The pin lasts for the session and survives a UI reload.

C_Map.GetUserWaypoint() / C_Map.HasUserWaypoint() / C_Map.ClearUserWaypoint()

GetUserWaypoint returns the pin as a UiMapPoint table, or nil when none is set. HasUserWaypoint returns whether one is set. ClearUserWaypoint removes it and fires USER_WAYPOINT_UPDATED.

local point = C_Map.GetUserWaypoint()
if point then
    local x, y = point.position:GetXY()
end

C_Map.GetUserWaypointPositionForMap(uiMapID)

Returns the pin's position on uiMapID as a vector2, or nil when the pin does not fall on that map.

The pin is stored against one map, so this converts it to whichever map you are drawing. A pin dropped on a zone resolves on that zone's continent, and the reverse.

-- pin at (0.5, 0.6) on Durotar (14)
local pos = C_Map.GetUserWaypointPositionForMap(-13)   -- on Kalimdor
-- 0.589, 0.534

GetUserWaypointHyperlink returns a link describing the pin, or nil when none is set. GetUserWaypointFromHyperlink reads such a link back into a UiMapPoint, or nil when the string is not one.

The payload is worldmap:uiMapID:x:y, with both coordinates multiplied by 10000.

/dump C_Map.GetUserWaypointHyperlink()
-- "|cffffff00|Hworldmap:14:5000:6000|h[Map Pin Location]|h|r"

GetUserWaypointFromHyperlink takes either a whole link or the bare worldmap: payload, so it can read a link out of chat text.

C_Map.CanSetUserWaypointOnMap(uiMapID)

Returns whether a pin can be placed on the given map. A map needs a coordinate rect to hold one, so zones, continents, and instance maps accept a pin while the whole-world map does not.

C_Map.CanSetUserWaypointOnMap(14)    -- true  (Durotar)
C_Map.CanSetUserWaypointOnMap(-694)  -- false (the world map)

USER_WAYPOINT_UPDATED event

Fires (with no payload) whenever the user waypoint changes — when C_Map.SetUserWaypoint places one, and when C_Map.ClearUserWaypoint removes one. Re-read the pin with C_Map.GetUserWaypoint when it fires.

Clearing while no pin is set changes nothing, so it fires nothing.

local f = CreateFrame("Frame")
f:RegisterEvent("USER_WAYPOINT_UPDATED")
f:SetScript("OnEvent", function()
    local point = C_Map.GetUserWaypoint()
    -- redraw the pin, or hide it when point is nil
end)

C_Map.GetAreaTriggerInfo(triggerID) / C_Map.GetAreaTriggers([mapID])

ClassicAPI extension. Exposes AreaTrigger.dbc — the client's 517 static trigger volumes (subzone-entry, exploration, and teleport triggers). The engine loads the DBC but exposes none of its geometry to Lua, which is why map addons (pfQuest et al.) ship hand-scraped trigger tables; this is a live DBC read, same "surface the hidden data" pattern as C_Map.GetMapOverlays.

  • GetAreaTriggerInfo(triggerID) — one trigger's info table, or nil for a missing / out-of-range id.
  • GetAreaTriggers([mapID]) — an array of every trigger's info table, optionally filtered to a single Map.dbc id (0 Eastern Kingdoms, 1 Kalimdor, 30 Alterac Valley, 489 Warsong Gulch, …).

Each info table carries both the authoritative raw geometry and a derived, ready-to-draw form:

field meaning
id AreaTrigger.dbc row id
mapID Map.dbc id (continent, or an instance map)
x / y / z continent-space world coordinates of the center
radius sphere-trigger radius (0 for a box trigger)
isBox true when the trigger is an oriented box
boxLength / boxWidth / boxHeight / boxYaw box dims + yaw (all 0 for a sphere)
areaID AreaTable zone the point falls in — absent if unresolved
mapX / mapY position as a 0..100 zone-relative percent — absent alongside areaID

The raw x/y/z come straight from the DBC. areaID/mapX/mapY are derived from the WorldMapArea.dbc zone rect (WoW's world axes: +X north, +Y west). mapX is the horizontal map axis (off world Y), mapY the vertical (off world X) — the standard WoW / pfQuest / Astrolabe convention:

mapX% = (locLeft - y) / (locLeft - locRight)  * 100   -- horizontal
mapY% = (locTop  - x) / (locTop  - locBottom) * 100   -- vertical

The zone is chosen by which one's drawn landmass the point sits on: WorldMapArea rects are loose overlapping boxes, so a point near a border falls in several; the WorldMapOverlay hit rects trace the real landmass, and the zone the point is deepest inside an overlay of wins (the runtime stand-in for an ADT area lookup). When the point is on no zone's overlay it falls back to the most-interior containing rect; when no rect contains it at all (open sea, an instance with no WorldMapArea row, a degenerate rect), the three derived fields are simply omitted — the raw world coords are always present.

-- One trigger, its position as a world-map percent point:
local t = C_Map.GetAreaTriggerInfo(2)
if t.areaID then
    print(t.areaID, t.mapX, t.mapY)  -- 85  21.89  67.87  (Tirisfal)
end

-- Every trigger in Warsong Gulch:
for _, t in ipairs(C_Map.GetAreaTriggers(489)) do
    -- t.x/y/z world coords, t.radius or t.isBox geometry, t.mapX/mapY
end

Matches the pfQuest-turtle trigger table 1:1. Keyed by trigger id, the resolved areaID + mapX/mapY line up with pfQuest-turtle's scraped areatrigger-turtle.lua (id 2 → {21.89, 67.87, 85} Tirisfal; id 45 → {68, 17, 796}). One difference in kind: a scraped table may list a trigger under several maps at once (a subzone and its parent continent); this resolves to the single zone whose drawn landmass the point sits on (via the WorldMapOverlay hit rects — the runtime stand-in for an ADT area lookup). An addon can drop GetAreaTriggers() straight in place of a shipped table.

MerchantFrame

The six C_MerchantFrame.* calls addons use when interacting with a vendor. All entry points read the engine's merchant/buyback storage directly — no Lua-roundtrip through GetMerchantItemInfo / GetBuybackItemLink etc. — and the SellAllJunkItems dispatcher bypasses UseContainerItem entirely by calling the engine's internal MerchantSellItem packet builder.

A "merchant frame is currently open" gate (VAR_MERCHANT_NPC_GUID_* non-zero) applies to every function. GetNumJunkItems returns 0 away from a vendor and the sell functions are no-ops; the per-slot getters return nil.

C_MerchantFrame.GetItemInfo(slot)

Returns a table describing the merchant's item at the given 1-based slot, or nil if no merchant is open / the slot is out of range.

Table fields:

Field Type Notes
itemID number Item record key, suitable for C_Item.GetItemInfoInstant / cache lookups.
price number Copper cost per stack (multiply by stackCount for unit price).
stackCount number Units delivered per purchase (1 for most equipment, e.g. 5 for stacks of cloth).
numAvailable number Limited-supply count, or -1 for unlimited stock.
isPurchasable boolean Always true in this build — there is no "blocked from buying" flag.
isThrottled boolean Always false — there's no anti-spam concept here.
hasExtendedCost boolean Always false — currency/honor-cost merchants don't exist here.
local info = C_MerchantFrame.GetItemInfo(1)
if info then
    print(info.itemID, info.price, info.stackCount)
end

Reads directly from the 28-byte merchant entry at VAR_MERCHANT_ITEMS + (slot-1) * MERCHANT_STRIDE (the same flat array Script_GetMerchantItemInfo walks). Compared to the existing GetMerchantItemID, this returns the wider struct shape in one call.

C_MerchantFrame.GetBuybackItemID(slot)

Returns the itemID of the merchant's buyback slot at the given 1-based index (1..12), or nil if the slot is empty / no merchant is open.

for slot = 1, 12 do
    local id = C_MerchantFrame.GetBuybackItemID(slot)
    if id then
        -- buybackable item at slot
    end
end

Resolution chain (engine-direct, no Lua-side GetBuybackItemLink roundtrip):

  1. Read the buyback slot's stored invMgr index from VAR_BUYBACK_SLOTS + (slot-1)*4.
  2. Look up that index in the player invMgr's flat GUID array (the engine keeps sold items alive in the player's inventory storage, not in a separate buyback pool).
  3. Resolve the GUID to a CGItem* via Item::Location::ResolveByGUID — same engine helper C_EquipmentSet.GetItemLocations uses.
  4. Read the itemID from the CGItem's instance block at +0x08 + 0x0C.

C_MerchantFrame.GetNumJunkItems()

Returns the count of grey-quality (LE_ITEM_QUALITY_POOR) items in the player's bags 0..4 that SellAllJunkItems would sell. Returns 0 when no merchant frame is open — the count is gated on merchant context, since it's meant as a "what would the sell-junk button do right now" signal rather than a passive inventory query.

-- Inside a MERCHANT_SHOW handler:
local junk = C_MerchantFrame.GetNumJunkItems()
if junk > 0 then
    print("Selling " .. junk .. " junk item(s)")
    C_MerchantFrame.SellAllJunkItems()
end

Quality is read from each item's ItemStats cache record at the m_quality field (OFF_ITEMSTATS_QUALITY = 0x1C). Items with unloaded cache records (rare for items in your own bags) are skipped — same conservative behavior the sell path uses.

C_MerchantFrame.SellAllJunkItems()

Sells every quality-0 item in the player's bags to the open merchant. No-op when no merchant frame is open.

Sells are dispatched one per frame via the shared WorldTick subscriber, not in a tight loop within the call — the network path drops packets when CMSG_SELL_ITEM is flooded (a 10-item burst consistently lost the 2nd-to-last sell in testing). Calling pace matches click-by-click selling. For 10 junk items, expect the queue to drain in ~10 frames (~150ms at 60fps).

C_MerchantFrame.SellAllJunkItems()

If the player closes the merchant frame or opens a different vendor mid-drain, the remaining queue is discarded rather than mis-routed to the new merchant.

Each sell is delivered via the engine's internal MerchantSellItem helper (FUN_MERCHANT_SELL_ITEM, opcode CMSG_SELL_ITEM/0x1A0) called with count = 0 ("sell whole stack"). Bypasses Script_UseContainerItem entirely — addons hooking UseContainerItem will not see these sells, and there's no risk of the use-not-sell dispatch branch firing if the merchant state changes mid-loop.

C_MerchantFrame.IsMerchantItemRefundable(slot)

Always returns false. There is no refund mechanic here; the function exists for API parity so addons that gate behavior on refundability don't break.

if not C_MerchantFrame.IsMerchantItemRefundable(slot) then
    -- always taken in this build
end

C_MerchantFrame.IsSellAllJunkEnabled()

Always returns true. There is no client setting to disable the sell-all-junk button, so the feature is always on. The function exists so addons that gate SellAllJunkItems on this don't no-op silently.

MapExplorationInfo

C_MapExplorationInfo.GetExploredMapTextures and GetUnexploredMapTextures (ClassicAPI extension) are the exploration-filtered views of a zone's WorldMapOverlay.dbc overlays. Together with C_Map.GetMapOverlays (which returns all overlays) they form the three-way split:

function returns
C_MapExplorationInfo.GetExploredMapTextures(areaID) only overlays the player has discovered
C_MapExplorationInfo.GetUnexploredMapTextures(areaID) only overlays not yet discovered
C_Map.GetMapOverlays(areaID) all overlays, explored or not

All three return the identical per-overlay table shape (see GetMapOverlays); these two just filter it. "Explored" is computed per-areaID from the player's explored-areas bitfield (the same source the engine's own GetNumMapOverlays/GetMapOverlayInfo gate on), so the areaID argument works for any zone — not only the one currently on the world map. With no argument, both default to the current map view.

C_MapExplorationInfo.GetExploredMapTextures([areaID])

Returns an array of the overlay tables for overlays the local player has discovered in the zone — same shape as C_Map.GetMapOverlays, including fileDataIDs (here it holds SetTexture-ready texture paths, since there are no numeric fileDataIDs). Empty before the player is resident (character select).

for _, tex in ipairs(C_MapExplorationInfo.GetExploredMapTextures(12)) do
    -- tex.fileDataIDs[i] -> SetTexture path; tex.offsetX/offsetY placement
end

C_MapExplorationInfo.GetUnexploredMapTextures([areaID])

ClassicAPI extension — the complement: overlays the player has not discovered, the pieces a map-reveal addon draws over the fogged base map. The related C_Map.GetMapOverlays returns everything. Same table shape and areaID semantics as the explored getter.

Model

model:SetDisplayInfo(creatureDisplayID)

Points a Model frame at a creature by its display ID. This is the alternative to Model:SetModel(path), which needs a raw model file path.

The engine resolves the display ID to a model file through two DBC tables: CreatureDisplayInfo, then CreatureModelData. It loads the model the same way SetModel does, then applies the creature's skin textures. So the model shows the correct look for that display, not an untextured base.

A Model frame provides no light of its own. Call SetLight after SetDisplayInfo, or the model renders as a black silhouette. Call SetCamera(0) to frame it.

An unknown or missing display ID falls back to the engine's placeholder model.

A character-based display (an NPC on the shared Character\<Race>\<Sex> base model) is dressed the same way a spawned NPC is: baked body texture, hair, face, facial hair, and equipment, including the attached helm and shoulder models. The dressing runs when the model file finishes loading, a moment after the call. Weapons do not show: the server sends NPC weapons only for spawned units, and no client table carries them.

This is the single-creature form. An optional second argument mountDisplayID that shows the creature on a mount is not supported here: a mount needs a second, attached model, and the Model frame holds one model.

local m = CreateFrame("Model", "PreviewModel", UIParent)
m:SetSize(200, 260)
m:SetPoint("CENTER")
m:SetDisplayInfo(330)   -- a human NPC
m:SetCamera(0)
m:SetLight(1, 0, 0, -1, -1, 1, 1, 1, 1)  -- else it is a black silhouette

model:SetCreature(creatureID)

Points a Model frame at a creature by its entry ID — the creature's database ID, not a display ID. This is the companion to SetDisplayInfo.

The engine looks the entry ID up in the client creature cache, reads the display ID, then loads the model exactly like SetDisplayInfo. The same light and camera notes apply.

Limitation. The lookup only works for a creature the client has cached. A creature caches after you see it, target it, or mouse over it, or from creaturecache.wdb at login. There is no client-side entry-to-display table, so an uncached creature ID is a no-op — the frame keeps its previous model.

For an uncached creature, request its data first, then set it when the data arrives:

local creatureID = 448  -- the creature's entry (database) ID
C_CreatureInfo.RequestLoadCreatureByID(creatureID)

local f = CreateFrame("Frame")
f:RegisterEvent("CREATURE_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
    if arg1 == creatureID and arg2 then   -- (creatureID, success)
        model:SetCreature(creatureID)
        model:SetCamera(0)
        model:SetLight(1, 0, 0, -1, -1, 1, 1, 1, 1)
    end
end)

ChatBubbles

C_ChatBubbles.GetAllChatBubbles([includeForbidden])

Returns a 1-based table of the currently-active chat-bubble Frame objects (the speech bubbles above characters who /say or /yell). This replaces the old "iterate over WorldFrame children and guess which are bubbles" idiom — we hand you the exact set instead.

for _, bubble in ipairs(C_ChatBubbles.GetAllChatBubbles()) do
    -- The spoken text lives in the bubble's FontString region.
    for _, region in ipairs({bubble:GetRegions()}) do
        if region.GetText and region:GetText() then
            print(region:GetText())
        end
    end
end

Chat bubbles are CGChatBubbleFrames — full frames the engine creates in C++ without ever calling CreateFrame (same as default nameplates). The returned frames are the engine's canonical wrappers: real, method-capable (:GetRegions(), :IsShown(), :GetPoint(), …), and identical to the object any other addon that touches the bubble sees, so decorations survive. The spoken-text FontString is parented to the bubble, so it shows up in :GetRegions().

includeForbidden is accepted for signature parity and ignored — there are no forbidden frames here, so every active bubble is returned regardless. Returns an empty table when no bubbles are showing. A bubble whose owner just despawned can linger for one frame before the engine prunes it; filter on bubble:IsShown() if that matters.

NamePlate

C_NamePlate.* returns nameplate Frame objects keyed off unit data. The engine doesn't ship the API, but the underlying data (per-unit nameplate pointer at CGUnit + 0xE60) exists. We enumerate visible units via the local-player-anchored object hash table, filter by TYPEMASK_UNIT, and return matches.

The "nameplateN" unit-token family is also supported — see Unit tokens below. NAME_PLATE_UNIT_ADDED / _REMOVED / _CREATED events fire via a per-tick visible-plate diff (see the events section).

C_NamePlate.GetNamePlates()

Returns a 1-based table of nameplate Frame objects — one per CGUnit that currently has an allocated Lua-registered nameplate. The frames are real Lua tables with methods (:GetName(), :GetWidth(), :SetAlpha(), etc.) and any addon-added decorations on them.

local plates = C_NamePlate.GetNamePlates()
for i, plate in ipairs(plates) do
    print(i, plate:GetName(), plate:GetWidth())
end

Two kinds of plates can show up. Both give you the same frame object that every other path gives you for that plate:

  • Addon-created plates (pfUI, TidyPlates, NamePlateMod, and others): built with CreateFrame. The addon puts its own fields and methods on that frame, and you see them here.

  • Default engine plates: built by the engine without CreateFrame. The first call registers the frame with Lua. Every later call gives you that same object.

Identity is stable across calls for both kinds. Two calls give you the same table for one plate, so you can compare frames with ==. Fields that you set on a frame stay on it.

A plate frame comes from a pool. When the unit goes out of range, the engine can give that frame to a different unit. Do not store a frame across that point. When you need plates, call GetNamePlates() again.

Reading region content from a default nameplate

Default plates have six child regions in stable positions. Walk them with :GetRegions():

local plates = C_NamePlate.GetNamePlates()
for _, plate in ipairs(plates) do
    local regions = {plate:GetRegions()}
    local name  = regions[3]:GetText()              -- e.g. "Joseph Dalton"
    local level = tonumber(regions[4]:GetText())    -- e.g. 60
    -- regions[1], [2], [5], [6] are textures (border, healthbar,
    -- glow, raid-icon — order depends on the engine's draw order)
end

Lua 5.0 has no select(), so collect into a table via {plate:GetRegions()} and index. Addon-created plates have different region layouts — those frames inherit whatever shape the addon built, not this one.

C_NamePlate.GetNamePlateForUnit(unitToken)

Returns the nameplate Frame for a single unit (resolved via the engine's token-to-GUID path, so out-of-range party/raid members work too), or nil if the unit has no allocated nameplate.

local plate = C_NamePlate.GetNamePlateForUnit("target")
if plate then
    local regions = {plate:GetRegions()}
    print("targeting:", regions[3]:GetText())   -- e.g. "Santora"
end

The frame object is the same one that GetNamePlates() gives you for that plate. Do not store the result across the unit going out of range. The engine can give that frame to a different unit.

C_NamePlate.GetNamePlateForGUID(guidString)

Same as GetNamePlateForUnit but takes the "0xHHHHHHHHHHHHHHHH" GUID-string form. Useful when you've stored a unit GUID across events (e.g., converted the positional "nameplateN" token to a GUID via UnitGUID(arg1) at NAME_PLATE_UNIT_ADDED time) and need the frame later.

local platesByGuid = {}

local f = CreateFrame("Frame")
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
f:SetScript("OnEvent", function()
    -- arg1 = "nameplateN" token; convert to stable GUID for storage
    local guid = UnitGUID(arg1)
    platesByGuid[guid] = C_NamePlate.GetNamePlateForGUID(guid)
end)

Returns nil if the GUID doesn't parse, doesn't resolve to a visible CGUnit, or the unit has no allocated nameplate.

C_NamePlate.GetNamePlateGUIDs()

Returns a 1-based table of GUID strings ("0xHHHHHHHHHHHHHHHH" format) — one per CGUnit with an allocated nameplate, regardless of whether the frame has been registered with Lua. Catches default engine nameplates that GetNamePlates can't surface as frames.

/dump C_NamePlate.GetNamePlateGUIDs()
-- { "0xF13000C36C26FD02", "0xF130000009276912", ... }

Walks the local-player-anchored object hash table for TYPEMASK_UNIT entries, filters by *(unit + 0xE60) != nullptr. The per-unit nameplate pointer is set by FUN_006086E0's "show nameplate" path regardless of which nameplate system rendered it. Order follows hash-bucket iteration and isn't stable across calls.

Unit tokens (nameplateN)

"nameplate1", "nameplate2", … work as unit tokens against every UnitX function: UnitName, UnitGUID, UnitClass, UnitHealth, UnitHealthMax, UnitLevel, UnitFaction, UnitReaction, UnitExists, UnitIsPlayer, UnitIsEnemy, UnitIsDead, etc. — ~30 functions for free.

-- A slot can be vacant, so skip gaps. Do not stop at the first one.
for i = 1, 40 do
    local token = "nameplate" .. i
    if UnitExists(token) then
        print(i, UnitName(token), UnitClass(token))
    end
end

Indices are assigned slots: a plate keeps its slot for its entire lifetime, so surviving plates are never renumbered when another plate is removed. A removed plate frees its slot, and the next new plate reuses the lowest free slot. So a middle plate vanishing leaves the others' nameplateN tokens unchanged (that slot simply becomes vacant until a new plate reuses it) — UnitExists("nameplateN") returns false for a currently-free slot. Stable for the lifetime of a single plate; no reordering, ever.

Token chains work too — "nameplate1target", "nameplate1targettarget", etc. — by mirroring the engine's own targettarget-style suffix walker (read UNIT_FIELD_TARGET off m_objectFields, loop). Other suffixes (pet, master) aren't supported by the engine's own walker either, so they don't compose.

Out-of-range indices return nil cleanly without raising "Unknown unit name" — UnitExists("nameplate99") just returns false.

Unit events fire for "nameplateN". Like party/raid tokens, UNIT_HEALTH / UNIT_AURA / UNIT_LEVEL / … fire with arg1 == "nameplateN" when a nameplated unit's descriptor field changes (the engine only watched its own target/party/raid units). Backed by Unit::TokenObserver — observers are registered per plate on NAME_PLATE_UNIT_ADDED and torn down on _REMOVED; the changed GUID is resolved to its current index at fire time, since indices shift as plates vanish. Note the per-plate observer cost in very large scenes.

Implementation note. We hook FUN_TOKEN_TO_GUID (the central token→GUID resolver) so the entire Script_Unit* surface gains the new token form transparently. The hook is gated by an SStrCmpI prefix check against "nameplate"; non-nameplate tokens ("player", "target", "partyN", etc.) fall straight through to the unmodified resolver. The ordered list is maintained alongside the existing NAME_PLATE_UNIT_ADDED / _REMOVED diff in the per-tick nameplate walker.

Unit tokens (markN)

"mark1" … "mark8" work as unit tokens against every UnitX function, resolving to whichever unit currently wears that raid-target marker (mark1 = star, mark2 = circle, … mark8 = skull — the same 1–8 order as SetRaidTarget / GetRaidTargetIndex):

if UnitExists("mark8") then                       -- something is skull-marked
    print(UnitName("mark8"), UnitHealth("mark8"))
end

UnitExists("markN") returns false when that marker is unset, when the marked unit is out of range / not synced, or when the marker is on a non-unit (a marked corpse, gameobject, or loot). Token chains compose — "mark1target", "mark1targettarget" — via the same suffix walker as the other families.

Unit events fire for "markN". UNIT_HEALTH / UNIT_MANA / UNIT_AURA / UNIT_LEVEL / … fire with arg1 == "markN" when the marked unit's descriptor field changes — the engine only watched its own target/party/raid units, so a marked mob that isn't otherwise one of those fired nothing:

local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_HEALTH")
f:SetScript("OnEvent", function()
    if arg1 == "mark8" then
        -- skull's health changed; UnitHealth("mark8") is fresh here
    end
end)

Backed by Unit::TokenObserver (the same mechanism as focus / nameplateN). A per-frame watcher diffs the engine's 8-slot marker table and attaches/detaches an observer per marked unit — deferring until the unit is in range, since observers can only bind to a live object and a marked unit may be marked while out of range and enter later. The idle case (no markers set) costs a handful of comparisons per frame and no object lookups. A marker on a non-unit is simply never observed.

Unit tokens (GUID literals)

A raw 64-bit GUID literal — "0x" followed by up to 16 hex digits (e.g. "0xF130000000000A5") — works as a unit token against the same UnitX surface, so a GUID can be passed anywhere a token is expected:

local guid = UnitGUID("target")        -- e.g. "0xF13000..."
print(UnitName(guid), UnitHealth(guid), UnitClass(guid))

Hex parsing is case-insensitive; the parsed GUID is handed to the engine's own object-manager lookup (the identical path "player" takes), so a GUID for a unit that isn't currently loaded (out of range, not synced) resolves to nil exactly like any absent unit — no error. Suffix chains compose: "0x…target" walks to the referenced unit's target.

This is the input direction only — to obtain a token's GUID use UnitGUID.

Compatible with SuperWoW. SuperWoW provides the same GUID-input support, so when it's loaded we detect it and defer to its resolver (no double handling); when it isn't, this fills the gap. Either way GUID-token input behaves identically, so addons needn't care whether SuperWoW is present.

IsUnitToken(value)

Returns true when value is a unit token, and false when it is anything else, such as a character name.

Several functions come in pairs, one that takes a token and one that takes a name, and each raises an error on the other kind. Use this to pick between them:

if IsUnitToken(x) then TargetUnit(x) else TargetByName(x) end
if IsUnitToken(x) then FollowUnit(x) else FollowByName(x) end

That is the common case for conditional slash commands, because SecureCmdOptionParse returns either kind from the same expression. /target [@focus] gives the token focus, and /target Bob gives the name Bob.

The answer covers every token family the client accepts, including nameplateN, markN, focus, GUID literals, and suffix chains such as focustarget.

The question is whether the text names a token, not whether a unit is there. So "target" with nothing targeted, and "party3" while solo, are both true. A missing or non-string argument is false.

A name that reads like a token is treated as the token. Call the by-name function directly to reach a player named Target.

ClassicAPI extension.

NameCache

GUID-keyed cache of player names and classes. The engine itself maintains an in-memory NameCache at 0x00C0E228, populated by SMSG_NAME_QUERY_RESPONSE — but it isn't exposed to Lua, and it doesn't survive /reload. This module surfaces it as GetPlayerInfoByGUID, adds an opt-in persistent layer that survives /reload and full client restarts, and (separately toggleable) sweeps the engine's visible-object list to populate entries the SMSG path would never reach on its own.

Two toggles, each independent:

  • C_PlayerCache.SetEnabled — turn on the on-disk cache. Off by default. Without this, the module is read-only against the engine's transient in-memory cache.
  • C_PlayerCache.SetScanEnabled — turn on the visible-object sweep. Off by default. Requires the on-disk cache to be enabled to have any effect.

Three population sources feed the cache when fully enabled:

  1. SMSG_NAME_QUERY_RESPONSE hook (always active when the on-disk cache is on). Every name-query response the engine processes is mirrored to disk. Covers chat, group/raid sync, guild updates, visible-object resolution — anything the engine itself issues a name query for.
  2. C_PlayerCache.RememberPlayer (always available, no-op when the cache is off). Lets addons feed in sources the engine never sees: /who results, etc.
  3. Visible-object sweep (active when both toggles are on). Walks the engine's live visible-object list every ~10 seconds on the Frame::RegisterEvent hook, resolving each player and feeding name + class. Picks up nearby players whether or not the engine ever queried them.

Storage:

  • WTF\Account\<account>\ClassicAPI.txt — account-level settings (PersistentNameCacheEnabled, NameCacheScanEnabled). The opt-in toggles stay per-account.
  • WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt — per-realm cache, shared across every account on the same server. Keyed by the realmlist address (unambiguous server identity, can't collide across unrelated private servers) plus the realm name — so alts on different accounts pool into one file rather than each rebuilding their own. Tab-separated text, ~30 bytes/entry, hand-editable. On first load with no shared file yet, a pre-existing per-account cache (WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt) is migrated in automatically.

GetPlayerInfoByGUID(guid)

Returns localizedClass, englishClass, localizedRace, englishRace, sex, name, realm for a player GUID the engine has cached, or nil on a cache miss.

local _, class, _, race, sex, name = GetPlayerInfoByGUID(UnitGUID("target"))
-- e.g. "WARRIOR", "NightElf", 2, "Sylphir"

GetPlayerInfoByGUID("0x0000000000000777")  -- same shape for a literal GUID

guid is the "0xHHHHHHHHLLLLLLLL" string returned by UnitGUID. Bare 8-hex "0xLLLLLLLL" (hi-dword zero) is also accepted.

Returns:

| 1 localizedClass | "Warrior" / "Krieger" etc. — from ChrClasses.dbc indexed by locale. | | 2 englishClass | "WARRIOR" (uppercase tag, the same value UnitClass returns as its 2nd return) — ChrClasses.dbc filename field. | | 3 localizedRace | "Human" / "Mensch" / "Humain" etc. — from ChrRaces.dbc indexed by locale. | | 4 englishRace | "Human", "Orc", "Dwarf", "NightElf", "Scourge" (the filename for what addons call Undead), "Tauren", "Gnome", "Troll" — from ChrRaces.dbc filename field. | | 5 sex | 2 = male, 3 = female. Matches UnitSex convention (the cache stores 0/1; we add 2). | | 6 name | Character name. | | 7 realm | Realm name. Single-realm, so usually the local realm. |

Cache coverage: the engine populates entries from SMSG_NAME_QUERY_RESPONSE. Anything the client has already seen populates the cache: chat (whispers, says, party chat), raid/group events, guild updates, visible objects (target, party, raid in zone). Names of offline friends never seen in chat are not cached — GetPlayerInfoByGUID returns nil for them until the client does something that triggers a name query. This module deliberately does not trigger queries from a passive getter; a future C_PlayerInfo.RequestLoadPlayerByID would do that explicitly and fire a load-result event.

Persistent fallback: when C_PlayerCache.SetEnabled(true) has been opted into, a per-realm on-disk cache extends coverage across sessions. On engine cache miss, GetPlayerInfoByGUID falls back to the persistent cache and returns name, class, race, and sex from storage (realm comes back as "" since this is single-realm and we don't carry per-player realm names). Returns nil only when both the engine and persistent caches miss.

Implementation: calls the engine's get-or-fetch primitive at 0x0055F080 with a NULL callback (pure cache read). The cache instance lives at 0x00C0E228; entry layout (name, realm, race, sex, class) was reverse-engineered from the SMSG_NAME_QUERY_RESPONSE write path at 0x0055F310.

UnitNameFromGUID(guid)

Returns name, realm for the player identified by guid, or nil if no player with that GUID has been encountered yet.

It tries three sources in order:

  1. The object manager — any currently-synced unit (a player you can see, your target, a party or raid member, an NPC in range).
  2. Your friends list — it keeps name and GUID for online and offline friends, so a friend resolves even when not synced and never seen in chat. Always on.
  3. The persistent on-disk name cache, when you have enabled it.
local name, realm = UnitNameFromGUID(UnitGUID("target"))
if name then
    ChatFrame1:AddMessage("Target: " .. name)
end

-- Resolving a chat link
local name, realm = UnitNameFromGUID("0x0000000000000777")

realm is always "" — the engine doesn't populate per-player realm names and there is no cross-realm interaction. We push the empty string rather than nil to match the convention of the other player-info accessors; addons can gate on realm == "".

Doesn't trigger a network query on miss. To populate an unknown GUID, either let the engine receive a SMSG_NAME_QUERY_RESPONSE through normal chat/target interaction, or call C_PlayerCache.RememberPlayer explicitly.

C_PlayerCache.GetPlayerInfoByName(name)

Returns localizedClass, englishClass, localizedRace, englishRace, sex, name, realm, guid for a player in the persistent NameCache, or nil if the name isn't cached. Companion to GetPlayerInfoByGUID for the case where an addon has a player's name but not their GUID — e.g., when GetCurrentChatGUID() returns nil for a chat event the engine didn't tag with the sender GUID (system notifications, some channel messages, etc.).

local _, class, _, race, _, _, _, guid = C_PlayerCache.GetPlayerInfoByName("Gedwyr")
-- "MAGE", "Human", "0x000000003B9ADAA7"

C_PlayerCache.GetPlayerInfoByName("NeverSeen")  -- nil (not cached)

Match is case-sensitive, exact — server-stored names are case-stable ("Gedwyr" won't match "gedwyr").

Returns (vs. GetPlayerInfoByGUID):

Slot Value Notes
1–7 same as GetPlayerInfoByGUID see above
8 guid "0xHHHHHHHHLLLLLLLL" — the cached player's full GUID, so name-based callers can chain into GUID-keyed APIs. Absent from GetPlayerInfoByGUID since the caller already has it.

Only reads from the persistent NameCache — does not hit the engine's in-memory NAME_QUERY cache (that's keyed by GUID and has no name index). So this requires C_PlayerCache.SetEnabled(true) to have been opted into; entries get there via the engine's NAME_QUERY response hook, the visible-object scan, and C_PlayerCache.RememberPlayer.

Implementation: O(1) lookup via a name → guid index map maintained in lockstep with the GUID-keyed entry map. Eviction keeps the index consistent: when a name's character is deleted and the name is recycled to a different GUID, the prior entry is removed from both maps.

C_PlayerCache.RememberPlayer(guid, name, classToken)

Adds a (guid → name, classID, raceID, sex) entry to the persistent name cache. For the engine-driven coverage (chat / group / guild / visible objects), no addon-side feeding is needed — those flow into the cache automatically through the engine's SMSG_NAME_QUERY_RESPONSE write path. This call exists for the other sources libunitscan-style addons harvest from but the engine NameCache doesn't see directly: /who results, mouseover, target snapshots, etc.

C_PlayerCache.RememberPlayer(guid, name, classToken [, raceToken [, sex]])

Returns true on success, false if the persistent cache isn't enabled or the required args are malformed.

  • guid — "0xHHHHHHHHLLLLLLLL" string (same format UnitGUID returns). 8-hex "0xLLLLLLLL" is also accepted (hi-dword zero).
  • name — 1–12 ASCII chars (the character-name range). Tabs, newlines, and high bytes are stripped.
  • classToken — uppercase token like "WARRIOR", "MAGE". Looked up against ChrClasses.dbc filename field, case-insensitive. Passing an unknown token keeps the entry's prior class (so a name-only sighting doesn't erase good class data).
  • raceToken (optional) — uppercase token like "NIGHTELF", "SCOURGE" (the filename for Undead). Same resolution as classToken, against ChrRaces.dbc. Omitted/unknown → leaves prior race alone.
  • sex (optional) — 0 (male) or 1 (female), matching the wire-format convention the engine cache stores. UnitSex uses 2/3; pass UnitSex - 2 if you're forwarding that value. Omitted/0 → leaves prior sex alone (so this call can't be used to flip a stored value back to male; the SMSG_NAME_QUERY_RESPONSE hook handles direct assignment).
-- Harvest /who results into the persistent cache
local function OnWhoUpdate()
    for i = 1, GetNumWhoResults() do
        local name, _, _, _, class, _, _, _, _, _, _, sex = GetWhoInfo(i)
        -- class/race come back localized from GetWhoInfo; the
        -- locale-token tables in pfUI/AceLocale handle the
        -- inverse mapping. RememberPlayer expects engine tokens
        -- ("WARRIOR" etc.).
    end
end

Each non-zero field is updated on the existing entry; zeros are treated as "caller doesn't know" and preserve prior real data. A name-only update (classToken passed but unknown) refreshes the name without erasing class.

The "deleted character recreated with same name, different class" collision case that name-keyed caches suffer from doesn't apply here: GUIDs are permanent for the life of a character, so the new character has a different GUID and gets a different cache entry.

C_PlayerCache.SetEnabled(enabled)

Opts into (or out of) the persistent name cache. enabled is a boolean (numeric 0/1 also accepted). Persists to WTF\Account\<account>\ClassicAPI.txt as PersistentNameCacheEnabled=1/0, so the choice survives client restarts.

When enabled:

  • Every SMSG_NAME_QUERY_RESPONSE the engine processes is also written to WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt.
  • Lua-side C_PlayerCache.RememberPlayer calls become effective (they're no-ops when the cache is disabled).
  • GetPlayerInfoByGUID gains the cross-session fallback path documented above.
  • SetScanEnabled becomes effective if also turned on.

When disabled, the on-disk file is left in place (re-enabling later restores the prior contents); future writes are simply suppressed.

C_PlayerCache.SetEnabled(true)

Returns nothing.

C_PlayerCache.IsEnabled()

Returns the current state of the persistent name cache as a boolean. false until SetEnabled has been called (or its prior call survived in the on-disk settings file).

if C_PlayerCache.IsEnabled() then
    -- persistent fallback is active for GetPlayerInfoByGUID
end

C_PlayerCache.SetScanEnabled(enabled)

Opts into the visible-object sweep: an opportunistic walk of the engine's live visible-object list that feeds every player in render range (whose object is currently loaded by the client) into the persistent name cache. Throttled to once per ~10 seconds, on the existing Frame::RegisterEvent hook — no per-frame overhead, no extra hooks installed.

enabled is a boolean (numeric 0/1 also accepted). Persists to WTF\Account\<account>\ClassicAPI.txt as NameCacheScanEnabled=1/0.

C_PlayerCache.SetEnabled(true)        -- prerequisite
C_PlayerCache.SetScanEnabled(true)
-- Now: standing in Stormwind for a minute pre-populates the cache
-- with every visible player's name and class.

Independent of the cache toggle. Turning this on alone has no effect — the sweep relies on Remember() which silently no-ops when the on-disk cache is disabled. Turning the cache off (without also turning the scan off) preserves the scan setting for the next time you re-enable the cache.

Players only. NPCs and pets aren't cached — their GUIDs are ephemeral and can get reused across sessions, so caching them is worse than useless.

Implementation: walks ClntObjMgrEnumVisibleObjects (0x00468380) — the same engine iterator VanillaMinimapTracking uses for blip rendering — filtering each GUID through ClntObjMgrObjectPtr (0x00468460) with TYPEMASK_PLAYER. Name comes from the CGObject vftable's GetName slot; class is the byte at [m_objectFields + 0x79] (UNIT_FIELD_BYTES_0 byte 1, same field Script_UnitClass reads).

C_PlayerCache.IsScanEnabled()

Returns the current visible-object scan setting as a boolean. Independent of the cache toggle — this only reflects the scan- specific setting, not whether the scan is effectively running (which also requires the cache to be on).

if C_PlayerCache.IsScanEnabled()
    and C_PlayerCache.IsEnabled() then
    -- visible-object sweeps are running every ~10s
end

NewItems

The "new item glow" bookkeeping — the sparkle a bag UI shows on items you've just picked up but haven't looked at yet. There is no DBC, server data, or engine bit for it; it's pure client-side tracking over your bag contents, so ClassicAPI adds it.

Newness is keyed on each item's instance GUID (read off the CGItem), not on (bagID, slotIndex). A flag therefore survives the player rearranging a bag, and is impossible for a pure-Lua addon (no per-instance GUID is exposed to Lua). Only bag items are tracked (bags 0–4); equipment and bank are out of scope.

The feed is entirely client-side C++ (no addon Lua), and event-driven rather than polled: it hangs off the engine's own BAG_UPDATE fire sites, so a bag rescan runs only on frames where a bag actually changed. Each rescan diffs the resident item GUIDs against the previous one, flags anything newly acquired, and prunes flags for items that leave. BAG_NEW_ITEMS_UPDATED (no payload) fires whenever the new-item set changes. A one-time baseline taken ~1.5s after login (re-armed on character switch) means items already owned at login aren't flagged.

C_NewItems.IsNewItem(bagID, slotIndex)

Returns true if the item currently in bag bagID slot slotIndex is flagged new, false otherwise (empty slot, unflagged item, or non-numeric args). bagID 0 is the backpack; 1–4 are the equipped bags. Slot indices are 1-based, matching GetContainerItemInfo.

if C_NewItems.IsNewItem(0, 3) then
    -- show the "new" glow on backpack slot 3
end

C_NewItems.RemoveNewItem(bagID, slotIndex)

Clears the new flag for the item in that slot (e.g. once the player has seen it). No-op if the slot is empty or the item wasn't flagged. Fires BAG_NEW_ITEMS_UPDATED when it actually removes a flag. Because the flag is GUID-keyed, the item stays un-flagged even if it's later moved to another slot.

C_NewItems.ClearAll()

Clears every new flag at once. Fires BAG_NEW_ITEMS_UPDATED if anything was flagged.

PlayerInfo

C_PlayerInfo.CanUseItem(itemID)

Returns true if the local player meets the item's use/equip requirements, false otherwise. This is the "would the item be red in the tooltip" gate, and rounds out the item-usability trio:

Function Question it answers
C_Item.IsEquippableItem(item) Does the item fit some slot? (static, player-independent)
IsUsableItem(item) Is the item's on-use ability castable right now?
C_PlayerInfo.CanUseItem(itemID) May this player equip/use it at all?

So this is what answers "a Mail chest on a Mage" (armor proficiency), "a level-40 item at level 20" (RequiredLevel), and class/race-restricted gear.

itemID is a number (item links are also accepted). Checks performed:

  1. Proficiency — for weapons/armor, the engine keeps a per-item-class subclass-proficiency bitmask (fed by SMSG_SET_PROFICIENCY); the item's subclass bit must be set. Item classes with no proficiency concept (consumables, trade goods, …) are unrestricted.
  2. RequiredLevel ≤ player level.
  3. AllowableClass / AllowableRace masks include the player.
  4. RequiredSkill — the player must have the item's skill line at an effective rank ≥ RequiredSkillRank (e.g. a mount that "Requires Riding (150)", a tool that "Requires Engineering (200)").
  5. RequiredSpell — the player must know the item's prerequisite spell (or a higher rank of it): a crafting specialization ("Requires Armorsmith") or a proficiency spell.
  6. RequiredHonorRank / RequiredCityRank — PvP-rank gates (the player's honor rank and earned PvP-medal bitmask).
  7. RequiredReputation — current standing with the item's faction must reach the required reaction band (e.g. "Requires Honored with …").
C_PlayerInfo.CanUseItem(12640)  -- Lionheart Helm (plate) → true on a Warrior, false on a Mage
C_PlayerInfo.CanUseItem(6948)   -- Hearthstone → true (no restrictions)
C_PlayerInfo.CanUseItem(19872)  -- a mount → false without Riding (150)
C_PlayerInfo.CanUseItem(12717)  -- Plans: Lionheart Helm → false without the Armorsmith spec

Synchronous, like C_Item.IsEquippableItem: an uncached item returns false with no async load fired. Warm the cache (C_Item.RequestLoadItemDataByID) and re-check on ITEM_DATA_LOAD_RESULT if needed.

C_PlayerInfo.GUIDIsPlayer(guid) / GUIDIsCreature / GUIDIsPet / GUIDIsGameObject

Type checks on the raw 1.12 GUID format. GUIDs encode the entity type in the high 16 bits of the qword — players have 0x0000 (low dword = player ID), creatures 0xF130xxxx, pets 0xF140xxxx, game objects (herbs / chests / etc.) 0xF110xxxx, items 0x4000xxxx. Each function returns true only for its specific prefix; the "0x0000000000000000" sentinel returns false from all four.

if C_PlayerInfo.GUIDIsPlayer(UnitGUID("target")) then
    -- target is a player
end

-- Combat-log row triage: was that a player kill or a mob kill?
local function OnCombatLogEvent(_, _, eventType, srcGUID, ...)
    if eventType == "PARTY_KILL" then
        if C_PlayerInfo.GUIDIsPlayer(srcGUID) then ... end
    end
end

GUIDIsPlayer takes a single GUID argument; the other three are companions for the most common type-distinction needs. For other types (corpse, dynamic object, transport, item) the underlying classifier exists internally — let us know if you need one exposed.

Accepts either the 16-digit "0xHHHHHHHHLLLLLLLL" form or the 8-digit "0xLLLLLLLL" shortcut (high dword implicitly zero). Malformed input returns false rather than raising — tolerant of stale GUIDs from addon-side caches.

C_PlayerInfo.GetName / GetClass / GetRace / GetSex / IsConnected(playerLocation)

The PlayerLocation-taking player accessors. A PlayerLocation (see PlayerLocation / PlayerLocationMixin) wraps one way to identify a player; these resolve it to a unit and return the requested attribute.

Function Returns
C_PlayerInfo.GetName(playerLocation) name
C_PlayerInfo.GetClass(playerLocation) className, classFilename, classID
C_PlayerInfo.GetRace(playerLocation) raceID
C_PlayerInfo.GetSex(playerLocation) sex (1 neutral / 2 male / 3 female)
C_PlayerInfo.IsConnected([playerLocation]) isConnected
local loc = PlayerLocation:CreateFromUnit("target")
local className, classFile, classID = C_PlayerInfo.GetClass(loc)
local name = C_PlayerInfo.GetName(loc)
local raceID = C_PlayerInfo.GetRace(loc)          -- feed into C_CreatureInfo.GetRaceInfo
local sex = C_PlayerInfo.GetSex(loc)
  • Supported location kinds: unit and guid. Unit locations use their token directly; GUID locations resolve only while the GUID maps to a currently-visible unit (target, party/raid member, someone in range). The chat-line / who / battlefield-score / community / voice kinds have no client-side unit resolution in 1.12 and return nil.
  • Everything is read straight from the engine's own data — no Unit* Lua wrappers are called. GetName uses the polymorphic object name getter (the same path as UnitNameFromGUID); GetSex reads the UNIT_FIELD_BYTES_0 gender byte and reports it as UnitSex's 2/3; IsConnected treats a currently-synced object as online and otherwise consults the group-member roster's online bit, exactly as UnitIsConnected does. IsConnected with no argument defaults to the local player.
  • GetRace / GetClass return the numeric ids that UnitRace / UnitClass don't (those give only names). classID round-trips with C_CreatureInfo.GetClassInfo and raceID with C_CreatureInfo.GetRaceInfo; GetClass's className / classFilename are the same localized name + non-localized token ("WARRIOR") those return.
  • An unresolvable or unsupported location returns nil. A malformed unit token raises (the engine's token→GUID resolver rejects it), same as passing it to UnitName/UnitClass directly.

Quest

C_QuestLog.GetQuestIDForLogIndex(index)

Returns the questID (Quest.dbc row ID) for the entry at the given 1-based quest log index. The stock GetQuestLogTitle doesn't return it, even though the engine has it internally.

  • Returns the questID for real quests.
  • Returns 0 for header rows (zone / category dividers).
  • Returns nil if the index is out of range.
for i = 1, GetNumQuestLogEntries() do
    local title, level, questTag, isHeader, isCollapsed, isComplete
        = GetQuestLogTitle(i)
    local questID = C_QuestLog.GetQuestIDForLogIndex(i)  -- 0 for headers
    -- ...
end

C_QuestLog.GetLogIndexForQuestID(questID)

The inverse of GetQuestIDForLogIndex: returns the 1-based quest log index of questID, or nil if the quest isn't in the log. The index spans the full entry list (headers included), so it can be passed straight to GetQuestLogTitle(index) / GetQuestLogLeaderBoardID(...) and the other index-based quest-log accessors.

local index = C_QuestLog.GetLogIndexForQuestID(questID)
if index then
    local title = GetQuestLogTitle(index)
    -- ...
end

C_QuestLog.GetHeaderIndexForQuest(questID)

Returns the 1-based log index of the collapsible header (zone / "Dungeon" / class-sort category, …) that questID sits under, or nil if the quest isn't in the log. The quest log is laid out header-then-its-quests, so this locates the quest and walks back to the nearest preceding header row. Pass the result to GetQuestLogTitle(headerIndex) for the category name.

local headerIndex = C_QuestLog.GetHeaderIndexForQuest(questID)
if headerIndex then
    local zone = GetQuestLogTitle(headerIndex)  -- e.g. "Westfall"
end

C_QuestLog.RequestLoadQuestByID(questID)

Asks the engine to fetch the static data for questID (title, description, objectives, reward text) from the server if not already cached. Returns no values — fire-and-forget.

Fires QUEST_DATA_LOAD_RESULT(questID, success) when the data lands in the cache. Synchronously fired when the data was already cached (so polling code paths still work), asynchronously fired after SMSG_QUEST_QUERY_RESPONSE lands when the engine had to round-trip to the server.

success is 1 or nil. The engine's printf-style event dispatcher has no %b token, so we encode the boolean as 1 for success / nil for failure. if success then ... works as expected (nil falsy, 1 truthy). Same encoding as ITEM_DATA_LOAD_RESULT and GET_ITEM_INFO_RECEIVED.

Server limitation: for invalid questIDs (ones the server doesn't have), the server silently drops the query — it doesn't send a "not found" response packet. The engine's pending callback never resolves, so QUEST_DATA_LOAD_RESULT doesn't fire with success=0 either. Addons that need to handle "request timed out" should use their own timer (the Lua polyfill at !!!ClassicAPI/Util/QuestUtil.lua uses a 180-second wait).

local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
    -- vanilla 1.12: event payload is in `arg1`, `arg2`, ... globals
    if event == "QUEST_DATA_LOAD_RESULT" and arg2 == 1 then
        local questID = arg1
        -- title/description/objectives are now in the engine's quest cache
    end
end)
C_QuestLog.RequestLoadQuestByID(2)

C_QuestLog.IsOnQuest(questID)

Returns true if questID is currently in the player's quest log (either still in progress or ready to turn in — both states live in the log). Returns false for invalid input (non-positive, non-number, or simply not in the log).

if C_QuestLog.IsOnQuest(215) then
    -- player has "Jungle Secrets" accepted
end

Walks the same VAR_QUEST_LOG_ENTRIES array C_QuestLog.GetQuestIDForLogIndex reads and matches against each real entry's questID, skipping the zone/category header rows.

Cheaper than the engine's IsUnitOnQuest(logIndex, unit), which requires the addon to walk the log to find the matching index first and resolves a unit token even when the answer is just about the player. Same answer in a single call.

C_QuestLog.IsUnitOnQuest(unit, questID)

Returns true if the unit has questID in their quest list. Arg order is unit first, questID second — and questID-keyed rather than log-index-keyed like the stock IsUnitOnQuest(logIndex, unit).

if C_QuestLog.IsUnitOnQuest("party1", 215) then
    -- partymate is also on Jungle Secrets
end

For unit == "player" equivalent to C_QuestLog.IsOnQuest(questID). For other tokens (party/raid members, target), the unit must be in the engine's sync range — the data comes from SMSG_QUESTGIVER_QUEST_DETAILS broadcasts, which only reach you while the other player is within the client's sync window. Returns false for NPCs, units out of range, and units that haven't synced their quest list yet.

Returns false (no lua_error) for invalid input: non-string unit, non-number / non-positive questID, unresolvable token, or unit that's not player-controlled (i.e., a creature). The UNIT_FLAG_PLAYER_CONTROLLED gate is mandatory — without it the +0xE68 deref would AV on any NPC.

C_QuestLog.GetTitleForQuestID(questID)

Returns the title (string) for questID from the engine's quest static-info cache, or nil if the data isn't loaded. Doesn't require the quest to be in the player's quest log — works for any questID once its data has been fetched. Header rows are excluded (their titles live in QuestSort.dbc, not in this cache); for those you'd use the existing GetQuestLogTitle.

The cache is populated lazily — by the engine's own quest-log path when the player has the quest, or explicitly by C_QuestLog.RequestLoadQuestByID. If the title isn't there yet, queue a load and read on the event:

local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
    if event == "QUEST_DATA_LOAD_RESULT" and arg2 == 1 then
        local title = C_QuestLog.GetTitleForQuestID(arg1)
        if title then
            -- title is now available
        end
    end
end)
C_QuestLog.RequestLoadQuestByID(215)

C_QuestLog.GetQuestDetails(questID)

Returns a table with every field we know how to read from the engine's quest static-info cache for questID, or nil if the data isn't loaded. One round-trip alternative to calling GetTitleForQuestID / GetQuestLogRewardMoney / etc. across each of its individual accessors. Quest-helper-style addons that want structured data per quest are the primary consumers.

Like GetTitleForQuestID, this is a pure cache probe — pair it with C_QuestLog.RequestLoadQuestByID and listen for QUEST_DATA_LOAD_RESULT if the quest isn't already cached.

Returned table

Field Type Notes
questID number Echo of the input.
title string Locale-applied.
level number Quest level from the static record.
questType string | absent Localized tag string — "Elite", "Group", "PvP", "Dungeon", "Raid" — sourced from QuestInfo.dbc. Field is omitted for plain quests with no tag.
rewardMoney number Reward copper. 0 if the quest requires money instead.
requiredMoney number Copper the player must hand in to complete (e.g. quartermaster contributions). 0 for quests with no money requirement.
rewardMoneyAtMaxLevel number The level-60 reward bonus. Added to rewardMoney when the player is level 60; populated even on low-level quests.
rewardSpellID number spellID of a spell taught on completion (e.g. profession recipes), or 0 for no learned spell reward.
srcItemID number itemID the questgiver hands the player on accept (e.g. the sigil in "Verdant Sigil" — given on accept, read by the player, then turned back in). 0 = no source item. Same as the quest's requirements[].id for "give-then-return" quests.
questFlags number Raw QUEST_FLAGS_* bitfield — only bit 0x08 (sharable) is positively confirmed-tested by the engine; other bits are presumed-stored but unverified.
isSharable boolean Convenience extraction of bit 0x08 from questFlags.
description string The questgiver's narrative text. Raw — printf-style $N (player name), $C (class), $R (race) tokens are not substituted; use string.gsub if you need runtime values.
objectives string The "what you must do" summary text, same raw / un-substituted format as description.
completionText string The "now turn it in" text shown on the reward UI panel after objectives are met. Often empty for simple quests; populated for narrative-heavy ones. Also exposed as GetQuestLogCompletionText.
poi table | absent Point-of-interest marker ({mapID, x, y, opt}). Set on quests with a server-supplied "go here" location; omitted when mapID == 0.
rewardItems array of {id, count} Items the quest gives unconditionally on turn-in. Empty when there are no fixed rewards.
choiceItems array of {id, count} "Pick one" reward items. Empty when none.
requirements array of {kind, id, count, text} Objectives. kind is "monster" (creature), "object" (gameobject), or "item" (collect / interact). text is the questgiver's per-objective override (e.g. "Investigate the cave" instead of the auto-generated "Mor'shan Bear: 0/8"); empty string means use the auto-format. Order: NPC/GO objectives first, then item objectives, both 1-indexed. Only non-empty slots included.

Not included — race, class, skill, time limit, and suggested-player count. The SMSG_QUEST_QUERY_RESPONSE packet doesn't ship those fields; the server enforces them and filters quests before broadcasting, so the client only ever sees quests it could accept and never receives the static restriction values. Verified empirically: a race-restricted starter (3120 Verdant Sigil — Night Elf Druid only), a dungeon quest (914), and a timed delivery (3364 Scalding Mornbrew — 5-min authored timer) all have those cache slots zero-filled in memory. Addons that need that data must source it from an external scraped database like pfQuest's.

Field reliability: everything currently returned is either confirmed-correct via the engine's own Script_GetQuestLog* accessors or empirically verified against in-game quest semantics. The poi field is the only remaining hypothesis — its offsets are confirmed but no test quest has yet exercised it.

XP rewards aren't exposed — there is no GetQuestLogRewardXP, and per emulator-decoded packet structure, the server doesn't include an XP field in SMSG_QUEST_QUERY_RESPONSE. XP is computed server-side at turn-in from level-scaling tables.

Example

local f = CreateFrame("Frame")
f:RegisterEvent("QUEST_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
    if event == "QUEST_DATA_LOAD_RESULT" and arg2 == 1 then
        local d = C_QuestLog.GetQuestDetails(arg1)
        if d then
            print(d.title, "level", d.level, d.questType or "(no tag)")
            for _, r in ipairs(d.requirements) do
                print("  -", r.kind, r.id, "x", r.count)
            end
        end
    end
end)
C_QuestLog.RequestLoadQuestByID(1467)

Sample output for quest 1467 ("Reagents for Reclaimers Inc."):

Reagents for Reclaimers Inc.  level 40  (no tag)
  - item 6253 x 1

with d.choiceItems = {{id=6793, count=1}, {id=6794, count=1}}, d.rewardMoney = 3500, d.rewardMoneyAtMaxLevel = 1920, etc.

C_QuestLog.GetNumQuestObjectives(questID)

The quest's objective count — exactly the length GetQuestDetails(questID).requirements would have — read straight from the quest cache with no Lua tables or strings materialized. Returns nil when the quest isn't cached (same contract as GetQuestDetails).

local n = C_QuestLog.GetNumQuestObjectives(1467)   -- 1

Use this instead of GetQuestDetails when only the count matters — e.g. bounds-checking authored objective indices across every step of a guide at load time. GetQuestDetails copies the quest's description / objectives / completion text into Lua strings and builds ~20 table fields per call; harmless one at a time, but a login-path loop over hundreds of cached quests can put real pressure on the fixed-size Lua memory pool. This accessor costs a few dozen byte reads regardless of cache temperature.

C_QuestLog.IsQuestDataCachedByID(questID)

Returns true if the quest's static data is currently in the client-side quest cache, false otherwise. Pure cache probe — no server query, no side effects. Pair with C_QuestLog.RequestLoadQuestByID when you need to ensure the data is loaded before checking.

Returns false (not error) for invalid input — non-number, non-positive, or non-existent questIDs all yield false, matching the C_Item.IsItemDataCachedByID shape.

if not C_QuestLog.IsQuestDataCachedByID(questID) then
    C_QuestLog.RequestLoadQuestByID(questID)
    -- listen for QUEST_DATA_LOAD_RESULT, or re-check on a timer
end

GetQuestLogLeaderBoardID(objectiveIndex [, questIndex])

Companion to the stock GetQuestLogLeaderBoard — returns (id, kind) for the same objective the existing call formats text for. id is always positive (the raw RequiredNPCOrGo field is signed, but the sign is folded into kind instead); kind is one of "monster", "object", or "item". Returns nothing for: out-of-range objectiveIndex, header rows, quests whose static data hasn't been cached yet, or event-text-only objectives that have no entry ID.

Arg shape matches the engine's actual Script_GetQuestLogLeaderBoard (its usage string says (index) but the function also accepts the 2-arg form): with questIndex provided, looks up the entry at that 1-based quest-log slot; without it, falls back to whichever quest is currently selected via SelectQuestLogEntry.

-- Selected quest path (pairs with GetQuestLogLeaderBoard's selected mode)
SelectQuestLogEntry(13)
local text, kind, done = GetQuestLogLeaderBoard(1)
local id              = GetQuestLogLeaderBoardID(1)
-- text = "Young Stranglethorn Tiger slain: 4/10"
-- kind = "monster"
-- id   = 2630   (the creature template / entry)

-- Explicit-quest path
local text, kind = GetQuestLogLeaderBoard(1, 13)
local id, k2     = GetQuestLogLeaderBoardID(1, 13)
-- kind == k2 always

The ID is the same key creaturecache.wdb / gameobjectcache.wdb / itemcache.wdb use, so cross-referencing against the existing item or creature caches is direct — no localized-text parsing required.

Why not just expose this on GetQuestLogLeaderBoard itself? Modifying the engine's existing return tuple would silently break addons that destructure positionally (local text, type, done = GetQuestLogLeaderBoard(...)). A separate function keeps the existing call wire-compatible.

Source: the cached quest record (returned by Quest::Cache::Peek) carries the SMSG_QUEST_QUERY_RESPONSE objective arrays inline at fixed offsets:

  • +0x149C — int32 RequiredNPCOrGo[4] (positive = creature entry, negative = gameobject entry)
  • +0x14AC — uint32 RequiredNPCOrGoCount[4]
  • +0x14BC — uint32 RequiredItem[4]
  • +0x14CC — uint32 RequiredItemCount[4]

Iteration mirrors the engine at 0x004E0110: walk the NPC/GO array first, skip zero slots, then the item array. 1-based objectiveIndex counts only non-empty slots.

Sound

Every sound the client plays comes from one table of sound entries. Each entry has an id — a SoundKitID — and one or more audio files. The same ids the modern client uses are the ids here, so 8959 is the raid warning in both.

To see the file paths, extract them with the ExportSoundFiles console command.

PlaySound(soundKitID) / PlaySound(soundName)

Plays a sound.

  • Give a number and it plays that SoundKitID. It returns willPlay, soundHandle.
  • Give a string and it plays the sound entry with that name, exactly as before. It returns nothing.
PlaySound(8959)                  -- true, 394043692
PlaySound("igMainMenuOpen")      -- plays, returns nothing

The id is the only thing added here. For a channel, or to be told when the sound ends, use C_Sound.PlaySound below.

C_Sound.PlaySound(soundKitID [, channel, forceNoDuplicates, runFinishCallback])

Plays a SoundKitID, and takes only a number. Returns willPlay, soundHandle.

local willPlay, handle = C_Sound.PlaySound(8959)

willPlay is false when the sound cannot start. That happens when the file is muted, when the id names no entry, or when the category already has as many sounds as it allows.

An entry can hold several files. The client picks one of them, with the same weighting it uses for its own sounds.

Argument Meaning
channel The sound category, 0 to 12. Leave it out for 0, which is what the client uses for interface sounds and music. A channel name such as "SFX" is accepted and ignored: the categories here do not match those names.
forceNoDuplicates Accepted and ignored.
runFinishCallback Pass true to get the SOUNDKIT_FINISHED event when this sound ends.

C_Sound.PlaySoundWithOptions(params)

The same as C_Sound.PlaySound, with the arguments in a table. Returns success, soundHandle.

local ok, handle = C_Sound.PlaySoundWithOptions({
    soundKitID = 8959,
    volumeOverride = 0.4,
    runFinishCallback = true,
})
Field Meaning
soundKitID The sound to play.
volumeOverride Scales the sound's volume. 1 leaves it alone, 0 is silent. Leave it out to play at the sound's own volume.
runFinishCallback Pass true to get the SOUNDKIT_FINISHED event when this sound ends.
uiSoundSubType Accepted and ignored.
forceNoDuplicates Accepted and ignored.
overridePriority Accepted and ignored.

C_Sound.GetSoundScaledVolume(soundHandle)

Returns the volume a sound is playing at — its own volume after any scaling. Returns nil once the sound has ended.

local _, handle = C_Sound.PlaySoundWithOptions({ soundKitID = 8959, volumeOverride = 0.4 })
C_Sound.GetSoundScaledVolume(handle)

C_Sound.IsPlaying(soundHandle)

Returns true while the sound for that handle is still playing.

local _, handle = C_Sound.PlaySound(8959)
C_Sound.IsPlaying(handle)   -- true, until the sound ends

A handle is only meaningful while its sound plays. After the sound ends the client can give the same handle value to a later sound, so a handle you have held for a while can report true for a different sound. Read it soon after you get it.

C_Sound.PlayItemSound(soundType, item)

Plays the noise an item makes when you handle it, without doing the action.

C_Sound.PlayItemSound(Enum.ItemSoundType.Pickup, { bagID = 0, slotIndex = 1 })
C_Sound.PlayItemSound(Enum.ItemSoundType.Drop, { equipmentSlotIndex = 1 })

soundType is an Enum.ItemSoundType value:

Value Field
0 Pickup
1 Drop
2 Use
3 Close

item is an item location — { bagID = B, slotIndex = S } or { equipmentSlotIndex = N }. It also takes the other forms this documentation's item functions accept: an item GUID string, an itemID, an item link, or the name of an item you carry.

All four types are passed through to the client, which looks the sound up in its own item-sound data. With the data a stock client ships, only Pickup and Drop produce a sound: items are grouped into sound groups, and every group a real item belongs to fills in those two while leaving Use and Close empty. Those two are accepted and play nothing.

A server that fills in the missing entries gets them for free — nothing here limits the type, so Use and Close start playing as soon as the data defines them.

The sound comes from the item's display, so items that look alike sound alike. A sword sounds like metal and a robe sounds like cloth, whatever else differs between them.

Nothing plays, and nothing is raised, when the item cannot be found or its data has not arrived yet.

C_Sound.PlayVocalErrorSound(vocalErrorSoundID)

Plays your character's spoken complaint for an error — "my bags are full", "I have no ammo" — in the voice of their race and sex.

C_Sound.PlayVocalErrorSound(Enum.Vocalerrorsounds.Inventoryfull)
C_Sound.PlayVocalErrorSound(Enum.Vocalerrorsounds.Outofammo)

Enum.Vocalerrorsounds holds all 68 values, from Inventoryfull (0) to ExhaustedObsolete (67). Every one of them has recordings for the playable races.

Some combinations have no recording for one sex, and a few have none at all. Those play nothing, and raise nothing.

MuteSoundFile(file) / UnmuteSoundFile(file)

Stops a sound file from playing, and lets it play again.

MuteSoundFile("Sound\\Interface\\RaidWarning.wav")
PlaySound(8959)      -- false: silent
UnmuteSoundFile("Sound\\Interface\\RaidWarning.wav")
PlaySound(8959)      -- true: plays again

file is the path of the audio file, such as "Sound\\Creature\\Ragnaros\\RagnarosAggro01.wav". Upper and lower case do not matter, and you can use / in place of \\.

A mute applies to the file, so it covers every sound that uses it, whoever starts it. MuteSoundFile returns true when the path is now muted. UnmuteSoundFile returns true when the path had been muted.

Mutes last until you log out or reload. To keep them, save your list and apply it again on login.

C_Sound.GetRecentSoundFiles()

Returns the files that played most recently, newest first. Use it to find the path of a sound you just heard.

local recent = C_Sound.GetRecentSoundFiles()
-- recent[1] = { file = "Sound\\interface\\RaidWarning.wav",
--               time = 96036833, muted = false }

Each entry has three fields:

Field Meaning
file The path of the audio file. Pass it to MuteSoundFile.
time When it played, in milliseconds on GetTime()'s scale. GetTime() * 1000 - time is how long ago.
muted true when the file is muted.

The list holds the last 64 different files. A file that plays again moves to the front instead of taking a second place, so a repeating footstep cannot push the rest out.

Muted files stay in the list, marked muted = true, so you can still find one to un-mute.

The list covers every sound the client plays, not only the ones you start. Opening a bag or taking a step puts a file in it.

Spell

All C_Spell.* functions in this section accept any spell identifier, not just a numeric spellID. The first argument can be:

  • number — spellID, used directly.
  • |Hspell:N|h hyperlink — extracted by parsing for the embedded spell:N payload, so C_Spell.X(GetSpellLink(id)) round-trips cleanly.
  • spell name — case-sensitive lookup through the engine's name resolver (FUN_RESOLVE_SPELL_NAME_TO_BOOK_ID, the same chain CastSpellByName uses). Tolerates (Rank N) suffixes via the engine's parser. Bounded to spells in the player's known spellbook — not arbitrary Spell.dbc rows.

Unrecognized identifiers (garbage strings, nil, tables, etc.) return nil rather than raising a Lua usage error — the permissive C_Spell.* convention. Function entries below write (spellID) in the signature for brevity but the broader shape is accepted everywhere.

The legacy globals in this section (GetSpellInfo, IsPassiveSpell, IsHarmfulSpell, IsUsableSpell, etc.) keep their existing (slot, bookType) overload — only the C_Spell.* namespace is broadened.

C_Spell.DoesSpellExist(spellID)

Returns true if spellID resolves to a populated Spell.dbc record, false otherwise. Cheap pre-flight check for any of the spellID-keyed C_Spell.* calls — if this returns false, the rest of the family will return nil/empty.

if C_Spell.DoesSpellExist(133) then
    local info = C_Spell.GetSpellInfo(133)  -- safe
end

C_Spell.GetSchoolString(schoolMask)

Returns the localized name for a damage-school bitmask. Resolves single-bit masks via the engine's SPELL_SCHOOL<n>_CAP global strings (SPELL_SCHOOL0_CAP = "Physical", SPELL_SCHOOL2_CAP = "Fire", etc.).

C_Spell.GetSchoolString(4)    -- "Fire"
C_Spell.GetSchoolString(32)   -- "Shadow"

Spells here are single-school — there are no multi-school combos ("Frostfire", "Shadowflame", "Spellstorm", etc.). Any multi-bit mask returns "Unknown", the documented fallback for unnamed combinations.

Lua 5.0 doesn't support hex literals (0x04 is a syntax error); pass mask values as decimals, or via tonumber("0x04", 16) if you want to keep hex notation in source.

Decimal Hex School
1 0x01 Physical
2 0x02 Holy
4 0x04 Fire
8 0x08 Nature
16 0x10 Frost
32 0x20 Shadow
64 0x40 Arcane

GetSpellInfo(spellID) / GetSpellInfo(slot, bookType)

Returns nine values, plus a 10th value: spellID, for any spell ID — including spells the player has not learned. The stock client has no GetSpellInfo Lua function at all (only GetSpellName/GetSpellTexture, both of which take a spellbook slot rather than an ID), so addons that need spell metadata for arbitrary IDs (raid frames, debuff trackers, aura libraries) currently can't get it.

Returns name, rank, icon, cost, isFunnel, powerType, castTime, minRange, maxRange, spellID. All read directly from Spell.dbc (with SpellIcon.dbc, SpellCastTimes.dbc, and SpellRange.dbc for the indirected fields). Cast time is in milliseconds; ranges are floats in yards. isFunnel is a real boolean (true/false). Returns nil if the spell ID is out of range.

Four input forms are accepted:

  • GetSpellInfo(spellID) — direct DBC lookup by ID.
  • GetSpellInfo(slot, bookType) — same shape as the stock GetSpellName(slot, bookType). slot is 1-based, bookType is "spell" (player) or "pet". The slot is resolved to a spellID via the engine's spellbook array, then the same DBC reads run. Returns nil for empty / out-of-range slots.
  • GetSpellInfo("name") — looks the name up in the player's, then the pet's, spellbook and returns the highest rank you know. The match is exact and case-sensitive. The name must be a spell you have. A name you do not know returns nil (it does not raise an error). It is not a database-wide search — a name shared by many ranks or by NPC spells has no single answer, so only your own spellbook is used.
  • GetSpellInfo("name(Rank N)") — a rank in parentheses pins that exact rank instead of the highest, the same SpellName(Rank N) form CastSpellByName accepts. A space before the parenthesis is allowed ("Mind Blast (Rank 8)"). A rank you do not know returns nil.
  • GetSpellInfo("|Hspell:ID|h[Name]|h") — a spell hyperlink. The spellID inside the link is used directly, so this works for any spell, learned or not.
local name, rank, icon, _, _, _, _, _, _, spellID = GetSpellInfo(133)
-- name="Fireball", rank="Rank 1", icon="Spell\\Fire\\...", spellID=133

-- spellbook overload
local _, _, _, _, _, _, _, _, _, id = GetSpellInfo(1, "spell")
-- id is the spellID at player spellbook slot 1

-- by name (highest rank you know), a specific rank, and a link
local name = GetSpellInfo("Fireball")
local _, rank = GetSpellInfo("Mind Blast (Rank 8)")  -- rank="Rank 8"
local _, _, _, _, _, _, _, _, _, id = GetSpellInfo(GetSpellLink(133))

The same name and link forms work for GetSpellLink, IsPassiveSpell, IsHarmfulSpell, and IsHelpfulSpell, which share this resolver.

Note on the 10th return. We kept the existing 9 returns (so addons that worked against the previous signature still work) and just appended spellID at position 10.

C_Spell.GetSpellInfo(spellID)

Table-style accessor for the same data. Returns a Lua table of the spell's metadata, or nil if the spell ID is out of range.

Table fields:

Field Type Notes
name string Localized name
iconID string Icon path (e.g. "Interface\\Icons\\Spell_Fire_FlameBolt"). See note below.
castTime number Base cast time in milliseconds, or 0 for instant
minRange number Yards, or 0 if not applicable
maxRange number Yards, or 0 if not applicable
spellID number Echo of the input
rank string Localized rank (e.g. "Rank 1") — an extra field
cost number Base ManaCost — an extra field
isFunnel boolean True for funnel-channeled spells — an extra field
powerType number 0=mana, 1=rage, 2=focus, 3=energy, 4=happiness — an extra field
local info = C_Spell.GetSpellInfo(133)
-- info.name = "Fireball"
-- info.iconID = "Interface\\Icons\\Spell_Fire_FlameBolt"
-- info.castTime = 1500
-- info.spellID = 133
-- info.rank = "Rank 1"
-- ... etc.

iconID is a path string. This value can be a fileID:number elsewhere. There is no fileID system here — assets are referenced by path strings. We surface the icon path so it's directly usable with texture:SetTexture(info.iconID). If you're backporting code that expects a number, this is the field to adjust.

Extra fields. The four fields beyond the base spec (rank/cost/isFunnel/powerType) are present because they're in Spell.dbc. Including them costs nothing.

C_Spell.GetSpellName(spellID)

Returns the localized name of spellID, or nil if the spell ID is out of range or has no name in the current locale. Convenience accessor for the name field of C_Spell.GetSpellInfo when that's all you need — single field read, no DBC indirection beyond the locale lookup.

local name = C_Spell.GetSpellName(133)  -- "Fireball"

C_Spell.GetSpellTexture(spellID)

Returns the icon path string for spellID (read from SpellIcon.dbc via the spell's SpellIconID field), or nil if the spell ID is out of range or the icon record is empty.

Path string, not fileID. This value can be a fileID:number elsewhere. There is no fileID system here — see the same note on C_Spell.GetSpellInfo's iconID field. Pass directly to texture:SetTexture(...).

local path = C_Spell.GetSpellTexture(133)
-- path = "Interface\\Icons\\Spell_Fire_FlameBolt"

GetSpellLink(spellID) / GetSpellLink(slot, bookType)

Returns the chat-style spell hyperlink and the spellID:

link, spellID = GetSpellLink(spellID)
              = GetSpellLink(slot, bookType)

Format is |cff71d5ff|Hspell:ID:0|h[Name]|h|r — the standard 1.12 spell-link wrapper. The trailing :0 after the spellID matches the extended hyperlink shape (where the field is a sub-data slot for pet-spellbook flags etc.); the engine ignores it during link parsing, but addons grepping with |Hspell:(%d+): patterns will pick it up correctly.

Two input forms, mirroring GetSpellInfo:

  • GetSpellLink(spellID) — direct DBC lookup.
  • GetSpellLink(slot, bookType) — resolves the spellbook slot to a spellID first. Useful when iterating the player's known spells: caller gets back both the link AND the underlying ID without a separate lookup.

Returns nil if the spellID/slot doesn't resolve to a real spell.

local link = GetSpellLink(133)
DEFAULT_CHAT_FRAME:AddMessage("Cast " .. link .. "!")

-- Walking the spellbook to print every learned spell:
for slot = 1, 1000 do
    local link, id = GetSpellLink(slot, "spell")
    if not link then break end
    -- ...
end

C_Spell.GetSpellLink(spellID)

Table-namespace variant. Same link string as GetSpellLink(spellID), but returns only the link — no spellID echo since the caller already had it on hand to make the call.

local link = C_Spell.GetSpellLink(133)  -- "|cff71d5ff|Hspell:133:0|h[Fireball]|h|r"

C_Spell.GetSpellDescription(spellID)

Returns the formatted spell description for any spellID — including spells the player has not learned. $s1/$s2/$o1/$d-style placeholders are resolved to base-rank values; ranges and durations appear as actual numbers (e.g. "14 to 22 Fire damage", not "$s1 to $s2 Fire damage"). Returns nil if the spell ID is out of range or has no description in the current locale.

The 1.12 client doesn't expose this from Lua at all — the only existing path is the scan-tooltip hack (set a hidden tooltip via SetHyperlink("spell:"..ID) and read each TextLeftN:GetText() line). That's slow, GC-heavy, and shares the global scan tooltip with every other addon. This function calls the engine's own description formatter directly — same code path the in-game tooltip uses, no UI side effects.

local desc = C_Spell.GetSpellDescription(133)  -- Fireball Rank 1
-- "Hurls a fiery ball that causes 14 to 22 Fire damage and an additional
--  2 Fire damage over 4 sec."

No caster scaling. Values reflect the spell's base rank — caster level / spell power / talents are not applied, the same as calling it outside a unit context. If you need the "currently displayed" tooltip text with caster scaling, use GameTooltip:SetSpellByID and read line strings from there.

C_Spell.GetSpellMechanicByID(spellID)

Returns the spell's mechanic as (mechanicID, name) — the standard WoW SpellMechanic ID and its English name. Works for any spellID, not just spells the player has learned.

Returns:

  • nothing (nil) if the spell ID is invalid / out of range
  • (0, nil) for a known spell that carries no mechanic
  • (mechanicID, name) otherwise

The name is always the enUS string (locale-independent), because the mechanic name is a stable token consumers string-match against ("rooted", "stunned", "polymorphed", …) — returning a localized value would break that matching across clients. It is nil only when the mechanic has no SpellMechanic.dbc record or no English name; the numeric ID is still returned in that case.

C_Spell.GetSpellMechanicByID(118)  -- Polymorph        → 17, "polymorphed"
C_Spell.GetSpellMechanicByID(339)  -- Entangling Roots  → 7,  "rooted"
C_Spell.GetSpellMechanicByID(133)  -- Fireball          → 0,  nil

This reads Spell.dbc's Mechanic field and resolves the name from SpellMechanic.dbc directly — both DBCs are resident from boot, so the call is synchronous with no caching or network round-trip (unlike item data). It replaces hand-maintained spellID → mechanic lookup tables that addons otherwise keep because there is no Lua reader for the field — e.g. crowd-control macro conditionals ([cc:stun], [cc:fear]) that need to know which mechanic a debuff applies.

The complete SpellMechanic.dbc set on 1.12 — all 27 rows (the name column is exactly what this function returns):

ID name ID name ID name
1 charmed 10 asleep 19 shielded
2 disoriented 11 ensnared 20 shackled
3 disarmed 12 stunned 21 mounted
4 distracted 13 frozen 22 seduced
5 fleeing 14 incapacitated 23 turned
6 clumsy 15 bleeding 24 horrified
7 rooted 16 healing 25 invulnerable
8 pacified 17 polymorphed 26 interrupted
9 silenced 18 banished 27 dazed

No mechanic 30. The table tops out at 27. Sap and Gouge report 14 (incapacitated) — the 30 ("sapped") value used by some addon tables has no row here.

C_Spell.GetSpellEffectInfo(spellID)

Returns one table per spell effect, holding that effect's raw Spell.dbc fields, as a 1-based array of three. nil for an invalid or out-of-range spell ID.

effects = C_Spell.GetSpellEffectInfo(spellID)

Each entry holds:

Field Meaning
effect The SPELL_EFFECT_* code. 0 when the slot is unused.
auraName Which aura the effect applies. 0 when it applies none.
basePoints The effect's stored magnitude.
baseDice Added to basePoints for the fixed magnitude.
dieSides Size of the magnitude's random range.
pointsPerLevel Magnitude gained per level. Fractional.
dicePerLevel Random range gained per level. Fractional.
miscValue The effect's parameter, such as which stat a stat-modifying aura applies to.

All three entries are always present. An unused effect slot has every field 0.

local fx = C_Spell.GetSpellEffectInfo(1243)  -- Power Word: Fortitude
-- fx[1].auraName == 29, fx[1].basePoints == 2, fx[1].baseDice == 1
-- so the effect grants 3 -- the +3 Stamina the tooltip shows

Working out an effect's magnitude. For most auras dieSides is 1 or 0 and pointsPerLevel is 0, and then the magnitude is simply basePoints + baseDice. The general form also scales with the caster's level, which needs the spell's level fields from GetSpellLevelInfo:

local spellLevel, _, maxLevel = C_Spell.GetSpellLevelInfo(spellID)
local cap = maxLevel > 0 and maxLevel or UnitLevel("player")
local level = math.max(spellLevel, math.min(UnitLevel("player"), cap)) - spellLevel

local points = fx.basePoints + level * fx.pointsPerLevel
local random = fx.dieSides + level * fx.dicePerLevel
local value = points + fx.baseDice   -- only valid while random <= 1

A dieSides above 1 means the magnitude is rolled. The value is picked when the spell is cast and is not available here, so treat such an effect as having no single magnitude rather than reporting the low end of its range.

The fields are the stored ones, so a magnitude worked out from them also leaves out what the caster brings to a cast: combo points, talent and gear modifiers, and the extra level scaling some spells are flagged for. Expect it to match a tooltip for a plain aura and to fall short for a damage or healing figure.

Reads Spell.dbc directly, so it covers every spell the client knows, not just the player's spellbook, with no caching or waiting on the server.

C_Spell.GetSpellEffectMechanics(spellID)

Returns the spell's three per-effect SpellMechanic ids (Spell.dbc EffectMechanic[3]) as a 1-based array table, or nil for an invalid / out-of-range spell ID. Each entry uses the same numbering as GetSpellMechanicByID (0 = that effect carries no mechanic).

C_Spell.GetSpellEffectMechanics(1822)  -- Rake  → { 0, 15, 0 }
C_Spell.GetSpellEffectMechanics(703)   -- Garrote → { 0, 0, 0 }
C_Spell.GetSpellEffectMechanics(133)   -- Fireball → { 0, 0, 0 }

Complements GetSpellMechanicByID, which reads only the spell-level Mechanic field (+0x14). A spell's mechanic is frequently stored on an effect instead — periodic damage such as bleeds is the common case. Garrote/Rupture/Rend/Rip tag bleeding (15) at the spell level, but Rake has spell-level 0 and EffectMechanic[2] = 15, so effect-mechanic-aware callers (e.g. bleed classification for immunity tracking) need this array:

local function IsBleed(spellID)
    if C_Spell.GetSpellMechanicByID(spellID) == 15 then return true end
    local em = C_Spell.GetSpellEffectMechanics(spellID)
    if em then for i = 1, 3 do if em[i] == 15 then return true end end end
    return false
end

Reads Spell.dbc directly (EffectMechanic[3] at +0x13C), so it covers every spell the client knows — not just the player's spellbook — with no caching or network round-trip.

C_Spell.GetSpellDispelType(spellID)

Returns the spell's dispel type from Spell.dbc: 1 = Magic, 2 = Curse, 3 = Disease, 4 = Poison. Returns 0 for a spell that nothing can dispel, and for an unknown spell.

C_Spell.GetSpellDispelType(118)   -- Polymorph → 1 (Magic)
C_Spell.GetSpellDispelType(980)   -- Curse of Agony → 2 (Curse)
C_Spell.GetSpellDispelType(133)   -- Fireball → 0 (none)

Use the number to show what removes an effect, or to filter auras by dispel type. The values match the DISPEL_* set the server uses.

C_Spell.GetSpellRadius(spellID) / GetSpellRadius(slot, bookType)

Returns (baseRadius, modifiedRadius) — the spell's AOE radius in yards, or nil for a non-AOE spell (no effect carries a radius). Works for any spell, not just ones the player has learned.

  • baseRadius — the raw SpellRadius.dbc value; caster-independent, what an AOE tracker watching other units wants.
  • modifiedRadius — baseRadius with the local player's talent / item modifiers applied (e.g. a Mage's Arctic Reach). Equal to baseRadius when the player has no matching modifier.
C_Spell.GetSpellRadius(122)    -- Frost Nova → 10, 10  (no Arctic Reach)
C_Spell.GetSpellRadius(122)    -- Frost Nova → 10, 12  (with Arctic Reach r2)
C_Spell.GetSpellRadius(133)    -- Fireball   → nil      (single-target)
GetSpellRadius(10, "spell")    -- 10th player-book slot, by spellbook position

Two signatures matching the dual-signature shape used elsewhere in this backport (GetSpellInfo, SpellHasRange, …): the namespaced form takes a spell identifier (numeric ID or name); the bare global takes the (slot, bookType) spellbook position, where bookType follows the GetSpellName convention — "pet" → pet book, "spell" (or any non-pet / omitted value) → player book.

baseRadius reads Spell.dbc's per-effect EffectRadiusIndex[3] (+0x160) and resolves each into SpellRadius.dbc; since a spell's effects can each carry their own radius, the largest base radius across the three effects is returned.

No caster-level scaling. The engine's internal radius helper also adds radiusPerLevel * casterLevel, but that needs a unit context; this reports the base value. Talent/item modifiers (the modifiedRadius return) are applied, via the engine's SpellMod system — but only the local player's, since the client tracks spell mods for the player alone (there's no way to know another caster's talents). All DBCs / mod tables are resident from boot, so the call is synchronous with no caching.

C_Spell.GetSpellPowerCost(spellIdentifier)

Returns an array of SpellPowerCostInfo tables, or nil if the spell isn't found or has no resource cost. Spells have exactly one power cost, so the array holds at most one entry.

C_Spell.GetSpellPowerCost(10187)   -- Blizzard, Mage with 3/3 Frost Channeling
-- { [1] = { type = 0, name = "MANA", cost = 1190, minCost = 1190,
--           costPercent = 0, costPerSec = 0, requiredAuraID = 0,
--           hasRequiredAura = false } }
-- base cost 1400, 15% reduction applied -> 1190
Field Meaning
type Enum.PowerType — 0 mana, 1 rage, 2 focus, 3 energy, 4 happiness (the spell's PowerType)
name power token ("MANA", "RAGE", …)
cost effective cost for the local player
minCost == cost (no optional-cost component)
costPercent the spell's %-of-base-resource cost, or 0 for a flat cost
costPerSec 0 (no per-second channel cost in these terms)
requiredAuraID 0 (no form/aura-conditional costs)
hasRequiredAura false

cost is the value the engine actually charges — base + level scaling + ManaCostPercent-of-resource + descriptor power-cost mods + the cost SpellMod (talents like Frost Channeling). It comes from the engine's own cost helper (FUN_006e31b0), so it stays in lockstep with what casting the spell deducts.

Effective vs. base. This returns the player-modified cost. C_Spell.GetSpellInfo's cost field is the base ManaCost (intrinsic spell data, caster-independent) — same base/modified split as GetSpellRadius.

C_Spell.GetSpellReagents(spellID)

Returns the spell's reagent list as an array of { itemID = N, count = M } tables, one entry per non-empty reagent slot. Returns an empty array {} for spells that consume no reagents, or nil for invalid spell IDs.

local list = C_Spell.GetSpellReagents(23028)  -- Arcane Brilliance
-- list = { { itemID = 17020, count = 1 } }   -- 1× Arcane Powder

-- Crafting recipe:
local list = C_Spell.GetSpellReagents(2538)
-- Cooking-style: { { itemID = ..., count = ... }, ... }

Reads directly from Spell.dbc at the documented reagent offsets (+0xA8 for itemIDs, +0xC8 for counts — verified empirically from the engine's tooltip-builder; the CMaNGOS-style schema documented in some emulator sources places these at +0x110 / +0x130, which is wrong for 1.12.1). Iteration stops at the first empty slot, matching how the engine walks its own reagent loop.

C_Spell.GetSpellCastCount(spellIdentifier)

Returns how many times the player can cast the spell with the reagents they carry. Returns 0 for a spell without reagents, and for an unknown spell.

C_Spell.GetSpellCastCount(23028)  -- Arcane Brilliance: your Arcane Powder count
C_Spell.GetSpellCastCount(133)    -- Fireball: 0 (no reagents)

The count is the smallest floor(carried / required) over the spell's reagents. "Carried" means equipped items and bags 0 to 4. Reagents in the bank do not count, because a cast cannot use them.

spellIdentifier accepts a spellID, a spell name, or a spell link, like C_Spell.GetSpellCooldown.

C_Spell.GetSpellSubtext(spellIdentifier)

Returns the localized "Rank N" / "Passive" / "Racial Passive" / similar string that appears below the spell's name in the spellbook. Read directly from Spell.dbc.Rank[9] at record +0x204 (same locale array shape as Name[9] two fields prior).

Accepts the full SpellIdentifier shape — spellID, name, name with rank ("Fireball(Rank 3)"), or |Hspell:N|h hyperlink. Returns nil for unrecognized input or spells with no rank text.

C_Spell.GetSpellSubtext(133)        -- "Rank 1"   (lowest-rank Fireball)
C_Spell.GetSpellSubtext(25306)      -- "Rank 12"  (max-rank Fireball)
C_Spell.GetSpellSubtext("Frost Armor")  -- "Rank 1"

IsPassiveSpell(spellID) / IsPassiveSpell(slot, bookType)

Returns true if the spell is passive (no cast bar — applies its effect as soon as it's learned/equipped), false otherwise. Returns nil for invalid spell IDs / out-of-range slots.

Reads Attributes bit 6 (SPELL_ATTR_PASSIVE = 0x40) directly off the Spell.dbc record. Used by aura libraries and talent code to decide whether a spell needs cast-bar tracking.

IsPassiveSpell(6603)   -- true  (Auto Attack)
IsPassiveSpell(133)    -- false (Fireball)

-- spellbook overload mirrors GetSpellInfo's
IsPassiveSpell(1, "spell")   -- true/false depending on slot 1

C_Spell.IsSpellPassive(spellID)

Table-namespace form of IsPassiveSpell. Same return semantics; doesn't accept the legacy (slot, bookType) shape.

C_Spell.IsSpellPassive(6603)   -- true (Auto Attack)

IsPlayerSpell(spellID)

Returns true if the player currently knows the given spellID, false otherwise. Covers everything granted by SMSG_LEARNED_SPELL:

  • Trained class abilities (the obvious case)
  • Racial abilities
  • Talent passives the player has invested in
  • Profession recipes — including ones from vendors / discovered via tradeskill — without needing to have the trade skill window open
  • Anything else the engine considers "known"
IsPlayerSpell(133)     -- true if you have Fireball
IsPlayerSpell(2963)    -- true for a tailor who knows Bolt of Linen
                       --   (works without opening the Tailoring window)

Only the current rank counts. For ranked spells (passives, trained ranks), only the spellID of the player's current rank returns true — lower-rank IDs return false even though the player conceptually "has" them: a player with 5/5 Unbreakable Will sees IsPlayerSpell(14791) (rank 5) as true but rank 4 / rank 3 / etc. as false.

This is by design — the engine's spell-knowledge bitmap stores one bit per spellID, and the highest-rank spellID is the one set when you train up. To check "do I have at least rank N", use the rank-N spellID specifically.

Reads a single bit from the engine's spell-knowledge bitmap at [0x00B710FC] — (bitmap[spellID >> 5] & (1 << (spellID & 31))) != 0. The same lookup the engine itself does internally. No spellbook walk, no talent walk, no profession-window dependency.

CanDualWield()

Returns true if the player can equip a weapon in the off hand.

if CanDualWield() then ... end   -- true for a warrior/rogue past level 10, etc.

Server-side this is a plain flag (Player::m_canDualWield) that starts false and is turned on only by the SPELL_EFFECT_DUAL_WIELD spell effect — no class has it innately. Exactly one spell in the 1.12 client's Spell.dbc carries that effect: 674 ("Dual Wield"), the trained warrior / rogue / hunter passive, which lands in the known-spell bitmap like any learned spell. So CanDualWield() is precisely IsPlayerSpell(674) — there is no separate client-visible capability field to read.

IsSpellKnown(spellID, [isPet])

Returns true if the given spellID is currently in the player's (or pet's) spellbook arrays — the same source the in-game spellbook UI displays. Strict semantics: only counts spells that have a spellbook button. isPet defaults to false.

IsSpellKnown(2050)         -- true if Lesser Heal is trained
IsSpellKnown(2649, true)   -- true if your hunter pet has Growl
IsSpellKnown(133)          -- false on a Priest (Fireball is a Mage spell)

Not the same as IsPlayerSpell. These two are deliberately split: IsSpellKnown is the strict "in the spellbook UI" check, IsPlayerSpell is the broad "any kind of known" query. The split matters because:

Spell type IsPlayerSpell IsSpellKnown
Trained class ability (Fireball) true true
Active talent grant (Mortal Strike) true true
Passive talent (Wand Spec, Imp Fireball) true false
Profession recipe (Bolt of Linen) true false

Use IsPlayerSpell for "do I have access to this effect at all"; use IsSpellKnown for "is this in my spellbook so I can put it on an action bar".

Implementation walks VAR_PLAYER_SPELLBOOK (0x00B700F0) when isPet=false or VAR_PET_SPELLBOOK (0x00B6F098) when isPet=true — a direct spellbook walk.

GetSpellBonusDamage(school)

Returns the local player's flat spell-damage bonus (spell power) for a given magic school, as a number. school is 1-based: 1 Physical, 2 Holy, 3 Fire, 4 Nature, 5 Frost, 6 Shadow, 7 Arcane. Raises a usage error for a missing / out-of-range school; returns 0 before you're in the world.

GetSpellBonusDamage(3)   -- your +Fire spell damage
GetSpellBonusDamage(6)   -- your +Shadow spell damage

GetSpellBonusHealing()

Returns the local player's flat healing bonus, as a number.

GetSpellBonusHealing()   -- your +Healing

Damage is an exact field read; healing is derived.

GetSpellBonusDamage reads the client's fully-computed value directly from the CGPlayer sub-struct (PLAYER_FIELD_MOD_DAMAGE_DONE_POS − _NEG per school). That field isn't in the broadcast descriptor — the server never sends it there, which is why it looks absent — but the client keeps a computed copy for its own use, with gear, enchants, buffs, talents, and set bonuses all baked in. So the value is exact and complete. (Same source nampower's GetSpellPower reads.)

GetSpellBonusHealing has no such field — there is no healing-done field at all (confirmed in-game: toggling a pure +healing item moved no field in the player struct) — so it's derived in two parts, both from Spell.dbc: (1) flat healing = the sum of every SPELL_AURA_MOD_HEALING_DONE off gear equip-effects, item enchants (permanent + weapon oils), random suffixes, and active buffs; plus (2) talent/buff stat-conversions, mirroring the server's own SpellBaseHealingBonusDone — the sum of SPELL_AURA_MOD_SPELL_HEALING_OF _STAT_PERCENT (aura 175) × total Spirit / 100 over your passive talents (Priest Spiritual Guidance) and active buffs. Part 2 reads the talent percent directly, so it's generic (any aura-175 talent/buff, including Turtle customs) and exact — e.g. Spiritual Guidance 5/5 with 182 Spirit adds int(182 × 25 / 100) = 45.

Residual: assumes talent ranks supersede (only the learned rank is known) and misses set-bonus healing granted via a set-completion spell.

IsUsableSpell(spell) / IsUsableSpell(slot, bookType)

Returns (usable, noMana) for a spell. The result is the same verdict that IsUsableAction gives for an action slot that holds this spell, read live. Returns (1, nil) when you can cast the spell now, (nil, 1) when only power stops you, and (nil, nil) for any other block. The 1/nil pairs match IsUsableAction.

Two argument shapes:

  • IsUsableSpell(spellID) — a spellID. A spell that you do not know returns (nil, nil). A pet spell given by ID (Growl, Cower) is tested for the pet.
  • IsUsableSpell(slot, bookType) — a spellbook slot. bookType is "spell" for the player book or "pet" for the pet book.
IsUsableSpell(133)           -- Fireball: 1, nil with mana; nil, 1 without
IsUsableSpell(1, "spell")    -- player spellbook slot 1
IsUsableSpell(1, "pet")      -- pet spellbook slot 1

What the result includes. Every condition that greys an action button:

  • The player is alive, unless the spell is castable while dead.
  • The player has control. When control is lost (fear, charm), only spells that work in that state pass.
  • The required stance or form. Whirlwind in Battle Stance is not usable.
  • Reagents and totems in the bags.
  • An equipped weapon of the required type, and ammo for ranged spells.
  • Combo points for finishers.
  • Stealth-only and out-of-combat-only spells.
  • Aura states on the player and on the current target.
  • A toggle spell that is already active (Stealth while stealthed) is not usable.
  • Power. The cost of the spell, with your talents applied, against your current power. This is the only test that sets noMana.

What the result does not include. Cooldown, silence, and school lockouts. An action button greys for usability and swipes for cooldown as two separate states. This function keeps that split. Read cooldown with C_Spell.GetSpellCooldown and lockouts with C_LossOfControl.GetActiveLossOfControlData.

For a pet spell, the test is the current power of the pet against the spell cost, after the pet can act (not stunned, feared, or confused).

C_Spell.IsSpellUsable(spellID)

Table-namespace form. Same result as IsUsableSpell(spellID), but returns booleans (isUsable, insufficientPower) per the C_Spell.* convention. Accepts a spellID, a spell link, or a spell name.

local usable, noMana = C_Spell.IsSpellUsable(133)
-- usable=true, noMana=false  → cast it
-- usable=false, noMana=true  → drink up
-- usable=false, noMana=false → unknown spell, wrong stance, dead, or another block

C_Spell.GetSpellCooldown(spellIdentifier)

Returns a SpellCooldownInfo table for the given spell, or nil if the identifier doesn't resolve to a Spell.dbc row. Table-shape variant of the stock GetSpellCooldown(slot, bookType) — accepts a spellID directly without forcing the caller to resolve a spellbook slot first.

local info = C_Spell.GetSpellCooldown(1953)   -- Blink
-- on cooldown:   { startTime=728565.79, duration=15, isEnabled=true, modRate=1 }
-- off cooldown:  { startTime=0,         duration=0,  isEnabled=true, modRate=1 }
Field Type Notes
startTime number Engine tick count (seconds) when the cooldown began. Same epoch as GetTime(), so info.startTime + info.duration is the absolute time the cooldown ends. 0 when no cooldown active.
duration number Cooldown length in seconds; 0 when no cooldown active.
isEnabled boolean false when the cooldown is "on hold" (e.g. some channeled abilities). true for normal cooldowns.
modRate number Always 1.0 — there is no haste-on-cooldown mechanic.

The activeCategory, timeUntilEndOfStartRecovery, and isOnGCD fields read as nil since they have no source here.

Returns nil if the resolved spellID is 0 or doesn't have a Spell.dbc row.

C_Spell.GetSpellLossOfControlCooldown(spellIdentifier)

Returns startTime, duration for the loss-of-control effect that stops the player from casting this spell right now. Both values are seconds on the GetTime() clock, like C_Spell.GetSpellCooldown. Both are 0 when nothing blocks the spell. Returns nil for an unknown spell.

local start, duration = C_Spell.GetSpellLossOfControlCooldown(133)  -- Fireball
if duration > 0 then
    cooldownFrame:SetCooldown(start, duration)  -- the loss-of-control swipe
end

Which effects block which spells:

Effect Blocks
School lockout (Counterspell, Kick, ...) Spells of the locked school.
Stun, fear, confuse, charm, possess Every spell.
Silence Spells that a silence prevents (most casts).
Pacify Abilities that a pacify prevents (most melee and ranged abilities).
Root, disarm Nothing.

The function reports only effects with a known end time. See the timing note in LossOfControl: a school lockout always has one, and a control effect has one when ClassicAPI saw the cast that applied it. When only the end time is known, startTime is the current time and duration is the remaining time. When several effects block the spell, the function reports the one that ends last.

C_Spell.IsCurrentSpell(spellIdentifier)

Returns true if the spell is currently being cast, queued mid-GCD, or channeled by the player; false otherwise (including for unresolvable identifiers).

C_Spell.IsCurrentSpell(11366)              -- true while casting Pyroblast
C_Spell.IsCurrentSpell("Pyroblast")        -- same, via name
C_Spell.IsCurrentSpell(GetSpellLink(7620)) -- true while channeling Fishing

Useful for action-bar addons that want to highlight the active / queued button — action bars gate the "currently casting" glow on this. Reads three engine slots and returns true on any match:

  • VAR_CURRENT_CAST_SPELL (0x00CECA88) — cast-bar spellID. Written by FUN_006E4AD0 when a new cast begins; cleared on cast end.
  • VAR_QUEUED_CAST_SPELL (0x00CECAA8) — spell that was active when a new cast superseded it mid-GCD. Restored to current when the new cast ends, so checking it here covers the "queued to cast next" half of the documented semantics.
  • UNIT_FIELD_CHANNEL_SPELL (descriptor +0x228) on the player — covers channeled abilities (Fishing, Drain Soul, Ritual of Summoning, etc.).

C_Spell.IsSelfBuff(spellID)

Returns true if every active effect on the spell targets only the caster — every effect's implicit target (A and B) must be either TARGET_NONE (0) or TARGET_SELF (1).

C_Spell.IsSelfBuff(1006)   -- Inner Fire        → true
C_Spell.IsSelfBuff(7302)   -- Frost Armor       → true
C_Spell.IsSelfBuff(1459)   -- Arcane Intellect  → false (targets friendly)
C_Spell.IsSelfBuff(133)    -- Fireball          → false (targets enemy)

Walks Spell.dbc.EffectImplicitTargetA[3] and EffectImplicitTargetB[3]. Spells with no active effects (a degenerate case for normal content) return false.

C_Spell.SpellHasRange(spellIdentifier) / SpellHasRange(slot, bookType)

Returns true if the spell has a non-zero min or max range — i.e. it's a targeted ability you cast at a unit / location rather than a self-cast (which has range 0).

C_Spell.SpellHasRange(133)    -- Fireball       → true (40yd)
C_Spell.SpellHasRange(1006)   -- Inner Fire     → false (self buff)
SpellHasRange(10, "spell")    -- 10th spellbook slot in player book

Looks up Spell.dbc.RangeIndex (+0x90) → SpellRange.dbc row, then tests minRange > 0 or maxRange > 0. The stock client doesn't ship this function. We expose both the namespaced form (any SpellIdentifier) and a positional SpellHasRange(slot, bookType) matching the dual-signature shape used elsewhere in this backport (GetSpellInfo, GetSpellLink, etc.). The bookType argument follows the same convention as GetSpellName(slot, bookType): "spell" (or any non-pet value) → player book, "pet" → pet book.

C_Spell.IsSpellInRange(spellIdentifier, targetUnit)

Returns true if the spell is in range of targetUnit, false if out of range, and nil when the range check doesn't apply — a rangeless spell (self buff), or an unresolvable spell / unit.

spellIdentifier is a spellID, spell link, or the name of a spell in the player's spellbook. targetUnit is a unit token.

C_Spell.IsSpellInRange(133, "target")      -- Fireball → true / false
C_Spell.IsSpellInRange("Fireball", "target")
C_Spell.IsSpellInRange(1006, "target")     -- Inner Fire (self) → nil

Uses the engine's own geometric range core (the same one the action button range-out coloring uses via IsActionInRange): it derives the spell's min/max range — folding in the target's bounding radius — and compares center-to-center distance, so the boundary matches the client exactly, melee and min-range ("too close") spells included.

Range-only: it ignores line of sight, and it does not reject wrong-faction targets (a friendly-only heal still returns a range answer against an enemy). Check target validity separately if you need it. Absent tokens (e.g. "target" with no target) return nil; a genuinely unrecognized token string raises a Lua error, same contract as UnitHealth("garbage").

C_Spell.IsAutoAttackSpell(spellID)

Returns true if the spell is the melee auto-attack — spell ID 6603 ("Auto Attack"), used by every class.

C_Spell.IsAutoAttackSpell(6603)              -- true
C_Spell.IsAutoAttackSpell(133)               -- false

See C_SpellBook.IsAutoAttackSpellBookItem for the spellbook-slot variant.

C_Spell.IsRangedAutoAttackSpell(spellID)

Returns true if the spell is one of the two ranged auto-attacks — spell 75 (Hunter "Auto Shot") or spell 5019 ("Shoot" wand).

C_Spell.IsRangedAutoAttackSpell(75)          -- true (Auto Shot)
C_Spell.IsRangedAutoAttackSpell(5019)        -- true (Shoot wand)
C_Spell.IsRangedAutoAttackSpell(6603)        -- false (melee)

Tests the auto-repeat flag in Spell.dbc.AttributesEx2 (bit 0x20). Only Auto Shot and Shoot carry it. The on-cast ranged abilities — Aimed Shot and Multi-Shot — set the general RANGED bit but not the auto-repeat flag, so they return false. The flag also covers any future auto-repeating spell a private server adds. Verified against Spell.dbc.

See C_SpellBook.IsRangedAutoAttackSpellBookItem for the spellbook-slot variant.

C_Spell.IsNextMeleeSpell(spellID)

Returns true if the spell is an "on next swing" melee ability. These abilities queue and land on your next weapon swing instead of an instant cast (Heroic Strike, Cleave, Maul, Raptor Strike).

C_Spell.IsNextMeleeSpell(78)     -- Heroic Strike → true
C_Spell.IsNextMeleeSpell(6807)   -- Maul → true
C_Spell.IsNextMeleeSpell(116)    -- Frostbolt → false

Tests the two on-next-swing bits in Spell.dbc.Attributes (0x04 and 0x400).

C_Spell.ResetsMeleeSwing(spellID)

Returns true if casting the spell resets your melee swing timer. Cast-time spells reset it when you cast them (Fireball, Frostbolt, Polymorph, wand Shoot). Instant abilities like Sinister Strike do not, so a rogue can weave them between swings.

C_Spell.ResetsMeleeSwing(133)    -- Fireball → true
C_Spell.ResetsMeleeSwing(1752)   -- Sinister Strike → false
C_Spell.ResetsMeleeSwing(1464)   -- Slam → false (flagged "no reset")

Mirrors the server's IsMeleeAttackResetSpell: true when the spell's InterruptFlags has the auto-attack bit (0x08) and its AttributesEx2 does not have the "no reset auto actions" bit (0x20000). Both fields come from Spell.dbc. Slam carries that second bit, so it keeps its swing.

IsHarmfulSpell(spell) / IsHelpfulSpell(spell)

Classify a spell as offensive (IsHarmfulSpell) or non-offensive (IsHelpfulSpell) without parsing its tooltip. Accept either form the spellbook-aware globals use:

IsHarmfulSpell(spellID)             -- by ID
IsHarmfulSpell(slot, "spell")       -- player spellbook slot
IsHarmfulSpell(slot, "pet")         -- pet spellbook slot
-- same calling shapes for IsHelpfulSpell

Examples:

IsHarmfulSpell(133)     -- true  (Fireball)
IsHarmfulSpell(118)     -- true  (Polymorph)
IsHelpfulSpell(2061)    -- true  (Flash Heal)
IsHelpfulSpell(1243)    -- true  (Power Word: Fortitude)
IsHarmfulSpell(2061)    -- false

Reads Spell.dbc.AttributesEx (record +0x1C) bit 0x80 — the same SPELL_ATTR_EX_NEGATIVE bit CMaNGOS uses to mark spells as debuffs server-side. IsHarmfulSpell is true iff that bit is set; IsHelpfulSpell is true iff the spell exists and the bit is NOT set. Both return false for invalid spellIDs.

There is no dedicated "positive" flag, so the helpful side is the rough complement of harmful. For utility spells with no clear orientation (Aspect of the Cheetah, Stealth, ground-targeted AOEs), both can read false elsewhere; we return true for helpful as a safer default for addons gating on "is this castable on me?" logic. Compute more precise semantics by also inspecting effect implicit targets if you need them.

C_Spell.IsSpellHarmful(spellID) / C_Spell.IsSpellHelpful(spellID)

Same classification logic as the globals above, exposed in the C_Spell namespace. Don't accept the legacy (slot, bookType) shape.

C_Spell.IsSpellHarmful(133)     -- true (Fireball)
C_Spell.IsSpellHelpful(2061)    -- true (Flash Heal)

Equivalent to C_Spell.IsSpellHarmful / C_Spell.IsSpellHelpful.

GetSpellSchool(spellID)

Returns (schoolID, schoolName) for any spell — known to the player or not. schoolID is 1-based; schoolName is the locale-independent English name.

schoolID schoolName
1 "Physical"
2 "Holy"
3 "Fire"
4 "Nature"
5 "Frost"
6 "Shadow"
7 "Arcane"

Reads directly from Spell.dbc record +0x04. School is stored as a single 0-based integer (no multi-school SchoolMask combinations), so a spell belongs to exactly one school.

Returns nil for invalid spellIDs (out of range or unpopulated Spell.dbc slot).

local id, name = GetSpellSchool(133)   -- 3, "Fire"   (Fireball)
local id, name = GetSpellSchool(116)   -- 5, "Frost"  (Frostbolt)
local id, name = GetSpellSchool(635)   -- 2, "Holy"   (Holy Light)

Useful for combat-log breakdown addons, dispel-eligibility checks, resistance-aware aura libraries, and damage-meter school tagging. Previously addons either maintained hardcoded spellID → school tables or scanned tooltips for the first-line color tag.

CastSpellNoToggle(name | spellID [, unit [, placeGroundSpell]])

Spam-safe variant of CastSpellByName that won't toggle off an already-active spell. This is what a /cast !Name line calls. Covers both kinds of toggle abilities:

  • Auto-repeat — Shoot, Auto-Shot, Wand. Tracked via the engine's active-auto-repeat global.
  • Self-aura toggles — shapeshift (Cat/Bear/Travel/Moonkin/ Shadowform), stance (Battle/Defensive/Berserker), aspect (Hunter), seal (Paladin), blessing-on-self, etc. Tracked via the unit descriptor's aura array.

If either toggle is already on for the requested spell, the call is a no-op — exactly what /cast !SpellName does, but expressed as a Lua call.

Engine state Behavior Return
Nothing toggled Starts the cast true if the spell is now active, else false
This spell already auto-repeating No-op true
This spell's aura already on (form / stance / aspect) No-op true
A different auto-repeat is active No-op false (don't disrupt the other auto-repeat)

Replaces both the action-bar scan-and-skip pattern and the form-already-active check:

-- Before (auto-repeat case)
for i = 1, 120 do
    if IsAutoRepeatAction(i) then return end
end
CastSpellByName("Shoot")

-- Before (shapeshift case)
if not (UnitBuff("player", "Cat Form") or GetShapeshiftFormID() == CAT_FORM) then
    CastSpellByName("Cat Form")
end

-- After (both cases)
CastSpellNoToggle("Shoot")
CastSpellNoToggle("Cat Form")

Accepts either a name string or a spellID number:

CastSpellNoToggle("Shoot")         -- by name (matches what CastSpellByName accepts)
CastSpellNoToggle(75)              -- by spellID (Auto Shot rank 1)
CastSpellNoToggle("Aspect of the Hawk")
CastSpellNoToggle("Battle Stance")

Optional unit target

A second argument casts the spell at a unit, without changing your current target:

CastSpellNoToggle("Auto Shot", "focus")       -- Auto Shot on your focus
CastSpellNoToggle("Shoot", "targettarget")    -- wand your target's target

The unit is any standard token — "focus", "targettarget", "party1", "mouseover", a player name, and so on. A unit-target or auto-repeat spell fires straight at that unit. A ground-target spell lands at the unit's feet. This is the same cast-at-unit path as C_Spell.CastAtUnit.

A third argument of false holds a ground-target spell back. The spell is cast on the unit, and the reticle comes up for you to click, exactly as the third argument of C_Spell.CastAtUnit works. Leave it out to place the spell at the unit.

CastSpellNoToggle("Auto Shot", "focus", false)

The toggle gates run first, so the unit only matters when the spell actually casts. If the spell is already toggled on, the call stays a no-op and ignores the unit. An unknown unit token raises the engine's standard "Unknown unit" error, the same as UnitHealth.

String input matches case-insensitively and tolerates a trailing (Rank N) suffix the same way CastSpellByName itself does — "Shoot" and "Shoot(Rank 1)" both compare equal to a Shoot that's already auto-repeating. A leading ! is accepted and dropped, so the macro form and the Lua form read the same.

Reads [VAR_ACTIVE_AUTO_REPEAT_SPELL] (0x00CEAC30) for the auto- repeat check, and the engine's FUN_SPELL_IS_TOGGLE_AURA_ACTIVE (0x004B36F0) for the aura-active check. Delegates to Script_CastSpellByName (0x004B4AB0) for the actual cast — same resolution semantics (rank picking, target rules, and more) as the engine's own global. With a unit argument, it dispatches through the same cast-at-unit path as C_Spell.CastAtUnit instead.

Using this from inside a macro action slot? See CastSpellNoToggle as a macro cast line below for the additional parser support that makes the slot tag correctly for action-bar UIs.

C_Spell.CastAtCursor(spellIDOrName)

Casts a ground-target spell at the player's current cursor world position, bypassing the manual click on the AoE reticle the engine would otherwise require. ClassicAPI's analog of /cast [@cursor] Blizzard. Returns true when the cursor-placement leg landed; false for non-ground-target spells (cast still fires normally on the current target), unknown spells, cursor over UI / off-screen, etc.

Accepts a spellID or a spell name:

C_Spell.CastAtCursor(10)                  -- Blizzard Rank 1 (exact spellID)
C_Spell.CastAtCursor(2120)                -- Flamestrike Rank 1
C_Spell.CastAtCursor("Blizzard")          -- highest known rank
C_Spell.CastAtCursor("Blizzard(Rank 6)")  -- that specific rank

A numeric spellID casts that exact spell — every rank is its own spellID, so 10 casts Blizzard Rank 1 (not the highest known rank). A name goes through the engine's own resolver, which parses a trailing (Rank N); a bare name casts the highest rank you know.

Two-stage internally:

  1. Initiates the cast: a numeric spellID resolves to its exact spellbook slot (Spell::Lookup::FindSpellbookSlot, so the requested rank is the one that fires), a name goes through FUN_RESOLVE_SPELL_NAME_TO_SLOT; either way it dispatches through the engine's cast dispatcher. If the spell needs a ground target, the engine enters placement mode and arms the AoE reticle.

  2. Refreshes the cursor's screen→world raycast via FUN_REFRESH_CURSOR_RAYCAST (the same internal the engine fires on every mouse move when placement is active), confirms the cursor hit terrain (not a UI frame or a unit), and commits placement via FUN_COMMIT_PLACEMENT_COORDS with the resolved (x, y, z). Cancels placement cleanly via FUN_STOP_PLACEMENT when the cursor isn't on terrain or when the spell wanted a unit-target rather than ground.

For non-placement spells (regular target casts, self-buffs, etc.) the cast fires normally; the placement-resolve no-ops since the engine never set the placement flag, and we return false.

The companion item version is C_Item.UseAtCursor — same chain via the item-use path for grenades / on-use ground-target items.

C_Spell.CastAtUnit(spellIDOrName, unit [, placeGroundSpell])

Casts a spell at unit, whatever its target type — ClassicAPI's analog of /cast [@unit] Spell:

  • Ground-target spells (Flamestrike, Blizzard, Rain of Fire, …) are placed at the unit's feet, bypassing the AoE reticle click.
  • Unit-target / normal spells (Frostbolt, Renew, a buff, …) fire directly on the unit — regardless of your current selection, so you can buff "party1" or heal "player" while an enemy stays targeted.

Works with any unit token ("target", "player", "mouseover", "party1", "raid7", …). Returns true when the spell was cast at the unit (fired or placed); false for a spell not in your spellbook, a unit with no resolvable position, or a unit the engine won't accept as a target for that spell (wrong faction, out of range).

The first argument takes a spellID or a spell name, with the same exact-rank / (Rank N) semantics as CastAtCursor.

placeGroundSpell defaults to true, which is the behavior above. Pass false to cast a normal spell on the unit as usual but leave a ground-target spell's reticle for the player to click, instead of dropping it on the unit. That is what /cast [@unit] Spell does for every unit but yourself.

-- ground-target: dropped at the unit's feet
C_Spell.CastAtUnit(2120, "target")            -- Flamestrike at the target's feet
C_Spell.CastAtUnit("Blizzard(Rank 6)", "target")

-- unit-target / normal: cast straight on the unit, ignoring current target
C_Spell.CastAtUnit("Frostbolt", "target")
C_Spell.CastAtUnit("Renew", "player")         -- heal yourself mid-fight
C_Spell.CastAtUnit("Power Word: Fortitude", "party1")

The unit is resolved once to its object; the cast is dispatched with the unit's GUID as the implicit target (the engine substitutes it exactly where it would the current selection), so a unit-target spell fires immediately at that unit. A ground spell ignores the GUID and enters placement mode instead, which is then committed at the unit's world position (read from its GetPosition virtual — the same one CheckInteractDistance / UnitInRange use) via FUN_COMMIT_PLACEMENT_COORDS. The engine can't be left armed in placement mode on failure: if no placement is active after dispatch the cast already went out, and a non-ground placement (an unaccepted unit target) is cancelled. A genuinely unrecognized unit-token string raises the engine's standard "Unknown unit" error, matching UnitHealth("garbage").

The companion item version is C_Item.UseAtUnit.

C_Spell.CancelSpellByID(spellID) / CancelSpellByName(name)

Cancel a buff on the player by spellID or by spell name. Both are player-only (the server only accepts CMSG_CANCEL_AURA for the local player's own auras).

C_Spell.CancelSpellByID(2378)      -- Cancel "Find Herbs"
CancelSpellByName("Cat Form")       -- Drop shapeshift
Function Arg Behavior
C_Spell.CancelSpellByID(spellID) spellID (number) Sends CMSG_CANCEL_AURA for spellID directly. No client-side check that the buff is actually present — server validates and silently no-ops if not.
CancelSpellByName(name) name (string, case-insensitive) Walks the player's buff range (UnitBuff slots 0..31) for the first aura whose Spell.dbc localized name matches, then cancels by its spellID. Buff-only — debuffs are skipped.

Calls FUN_006E7040 (0x006E7040) directly with the spellID. This bypasses Script_CancelPlayerBuff's client-side gates — the per-buff cancelable flag at [entry+0x0A] & 0x01 and the fallback AttributesEx & 0x04 check on the spell record. Trade-off: the server is now the sole authority on what's cancelable. Non-cancelable auras (proc-buffs, certain world buffs) still get rejected — just server-side instead of client-side.

For invalid input (non-positive spellID, OOR, or a Spell.dbc empty slot), both functions silently no-op. No lua_error, no event fired.

Spell name matching is case-insensitive via _stricmp. Rank-suffixed input (e.g. "Power Word: Fortitude(Rank 4)") is not parsed — pass the plain name. Multi-rank buffs match on first-found; if you have multiple ranks of the same buff active (unusual but possible with e.g. paladin blessings before talent merge), the first slot wins.

C_Spell.UnitCastingInfo(unit) / C_Spell.CastingInfo()

Returns the unit's in-progress regular cast (not a channel), or nil if it isn't casting. C_Spell.CastingInfo() is C_Spell.UnitCastingInfo("player") without the unit-token lookup.

Under C_Spell, not the global UnitCastingInfo. A global here would clobber addons that ship their own cast-tracking via the _G.UnitCastingInfo or <fallback> idiom — e.g. ShaguTweaks' libcast scrapes the combat log for remote/enemy casts the engine never exposes. If we occupied the global, those addons would adopt our player-only version and lose their (superior, for that case) fallback. So we cede the global names and register under C_Spell instead. Same for UnitChannelInfo/ChannelInfo.

name, displayName, textureID, startTimeMs, endTimeMs, isTradeskill,
  castID, notInterruptible, castingSpellID, castBarID, delayTimeMs
    = C_Spell.UnitCastingInfo(unit)
-- mid-cast:
C_Spell.UnitCastingInfo("player")
-- "Conjure Food", "Conjure Food", "Interface\\Icons\\INV_Misc_Food_...",
--  907928268, 907931268, false, nil, false, 28612, nil, 0

startTimeMs/endTimeMs share GetTime()'s epoch (GetTime()*1000), so progress is (GetTime()*1000 - startTimeMs) / (endTimeMs - startTimeMs).

Works for any unit. The CGUnit stores no cast times (the cast bar is Lua-driven off SPELLCAST_START), so we source them two ways: the local player is self-tracked on a per-frame tick (VAR_CURRENT_CAST_SPELL, stamping startTimeMs = now, endTimeMs = now + effective cast time); other units come from a co-hook on SMSG_SPELL_START (Spell::Cast), the one packet carrying another unit's cast with a server-authoritative cast time, cached per caster GUID. So C_Spell.UnitCastingInfo("target") now backs enemy/target/focus/nameplate cast bars. Instant casts return nil (no cast bar). Aborted remote casts (interrupt, death, movement, cancel) clear the same tick — a co-hook on the engine's own CGUnit_C::ClearCastingSpell (the choke point every "unit stopped casting" path funnels through) evicts the cached cast the moment the engine stops the unit's cast visual, backstopped by co-hooks on the SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER packets for servers that broadcast them (Turtle's core doesn't — its interrupt propagation reaches observers through SuperWoW, which the choke-point hook catches too). Verified: Kick / Earth Shock / Counterspell each clear the victim's bar, while non-interrupting damage doesn't false-clear it. Best-effort for remote units, in three ways: only casts that began while the unit was in range are seen; the remote startTimeMs/endTimeMs are shifted later by roughly your latency (SMSG_SPELL_START carries only a single castTime, so we stamp start = now, end = now + castTime on receipt — the packet has no field to recover elapsed from, so the skew can't be removed); and remote pushback is invisible — the server sends SMSG_SPELL_DELAYED only to the affected caster, so another unit's bar can't stretch when they take damage. The local player is unaffected by all of this — its cast is detected client-side at the moment of cast, no packet involved, and its pushback is tracked.

isTradeskill is real — the spell's SPELL_ATTR_TRADESPELL flag, so profession recipe casts (smelting, cooking/enchanting recipes, …) return true while regular spells return false. endTimeMs reflects the effective cast time (the engine's own cast-time helper: base + level scaling + cast-time talents like Improved Frostbolt + the haste/cast-speed multiplier), so it matches the in-game cast bar — e.g. a Mage's talented Frostbolt reads 2.5s, not the 3.0s base. castID is the cast's castGUID — the exact string the UNIT_SPELLCAST_* events carry for this cast, so you can correlate the polled info with the events (works for the player and other units). delayTimeMs is the pushback the cast has taken so far, in milliseconds. castBarID has no source here and is always nil.

notInterruptible is true when no interrupt or silence that you know can stop the cast. The value is relative to your own abilities, so a rogue and a mage can read different values for the same cast. It is true in two cases: the spell can never be interrupted, or the caster has an aura that blocks every interrupt and silence you know (for example Divine Shield, Ice Block, or Banish). If you know no interrupt and no silence, the value is always false. Limits: a creature's built-in interrupt immunity is server-side data that the client never receives, so a boss with that immunity reads false while your interrupt still fails on it.

C_Spell.UnitChannelInfo(unit) / C_Spell.ChannelInfo()

Returns the unit's in-progress channel, or nil if it isn't channeling. C_Spell.ChannelInfo() is C_Spell.UnitChannelInfo("player") without the token lookup. (Under C_Spell rather than the global name for the same reason as C_Spell.UnitCastingInfo.)

name, displayName, textureID, startTimeMs, endTimeMs, isTradeskill,
  notInterruptible, spellID = C_Spell.UnitChannelInfo(unit)
C_Spell.ChannelInfo()
-- "Blizzard", "Blizzard", "Interface\\Icons\\Spell_Frost_IceStorm",
--  907894314, 907902314, false, false, 10187

The channeled spellID is broadcast for every unit (UNIT_FIELD_CHANNEL_SPELL), so C_Spell.UnitChannelInfo("target") works on other units. Timing now comes from the same SMSG_SPELL_START co-hook as casts: when we observed the channel begin, the remote unit returns full start/end times (validated against the live UNIT_FIELD_CHANNEL_SPELL so a stale cache entry never applies to a different/ended channel); otherwise it falls back to name/displayName/textureID/spellID with nil times. The player path is unchanged (full timing). notInterruptible follows the same rules as in C_Spell.UnitCastingInfo.

C_Spell.GetSpellLevelInfo(spellID)

Returns the raw Spell.dbc level fields for a spell:

spellLevel, baseLevel, maxLevel = C_Spell.GetSpellLevelInfo(spellID)
  • spellLevel — the rank's effective level (drives the target-level rule below and per-level scaling).
  • baseLevel — the spell's base level.
  • maxLevel — the level at which per-level scaling caps (0 = no cap).

Returns nil for an invalid / unpopulated spellID.

C_Spell.GetSpellLevelInfo(14752)   -- 30, 30, 40  (Divine Spirit Rank 1)
C_Spell.GetSpellLevelInfo(14818)   -- 40, 40, 50  (Divine Spirit Rank 2)

GetSpellRequiredTargetLevel(spellID)

Also C_Spell.GetSpellRequiredTargetLevel(spellID). Returns the minimum level a target must be for this exact spell (rank) to apply — e.g. Divine Spirit Rank 1 can only be cast on a target level 20 or higher. Returns 0 when the spell has no such restriction, or nil for an invalid spellID.

GetSpellRequiredTargetLevel(14752)   -- 20   (Divine Spirit Rank 1)
GetSpellRequiredTargetLevel(14818)   -- 30   (Divine Spirit Rank 2)
GetSpellRequiredTargetLevel(1243)    -- 0    (Fortitude Rank 1)
GetSpellRequiredTargetLevel(10060)   -- 0    (Power Infusion — single rank)
GetSpellRequiredTargetLevel(133)     -- 0    (Fireball — damage, not a buff)

Mirrors the server's SelectAuraRankForLevel rule: a ranked beneficial aura is castable when targetLevel + 10 >= spellLevel, so the minimum target level is spellLevel - 10. The restriction is applied only when the spell is a ranked positive aura — spellLevel > 10, not passive, part of a rank chain, and applying an aura to a friendly/party/raid target (or an APPLY_AREA_AURA_PARTY effect). Below the minimum, the lowest rank fails with SPELL_FAILED_LOWLEVEL; higher ranks auto-downrank.

The explicit MinTargetLevel / MaxTargetLevel spell fields the server also enforces are server-only columns — they aren't present in the 1.12 client's Spell.dbc, so the spellLevel rule is the only client-readable target-level mechanism.

SpellBook

Functions keyed on the player or pet spellbook (slot indices, learn requirements, skill-tab membership). The underlying Spell.dbc data is exposed through the Spell section; what lives here is the spellbook-as-a-collection layer.

FindSpellBookSlotByID(spellID)

Inverse of 1.12's GetSpellName(slot, bookType). Given a spellID, returns the 1-based slot it occupies in the player or pet spellbook, along with the matching bookType so the result feeds directly into slot-and-bookType APIs (GetSpellName, GetSpellTexture, GameTooltip:SetSpell, etc.).

slot, bookType = FindSpellBookSlotByID(spellID)
  • Returns (slot, "spell") if the spell is in the player spellbook.
  • Returns (slot, "pet") if it's only in the pet spellbook.
  • Returns nil if the spellID isn't currently in either book.

Player book is searched first, so if a spell somehow appeared in both (unusual but possible for special pet-shared abilities), the player slot wins.

local slot, book = FindSpellBookSlotByID(133)
if slot then
    GameTooltip:SetSpell(slot, book)  -- shows full caster-scaled tooltip
end

Equivalent to the function of the same name (also known as FindSpellBookSlotBySpellID).

C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)

Returns a table describing the spell in a spellbook slot. slotIndex is 1-based across the whole book. spellBank picks the book — Enum.SpellBookSpellBank.Player (0) or Enum.SpellBookSpellBank.Pet (1).

local info = C_SpellBook.GetSpellBookItemInfo(1, Enum.SpellBookSpellBank.Player)
-- info.name, info.spellID, info.itemType, info.isPassive, info.iconID, ...

Table fields:

Field Type Notes
itemType Enum.SpellBookItemType Spell (1) for the player book, PetAction (3) for the pet book.
actionID number The spellID. Equals spellID — there is no spell-override system.
spellID number The spellID in the slot.
name string The localized spell name.
subName string The rank text (e.g. "Rank 3"), or "" when the spell has no rank.
iconID string The icon path — feed it straight to texture:SetTexture(...).
isPassive boolean true for a passive spell.
isOffSpec boolean Always false.

Returns nil for an empty or out-of-range slot.

Deviations forced by the data. iconID is a texture path, not a fileID (there is no fileID system) — the same deviation as C_Spell.GetSpellInfo. skillLineIndex is not returned (nil) — use C_SpellBook.GetSpellBookItemSkillLineIndex. isOffSpec is always false — there are no specializations.

C_SpellBook.GetNumSpellBookSkillLines()

Returns the number of tabs in the player spellbook: General, then one tab per skill line such as Fire or Fury.

C_SpellBook.GetSpellBookSkillLineInfo(skillLineIndex)

Returns a table describing spellbook tab skillLineIndex (1-based), or nil for an index outside 1 .. GetNumSpellBookSkillLines().

for i = 1, C_SpellBook.GetNumSpellBookSkillLines() do
    local tab = C_SpellBook.GetSpellBookSkillLineInfo(i)
    for slot = tab.itemIndexOffset + 1, tab.itemIndexOffset + tab.numSpellBookItems do
        local item = C_SpellBook.GetSpellBookItemInfo(slot, Enum.SpellBookSpellBank.Player)
        print(tab.name, item.name)
    end
end
Field Type Notes
name string The tab name ("General", "Fire", "Fury", ...).
iconID string The tab icon path — feed it to texture:SetTexture(...).
itemIndexOffset number This value + 1 is the first slotIndex of the tab.
numSpellBookItems number The number of spells in the tab.
isGuild boolean Always false.
shouldHide boolean Always false.
specID, offSpecID nil Always nil.

The name, icon, offset, and count are the same values GetSpellTabInfo(skillLineIndex) returns.

C_SpellBook.GetSpellBookItemSkillLineIndex(slotIndex, spellBank)

Returns the 1-based tab index that holds player-book slot slotIndex. Returns nil for a slot past the last spell, and for every pet-book slot (the pet book has no tabs).

C_SpellBook.GetSpellBookItemSkillLineIndex(1, Enum.SpellBookSpellBank.Player)  -- 1 (General)

C_SpellBook.GetSkillLineIndexByID(skillLineID)

Returns the 1-based tab index for a SkillLine.dbc ID, or nil when the player has no tab for that skill line. The General tab has no skill line ID.

local _, skillLineID = C_SpellBook.GetSpellSkillLine(133)   -- Fireball: "Fire", 8
local tab = C_SpellBook.GetSkillLineIndexByID(skillLineID)  -- the Fire tab, or nil on a non-mage

Paired with C_SpellBook.GetSpellSkillLine it answers "which of my tabs holds this spellID" without walking the book.

C_SpellBook.GetSpellBookItemCastCount(slotIndex, spellBank)

Spellbook-slot variant of C_Spell.GetSpellCastCount. Returns 0 for an empty slot.

C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo(slotIndex, spellBank)

Spellbook-slot variant of C_Spell.GetSpellLossOfControlCooldown, returned as a table. Returns nil for an empty slot.

Field Type Notes
startTime number Start of the blocking effect, in GetTime() seconds. 0 when nothing blocks the spell.
duration number Length of the blocking effect in seconds. 0 when nothing blocks the spell.
modRate number Always 1.
isActive boolean true while an effect blocks the spell.
shouldReplaceNormalCooldown boolean true when the effect outlasts the spell's own cooldown. A button then draws the loss-of-control swipe instead of the normal one.

C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration(slotIndex, spellBank)

The duration field above on its own. Returns nil for an empty slot.

C_SpellBook.IsClassTalentSpellBookItem(slotIndex, spellBank)

Returns true when the spell in the slot comes from a talent: the talent's own spell (Mortal Strike rank 1), or a higher rank of that ability that you trained (Mortal Strike rank 2). Returns false for every pet-book slot and for an empty slot.

local bank = Enum.SpellBookSpellBank.Player
local info = C_SpellBook.GetSpellBookItemInfo(slot, bank)
if C_SpellBook.IsClassTalentSpellBookItem(slot, bank) then
    print(info.name, "is a talent ability")
end

Higher ranks are found in two ways: through the next-rank links in the client data, and through the spell name. A spellbook spell with the same name as a talent's spell counts as a rank of that talent. Mind Flay is one chain that needs the second way.

C_SpellBook.ContainsAnyDisenchantSpell()

Returns true when the player knows a spell that disenchants items (Disenchant, from Enchanting).

C_SpellBook.GetSpellLevelLearned(spellID)

Returns the level at which a spell becomes available — the BaseLevel field in Spell.dbc (record +0x70). Direct read off the cached record; no class/race filtering.

C_SpellBook.GetSpellLevelLearned(133)    -- Fireball rank 1 → 4
C_SpellBook.GetSpellLevelLearned(25306)  -- Fireball rank 12 → 60
C_SpellBook.GetSpellLevelLearned(2061)   -- Flash Heal rank 1 → 20

Returns 0 for invalid spellIDs, spells with no level requirement (most non-class utility spells), or records the engine hasn't cached. Unknown / utility spells return 0 rather than nil.

C_SpellBook.GetCurrentLevelSpells([level])

Returns a 1-based table of spellIDs the player's class/race can learn at the given level. Without arguments, defaults to the player's current level.

C_SpellBook.GetCurrentLevelSpells()    -- spells trainable at your level
C_SpellBook.GetCurrentLevelSpells(20)  -- preview what's available at 20
C_SpellBook.GetCurrentLevelSpells(60)  -- preview at 60

Walks SkillLineAbility.dbc filtering by the player's class bit (1 << (classID - 1)) and race bit, respecting both the include masks (entries with mask = 0 are "all classes"/"all races", which also pass) and the exclude masks. For each surviving entry, looks up the spell's BaseLevel and includes it if it matches the queried level.

Spell learning is trainer-driven. Elsewhere GetCurrentLevelSpells returns auto-learned spells. Here you must visit a trainer to actually learn most class spells. We return the closest available analog: what's trainable at this level. Useful for "what's new this level" UI panels and level-preview tooling.

Class/race come from the local player — there's no (class, race, level) form because there's no clean class-string→classID lookup. Returns an empty table at character select / pre-login (no CGPlayer yet) and for levels where no class/race spells match.

C_SpellBook.GetPlayerSpellsByAura(auraName)

Returns every spell the player currently knows that has an effect applying the given aura, as a 1-based array of spell IDs in ascending order. Empty when none does.

spellIDs = C_SpellBook.GetPlayerSpellsByAura(auraName)

auraName is an aura code — the same number C_Spell.GetSpellEffectInfo reports in its auraName field. 0 means "applies no aura" and returns an empty array.

This is IsPlayerSpell asked the other way round. Instead of testing one spell, it reads the same set of known spells and reports the ones whose effects carry the aura. A talent is present at its current rank only, so summing the amounts of the result never counts two ranks of one talent.

-- everything the player knows that shortens the energy tick
for _, spellID in ipairs(C_SpellBook.GetPlayerSpellsByAura(217)) do
    local fx = C_Spell.GetSpellEffectInfo(spellID)
    -- read the amount from the effect whose auraName is 217
end

Known is not the same as active. For a passive — a talent, a racial — it is: known means in effect, and a passive never appears in the buff list. A castable buff in the result is only learned; whether it is up is a question for the buff list. And a buff another player puts on you is not a spell you know, so it is not here at all. Split the result on C_Spell.IsSpellPassive and check active buffs separately:

local mod = 0
for _, spellID in ipairs(C_SpellBook.GetPlayerSpellsByAura(217)) do
    if C_Spell.IsSpellPassive(spellID) then
        mod = mod + AmountOf(spellID)      -- in effect while known
    end
end
for i = 1, 32 do
    local spellID = select(10, C_UnitAuras.UnitAura("player", i, "HELPFUL"))
    if not spellID then break end
    mod = mod + AmountOf(spellID)          -- in effect while up
end

A list built this way follows the data: a retuned value, a new rank, or an added source shows up without a code change.

C_SpellBook.GetSkillLineName(skillLineID)

Returns the localized SkillLine.dbc name for a skill-line id — the id → name half of the bridge addons build by hand. A pure DBC read: it answers for any valid skill line (182 = Herbalism, 186 = Mining, the weapon/defense lines, profession lines) whether or not the player has learned it. nil for non-numeric / non-positive input or an id with no SkillLine.dbc row.

Pairs with GetSkillLineRank — name from the DBC, rank from the player's live skills.

C_SpellBook.GetSkillLineName(182)   -- "Herbalism"
C_SpellBook.GetSkillLineName(186)   -- "Mining"

C_SpellBook.GetSkillLineRank(skillLineID)

Returns (curRank, maxRank, modifier) — the local player's rank in a skill line by SkillLine.dbc id — or nil if the player hasn't learned it.

  • curRank — current skill value (base, no temp bonus)
  • maxRank — the line's cap at the player's level
  • modifier — temporary bonus (weapon oils, buffs; 0 if none)
local cur, max = C_SpellBook.GetSkillLineRank(182)  -- Herbalism: 285, 300 (nil if unlearned)
if C_SpellBook.GetSkillLineRank(186) then           -- has Mining at all?
    -- ...
end

The stock GetSkillLineInfo(index) only walks the skill window by position and returns skills by name, so an addon starting from a SkillLine.dbc id (a quest's requiredSkill) has to map id → localized name and then string-match every skill line to recover the rank. This reads the player's live skill list keyed by id directly — the returned ranks are the same values GetSkillLineInfo reports. Resolves the id to the player's skill slot via FUN_SKILL_LINE_TO_SLOT, then reads the CGPlayer skill table (the same storage C_Item.IsEquippableItem's require-skill gate uses). nil when the line isn't learned, for bad input, or before the player object is resident (character select).

C_SpellBook.GetSpellSkillLine(spellID)

Returns (name, skillID) — the SkillLine.dbc row that the given spell belongs to. The name is the user-facing skill/tab string in the engine's current locale: spellbook tab names for class spells ("Protection", "Fire", "Holy"), profession headers ("Tailoring", "Engineering"), weapon-skill rows ("Swords", "Bows"), etc.

C_SpellBook.GetSpellSkillLine(1671)   -- "Protection", 26 (Shield Bash)
C_SpellBook.GetSpellSkillLine(133)    -- "Fire", 8        (Fireball)
C_SpellBook.GetSpellSkillLine(2050)   -- "Holy", 56       (Lesser Heal)
C_SpellBook.GetSpellSkillLine(2480)   -- "Bows", 45       (Shoot Bow)
C_SpellBook.GetSpellSkillLine(3273)   -- "First Aid", 129 (Anesthetic)

Walks SkillLineAbility.dbc for any row whose spellID matches, then reads the localized Name[locale] field at offset +0x0C of the referenced SkillLine.dbc record. For spells with multiple SLA rows (race-locked variants, Turtle WoW's faction-specific entries) the lookup prefers a row whose class/race masks match the local player — so the result reflects "what tab is this spell in for me" when one exists. Falls back to the first matching row otherwise, so the function still answers for spells the local player can't actually use (inspecting other classes, parsing combat-log entries from unfamiliar specs).

Returns (nil, nil) for:

  • non-numeric or non-positive input
  • spellIDs with no SkillLineAbility.dbc row (most temporary auras, proc-only spells, item-on-use effects, GM-debug spells)
  • rows whose skillID doesn't resolve in SkillLine.dbc

The stock GetSpellTabInfo(tabIndex) enumerates spellbook tabs by 1-based index — it can't answer "what tab is this spellID in". Addons that need a spellID→tab mapping have historically had to walk every tab's slot range and string-match against GetSpellName. This function reads it directly off the DBC for any spellID, including spells the player hasn't learned.

C_SpellBook.IsAutoAttackSpellBookItem(slot, bookType)

Spellbook-slot variant of C_Spell.IsAutoAttackSpell. Resolves the spellbook slot to its spellID first, then tests == 6603 (melee auto-attack).

C_SpellBook.IsAutoAttackSpellBookItem(1, "spell")

C_SpellBook.IsRangedAutoAttackSpellBookItem(slot, bookType)

Spellbook-slot variant of C_Spell.IsRangedAutoAttackSpell. Resolves the slot to a spellID, returns true if it's 75 (Auto Shot) or 5019 (Shoot wand).

C_SpellBook.IsRangedAutoAttackSpellBookItem(10, "spell")
-- true if slot 10 is your wand Shoot

State

Player movement / visibility state queries, as no-arg globals. The stock client doesn't bind them to Lua despite the engine tracking the underlying state — broadcast in UpdateFields for some (mount, stealth visibility), local-only for others (falling, swimming).

IsMounted()

Returns true if the player is currently mounted, false otherwise.

Reads UNIT_FIELD_MOUNTDISPLAYID from the player's broadcast descriptor. The field holds a creature display ID (the model the engine renders under the player) when mounted, and 0 otherwise.

if not IsMounted() then
    CastSpellByName("Summon Dreadsteed")
end

Dismount()

Dismisses the player's currently summoned mount. No-op when the player isn't mounted.

if IsMounted() then Dismount() end

Walks the player's buff range for an aura whose Spell.dbc effects include SPELL_AURA_MOUNTED (78), then sends CMSG_CANCEL_AURA for that spellID via the same direct sender C_Spell.CancelSpellByID uses. By the time the server processes the cancel, IsMounted() will return false.

IsStealthed()

Returns true if the player is currently in Stealth (Rogue) or Prowl (Druid), false otherwise.

Reads bit 0x02 of the player visibility byte at descriptor +0x17C and AND-gates with MountDisplayID == 0 to disambiguate mount (which sets the same bit). Untested for Druid shapeshift forms — if you find a false-positive there, file an issue and we'll switch to walking the player's aura array for the actual stealth spell.

if IsStealthed() then
    -- defer the spell that would break stealth
end

IsFalling()

Returns true if the player is currently mid-jump or falling, false otherwise.

Reads the local CGPlayer movement-flags word at +0x9E8 and tests MOVEFLAG_FALLING | MOVEFLAG_FALLING_FAR (0x2000 | 0x4000). This is client-side state (outbound MSG_MOVE_* data, never visible for remote units), so the function only meaningfully applies to the local player.

if not IsFalling() then
    -- safe to bind ground-targeted spell
end

IsSwimming()

Returns true if the player is currently swimming (in liquid, with the swim animation/movement set), false otherwise.

Same movement-flags word as IsFalling, testing MOVEFLAG_SWIMMING (0x200000). Local-player only.

if IsSwimming() then
    -- breath bar logic, mount-failure suppression, etc.
end

IsIndoors()

Returns 1 if the local player is under a WMO roof — inside a building, cave, or instance interior — and nil otherwise. nil before the player object exists (pre-world).

Recomputed live from the engine's WMO geometry query, so it flips as you cross an interior threshold rather than tracking the current zone/area — you can be indoors and outdoors within the same subzone. Returns 1/nil (not true/false) to match the historical contract and SuperWoW 2.2, so both if IsIndoors() and IsIndoors() == 1 work.

if IsIndoors() then
    -- suppress a mount cast that would just fail
end

IsOutdoors()

Returns 1 if the local player is outdoors (open sky, or simply not inside a WMO interior) and nil otherwise. Exact complement of IsIndoors() for a resolvable player; both return nil pre-world.

if IsOutdoors() then
    CastSpellByName("Mount Up")
end

IsAssistingRitual()

Returns true if the local player is currently clicking a warlock summoning portal (the channel-on-GameObject state with no castbar UI, where movement breaks the channel), false otherwise.

This is distinct from spell channeling: the function fires for participants who clicked the portal, not the warlock who cast Ritual of Summoning. There is no stock Lua surface for this state — the engine's SPELLCAST_CHANNEL_* events don't fire and C_Spell.CastingInfo() returns nothing — so addons that want to react to the player being committed to a ritual (e.g. suppress autorun toggles, hide nameplate clicks, warn before movement) have no other way to detect it.

Detection uses two state slots:

  • UNIT_CHANNEL_SPELL (descriptor +0x228) — the warlock's channel spell ID (698) is mirrored onto every clicker for the duration of the ritual.
  • A CGPlayer-local pointer at +0xB4 — the engine's "current spell cast target GameObject" slot, set to the portal GO while the clicker is engaged.

Either signal alone is ambiguous (the warlock channeling matches the first; mining and other GO-targeted casts match the second); the conjunction is portal-clicker-specific.

The engine fires SPELLCAST_CHANNEL_START / SPELLCAST_CHANNEL_STOP on the portal click, even though there's no castbar — Blizzard's castbar UI filters out spell ID 698 (Ritual of Summoning), but the events themselves fire normally. Combine them with this function for ritual-specific triggers:

local frame = CreateFrame("Frame")
frame:RegisterEvent("SPELLCAST_CHANNEL_START")
frame:RegisterEvent("SPELLCAST_CHANNEL_STOP")
frame:SetScript("OnEvent", function()
    if event == "SPELLCAST_CHANNEL_START" and IsAssistingRitual() then
        -- player just committed to a ritual portal
    elseif event == "SPELLCAST_CHANNEL_STOP" then
        -- channel ended; was it a ritual?
    end
end)

Local-player only — the +0xB4 pointer lives on the CGPlayer object, not in the broadcast descriptor, so the function can't report state for target / party* / etc. Returns false when called before the player object is initialized (login screen).

IsInGroup()

Returns true if the player is in a party or a raid, false otherwise. Shortcut over GetNumPartyMembers() > 0 or GetNumRaidMembers() > 0.

Accepts an optional groupType argument (Enum.PartyCategory.Home / Instance). There is no LFG / LFD instance-group concept here, so the argument is accepted and ignored.

if IsInGroup() then
    SendChatMessage("ready check", "PARTY")
end

IsInRaid()

Returns true if the player is in a raid (specifically — parties return false), false otherwise. Same groupType argument story as IsInGroup.

local channel = IsInRaid() and "RAID" or "PARTY"
SendChatMessage("ready check", channel)

GetMirrorTimerInfo(index) / GetMirrorTimerProgress(label)

Readers for the BREATH (drowning) / EXHAUSTION (off-map) / FEIGNDEATH (hunter Feign Death) bar state. The engine fires the MIRROR_TIMER_START / _PAUSE / _STOP events with the full packet payload but doesn't cache anything internally; ClassicAPI hooks the SMSG handler at 0x005E7990 and builds a 3-slot side cache so these accessors work.

GetMirrorTimerInfo(index) — index is 1, 2, or 3 (slot id). Returns (timer, value, maxValue, scale, paused, label) for an active slot, or nothing if that slot is empty:

Return Type Meaning
timer string Engine type-name: "EXHAUSTION", "BREATH", or "FEIGNDEATH"
value number The snapshot value from the last server packet (ms). Not live-interpolated — see GetMirrorTimerProgress for that
maxValue number Timer's max (ms)
scale number Server-set rate. Negative = depleting (the server sends -1 for breath, draining 60000 ms over 60 s of real time); positive = filling
paused boolean true if the engine has frozen the timer
label string Localized display label, e.g. "Breath". For FEIGNDEATH, the spell name

GetMirrorTimerProgress(label) — label is the engine type-name ("BREATH" etc.). Returns the live interpolated value in milliseconds, computed each call from the snapshot + elapsed real-time ticks. Returns 0 when no matching timer is active.

local f = CreateFrame("Frame")
f:RegisterEvent("MIRROR_TIMER_START")
f:SetScript("OnEvent", function()
    -- pull whichever slot the engine just populated
    for i = 1, 3 do
        local timer, value, maxv, scale, paused, label = GetMirrorTimerInfo(i)
        if timer then
            DEFAULT_CHAT_FRAME:AddMessage(format("%s: %d / %d (%s)",
                label, value, maxv, paused and "paused" or "running"))
        end
    end
end)

-- Smooth bar updates: poll progress in OnUpdate
local function OnUpdate(self)
    self:SetValue(GetMirrorTimerProgress("BREATH"))
end

The off-map timer is "EXHAUSTION", not "FATIGUE". The engine type-name string for the off-map timer is "EXHAUSTION" — we surface what the engine actually returns rather than translating the name. Addons porting "FATIGUE"-keyed code need to handle either string.

GetShapeshiftFormID()

Returns the player's current shapeshift form as the integer ID from SpellShapeshiftForm.dbc. Returns 0 when the player isn't shifted.

Class Form IDs
Druid 1 Cat, 2 Tree, 3 Travel, 4 Aquatic, 5 Bear, 8 Dire Bear, 31 Moonkin
Warrior 17 Battle, 18 Defensive, 19 Berserker
Shaman 16 Ghost Wolf
Rogue 30 Stealth
Priest 28 Shadowform, 32 Spirit of Redemption

Turtle WoW extends the DBC with custom rows that aren't present on the stock client:

ID Name Notes
9 Tree of Life Form Restoration druid endgame form
11 Swift Travel Form Mounted-speed travel form

Form numbering is client-specific. These IDs are the ones this client uses — e.g. 3 for Travel; Turtle uses 9 for Tree of Life. Other clients number the table differently. Don't copy named constants from another addon and expect them to match.

if GetShapeshiftFormID() == 0 then
    CastSpellByName("Cat Form")
end

Reads byte 2 of UNIT_BYTES_1 (descriptor +0x212) on the local player — the same byte the engine's Script_GetShapeshiftFormInfo compares against each form-spell's effect-encoded form ID to answer "is this form active". The engine updates the byte from SMSG_UPDATE_OBJECT aura/form packets, so the value is live without needing an event listener.

The stock client has only GetShapeshiftFormInfo(index) (1-based bar index); GetShapeshiftFormID exposes the DBC ID directly so callers can write if formID == CAT_FORM without iterating the bar.

CancelShapeshiftForm()

Cancels the player's current shapeshift form, dropping them back to caster (or whatever neutral state the class uses). No-op when the player isn't shifted.

if GetShapeshiftFormID() ~= 0 then
    CancelShapeshiftForm()
end

Covers druid forms (Cat / Bear / Travel / Aquatic / Moonkin / Dire Bear / Tree of Life), shaman Ghost Wolf, priest Shadowform / Spirit of Redemption, and rogue Stealth. No form table hardcoding — the implementation finds whichever buff currently provides the active form by scanning Spell.dbc effects.

Does not cover warrior stances. Warriors are always in a stance — the engine has no "no stance" state and right-clicking the active stance in the stance bar does nothing. The server rejects the cancel packet for stances, so this function is a no-op when called as a warrior. Switch stances with the normal stance spells instead.

Implementation: reads the current form byte (descriptor +0x212, same source as GetShapeshiftFormID()), walks the player's buff slots (0..31), looks up each spell's Spell.dbc[spellID] effect arrays, and on the first effect whose EffectApplyAuraName is SPELL_AURA_MOD_SHAPESHIFT (36) and whose EffectMiscValue matches the current form ID, sends CMSG_CANCEL_AURA via the engine's direct sender at FUN_006E7040.

Does an effect-array scan + form-id match before issuing its cancel packet. The engine has the machinery; it just lacks the public Lua surface.

GetSheathState()

Returns the weapon type that the player has drawn. Takes no arguments. The value is 1-based:

Value Meaning
1 None. The weapons are put away.
2 A melee weapon is drawn.
3 A ranged weapon is drawn.

Returns 1 before the player object exists (out of world).

if GetSheathState() == 3 then
    -- the ranged weapon is out
end

The engine has ToggleSheath() but no matching getter. GetSheathState adds the query, with 1-based values. To change the state, use the built-in ToggleSheath().

SwingTimer

C_SwingTimer reports whether your current target is in range for a melee or ranged auto-attack. See PLAYER_SWING for the matching attack-timer event, and Enum.PlayerSwingType for the weapon values both use.

C_SwingTimer.EnableRangeCheck(swingType, enable)

Turns PLAYER_SWING_RANGE_UPDATE on or off for one swing type.

C_SwingTimer.EnableRangeCheck(Enum.PlayerSwingType.MainHand, true)

After you enable a swing type, call C_SwingTimer.IsTargetWithinSwingRange once to read the current range. The event fires only on a later change.

C_SwingTimer.IsTargetWithinSwingRange(swingType)

Returns whether your current target is in range for a swing type (an Enum.PlayerSwingType value).

local inRange = C_SwingTimer.IsTargetWithinSwingRange(Enum.PlayerSwingType.MainHand)

Returns nil when no range answer is possible. Reasons include no current target, an unattackable target, or no weapon equipped for that swing type. A nil value must not be read as out of range. Auto-attacks apply only to the current target, so no other unit can be queried.

System

Host/OS-level helpers that aren't tied to a game domain.

GetPhysicalScreenSize()

Returns (widthPixels, heightPixels) — the display's physical resolution in pixels. FrameXML's PixelUtil builds its entire pixel↔UI-unit conversion on it (768.0 / physicalHeight), which is what lets addons snap frames to whole pixels for crisp borders at fractional UI scales.

local w, h = GetPhysicalScreenSize()   -- e.g. 1920, 1080

The stock client has no such native — and no pre-parsed pixel-dimension global. GetScreenWidth()/GetScreenHeight() return UI-space units (derived from the UIParent aspect ratio at [UIParent + 0x7c]), not pixels. The engine only ever holds the current mode as the gxResolution CVar string ("WIDTHxHEIGHT"); both SetScreenResolution (0x0048bfd0) and the display-init path sscanf it on demand rather than caching dimensions anywhere. So we read the gxResolution CVar directly through the engine's by-name lookup (FUN_FIND_CVAR, value at +0x20) and parse it — the same source of truth the engine uses.

Returns 1024, 768 (→ factor 1.0, making PixelUtil a 1:1 no-op) when the CVar is missing or unparseable. Caveat: in windowed mode gxResolution reflects the configured render resolution, which may differ from the OS window's client size — there is no separate window-pixel query.

The companion PixelUtil table (PixelUtil.SetPoint/SetSize/ SetStatusBarValue/…) is provided Lua-side by the ClassicAPI addon and consumes this function.

CopyToClipboard(text [, removeMarkup])

Copies text to the Windows clipboard and returns the number of bytes copied. There is no stock clipboard access, so this is a ClassicAPI addition backed by the Win32 clipboard API. Available on both the in-game and login/character-select screens.

  • text — the string to copy (numbers are coerced, like Lua does).
  • removeMarkup — optional; when truthy, strip WoW UI escape sequences before copying so the clipboard gets plain text:
    • |cAARRGGBB … |r color codes → removed
    • |H…|h text |h hyperlinks → keeps only the visible text
    • |T…|t textures → removed
    • |n → newline, || → literal |
    • unknown |x sequences are left verbatim, so real text that merely contains a pipe survives
  • returns the byte length of the copied (possibly stripped) string — consistent with Lua's #s. 0 for an empty string or if the clipboard couldn't be opened.
CopyToClipboard("hello world")                              -- 11
local link = "\124cffff0000Red\124r"                        -- real single-pipe markup
CopyToClipboard(link, true)                                 -- 3   (clipboard gets "Red")
CopyToClipboard(link)                                       -- 15  (raw escapes copied)

The string is treated as UTF-8 (matching the credential and TTS modules) and written as CF_UNICODETEXT; Windows synthesizes CF_TEXT for legacy consumers.

Note when testing from the chat box: the edit box escapes every typed | into ||, so a literal |cff… you type is no longer a color code by the time it reaches Lua. Build real markup with \124 (as above) to exercise removeMarkup.

Talent

GetTalentSpellID(tabIndex, talentIndex, [rank[, classID]])

Returns the spellID for the talent at the given (tabIndex, talentIndex) and rank, or nil if the talent index is out of range or the rank slot is empty. 1.12's GetTalentInfo returns (name, icon, tier, column, currentRank, maxRank, ...) with no spellID; this fills the gap so addons can chain into the spell APIs without maintaining their own (class, tab, idx) → spellID lookup tables.

GetTalentSpellID(1, 9)           -- 14751  (Inner Focus, single rank)
GetTalentSpellID(1, 1)           -- 14525  (Wand Specialization, current rank)
GetTalentSpellID(1, 1, 1)        -- 14524  (Wand Specialization rank 1)
GetTalentSpellID(1, 1, 2)        -- 14525  (Wand Specialization rank 2)
GetTalentSpellID(1, 1, 1, 8)     -- 11210  (Arcane Subtlety r1 — Mage, classID 8)

-- chains into the spell APIs cleanly
GameTooltip:SetSpellByID(GetTalentSpellID(1, 9))
print(C_Spell.GetSpellName(GetTalentSpellID(1, 9)))  -- "Inner Focus"

rank is optional. When omitted, defaults to the player's currentRank by delegating to the engine's existing GetTalentInfo (5th return) — that's the same player-state derivation the engine uses for talent UI rendering, including the spell-knowledge checks that account for talent-granted abilities. If currentRank is 0 (no points spent), falls back to rank 1 so the function still produces the talent's canonical spellID for unallocated talents.

When provided explicitly, returns the spellID at that rank regardless of how many points the player has invested. Useful for tooltip-on-hover scenarios where you want to preview "what rank 5 would do" without respec'ing.

classID is optional (4th arg; pass rank as nil to keep the default) and queries any class's talent tree via Talent.dbc / TalentTab.dbc — the same cross-class path as GetTalentIDByIndex, with identical (tab, idx) ordering. Because there's no "currentRank" for a class you aren't, a cross-class query with no explicit rank defaults to rank 1 (rather than the player-currentRank default of the same-class path).

Returns nil for:

  • non-numeric or non-positive tabIndex / talentIndex
  • tabIndex or talentIndex out of range for the class
  • explicit rank exceeding the talent's allocated max — e.g. asking for rank 5 on a 1-rank talent like Inner Focus
  • a classID that matches no class

Without classID, reads the engine's per-tab talent arrays at [0x00BDCD28] (populated at login from Talent.dbc filtered by the player's class); with it, reads the Talent.dbc flat array directly. Either way the SpellRank[] array lives at offset +0x10 of each record (stride 0x54), one spellID per rank — the engine populates indices 0..4 (ranks 1..5), the higher slots stay zero.

Equivalent to one of GetTalentInfo's extended returns — the talent's spellID.

GetTalentIDByIndex(tabIndex, talentIndex[, classID])

Returns the engine's hidden talentID — the primary key of the Talent.dbc row — for the talent at the given (tab, idx). Returns nil for out-of-range indices.

GetTalentIDByIndex(1, 9)      -- 174  (Inner Focus, Discipline tier 3)
GetTalentIDByIndex(1, 1)      -- 166  (first Discipline talent)
GetTalentIDByIndex(1, 1, 8)   -- 37   (first Arcane talent — Mage, classID 8)

The stock GetTalentInfo(tab, idx) returns (name, icon, tier, column, currentRank, maxRank, ...) but NOT the talentID. We add this getter so addons that key on talentIDs (used for GetTalentInfoByID, talent build sharing strings, etc.) work unmodified.

The optional classID (1-based: Warrior 1, Paladin 2, Hunter 3, Rogue 4, Priest 5, Shaman 7, Mage 8, Warlock 9, Druid 11) queries any class's talent tree, not just the local player's — useful for talent-build tooling and previewing other specs. When omitted or nil, it falls back to the player's own class.

  • Without classID — reads TalentEntry+0x00 from the per-tab talent arrays at [0x00BDCD28] (the engine's runtime state for the local player's class). Same struct walk as GetTalentSpellID.
  • With classID — reads the Talent.dbc / TalentTab.dbc flat arrays directly, replicating the engine's tree builder (FUN_004f2c00): tabs in DBC row order filtered by class-mask, then talents in DBC row order within each tab. This is the same ordering the runtime arrays are built from, so GetTalentIDByIndex(t, i) and GetTalentIDByIndex(t, i, <player's class>) return identical values.

Equivalent to the talentID return slot of GetTalentInfo (which the stock client doesn't expose).

Targeting

Additional TargetScript selection functions. The engine already ships TargetNearestEnemy/Friend/PartyMember/RaidMember, TargetLastEnemy/Target, AssistUnit, TargetUnit, ClearTarget; these fill in the rest. They're built on the engine's own tab-targeting core — the same visible-unit enumeration, per-mode validity predicate (reaction/attackability/alive), and selection commit the native TargetNearestEnemy uses — so hostility semantics match the client exactly.

Not backported (no equivalent here — soft-target / action-camera features): TargetPriorityHighlightStart/End, IsTargetLoose, TargetToggle.

GetPlayerFacing()

Returns the player's facing as an angle in radians (0 … 2*pi, increasing counter-clockwise), or nothing off-world (glue / loading). The stock client doesn't ship this (only character-create/select facing exist), but it's the companion the direction-target functions below need — pass it to aim "in front of me."

local facing = GetPlayerFacing()   -- e.g. 1.65

Read from the player unit's movement-block orientation field (player + 0x9C4).

TargetDirectionEnemy(facing [, coneAngle])

Targets the nearest attackable unit within a cone centered on facing (an absolute world angle in radians, as returned by GetPlayerFacing()). coneAngle is the full cone width in radians; omitted, it defaults to pi/2 (90° — 45° to either side).

-- Target whatever enemy you're looking at:
TargetDirectionEnemy(GetPlayerFacing())

-- Wider 180° sweep in front:
TargetDirectionEnemy(GetPlayerFacing(), math.pi)

Does nothing if no enemy falls inside the cone. "Attackable" uses the engine's own reaction check, so it matches what TargetNearestEnemy would consider a valid enemy (hostile, alive, not a critter).

TargetDirectionFriend(facing [, coneAngle])

Same as TargetDirectionEnemy, but selects the nearest friendly (assistable) unit in the cone.

TargetNearest([reverse])

Cycles the nearest unit you can attack or assist. Repeated calls within ~1s step through candidates nearest→farthest (a frozen snapshot, so the order is stable mid-cycle); a longer gap or an externally-changed target rebuilds and re-targets the nearest. reverse (truthy) steps the other way.

TargetNearestEnemyPlayer([reverse])

Like TargetNearest, but restricted to hostile players (PvP targeting). Same cycle behavior and reverse flag.

TargetNearestFriendPlayer([reverse])

Like TargetNearestEnemyPlayer, but restricted to friendly players.

TaxiMap

Backports of the taxi-map node enumerators, reading TaxiNodes.dbc (static) and the open flight master's live session. Both also register the Enum.FlightPathFaction (Neutral=0, Horde=1, Alliance=2) and Enum.FlightPathState (Current=0, Reachable=1, Unreachable=2) tables, so code comparing against those enums ports unchanged.

Ship both because they answer different questions:

function source use
C_TaxiMap.GetTaxiNodesForMap([mapID]) TaxiNodes.dbc (static) every flight point (one continent, or all with no arg), faction-tagged, discovered or not — for a database / map overlay
C_TaxiMap.GetAllTaxiNodes([uiMapID]) live taxi session the nodes reachable from the open flight master, with per-node state + the slot index to fly — for a flight-map UI

C_TaxiMap.GetTaxiNodesForMap([mapID])

Returns an array of flight masters. A numeric mapID (a Map.dbc id — 0 Eastern Kingdoms, 1 Kalimdor, 30 Alterac Valley, … — the same identity C_Map.GetAreaTriggers([mapID]) uses) filters to that continent. Omitted / non-number returns every flight master on every continent — a one-call flight database (each entry carries its own mapID). This is a ClassicAPI extension to the required-argument signature.

Only real flight masters are returned. TaxiNodes.dbc also contains non-flight rows — transports/boats/zeppelins (no flight mount) and non-selectable markers like Northshire Abbey or standalone instance entries (no connecting flight path) — which are filtered out: a node is included only if it has a flight mount and is a TaxiPath.dbc endpoint (some flight path connects to it).

Each entry:

field meaning
nodeID TaxiNodes.dbc row id
name localized node name ("Stormwind, Elwynn Forest")
faction Enum.FlightPathFaction — Alliance-mount-only → Alliance, Horde-only → Horde, both → Neutral
reachable (ext) a flight path leads to this node (in-degree > 0). false = departure-only, reachable only by other means (druid Teleport → "Nighthaven, Moonglade"; a boat dock → "Stormwind Harbor")
position {x, y} 0–1 on the continent map
mapID (ext) continent Map.dbc id
x / y / z (ext) raw continent world coords
areaID (ext) the AreaTable zone, from the node's own name — its location if that's a mapped city ("Orgrimmar" → Orgrimmar city), else the zone suffix ("…, Western Plaguelands" → WPL). Authoritative; absent if unresolved
mapX / mapY (ext) 0–100 position within that zone (mapX horizontal, mapY vertical)

nodeID / name / faction / position are the standard fields; the rest are ClassicAPI extensions (marked (ext)). position is continent 0–1, while areaID + mapX/mapY let a zone-map addon pin directly. areaID comes from the node's name, matched against AreaTable — the location part when it's a mapped city ("Orgrimmar, Durotar" → Orgrimmar city, so a capital's master pins on the city map), otherwise the zone suffix ("Chillwind Camp, Western Plaguelands" → WPL). Each candidate is accepted only if its map rect contains the node, so this is right at borders a purely geometric guess gets wrong (Chillwind → WPL, not the Alterac box it sits in). Nodes whose name resolves to no containing zone fall back to the geometric landmass resolver. isUndiscovered is not wired (the client's known-node bitfield isn't read) — every node is returned regardless of discovery.

for _, n in ipairs(C_TaxiMap.GetTaxiNodesForMap(0)) do
    if n.faction == Enum.FlightPathFaction.Alliance and n.areaID then
        Pin(n.areaID, n.mapX, n.mapY, n.name)   -- zone-map pin
    end
end

Replaces hand-scraped flight tables (pfQuest's pfDB.meta.flight): draw a node per entry (filtered by faction, using areaID+mapX/mapY) exactly like SearchAreaTriggerID. Auto-covers custom-server flight points — no NPC-id curation.

C_TaxiMap.GetAllTaxiNodes([uiMapID])

Returns the live flight-master destination list — the nodes reachable from the currently open taxi map, with reachability state and the slot index needed to take the flight. Only meaningful while the taxi map is open (between TAXIMAP_OPENED and TAXIMAP_CLOSED); returns an empty table otherwise. uiMapID is accepted for signature parity but ignored (there is a single active taxi map).

Each entry:

field meaning
slotIndex the legacy 1..NumTaxiNodes() index — pass to TakeTaxiNode(slotIndex) to fly
name node name
position {x, y} 0–1 on the flight-map image
state Enum.FlightPathState — CURRENT→Current, REACHABLE→Reachable, DISTANT/none→Unreachable
nodeID TaxiNodes.dbc id (resolved from the slot's node record; 0 if absent)

No faction / isUndiscovered here — GetAllTaxiNodes doesn't carry them (reachability implies faction); that data lives on GetTaxiNodesForMap. The textureKit / useSpecialIcon cosmetic fields have no equivalent here and are omitted.

-- Fly to the first reachable destination:
for _, n in ipairs(C_TaxiMap.GetAllTaxiNodes()) do
    if n.state == Enum.FlightPathState.Reachable then
        TakeTaxiNode(n.slotIndex)
        break
    end
end

This is the interactive counterpart to GetTaxiNodesForMap: session-gated and player-relative (its whole point is the open master's reachable set), which is why a static database like pfQuest uses GetTaxiNodesForMap instead.

C_TaxiMap.GetTaxiPaths()

Returns every flight path (directed edge) in TaxiPath.dbc as an array of tables — the taxi routing graph. No flight master needed; reads the DBC directly.

local paths = C_TaxiMap.GetTaxiPaths()
-- paths[i] = { pathID = 94, fromNodeID = 8, toNodeID = 16, cost = 110 }
Field Type Meaning
pathID number TaxiPath.dbc id — pass to GetTaxiPathWaypoints
fromNodeID number source TaxiNodes.dbc id
toNodeID number destination TaxiNodes.dbc id
cost number flight cost in copper (the routing weight)

Directed — A→B and B→A are two separate entries with their own path IDs and geometry. Combine with GetTaxiNodesForMap (the node table) to build a weighted graph, and with GetTaxiPathWaypoints to measure real distances.

C_TaxiMap.GetTaxiPathWaypoints(pathID)

Returns the waypoint polyline of one flight path as an array of world coordinates, ordered by node index (flight order).

local wp = C_TaxiMap.GetTaxiPathWaypoints(94)
-- wp[i] = { x = -8851.66, y = 498.55, z = 111.26, mapID = 0 }
Field Type Meaning
x, y, z number world position (yards)
mapID number continent (Map.dbc id)

Returns nothing for an unknown pathID or an empty path. Sum the 3D distances between consecutive waypoints for the path's length; taxi flight speed is 32 yd/s, so length / 32 ≈ flight seconds for a single hop. (Multi-hop trips are shorter than the sum of their segments — the server cuts the corner at each intermediate flight master via data not present in any client file, so a client-side estimate of a chained route is an upper bound.)

C_TaxiMap.GetTaxiRoute(slotIndex)

Returns the node chain the client would actually fly to a taxi-map slot, as an array of TaxiNodes.dbc IDs in flight order ({ currentNodeID, …, destNodeID }). slotIndex is the legacy 1..NumTaxiNodes() index (the same one TaxiNodeName/TakeTaxiNode take). Only meaningful while the taxi map is open.

local route = C_TaxiMap.GetTaxiRoute(slotIndex)
-- route = { 8, 16, 43, 66 }  (Thelsamar -> Refuge Pointe -> Aerie Peak -> Chillwind)

This is the exact chain TakeTaxiNode(slotIndex) would send to the server — mirrors the client's own routing decision: a single direct hop when a TaxiPath edge exists ({ current, dest }), otherwise the client's precomputed multi-hop route (which, per character, may detour around flight points you haven't discovered). Returns nothing when the map is closed, the slot is out of range, it's the current node, or no route exists.

Pairs with GetTaxiPaths (each consecutive (from, to) names a pathID) and GetTaxiPathWaypoints (that path's geometry) to measure a chained trip without matching flight-map pixel coordinates back to nodes.

Texture

An atlas is a name for one piece of art. The name points to a texture file and to a rectangle inside that file. texture:SetAtlas(name) applies both in one step, so you never write the coordinates at the call site.

ClassicAPI ships a small set of built-in atlas names. Call C_Texture.GetAtlasElements() to list them. To use your own art, describe it once with C_Texture.RegisterAtlas and then set it by name like any other atlas.

Atlas names are not case sensitive.

The eight raid target markers are published as atlases, named UI-RaidTargetingIcon_1 through UI-RaidTargetingIcon_8. All eight markers share one sheet here, so an atlas is the only way to name a single marker.

markerTexture:SetAtlas("UI-RaidTargetingIcon_8", true)   -- the skull

C_Texture.GetAtlasInfo(atlasName)

Returns a table that describes the atlas, or nil when the name is not known.

Table fields:

Field Type Notes
elementName string The name in its canonical spelling.
width number Width of the art in pixels.
height number Height of the art in pixels.
leftTexCoord number Left edge of the rectangle, from 0 to 1.
rightTexCoord number Right edge of the rectangle, from 0 to 1.
topTexCoord number Top edge of the rectangle, from 0 to 1.
bottomTexCoord number Bottom edge of the rectangle, from 0 to 1.
tilesHorizontally boolean True when the art repeats across its width.
tilesVertically boolean True when the art repeats down its height.
filename string Path of the texture that holds the art.

Textures are named by path, so filename carries the texture and the file field is not set.

local info = C_Texture.GetAtlasInfo("Repair")
print(info.filename, info.width, info.height)

C_Texture.GetAtlasExists(atlasName)

Returns true when the name is known and false when it is not. This agrees with GetAtlasInfo at all times. A name that exists always returns a table.

C_Texture.GetAtlasID(atlasName)

Returns the id of the sheet that holds the atlas, or nil when the name is not known. A built-in atlas has a positive id. An atlas you add with C_Texture.RegisterAtlas gets a negative id, so the two kinds never collide.

C_Texture.GetAtlasElementID(atlasName)

Returns the id of this one piece of art, or nil when the name is not known. The same positive and negative rule applies.

C_Texture.GetAtlasElements()

Returns an array of every atlas name that is currently known, in alphabetical order. The list includes the names you registered yourself.

for _, name in ipairs(C_Texture.GetAtlasElements()) do
    print(name)
end

C_Texture.RegisterAtlas(name, file, width, height, left, right, top, bottom [, tilesHorizontally, tilesVertically])

A ClassicAPI extension. Adds an atlas name that points at your own art, so SetAtlas and atlas markup work with a sprite sheet you ship yourself.

  • name — the atlas name callers will use.
  • file — texture path, without the file extension.
  • width, height — size of the art in pixels, used by useAtlasSize.
  • left, right, top, bottom — the rectangle inside the file, from 0 to 1.

Registering a name a second time replaces its definition and keeps its ids, so a caller that stored an id keeps a valid one. Returns true.

-- One 32x32 cell from the top-left of a 128x128 sheet.
C_Texture.RegisterAtlas("myaddon-gem", "Interface\\AddOns\\MyAddon\\sheet",
                        32, 32, 0, 0.25, 0, 0.25)

myTexture:SetAtlas("myaddon-gem", true)

Atlas markup

|A:atlasName:height:width|a draws an atlas inside any text, next to the |T…|t form that draws a texture path. Use it in chat messages, tooltip lines, and font strings.

  • A height of 0 uses the atlas's own pixel height.
  • A width of 0 keeps the atlas's own shape at the height you asked for.
  • Two more fields shift the art from the text, as |A:name:h:w:offsetX:offsetY|a.
print("Repair cost: |A:Repair:0:0|a")
print("Small marker |A:MinimapArrow:12:12|a here")

An unknown name draws nothing and leaves the rest of the line intact. Art that repeats is drawn once, so tilesHorizontally and tilesVertically have no effect in text.

Text typed by other players never draws atlas art. This matches how a typed texture path behaves.

Time

GetServerTime()

Returns the current server clock as a Unix epoch timestamp (seconds since 1970-01-01 UTC), or nil before login (while the engine's game-time struct is still BSS-zero).

local now = GetServerTime()
-- now = 1778260148 (Fri 2026-05-08 17:09:08 UTC)

A client can replace this global. Some clients declare their own GetServerTime function in FrameXML, which replaces the global with a different return value. Turtle WoW is one, and its version returns the server hour and the server minute. Call ClassicAPI.GetServerTime() to reach the timestamp on such a client.

The value is an instant, with second resolution. It is independent of the realm's timezone and of any per-zone time shift the realm applies, so it stays continuous when the player changes continents. That makes it the right call for log timestamps, sorting, and anything that counts down. Stock GetTime() measures the session instead, and cannot be compared against it.

To print the realm's clock digits rather than the instant, use C_DateAndTime.GetServerTimeLocal().

Right after login. The realm reports the instant on request, and the answer takes a moment to arrive. Until it does, this function reports the realm's wall clock instead, which is wrong by the realm's timezone offset. The value corrects itself once the answer lands, so a caller that starts at login can see it step once.

GetTimeCached()

Returns the same value as GetTime() — seconds on the engine's uptime clock — but frame-stable: every call within a single frame returns the identical value, refreshed once per frame. Same epoch as GetTime(), so the two are directly comparable.

Provides a frame-stable GetTime() (sampled once per frame in the main loop) under a distinct name, leaving the stock GetTime() untouched — GetTime() is live, recomputing the OS tick on every call, so two same-frame GetTime() calls can differ.

-- Frame-stable: consistent timestamps across a frame's work
local now = GetTimeCached()
if now - lastFire >= interval then
    lastFire = now
    ...
end

Not a performance feature. The underlying tick source is cheap (GetTickCount, a user-mode shared-page read, or rdtsc), and both functions pay the same Lua→C call overhead, which dominates. So GetTimeCached() is not meaningfully faster than GetTime() — its only advantage is the frame-stable semantics. To actually cut cost in a hot OnUpdate, cache the value in a Lua local (local now = GetTime()) and reuse it; that removes the per-call Lua dispatch, which a cached C function cannot.

The cache is refreshed from the shared once-per-frame Tick::WorldTick hook; before the first world tick (pre-login / glue) it falls back to a live sample, so it never returns 0.

C_Timer.After(seconds, callback)

Schedules callback to fire once after seconds. Returns nothing. C_Timer.After as an engine binding — a Script_* C function, not Lua.

C_Timer.After(0.5, function() print("half a second later") end)
C_Timer.After(0, function() print("next frame") end)

Errors in the callback are caught (via lua_pcall) and silently swallowed. One broken timer doesn't poison other timers on the same tick.

C_Timer.NewTimer(seconds, callback)

Like After, but returns a cancel-handle table:

local handle = C_Timer.NewTimer(10, function() print("never if I cancel") end)
handle:Cancel()           -- prevents the callback from firing
print(handle:IsCancelled()) -- true

Cancel() is idempotent; calling it on a timer that already fired or was previously cancelled is a no-op. IsCancelled() returns true after Cancel(), after natural expiry, or for an unknown timer ID. Both methods take no arguments other than self.

C_Timer.NewTicker(seconds, callback, [iterations])

Recurring variant. callback fires every seconds until cancelled or until iterations fires have happened. iterations is optional; omitted or non-numeric means forever.

local h = C_Timer.NewTicker(1, function() print("each second") end)
-- ...later
h:Cancel()

-- bounded:
C_Timer.NewTicker(0.25, function() ... end, 8)  -- fires 8 times, every 250ms

Minimum period is clamped to 0.0001 to prevent tight loops. Returns the same kind of cancel handle as NewTimer. The handle auto-cancels itself when the iteration count exhausts.

Implementation notes (apply to all three functions):

  • One unified timer queue walked once per frame from FUN_WORLD_TICK's subscription. Same hook the BAG_UPDATE_DELAYED flusher uses, so there's no per-feature hook overhead.
  • Resolution is one frame (~16ms at 60fps). A timer scheduled for 0.001 seconds will fire on the next tick, same as one scheduled for 0.
  • The timer queue uses a monotonic clock (QueryPerformanceCounter), so timers don't drift if the system clock changes mid-session.
  • Callbacks are pinned in the Lua registry under per-timer string keys so they survive GC between scheduling and firing.

C_DateAndTime overview

Backport of the C_DateAndTime namespace — calendar-style date math built on top of the server clock. The calendar functions exchange CalendarTime tables with these fields (matching Blizzard's TimeDocumentation.lua):

Field Range Notes
year full year, e.g. 2026
month 1..12 luaIndex (1-based)
monthDay 1..31 luaIndex (1-based)
weekday 1..7 luaIndex; 1 = Sunday
hour 0..23
minute 0..59

Two clocks. A realm sends the client both an instant and a wall clock, and they differ by the realm's timezone. GetServerTime and GetSecondsUntilDailyReset report the instant, so they are the ones to compare, sort, store, and count down with. GetCurrentCalendarTime and GetServerTimeLocal report the wall clock, for display, and always agree with GetGameTime().

Daily reset semantics. GetSecondsUntilDailyReset counts down to UTC midnight, which is where a realm's own day boundary sits. A realm can shift that boundary by a configured amount, and it never tells the client, so a realm that shifts it resets at a different moment.

Weekly reset not implemented. There is no server-broadcast weekly reset schedule, and Turtle WoW realm schedules vary, so C_DateAndTime.GetSecondsUntilWeeklyReset would have to hardcode a weekday/hour that's wrong on some realms. Addons that need it can compute their own value from the daily reset + a CVAR.

C_DateAndTime.GetCurrentCalendarTime()

Returns the realm's date and time as a CalendarTime table, or nothing before login. The hour and minute fields always agree with GetGameTime(). This is realm time, not the instant, so it does not match GetCalendarTimeFromEpoch(GetServerTime()) on a realm that is not on UTC.

local t = C_DateAndTime.GetCurrentCalendarTime()
-- t = { year=2026, month=5, monthDay=11, weekday=2, hour=14, minute=30 }

C_DateAndTime.GetCalendarTimeFromEpoch(epoch)

Converts a Unix epoch (seconds since 1970-01-01 UTC) to a CalendarTime table. Pure gmtime; no engine state touched.

C_DateAndTime.AdjustTimeByDays(date, days) / AdjustTimeByMinutes(date, minutes)

Returns a new CalendarTime offset from the input by the given number of days (or minutes). Negative deltas walk backwards. Out-of-range inputs (month=13, monthDay=32) normalize via the underlying _mkgmtime / gmtime round-trip.

local today = C_DateAndTime.GetCurrentCalendarTime()
local tomorrow = C_DateAndTime.AdjustTimeByDays(today, 1)
local yesterday = C_DateAndTime.AdjustTimeByDays(today, -1)

C_DateAndTime.CompareCalendarTime(lhs, rhs)

Returns -1 if lhs < rhs, 0 if equal, 1 if lhs > rhs. Compares by epoch conversion so denormalized inputs sort consistently.

C_DateAndTime.GetServerTimeLocal()

Returns GetServerTime() offset by the server's timezone. The result is a display value, not an instant: formatting it shows the realm's clock digits. If the realm reports 14:30, this returns the epoch for 14:30 UTC, so date("!%H:%M", t) prints 14:30. Do not compare it against a real timestamp.

C_DateAndTime.GetSecondsUntilDailyReset()

Returns seconds until the next server midnight (00:00:00 in server wall-clock time). Returns 0 if called before login.

See the overview's "Daily reset semantics" note — this uses server midnight, not UTC midnight (though they happen to coincide for Turtle WoW since the server reports UTC-aligned components).

Totem

GetTotemInfo(slot)

Returns haveTotem, totemName, startTime, duration, icon, modRate, spellID for one of the four shaman totem slots (1 Fire, 2 Earth, 3 Water, 4 Air).

local _, name, start, duration, icon = GetTotemInfo(1)   -- Fire slot
if name ~= "" then                                       -- a totem IS active
    local remaining = duration - (GetTime() - start)
    -- name = "Searing Totem", icon = "Interface\\Icons\\...", etc.
end
Return Meaning
haveTotem true if the player carries the slot's totem tool (Fire/Earth/Water/Air Totem item) — the documented meaning, not whether a totem is summoned.
totemName The active totem spell's localized name ("" when no totem is out — this is how you test "is a totem active").
startTime GetTime() value when the totem was cast (0 when none).
duration Total duration in seconds (0 when none).
icon The active totem spell's icon texture path, or nil when none.
modRate Always 1 — there is no per-totem haste.
spellID The active summon spell's ID (0 when none).

haveTotem is tool presence, not summon state. Matching the live API: haveTotem reflects whether you hold the slot's totem tool item (Fire Totem 5176, Earth Totem 5175, Water Totem 5177, Air Totem 5178) in bags/equipped, independent of whether a totem is currently deployed. The tool item is the totem spell's Totem[0] requirement (a tool, not a consumed reagent), read from the spell — not hardcoded. To check for an active totem, test totemName ~= "" (or startTime > 0).

There is no client-side totem tracking here (no totem bar, PLAYER_TOTEM_UPDATE, or GetTotemInfo), so this is self-tracked but data-driven: the slot is read from the summon spell's Spell.dbc effect (SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air), duration from SpellDuration.dbc, the totem's creature entry (for death detection) from the effect's EffectMiscValue, and the haveTotem tool item from the spell's Totem field (the totem the spell requires) — resolved from the player's spellbook, not a hardcoded table. Casts are observed via the SMSG_SPELL_GO hook; a slot clears when its duration elapses OR when the totem creature disappears early (killed, Totemic Recall, or destroyed) — detected by scanning the object manager for the player-owned totem creature. Because it's data-driven, Turtle-custom totems are tracked automatically. Slot changes fire PLAYER_TOTEM_UPDATE.

GetTotemTimeLeft(slot)

Returns the seconds remaining before the slot's totem is auto-destroyed, or 0 when no totem is active in that slot. Slots: 1 Fire, 2 Earth, 3 Water, 4 Air.

local remaining = GetTotemTimeLeft(1)   -- Fire slot, e.g. 12.4

Computed from the same tracker as GetTotemInfo (startTime + duration − now), so it shares that data's caveats — most notably it reflects the totem's full duration from cast, unaffected by any server-side early expiry the client isn't told about (the WorldTick scan still clears the slot when the totem creature actually disappears).

GetTotemDuration(slot)

Returns the slot's active totem's total duration in seconds (its full lifetime from cast), or 0 when no totem is active. The companion to GetTotemTimeLeft (remaining time) — same value as GetTotemInfo's 4th return.

local total = GetTotemDuration(1)                       -- e.g. 60
local frac  = GetTotemTimeLeft(1) / GetTotemDuration(1) -- bar fill

TargetTotem(slot)

Targets the player's totem in the given slot (1 Fire, 2 Earth, 3 Water, 4 Air). No-op when the slot has no active totem or the totem creature isn't currently visible in the client.

TargetTotem(2)   -- target your Earth totem

Looks up the totem creature's live GUID (object-manager scan for the player-owned creature of the slot's tracked entry) and sets the target through the same engine path TargetUnit uses (CMSG_SET_SELECTION).

Tracking

The engine stores only the tracking spell that is active now. It does not keep a list of the tracking spells you know. These globals add that list. A minimap addon can then build a tracking menu without a hardcoded spell table.

A tracking spell is a spell in your spellbook that finds creatures, resources, or hidden units. This is the same test the engine uses to find the active tracker. The list covers Find Herbs, Find Minerals, Find Treasure, the Hunter Track spells, Sense Undead, Sense Demons, and Track Humanoids. It also covers server-added trackers, such as Turtle's Find Trees. You do not have to maintain a spell list.

GetNumTrackingTypes()

Returns the number of tracking spells in your spellbook.

GetNumTrackingTypes()   -- for example, 8 for a Hunter

GetTrackingInfo(index)

index starts at 1 and follows spellbook order. The function returns these five values, or nil if index is out of range:

# Value Type Notes
1 name string Localized spell name.
2 texture string Icon path. Give it to texture:SetTexture(...). nil if the icon is missing.
3 active boolean true when this tracker is the one now in effect.
4 category string Always "spell". There are no non-spell trackers here.
5 spellID number The tracking spell's ID. This is a ClassicAPI extra.
local name, texture, active, category, spellID = GetTrackingInfo(1)

SetTracking(index)

Turns on the tracking spell at index. You select a tracker when you cast its spell. This function casts it. For an out-of-range index, it does nothing. To turn tracking off, use the built-in CancelTrackingBuff().

for i = 1, GetNumTrackingTypes() do
    local name = GetTrackingInfo(i)
    if name == "Find Herbs" then
        SetTracking(i)
        break
    end
end

TradeSkillUI

The trade-skill list link — the shareable |Htrade:...|h[Profession]|h link that shows a player's profession and which recipes they know. The engine has no such link type (its hyperlink parser only knows item: and enchant:), so both the link and the click-to-view UI are synthesized. Clicking a received link opens a crafting-window-style frame (the real TradeSkillFrame parchment art) listing the recipes the linker knows — difficulty-coloured and sorted high-craft-first — with a skill-rank bar and an "X of Y recipes known" summary.

The "known recipes" data is a bitfield over the skill line's canonical recipe list — every SkillLineAbility.dbc row for the skill line that is a craftable recipe, in ascending record-ID order. That ordering is identical on every client with the same DBC, so the sender and reader agree on the bit-index space with no handshake. Whether the sender knows a given recipe comes from the same player-spell bitmap IsPlayerSpell reads (which covers profession recipes), so the link is self-contained — the reader needs neither the sender online nor the profession itself.

Returns the |Htrade:...|h link for the currently-open trade skill window, or nil if no profession window is open.

-- with a profession window open:
local link = C_TradeSkillUI.GetTradeSkillListLink()
-- "|cffffd000|Htrade:164:300:300:Bob:eE0C...|h[Blacksmithing]|h|r"
ChatEdit_InsertLink(link)   -- or SendChatMessage(link, "GUILD"), etc.

Built entirely in the DLL — no addon wiring. The window only needs to be open long enough to identify the profession; the recipe data is read from the spell bitmap, not the window's server list. Wire format (a ClassicAPI-private shape modeled on the 2.x link, with the linker's name in the slot 2.x used for the GUID):

|cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r

skillLineID is the SkillLine.dbc row (164 = Blacksmithing); curRank / maxRank are the linker's skill level (cosmetic); linkerName is the linking character's name (shown in the viewer title); the base64 tail packs 6 recipes per character, LSB-first. Links from before this field was added (no linkerName) still decode — the viewer just omits the name from its title.

Clicking a trade: link is handled automatically (an override of SetItemRef) — it opens the recipe viewer. No addon wiring needed.

Same as GetTradeSkillListLink, but for the Craft window — the separate profession frame used by Enchanting and pet training. Returns nil unless CraftFrame is shown. The two windows have separate storage and can be open simultaneously, so they get separate builders; the resulting link is the same |Htrade:...|h format and opens the same viewer.

local link = C_TradeSkillUI.GetCraftListLink()   -- with the enchanting window open
-- "|cffffd000|Htrade:333:300:300:Bob:...|h[Enchanting]|h|r"

C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)

Decodes a link's bitfield against a skill line's canonical recipe list. Returns an array table:

local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(164, "eE0C...")
-- recipes[i] = { spellID, isKnown, trivialLevel, greenLevel, createdItem }
for i = 1, table.getn(recipes) do
    local name = GetSpellInfo(recipes[i].spellID)
    print(name, recipes[i].isKnown and "known" or "unknown")
end

trivialLevel / greenLevel are the skill levels the recipe turns grey / green at — the recipe's difficulty band, coloured against the linker's skill exactly as the default UI does (the required-skill field is uselessly 1 for nearly every trainer-taught recipe). createdItem is the crafted item's ID (0 for enchants), which the viewer uses to build its subclass filter.

The array is in canonical recipe order (one entry per possible recipe in the skill line, not just the known ones). Turn a spellID into a name/icon with GetSpellInfo. Recipes past the end of a short/garbled bits string decode as not-known rather than erroring.

UIColor

C_UIColor.GetColors()

Returns an array of rows, each shaped { baseTag = "FOO_COLOR", color = ColorMixin }, mirroring the function of the same name. The color field is a real ColorMixin instance (carries GetRGB, GenerateHexColorMarkup, etc.) — the DLL calls back into Lua's CreateColor(r,g,b,a) per row to construct it, so ColorMixin and CreateColor must already be defined when GetColors is called.

The companion addon !!!ClassicAPI/Util/Color.lua does both: it defines ColorMixin/CreateColor first, then loops the result the same way Blizzard_SharedXMLBase/Color.lua does — assigning each row as a global under its baseTag plus a _CODE variant holding the |c-prefixed hex markup:

for _, dbColor in ipairs(C_UIColor.GetColors()) do
    local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
    _G[dbColor.baseTag] = color
    _G[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end

After that runs, addons can use the standard color globals directly:

/dump ACHIEVEMENT_COLOR:GetRGB()       -- 1, 1, 0
/dump ITEM_EPIC_COLOR:GenerateHexColor()
ITEM_EPIC_COLOR_CODE .. "Legendary" .. "|r"

There is no GlobalColor.dbc here, so the data comes from a snapshot embedded in the DLL (see src/ui/ColorData.h). Duplicate baseTags in the source DBC (PURE_RED_COLOR, INVASION_*, etc.) are deduplicated keeping the higher-ID entry, matching what ipairs(DBColors) ends up assigning. Colors for Death Knight runes, Mythic+ medals, healing absorbs, glyphs, and the objective tracker aren't in the snapshot and so aren't surfaced — none have a use case here anyway.

If CreateColor happens not to be defined when GetColors is called (e.g. another DLL or addon manages to call us before !!!ClassicAPI loads), each color field falls back to a plain {r,g,b,a} table. The loop above tolerates both shapes — dbColor.color.r reads the same way either way — so consumers shouldn't notice.

Unit

UnitGUID(unit)

Returns the unit's 64-bit GUID formatted as a hex string "0xHHHHHHHHLLLLLLLL" (16 hex digits, hi dword first), or nil if the resolved unit's GUID is empty (e.g. "target" with nothing targeted, empty party/raid slot).

local guid = UnitGUID("player")  -- "0x0000000000000777" (low IDs are local-realm characters)
local guid = UnitGUID("target")  -- "0xF13000059A002553" (the F130... prefix tags creatures)
local guid = UnitGUID("party1")  -- works even if party1 is on a different continent

The returned string is itself accepted as a unit token — UnitName(guid), UnitHealth(guid), etc. all work — so a GUID round-trips as a stable handle. See Unit tokens (GUID literals).

Works for OOR party / raid members. Earlier versions of this function returned nil for "partyN" / "raidN" when the member's CGObject wasn't currently active in the client (e.g. on another continent, in a different zone phase). We now read from the engine's parallel group-roster GUID array (populated by SMSG_GROUP_LIST), which is independent of unit visibility.

GUID format. GUIDs here are plain 64-bit integers — there's no "Player-RealmID-CharacterID" / "Creature-0-0-MapID-..." prefix system. Addons written for the prefixed form will need to either accept the "0x..." form or extract the raw hex.

Errors on invalid unit tokens. Same behavior as the other unit-token functions (UnitAffectingCombat, UnitName, etc.) — passing a string that doesn't match a known unit ID raises a Lua error rather than returning nil. We match the engine's existing convention here. Unit tokens that resolve to "no current unit" (like "target" with nothing targeted) return nil cleanly via the GUID = 0 check.

UnitTokenFromGUID(guid)

Best-effort reverse lookup: given a GUID string in the "0xHHHHHHHHLLLLLLLL" format UnitGUID returns, walk the engine's known unit tokens and return the first one currently mapped to that GUID, or nil if none of them point at it.

The search order omits tokens the engine's resolver doesn't recognize (vehicle, arenaN, arenapetN, bossN, softenemy, softfriend, softinteract). nameplateN and focus are included — we hook the resolver so the engine recognizes both token forms. As with the other positional tokens, a returned "nameplate3" result is only valid at that instant; the slot may shift to a different unit between calls, so re-verify with UnitGUID(token) before reusing.

player → pet → party1..4 → partypet1..4 → raid1..40
       → raidpet1..40 → nameplate1..N → target → focus
       → npc → mouseover
local name, guid = GameTooltip:GetUnitGUID()
local token = UnitTokenFromGUID(guid)
if token then
    -- can now feed `token` to UnitHealth / UnitClass / etc.
end

The return is inherently unstable. Multiple tokens can map to the same GUID at once (your target IS your party1, your pet IS your raidpet5 if you're in a raid), and the mapping changes every time SMSG_GROUP_LIST fires, the player tabs target, or a party member loads in. If you cache a result, re-verify with UnitGUID(token) == originalGuid before reusing it.

Returns nil for malformed GUID strings, zero GUIDs, and GUIDs that don't match any currently-resolvable token — including ex-targets, ex-mouseover units, distant players seen in the chat log, etc. The engine simply doesn't address those by token.

UnitTokenFromName(name [, exactMatch])

Finds the unit called name and returns a token for it, or nil when nobody around matches. This is what lets a character name stand in for a unit token, as [target=Feral] does in a macro.

local token = UnitTokenFromName("Feral")
if token then
    print(UnitHealth(token), UnitClass(token))
end

The search matches on the start of the name and prefers the nearest unit, the same rule TargetByName uses. Pass a true exactMatch to require the whole name. It looks through your party, then your raid, then every unit loaded around you, so it reaches units that no standard token names.

The result is a standard token such as party1 or target when one fits, and otherwise the unit's GUID, which the unit functions also accept.

Treat it as a snapshot. Which token names a unit changes as you retarget or as the group changes, so resolve it again rather than storing it.

ClassicAPI extension.

UnitSubName(unit)

Returns the NPC's subtitle / title string (the small italic text under their name in the tooltip): "Innkeeper", "Stable Master", "<Master Engineer>", etc. Returns nil for players, unsynced NPCs, and NPCs with no subtitle.

/dump UnitSubName("target")
-- "Innkeeper"             (Stormwind innkeep)
-- "<Auction House>"       (auctioneer)
-- nil                     (a player, or a wolf in the woods)

The stock UnitName returns only one value, leaving no direct route to the subtitle. Addons that wanted it had to scrape GameTooltip text, which is fragile and required the unit to be hovered. UnitSubName reads it straight off the engine's creature cache ([unit + 0xB30] → [+0x10]), no tooltip round-trip.

Coverage caveat: the creature cache row is populated when an NPC becomes visible to the client AND the server has sent its CMSG_CREATURE_QUERY_RESPONSE. The first time a fresh NPC appears the row may be briefly NULL — usually only one or two frames. Subsequent calls succeed once the response lands.

UnitCreatureFamilyID(unit)

Returns the numeric CreatureFamily id (the CreatureFamily.dbc row — 1 = Wolf, 2 = Cat, 3 = Spider, …) for a unit, or nil when it has no family (players, non-beast NPCs, unsynced NPCs).

/dump UnitCreatureFamilyID("pet")     -- e.g. 1 for a wolf pet
/dump UnitCreatureFamilyID("target")  -- 2 for a cat, nil for a humanoid

Stock UnitCreatureFamily(unit) returns only the localized name ("Wolf", "Chat") — awkward to compare and locale-dependent. This extension exposes the raw id addons actually want for stable checks (tameable-pet filtering, family-specific ability tables). It's the same id the engine's UnitCreatureFamily resolves internally ([unit + 0xB30] → [+0x1C]) before mapping it through CreatureFamily.dbc for the display name — this just stops at the id. nil semantics match UnitCreatureFamily (both return nothing for a family of 0), and it shares UnitSubName's creature-cache coverage caveat (a fresh NPC's row may be briefly NULL for a frame or two).

UnitCreatureTypeID(unit)

Returns the numeric CreatureType id (the CreatureType.dbc row — 1 = Beast, 3 = Demon, 7 = Humanoid, …) for a unit, or nil when it has no resolvable type. The raw-id twin of UnitCreatureFamilyID.

/dump UnitCreatureTypeID("target")   -- 7 for a humanoid, 1 for a beast
/dump UnitCreatureTypeID("player")   -- 7 (Humanoid, via the player's race)

Stock UnitCreatureType(unit) returns only the localized name ("Humanoid", "Humanoïde") — awkward to compare and locale-dependent. This exposes the id addons want for stable checks. It calls the same inner resolver the engine's UnitCreatureType uses, which handles every unit kind (NPCs via the creature cache, players via their race → Humanoid). Pair with C_CreatureInfo.GetCreatureTypeInfo to get the localized name back from the id.

UnitCreatureID(unit)

Returns the numeric creature id — the NPC / creature-template entry — for a unit, or nil. This is the unit-token twin of C_CreatureInfo.GetCreatureID: it does C_CreatureInfo.GetCreatureID(UnitGUID(unit)) for you.

creatureID = UnitCreatureID(unit)
/dump UnitCreatureID("target")   -- 1842 for Hogger, nil for a player
/dump UnitCreatureID("pet")      -- your pet's creature-template id

The id is read live from the unit, so it always matches the name you see, and it is the id Wowhead and Turtle's database show for that NPC. For a unit that is out of view, the same fallback as C_CreatureInfo.GetCreatureID applies. Returns nil for a player (a player carries no template), an unresolvable-but-valid token ("target" with nothing targeted, an empty "partyN" slot), an out-of-view pet, and any unit whose GUID isn't a creature or pet. Raises a Lua error on a garbage token — the standard UnitX behavior. Pair with C_CreatureInfo.GetCreatureInfoByID to get the name from the id.

GetUnitSpeed(unit)

Returns currentSpeed, runSpeed, flightSpeed, swimSpeed — all four in yards/second. There is no flying, so flightSpeed is always 0.

local current, run, _, swim = GetUnitSpeed("player")
-- e.g. 7.0, 7.0, 0, 4.722 for an unmounted player running normally
-- e.g. 14.0, 14.0, 0, 4.722 with a 100% mount active (run speed
--      reflects the modifier; swim ignores mount)
  • currentSpeed — speed the engine would apply to this frame's movement step. 0 when stationary; otherwise the walk/run/swim/ backward variant the unit is actually moving with. Read via the engine's own selector at 0x007C4C90 so movement-flag handling (walking, swimming, taxi paths) matches the engine exactly.
  • runSpeed — raw forward-run speed from MovementInfo +0x8C. Includes mount / buff / debuff multipliers — the engine maintains this field as the post-modifier value, updated by SMSG_FORCE_RUN_SPEED_CHANGE and friends.
  • flightSpeed — always 0.
  • swimSpeed — raw forward-swim speed from MovementInfo +0x94.

All four returns are 0 if the token doesn't resolve to a live CGUnit — empty "target", out-of-range party member, etc. Pushes 0.0 rather than nil for non-visible units.

Reads [CGUnit + 0x118] to get the MovementInfo pointer, then reads speed fields directly. Field offsets verified via the movement-prediction loop (FUN_005FC350 and the sprint-jump calculator at 0x00511920 both reach into the same struct).

UnitIsAFK(unit)

Returns true if the unit is currently AFK (toggled via /afk or auto-set after idle timeout). Works for any player-controlled unit — local self, target, party*, raid*, inspect targets. NPCs always return false.

UnitIsAFK("player")   -- true if you've /afk'd
UnitIsAFK("target")   -- true if the targeted player is AFK
UnitIsAFK("party1")   -- true if party member 1 is AFK
UnitIsAFK("npc")      -- always false

How it works under the hood. PLAYER_FLAGS isn't broadcast as a UpdateField here, but every nearby player's CGPlayer-side info struct at [unit + 0xE68] carries it at byte +0x08. Same struct the engine reads when rendering the <AFK> prefix above a player's head. Verified against the in-game nameplate behavior.

UnitIsDND(unit)

Returns true if the unit is currently in DND mode ("Do Not Disturb", toggled via /dnd). Same unit-coverage as UnitIsAFK — any player-controlled unit, false for NPCs.

UnitIsDND("player")
UnitIsDND("target")

UnitIsFeignDeath(unit)

Returns true if the unit is feigning death (Hunter's Feign Death). Reads UNIT_FIELD_FLAGS bit 29 (0x20000000) from the broadcast descriptor — works for any unit since UNIT_FIELD_FLAGS is broadcast in object updates.

UnitIsFeignDeath("target")   -- true if a feigning hunter

UnitIsInMyGuild(unitOrName)

Returns 1 if the unit/character shares the player's guild, nil otherwise. Accepts either a unit token ("player", "target", "party1", etc.) or a literal character name ("Bob").

UnitIsInMyGuild("player")    -- 1 if you're in any guild
UnitIsInMyGuild("target")    -- 1 if your target is a guildmate
UnitIsInMyGuild("party1")    -- 1 if party member 1 is a guildmate
UnitIsInMyGuild("Bob")       -- 1 if there's a guildmate named Bob

Resolution strategy:

  1. Calls UnitName(input) via lua_pcall to canonicalize the input. Tokens resolve cleanly; literal names hit the engine's "Unknown unit name" error which pcall swallows.
  2. For valid tokens, attempts a fast direct comparison against the player's guild-key field at [unit + 0xE68 + 0x0C] — same field GetGuildInfo reads, populated immediately on guild join for the local player and for any synced player-controlled unit. No roster fetch needed for nearby/loaded units.
  3. Falls back to walking the engine's guild roster array (same backing storage GetGuildRosterInfo reads) by name. Required for out-of-range party members, distant raid members, and literal name input — needs GuildRoster() to have been called and the server's response to have arrived.

Return convention is 1.0 / nil, not boolean. Both work for if UnitIsInMyGuild(x) then checks.

The slow path reads the engine's full roster count ([0x00B73118], the same value GetNumGuildMembers(true) returns), not the online-only count, so offline guildmates resolve too — the "show offline" toggle doesn't affect lookup. The only requirement is that GuildRoster() has been called and the server's SMSG_GUILD_ROSTER response has arrived.

UnitIsPossessed(unit)

Returns true if the unit is currently possessed (priest's Mind Control, warlock's Subjugate Demon). Reads UNIT_FIELD_FLAGS bit 24 (0x01000000) — the standard UNIT_FLAG_POSSESSED per emulator sources — directly off the unit's m_objectFields descriptor. Works for any unit token since UNIT_FIELD_FLAGS is broadcast in object updates.

UnitIsPossessed("target")   -- true if mind-controlled

Distinct from UnitIsCharmed: charm covers any charm-type effect (including pets summoned via mob-charm spells), possess is the specific spell-driven take-over effect.

UnitIsMinion(unit)

Returns true if unit is a minion of a player — a pet, guardian, totem, or charmed creature — false otherwise. Primarily owner-based: the unit's owner GUID (SummonedBy / CreatedBy / CharmedBy) must resolve to a player, with "player-controlled but not itself a player" as a fallback. Owner-based is needed because summoned guardians (shown as "X's Minion") carry an owner but not the PLAYER_CONTROLLED flag, so a flag-only check would miss them. Players, NPCs, and world creatures return false.

Built from the engine's own primitives. A bad or unresolved unit token returns false.

UnitIsMinion("pet")     -- your pet → true
UnitIsMinion("player")  -- you → false
UnitIsMinion("target")  -- another player's felhunter → true; a boar → false

UnitIsPet(unit)

Returns true if unit is a pet of a player (as opposed to a guardian/totem "minion"), false otherwise — the pet↔minion discriminator. Uses the engine's own classifier: owned by a player and the family-based pet/minion test that drives the UNITNAME_TITLE_PET vs UNITNAME_TITLE_MINION tooltip label. Neither the GUID (pets and guardians share the 0xF14… prefix on this client) nor UNIT_FLAG_PLAYER_CONTROLLED (a "Minion" can carry it) reliably separates them, so the creature-family classifier is the correct signal; the owner-is-player gate also excludes wild tameable beasts of the same family.

UnitIsMinion is the umbrella (pet + guardian + totem); UnitIsPet is the controllable subset. A ClassicAPI extension, not a stock WoW global. A bad or unresolved unit token returns false.

UnitIsPet("pet")                 -- your pet → true
-- another player's "X's Pet"    → UnitIsMinion true, UnitIsPet true
-- another player's "X's Minion" → UnitIsMinion true, UnitIsPet false (guardian, not controllable)

UnitIsOtherPlayersPet(unit)

Returns true if unit is a minion (pet, guardian, totem, or charmed creature) whose owner is a player other than you, false otherwise. Owner-based: it reads the unit's owner GUID (SummonedBy / CreatedBy / CharmedBy), requires that owner to be a player, and requires it to differ from you. World creatures and players (no owner) return false; your own minions (owner is you) return false. A bad or unresolved unit token returns false.

UnitIsOtherPlayersPet("target")  -- a party member's pet → true; your own pet → false

UnitOwnerGUID(unit)

Returns the GUID string ("0x…") of the unit's owner — the summoner of a pet/guardian/totem, or the charmer of a charmed unit. nil for an unresolved unit and for anything with no owner (players, world creatures).

local owner = UnitOwnerGUID("pettarget")   -- e.g. the enemy hunter's GUID
if owner == UnitGUID("player") then ... end  -- is it my minion?

Reads the same owner field the pet predicates use (CharmedBy, else CreatedBy in the unit's m_objectFields).

UnitCreatedBySpell(unit)

Returns the spell ID that summoned unit: the totem-drop spell for a totem, or the summon spell for a pet or guardian. Returns nil for an unresolved unit, and for a unit that no spell summoned (players and world creatures).

This is the summoning spell, not a spell that the unit casts later. The spell that a totem casts stays on the server and never reaches the client. The client receives this value for every summoned unit, so it works for any unit in range, not only your own summons. Use GetSpellInfo to get a readable name.

A ClassicAPI extension, not a stock WoW global.

UnitCreatedBySpell("pet")                -- your pet's summon spell id
local id = UnitCreatedBySpell("target")  -- target a totem → its totem spell id
if id then print(GetSpellInfo(id)) end   -- prints the name, like "Searing Totem"

UnitStandState(unit)

Returns the unit's standstate as an integer, matching the Enum.PlayerStandState values:

Value Meaning
0 STANDING
1 SITTING
2 SITTING_CHAIR
3 SLEEPING
4 SITTING_LOW_CHAIR
5 SITTING_MEDIUM_CHAIR
6 SITTING_HIGH_CHAIR
7 DEAD
8 KNEELING

Reads the low byte of UNIT_BYTES_1 (descriptor +0x210), a broadcast UpdateField — works for any synced unit (player, target, party*, raid*, mouseover, etc.). Returns 0 (STANDING) for unresolvable units (empty party* slot, no current target, etc.) returning a safe default.

UnitStandState("player")    -- 0 standing, 1 sitting, 5 chair-sit, …
UnitStandState("target")    -- works for any visible unit
UnitStandState("party1")    -- 0 if the slot is empty

The stock client has IsSitOrStanding() (local-player boolean) but no unit-token form; UnitStandState fills the gap and exposes the full enum.

UnitInRange(unit)

Returns (inRange, checkedRange) — whether unit is within 40 yards of the player, plus a flag indicating whether the range check could actually be performed.

local inRange, checked = UnitInRange("party1")
if checked and not inRange then
    -- partymate is loaded but >40 yards away → skip the heal
end
Return Meaning
inRange true if the unit is within a 40-yard heal's reach of the player. false if out of range OR if checkedRange is false.
checkedRange true when a position-based range check was performed. false when the unit's position isn't available — either the token didn't resolve (empty partyN slot, no target, raid member in a different zone, etc.) or unit == "player" (see below).

Reads the world position via the CGObject::GetPosition vtable virtual (slot 5, offset +0x14) — same path CheckInteractDistance uses.

Reach-aware. The threshold is not a flat 40 yards on the raw center-to-center distance — it's 40 + playerReach + targetReach, mirroring the engine's own spell-range formula (FUN_006e3480), where each unit's reach is the bounding-radius float at [m_objectFields + 0x1F0] (the same size factor the interact-distance and loot-range checks add). A 40-yard heal lands on a target whose center is up to 40 + reach yards away, so a bare center-distance cap of 40 wrongly reports out-of-range for a target the client can actually heal (on a large target the reach gap is 2–3 yards). If a unit's descriptor isn't populated yet the reach term is 0 and the check degrades to a plain 40-yard cap. Note this is the one difference from UnitDistanceSquared, which stays raw center-to-center.

UnitInRange("player") returns (false, false) by design. The function is meant for healing-frame "is another unit in range" checks; querying yourself is meaningless. We short-circuit before the position read so the result is unambiguous (checkedRange=false).

Works for any synced unit (party/raid members in your zone, target, mouseover, etc.). For raid members in a different zone or otherwise not in the engine's sync window, the position virtual returns null and the function reports (false, false).

UnitDistanceSquared(unit)

Returns (distanceSquared, checkedPosition) — the squared world distance (yards²) from the player to unit, and a flag indicating whether both positions were available.

local distSq, checked = UnitDistanceSquared("target")
if checked and distSq <= 30 * 30 then
    -- target is within 30 yards
end
Return Meaning
distanceSquared Squared distance player→unit. 0 when checkedPosition is false (a placeholder — an "always a number" shape).
checkedPosition true when both positions were read. false when unit's position isn't available (empty partyN slot, no target, raid member outside the sync window, etc.).

The value is squared on purpose: nearly all distance logic is a threshold compare (distSq <= range * range) or a nearest-unit sort, neither of which needs the square root — so only the squared form is exposed, never a plain UnitDistance. Take math.sqrt(distanceSquared) only when you need a yard number to show a human.

Reads world positions via the CGObject::GetPosition vtable virtual (slot 5, offset +0x14) — the same path UnitInRange / CheckInteractDistance and our own UnitPosition use. Center-to-center, not reach-aware (that edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd compute from the raw UnitPosition coordinates, and it's self-contained (no sibling-DLL dependency).

No self-quirk (unlike UnitInRange): UnitDistanceSquared("player") returns a legitimate (0, true). Because a real 0 (self, or two exactly co-located units) is indistinguishable by value from the miss placeholder, always branch on checkedPosition.

UnitPosition(unit)

Returns posY, posX, posZ, instanceID — the unit's world position, in the standard UnitPosition shape. nil when the unit has no known position.

local posY, posX, posZ, instanceID = UnitPosition("player")
Return Meaning
posY World Y coordinate (the west axis).
posX World X coordinate (the north axis).
posZ World Z coordinate (height).
instanceID Currently-loaded map id (Map.dbc row — e.g. 0 Eastern Kingdoms, 1 Kalimdor). Every visible unit shares the player's instance.

Standard field order — posY (west) first, then posX (north), then posZ, mirroring Blizzard's own UnitPosition. So an addon that does local py, px = UnitPosition(u) gets its coordinates labelled as expected. (World X is north/south, Y is west/east — WoW's long-standing convention.)

Not group-restricted. The standard UnitPosition returns nil for units outside your party/raid (a privacy guard that doesn't exist here). This backport reads any visible unit — "target", "mouseover", nameplate tokens, arbitrary party/raid members in sync range — so it's strictly more permissive, closer to SuperWoW's unit-position access. Units the client can't currently see (out of range, another zone) have no position → nil.

Reads through the same CGObject::GetPosition virtual as UnitDistanceSquared; the two are consistent (the squared delta of two UnitPosition reads equals UnitDistanceSquared).

SuperWoW interaction. SuperWoW also defines a global UnitPosition with a different return shape. Both register on the same Lua state, so if SuperWoW is loaded the last registrant wins — install order decides which shape is live. Without SuperWoW, this version is the one you get.

UnitInLineOfSight(unit)

Returns true if the player has clear line of sight to unit, false if world geometry (terrain or a building) blocks it, and nil when the check can't apply — an absent / unresolvable token, or a non-unit object. A ClassicAPI extension (not a stock WoW global).

if UnitInLineOfSight("target") then
    -- clear shot
end

Traces a ray between the two units through the client's world-collision geometry (CWorld::Intersect, flags terrain + WMO). Endpoints are raised by each unit's collision-box height so a foot-to-foot ray doesn't false-block on terrain, and the trace runs in two passes (level look, then look up/down) for height mismatches — the same approach UnitXP_SP3 uses, implemented natively so it works with or without that addon (a call routes harmlessly through UnitXP_SP3's hook when present).

Notes and limits:

  • Terrain + buildings (WMO) only — M2 doodads (trees, small props) are not tested (that flag combination crashes in some dungeons). This matches server-side LoS, which also ignores most doodads.
  • Center/eye-line, not a volume — a sliver of a unit peeking past a corner reads as blocked if the eye-line itself is occluded.
  • Pairs beyond ~150 yards report false (a guard against a long-segment crash in the underlying trace); such units are usually outside the client's object sync window anyway.
  • UnitInLineOfSight("player") is true (you always see yourself).

UnitClassBase(unit)

Returns (classFile, classID) — the locale-independent class token plus the numeric classID. The token is one of "WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST", "SHAMAN", "MAGE", "WARLOCK", "DRUID"; the classID matches the integer UnitClass's third return surfaces (1=Warrior, 2=Paladin, 3=Hunter, 4=Rogue, 5=Priest, 7=Shaman, 8=Mage, 9=Warlock, 11=Druid — 6 and 10 are unused here).

local token, id = UnitClassBase("player")    -- "WARRIOR", 1
local token = UnitClassBase("target")        -- works for any synced unit
local color = RAID_CLASS_COLORS[UnitClassBase("party1")]

Addons use the token for class detection because the UnitClass(unit) returns a localized first return (e.g. "Krieger" on a German client), which is fine for display but breaks any addon code that keys on if class == "WARRIOR". The classID second return is a ClassicAPI extension — the standard UnitClassBase returns the token only — but it saves callers from chaining UnitClass(unit) just to get the integer.

Reads the class byte from UNIT_FIELD_BYTES_0 (descriptor +0x79, the same byte UnitClass's general-token path reads) and looks up the english filename at ChrClasses.dbc::Filename (record +0x38). Both fields are broadcast / static, so remote players' class is current regardless of distance.

Returns (nil, nil) for:

  • target with no current target, empty partyN / raidN slots, or units whose descriptor isn't loaded yet
  • creatures and other non-player units (their class byte indexes a different table; the class-byte lookup naturally returns nil for out-of-range / zero values)

Throws a Lua error on missing / non-string unit argument — same shape as UnitClass itself.

UnitRaceBase(unit)

Returns (raceFile, raceID) — the locale-independent race token plus the numeric raceID. The token is one of "Human", "Orc", "Dwarf", "NightElf", "Scourge", "Tauren", "Gnome", "Troll"; the raceID is 1..8 for those values respectively.

local token, id = UnitRaceBase("player")    -- "Human", 1
local token = UnitRaceBase("target")        -- works for any synced unit

Sibling to UnitClassBase. Same problem (the UnitRace(unit) returns a localized name — "Mensch", "Orc", etc.); same solution (locale-independent token straight from the DBC). Reads byte 0 of UNIT_FIELD_BYTES_0 (descriptor +0x78) and looks up ChrRaces.dbc::Filename (+0x3C).

Returns (nil, nil) for unresolvable units and non-player units (creature race bytes don't index ChrRaces.dbc).

ClosestUnitPosition(creatureID)

Returns xPos, yPos, distance — the world position of the nearest creature with the given NPC (creature-template) ID, and its distance from the player in yards. Returns nothing (nil) when no matching creature is in range.

local x, y, dist = ClosestUnitPosition(3098)   -- nearest Kobold Vermin
if x then
    -- x, y = world coords; dist = yards from the player
end

Walks the client's visible-object manager, matches creatures whose GUID encodes creatureID (bits 24–47), and returns the closest one's position

  • center-to-center distance.

Behavior note. A ClosestUnitPosition that reads a static client-side spawn database only works for starting-zone mobs. There is no such database here, so this returns the nearest currently-visible creature of that entry (anywhere, not just starting zones). It can't point at un-synced spawns elsewhere in the zone the way a static spawn database can — but for "where's the nearest <mob> I can see" it's more general.

UnitHealthMissing(unit)

The health deficit — UnitHealthMax(unit) - UnitHealth(unit) — in one call. A convenience for healing addons (overheal math, "missing health" bars) that would otherwise call both engine functions and subtract in Lua every frame.

local missing = UnitHealthMissing("player")   -- 0 at full health

Returns 0 at full health and for a valid-but-absent unit (e.g. "target" with nothing targeted), matching UnitHealth's 0-for-missing convention; clamped so a transient current > max never returns negative.

Tracks the engine's own UnitHealth / UnitHealthMax exactly, including the percentage form for non-grouped units (where UnitHealthMax is 100): a target at 85/100 reports 15.

Direct descriptor reads — desc[+0x40] (HEALTH), desc[+0x58] (MAXHEALTH). No engine call, no Lua-stack roundtrip.

UnitPower(unit [, powerType [, unmodified]]) / UnitPowerMax(unit [, powerType [, unmodified]])

Multi-power-type getters. The stock client only ships UnitMana(unit) / UnitManaMax(unit) which return whichever primary power the unit happens to have (mana for casters, energy for rogues, rage for warriors, etc.); these add the explicit powerType arg so addons can read a specific power slot without caring what the unit's primary is.

local mana    = UnitPower("player", 0)   -- explicit mana
local energy  = UnitPower("player", 3)   -- explicit energy
local primary = UnitPower("player")      -- whatever the player's primary is
local maxMana = UnitPowerMax("target", 0)

powerType values:

value type
0 MANA
1 RAGE
2 FOCUS
3 ENERGY
4 HAPPINESS

These match the Enum.PowerType namespace published alongside — UnitPower("player", Enum.PowerType.Mana) is the idiomatic shape.

Omitting powerType (or passing -1 / any out-of-range value) falls back to the unit's primary power, read from UNIT_FIELD_BYTES_0 byte 3 (descriptor +0x7B) — the same source the engine uses internally.

Returns 0 for invalid units, unresolvable tokens, or power types outside the 0..4 range (Runes / Runic Power have no descriptor slots in the unit layout).

Display-divisor applied. Some power types are stored at a scaled value internally and divided before being exposed through Lua — same trick Script_UnitMana (0x00517670) uses, reading the divisor table at 0x0086F978:

type divisor
0 MANA 1
1 RAGE 10 (raw 0..1000 → display 0..100)
2 FOCUS 1
3 ENERGY 1
4 HAPPINESS 1000

So a fresh warrior reads UnitPower("player", 1) = 0..100, not 0..1000.

Pass a truthy unmodified (third arg) to bypass the divisor and get the raw internal value — UnitPower("player", 1, true) returns rage as 0..1000. The same third argument UnitPower / UnitPowerMax take.

Direct descriptor reads — desc[+0x44 + type*4] for current power, desc[+0x5C + type*4] for max, divided by the table entry for the type. No engine call, no Lua-stack roundtrip.

UnitPowerMissing(unit [, powerType [, unmodified]])

The power deficit — UnitPowerMax(...) - UnitPower(...) for the given type — in one call. The power analogue of UnitHealthMissing, for energy/mana/rage "resource needed" checks. Same unit / powerType handling and invalid-unit-returns-0 semantics as UnitPower.

local energyNeeded = UnitPowerMissing("player", Enum.PowerType.Energy)
local rageMissing  = UnitPowerMissing("player")   -- primary power

unmodified (arg 3, any truthy value) skips the display divisor and returns the raw internal deficit — the same third argument UnitPower takes.

Computed as the difference of the two display values (each integer-divided by the per-type divisor), not by dividing the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly. Those diverge for rage: raw 1000/55 displays as 100/5, a deficit of 95, whereas (1000-55)/10 truncates to 94. Clamped at 0.

UnitPowerType(unit)

The stock version returns just the integer power type; this implementation extends it to the 2-tuple (powerType, powerToken). Strict-superset signature — addons that destructure only the first return are unaffected.

local pt, token = UnitPowerType("player")
-- e.g. (0, "MANA") for a paladin, (1, "RAGE") for a warrior

Token strings:

value token
0 "MANA"
1 "RAGE"
2 "FOCUS"
3 "ENERGY"
4 "HAPPINESS"
5 "RUNES"
6 "RUNIC_POWER"

5/6 are power types that can't appear on a unit's descriptor here; they're included in the table for symmetry in case a private server pushes one through.

Chains to the engine's original Script_UnitPowerType at 0x00517940 to preserve its full unit-resolution flow (object- manager lookup with pet / totem / vehicle fallbacks), then reads the just-pushed integer and appends the token string.

UnitSpellHaste(unit)

A spell-haste getter — there is no stock haste API. Returns the spell haste percentage: 0 for an unhasted unit, positive when casting is sped up, negative when slowed (Curse of Tongues).

local haste = UnitSpellHaste("player")   -- 0 for most vanilla casters

Reads UNIT_MOD_CAST_SPEED — the cast-time multiplier at descriptor +0x22c that the server folds into SpellEntry::GetCastTime (castTime *= modCastSpeed) — and converts it to a percentage: haste% = (1 / modCastSpeed - 1) * 100. So modCastSpeed 1.0 → 0, 0.5 (half cast time) → 100, >1.0 → negative.

This is the Blizzard-shaped surface over the field nampower exposes raw as GetUnitField(unit, "modCastSpeed"), so addons can drop that dependency. Consumers that need the raw multiplier back can recover it as 1 / (1 + UnitSpellHaste(unit) / 100). Returns 0 for invalid units.

UnitSpellTargetName(unit)

Returns the name of the unit that unit is casting or channeling a spell at, or nil.

targetName = UnitSpellTargetName(unit)
UnitSpellTargetName("target")   -- "Playername" while the mob casts at you
UnitSpellTargetName("player")   -- your current cast's target, or nil

Returns nil when:

  • unit is not casting or channeling.
  • The spell has no unit target. This covers a self-cast, a ground-target spell (Blizzard, Rain of Fire), and an item or lock cast.
  • The target's name can't be resolved (an off-screen stranger).

The target comes from the cast's SMSG_SPELL_START packet. ClassicAPI captures it for the player and for any remote unit whose cast you observed since it began. So the same limit as C_Spell.UnitCastingInfo applies: a remote unit's target is known only while you saw its cast. The name is resolved through the object manager (players and creatures), then the friends list, then the persistent name cache — the same chain as UnitNameFromGUID.

The player's target lands with the confirming packet, about one round-trip after the cast starts. So UnitSpellTargetName("player") can read nil for the first part of your own cast, then return the name.

UnitAuras

Backport of the C_UnitAuras namespace. Returns AuraData-shaped tables instead of the UnitBuff / UnitDebuff multi-return tuples, so addon code that does local d = C_UnitAuras.GetAuraDataByIndex(unit, 1); if d.dispelName == "Magic" then ... works unchanged.

Reads primarily off the unit's m_objectFields descriptor — same data source UnitBuff / UnitDebuff use. The descriptor has 48 aura slots. Buffs sit in slots 0 to 31 and debuffs in slots 32 to 47.

Some servers, Turtle among them, put extra debuffs into free buff slots once the 16 debuff slots are full. HELPFUL and HARMFUL still select on the aura's real polarity there, so a debuff in a buff slot is still a debuff. The native UnitBuff and UnitDebuff split by slot range and report that debuff as a buff. Indices stay stable either way. A HARMFUL walk visits slots 32 to 47 first, then any spilled debuffs in 0 to 31.

When a party/raid member has no live unit object at all (a different map, far out of range), there is no descriptor to read — but the server still transmits that member's current aura spell IDs, and these functions surface them, exactly as the built-in UnitBuff / UnitDebuff do. See Out-of-range group members.

AuraData table shape

Field Type Source / value
name string localized spell name from Spell.dbc
icon string icon path from SpellIcon.dbc
applications number stack count (engine stores stacks-1, we display +1)
spellId number spell ID from the descriptor's aura array
dispelName string "Magic" / "Curse" / "Disease" / "Poison" (from SpellDispelType.dbc), or "" if non-dispellable
isHelpful boolean true when the aura is a buff
isHarmful boolean true when the aura is a debuff. This is the aura's real polarity, so a debuff parked in a buff slot still reads harmful
duration number applied duration in seconds. When the aura's cast was observed (in the Aura::Source cache), this is the caster-modified duration — talent/glyph extensions like Improved Shadow Word: Pain included — so it stays consistent with expirationTime (remaining ≤ duration). On a cache miss it falls back to the base Spell.dbc → SpellDuration.dbc value with level scaling. Returns 0 for spells flagged "no duration" (passives, paladin auras, infinite buffs)
expirationTime number for unit == "player", read from the engine's player-buff table at 0x00BC6040 (same data GetPlayerBuffTimeLeft returns). For any other unit, taken from the Aura::Source cache (cast time + duration captured from SMSG_SPELL_GO; see below). 0 when neither source has it. expirationTime - GetTime() gives the true remaining time
sourceUnit string unit token of the caster ("player", "raid7", "nameplate1", …), resolved from the Aura::Source cache. nil if the cast wasn't observed or the caster maps to no current token
sourceGUID string caster's "0x…" GUID string from the same cache. A ClassicAPI extension — not a standard AuraData field. Set whenever a caster is known, including when sourceUnit is nil (caster left token range). Stable for the session, unlike the volatile nameplate token; doubles as a unit token under SuperWoW. nil on a cache miss
charges / maxCharges number always 0 — there are stacks, not charges
timeMod number always 1 — there are no haste-affected auras
isFromPlayerOrPlayerPet boolean true when a player — any player, not only you — or a player's pet applied the aura. Read from the cached caster GUID, so it is false when the cast was not seen this session. Totem buffs read false: a totem is a creature, not a pet
isStealable, isBossAura, isNameplateOnly, nameplateShowAll, nameplateShowPersonal, canApplyAura, shouldConsolidate, isRaid boolean always false — UI concepts not present here
auraInstanceID, points (absent) omitted from the table — Lua read yields nil for "field doesn't apply"

Filter parsing

The optional filter string is a pipe-separated set of upper-case tokens ("HELPFUL", "HARMFUL", "HELPFUL|PLAYER", etc.). Honored:

  • HELPFUL (default) / HARMFUL — pick buffs or debuffs by each aura's polarity flag. In GetUnitAuras, supplying neither returns both.
  • PLAYER — restrict to auras that the player or the player's pet cast. Combines with the range tokens ("HARMFUL|PLAYER" = your debuffs only). The caster is known only for casts seen this session, so an aura that predates login counts as not-player-cast and is excluded.

Other tokens (RAID / CANCELABLE / INCLUDE_NAME_PLATE_ONLY) are accepted but no-op — they need engine systems (raid-dispel relevance, nameplate visibility flags) not present here.

Caster & timing (Aura::Source)

sourceUnit, sourceGUID, and non-player expirationTime come from a client-side cache the engine itself can't provide: the unit aura array stores only spell IDs — never the caster, and no cast/expiration timing for anyone but the local player. Aura::Source fills the gap by co-hooking three engine functions and caching (targetGuid, spellId) → { casterGuid, expirationMs, durationMs }; Push keys into it by the unit's GUID + the aura's spell ID.

  • SpellGo (FUN_SPELL_GO, 0x006E7A70) — the cast packet, the only place the client sees an aura's caster + a server-authoritative, caster-modified duration. Fills sourceUnit/sourceGUID + timing for directly-cast auras.
  • OnAuraAdded (0x006123F0) — fires when any aura first occupies a slot, including proc/triggered auras that emit no SMSG_SPELL_GO (Shadow Weaving etc.). Stamps expirationMs = now + base duration (no caster).
  • OnAuraStacksChanged (0x00612450) — fires when an existing aura's stack count changes; re-stamps expiration so a climbing stacking debuff (e.g. Shadow Weaving 1→5) refreshes.

Application hooks never overwrite an entry SpellGo already owns, so a directly-cast aura keeps its talented timing + caster regardless of hook order.

Implications:

  • Best-effort. Only auras observed after login carry data; auras already up when you logged in leave the defaults (sourceUnit/sourceGUID nil, non-player expirationTime 0).
  • Max-stack refresh is a blind spot. When a stacking debuff is refreshed at max stacks (e.g. Shadow Weaving 5→5), no client-visible aura field changes (spellID, stacks, flags, level all identical), so the server sends no UpdateField and the engine fires no callback — there is no packet to hook (verified: the stack-change dispatcher FUN_00604ea0 calls OnAuraStacksChanged unconditionally, so its silence proves nothing arrived). The countdown therefore runs to 0 and the entry evicts; once evicted, expirationTime falls back to 0 (unknown) while the aura is still shown. Climbing stacks and single-stack refreshes (re-cast → fresh SpellGo) are unaffected.
  • No nampower dependency. We parse the engine functions ourselves; nampower (which hooks the same three) need not be loaded — when it is, we co-hook the sites.
  • Entries are evicted once their timed aura elapses, so the cache stays bounded; infinite-duration auras persist until overwritten under load.

Out-of-range group members

The descriptor and the Aura::Source cache both require the member's CGUnit to exist on the client. When a party/raid member is on a different map (or otherwise far enough out of range that the engine drops their object entirely), neither is available — yet the server keeps every group member's current auras flowing in SMSG_PARTY_MEMBER_STATS (GROUP_UPDATE_FLAG_AURAS for buffs, GROUP_UPDATE_FLAG_AURAS_NEGATIVE for debuffs), and the client stashes them in the group-member roster structs. Every C_UnitAuras.* function falls back to that array when the unit has no object — the same source the built-in UnitBuff / UnitDebuff read out of range — so a cross-map GetUnitAuras("raid7") returns the member's real buffs/debuffs instead of an empty table.

This path is spell-ID only, because that is all the packet carries:

  • applications is always 1 — stack counts aren't transmitted.
  • expirationTime / sourceUnit / sourceGUID are present only if you cast the aura (the Aura::Source SMSG_SPELL_GO entry is merged in when it matches); otherwise the usual defaults.
  • duration is the Spell.dbc base with level scaling.
  • Spell IDs are truncated to 16 bits on the wire, so a custom aura with a spell ID above 65535 comes through wrong — an inherent limitation of the packet that UnitBuff shares.

Distinct from the in-range descriptor-drop cases (rogue stealth, nearby range fluctuation), where the object still exists and the Aura::Source cache is the fallback — see Caster & timing.

C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])

Returns the AuraData table for the index-th aura (1-based) matching filter, or nil if the unit has fewer than index matching auras. Empty / non-visible descriptor slots are skipped during iteration, so consecutive indices return consecutive active auras the same way UnitBuff does.

local d = C_UnitAuras.GetAuraDataByIndex("player", 1, "HELPFUL")
if d then
    print(d.name, d.spellId, d.dispelName, d.applications)
end

Filter tokens. filter uses the AuraFilters format: tokens separated by | or spaces, each token optionally negated with a leading !. This build honors:

  • HELPFUL / HARMFUL — buffs / debuffs, by each aura's polarity flag rather than its slot number. With neither token the query returns both (the indexed getter defaults to helpful).
  • PLAYER / !PLAYER — only auras the player or the player's pet cast, or only the auras neither of them cast. Caster data comes from casts this session, so an aura present before you saw it cast has no caster and counts as not-player.
  • DISPELLABLE / !DISPELLABLE — only auras that can be dispelled, purged, or stolen (dispel type Magic, Curse, Disease, or Poison), or only auras that cannot. This is "can it be removed at all", not "can you remove it".
  • CROWD_CONTROL / !CROWD_CONTROL — only auras that are a crowd- control effect (stun, fear, silence, root, charm, confuse, disarm, and movement slows), or only auras that are not. Shares the loss-of-control classifier with C_LossOfControl, plus snares (which are crowd control but not loss of control).

All other AuraFilters tokens (RAID, CANCELABLE, INCLUDE_NAME_PLATE_ONLY, MAW, and the rest) are accepted and ignored — there is no data for them. Token matching is whole-token, so RAID_PLAYER_DISPELLABLE is not read as PLAYER.

C_UnitAuras.GetBuffDataByIndex(unit, index) / GetDebuffDataByIndex(unit, index)

Convenience wrappers locking the filter to HELPFUL or HARMFUL respectively. Equivalent to GetAuraDataByIndex(unit, index, "HELPFUL") / "HARMFUL".

C_UnitAuras.UnitAura(unit, index [, filter])

Returns the same aura as GetAuraDataByIndex, but as 15 positional values instead of a table — so it allocates nothing. Use it in an OnUpdate loop that scans many auras, where one table per aura strains Lua's garbage collector. Prefer AuraUtil.ForEachAura for iteration.

The values, in order:

name, icon, count, dispelType, duration, expirationTime, source, isStealable,
nameplateShowPersonal, spellId, canApplyAura, isBossDebuff, castByPlayer,
nameplateShowAll, timeMod

Returns a single nil when the unit has fewer than index matching auras. dispelType is the dispel-type string ("Magic", "Curse", "Disease", "Poison", or nil). source is the caster's unit token, or nil when the cast was not seen this session. castByPlayer is true when a player — any player, not only you — or a player's pet applied the aura, and false when the cast was not seen this session. filter takes the same tokens as GetAuraDataByIndex.

The values are the GetAuraDataByIndex fields in the classic UnitAura order, with two field-name differences from the table: position 12 is isBossDebuff (the table field is isBossAura) and position 13 is castByPlayer (the table field is isFromPlayerOrPlayerPet, the same value). The boss field is always false; castByPlayer is reported truthfully.

C_UnitAuras.UnitBuff(unit, index [, filter]) / UnitDebuff(unit, index [, filter])

The UnitAura positional form with the range locked to HELPFUL or HARMFUL. The filter still honors the PLAYER, DISPELLABLE, and CROWD_CONTROL predicates. Namespaced under C_UnitAuras so they never clash with the native global UnitBuff / UnitDebuff (which keep their texture-first return).

C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])

Returns a continuation token, then the slot ids of the auras on unit that match filter. A slot id is an opaque number. Pass it to GetAuraDataBySlot or UnitAuraBySlot to read that aura.

local token, slot1, slot2 = C_UnitAuras.GetAuraSlots("target", "HARMFUL", 2)

The slot ids come in the order that the by-index getters visit the auras. maxSlots limits how many ids one call returns. With nil or 0, one call returns all of them. When more auras remain, the first return value is a token. Pass this token back as continuationToken to get the next batch. When the batch reaches the last aura, the first return value is nil.

Use this function instead of a loop over GetAuraDataByIndex. The by-index getters walk the aura array from the start on every call, so a loop over them repeats that walk for each index. One GetAuraSlots call plus one by-slot fetch per aura walks the array once. AuraUtil.ForEachAura uses this path.

A slot id is valid in the frame that returned it. Do not store slot ids across frames. filter takes the same tokens as GetAuraDataByIndex. The range is HELPFUL unless the filter has HARMFUL.

Fill a table instead (ClassicAPI extension). Pass a table as the fifth argument. The function writes the slot ids into it as t[1] to t[n], clears old entries after n, sets t.n, and returns continuationToken, n. Use this form in code that runs every frame. On this client's Lua, a call to a function with ... in its parameter list allocates a table. So a Lua helper that receives the normal return list allocates once per call, and the fill form does not.

local slots = {}
local token, n = C_UnitAuras.GetAuraSlots("target", "HARMFUL", nil, nil, slots)
for i = 1, n do
    local name = C_UnitAuras.UnitAuraBySlot("target", slots[i])
end

C_UnitAuras.GetAuraDataBySlot(unit, slot) / UnitAuraBySlot(unit, slot)

GetAuraDataBySlot returns the AuraData table for the aura that slot id slot names on unit. It returns nil when the slot id no longer names an aura. UnitAuraBySlot returns the same aura as the 15 positional values of C_UnitAuras.UnitAura, so it allocates nothing. Get slot ids from GetAuraSlots.

local token, slot = C_UnitAuras.GetAuraSlots("player", "HELPFUL", 1)
if slot then
    local d = C_UnitAuras.GetAuraDataBySlot("player", slot)
    print(d.name, d.applications)
end

C_UnitAuras.GetUnitAuraBySpellID(unit, spellID [, filter])

Linear-searches unit's aura array for the first populated slot whose spellId matches and returns its AuraData, or nil if not found. Without a filter, searches both ranges (helpful first, then harmful).

-- Is the player blessed with Wisdom right now?
local d = C_UnitAuras.GetUnitAuraBySpellID("player", 25290)
if d then print("yes, with", d.applications, "stacks") end

C_UnitAuras.GetPlayerAuraBySpellID(spellID)

Shorthand for GetUnitAuraBySpellID("player", spellID) — the most common consumer pattern (WeakAuras-style aura tracking).

C_UnitAuras.GetAuraDataBySpellName(unit, spellName [, filter])

Linear-searches unit's aura array for the first populated slot whose locale-resolved name matches spellName exactly. Returns the AuraData for that slot or nil if not found. Case-sensitive. Without a filter, searches both ranges (helpful first, then harmful).

local d = C_UnitAuras.GetAuraDataBySpellName("player", "Inner Fire")
if d then print(d.applications, "stacks of Inner Fire") end

-- restrict to debuffs:
local poison = C_UnitAuras.GetAuraDataBySpellName(
    "target", "Mind-numbing Poison", "HARMFUL")

Uses the same name field the other C_UnitAuras.* functions populate (locale-applied Spell.dbc name). If the player is on a non-English client, pass the localized name — addons that hard-code English names should use GetUnitAuraBySpellID instead for portability.

C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)

ClassicAPI extension — you normally never call this. Overrides the combo-point duration data for one spellID, for servers whose values differ from (or are missing in) the client's SpellDuration.dbc. Returns true on success; re-registering a spellID replaces its entry.

Background: combo-point finishers (Rupture, Kidney Shot, Slice and Dice, …) get their real duration computed server-side at cast time as base + (max − base) × comboPoints / 5, and 1.12 never transmits the result for debuffs the player puts on other units (verified with a whole-protocol packet capture). ClassicAPI therefore mirrors the computation automatically: it snapshots your combo points the instant the cast request leaves the client and applies the same formula from the spell's own SpellDuration.dbc row, so duration/ expirationTime in every AuraData are combo-accurate out of the box — Rupture at 4 CP reads 14s, not the 6s base. Your duration talents apply on top, in the server's order. No registration involved.

The override exists only for spells whose client DBC data is missing or wrong on a specific server. The known case — Turtle's reworked Rip (8s + 2s per combo point), whose SpellDuration row exists on the server only — is built into ClassicAPI and gated on the client-data bug itself: the fallback applies only while the spell's DurationIndex points at a SpellDuration row the client doesn't have (a condition unique to Rip on Turtle's client; on a stock client Rip's index resolves normally, and if Turtle ever ships the missing row, the DBC takes over automatically). No realm detection involved. This function is the escape hatch for other custom servers with the same class of gap — call it from your own config or addon; a registered override beats both the DBC and the built-in values.

C_UnitAuras.RegisterAuraDurationModifierByTrigger(triggerFamily, triggerSchool, affectedFamily, affectedFamilyFlags, affectedIcon, op [, valueSeconds])

ClassicAPI extension for server-side DoT-duration changes on another unit that the client is never told about. Returns true on success.

expirationTime for a debuff on another unit is computed from the cast packet (SMSG_SPELL_GO) at cast time. When the server later changes that debuff's remaining duration — a refresh, or a partial consume — 1.12 sends no packet an observing caster can see (verified in the server source: the change runs through SetAuraDuration/RefreshHolder, and the only duration packet is self-scoped to the aura-bearer; on a mob it isn't even built). So the cached expirationTime would go stale.

The client does always see the triggering cast, though. Register a rule and ClassicAPI mirrors the server's edit on the cached aura when that trigger lands:

Arg Meaning
triggerFamily SpellFamilyName of the triggering cast (e.g. 6 priest).
triggerSchool School index the trigger must be (0 physical … 2 fire, 5 shadow, 6 arcane), or < 0 for any. Matching by family + school covers a whole class of spells at once — every rank, plus server-added ones — rather than a named ability.
affectedFamily SpellFamilyName of the affected aura (e.g. 5 warlock, 11 shaman, 6 priest).
affectedFamilyFlags A SpellFamilyFlags bitmask; the affected aura matches if it overlaps. Family plus flag is rank-proof, so it covers every rank at once. Pass 0 to select on the icon alone.
affectedIcon SpellIconID the affected aura must have, or 0 to match any. Give this when the aura has no SpellFamilyFlags, which is common for a server's custom spells. An icon is shared by a spell's whole rank ladder, so it stays rank-proof.
op "refresh" (reset to full duration), "reduce" (subtract valueSeconds, removing the aura if it would go non-positive), "set" (to valueSeconds), "remove".
valueSeconds Amount for reduce/set; ignored otherwise.

Give at least one of affectedFamilyFlags and affectedIcon. A rule with neither would match every aura of the class, so it is rejected.

The rule only fires for the aura cast by the same unit as the trigger (these mechanics act on the caster's own DoT), and misses are excluded for free (a missed trigger isn't in the packet's hit list).

Rules keyed to an exact trigger spellID (rather than a family + school category) are registered inside the DLL instead. !!!ClassicAPI's built-in Turtle mods — Conflagrate shaving 3s off the caster's Immolate, Molten Blast refreshing the caster's Flame Shock, and Bestial Wrath stretching the pet's Scent of Blood to its own length — live in src/turtle/DurationMods.cpp, and Carnage's roll-gated Rip/Rake refresh in src/turtle/Carnage.cpp.

The one rule registered from Lua (Util/AuraDurationModifiers.lua) is Shadow Weaving: for a 5/5 priest (talent 15334, where the proc is 100%), any shadow-school priest cast refreshes the target's Shadow Vulnerability (15258). Below 5/5 it's roll-gated and deliberately not inferred — the client can't see the server's roll, so inferring it would show wrong timers. One accepted best-effort caveat: DoT ticks (SW:P / Devouring Plague) also refresh it server-side but emit no cast packet, so a pure DoT-only phase under-counts slightly.

-- Flags are hex; Lua 5.0 has no 0x literals, so use tonumber(hex, 16).
-- Any priest (6) shadow-school (5) cast refreshes Shadow Vulnerability
-- (affected priest 6, flag 0x4000000, icon 9):
C_UnitAuras.RegisterAuraDurationModifierByTrigger(6, 5, 6, tonumber("4000000", 16), 9, "refresh")

C_UnitAuras.GetUnitAuras(unit [, filter])

Returns a numerically-indexed array of AuraData tables for every populated aura on unit. Without a filter the array is helpful auras followed by harmful; with a filter it's restricted to one range. Empty array ({}) if the unit has no auras or doesn't resolve.

for _, aura in ipairs(C_UnitAuras.GetUnitAuras("target")) do
    print(aura.spellId, aura.dispelName, aura.isHarmful)
end

C_UnitAuras.GetAuraDispelTypeColor(dispelName)

Returns a {r, g, b, a} table for the given dispel-type name, matching FrameXML's DebuffTypeColor values:

dispelName r g b
"Magic" 0.20 0.60 1.00
"Curse" 0.60 0.00 1.00
"Disease" 0.60 0.40 0.00
"Poison" 0.00 0.60 0.00
"Enrage" 1.00 0.55 0.00
(anything else, including "") 0.80 0 0

Returns a ColorMixin instance — the C function pcalls Lua's CreateColor(r, g, b, a) (defined in !!!ClassicAPI/Util/Color.lua) and returns whatever table it builds. So the returned value carries the mixin methods (GetRGB, GenerateHexColorMarkup, etc.) in addition to the .r/.g/.b/.a fields. Falls back to a plain {r,g,b,a} table if CreateColor isn't loaded yet (shouldn't happen — !!!ClassicAPI loads first thanks to the triple-! prefix).

VoiceChat

A text-to-speech surface — the C_VoiceChat and C_TTSSettings namespaces — backed by Windows SAPI (the OS speech engine). All playback is local (rendered on your own machine); there is no voice-chat transport, so there is nothing "remote" to transmit synthesized speech into.

Coexistence with VanillaTTS: the same surface also ships as the standalone VanillaTTS DLL, which adds a software-synth backend for Wine. If VanillaTTS.dll is loaded in the same client, this built-in module stands down automatically (registers none of the functions, events, or cvars below) and VanillaTTS provides them instead — the Lua-visible API is identical either way, so addons don't need to care which one is active.

SAPI is created lazily on first use. If SAPI is unavailable (e.g. running the client under Wine, or a stripped Windows), every call degrades gracefully: GetTtsVoices returns an empty table, SpeakText fires VOICE_CHAT_TTS_PLAYBACK_FAILED and does nothing, and the client stays stable — there is no crash. Install more voices under Windows Settings → Time & Language → Speech, then call C_TTSSettings.RefreshVoices().

C_VoiceChat.GetTtsVoices() / C_VoiceChat.GetRemoteTtsVoices()

Return a numerically-indexed array of voice tables — one per SAPI voice installed on the machine:

for _, v in ipairs(C_VoiceChat.GetTtsVoices()) do
    print(v.voiceID, v.name)   -- e.g.  0  "Microsoft David Desktop - English (United States)"
end

Each entry is { voiceID = <0-based index>, name = <display name> }. The voiceID is the index you pass to SpeakText and store via C_TTSSettings.SetVoiceOption. GetRemoteTtsVoices is an alias returning the same local list (kept for API parity — there is no voice chat). Calling either refreshes the cached voice list and fires VOICE_CHAT_TTS_VOICES_UPDATE if it changed.

C_VoiceChat.SpeakText(voiceID, text [, destination, rate, volume])

Speaks text aloud using the SAPI voice at index voiceID.

C_VoiceChat.SpeakText(
    C_TTSSettings.GetSpeechVoiceID(),         -- use the configured voice
    UnitName("target") or "no target")
  • voiceID (number, required) — index into GetTtsVoices(). Note this is an explicit argument — SpeakText does not read the ttsVoice cvar itself, matching Blizzard's contract. Pass C_TTSSettings.GetSpeechVoiceID() to honor the configured default.
  • text (string, required) — UTF-8; spoken as-is.
  • destination (number, optional, default 1) — 1 = LOCAL_PLAYBACK, 4 = QUEUED_LOCAL_PLAYBACK. Accepted for parity; both queue FIFO through SAPI (no app-side queue).
  • rate (number, optional, default 0) — speech rate, clamped to [-10, 10].
  • volume (number, optional, default 100) — clamped to [0, 100].

Speech is asynchronous. The call returns immediately and fires VOICE_CHAT_TTS_PLAYBACK_STARTED when SAPI begins the utterance and VOICE_CHAT_TTS_PLAYBACK_FINISHED when it completes (or ..._PLAYBACK_FAILED if the voice/engine is unavailable). Raises a Lua error if voiceID isn't a number or text isn't a string.

C_VoiceChat.StopSpeakingText()

Stops and purges everything currently playing or queued.

C_TTSSettings — getters & setters

The settings store (separate from SpeakText, which takes its parameters explicitly). All three values are backed by CVars (see below), so they persist across sessions and are clamped to valid ranges on write.

Function Returns / effect
C_TTSSettings.GetSpeechRate() configured rate, [-10, 10] (default 0)
C_TTSSettings.GetSpeechVolume() configured volume, [0, 100] (default 100)
C_TTSSettings.GetSpeechVoiceID() configured voice index (default 0)
C_TTSSettings.GetVoiceOptionName() display name of the configured voice, or ""
C_TTSSettings.SetSpeechRate(rate) set rate (clamped)
C_TTSSettings.SetSpeechVolume(volume) set volume (clamped)
C_TTSSettings.SetVoiceOption(voiceID) set the voice by index (clamped to installed count)
C_TTSSettings.SetVoiceOptionByName(name) set the voice by display name (case-insensitive exact match)
C_TTSSettings.SetDefaultSettings() reset to voice 1 (if present, else 0), rate 0, volume 100
C_TTSSettings.RefreshVoices() re-enumerate SAPI voices; fires VOICE_CHAT_TTS_VOICES_UPDATE if the list changed
C_TTSSettings.SetVoiceOptionByName("Microsoft Zira Desktop - English (United States)")
C_TTSSettings.SetSpeechVolume(150)              -- clamps to 100
print(C_TTSSettings.GetVoiceOptionName())       -- "Microsoft Zira Desktop - ..."

TTS events

Registered like any engine event (frame:RegisterEvent("VOICE_CHAT_TTS_PLAYBACK_STARTED")).

Event Args
VOICE_CHAT_TTS_PLAYBACK_STARTED numConsumers (number), utteranceID (number), durationMS (number, always 0), destination (number)
VOICE_CHAT_TTS_PLAYBACK_FINISHED numConsumers (number), utteranceID (number), destination (number)
VOICE_CHAT_TTS_PLAYBACK_FAILED status (string — e.g. "EngineAllocationFailed", "InternalError"), utteranceID (number), destination (number)
VOICE_CHAT_TTS_VOICES_UPDATE (none) — the installed-voice list changed
VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE reserved for parity; not currently fired

utteranceID correlates the START/FINISHED/FAILED events for a given SpeakText call. PLAYBACK_FINISHED fires only when playback actually completes, so it's the signal to start the next utterance in a queue.

TTS CVars

The C_TTSSettings values are stored as engine CVars, persisted to WTF\Config.wtf. You can also read/write them with the standard GetCVar/SetCVar (or /console set); writes are clamped by the same validation:

CVar Default Range Backing setting
ttsVoice 0 [0, voiceCount-1] GetSpeechVoiceID / SetVoiceOption
ttsSpeed 0 [-10, 10] GetSpeechRate / SetSpeechRate
ttsVolume 100 [0, 100] GetSpeechVolume / SetSpeechVolume

XMLUtil

C_XMLUtil.GetTemplates()

Returns an array of every registered virtual XML template — the virtual="true" frames that inherits= targets — one entry per template:

for _, t in ipairs(C_XMLUtil.GetTemplates()) do
    print(t.name, t.type)   -- e.g.  "UIPanelButtonTemplate"  "Button"
end

Each entry is { name = <string>, type = <frameType> }, where type is the XML element tag the template was declared with ("Frame", "Button", "CheckButton", "StatusBar", …).

The list is read straight from the engine's virtual-template registry — the same store the XML loader fills for inherits= — so it covers every template currently loaded from both Blizzard FrameXML and addon .xml files, and is rebuilt on /reload. Font templates live in a separate registry and are not included (GetTemplates returns frame templates). Returns an empty table if no template has registered yet.

Note: the standard XMLTemplateListInfo carries only the name and type fields, both provided here. There's no filtering — every virtual template is returned, in hash-table order (not load or alphabetical order).

C_XMLUtil.GetTemplateInfo(name)

Returns an XMLTemplateInfo table for the named template, or nil if no template of that name is registered:

local info = C_XMLUtil.GetTemplateInfo("StatFrameTemplate")
-- { type = "Frame", width = 104, height = 13, keyValues = {}, inherits = nil }
Field Type Notes
type string The template's frame type — its XML element tag ("Frame", "Button", …).
width / height number The size statically declared on the template itself via a <Size> element, or 0 if it declares none (including when the size comes from an inherited template rather than a direct <Size>).
keyValues table Always an empty table — the XML schema has no <KeyValues> element, so no template can carry key/value pairs. Present for API parity.
inherits string? The template's inherits= attribute (a comma-delimited list), or nil if it inherits nothing.

Lookup is by name through the engine's own template registry — the same resolution inherits= uses — so it's case-insensitive and covers both Blizzard FrameXML and addon templates.

Not provided: the sourceLocation field (file:line where the template was defined); no source location is recorded for XML nodes, so the field is omitted (reads as nil).

C_XMLUtil.DoesTemplateExist(name)

Returns true if a virtual template of that name is registered, false otherwise:

if C_XMLUtil.DoesTemplateExist("MyAddonRowTemplate") then
    button = CreateFrame("Button", nil, parent, "MyAddonRowTemplate")
end

Case-insensitive, resolved through the same registry as inherits=.