Files
WeakestAuras/GenericTrigger.lua
github-actions[bot] fc92506470 WeakestAuras 0.18.1
Fewer freezes

Fixes
- Fixed frequent freezes on characters with auras for another class or for items
  not carried: those auras no longer make every aura recompile whenever spell or
  item data arrives.
- Trigger State Updater auras no longer lose their clones when an unrelated aura
  names a spell or item the client cannot look up.

Performance
- Much less garbage during play from dynamic groups, health, power and cast bars
  in raids, spell cooldowns, progress bars and looping animations.

- /wa prof report shows memory allocated per entry, WeakestAuras' share of the
  client's memory churn, and garbage collections with how long their frame took.
  /wa prof report kb sorts by memory.
2026-10-31 18:45:49 +00:00

7073 lines
325 KiB
Lua

-- WeakestAuras -- GenericTrigger-lite: the runtime trigger system for every
-- non-aura trigger kind. Ported from WeakAuras2's GenericTrigger (ref
-- WA2's GenericTrigger (§4), scaled to this client.
--
-- The payoff of the whole engine: a new trigger *category* is a data table (an
-- "event prototype" in PROTOTYPES) declaring its game/internal events, an `init`
-- preamble, an `args` list, and optional display hooks -- no new system code.
-- One prototype's args drive its test function, its state fields, its condition
-- variables (§10) and its options editor at once.
--
-- Split faithful to upstream (and to Conditions.lua's own reasoning): the
-- *matching* logic (init + per-arg tests + stores) is compiled to one Lua
-- function per trigger via ConstructFunction/loadstring -- prototype fragments
-- are Lua source by design, so an interpreter would loadstring the pieces
-- anyway. The *display* side (duration/name/icon) stays plain Lua closures the
-- system calls after a successful test, exactly as upstream's durationFunc/
-- nameFunc/iconFunc do. loadstring is confirmed on this client (StateMachine's
-- customTriggerLogic, Conditions' deferred custom). /wa gen <id> dumps the
-- generated source, making source-assembly errors readable.
--
-- Watcher sections sit inside a bare `do ... end`. Lua 5.0 allows 200 locals
-- live at once in a function, and this file's chunk is one function; a block
-- returns its slots at `end`, so each self-contained watcher costs the chunk
-- nothing. Closures still capture what they need -- an upvalue survives the
-- block that declared it -- and the sections reach the rest of the file the
-- way they already did, through WA.* and PROTOTYPES. Deleting one of those
-- `do`/`end` pairs re-spends its slots and puts the file back at the ceiling.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
local GenericTrigger = {}
-- events[id][triggernum] = triggerInfo (this display's compiled generic
-- triggers). Present after Add (compile); a ti only enters loaded_events (starts
-- receiving events) once the display is loaded (§11).
local events = {}
-- loaded_events[event][id][triggernum] = triggerInfo -- the dispatch index a
-- game/internal event walks (§4.3). Both game and internal events live here;
-- only game events are RegisterEvent'd on the frame (internal ones arrive via
-- WA.ScanEvents from a watcher).
local loaded_events = {}
-- activeIds[id] = true while this display's tis are in loaded_events (it's
-- loaded). Keeps Load/Unload idempotent.
local activeIds = {}
-- The tis a Delete just retired, kept only until the next Add compares its
-- sourceKey against them (see GenericTrigger.Delete).
local lastCompiled = {}
-- The display being compiled, and per display the spell or item names its
-- compile could not resolve, each with the resolver that failed it. A name
-- resolves to an id baked into the generated source, so an unresolved one
-- leaves that trigger dead until something recompiles it -- which is the entire
-- reason the addon recompiles on spell/item cache traffic (OptionsFrame.lua's
-- debounced sweep). Nothing else about a compile depends on the caches.
local compilingId
local unresolvedIds = {} -- id -> { [name] = resolver }
-- Keyed by the name that failed, so a display waiting on two of them says both.
-- Never printed: an item cache is cold at every login, so the common case is a
-- name that resolves a moment later and the recompile sweep clears it -- a chat
-- line would fire on every login and mean nothing.
local function noteUnresolved(input, resolve)
if not compilingId then return end
local names = unresolvedIds[compilingId]
if not names then
names = {}
unresolvedIds[compilingId] = names
end
names[input] = resolve
WA.ReportForAura(compilingId, "unresolved:" .. tostring(input), "warning",
"[" .. tostring(compilingId) .. "] \"" .. tostring(input)
.. "\" is not a spell or item this client can name yet, so the trigger using it stays dark.")
end
-- Whether any compiled display is still waiting on a name the client cannot
-- resolve yet.
function WA.HasUnresolvedNames()
for _ in pairs(unresolvedIds) do return true end
return false
end
-- Recompiles the displays waiting on a name that resolves now, and nothing
-- else. A name that never resolves -- another class's spell, an item never
-- carried -- is the common case on a multi-class profile, and recompiling every
-- display on each cache event for it was the addon's largest source of garbage.
-- Outside a compile the resolvers note nothing, so the retry is side-effect free.
function WA.RecompileResolvedNames()
local ready
for id, names in pairs(unresolvedIds) do
local data = WeakestAurasDB.displays[id]
if not data then
unresolvedIds[id] = nil
else
for input, resolve in pairs(names) do
if resolve(input) then
ready = ready or {}
table.insert(ready, data)
break
end
end
end
end
if ready then WA.AddMany(ready) end
return ready ~= nil
end
-- Internal (WA-generated) event names never handed to Frame:RegisterEvent -- a
-- watcher re-dispatches them through WA.ScanEvents instead (§4.4).
local INTERNAL_EVENTS = {
SPELL_COOLDOWN_CHANGED = true,
SPELL_COOLDOWN_READY = true,
ITEM_COOLDOWN_CHANGED = true,
ITEM_COOLDOWN_READY = true,
EQUIPSLOT_COOLDOWN_CHANGED = true,
EQUIPSLOT_COOLDOWN_READY = true,
GCD_UPDATE = true,
GCD_END = true,
-- Re-emitted by the threat watcher after parsing a TWThreat addon-message
-- broadcast (there's no threat game event; see the watcher below).
WA_THREAT_CHANGED = true,
-- Re-emitted by the swing-timer watcher on a swing reset / rescale / stop.
WA_SWING_UPDATE = true,
-- Re-emitted with the cast spell id after the player's own cast completes.
WA_SPELL_CAST_SUCCEEDED = true,
-- A shared 1s heartbeat for status prototypes whose value depletes with no
-- natural game event (weapon-enchant / crowd-control remaining time). Started
-- lazily by WA.EnsureSlowTick when such a trigger loads (below).
WA_SLOW_TICK = true,
-- A shared 0.1s heartbeat for status prototypes that track a fast-moving
-- value (range to a unit). Started lazily by WA.EnsureFastTick.
WA_FAST_TICK = true,
-- Fired after the player's login/loading-screen state has settled. Native
-- status APIs such as GetMoney can return their usable value only after the
-- initial PLAYER_ENTERING_WORLD burst.
WA_DELAYED_PLAYER_ENTERING_WORLD = true,
-- Re-emitted by the power-tick watcher when a regen tick is inferred or the
-- timer stops (power type change, full power).
WA_POWERTICK_UPDATE = true,
-- Upstream's per-frame custom-trigger pulse, carrying the frame's elapsed
-- time. Driven by an OnUpdate rather than C_Timer, which cannot schedule
-- per-frame; per-trigger onUpdateThrottle is what keeps it affordable.
FRAME_UPDATE = true,
}
-- The unit watcher's occupancy events carry the token in their name
-- (WA_UNIT_CHANGED_target), so they cannot be listed above: the set is whatever
-- tokens loaded auras name, `specific`'s free text included.
local UNIT_CHANGED_PREFIX = "WA_UNIT_CHANGED_"
-- The token an occupancy event names, or nil if this is not one.
local function unitChangeToken(event)
local _, _, token = string.find(event, "^" .. UNIT_CHANGED_PREFIX .. "(.+)$")
return token
end
local function isInternalEvent(name)
if INTERNAL_EVENTS[name] then return true end
return unitChangeToken(name) and true or false
end
-- ---------------------------------------------------------------------------
-- Code-generation helpers (ConstructFunction, §4.2)
-- ---------------------------------------------------------------------------
-- A config value as a Lua literal for embedding in generated source. %q handles
-- string quoting/escaping (Lua 5.0 has it); numbers/bools go verbatim.
local function fmt(v)
if type(v) == "number" then return tostring(v)
elseif type(v) == "boolean" then return v and "true" or "false"
elseif v == nil then return "nil"
else return string.format("%q", tostring(v)) end
end
-- Splits a user-typed "EVT_A, EVT_B EVT_C" event string (custom-trigger config)
-- into a trimmed list. Commas and any whitespace both separate; empty tokens are
-- dropped. Pattern string ops are fine on Lua 5.0 (gsub/find with patterns work;
-- only gmatch is missing).
local function parseEventList(str)
local out = {}
if not str or str == "" then return out end
local s = string.gsub(str, ",", " ")
local pos = 1
while true do
local a, b = string.find(s, "%s+", pos)
local tok
if a then tok = string.sub(s, pos, a - 1); pos = b + 1
else tok = string.sub(s, pos) end
if tok ~= "" then table.insert(out, tok) end
if not a then break end
end
return out
end
local COMMON_CUSTOM_EVENTS = {
"PLAYER_ENTERING_WORLD", "PLAYER_TARGET_CHANGED", "PLAYER_REGEN_DISABLED",
"PLAYER_REGEN_ENABLED", "UNIT_AURA", "UNIT_HEALTH", "UNIT_MANA",
"UNIT_ENERGY", "UNIT_RAGE", "BAG_UPDATE", "SPELLS_CHANGED",
"SPELL_UPDATE_COOLDOWN", "ACTIONBAR_UPDATE_COOLDOWN", "READY_CHECK",
"PARTY_MEMBERS_CHANGED", "RAID_ROSTER_UPDATE", "CHAT_MSG_SAY",
"CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_WHISPER",
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_SUCCEEDED",
"UNIT_SPELLCAST_INTERRUPTED", "UNIT_SPELLCAST_CHANNEL_START", "UNIT_SPELLCAST_CHANNEL_STOP",
"COMBAT_LOG_EVENT_UNFILTERED",
}
local customEventSearch = {}
-- One "EVENT:a:b" token's colon-separated parts.
local function splitEventToken(token)
local parts = {}
for part in string.gmatch(token, "[^:]+") do table.insert(parts, part) end
return parts
end
-- A custom trigger's event list in upstream's extended syntax, resolved into the
-- three things this client can act on: the events to register, a per-event set of
-- unit tokens to filter arg1 against, and the trigger numbers to watch.
--
-- "UNIT_AURA:player:target" is a filter rather than a narrower registration: the
-- general event is registered and dispatch drops payloads whose unit is not
-- listed.
-- "TRIGGER:2" names another of the display's triggers and registers no game
-- event at all. "CLEU" is upstream's alias for COMBAT_LOG_EVENT_UNFILTERED, and
-- "CLEU:SPELL_DAMAGE:SPELL_HEAL" filters on the subevent (arg2) the way
-- "UNIT_AURA:player" filters on the unit. A bare CLEU token takes every subevent.
local function parseCustomEventList(str)
local eventList, unitFilters, watched = {}, nil, nil
local cleu, subeventFilter, cleuAll = false, nil, false
local tokens = parseEventList(str)
for i = 1, #tokens do
local token = tokens[i]
local parts = splitEventToken(token)
local base = parts[1]
local upper = base and string.upper(base) or ""
if upper == "CLEU" or upper == "COMBAT_LOG_EVENT_UNFILTERED" then
if not cleu then
cleu = true
table.insert(eventList, "COMBAT_LOG_EVENT_UNFILTERED")
end
if #parts == 1 then cleuAll = true end
for p = 2, #parts do
subeventFilter = subeventFilter or {}
subeventFilter[string.upper(parts[p])] = true
end
elseif upper == "TRIGGER" then
for p = 2, #parts do
local num = tonumber(parts[p])
if num then
watched = watched or {}
watched[num] = true
end
end
elseif #parts > 1 and string.find(upper, "^UNIT_") then
table.insert(eventList, base)
unitFilters = unitFilters or {}
local set = unitFilters[base] or {}
unitFilters[base] = set
for p = 2, #parts do
set[string.lower(parts[p])] = true
end
else
table.insert(eventList, base or token)
end
end
if cleuAll then subeventFilter = nil end
return eventList, unitFilters, watched, subeventFilter
end
-- The unit filters a built-in prototype gets for free when its UNIT_* events
-- all name the one unit it reads (its `subjectUnit`): upstream's
-- RegisterUnitEvent. Without them every UNIT_HEALTH in a raid runs every Health
-- trigger, and ClassicAPI fires each one once per token -- raidN, partyN,
-- target, nameplateN, markN all at once. Only tokens ClassicAPI does fire
-- events under are filtered; a derived token (targettarget, mouseover) fires
-- none, so its trigger keeps refreshing on any unit's event as before.
local subjectUnitFilters
do
local FIRING_TOKENS = { player = true, pet = true, target = true, focus = true }
local function firesUnitEvents(token)
return FIRING_TOKENS[token] or string.find(token, "^party[1-4]$")
or string.find(token, "^raid%d+$") or string.find(token, "^nameplate%d+$")
or string.find(token, "^mark[1-8]$")
end
function subjectUnitFilters(eventList, token)
if type(token) ~= "string" then return nil end
token = string.lower(token)
if not firesUnitEvents(token) then return nil end
local set = { [token] = true }
local filters
for i = 1, #eventList do
local event = eventList[i]
if string.sub(event, 1, 5) == "UNIT_" then
filters = filters or {}
filters[event] = set
end
end
return filters
end
end
local function isClientEvent(name)
if isInternalEvent(name) then return true end
return C_EventUtils.IsEventValid(name) and true or false
end
-- Retail event names any custom trigger may listen under, and the 1.12 events
-- that carry the same news. First-class, not an import fixup: registering one
-- of these names physically registers its client events, and dispatch delivers
-- under the name the trigger listed -- so code branching on
-- `event == "UNIT_POWER_UPDATE"` runs as written whether the aura was imported
-- or typed here, and the events box always means what it says. Without this a
-- retail name pcall-registers into silence and the trigger never re-evaluates.
-- Vanilla splits power by resource and carries no power-type argument, so the
-- four resource events together are what "this unit's power moved" means here.
local EVENT_COMPAT = {
GROUP_ROSTER_UPDATE = { "PARTY_MEMBERS_CHANGED", "RAID_ROSTER_UPDATE" },
UNIT_POWER_UPDATE = { "UNIT_MANA", "UNIT_RAGE", "UNIT_ENERGY", "UNIT_FOCUS" },
UNIT_POWER_FREQUENT = { "UNIT_MANA", "UNIT_RAGE", "UNIT_ENERGY", "UNIT_FOCUS" },
}
-- Client event -> the compat names it feeds, for dispatch's second pass.
local EVENT_COMPAT_SOURCES = {}
for name, sources in pairs(EVENT_COMPAT) do
for i = 1, #sources do
EVENT_COMPAT_SOURCES[sources[i]] = EVENT_COMPAT_SOURCES[sources[i]] or {}
table.insert(EVENT_COMPAT_SOURCES[sources[i]], name)
end
end
-- Names the editor should flag, in two piles: `invalid` this client neither
-- fires nor redirects, and `redirected` the EVENT_COMPAT names -- which work,
-- and deserve a note saying how rather than a warning saying they don't. A
-- token carrying the extended syntax is judged on its base event, and TRIGGER
-- (no game event) and CLEU (an alias) are exempt rather than reported as
-- unregisterable.
local function invalidEventList(str)
local invalid, redirected = {}, {}
local names = parseEventList(str)
for i = 1, #names do
local parts = splitEventToken(names[i])
local base = parts[1] or names[i]
local upper = string.upper(base)
if upper == "TRIGGER" or upper == "CLEU" then
-- not a client event name
elseif EVENT_COMPAT[base] then
table.insert(redirected, base)
elseif not isClientEvent(base) then
table.insert(invalid, names[i])
end
end
return invalid, redirected
end
local function eventPickerValues(query)
local values, labels = {}, {}
local needle = string.upper(query or "")
local source = needle == "" and COMMON_CUSTOM_EVENTS or (WA.customEventCatalog or {})
for i = 1, #source do
local name = source[i]
if isClientEvent(name)
and (needle == "" or string.find(name, needle, 1, true)) then
table.insert(values, name)
labels[name] = name
if #values >= 40 then break end
end
end
if #values == 0 then
table.insert(values, "__NO_EVENT_MATCH__")
labels.__NO_EVENT_MATCH__ = "No matching events"
end
return values, labels
end
local function appendEventName(str, name)
if name == "__NO_EVENT_MATCH__" then return str or "" end
local names = parseEventList(str)
for i = 1, #names do
if names[i] == name then return str or "" end
end
if not str or not string.find(str, "%S") then return name end
return str .. " " .. name
end
local VALID_OPS = { ["=="] = true, ["~="] = true, ["<"] = true,
["<="] = true, [">"] = true, [">="] = true }
local function safeOp(op)
return VALID_OPS[op] and op or "=="
end
-- The comparisons a `string` arg offers, and their editor captions.
local STRING_OPS = { "==", "~=", "find", "notfind" }
local STRING_OP_LABELS = { ["=="] = "Is", ["~="] = "Is Not",
find = "Contains", notfind = "Doesn't Contain" }
-- The compiled half of a `multiselect` filter (the convention itself lives in
-- WeakestAuras.lua): the comparison it expands into, or nil when it is off.
-- Set keys survive a round trip through a serialiser as strings, so a numeric
-- domain coerces per key rather than trusting the key's type.
--
-- Callable from a prototype's `init` as well as from the arg walk below, so a
-- prototype needing the match as a named local (one an inverse toggle can
-- negate) builds it from the same source the plain filter would.
local function multiSelectSource(trigger, name, var)
local mode = WA.MultiSelectMode(trigger, name)
if mode == "multi" then
local set, terms = trigger[name .. "_multi"], {}
for value, selected in pairs(set or {}) do
if selected then
table.insert(terms, string.format("%s == %s", var, fmt(tonumber(value) or value)))
end
end
if #terms == 0 then return "false" end
return table.concat(terms, " or ")
elseif mode == "single" then
return string.format("%s == %s", var, fmt(trigger[name]))
end
return nil
end
-- The compiled half of a `list` filter: the value matches any entry. Where the
-- arg sets `listNegate`, an entry led by "-" excludes its value instead, as
-- upstream's ParseStringCheck does -- so a list of exclusions alone passes
-- anything else, a nil value included. A `listNumeric` arg skips an entry that
-- is not a number. A list with nothing usable matches nothing. The value is the
-- local `listVar` names, the arg's own name by default.
local function listSource(arg, entries)
if type(entries) ~= "table" then entries = { entries } end
local var = arg.listVar or arg.name
local include, exclude = {}, {}
for i = 1, #entries do
local _, _, negate, value = string.find(tostring(entries[i]), "^%s*(%-?)%s*(.-)%s*$")
if negate ~= "" and not arg.listNegate then value = negate .. value; negate = "" end
if arg.listNumeric then value = tonumber(value) end
if value ~= nil and value ~= "" then
if negate ~= "" then
table.insert(exclude, string.format("%s ~= %s", var, fmt(value)))
else
table.insert(include, string.format("%s == %s", var, fmt(value)))
end
end
end
if #include == 0 and #exclude == 0 then return "false" end
local parts = {}
if #include > 0 then table.insert(parts, "(" .. table.concat(include, " or ") .. ")") end
if #exclude > 0 then table.insert(parts, table.concat(exclude, " and ")) end
return table.concat(parts, " and ")
end
-- The test source for one arg, or nil if it contributes none. A `test` win:
-- either a format string with a single %s for the user value, or a function
-- (trigger) -> source-string for config-branching tests (e.g. genericShowOn's
-- three show modes each need a different comparison, not one template). A test
-- function returning nil contributes no test. Otherwise a number arg becomes a
-- gated `name <op> value` comparison.
local function constructArgTest(arg, trigger)
if arg.test then
if type(arg.test) == "function" then
local src = arg.test(trigger)
if not src then return nil end
return "(" .. src .. ")"
end
return "(" .. string.format(arg.test, fmt(trigger[arg.name])) .. ")"
end
if arg.type == "number" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
local op = safeOp(trigger[arg.name .. "_operator"] or ">=")
local test = string.format("(%s %s %s)", arg.name, op, fmt(trigger[arg.name] or 0))
-- A second bound on the same value turns one comparison into a range
-- ("between 20 and 50"), which one operator cannot express. Parenthesised
-- as a unit so it stays one term however the caller joins the tests.
if arg.multiEntry and trigger["use_" .. arg.name .. "2"] then
local op2 = safeOp(trigger[arg.name .. "2_operator"] or "<=")
return string.format("(%s and (%s %s %s))", test, arg.name, op2,
fmt(trigger[arg.name .. "2"] or 0))
end
return test
elseif arg.type == "select" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
return string.format("(%s == %s)", arg.name, fmt(trigger[arg.name]))
elseif arg.type == "multiselect" then
local src = multiSelectSource(trigger, arg.name, arg.name)
return src and ("(" .. src .. ")") or nil
elseif arg.type == "list" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
return "(" .. listSource(arg, trigger[arg.name]) .. ")"
elseif arg.type == "tristate" then
-- Upstream's three-way flag lives in use_<name> alone: nil ignores the
-- field, true requires it, false requires its absence. The field itself
-- is a local the init fills, not a setting.
local use = trigger["use_" .. arg.name]
if use == true then return "(" .. arg.name .. ")" end
if use == false then return "(not " .. arg.name .. ")" end
return nil
elseif arg.type == "string" then
if not arg.required and not trigger["use_" .. arg.name] then return nil end
local op = trigger[arg.name .. "_operator"] or "=="
local val = fmt(trigger[arg.name] or "")
-- find/notfind are plain substring searches (find's 4th argument), not Lua
-- patterns: spell and unit names carry magic characters the user should not
-- have to escape. Both sides lowered so matching is case-insensitive, and
-- the nil guard matters because a stored name is nil until its unit exists.
if op == "find" then
return string.format("(%s ~= nil and string.find(string.lower(%s), string.lower(%s), 1, true) ~= nil)",
arg.name, arg.name, val)
elseif op == "notfind" then
return string.format("(%s == nil or string.find(string.lower(%s), string.lower(%s), 1, true) == nil)",
arg.name, arg.name, val)
elseif op == "~=" then
return string.format("(%s ~= %s)", arg.name, val)
end
return string.format("(%s == %s)", arg.name, val)
end
return nil
end
-- Compiles prototype+trigger into one test function (§4.2). Signature
-- (state, event, arg1..arg12): the status prototypes re-read live game state in
-- `init` and ignore the event args, but the parameters carry the firing event's
-- real payload, so a prototype can test against it instead of re-polling.
-- Dispatch carries nine; only a chat event fills arg10..arg12 (runTriggerCode).
local function constructFunction(proto, trigger, errTag)
local lines = {}
table.insert(lines, "return function(state, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12)")
if proto.init then
table.insert(lines, proto.init(trigger))
end
for i = 1, #proto.args do
local arg = proto.args[i]
if arg.init then
table.insert(lines, string.format("local %s = %s", arg.name, arg.init))
end
end
local tests = {}
for i = 1, #proto.args do
local arg = proto.args[i]
-- An arg whose enable() is false is not part of this configuration (the
-- editor omits its control), so its test is left out too: a use_<name>
-- left on from an earlier mode would otherwise compile a comparison
-- against a local the current mode never fills.
if not (arg.enable and not arg.enable(trigger)) then
local t = constructArgTest(arg, trigger)
if t then table.insert(tests, t) end
end
end
local cond = #tests > 0 and table.concat(tests, " and ") or "true"
for i = 1, #proto.args do
local arg = proto.args[i]
if arg.store and arg.storeAlways then
table.insert(lines, string.format(
"if state.%s ~= %s then state.%s = %s state.changed = true end",
arg.name, arg.name, arg.name, arg.name))
end
end
table.insert(lines, "if (" .. cond .. ") then")
for i = 1, #proto.args do
local arg = proto.args[i]
if arg.store and not arg.storeAlways then
table.insert(lines, string.format(
"if state.%s ~= %s then state.%s = %s state.changed = true end",
arg.name, arg.name, arg.name, arg.name))
end
end
table.insert(lines, "return true else return false end")
table.insert(lines, "end")
local source = table.concat(lines, "\n")
-- Engine-generated, so it keeps the real globals: the sandbox belongs to the
-- code's author, not to the call site. The source is already complete, `return
-- function(...)` and all, which is why it goes through the builtin door.
local fn, err = WA.LoadBuiltinFunction(source, errTag)
return fn, source, err
end
-- Compiles a custom trigger's user text into a function.
-- The text is a whole function expression ("function(event, ...) ... end");
-- WA.LoadFunction owns the wrapper, the sandbox and the error report. Shape
-- mirrors constructFunction's return contract (fn, source, err).
local function constructCustomFunction(trigger, errTag)
local body = trigger.custom or ""
-- No source, no function, no complaint -- the same bargain compileCustomField
-- makes. Picking the Custom category compiles before the editor has painted
-- anything, and WA.LoadFunction refuses an empty body in the user's chat, so
-- an untouched trigger would report an error against code it has not been
-- given yet. It stays inert until there is something to run.
if not string.find(body, "%S") then return nil, body end
local fn, err = WA.LoadFunction(body, errTag)
return fn, body, err
end
local function compileCustomField(source, errTag)
if not source or not string.find(source, "%S") then return nil end
return WA.LoadFunction(source, errTag)
end
-- Whether one of a custom trigger's code fields is code the compile below
-- actually reaches. Asked of the trigger table alone, so the import review can
-- put the same question to a payload it has never compiled and get the
-- compiler's own answer. `key` is the field name, except that "untrigger"
-- stands for the untrigger block's `custom` -- which is the trigger's setting to
-- decide, not the block's. `customTexture`, `customStacks` and `customOverlay*`
-- arrive from WeakAuras and are read by nothing here, so they are never live.
--
-- A Trigger State Updater carries its own progress, name, icon and hiding, so
-- its auxiliary fields are not compiled at all rather than compiled and left
-- unread -- two sources for one value is what the editor hides in that mode.
function WA.TriggerCodeIsLive(trigger, key)
if type(trigger) ~= "table" or trigger.type ~= "custom" then return false end
local tsu = trigger.custom_type == "stateupdate"
if key == "custom" then return true end
if key == "customVariables" then return tsu end
if tsu then return false end
if key == "untrigger" then
return trigger.custom_type == "status"
or (trigger.custom_type == "event" and trigger.custom_hide == "custom")
end
if key == "customDuration" then
return trigger.custom_type ~= "event" or trigger.custom_hide == "custom"
end
return key == "customName" or key == "customIcon"
end
-- A Trigger State Updater's `customVariables` text is a table *expression*, not a
-- function, so it is wrapped into one that returns it. The newlines around the
-- body keep a trailing comment from swallowing the closing `end`.
local function compileTsuVariables(source, errTag)
if not source or not string.find(source, "%S") then return nil end
return WA.LoadFunction("function() return \n" .. source .. "\n end", errTag)
end
local function trueFunction() return true end
-- Resolves a "spell" field's stored value -- a numeric spellID, or a name the
-- user typed -- to a numeric spellID, or nil if it doesn't resolve to
-- anything (yet). Numeric input is trusted as-is and never round-tripped
-- through a lookup: the legacy GetSpellInfo(spellID) works for *any* spell
-- ID, known or not (ref ClassicAPI docs §Spell), so a raw ID the player
-- hasn't learned still resolves. A name resolves against the player's own
-- spellbook only, so a name for a spell they don't have (wrong spelling, wrong
-- class, not yet learned) legitimately has no numeric ID to resolve to and this
-- returns nil.
--
-- Two name lookups, in this order. The book-name one has to come first: the
-- engine resolver behind C_Spell (FUN_RESOLVE_SPELL_NAME_TO_BOOK_ID, the same
-- chain CastSpellByName uses) reaches its lookup through the macro parser's
-- rank splitter, which reads a trailing parenthesised suffix as a "(Rank N)"
-- and strips it -- so a spell whose real name ends in parentheses, "Faerie Fire
-- (Feral)", never arrives intact and either misses or lands on the unsuffixed
-- spell of the same stem. C_Spell still has the last word for the "(Rank N)"
-- forms and the pet book, neither of which the player-book index carries.
function WA.ResolveSpellID(input)
if input == nil or input == "" then return nil end
local id = tonumber(input)
if id then return id end
local bookId = WA.SpellIDByBookName(input)
if bookId then return bookId end
local info = C_Spell.GetSpellInfo(input)
if info and info.spellID then return info.spellID end
noteUnresolved(input, WA.ResolveSpellID)
return nil
end
-- Resolves an "item" field's stored value (a numeric itemID, or a name/
-- link the user typed) to a numeric itemID, or nil if it doesn't resolve to
-- anything (yet -- an uncached name needs the client to have seen the item
-- once, same GetItemInfo caveat as any other addon). No plain-Lua item-name-
-- >ID API exists, so a name resolves through GetItemInfo's link return and a
-- string.find capture (this client's Lua 5.0 has no string.match, ref
-- TextReplace.lua).
-- ---------------------------------------------------------------------------
-- The spellbook, indexed by name. The global cooldown is read off a slot because
-- there is no GCD spellID to query, and Debug.lua's /wa cdprobe reads slots to
-- show what each rank of a name reports. Ranks of a name occupy consecutive
-- ascending slots, so the last write wins and the index holds the highest rank
-- (the same rank walk DoiteAuras does before its own GetSpellCooldown calls).
-- ---------------------------------------------------------------------------
do -- scoped; see the local-ceiling note in the file header
local spellSlotByName, spellSlotById, exactSlotById
local function buildSpellSlotIndex()
spellSlotByName, spellSlotById = {}, {}
for tab = 1, GetNumSpellTabs() do
local _, _, offset, numSlots = GetSpellTabInfo(tab)
if offset and numSlots then
for i = offset + 1, offset + numSlots do
local name = GetSpellName(i, BOOKTYPE_SPELL or "spell")
if name then spellSlotByName[name] = i end
end
end
end
end
function WA.InvalidateSpellSlots()
spellSlotByName, spellSlotById, exactSlotById = nil, nil, nil
end
-- Slot numbers shift as the book grows, so every cached one stops meaning
-- anything. Registered here rather than on a consumer's frame: the per-spell
-- cooldown watcher depends on this index whether or not anything is watching the
-- global cooldown.
local spellBookFrame = CreateFrame("Frame")
pcall(spellBookFrame.RegisterEvent, spellBookFrame, "SPELLS_CHANGED")
pcall(spellBookFrame.RegisterEvent, spellBookFrame, "LEARNED_SPELL_IN_TAB")
spellBookFrame:SetScript("OnEvent", WA.InvalidateSpellSlots)
function WA.SpellSlotByName(name)
if not name then return nil end
if not spellSlotByName then buildSpellSlotIndex() end
return spellSlotByName[name]
end
-- spellID for a name that appears verbatim in the player's book, or nil. The
-- index holds the highest known rank, which is both the rank a bare name means
-- everywhere else and the one that carries the cooldown.
function WA.SpellIDByBookName(name)
local slot = WA.SpellSlotByName(name)
if not slot then return nil end
local ok, _, _, _, _, _, _, _, _, _, id = pcall(GetSpellInfo, slot, BOOKTYPE_SPELL or "spell")
if not ok then return nil end
return id
end
-- Slot for a spellID, via its name -- so a specific rank's ID still lands on the
-- book's highest rank, which is what actually goes on cooldown.
function WA.SpellSlotByID(spellId)
if not spellId or spellId == 0 then return nil end
if not spellSlotById then
if not spellSlotByName then buildSpellSlotIndex() end
spellSlotById = {}
end
local hit = spellSlotById[spellId]
if hit ~= nil then return hit or nil end
local name = C_Spell.GetSpellName(spellId)
local slot = name and spellSlotByName[name]
spellSlotById[spellId] = slot or false
return slot
end
-- The slot holding exactly this spellID -- this rank, not the name's highest --
-- or nil when that rank has no button in the player's book.
function WA.ExactSpellSlot(spellId)
if not spellId or spellId == 0 then return nil end
if not exactSlotById then exactSlotById = {} end
local hit = exactSlotById[spellId]
if hit == nil then
local slot, book = FindSpellBookSlotByID(spellId)
hit = (slot and book == (BOOKTYPE_SPELL or "spell")) and slot or false
exactSlotById[spellId] = hit
end
return hit or nil
end
-- (start, duration) off a spellbook slot, or nil. pcall'd: a slot that has gone
-- out of range (the book shrank) raises rather than answering.
function WA.SpellSlotCooldown(slot)
if not slot then return nil end
local ok, start, duration = pcall(GetSpellCooldown, slot, BOOKTYPE_SPELL or "spell")
if not ok or not start or not duration then return nil end
return start, duration
end
-- Whether the player has actually learned this spell. Two reads because neither
-- alone is enough: IsPlayerSpell answers off the engine's knowledge bitmap,
-- which holds one bit per spellID and so recognises only the *exact* rank
-- learned -- a display pointed at a lower-rank ID (what a WA2 pack that stored
-- one carries) would read as unknown -- while the book walk resolves the ID
-- through its name onto whichever rank is in the book, but sees nothing that
-- has no spellbook button.
function WA.SpellIsKnown(spellId)
if not spellId or spellId == 0 then return false end
if IsPlayerSpell(spellId) then return true end
return WA.SpellSlotByID(spellId) ~= nil
end
end -- spellbook index
-- itemID for a name the user typed, found by walking the player's own
-- containers. Nothing on this client resolves a name: C_Item.GetItemCount and
-- C_Item.GetItemInfo both take an id, an "item:N" string or a link, and say so
-- explicitly -- so an item named rather than linked can only be identified by
-- finding one you hold. A hit is memoized for good (an id never stops belonging
-- to a name); a miss is only rate-limited, since the item may simply not be
-- carried yet and looting one must start working without a reload.
do -- scoped; see the local-ceiling note in the file header
local itemIdByName = {}
local itemNameMissAt = {}
local ITEM_NAME_RESCAN = 0.5
-- Bags the scan walks. GetContainerItemID documents 0 (backpack) and 1..4
-- (equipped bags); the bank indices are tried anyway because the count call
-- reads the bank cold here, and an unsupported index just answers nil.
local SCAN_BAGS = { 0, 1, 2, 3, 4, -1, 5, 6, 7, 8, 9, 10 }
function WA.ItemIDByName(name)
if not name or name == "" then return nil end
local lname = string.lower(name)
if itemIdByName[lname] then return itemIdByName[lname] end
local last = itemNameMissAt[lname]
if last and (GetTime() - last) < ITEM_NAME_RESCAN then return nil end
local function matches(id)
if not id then return nil end
local n = C_Item.GetItemNameByID(id)
if n and string.lower(n) == lname then return id end
return nil
end
local function remember(id)
itemIdByName[lname] = id
itemNameMissAt[lname] = nil
return id
end
-- Equipped first: cheapest, and the one place a bag walk cannot look.
for slot = 1, 19 do
local hit = matches(GetInventoryItemID("player", slot))
if hit then return remember(hit) end
end
for i = 1, #SCAN_BAGS do
local bag = SCAN_BAGS[i]
for slot = 1, GetContainerNumSlots(bag) do
local hit = matches(C_Container.GetContainerItemID(bag, slot))
if hit then return remember(hit) end
end
end
itemNameMissAt[lname] = GetTime()
return nil
end
-- A name that missed can only start resolving once the player is carrying one,
-- and the id a miss baked into a trigger's compiled source is not revisited on
-- its own. GET_ITEM_INFO_RECEIVED (OptionsFrame.lua's recompile hook) covers an
-- item whose *static data* had not arrived; it does not fire for an item already
-- in the client's cache that simply was not in a bag yet, which is what this
-- covers. Shaped after upstream's itemDataLoadFrame: the retry only walks while
-- a name is outstanding, and a name that starts resolving recompiles.
local itemNameRetryFrame = CreateFrame("Frame")
pcall(itemNameRetryFrame.RegisterEvent, itemNameRetryFrame, "BAG_UPDATE")
itemNameRetryFrame:SetScript("OnEvent", function()
local retry
for lname in pairs(itemNameMissAt) do
retry = retry or {}
table.insert(retry, lname)
end
if not retry then return end
local resolved = false
for i = 1, #retry do
-- Clear the rate-limit stamp: a bag change is exactly the event that can
-- make this walk succeed, so it should not be waited out.
itemNameMissAt[retry[i]] = nil
if WA.ItemIDByName(retry[i]) then resolved = true end
end
if resolved then WA.RecompileResolvedNames() end
end)
end -- item-name cache
function WA.ResolveItemID(input)
if input == nil or input == "" then return nil end
local id = tonumber(input)
if id then return id end
-- A shift-clicked link (or any "item:N" string) carries the id outright,
-- which is the one form needing neither the item cache nor a scan.
local _, _, linkId = string.find(input, "item:(%d+)")
if linkId then return tonumber(linkId) end
local name, link = GetItemInfo(input)
if link then
local _, _, capturedId = string.find(link, "item:(%d+)")
local id2 = capturedId and tonumber(capturedId)
if id2 then return id2 end
end
local byName = WA.ItemIDByName(input)
if byName == nil then noteUnresolved(input, WA.ResolveItemID) end
return byName
end
-- Inspect the player's equipment and return the first matching item's details.
-- GetItemInfoInstant is cache-only: an ID can be present while its class data
-- is unavailable, so an uncached item is equipped but cannot match a type.
-- `wantedSubclasses` is a set of subclass ids, or nil for any subclass of the
-- class -- one weapon *type* is rarely what a user means, where "a one-hander"
-- is several of them.
function WA.ItemTypeEquipped(wantedClassID, wantedSubclasses, selectedSlot)
local firstSeen
local firstID, firstName, firstIcon, firstClass, firstSubclass
local firstClassID, firstSubclassID, firstKnown
local matchingID, matchingName, matchingIcon, matchingClass, matchingSubclass
local matchingClassID, matchingSubclassID
local equipped = false
local startSlot, endSlot = 1, 19
if selectedSlot and tonumber(selectedSlot) and tonumber(selectedSlot) > 0 then
startSlot, endSlot = tonumber(selectedSlot), tonumber(selectedSlot)
end
for slot = startSlot, endSlot do
local id = GetInventoryItemID("player", slot)
if id then
equipped = true
local itemID, itemClass, itemSubclass, _, itemIcon, classID, subclassID = C_Item.GetItemInfoInstant(id)
local known = itemID and classID ~= nil and subclassID ~= nil
if not firstSeen then firstID = itemID or id end
if known then
local itemName, _, _, _, _, _, _, _, _, cachedIcon = C_Item.GetItemInfo(itemID)
itemIcon = itemIcon or cachedIcon
if not firstKnown and not firstSeen then
firstName, firstIcon = itemName, itemIcon
firstClass, firstSubclass = itemClass, itemSubclass
firstClassID, firstSubclassID, firstKnown = classID, subclassID, true
end
local subclassMatches = wantedSubclasses == nil or wantedSubclasses[subclassID]
if not matchingID and classID == tonumber(wantedClassID) and subclassMatches then
matchingID, matchingName, matchingIcon = itemID, itemName, itemIcon
matchingClass, matchingSubclass = itemClass, itemSubclass
matchingClassID, matchingSubclassID = classID, subclassID
end
elseif not firstSeen then
firstID = itemID or id
end
firstSeen = true
end
end
if matchingID then
return matchingID, matchingName, matchingIcon, matchingClass, matchingSubclass,
matchingClassID, matchingSubclassID, equipped, true, true
end
return firstID, firstName, firstIcon, firstClass, firstSubclass,
firstClassID, firstSubclassID, equipped, false, firstKnown and true or false
end
function WA.ItemSetEquipped(setID)
local targetID = tonumber(setID) or 0
local total = 18
local count = 0
local setName
local known = false
local info = C_Item.GetItemSetInfo(targetID)
if info then
setName = info.name
known = true
end
if targetID > 0 then
for slot = 1, 18 do
local itemID = GetInventoryItemID("player", slot)
local equippedSetID = itemID and C_Item.GetItemSetIDByID(itemID)
if equippedSetID == targetID then count = count + 1 end
end
end
return count, total, setName, known
end
function WA.EquipmentSetInfo(setName, partial)
setName = setName or ""
local setID = C_EquipmentSet.GetEquipmentSetID(setName)
if setID then
local name, icon, _, isEquipped, numItems, numEquipped, _, _, numIgnored =
C_EquipmentSet.GetEquipmentSetInfo(setID)
if name then
numItems = tonumber(numItems) or 0
numEquipped = tonumber(numEquipped) or 0
numIgnored = tonumber(numIgnored) or 0
local active = (partial and numItems > 0 and numEquipped > 0)
or ((not partial) and isEquipped and true or false)
return name, icon, numEquipped, numItems, active and true or false, setID, numIgnored
end
end
-- ItemRack is name-keyed and has no numeric set ID. Prefer the
-- ClassicAPI/pfUI set when both systems contain the same name.
if ItemRack_GetUserSets then
local sets = ItemRack_GetUserSets()
local set = sets and sets[setName]
if set then
local total, count = 0, 0
for slot = 0, 19 do
local entry = set[slot]
if entry then
total = total + 1
local equippedID = GetInventoryItemID("player", slot)
if equippedID and tonumber(entry.id) == tonumber(equippedID) then count = count + 1 end
end
end
local active
if partial then active = total > 0 and count > 0
elseif ItemRack_IsSetEquipped then active = ItemRack_IsSetEquipped(setName) and true or false
else active = total > 0 and count == total end
return setName, set.icon, count, total, active and true or false, nil, 0
end
end
return nil, nil, 0, 0, false
end
-- How many of an item the player holds, plus whether the item was identified at
-- all. Resolved at run time rather than baked into the generated source like
-- other trigger constants: an item can be configured long before one is ever
-- carried, and a typed name only becomes resolvable once one is found.
function WA.ItemCount(input, includeBank)
local id = WA.ResolveItemID(input)
if not id then return 0, false end
return C_Item.GetItemCount(id, includeBank and true or false) or 0, true
end
-- Bool condition/filter for "is `unit` in range of this spell", unit defaulting
-- to "target" for the spell-cooldown caller. SpellHasRange gates self-buffs and
-- other unrestricted spells to always-true rather than gating on a unit the
-- spell was never range-limited against; IsSpellInRange is the engine's own
-- per-spell range check, melee and minimum range included. Real Lua (not
-- generated source) since neither call needs per-trigger config.
function WA.SpellInRange(spellId, unit)
unit = unit or "target"
if not C_Spell.SpellHasRange(spellId) then return true end
if not UnitExists(unit) then return false end
return C_Spell.IsSpellInRange(spellId, unit) == true
end
-- IsUsableSpell covers cast requirements but not proc state. When a spell is
-- present on a direct action-bar slot, IsUsableAction includes that state (for
-- example, Overpower after a dodge); spells without such a slot keep the
-- spell-level fallback used by the upstream trigger.
function WA.ActionUsability(spellId)
if GetActionInfo and IsUsableAction then
for slot = 1, 120 do
local actionType, actionId = GetActionInfo(slot)
if actionType == "spell" and actionId == spellId then
local usable, insufficientResources = IsUsableAction(slot)
return usable and true or false, insufficientResources and true or false
end
end
end
local usable, insufficientResources = IsUsableSpell(spellId)
return usable and true or false, insufficientResources and true or false
end
-- The unit token a trigger actually targets: the dropdown's choice, or the
-- free-text override when the dropdown's "specific" entry is selected (raid17,
-- partyN, or a GUID). Resolved at compile time and baked into the
-- generated source, like every other trigger constant.
function WA.TriggerUnit(trigger, fallback)
if trigger.unit == "specific" then
if trigger.specificUnit and trigger.specificUnit ~= "" then
return trigger.specificUnit
end
return fallback or "player"
end
return trigger.unit or fallback or "player"
end
-- GetItemInfo's classic 14-tuple; only name (1st) and icon (10th) matter to
-- the item-keyed prototypes below.
-- C_Item.GetItemInfo, not the stock global: the global answers vanilla's short
-- tuple, which has no itemLevel and so puts the texture one slot earlier than
-- the modern 18-value shape documents. Reading position 10 off the global gets
-- nil, which is why an item trigger drew no icon.
local function itemNameIcon(id)
if not id then return nil, nil end
local name, _, _, _, _, _, _, _, _, icon = C_Item.GetItemInfo(id)
return name, icon
end
-- Shared 1s heartbeat: some status prototypes hold a value that depletes with no
-- natural game event (a weapon enchant's remaining time, a crowd-control debuff's
-- countdown-to-expiry). They list "WA_SLOW_TICK" in their events and call this in
-- loadFunc; the ticker starts once and re-dispatches through ScanEvents so those
-- triggers re-read live state ~1x/sec (the region itself animates the countdown;
-- this is only to flip show=false at expiry and re-detect a fresh application).
local slowTicker
function WA.EnsureSlowTick()
if not slowTicker then
slowTicker = C_Timer.NewTicker(1, function() WA.ScanEvents("WA_SLOW_TICK") end)
end
end
-- ---------------------------------------------------------------------------
-- The unit-token watcher (§4.4 WatchUnitChange)
--
-- A single-token trigger reads whatever `target`/`focus`/`mouseover`/... points
-- at right now, and nothing in the game announces "that token moved" in a form
-- a prototype can list: PLAYER_TARGET_CHANGED says the token, not the trigger.
-- So the occupancy half lives here once, for every unit prototype, and reaches
-- them as WA_UNIT_CHANGED_<token> through the ordinary dispatch. A multi-unit
-- family gets its churn from appendMultiUnitEvents instead and never enters
-- this set.
-- ---------------------------------------------------------------------------
do -- scoped; see the local-ceiling note in the file header
-- Tokens whose occupancy the client announces. `player` is here because it
-- never moves at all. Everything else -- the derived-target tokens, and
-- whatever `specific` names -- is polled: UNIT_TARGET exists on no ClassicAPI
-- surface and in no sibling addon, so there is nothing to listen to.
local EVENTED_UNIT_TOKENS = {
player = true, target = true, focus = true, pet = true, mouseover = true,
}
-- Nameplate churn is a wake as much as the occupancy events are: a `nameplateN`
-- token's creature changes with the plate, and a monitor re-resolving a frame
-- has to hear about a recycled plate.
local UNIT_WATCH_EVENTS = {
"PLAYER_TARGET_CHANGED", "PLAYER_FOCUS_CHANGED", "UNIT_PET",
"UPDATE_MOUSEOVER_UNIT", "PLAYER_ENTERING_WORLD",
"NAME_PLATE_UNIT_ADDED", "NAME_PLATE_UNIT_REMOVED",
}
-- "This token points at nothing" as a value rather than as an absent key, so
-- tokenGuid's keys stay exactly the watch set and the diff below needs no nil
-- case of its own. No GUID can equal it.
local NO_UNIT = "\0"
local watchedTokens = {} -- token -> refcount, held while an aura naming it is loaded
local polledTokens = {} -- the watched tokens with no occupancy event
local tokenGuid = {} -- token -> last seen GUID, or NO_UNIT
local watchCallbacks = {}
local unitWatchFrame
local diffScratch = {}
local function diffUnitToken(token)
local guid = UnitGUID(token) or NO_UNIT
if tokenGuid[token] == guid then return end
tokenGuid[token] = guid
-- The GUID, not the event, is what defines a change: a target-to-target
-- swap, an emptied token and a refilled one all come out here, and an event
-- that fires spuriously costs nothing.
WA.ScanEvents(UNIT_CHANGED_PREFIX .. token, token)
end
-- Dispatch can load, unload or delete a display, which rewrites the watch set
-- mid-walk; the tokens are copied out first so `next` is never resumed from a
-- key that has since gone. The scratch table is reused -- a tick that changes
-- nothing must not allocate.
local function diffUnitTokens(set)
local n = 0
for token in pairs(set) do n = n + 1; diffScratch[n] = token end
for i = 1, n do diffUnitToken(diffScratch[i]) end
end
local function runWatchCallbacks()
for i = 1, #watchCallbacks do
WA.safecall("unit watch callback", watchCallbacks[i])
end
end
local function ensureUnitWatchFrame()
if unitWatchFrame then return end
unitWatchFrame = CreateFrame("Frame")
for i = 1, #UNIT_WATCH_EVENTS do
-- Guarded on the same grounds as ensureEventRegistered's: an event name
-- this build does not know must not error out the load that asked for it.
pcall(unitWatchFrame.RegisterEvent, unitWatchFrame, UNIT_WATCH_EVENTS[i])
end
unitWatchFrame:SetScript("OnEvent", function()
diffUnitTokens(watchedTokens)
runWatchCallbacks()
end)
end
-- The polled half. The fast tick below is its only caller in game; it is public
-- because a headless run has to drive it directly, a ticker created at load
-- being unpaceable there.
function WA.PollUnitTokens()
diffUnitTokens(polledTokens)
runWatchCallbacks()
end
-- Start watching `token`, refcounted. The count is what makes this safe across
-- load transitions: loadFunc-style accumulation would poll every token an aura
-- ever selected for the rest of the session, since nothing unloads it again.
-- LoadDisplays and unregisterEvents own the pairing, off the same event list
-- loaded_events is keyed by.
function WA.WatchUnitToken(token)
if not token or token == "" then return end
local count = watchedTokens[token]
watchedTokens[token] = (count or 0) + 1
if count then return end
-- Seeded rather than dispatched: LoadDisplays' own force_events pass is what
-- gives the new trigger its first read.
tokenGuid[token] = UnitGUID(token) or NO_UNIT
ensureUnitWatchFrame()
if not EVENTED_UNIT_TOKENS[token] then
polledTokens[token] = true
WA.EnsureFastTick()
end
end
function WA.UnwatchUnitToken(token)
local count = watchedTokens[token]
if not count then return end
if count > 1 then watchedTokens[token] = count - 1; return end
watchedTokens[token] = nil
polledTokens[token] = nil
tokenGuid[token] = nil
end
-- What the watcher holds for one token: how many loaded triggers name it, and
-- whether it rides the poll rather than an event.
function WA.UnitTokenWatch(token)
return watchedTokens[token] or 0, polledTokens[token] and true or false
end
-- Anything that resolves a live token to a frame and must re-resolve when the
-- token moves -- an external glow on a unit frame or a plate. Called on every
-- wake, event or tick, because the caller's token comes from a region's state
-- rather than from the watch set.
function WA.RegisterUnitWatchCallback(fn)
table.insert(watchCallbacks, fn)
ensureUnitWatchFrame()
end
end -- unit-token watcher
-- Shared 0.1s heartbeat for status prototypes tracking a fast-moving value
-- (range to a unit), and the carrier for the polled tokens above. Started
-- lazily by WA.EnsureFastTick when such a trigger loads and never cancelled; a
-- range readout wants smoother than the 1s slow tick, and 0.1s is cheap because
-- ScanEvents early-outs on an event no trigger is registered for and the poll
-- walks an empty set until something enrols.
local fastTicker
function WA.EnsureFastTick()
if not fastTicker then
fastTicker = C_Timer.NewTicker(0.1, function()
WA.PollUnitTokens()
WA.ScanEvents("WA_FAST_TICK")
end)
end
end
-- Temporary weapon enchant (oils/stones/poisons) for one hand. C_Item.
-- GetWeaponEnchantInfo returns a flat 12-tuple (main/off/ranged x has/expireMs/
-- charges/enchantID) -- captured whole via a plain multi-assign, never an
-- `and`-chain (which truncates multi-returns to one value on this client's Lua
-- 5.0). expireMs is remaining time in ms; the API gives no *original* duration.
-- The cached expiration is what is *returned*: expireMs is coarse, so
-- GetTime()+remaining wobbles between reads, and publishing the fresh value
-- re-armed the display's timer with a slightly different endpoint on every
-- poll. Within a 1s tolerance the timer holds still; past it the cache snaps
-- to the new value.
local weCache = {}
-- The original duration, learned: the only moment it is readable is an
-- application seen happening, when the remaining IS the full window (less the
-- poll's lag, which rounding to the 30s grid every vanilla enchant duration
-- sits on absorbs -- Quartermaster's curated table is 1800 on all but two
-- rows). Learned per enchantId, monotonic max -- a mid-life first sighting
-- stores its too-low remaining and the first watched application raises it for
-- good -- and persisted in the SavedVariables so a bar after /reload spans the
-- real window with the live remaining inside it, not a full bar of whatever
-- was left.
local function weLearnedDurations()
if type(WeakestAurasDB) ~= "table" then return nil end
if type(WeakestAurasDB.weaponEnchantDurations) ~= "table" then
WeakestAurasDB.weaponEnchantDurations = {}
end
return WeakestAurasDB.weaponEnchantDurations
end
function WA.WeaponEnchantInfo(hand)
local hM, eM, cM, idM, hO, eO, cO, idO, hR, eR, cR, idR = C_Item.GetWeaponEnchantInfo()
local has, expireMs, enchantId
if hand == "off" then has, expireMs, enchantId = hO, eO, idO
elseif hand == "ranged" then has, expireMs, enchantId = hR, eR, idR
else has, expireMs, enchantId = hM, eM, idM end
if not has then weCache[hand] = nil; return false, 0, 0, nil end
local remaining = (expireMs or 0) / 1000
local expiration = GetTime() + remaining
local c = weCache[hand]
-- A new identity re-derives the window too: swapping oils mid-life must not
-- leave the new enchant wearing the old one's duration.
if not c or c.enchantId ~= enchantId or expiration > c.expiration + 2 then
local durations = weLearnedDurations()
local known = durations and enchantId and durations[enchantId]
local rounded = math.floor((remaining + 15) / 30) * 30
if durations and enchantId and rounded > 0 and rounded > (known or 0) then
durations[enchantId] = rounded
known = rounded
end
local duration = (known and known >= remaining) and known or remaining
c = { expiration = expiration, duration = duration, enchantId = enchantId }
weCache[hand] = c
elseif math.abs(expiration - c.expiration) > 1 then
c.expiration = expiration
end
return true, c.expiration, c.duration, enchantId
end
-- The enchant's own icon: the proc/use/equip spell C_Item.GetEnchantInfo names
-- for it (a poison's proc, an imbue's aura), read once per enchantId; vanilla
-- has no direct enchant->icon read. A spell-less enchant (sharpening stones are
-- flat weapon damage) falls back to the enchanted weapon's texture, read live
-- so a swap moves it.
local weIconCache = {}
local WE_ICON_SLOT = { main = 16, off = 17, ranged = 18 }
function WA.WeaponEnchantIcon(hand, enchantId)
local icon = enchantId and weIconCache[enchantId]
if icon == nil and enchantId then
icon = false
local info = C_Item.GetEnchantInfo(enchantId)
if info then
local spellId = info.spellID
if not spellId and info.effects then
for i = 1, #info.effects do
local e = info.effects[i]
if e and (e.type == 1 or e.type == 3 or e.type == 7) and e.arg then
spellId = e.arg
break
end
end
end
if spellId then
icon = C_Spell.GetSpellTexture(spellId) or false
end
end
weIconCache[enchantId] = icon
end
if icon then return icon end
return GetInventoryItemTexture("player", WE_ICON_SLOT[hand] or 16)
end
-- Reputation standing for a named faction. Walks the displayed faction list
-- (headers skipped) since GetFactionInfoByID needs an ID the user doesn't know;
-- niche enough that an O(n) scan on the UPDATE_FACTION event is fine. barMin/
-- barMax/barValue bracket the current standing bar. nil when the faction isn't
-- in the player's list (not yet encountered).
function WA.FactionStanding(name)
if not name or name == "" then return nil end
for i = 1, GetNumFactions() do
local fname, _, standingID, barMin, barMax, barValue, _, _, isHeader = GetFactionInfo(i)
if not isHeader and fname == name then
return standingID, barMin, barMax, barValue, fname
end
end
return nil
end
-- The first active loss-of-control effect on the player that matches, or
-- (false, ...). A nil controlType matches any type; a SCHOOL_INTERRUPT match
-- can be narrowed to the schools in schoolMask. C_LossOfControl classifies from
-- the spell's own data and takes CC timing from the server's player aura timer.
-- A school lockout carries no spell, so it reports spellID 0 and the locked
-- schools as `school`.
function WA.PlayerLossOfControl(controlType, schoolMask)
for i = 1, C_LossOfControl.GetActiveLossOfControlDataCount() do
local d = C_LossOfControl.GetActiveLossOfControlData(i)
if d and (not controlType or d.locType == controlType)
and (not schoolMask or d.locType ~= "SCHOOL_INTERRUPT"
or bit.band(d.lockoutSchool, schoolMask) ~= 0) then
local school = d.lockoutSchool ~= 0 and C_Spell.GetSchoolString(d.lockoutSchool) or nil
local expirationTime = d.timeRemaining and (GetTime() + d.timeRemaining) or 0
local icon = d.iconTexture ~= "" and d.iconTexture or nil
return true, d.locType, d.spellID, d.displayText, icon, expirationTime, d.duration or 0, school
end
end
return false, nil, nil, nil, nil, 0, 0, nil
end
do -- scoped; see the local-ceiling note in the file header
-- The character-stat readers the charstats prototype's args call by key. One
-- entry point rather than inline API calls per arg: most of these are
-- multi-return with a discarded first value, which an arg `init` expression
-- cannot express on 5.0, and each reader guards its own API's absence once.
--
-- Four conventions here are not derivable from the call site:
-- * UnitAttackPower / UnitRangedAttackPower return base, posBuff, negBuff with
-- negBuff ALREADY NEGATIVE. Effective is base + pos + neg; subtracting it
-- counts every Curse of Weakness twice, in the wrong direction.
-- * UnitResistance's index is 0-based with 0 = armor, so the six schools are
-- 1..6. (SuperCleveRoidMacros passes 3..7 for fire..arcane and is wrong.)
-- * GetSpellBonusDamage's school is 1-based with 1 = physical -- a different
-- base from UnitResistance's, for the same six schools.
-- * UnitArmor returns five values and the effective armor is the second;
-- UnitDefense returns base and modifier, which have to be summed.
local function primaryStat(index)
local _, effective = UnitStat("player", index)
return effective or 0
end
local function resistance(school)
local _, total = UnitResistance("player", school)
return total or 0
end
local function schoolPower(school)
return GetSpellBonusDamage(school) or 0
end
local function effectiveAttackPower(fn)
if not fn then return 0 end
local base, pos, neg = fn("player")
return (base or 0) + (pos or 0) + (neg or 0)
end
local function weaponSpeed(offHand)
local main, off = UnitAttackSpeed("player")
if offHand then return off or 0 end
return main or 0
end
local STAT_READERS = {
strength = function() return primaryStat(1) end,
agility = function() return primaryStat(2) end,
stamina = function() return primaryStat(3) end,
intellect = function() return primaryStat(4) end,
spirit = function() return primaryStat(5) end,
attackPower = function() return effectiveAttackPower(UnitAttackPower) end,
rangedAttackPower = function() return effectiveAttackPower(UnitRangedAttackPower) end,
mainHandSpeed = function() return weaponSpeed(false) end,
offHandSpeed = function() return weaponSpeed(true) end,
-- The paperdoll's "spell damage" is the best school, not their sum, and
-- physical (school 1) is not part of that comparison.
spellPower = function()
local best = 0
for school = 2, 7 do
local value = schoolPower(school)
if value > best then best = value end
end
return best
end,
spellPowerHoly = function() return schoolPower(2) end,
spellPowerFire = function() return schoolPower(3) end,
spellPowerNature = function() return schoolPower(4) end,
spellPowerFrost = function() return schoolPower(5) end,
spellPowerShadow = function() return schoolPower(6) end,
spellPowerArcane = function() return schoolPower(7) end,
healingPower = function()
return GetSpellBonusHealing() or 0
end,
spellHaste = function()
return UnitSpellHaste("player") or 0
end,
armor = function()
local _, effective = UnitArmor("player")
return effective or 0
end,
defense = function()
local base, modifier = UnitDefense("player")
return (base or 0) + (modifier or 0)
end,
dodge = function() return GetDodgeChance() or 0 end,
parry = function() return GetParryChance() or 0 end,
block = function() return GetBlockChance() or 0 end,
resistHoly = function() return resistance(1) end,
resistFire = function() return resistance(2) end,
resistNature = function() return resistance(3) end,
resistFrost = function() return resistance(4) end,
resistShadow = function() return resistance(5) end,
resistArcane = function() return resistance(6) end,
}
-- One player stat by key, 0 for anything this client cannot answer. Called from
-- generated trigger code, so an unknown key returns a number rather than
-- erroring the whole compiled test.
function WA.CharacterStat(key)
local reader = STAT_READERS[key]
if not reader then return 0 end
return reader() or 0
end
end
-- ---------------------------------------------------------------------------
-- Prototypes (§4). Each = one table; adding a category never touches the
-- system code above or below.
-- ---------------------------------------------------------------------------
local PROTOTYPES = {}
-- A saved trigger field written under an older name, moved in place. Reached
-- through the trigger type's `migrate` hook, which MergeDefaults runs on every
-- load before seeding defaults -- so it has to stay idempotent and must not
-- overwrite a value already stored under the new name.
local function renameArg(trigger, old, new)
if trigger[old] == nil then return end
if trigger[new] == nil then trigger[new] = trigger[old] end
trigger[old] = nil
end
local COMBAT_EVENT_VALUES = { "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED" }
local COMBAT_EVENT_LABELS = {
PLAYER_REGEN_DISABLED = "Entering Combat",
PLAYER_REGEN_ENABLED = "Leaving Combat",
}
local CHAT_MESSAGE_VALUES = {
"CHAT_MSG_SAY", "CHAT_MSG_PARTY", "CHAT_MSG_RAID", "CHAT_MSG_GUILD",
"CHAT_MSG_OFFICER", "CHAT_MSG_YELL", "CHAT_MSG_WHISPER", "CHAT_MSG_EMOTE",
"CHAT_MSG_SYSTEM", "CHAT_MSG_MONSTER_SAY",
"CHAT_MSG_MONSTER_YELL", "CHAT_MSG_MONSTER_WHISPER", "CHAT_MSG_MONSTER_EMOTE",
"CHAT_MSG_CHANNEL", "CHAT_MSG_LOOT", "CHAT_MSG_RAID_WARNING",
"CHAT_MSG_BG_SYSTEM_NEUTRAL", "CHAT_MSG_BG_SYSTEM_ALLIANCE", "CHAT_MSG_BG_SYSTEM_HORDE",
}
local CHAT_MESSAGE_LABELS = {}
for i = 1, #CHAT_MESSAGE_VALUES do
local value = CHAT_MESSAGE_VALUES[i]
CHAT_MESSAGE_LABELS[value] = string.sub(value, 10)
end
local CHAT_MESSAGE_ALL_EVENTS = {}
for i = 1, #CHAT_MESSAGE_VALUES do table.insert(CHAT_MESSAGE_ALL_EVENTS, CHAT_MESSAGE_VALUES[i]) end
table.insert(CHAT_MESSAGE_ALL_EVENTS, "CHAT_MSG_PARTY_LEADER")
table.insert(CHAT_MESSAGE_ALL_EVENTS, "CHAT_MSG_RAID_LEADER")
table.insert(CHAT_MESSAGE_ALL_EVENTS, "CHAT_MSG_TEXT_EMOTE")
local function chatMessageEvents(trigger)
if trigger and trigger.use_messageType and trigger.messageType and CHAT_MESSAGE_LABELS[trigger.messageType] then
local events = { trigger.messageType }
if trigger.messageType == "CHAT_MSG_PARTY" then table.insert(events, "CHAT_MSG_PARTY_LEADER") end
if trigger.messageType == "CHAT_MSG_RAID" then table.insert(events, "CHAT_MSG_RAID_LEADER") end
if trigger.messageType == "CHAT_MSG_EMOTE" then table.insert(events, "CHAT_MSG_TEXT_EMOTE") end
return events
end
return CHAT_MESSAGE_ALL_EVENTS
end
function WA.TalentInfo(wanted, wantedTab, wantedTier, wantedColumn)
if not wanted or wanted == "" then return false, false, 0, 0, 0, 0, 0, nil, nil end
local tabs = tonumber(GetNumTalentTabs()) or 0
for tab = 1, tabs do
if wantedTab == 0 or wantedTab == tab then
local count = tonumber(GetNumTalents(tab)) or 0
for index = 1, count do
local name, icon, tier, column, rank, maxRank = GetTalentInfo(tab, index)
if name == wanted
and (wantedTier == 0 or tier == wantedTier)
and (wantedColumn == 0 or column == wantedColumn) then
return true, (tonumber(rank) or 0) > 0, tonumber(rank) or 0,
tonumber(maxRank) or 0, tab, tonumber(tier) or 0, tonumber(column) or 0, icon
end
end
end
end
return false, false, 0, 0, 0, 0, 0, nil, nil
end
function WA.PetBehavior()
local slots = NUM_PET_ACTION_SLOTS or 10
for index = 1, slots do
local name, _, texture, token, active = GetPetActionInfo(index)
if active and name then
local behavior
if name == "PET_MODE_AGGRESSIVE" then behavior = "aggressive"
elseif name == "PET_MODE_ASSIST" then behavior = "assist"
elseif name == "PET_MODE_DEFENSIVEASSIST" or name == "PET_MODE_DEFENSIVE" then behavior = "defensive"
elseif name == "PET_MODE_PASSIVE" then behavior = "passive" end
if behavior then
if token and texture then texture = _G[texture] end
return behavior, texture
end
end
end
return nil, nil
end
do -- scoped; see the local-ceiling note in the file header
local TALENT_TAB_VALUES = { 0, 1, 2, 3 }
local TALENT_TAB_LABELS = { [0] = "Any Tab", [1] = "Tab 1", [2] = "Tab 2", [3] = "Tab 3" }
local TALENT_RANK_VALUES = { "ignore", "known", "maxed" }
local TALENT_RANK_LABELS = { ignore = "Any Rank", known = "Known (rank > 0)", maxed = "Max Rank" }
PROTOTYPES["talentknown"] = {
displayName = "Talent Known",
wa2Event = "Talent Known",
category = "unit",
progressType = "static",
progressValue = "rank",
progressTotal = "maxRank",
events = function() return { "CHARACTER_POINTS_CHANGED", "SPELLS_CHANGED", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Book_09",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Book_09" end,
nameFunc = function(trigger) return trigger.talentName or "Talent Known" end,
iconFunc = function(trigger)
local _, _, _, _, _, _, _, icon = WA.TalentInfo(trigger.talentName,
(trigger.usePlacement and tonumber(trigger.talentTab)) or 0,
(trigger.usePlacement and tonumber(trigger.talentTier)) or 0,
(trigger.usePlacement and tonumber(trigger.talentColumn)) or 0)
return icon or "Interface\\Icons\\INV_Misc_Book_09"
end,
init = function(trigger)
return "local talentFoundValue, knownValue, rankValue, maxRankValue, talentTabFoundValue, talentTierFoundValue, talentColumnFoundValue, talentIconValue = WeakestAuras.TalentInfo("
.. fmt(trigger.talentName or "") .. ", " .. tostring(tonumber(trigger.talentTab) or 0) .. ", "
.. tostring(tonumber(trigger.talentTier) or 0) .. ", " .. tostring(tonumber(trigger.talentColumn) or 0) .. ")"
end,
args = {
{ name = "talentName", type = "talent", display = "Talent Name", required = true },
{ name = "usePlacement", type = "toggle", display = "Advanced Placement Filters", default = false, reloadOptions = true },
{ name = "talentTab", type = "select", required = true, display = "Talent Tab",
valueList = TALENT_TAB_VALUES, valueLabels = TALENT_TAB_LABELS, default = 0,
enable = function(trigger) return trigger.usePlacement end,
reloadOptions = true, test = function() return nil end },
{ name = "talentTier", type = "range", display = "Tier", min = 0, max = 10, step = 1, default = 0,
enable = function(trigger) return trigger.usePlacement end,
reloadOptions = true, test = function() return nil end },
{ name = "talentColumn", type = "range", display = "Column", min = 0, max = 4, step = 1, default = 0,
enable = function(trigger) return trigger.usePlacement end,
reloadOptions = true, test = function() return nil end },
{ name = "rankMode", type = "select", required = true, display = "Rank",
valueList = TALENT_RANK_VALUES, valueLabels = TALENT_RANK_LABELS, default = "ignore", reloadOptions = true,
test = function(trigger)
if trigger.rankMode == "known" then return "(talentFound and known)" end
if trigger.rankMode == "maxed" then return "(known and rank >= maxRank)" end
return "talentFound"
end },
{ name = "talentFound", type = "hidden", init = "talentFoundValue", store = true, conditionType = "bool", display = "Talent Found" },
{ name = "known", type = "hidden", init = "knownValue", store = true, conditionType = "bool", display = "Known" },
{ name = "rank", type = "hidden", init = "rankValue", store = true, conditionType = "number", display = "Rank" },
{ name = "maxRank", type = "hidden", init = "maxRankValue", store = true, conditionType = "number", display = "Max Rank" },
{ name = "talentTabFound", type = "hidden", init = "talentTabFoundValue", store = true, conditionType = "number", display = "Tab" },
{ name = "talentTierFound", type = "hidden", init = "talentTierFoundValue", store = true, conditionType = "number", display = "Tier" },
{ name = "talentColumnFound", type = "hidden", init = "talentColumnFoundValue", store = true, conditionType = "number", display = "Column" },
{ name = "icon", type = "hidden", init = "talentIconValue", store = true },
},
}
end -- talent prototype
do -- scoped; see the local-ceiling note in the file header
local CONDITION_STATUS_LABELS = { ignore = "Ignore", ["true"] = "Yes", ["false"] = "No" }
local CONDITION_STATUS_VALUES = { "ignore", "true", "false" }
local function conditionStatusTest(name)
return function(trigger)
local value = trigger[name] or "ignore"
if value == "ignore" then return nil end
return value == "true" and name or "not " .. name
end
end
local function addConditionEvent(events, seen, eventName)
if not seen[eventName] then
seen[eventName] = true
table.insert(events, eventName)
end
end
local function conditionEvents(trigger)
local out, seen = {}, {}
addConditionEvent(out, seen, "PLAYER_ENTERING_WORLD")
if trigger.incombat ~= "ignore" then
addConditionEvent(out, seen, "PLAYER_REGEN_ENABLED")
addConditionEvent(out, seen, "PLAYER_REGEN_DISABLED")
end
if trigger.alive ~= "ignore" then
addConditionEvent(out, seen, "PLAYER_DEAD")
addConditionEvent(out, seen, "PLAYER_ALIVE")
addConditionEvent(out, seen, "PLAYER_UNGHOST")
end
if trigger.onTaxi ~= "ignore" then
addConditionEvent(out, seen, "PLAYER_CONTROL_LOST")
addConditionEvent(out, seen, "PLAYER_CONTROL_GAINED")
end
if trigger.resting ~= "ignore" then
addConditionEvent(out, seen, "PLAYER_UPDATE_RESTING")
end
if trigger.mounted ~= "ignore" then
addConditionEvent(out, seen, "PLAYER_AURAS_CHANGED")
addConditionEvent(out, seen, "PLAYER_MOUNT_DISPLAY_CHANGED")
end
if trigger.hasPet ~= "ignore" then
addConditionEvent(out, seen, "UNIT_PET")
end
if trigger.isMoving ~= "ignore" then
addConditionEvent(out, seen, "WA_FAST_TICK")
end
if trigger.afk ~= "ignore" or trigger.pvpFlagged ~= "ignore" then
addConditionEvent(out, seen, "PLAYER_FLAGS_CHANGED")
end
if trigger.groupType ~= "ignore" then
addConditionEvent(out, seen, "GROUP_ROSTER_UPDATE")
addConditionEvent(out, seen, "PARTY_MEMBERS_CHANGED")
addConditionEvent(out, seen, "RAID_ROSTER_UPDATE")
end
if trigger.instanceType ~= "ignore" then
addConditionEvent(out, seen, "ZONE_CHANGED")
addConditionEvent(out, seen, "ZONE_CHANGED_INDOORS")
addConditionEvent(out, seen, "ZONE_CHANGED_NEW_AREA")
end
return out
end
local function conditionStatusArg(name, display, init)
return { name = name, type = "select", required = true, display = display,
valueList = CONDITION_STATUS_VALUES, valueLabels = CONDITION_STATUS_LABELS,
default = "ignore", init = init, store = true, conditionType = "bool",
test = conditionStatusTest(name) }
end
-- No "ignore" entry, and no "any": the mode tier owns being off, and "any group"
-- is the two-entry set { party, raid } rather than a third spelling of it.
local CONDITION_VALUE_LABELS = {
groupType = { solo = "Solo", party = "Party", raid = "Raid" },
instanceType = { none = "Outside Instance", dungeon = "Dungeon",
raid = "Raid", pvp = "Battleground", arena = "Arena" },
}
local function conditionValueTest(name)
return function(trigger)
local src = multiSelectSource(trigger, name, name)
return src and ("(" .. src .. ")") or nil
end
end
function WA.ConditionGroupType()
if IsInRaid() then return "raid" end
if IsInGroup() then return "party" end
return "solo"
end
function WA.ConditionInstanceType()
local _, instanceType = GetInstanceInfo()
if instanceType == "party" then return "dungeon" end
return instanceType
end
local function conditionValueArg(name, display, values, labels, init)
return { name = name, type = "multiselect", display = display,
valueList = values, valueLabels = labels, default = values[1], init = init,
test = conditionValueTest(name), store = true, conditionType = "string" }
end
PROTOTYPES["conditions"] = {
displayName = "Conditions",
wa2Event = "Conditions",
category = "unit",
progressType = "none",
events = conditionEvents,
force_events = true,
loadFunc = function(trigger)
if trigger.isMoving ~= "ignore" then WA.EnsureFastTick() end
end,
migrate = function(trigger)
-- The two shapes these filters were saved in before they took a set:
-- "ignore" inside the value list meant off, and "any" meant the
-- party-or-raid pair the set now spells out.
local function migrateValue(name)
if trigger[name] == "ignore" then
trigger[name] = nil
trigger["use_" .. name] = false
elseif name == "groupType" and trigger[name] == "any" then
trigger[name] = nil
trigger.use_groupType = "multi"
trigger.groupType_multi = { party = true, raid = true }
elseif trigger[name] ~= nil and trigger["use_" .. name] == nil then
trigger["use_" .. name] = "single"
end
WA.MultiSelectMigrateGate(trigger, name)
end
migrateValue("groupType")
migrateValue("instanceType")
end,
icon = "Interface\\Icons\\Spell_Holy_PowerInfusion",
iconFunc = function() return "Interface\\Icons\\Spell_Holy_PowerInfusion" end,
nameFunc = function() return "Conditions" end,
init = function() return "" end,
args = {
conditionStatusArg("alwaystrue", "Always Active", "true"),
conditionStatusArg("incombat", "In Combat", "UnitAffectingCombat(\"player\") and true or false"),
conditionStatusArg("alive", "Alive", "(not UnitIsDeadOrGhost(\"player\")) and true or false"),
conditionStatusArg("onTaxi", "On Taxi", "UnitOnTaxi(\"player\") and true or false"),
conditionStatusArg("resting", "Resting", "IsResting() and true or false"),
conditionStatusArg("mounted", "Mounted", "IsMounted() and true or false"),
conditionStatusArg("hasPet", "Has Pet", "(UnitExists(\"pet\") and not UnitIsDeadOrGhost(\"pet\")) and true or false"),
conditionStatusArg("isMoving", "Is Moving", "(GetUnitSpeed(\"player\") or 0) > 0"),
conditionStatusArg("afk", "AFK", "UnitIsAFK(\"player\") and true or false"),
conditionStatusArg("pvpFlagged", "PvP Flagged", "(UnitIsPVP(\"player\") or (UnitIsPVPFreeForAll(\"player\"))) and true or false"),
conditionValueArg("groupType", "Group Type", { "solo", "party", "raid" }, CONDITION_VALUE_LABELS.groupType, "WeakestAuras.ConditionGroupType()"),
conditionValueArg("instanceType", "Instance Type", { "none", "dungeon", "raid", "pvp", "arena" }, CONDITION_VALUE_LABELS.instanceType, "WeakestAuras.ConditionInstanceType()"),
},
}
end -- conditions prototype
-- GetInstanceInfo's own instanceType strings. No "ignore" entry: the filter's
-- mode tier owns being off, where a sentinel inside the value list would be a
-- second way to say the same thing.
do -- scoped; see the local-ceiling note in the file header
local LOCATION_INSTANCE_VALUES = { "none", "party", "raid", "pvp", "arena" }
local LOCATION_INSTANCE_LABELS = { none = "Outside Instance", party = "Dungeon", raid = "Raid", pvp = "Battleground", arena = "Arena" }
PROTOTYPES["location"] = {
displayName = "Location",
wa2Event = "Location",
category = "unit",
progressType = "none",
events = function() return { "ZONE_CHANGED", "ZONE_CHANGED_INDOORS", "ZONE_CHANGED_NEW_AREA", "MINIMAP_ZONE_CHANGED", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Map_01",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Map_01" end,
nameFunc = function() return "Location" end,
migrate = function(trigger)
-- The instance filter was one gated value with an "ignore" sentinel in
-- its own list before it became a set; the sentinel is now the tier's
-- off mode, and it is the value that has to move rather than the gate.
if trigger.instanceTypeFilter == "ignore" then
trigger.instanceTypeFilter = nil
trigger.use_instanceTypeFilter = false
end
WA.MultiSelectMigrateGate(trigger, "instanceTypeFilter")
end,
init = function()
return "local zoneValue = GetRealZoneText() or GetZoneText() or \"\"\n"
.. "local subzoneValue = GetSubZoneText() or \"\"\n"
.. "local instanceNameValue, instanceTypeValue = GetInstanceInfo()"
end,
args = {
{ name = "zone", type = "hidden", display = "Zone", init = "zoneValue", store = true, conditionType = "string" },
{ name = "subzone", type = "hidden", display = "Subzone", init = "subzoneValue", store = true, conditionType = "string" },
{ name = "instanceName", type = "hidden", display = "Instance Name", init = "instanceNameValue", store = true, conditionType = "string" },
{ name = "zoneFilter", type = "text", display = "Zone Filter", default = "", test = function(trigger)
if not trigger.zoneFilter or trigger.zoneFilter == "" then return nil end
return "string.lower(zoneValue) == string.lower(" .. fmt(trigger.zoneFilter) .. ")"
end },
{ name = "subzoneFilter", type = "text", display = "Subzone Filter", default = "", test = function(trigger)
if not trigger.subzoneFilter or trigger.subzoneFilter == "" then return nil end
return "string.lower(subzoneValue) == string.lower(" .. fmt(trigger.subzoneFilter) .. ")"
end },
{ name = "instanceNameFilter", type = "text", display = "Instance Name Filter", default = "", test = function(trigger)
if not trigger.instanceNameFilter or trigger.instanceNameFilter == "" then return nil end
return "string.lower(instanceNameValue) == string.lower(" .. fmt(trigger.instanceNameFilter) .. ")"
end },
{ name = "instanceType", type = "hidden", init = "instanceTypeValue", store = true, conditionType = "string", display = "Instance Type" },
{ name = "instanceTypeFilter", type = "multiselect", display = "Instance Type",
valueList = LOCATION_INSTANCE_VALUES, valueLabels = LOCATION_INSTANCE_LABELS,
default = "none", test = function(trigger)
local src = multiSelectSource(trigger, "instanceTypeFilter", "instanceTypeValue")
return src and ("(" .. src .. ")") or nil
end },
},
}
end -- location prototype
PROTOTYPES["money"] = {
displayName = "Money",
wa2Event = "Money",
category = "unit",
progressType = "none",
events = function() return { "PLAYER_MONEY", "WA_DELAYED_PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Coin_01",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Coin_01" end,
nameFunc = function() return "Money" end,
init = function()
return "local money = GetMoney() or 0\n"
.. "local gold = math.floor(money / 10000)\n"
.. "local silver = math.fmod(math.floor(money / 100), 100)\n"
.. "local copper = math.fmod(money, 100)"
end,
args = {
{ name = "money", type = "hidden", required = true, init = "money",
store = true, conditionType = "number", display = "Money" },
{ name = "gold", type = "hidden", required = true, init = "gold",
store = true, conditionType = "number", display = "Gold" },
{ name = "silver", type = "hidden", required = true, init = "silver",
store = true, conditionType = "number", display = "Silver" },
{ name = "copper", type = "hidden", required = true, init = "copper",
store = true, conditionType = "number", display = "Copper" },
},
}
do -- scoped; see the local-ceiling note in the file header
local PET_BEHAVIOR_VALUES = { "aggressive", "assist", "defensive", "passive" }
local PET_BEHAVIOR_LABELS = { aggressive = "Aggressive", assist = "Assist", defensive = "Defensive", passive = "Passive" }
PROTOTYPES["petbehavior"] = {
displayName = "Pet Behavior",
wa2Event = "Pet Behavior",
category = "unit",
progressType = "none",
events = function() return { "PET_BAR_UPDATE", "UNIT_PET", "WA_DELAYED_PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\Ability_Hunter_MendPet",
iconFunc = function() return "Interface\\Icons\\Ability_Hunter_MendPet" end,
nameFunc = function() return "Pet" end,
init = function()
return "local petBehaviorValue, petIconValue = WeakestAuras.PetBehavior()"
end,
args = {
{ name = "behavior", type = "select", required = true, display = "Pet Behavior",
valueList = PET_BEHAVIOR_VALUES, valueLabels = PET_BEHAVIOR_LABELS, default = "aggressive",
test = function(trigger)
if trigger.inverse then return "petBehaviorValue ~= " .. fmt(trigger.behavior) end
return "petBehaviorValue == " .. fmt(trigger.behavior)
end },
{ name = "inverse", type = "toggle", display = "Inverse" },
{ name = "petExists", type = "hidden", required = true, init = "UnitExists(\"pet\") and true or false",
store = true, conditionType = "bool", display = "Has Pet", test = "petExists" },
{ name = "behaviorValue", type = "hidden", init = "petBehaviorValue",
store = true, conditionType = "string", display = "Current Behavior" },
{ name = "icon", type = "hidden", init = "petIconValue", store = true },
},
}
end -- pet behavior prototype
PROTOTYPES["queuedaction"] = {
displayName = "Queued Action",
wa2Event = "Queued Action",
category = "spell",
progressType = "none",
events = function() return { "ACTIONBAR_UPDATE_STATE", "ACTIONBAR_SLOT_CHANGED", "CURRENT_SPELL_CAST_CHANGED", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_QuestionMark",
iconFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if id then
local _, _, icon = GetSpellInfo(id)
return icon or "Interface\\Icons\\INV_Misc_QuestionMark"
end
return "Interface\\Icons\\INV_Misc_QuestionMark"
end,
nameFunc = function(trigger) return trigger.spellName or "Queued Action" end,
init = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName) or 0
return "local spellId = " .. fmt(id) .. "\n"
.. "local queuedValue = C_Spell.IsCurrentSpell(spellId) and true or false"
end,
args = {
{ name = "spellName", type = "spell", display = "Spell", required = true },
{ name = "queued", type = "hidden", required = true, init = "queuedValue", test = "queued",
store = true, conditionType = "bool", display = "Queued" },
},
}
-- Combat status: shown while the player is in combat. No user config.
PROTOTYPES["combat"] = {
displayName = "In Combat",
category = "unit",
progressType = "none",
events = function() return { "PLAYER_REGEN_ENABLED", "PLAYER_REGEN_DISABLED", "PLAYER_TARGET_CHANGED" } end,
force_events = true,
icon = "Interface\\Icons\\Ability_DualWield",
iconFunc = function() return "Interface\\Icons\\Ability_DualWield" end,
nameFunc = function() return "In Combat" end,
init = function()
return "local incombat = UnitAffectingCombat(\"player\") and true or false\n"
.. "local hastarget = UnitExists(\"target\") and true or false\n"
.. "local attackabletarget = (UnitExists(\"target\") and UnitCanAttack(\"player\", \"target\")) and true or false"
end,
args = {
{ name = "incombat", type = "hidden", required = true, test = "incombat",
store = true, conditionType = "bool", display = "In Combat" },
-- Optional filters: require a (attackable) target as well as combat.
{ name = "hastarget", type = "toggle", display = "Has Target",
test = function(t) return t.hastarget and "hastarget" or nil end },
{ name = "attackabletarget", type = "toggle", display = "Attackable Target",
test = function(t) return t.attackabletarget and "attackabletarget" or nil end },
},
}
-- A `statesParameter = "unit"` prototype offers WA.multi_unit_tokens beside the
-- single tokens: one clone per member, keyed by GUID rather than by upstream's
-- positional token (§4.3). WA.MultiUnitFamily is called through the table rather
-- than aliased to a local: this chunk sits on Lua 5.0's 200-local ceiling, and a
-- shorthand is not worth a slot.
-- Membership churn for a family, appended to whatever unit events the prototype
-- reads: those say a member changed, these say which members there are.
local function appendMultiUnitEvents(trigger, out)
local family = WA.MultiUnitFamily(trigger)
if not family then return out end
if family == "nameplate" then
table.insert(out, "NAME_PLATE_UNIT_ADDED")
table.insert(out, "NAME_PLATE_UNIT_REMOVED")
else
table.insert(out, "PARTY_MEMBERS_CHANGED")
table.insert(out, "RAID_ROSTER_UPDATE")
end
table.insert(out, "PLAYER_ENTERING_WORLD")
return out
end
-- The single token this trigger reads, or nil for a multi-unit family.
local function singleUnitToken(trigger, fallback)
if WA.MultiUnitFamily(trigger) then return nil end
return WA.TriggerUnit(trigger, fallback)
end
-- Occupancy for a single token, the pair to appendMultiUnitEvents: those say
-- which members a family has, this says the one token now points somewhere
-- else. A family trigger is already covered, so this leaves it alone and a
-- prototype can call both unconditionally. The fallback must be the same one
-- the prototype's `init` compiles in, or the trigger subscribes to a token it
-- does not read.
local function appendUnitChangeEvents(trigger, out, fallback)
local token = singleUnitToken(trigger, fallback)
if token then table.insert(out, UNIT_CHANGED_PREFIX .. token) end
return out
end
-- Multi-unit init: the runner hands the member's live token in as arg1, so the
-- generated source reads it instead of a compile-time constant.
local function multiUnitInit(trigger, fallback)
if WA.MultiUnitFamily(trigger) then return "local unit = arg1" end
return "local unit = " .. fmt(WA.TriggerUnit(trigger, fallback))
end
local function multiUnitName(trigger, fallback)
local family = WA.MultiUnitFamily(trigger)
if family then return WA.multi_unit_labels[family] end
local unit = WA.TriggerUnit(trigger, fallback)
return UnitName(unit) or unit
end
-- Unit health, static progress (value/total = health/maxhealth).
PROTOTYPES["health"] = {
displayName = "Health",
wa2Event = "Health",
category = "unit",
progressType = "static",
progressValue = "health",
progressTotal = "maxhealth",
events = function(trigger)
return appendUnitChangeEvents(trigger,
appendMultiUnitEvents(trigger, { "UNIT_HEALTH", "UNIT_MAXHEALTH" }), "player")
end,
subjectUnit = function(trigger) return singleUnitToken(trigger, "player") end,
force_events = true,
statesParameter = "unit",
icon = "Interface\\Icons\\INV_Potion_54",
iconFunc = function() return "Interface\\Icons\\INV_Potion_54" end,
nameFunc = function(trigger) return multiUnitName(trigger, "player") end,
init = function(trigger) return multiUnitInit(trigger, "player") end,
args = {
{ name = "unit", type = "unit", display = "Unit",
valueList = WA.unit_tokens_multi, valueLabels = WA.unit_labels_multi,
store = true, conditionType = "string" },
{ name = "exists", type = "hidden", required = true, test = "UnitExists(unit)" },
{ name = "health", type = "number", display = "Health", init = "UnitHealth(unit)",
multiEntry = true, store = true, conditionType = "number" },
{ name = "maxhealth", type = "hidden", init = "UnitHealthMax(unit)",
store = true, conditionType = "number", display = "Max Health" },
{ name = "percenthealth", type = "number", display = "Health (%)",
init = "(UnitHealthMax(unit) > 0 and (UnitHealth(unit) / UnitHealthMax(unit) * 100) or 0)",
multiEntry = true, store = true, conditionType = "number" },
{ name = "healthDeficit", type = "number", display = "Health Deficit",
init = "(UnitHealthMax(unit) - UnitHealth(unit))",
store = true, conditionType = "number" },
{ name = "name", type = "string", init = "UnitName(unit)",
store = true, conditionType = "string", display = "Name" },
-- UnitInRange is a ClassicAPI backport (a real 40yd position check); guard
-- it so a build without it can't error the whole compiled test. Refreshes
-- on this prototype's health events, not on movement -- a condition var, not
-- a real-time range gate.
{ name = "inRange", type = "hidden", init = "(UnitInRange(unit) and true or false)",
store = true, conditionType = "bool", display = "In Range" },
{ name = "ignoreDead", type = "toggle", display = "Ignore Dead",
test = function(t) return t.ignoreDead and "(not UnitIsDead(unit))" or nil end },
{ name = "ignoreDisconnected", type = "toggle", display = "Ignore Disconnected",
test = function(t) return t.ignoreDisconnected and "UnitIsConnected(unit)" or nil end },
{ name = "inRangeFilter", type = "toggle", display = "Only if In Range",
test = function(t) return t.inRangeFilter and "inRange" or nil end },
},
}
-- Unit power (mana/rage/energy). Static progress. The Power Type select chooses
-- which resource is read, not which one the unit has to be displaying.
PROTOTYPES["power"] = {
displayName = "Power",
wa2Event = "Power",
category = "unit",
progressType = "static",
progressValue = "power",
progressTotal = "maxpower",
events = function(trigger)
return appendUnitChangeEvents(trigger,
appendMultiUnitEvents(trigger, { "UNIT_MANA", "UNIT_RAGE", "UNIT_ENERGY", "UNIT_FOCUS",
"UNIT_MAXMANA", "UNIT_MAXRAGE", "UNIT_MAXENERGY", "UNIT_DISPLAYPOWER" }), "player")
end,
subjectUnit = function(trigger) return singleUnitToken(trigger, "player") end,
force_events = true,
statesParameter = "unit",
icon = "Interface\\Icons\\Spell_Nature_Lightning",
iconFunc = function() return "Interface\\Icons\\Spell_Nature_Lightning" end,
nameFunc = function(trigger) return multiUnitName(trigger, "player") end,
-- `powertype` is the slot the reads below address: the selected one, or nil
-- for whatever bar the unit displays. ClassicAPI's UnitPower answers for a
-- type the unit is not currently showing -- a druid's mana pool in cat form,
-- which UnitMana cannot reach at all -- so the selection chooses what to read
-- and narrowing to the displayed bar is requirePowerType's job below.
init = function(trigger)
return multiUnitInit(trigger, "player") .. "\nlocal powertype = "
.. (trigger.use_powertype and fmt(trigger.powertype or 0) or "nil")
end,
-- The reads were UnitMana/UnitManaMax, which answer for the displayed bar
-- alone, so a saved trigger with a type selected was narrowed to the unit
-- displaying it whether or not the user asked for that. Carrying the
-- narrowing into the toggle keeps such a trigger doing exactly what it did,
-- and leaves a checkbox to widen it; a missing key is what marks a trigger
-- from before the toggle existed, so this must not overwrite a stored one.
migrate = function(trigger)
if trigger.use_powertype and trigger.requirePowerType == nil then
trigger.requirePowerType = true
end
end,
args = {
{ name = "unit", type = "unit", display = "Unit",
valueList = WA.unit_tokens_multi, valueLabels = WA.unit_labels_multi,
store = true, conditionType = "string" },
{ name = "exists", type = "hidden", required = true, test = "UnitExists(unit)" },
-- No test of its own: the selection is which slot `init` reads, and
-- comparing that slot back to the displayed one is the toggle below.
{ name = "powertype", type = "select", display = "Power Type",
valueList = { 0, 1, 3, 2 },
valueLabels = { [0] = "Mana", [1] = "Rage", [2] = "Focus", [3] = "Energy" },
test = function() return nil end },
-- Upstream's "Only if Primary", and the narrowing it is upstream: the
-- reads answer for a type the unit is not displaying, and this restricts
-- them to the one it is.
{ name = "requirePowerType", type = "toggle", display = "Only if Primary", default = false,
enable = function(trigger) return trigger.use_powertype and true or false end,
test = function(trigger)
if not (trigger.requirePowerType and trigger.use_powertype) then return nil end
return "UnitPowerType(unit) == " .. fmt(trigger.powertype)
end },
{ name = "power", type = "number", display = "Power", init = "UnitPower(unit, powertype)",
store = true, conditionType = "number" },
{ name = "maxpower", type = "hidden", init = "UnitPowerMax(unit, powertype)",
store = true, conditionType = "number", display = "Max Power" },
{ name = "percentpower", type = "number", display = "Power (%)",
init = "(UnitPowerMax(unit, powertype) > 0 and (UnitPower(unit, powertype) / UnitPowerMax(unit, powertype) * 100) or 0)",
multiEntry = true, store = true, conditionType = "number" },
{ name = "powerDeficit", type = "number", display = "Power Deficit",
init = "UnitPowerMissing(unit, powertype)",
store = true, conditionType = "number" },
{ name = "name", type = "hidden", init = "UnitName(unit)",
store = true, conditionType = "string", display = "Name" },
},
}
-- "Is a real cooldown running", shared by the three cooldown prototypes. The
-- global cooldown is already gone from `duration` by the time this runs -- the
-- watcher's Info excludes it unless the trigger's `showgcd` asked for it -- so
-- this tests nothing but the window it was handed.
do -- scoped; see the local-ceiling note in the file header
local ON_COOLDOWN_EXPR = "(duration ~= nil and duration > 0 and expirationTime > GetTime())"
-- Spell cooldown, timed progress. Fed by the central cooldown watcher (below):
-- its init reads the watcher's cache rather than polling GetSpellCooldown
-- itself, so GCD filtering and ready-time scheduling stay in one place (ref
-- §4.4). `genericShowOn` selects whether it shows while on cooldown (default,
-- the old hardwired behavior), while ready, or always (WA2's showOnCheck).
-- The `remaining` arg is shared by the three cooldown prototypes: a
-- remaining-time threshold only means something while the cooldown is running,
-- so in ready mode `remaining` is 0 and any "<=" test would pass -- the arg is
-- hidden and contributes no test there.
local function cooldownRemainingArg()
return { name = "remaining", type = "number", display = "Remaining Time", operator = "<=",
enable = function(trigger) return (trigger.genericShowOn or "showOnCooldown") ~= "showOnReady" end,
test = function(trigger)
if not trigger.use_remaining then return nil end
if (trigger.genericShowOn or "showOnCooldown") == "showOnReady" then return nil end
return string.format("remaining %s %s",
safeOp(trigger.remaining_operator or "<="), fmt(trigger.remaining or 0))
end }
end
PROTOTYPES["spellcooldown"] = {
displayName = "Cooldown Progress (Spell)",
wa2Event = "Cooldown Progress (Spell)",
category = "spell",
progressType = "timed",
-- SPELL_UPDATE_USABLE keeps the usable / insufficient-resources stores live
-- (pcall-guarded registration -- harmless if this build never fires it; the
-- force_events pass + cooldown events still seed them). PLAYER_TARGET_CHANGED
-- re-derives spellInRange on a target swap rather than only on the next
-- cooldown flip; movement in/out of range with the same target refreshes only
-- on this trigger's own event cadence. SPELLS_CHANGED / LEARNED_SPELL_IN_TAB
-- re-run the spell-known gate below, so training the spell lights the trigger
-- without a reload.
events = function() return { "SPELL_COOLDOWN_CHANGED", "SPELL_COOLDOWN_READY", "SPELL_UPDATE_USABLE",
"PLAYER_TARGET_CHANGED", "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB" } end,
force_events = true,
-- ClassicAPI's GetSpellInfo/GetSpellCooldown only accept a numeric spellID
-- (or slot+bookType) -- unlike vanilla's, there's no name-string lookup
-- form, so every entry point here goes through WA.ResolveSpellID (which
-- does the name->ID resolution the legacy global can't) and falls back
-- cleanly if it doesn't resolve to anything (yet). A `remaining` filter asks
-- the watcher to re-emit while running so the threshold flips near real time.
loadFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if id then WA.WatchSpellCooldown(id, trigger.use_remaining and (trigger.genericShowOn or "showOnCooldown") ~= "showOnReady") end
end,
icon = "Interface\\Icons\\Spell_Holy_BorrowedTime",
iconFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return "Interface\\Icons\\Spell_Holy_BorrowedTime" end
local _, _, ic = GetSpellInfo(id)
return ic or "Interface\\Icons\\Spell_Holy_BorrowedTime"
end,
nameFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return (trigger.spellName and trigger.spellName ~= "") and trigger.spellName or "?" end
return GetSpellInfo(id) or trigger.spellName or "?"
end,
init = function(trigger)
return "local spellId = " .. fmt(WA.ResolveSpellID(trigger.spellName) or 0) .. "\n"
.. "local startTime, duration = WeakestAuras.SpellCdInfo(spellId, " .. (trigger.showgcd and "true" or "false") .. ")\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0\n"
.. "local onCooldown = " .. ON_COOLDOWN_EXPR .. " and true or false\n"
.. "local name, _, icon = GetSpellInfo(spellId)\n"
.. "local spellUsable, insufficientResources = IsUsableSpell(spellId)\n"
.. "spellUsable, insufficientResources = spellUsable and true or false, insufficientResources and true or false\n"
.. "local spellInRange = WeakestAuras.SpellInRange(spellId)\n"
.. "local spellKnown = WeakestAuras.SpellIsKnown(spellId)"
end,
args = {
{ name = "spellName", type = "spell", display = "Spell" },
{ name = "genericShowOn", type = "select", required = true, display = "Show",
valueList = { "showOnCooldown", "showOnReady", "showAlways" },
valueLabels = { showOnCooldown = "On Cooldown", showOnReady = "Ready", showAlways = "Always" },
default = "showOnCooldown", reloadOptions = true,
test = function(trigger)
local onCd = ON_COOLDOWN_EXPR
local mode = trigger.genericShowOn or "showOnCooldown"
if mode == "showOnReady" then return "not " .. onCd
elseif mode == "showAlways" then return "true"
else return onCd end
end },
{ name = "showgcd", type = "toggle", display = "Show Global Cooldown", default = false },
cooldownRemainingArg(),
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
{ name = "onCooldown", type = "hidden", store = true, conditionType = "bool", display = "On Cooldown" },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = "Spell Name" },
{ name = "icon", type = "hidden", store = true },
{ name = "spellUsable", type = "hidden", store = true, conditionType = "bool", display = "Usable" },
{ name = "insufficientResources", type = "hidden", store = true, conditionType = "bool", display = "Insufficient Resources" },
{ name = "spellInRange", type = "hidden", store = true, conditionType = "bool", display = "In Range" },
{ name = "ignoreSpellKnown", type = "toggle", display = "Ignore Unknown Spell", default = false },
-- A spell the player never learned still has a Spell.dbc row, so the
-- cooldown read answers for it -- with a zero-length window, which is
-- indistinguishable from ready. Without this gate a trigger set to show
-- always is permanently active for every other class's and race's
-- abilities, and on a multi-trigger display the first_active scan then
-- hands the icon to whichever of those sits at trigger 1. Mirrors
-- upstream, where the same gate lives inside GetSpellCooldown (§4.4).
{ name = "spellKnown", type = "hidden", required = true, store = true, conditionType = "bool",
display = "Spell Known",
test = function(trigger)
if trigger.ignoreSpellKnown then return nil end
return "spellKnown"
end },
},
}
-- Item cooldown, timed progress. Near-clone of spellcooldown, fed by a parallel
-- watcher keyed by itemID instead of spellID. GetItemCooldown returns
-- (start, duration, enable) directly rather than a table -- its pollRaw wrapper
-- (below, in the watcher section) reads them behind an if-guard, not an
-- `and`-chain, since `and`-chaining a multi-return call truncates it to one
-- value on this client's Lua 5.0 (confirmed; the existing
-- spellUsable/insufficientResources init above already avoids the same trap the
-- same way).
PROTOTYPES["itemcooldown"] = {
displayName = "Cooldown Progress (Item)",
wa2Event = "Cooldown Progress (Item)",
category = "item",
progressType = "timed",
events = function() return { "ITEM_COOLDOWN_CHANGED", "ITEM_COOLDOWN_READY" } end,
force_events = true,
loadFunc = function(trigger)
local id = WA.ResolveItemID(trigger.itemName)
if id then WA.WatchItemCooldown(id, trigger.use_remaining and (trigger.genericShowOn or "showOnCooldown") ~= "showOnReady") end
end,
icon = "Interface\\Icons\\INV_Misc_Bag_08",
iconFunc = function(trigger)
local _, ic = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return ic or "Interface\\Icons\\INV_Misc_Bag_08"
end,
nameFunc = function(trigger)
local name = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return name or (trigger.itemName and trigger.itemName ~= "" and trigger.itemName) or "?"
end,
init = function(trigger)
return "local itemId = " .. fmt(WA.ResolveItemID(trigger.itemName) or 0) .. "\n"
.. "local startTime, duration = WeakestAuras.ItemCdInfo(itemId, " .. (trigger.showgcd and "true" or "false") .. ")\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0\n"
.. "local onCooldown = " .. ON_COOLDOWN_EXPR .. " and true or false"
end,
args = {
{ name = "itemName", type = "item", display = "Item" },
{ name = "genericShowOn", type = "select", required = true, display = "Show",
valueList = { "showOnCooldown", "showOnReady", "showAlways" },
valueLabels = { showOnCooldown = "On Cooldown", showOnReady = "Ready", showAlways = "Always" },
default = "showOnCooldown", reloadOptions = true,
test = function(trigger)
local onCd = ON_COOLDOWN_EXPR
local mode = trigger.genericShowOn or "showOnCooldown"
if mode == "showOnReady" then return "not " .. onCd
elseif mode == "showAlways" then return "true"
else return onCd end
end },
{ name = "showgcd", type = "toggle", display = "Show Global Cooldown", default = false },
cooldownRemainingArg(),
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
{ name = "onCooldown", type = "hidden", store = true, conditionType = "bool", display = "On Cooldown" },
},
}
-- Equipment-slot cooldown IDs, resolved once at load. GetInventorySlotInfo is
-- a native 1.12 global (not a ClassicAPI backport) -- safe to call at file
-- scope, same load-time-global pattern as the CLASSIC_API_VERSION gate
-- (WeakestAuras.lua). Covers the on-use slots that commonly carry an item
-- cooldown (trinkets, weapons); not an exhaustive slot list.
local EQUIP_SLOT_DEFS = {
{ key = "Trinket0Slot", label = "Trinket 1" },
{ key = "Trinket1Slot", label = "Trinket 2" },
{ key = "MainHandSlot", label = "Main Hand" },
{ key = "SecondaryHandSlot", label = "Off Hand" },
{ key = "NeckSlot", label = "Neck" },
{ key = "WaistSlot", label = "Waist" },
}
local EQUIP_SLOT_IDS, EQUIP_SLOT_LABELS = {}, {}
for i = 1, #EQUIP_SLOT_DEFS do
local def = EQUIP_SLOT_DEFS[i]
local slotId = GetInventorySlotInfo(def.key)
if slotId then
table.insert(EQUIP_SLOT_IDS, slotId)
EQUIP_SLOT_LABELS[slotId] = def.label
end
end
PROTOTYPES["equipslotcooldown"] = {
displayName = "Cooldown Progress (Equipment Slot)",
wa2Event = "Cooldown Progress (Equipment Slot)",
category = "item",
progressType = "timed",
-- PLAYER_ENTERING_WORLD is what makes the stored icon right on a fresh login:
-- the force_events pass runs at ADDON_LOADED, where the player's inventory is
-- not populated yet, so every inventory read there answers nil. The other two
-- follow the slot's occupant changing and its name arriving.
events = function() return { "EQUIPSLOT_COOLDOWN_CHANGED", "EQUIPSLOT_COOLDOWN_READY",
"PLAYER_EQUIPMENT_CHANGED", "GET_ITEM_INFO_RECEIVED", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
loadFunc = function(trigger)
if trigger.itemSlot then WA.WatchEquipSlotCooldown(trigger.itemSlot, trigger.use_remaining and (trigger.genericShowOn or "showOnCooldown") ~= "showOnReady") end
end,
migrate = function(trigger) renameArg(trigger, "equipSlot", "itemSlot") end,
icon = "Interface\\Icons\\INV_Misc_Bag_09",
iconFunc = function(trigger)
local slot = trigger.itemSlot
local link = slot and GetInventoryItemLink("player", slot)
local _, ic = itemNameIcon(link)
return ic or "Interface\\Icons\\INV_Misc_Bag_09"
end,
nameFunc = function(trigger)
return EQUIP_SLOT_LABELS[trigger.itemSlot] or "?"
end,
-- The icon has to be a live store, not the compile-time `iconFunc` constant
-- every other prototype leans on: what a slot holds is a property of the
-- player, not of the trigger, so a constant baked at Add time is wrong from
-- the first trinket swap -- and wrong from the start on a login, where Add
-- runs before the inventory exists. GetInventoryItemTexture reads the slot
-- directly and so needs no item cache at all (`name` still does, hence
-- GET_ITEM_INFO_RECEIVED above).
init = function(trigger)
return "local itemSlot = " .. fmt(trigger.itemSlot or 0) .. "\n"
.. "local startTime, duration, enable = WeakestAuras.EquipSlotCdInfo(itemSlot, " .. (trigger.showgcd and "true" or "false") .. ")\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0\n"
.. "local onCooldown = " .. ON_COOLDOWN_EXPR .. " and true or false\n"
.. "local itemId = GetInventoryItemID(\"player\", itemSlot) or nil\n"
.. "local name = itemId and C_Item.GetItemInfo(itemId) or nil\n"
.. "local icon = GetInventoryItemTexture(\"player\", itemSlot) or nil"
end,
args = {
{ name = "itemSlot", type = "select", required = true, display = "Slot",
valueList = EQUIP_SLOT_IDS, valueLabels = EQUIP_SLOT_LABELS,
default = EQUIP_SLOT_IDS[1] },
{ name = "genericShowOn", type = "select", required = true, display = "Show",
valueList = { "showOnCooldown", "showOnReady", "showAlways" },
valueLabels = { showOnCooldown = "On Cooldown", showOnReady = "Ready", showAlways = "Always" },
default = "showOnCooldown", reloadOptions = true,
test = function(trigger)
local onCd = ON_COOLDOWN_EXPR
local mode = trigger.genericShowOn or "showOnCooldown"
if mode == "showOnReady" then return "not " .. onCd
elseif mode == "showAlways" then return "true"
else return onCd end
end },
{ name = "showgcd", type = "toggle", display = "Show Global Cooldown", default = false },
-- GetInventoryItemCooldown's third return, upstream's `enable == 1`. The
-- slot answers it whether or not anything is on cooldown, so this narrows
-- to a slot holding something the player can actually use.
{ name = "testForCooldown", type = "toggle", display = "Is Usable", default = false,
test = function(trigger) return trigger.testForCooldown and "enable" or nil end },
cooldownRemainingArg(),
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
{ name = "onCooldown", type = "hidden", store = true, conditionType = "bool", display = "On Cooldown" },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = "Item Name" },
{ name = "itemId", type = "hidden", store = true, conditionType = "number", display = "Item ID" },
{ name = "icon", type = "hidden", store = true },
},
}
-- The watcher's own edges only. It already reads the client's cooldown events
-- and emits GCD_UPDATE/GCD_END on the transitions that mean something, so
-- listening to the raw events here as well would re-evaluate on every unrelated
-- actionbar cooldown change and learn nothing.
PROTOTYPES["globalcooldown"] = {
displayName = "Global Cooldown",
wa2Event = "Global Cooldown",
category = "spell",
progressType = "timed",
events = function() return { "GCD_UPDATE", "GCD_END", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
loadFunc = function() WA.WatchGCD() end,
icon = "Interface\\Icons\\Spell_Holy_SealOfMight",
iconFunc = function() return "Interface\\Icons\\Spell_Holy_SealOfMight" end,
nameFunc = function() return "Global Cooldown" end,
init = function(trigger)
return "local startTime, duration = WeakestAuras.GcdInfo()\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0\n"
.. "local active = duration ~= nil and duration > 0 and expirationTime > GetTime()"
end,
args = {
{ name = "inverse", type = "toggle", display = "Inverse" },
{ name = "active", type = "hidden", required = true, store = true,
conditionType = "bool", display = "On Global Cooldown",
test = function(trigger) return trigger.inverse and "not active" or "active" end },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
{ name = "remaining", type = "hidden", store = true, conditionType = "number", display = "Time Remaining" },
},
}
-- "Ready" is the absence of a cooldown of the thing's own: an expired window or
-- no window. A window that is only the global cooldown passing over the key is
-- already no window here -- the read excludes it -- which is also what keeps the
-- progress these prototypes publish from counting the GCD they are ready
-- through. There is no `showgcd` opt-out: a GCD is not what "ready" asks about.
local function cooldownReadyInit(infoFunc, keySource, knownSource)
return "local cooldownKey = " .. keySource .. "\n"
.. "local startTime, duration = " .. infoFunc .. "(cooldownKey, false)\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local ready = (" .. knownSource .. ") and (duration == nil or duration <= 0 or expirationTime <= GetTime())"
end
-- Whether an item is the on-use kind a cooldown can belong to at all. Gear with
-- a passive or on-proc effect has no cooldown to be ready from, so a trigger
-- pointed at one should stay dark rather than claim to be permanently ready.
-- C_Item.GetItemSpell answers nil for an item not yet in the local cache and
-- requests it in the background, which is why the prototypes reading this also
-- listen for GET_ITEM_INFO_RECEIVED -- without that a cold-cached item would
-- read as passive until something else happened to re-evaluate the trigger.
local function itemHasUseSpell(itemId)
if not itemId or itemId == 0 then return false end
local spellName, spellId = C_Item.GetItemSpell(itemId)
return (spellId and spellId > 0) or (spellName and spellName ~= "")
end
WA.ItemHasUseSpell = itemHasUseSpell
local function cooldownReadyArgs(kind, selector)
local args = {
{ name = "ready", type = "hidden", required = true, store = true,
conditionType = "bool", display = "Ready", test = "ready" },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Ready Since" },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = kind .. " Name" },
{ name = "icon", type = "hidden", store = true },
}
table.insert(args, 1, selector)
return args
end
PROTOTYPES["spellcooldownready"] = {
displayName = "Cooldown Ready (Spell)",
wa2Event = "Cooldown Ready (Spell)",
category = "spell",
progressType = "timed",
events = function() return { "SPELL_COOLDOWN_CHANGED", "SPELL_COOLDOWN_READY", "SPELL_UPDATE_COOLDOWN" } end,
force_events = true,
loadFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if id then WA.WatchSpellCooldown(id) end
end,
icon = "Interface\\Icons\\Spell_Holy_BorrowedTime",
iconFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return "Interface\\Icons\\Spell_Holy_BorrowedTime" end
local _, _, icon = GetSpellInfo(id)
return icon or "Interface\\Icons\\Spell_Holy_BorrowedTime"
end,
nameFunc = function(trigger) return trigger.spellName or "Cooldown Ready" end,
init = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName) or 0
return cooldownReadyInit("WeakestAuras.SpellCdInfo", fmt(id),
"cooldownKey ~= 0 and WeakestAuras.SpellCdKnown(cooldownKey)")
.. "\nlocal name, _, icon = GetSpellInfo(cooldownKey)"
end,
args = cooldownReadyArgs("Spell", { name = "spellName", type = "spell", required = true, display = "Spell" }),
}
PROTOTYPES["itemcooldownready"] = {
displayName = "Cooldown Ready (Item)",
wa2Event = "Cooldown Ready (Item)",
category = "item",
progressType = "timed",
events = function() return { "ITEM_COOLDOWN_CHANGED", "ITEM_COOLDOWN_READY", "BAG_UPDATE_COOLDOWN", "GET_ITEM_INFO_RECEIVED" } end,
force_events = true,
loadFunc = function(trigger)
local id = WA.ResolveItemID(trigger.itemName)
if id then WA.WatchItemCooldown(id) end
end,
icon = "Interface\\Icons\\INV_Misc_Bag_08",
iconFunc = function(trigger)
local _, icon = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return icon or "Interface\\Icons\\INV_Misc_Bag_08"
end,
nameFunc = function(trigger)
local name = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return name or trigger.itemName or "Cooldown Ready"
end,
init = function(trigger)
local id = WA.ResolveItemID(trigger.itemName) or 0
return cooldownReadyInit("WeakestAuras.ItemCdInfo", fmt(id), "cooldownKey ~= 0 and WeakestAuras.ItemHasUseSpell(cooldownKey)")
.. "\nlocal name, _, _, _, _, _, _, _, _, icon = C_Item.GetItemInfo(cooldownKey)"
end,
args = cooldownReadyArgs("Item", { name = "itemName", type = "item", required = true, display = "Item" }),
}
PROTOTYPES["equipslotcooldownready"] = {
displayName = "Cooldown Ready (Equipment Slot)",
wa2Event = "Cooldown Ready (Equipment Slot)",
category = "item",
progressType = "timed",
-- PLAYER_ENTERING_WORLD for the same reason as the progress prototype above:
-- the force_events pass runs before the player has an inventory to read.
events = function() return { "EQUIPSLOT_COOLDOWN_CHANGED", "EQUIPSLOT_COOLDOWN_READY", "BAG_UPDATE_COOLDOWN",
"PLAYER_EQUIPMENT_CHANGED", "GET_ITEM_INFO_RECEIVED", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
loadFunc = function(trigger)
if trigger.itemSlot then WA.WatchEquipSlotCooldown(trigger.itemSlot) end
end,
migrate = function(trigger) renameArg(trigger, "equipSlot", "itemSlot") end,
icon = "Interface\\Icons\\INV_Misc_Bag_09",
iconFunc = function(trigger)
local slot = trigger.itemSlot
local link = slot and GetInventoryItemLink("player", slot)
local _, icon = itemNameIcon(link)
return icon or "Interface\\Icons\\INV_Misc_Bag_09"
end,
nameFunc = function(trigger) return EQUIP_SLOT_LABELS[trigger.itemSlot] or "Cooldown Ready" end,
init = function(trigger)
return "local cooldownKey = " .. fmt(trigger.itemSlot or 0) .. "\n"
.. "local startTime, duration = WeakestAuras.EquipSlotCdInfo(cooldownKey, false)\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local itemSlot = cooldownKey\n"
.. "local item = GetInventoryItemID(\"player\", cooldownKey)\n"
.. "local hasUseSpell = WeakestAuras.ItemHasUseSpell(item)\n"
.. "local ready = (cooldownKey ~= 0 and hasUseSpell) and (duration == nil or duration <= 0 or expirationTime <= GetTime())\n"
.. "local name = item and C_Item.GetItemInfo(item) or nil\n"
.. "local icon = GetInventoryItemTexture(\"player\", cooldownKey) or nil"
end,
args = cooldownReadyArgs("Equipment", {
name = "itemSlot", type = "select", required = true, display = "Equipment Slot",
valueList = EQUIP_SLOT_IDS, valueLabels = EQUIP_SLOT_LABELS, default = EQUIP_SLOT_IDS[1],
}),
}
end -- cooldown prototypes
PROTOTYPES["actionusable"] = {
displayName = "Action Usable",
wa2Event = "Action Usable",
category = "spell",
progressType = "none",
events = function() return { "SPELL_UPDATE_USABLE", "ACTIONBAR_UPDATE_USABLE", "ACTIONBAR_SLOT_CHANGED", "SPELL_COOLDOWN_CHANGED", "PLAYER_TARGET_CHANGED" } end,
force_events = true,
loadFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if id then WA.WatchSpellCooldown(id) end
end,
icon = "Interface\\Icons\\Spell_Holy_BorrowedTime",
iconFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return "Interface\\Icons\\Spell_Holy_BorrowedTime" end
local _, _, icon = GetSpellInfo(id)
return icon or "Interface\\Icons\\Spell_Holy_BorrowedTime"
end,
nameFunc = function(trigger) return trigger.spellName or "Action Usable" end,
init = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName) or 0
return "local spellId = " .. fmt(id) .. "\n"
.. "local usable, insufficientResources = false, false\n"
.. "usable, insufficientResources = WeakestAuras.ActionUsability(spellId)\n"
.. "local startTime, duration = WeakestAuras.SpellCdInfo(spellId, false)\n"
.. "local expirationTime = (startTime and duration and (startTime + duration)) or 0\n"
.. "local ready = (duration == nil or duration <= 0 or not startTime or startTime == 0 or expirationTime <= GetTime())\n"
.. "local active = usable and ready\n"
.. "local spellInRange = WeakestAuras.SpellInRange(spellId)"
end,
args = {
{ name = "spellName", type = "spell", display = "Spell", required = true },
{ name = "targetRequired", type = "toggle", display = "Require Valid Target", reloadOptions = true },
{ name = "ignoreSpellCooldown", type = "toggle", display = "Ignore Spell Cooldown", reloadOptions = true },
{ name = "inverse", type = "toggle", display = "Inverse", reloadOptions = true },
{ name = "active", type = "hidden", required = true, store = true, conditionType = "bool",
display = "Usable", test = function(trigger)
local test = trigger.ignoreSpellCooldown and "usable" or "active"
if trigger.targetRequired then test = test .. " and spellInRange" end
if trigger.inverse then return "not (" .. test .. ")" end
return test
end },
{ name = "usable", type = "hidden", store = true, conditionType = "bool", display = "Spell Usable" },
{ name = "insufficientResources", type = "hidden", store = true, conditionType = "bool", display = "Insufficient Resources" },
{ name = "spellInRange", type = "hidden", store = true, conditionType = "bool", display = "In Range" },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Cooldown Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Cooldown End" },
},
}
-- Cast/channel progress for any unit token. ClassicAPI's UNIT_SPELLCAST_* events
-- say when to re-read; C_Spell.UnitCastingInfo/UnitChannelInfo say what is
-- being cast. Both C_Spell calls return several values positionally; captured through
-- named throwaway locals first and reassigned, rather than destructured
-- directly into the final names, so the two different return shapes
-- (UnitCastingInfo has castID at position 7, UnitChannelInfo doesn't) can't
-- be confused with each other.
PROTOTYPES["cast"] = {
displayName = "Cast",
wa2Event = "Cast",
category = "unit",
progressType = "timed",
-- The six all-unit events, plus the player-only pushback pair so the bar
-- re-reads its end time. FAILED is left out: a started cast that fails also
-- fires STOP.
events = function(trigger)
return appendUnitChangeEvents(trigger,
appendMultiUnitEvents(trigger, {
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_SUCCEEDED",
"UNIT_SPELLCAST_INTERRUPTED", "UNIT_SPELLCAST_CHANNEL_START",
"UNIT_SPELLCAST_CHANNEL_STOP", "UNIT_SPELLCAST_DELAYED",
"UNIT_SPELLCAST_CHANNEL_UPDATE" }), "player")
end,
subjectUnit = function(trigger) return singleUnitToken(trigger, "player") end,
force_events = true,
statesParameter = "unit",
icon = "Interface\\Icons\\Spell_Nature_WispSplode",
iconFunc = function() return "Interface\\Icons\\Spell_Nature_WispSplode" end,
nameFunc = function(trigger) return "Cast (" .. (WA.MultiUnitFamily(trigger) or WA.TriggerUnit(trigger, "player")) .. ")" end,
init = function(trigger)
return multiUnitInit(trigger, "player") .. "\n"
.. "local castName, _, castIcon, startMs, endMs, _, _, notInterruptible, castingSpellID = C_Spell.UnitCastingInfo(unit)\n"
.. "local chanName, chanIcon, chanStartMs, chanEndMs, chanNotInterruptible, chanSpellID\n"
.. "if not castName then\n"
.. "\tlocal _n2, _d2, _i2, _s2, _e2, _t2, _ni2, _sid2 = C_Spell.UnitChannelInfo(unit)\n"
.. "\tchanName, chanIcon, chanStartMs, chanEndMs, chanNotInterruptible, chanSpellID = _n2, _i2, _s2, _e2, _ni2, _sid2\n"
.. "end\n"
.. "local casting = castName ~= nil\n"
.. "local channeling = chanName ~= nil\n"
.. "local active = casting or channeling\n"
.. "local name = castName or chanName\n"
.. "local icon = castIcon or chanIcon\n"
.. "local spellId = castingSpellID or chanSpellID\n"
.. "local notInterruptibleFlag = (casting and notInterruptible) or (channeling and chanNotInterruptible) or false\n"
.. "local interruptible = not notInterruptibleFlag\n"
.. "local startTime = ((casting and startMs) or (channeling and chanStartMs) or 0) / 1000\n"
.. "local expirationTime = ((casting and endMs) or (channeling and chanEndMs) or 0) / 1000\n"
.. "local duration = (expirationTime > 0 and startTime > 0) and (expirationTime - startTime) or 0"
end,
args = {
{ name = "unit", type = "unit", display = "Unit",
valueList = WA.unit_tokens_multi, valueLabels = WA.unit_labels_multi,
store = true, conditionType = "string" },
{ name = "unitName", type = "hidden", init = "UnitName(unit)",
store = true, conditionType = "string", display = "Unit Name" },
{ name = "active", type = "hidden", required = true, test = "active" },
{ name = "casting", type = "hidden", store = true, conditionType = "bool", display = "Casting" },
{ name = "channeling", type = "hidden", store = true, conditionType = "bool", display = "Channeling" },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = "Spell Name" },
{ name = "icon", type = "hidden", store = true },
{ name = "spellId", type = "hidden", store = true, conditionType = "number", display = "Spell ID" },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Cast End" },
{ name = "notInterruptibleFlag", type = "hidden", store = true, conditionType = "bool", display = "Not Interruptible" },
-- The same flag the way upstream's cast trigger spells it. Vanilla casts
-- carry no protection flag, so this is true for any cast this client can
-- see -- a condition tinting uninterruptible casts simply never fires.
{ name = "interruptible", type = "hidden", store = true, conditionType = "bool", display = "Interruptible" },
},
}
-- A completed cast is an instant, not a state, so this prototype shows for a
-- configured window and takes itself back down (autoHide) rather than waiting
-- for an event that says the cast stopped being true.
--
-- The player's UNIT_SPELLCAST_SUCCEEDED is the signal, through the shared
-- dispatch below. Your casts only, and no unit arg.
PROTOTYPES["castsucceeded"] = {
displayName = "Spell Cast Succeeded",
wa2Event = "Spell Cast Succeeded",
category = "spell",
autoHide = true,
events = function() return { "WA_SPELL_CAST_SUCCEEDED" } end,
loadFunc = function() WA.WatchSpellCast() end,
icon = "Interface\\Icons\\Spell_Holy_MagicalSentry",
iconFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return "Interface\\Icons\\Spell_Holy_MagicalSentry" end
local _, _, ic = GetSpellInfo(id)
return ic or "Interface\\Icons\\Spell_Holy_MagicalSentry"
end,
nameFunc = function(trigger)
return (trigger.spellName and trigger.spellName ~= "" and trigger.spellName) or "Cast Succeeded"
end,
init = function(trigger)
return "local castSpellId = arg1\n"
.. "local wantSpellId = " .. fmt(WA.ResolveSpellID(trigger.spellName) or 0) .. "\n"
end,
args = {
{ name = "spellName", type = "spell", display = "Spell" },
-- An unset or unresolvable spell name resolves to 0, which must match
-- nothing rather than fire on every cast the player makes.
{ name = "matched", type = "hidden", required = true,
test = "wantSpellId ~= 0 and castSpellId == wantSpellId" },
{ name = "duration", type = "range", display = "Show For (s)",
min = 0.5, max = 10, step = 0.5, default = 1 },
{ name = "castSpellId", type = "hidden", store = true,
conditionType = "number", display = "Spell ID" },
},
}
PROTOTYPES["readycheck"] = {
displayName = "Ready Check",
wa2Event = "Ready Check",
category = "event",
eventMode = true,
autoHide = true,
events = function() return { "READY_CHECK" } end,
icon = "Interface\\Icons\\Spell_Holy_DivineIllumination",
iconFunc = function() return "Interface\\Icons\\Spell_Holy_DivineIllumination" end,
args = {
{ name = "duration", type = "range", display = "Show For (s)",
min = 0.5, max = 10, step = 0.5, default = 1 },
},
}
PROTOTYPES["combatevents"] = {
displayName = "Combat Events",
category = "event",
eventMode = true,
autoHide = true,
events = function() return COMBAT_EVENT_VALUES end,
icon = "Interface\\Icons\\Ability_Warrior_Charge",
iconFunc = function() return "Interface\\Icons\\Ability_Warrior_Charge" end,
args = {
{ name = "eventtype", type = "select", display = "Type", required = true,
valueList = COMBAT_EVENT_VALUES, valueLabels = COMBAT_EVENT_LABELS,
default = "PLAYER_REGEN_DISABLED", store = true, conditionType = "string",
init = "event",
test = function(trigger) return "event == " .. fmt(trigger.eventtype or "") end },
{ name = "duration", type = "range", display = "Show For (s)",
min = 0.5, max = 10, step = 0.5, default = 1 },
},
}
PROTOTYPES["chatmessage"] = {
displayName = "Chat Message",
wa2Event = "Chat Message",
category = "event",
eventMode = true,
autoHide = true,
events = chatMessageEvents,
icon = "Interface\\Icons\\INV_Misc_Note_01",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Note_01" end,
init = function(trigger)
local base = trigger and trigger.messageType
return "if event == 'CHAT_MSG_PARTY_LEADER' then event = 'CHAT_MSG_PARTY' elseif event == 'CHAT_MSG_RAID_LEADER' then event = 'CHAT_MSG_RAID' elseif event == 'CHAT_MSG_TEXT_EMOTE' then event = 'CHAT_MSG_EMOTE' end"
end,
args = {
{ name = "messageType", type = "select", display = "Message Type", init = "event",
valueList = CHAT_MESSAGE_VALUES, valueLabels = CHAT_MESSAGE_LABELS,
default = "CHAT_MSG_SAY" },
{ name = "message", type = "string", display = "Message", init = "arg1",
store = true, conditionType = "string" },
{ name = "sourceName", type = "string", display = "Source Name", init = "arg2",
store = true, conditionType = "string" },
{ name = "destName", type = "string", display = "Destination Name", init = "arg5",
store = true, conditionType = "string" },
-- ClassicAPI's arg12, "" for monster, system and GM lines; stored as nil there.
{ name = "sourceGUID", type = "hidden", init = "arg12 ~= '' and arg12 or nil", store = true },
{ name = "use_cloneId", type = "toggle", display = "Clone per Event", default = false },
{ name = "duration", type = "range", display = "Show For (s)",
min = 0.5, max = 10, step = 0.5, default = 1 },
},
}
-- Combat Log: one subevent of COMBAT_LOG_EVENT_UNFILTERED, filtered on the
-- fields that subevent carries (upstream's "Combat Log" event trigger). The
-- prefix and suffix choose the subevent, and dispatch applies it through the
-- same subevent filter a custom trigger's CLEU:SUBEVENT sets, so the compiled
-- test runs only for matching events. Settings and stored fields keep
-- upstream's names, so an imported Combat Log trigger lands as written.
--
-- The payload is read with CombatLogGetCurrentEventInfo inside the compiled
-- function rather than off arg1..arg9: a combat log event carries up to
-- twenty-four values and the dispatch signature stops at nine. The positions
-- after the eleven shared values depend on the subevent (ClassicAPI docs, the
-- prefix and suffix tables), and the subevent is fixed per trigger, so the
-- layout is resolved at compile time and the init binds each position to its
-- field name. Every field name is declared whether or not this subevent fills
-- it, so a store compiled against any of them reads a defined (nil) value.
do -- scoped; see the local-ceiling note in the file header
-- Prefixes that take a suffix. Any other prefix is a whole subevent on its own
-- (UNIT_DIED, DAMAGE_SHIELD), and a suffix left in the config from an earlier
-- choice is ignored rather than appended.
local CLEU_ACTUAL_PREFIXES = { SWING = true, RANGE = true, SPELL = true,
SPELL_PERIODIC = true, ENVIRONMENTAL = true }
-- Only what this client's combat log fires: no SPELL_BUILDING, UNIT_DISSIPATES,
-- ENCHANT_*, _ABSORBED, _STOLEN or _AURA_BROKEN*. importCheck below refuses an
-- import naming one of those rather than compiling a trigger that never fires.
local CLEU_PREFIX_VALUES = { "SWING", "RANGE", "SPELL", "SPELL_PERIODIC", "ENVIRONMENTAL",
"DAMAGE_SHIELD", "DAMAGE_SPLIT", "DAMAGE_SHIELD_MISSED", "PARTY_KILL", "UNIT_DIED", "UNIT_DESTROYED" }
local CLEU_PREFIX_LABELS = { SWING = "Swing", RANGE = "Range", SPELL = "Spell",
SPELL_PERIODIC = "Periodic Spell", ENVIRONMENTAL = "Environmental",
DAMAGE_SHIELD = "Damage Shield", DAMAGE_SPLIT = "Damage Split",
DAMAGE_SHIELD_MISSED = "Damage Shield Missed", PARTY_KILL = "Party Kill",
UNIT_DIED = "Unit Died", UNIT_DESTROYED = "Unit Destroyed" }
local CLEU_SUFFIX_VALUES = { "_DAMAGE", "_MISSED", "_HEAL", "_ENERGIZE", "_DRAIN", "_LEECH",
"_INTERRUPT", "_DISPEL", "_DISPEL_FAILED", "_EXTRA_ATTACKS",
"_AURA_APPLIED", "_AURA_REMOVED", "_AURA_APPLIED_DOSE", "_AURA_REMOVED_DOSE", "_AURA_REFRESH",
"_CAST_START", "_CAST_SUCCESS", "_CAST_FAILED", "_INSTAKILL",
"_DURABILITY_DAMAGE", "_DURABILITY_DAMAGE_ALL", "_CREATE", "_SUMMON", "_RESURRECT" }
local CLEU_SUFFIX_LABELS = { _DAMAGE = "Damage", _MISSED = "Missed", _HEAL = "Heal",
_ENERGIZE = "Energize", _DRAIN = "Drain", _LEECH = "Leech", _INTERRUPT = "Interrupt",
_DISPEL = "Dispel", _DISPEL_FAILED = "Dispel Failed", _EXTRA_ATTACKS = "Extra Attacks",
_AURA_APPLIED = "Aura Applied", _AURA_REMOVED = "Aura Removed",
_AURA_APPLIED_DOSE = "Aura Applied Dose", _AURA_REMOVED_DOSE = "Aura Removed Dose",
_AURA_REFRESH = "Aura Refresh", _CAST_START = "Cast Start", _CAST_SUCCESS = "Cast Success",
_CAST_FAILED = "Cast Failed", _INSTAKILL = "Instakill",
_DURABILITY_DAMAGE = "Durability Damage", _DURABILITY_DAMAGE_ALL = "Durability Damage All",
_CREATE = "Create", _SUMMON = "Summon", _RESURRECT = "Resurrect" }
-- The subevent a trigger is configured for, or nil while the choice is
-- incomplete -- which leaves the trigger inert, as upstream's is.
local function cleuSubevent(trigger)
local prefix = trigger.subeventPrefix
if not prefix or not CLEU_PREFIX_LABELS[prefix] then return nil end
if not CLEU_ACTUAL_PREFIXES[prefix] then return prefix end
local suffix = trigger.subeventSuffix
if not suffix or not CLEU_SUFFIX_LABELS[suffix] then return nil end
return prefix .. suffix
end
-- The field names behind the payload positions. A prefix that is a whole
-- subevent shares the layout of the suffix it stands for.
local CLEU_SPELL_PREFIX = { "spellId", "spellName", "spellSchool" }
local CLEU_ENVIRONMENTAL_PREFIX = { "environmentalType" }
local CLEU_SUFFIX_FIELDS = {
_DAMAGE = { "amount", "overkill", "school", "resisted", "blocked", "absorbed",
"critical", "glancing", "crushing", "isOffHand" },
_MISSED = { "missType", "isOffHand", "amountMissed" },
_HEAL = { "amount", "overhealing", "absorbed", "critical" },
_ENERGIZE = { "amount", "powerType" },
_DRAIN = { "amount", "powerType", "extraAmount" },
_LEECH = { "amount", "powerType", "extraAmount" },
_CAST_FAILED = { "failedType" },
_INTERRUPT = { "extraSpellId", "extraSpellName", "extraSchool" },
_DISPEL_FAILED = { "extraSpellId", "extraSpellName", "extraSchool" },
_DISPEL = { "extraSpellId", "extraSpellName", "extraSchool", "auraType" },
_EXTRA_ATTACKS = { "number" },
_AURA_APPLIED = { "auraType" },
_AURA_REMOVED = { "auraType" },
_AURA_REFRESH = { "auraType" },
_AURA_APPLIED_DOSE = { "auraType", "number" },
_AURA_REMOVED_DOSE = { "auraType", "number" },
}
-- Values the log leaves nil for zero, and the 1-or-nil flags, normalized so a
-- number test and a bool condition read them directly.
local CLEU_ZERO_FIELDS = { resisted = true, blocked = true, absorbed = true }
local CLEU_FLAG_FIELDS = { critical = true, glancing = true, crushing = true, isOffHand = true }
-- Every field name, for the one `local` line that declares them all.
local CLEU_ALL_FIELDS = {}
do
local seen = {}
local function collect(fields)
for i = 1, #fields do
if not seen[fields[i]] then
seen[fields[i]] = true
table.insert(CLEU_ALL_FIELDS, fields[i])
end
end
end
collect(CLEU_SPELL_PREFIX)
collect(CLEU_ENVIRONMENTAL_PREFIX)
for _, fields in pairs(CLEU_SUFFIX_FIELDS) do collect(fields) end
end
-- (prefixFields, suffixKey) for the trigger's subevent.
local function cleuLayout(trigger)
local prefix = trigger.subeventPrefix
if prefix == "SPELL" or prefix == "SPELL_PERIODIC" or prefix == "RANGE" then
return CLEU_SPELL_PREFIX, trigger.subeventSuffix
elseif prefix == "SWING" then
return nil, trigger.subeventSuffix
elseif prefix == "ENVIRONMENTAL" then
return CLEU_ENVIRONMENTAL_PREFIX, trigger.subeventSuffix
elseif prefix == "DAMAGE_SHIELD" or prefix == "DAMAGE_SPLIT" then
return CLEU_SPELL_PREFIX, "_DAMAGE"
elseif prefix == "DAMAGE_SHIELD_MISSED" then
return CLEU_SPELL_PREFIX, "_MISSED"
end
return nil, nil
end
-- An enable predicate: does the trigger's subevent carry this field.
local function cleuCarries(name)
return function(trigger)
local prefixFields, suffixKey = cleuLayout(trigger)
for i = 1, #(prefixFields or {}) do
if prefixFields[i] == name then return true end
end
local suffixFields = suffixKey and CLEU_SUFFIX_FIELDS[suffixKey] or {}
for i = 1, #suffixFields do
if suffixFields[i] == name then return true end
end
return false
end
end
-- Subevents with no source or no target (ClassicAPI docs), whose filters on
-- that side would match nothing.
local CLEU_SOURCELESS = { ENVIRONMENTAL = true, UNIT_DIED = true, UNIT_DESTROYED = true }
local function cleuHasSource(trigger)
return not CLEU_SOURCELESS[trigger.subeventPrefix or ""]
end
local function cleuHasDest(trigger)
local _, suffixKey = cleuLayout(trigger)
return suffixKey ~= "_CAST_START"
end
-- Upstream's CheckCombatLogFlags family, called from the generated test. The
-- COMBATLOG_OBJECT_* bits, by their upstream filter names. `flags` is nil on
-- the options preview and for an event with no unit on that side, which fails
-- every check.
local CLEU_AFFILIATION_MASKS = { Mine = 1, InParty = 2, InGroup = 7, NotInGroup = 8 }
local CLEU_REACTION_MASKS = { Friendly = 16, Neutral = 32, Hostile = 64 }
local CLEU_OBJECT_TYPE_MASKS = { Player = 1024, NPC = 2048, Pet = 4096, Guardian = 8192, Object = 16384 }
local function flagsMatch(masks)
return function(flags, want)
local mask = masks[want]
return type(flags) == "number" and mask ~= nil and bit.band(flags, mask) ~= 0
end
end
WA.CheckCombatLogFlags = flagsMatch(CLEU_AFFILIATION_MASKS)
WA.CheckCombatLogFlagsReaction = flagsMatch(CLEU_REACTION_MASKS)
WA.CheckCombatLogFlagsObjectType = flagsMatch(CLEU_OBJECT_TYPE_MASKS)
-- Raid marks by upstream's index: 0 none, 1..8 star..skull, 9 any mark.
local CLEU_RAID_MARK_BITS = { 1, 2, 4, 8, 16, 32, 64, 128 }
function WA.CheckRaidFlags(flags, want)
want = tonumber(want)
if type(flags) ~= "number" or not want then return false end
local marks = bit.band(flags, 255)
if want == 0 then return marks == 0 end
if want == 9 then return marks ~= 0 end
local mark = CLEU_RAID_MARK_BITS[want]
return mark ~= nil and bit.band(flags, mark) ~= 0
end
function WA.RaidFlagToIndex(flags)
if type(flags) ~= "number" then return 0 end
for i = 1, 8 do
if bit.band(flags, CLEU_RAID_MARK_BITS[i]) ~= 0 then return i end
end
return 0
end
-- A unit's NPC ID as a string, which is how upstream stores and compares it:
-- nil for a player, an object or no unit at all.
function WA.CreatureIdString(guid)
local id = guid and C_CreatureInfo.GetCreatureID(guid)
return id and tostring(id) or nil
end
local CLEU_NPC_TOOLTIP = {
title = "NPC IDs",
lines = { "Matches a unit whose NPC ID is any entry here.",
"Start an entry with - to exclude that NPC instead: a list of only exclusions matches everyone else, players included." },
}
local CLEU_AFFILIATION_VALUES = { "Mine", "InParty", "InGroup", "NotInGroup" }
local CLEU_AFFILIATION_LABELS = { Mine = "Mine", InParty = "In Party", InGroup = "In Group",
NotInGroup = "Not in Group" }
local CLEU_REACTION_VALUES = { "Hostile", "Neutral", "Friendly" }
local CLEU_REACTION_LABELS = { Hostile = "Hostile", Neutral = "Neutral", Friendly = "Friendly" }
local CLEU_OBJECT_TYPE_VALUES = { "Player", "NPC", "Pet", "Guardian", "Object" }
local CLEU_OBJECT_TYPE_LABELS = { Player = "Player", NPC = "NPC", Pet = "Pet",
Guardian = "Guardian", Object = "Object" }
local CLEU_RAID_MARK_VALUES = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }
local CLEU_RAID_MARK_LABELS = { [0] = "None", [1] = "Star", [2] = "Circle", [3] = "Diamond",
[4] = "Triangle", [5] = "Moon", [6] = "Square", [7] = "Cross", [8] = "Skull", [9] = "Any" }
-- Single-school masks, labelled with the client's localized school names.
-- Shared with the crowd-control prototype's interrupt-school select.
WA.SCHOOL_VALUES = { 1, 2, 4, 8, 16, 32, 64 }
WA.SCHOOL_LABELS = {}
for _, mask in ipairs(WA.SCHOOL_VALUES) do WA.SCHOOL_LABELS[mask] = C_Spell.GetSchoolString(mask) end
local CLEU_MISS_VALUES = { "MISS", "RESIST", "DODGE", "PARRY", "BLOCK", "EVADE", "IMMUNE",
"DEFLECT", "ABSORB", "REFLECT" }
local CLEU_MISS_LABELS = { MISS = "Miss", RESIST = "Resist", DODGE = "Dodge", PARRY = "Parry",
BLOCK = "Block", EVADE = "Evade", IMMUNE = "Immune", DEFLECT = "Deflect", ABSORB = "Absorb",
REFLECT = "Reflect" }
local CLEU_AURA_TYPE_VALUES = { "BUFF", "DEBUFF" }
local CLEU_AURA_TYPE_LABELS = { BUFF = "Buff", DEBUFF = "Debuff" }
-- Upstream's spelling; the init rewrites the log's "DROWNING" to match.
local CLEU_ENVIRONMENT_VALUES = { "Drowning", "Falling", "Fatigue", "Fire", "Lava", "Slime" }
local CLEU_ENVIRONMENT_LABELS = { Drowning = "Drowning", Falling = "Falling", Fatigue = "Fatigue",
Fire = "Fire", Lava = "Lava", Slime = "Slime" }
local CLEU_POWER_VALUES = { 0, 1, 2, 3 }
local CLEU_POWER_LABELS = { [0] = "Mana", [1] = "Rage", [2] = "Focus", [3] = "Energy" }
-- The source and destination unit dropdowns: the single tokens without
-- `specific`, whose free-text field is one per trigger and could not serve two
-- units at once.
local CLEU_UNIT_TOKENS, CLEU_UNIT_SET = {}, {}
for i = 1, #WA.unit_tokens do
local token = WA.unit_tokens[i]
if token ~= "specific" then
table.insert(CLEU_UNIT_TOKENS, token)
CLEU_UNIT_SET[token] = true
end
end
-- Tests for the gated filters whose auto-generated comparison would be wrong:
-- a unit matches by GUID, flags by mask.
local function cleuUnitTest(name, guidVar)
return function(trigger)
if not trigger["use_" .. name] then return nil end
return guidVar .. " ~= nil and " .. guidVar .. " == UnitGUID(" .. fmt(trigger[name] or "player") .. ")"
end
end
local function cleuFlagTest(name, checker, flagsVar)
return function(trigger)
if not trigger["use_" .. name] then return nil end
return "WeakestAuras." .. checker .. "(" .. flagsVar .. ", " .. fmt(trigger[name]) .. ")"
end
end
local function cleuSourceUnitOn(trigger) return cleuHasSource(trigger) and trigger.use_sourceUnit end
local function cleuDestUnitOn(trigger) return cleuHasDest(trigger) and trigger.use_destUnit end
PROTOTYPES["combatlog"] = {
displayName = "Combat Log",
wa2Event = "Combat Log",
category = "event",
eventMode = true,
autoHide = true,
-- Spell ID and Spell Name were saved as one value in `spellId` / `spellName`
-- with an `_operator` beside it, which no other trigger writes -- so the
-- operator is what tells such a save from a key another type left behind,
-- which stays untouched for a switch back.
migrate = function(trigger)
for _, key in ipairs({ "spellId", "spellName" }) do
if trigger[key .. "_operator"] ~= nil then
local value = trigger[key]
if trigger[key .. "List"] == nil then
trigger[key .. "List"] = (value ~= nil and value ~= "") and { tostring(value) } or {}
trigger["use_" .. key .. "List"] = trigger["use_" .. key] and true or false
end
trigger[key], trigger["use_" .. key], trigger[key .. "_operator"] = nil, nil, nil
end
end
end,
events = function() return { "COMBAT_LOG_EVENT_UNFILTERED" } end,
subevents = function(trigger)
local subevent = cleuSubevent(trigger)
if subevent then return { [subevent] = true } end
return {}
end,
icon = "Interface\\Icons\\Ability_Warrior_SavageBlow",
iconFunc = function() return "Interface\\Icons\\Ability_Warrior_SavageBlow" end,
nameFunc = function(trigger) return cleuSubevent(trigger) or "Combat Log" end,
init = function(trigger)
local prefixFields, suffixKey = cleuLayout(trigger)
local lines = {
"local timestamp, subevent, hideCaster, sourceGUID, sourceName, sourceFlags, sourceRaidFlags,"
.. " destGUID, destName, destFlags, destRaidFlags,"
.. " p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13 = CombatLogGetCurrentEventInfo()",
"local " .. table.concat(CLEU_ALL_FIELDS, ", "),
}
local pos = 1
local function bind(fields)
for i = 1, #fields do
local field = fields[i]
table.insert(lines, field .. " = p" .. pos)
pos = pos + 1
if CLEU_ZERO_FIELDS[field] then
table.insert(lines, field .. " = " .. field .. " or 0")
elseif CLEU_FLAG_FIELDS[field] then
table.insert(lines, field .. " = " .. field .. " and true or false")
end
end
end
if prefixFields then bind(prefixFields) end
local suffixFields = suffixKey and CLEU_SUFFIX_FIELDS[suffixKey]
if suffixFields then bind(suffixFields) end
if prefixFields == CLEU_ENVIRONMENTAL_PREFIX then
table.insert(lines, "if environmentalType then environmentalType = string.upper(string.sub(environmentalType, 1, 1))"
.. " .. string.lower(string.sub(environmentalType, 2)) end")
end
table.insert(lines, "local icon")
table.insert(lines, "if spellId and spellId ~= 0 then local _, _, tex = GetSpellInfo(spellId); icon = tex end")
return table.concat(lines, "\n")
end,
-- The prefix and suffix are matched by the subevent filter in dispatch, not
-- here; their tests contribute nothing.
args = {
{ name = "subeventPrefix", type = "select", required = true, display = "Message Prefix",
valueList = CLEU_PREFIX_VALUES, valueLabels = CLEU_PREFIX_LABELS, default = "SPELL",
reloadOptions = true, test = function() return nil end },
{ name = "subeventSuffix", type = "select", required = true, display = "Message Suffix",
valueList = CLEU_SUFFIX_VALUES, valueLabels = CLEU_SUFFIX_LABELS, default = "_DAMAGE",
enable = function(trigger) return CLEU_ACTUAL_PREFIXES[trigger.subeventPrefix or ""] == true end,
reloadOptions = true, test = function() return nil end },
{ name = "use_sourceUnit", type = "toggle", display = "Source Unit", reloadOptions = true,
enable = cleuHasSource },
{ name = "sourceUnit", type = "unit", display = "Source Unit",
valueList = CLEU_UNIT_TOKENS, valueLabels = WA.unit_labels,
enable = cleuSourceUnitOn, test = cleuUnitTest("sourceUnit", "sourceGUID") },
{ name = "sourceName", type = "string", display = "Source Name", enable = cleuHasSource,
store = true, conditionType = "string" },
{ name = "sourceNpcId", type = "list", listNegate = true, display = "Source NPC ID",
listLabel = "Source NPC IDs (any match)", tooltip = CLEU_NPC_TOOLTIP,
init = "WeakestAuras.CreatureIdString(sourceGUID)", enable = cleuHasSource,
store = true, conditionType = "string" },
{ name = "sourceFlags", type = "select", display = "Source Affiliation",
valueList = CLEU_AFFILIATION_VALUES, valueLabels = CLEU_AFFILIATION_LABELS,
enable = cleuHasSource, test = cleuFlagTest("sourceFlags", "CheckCombatLogFlags", "sourceFlags") },
{ name = "sourceFlags2", type = "select", display = "Source Reaction",
valueList = CLEU_REACTION_VALUES, valueLabels = CLEU_REACTION_LABELS,
enable = cleuHasSource, test = cleuFlagTest("sourceFlags2", "CheckCombatLogFlagsReaction", "sourceFlags") },
{ name = "sourceFlags3", type = "select", display = "Source Object Type",
valueList = CLEU_OBJECT_TYPE_VALUES, valueLabels = CLEU_OBJECT_TYPE_LABELS,
enable = cleuHasSource, test = cleuFlagTest("sourceFlags3", "CheckCombatLogFlagsObjectType", "sourceFlags") },
{ name = "sourceRaidFlags", type = "select", display = "Source Raid Mark",
valueList = CLEU_RAID_MARK_VALUES, valueLabels = CLEU_RAID_MARK_LABELS,
enable = cleuHasSource, test = cleuFlagTest("sourceRaidFlags", "CheckRaidFlags", "sourceRaidFlags") },
{ name = "sourceGUID", type = "hidden", store = true, conditionType = "string",
display = "Source GUID", enable = cleuHasSource },
{ name = "sourceRaidMarkIndex", type = "hidden", init = "WeakestAuras.RaidFlagToIndex(sourceRaidFlags)",
store = true, conditionType = "number", display = "Source Raid Mark Index", enable = cleuHasSource },
{ name = "sourceRaidMark", type = "hidden",
init = "sourceRaidMarkIndex > 0 and ('{rt' .. sourceRaidMarkIndex .. '}') or ''",
store = true, conditionType = "string", display = "Source Raid Mark Texture", enable = cleuHasSource },
{ name = "use_destUnit", type = "toggle", display = "Destination Unit", reloadOptions = true,
enable = cleuHasDest },
{ name = "destUnit", type = "unit", display = "Destination Unit",
valueList = CLEU_UNIT_TOKENS, valueLabels = WA.unit_labels,
enable = cleuDestUnitOn, test = cleuUnitTest("destUnit", "destGUID") },
{ name = "destName", type = "string", display = "Destination Name", enable = cleuHasDest,
store = true, conditionType = "string" },
{ name = "destNpcId", type = "list", listNegate = true, display = "Destination NPC ID",
listLabel = "Destination NPC IDs (any match)", tooltip = CLEU_NPC_TOOLTIP,
init = "WeakestAuras.CreatureIdString(destGUID)", enable = cleuHasDest,
store = true, conditionType = "string" },
{ name = "destFlags", type = "select", display = "Destination Affiliation",
valueList = CLEU_AFFILIATION_VALUES, valueLabels = CLEU_AFFILIATION_LABELS,
enable = cleuHasDest, test = cleuFlagTest("destFlags", "CheckCombatLogFlags", "destFlags") },
{ name = "destFlags2", type = "select", display = "Destination Reaction",
valueList = CLEU_REACTION_VALUES, valueLabels = CLEU_REACTION_LABELS,
enable = cleuHasDest, test = cleuFlagTest("destFlags2", "CheckCombatLogFlagsReaction", "destFlags") },
{ name = "destFlags3", type = "select", display = "Destination Object Type",
valueList = CLEU_OBJECT_TYPE_VALUES, valueLabels = CLEU_OBJECT_TYPE_LABELS,
enable = cleuHasDest, test = cleuFlagTest("destFlags3", "CheckCombatLogFlagsObjectType", "destFlags") },
{ name = "destRaidFlags", type = "select", display = "Destination Raid Mark",
valueList = CLEU_RAID_MARK_VALUES, valueLabels = CLEU_RAID_MARK_LABELS,
enable = cleuHasDest, test = cleuFlagTest("destRaidFlags", "CheckRaidFlags", "destRaidFlags") },
{ name = "destGUID", type = "hidden", store = true, conditionType = "string",
display = "Destination GUID", enable = cleuHasDest },
{ name = "destRaidMarkIndex", type = "hidden", init = "WeakestAuras.RaidFlagToIndex(destRaidFlags)",
store = true, conditionType = "number", display = "Destination Raid Mark Index", enable = cleuHasDest },
{ name = "destRaidMark", type = "hidden",
init = "destRaidMarkIndex > 0 and ('{rt' .. destRaidMarkIndex .. '}') or ''",
store = true, conditionType = "string", display = "Destination Raid Mark Texture", enable = cleuHasDest },
-- The filters are lists under their own keys: `spellName` and `spellId`
-- hold a single spell on the cooldown triggers, and a trigger keeps its
-- keys across a type switch, so a list there would reach code reading one.
{ name = "spellIdList", type = "list", listNumeric = true, listVar = "spellId",
display = "Spell ID", listLabel = "Spell IDs (any match)", enable = cleuCarries("spellId") },
{ name = "spellId", type = "hidden", enable = cleuCarries("spellId"),
store = true, conditionType = "number", display = "Spell ID" },
{ name = "spellNameList", type = "list", listVar = "spellName",
display = "Spell Name", listLabel = "Spell Names (any match)", enable = cleuCarries("spellName") },
{ name = "spellName", type = "hidden", enable = cleuCarries("spellName"),
store = true, conditionType = "string", display = "Spell Name" },
{ name = "spellSchool", type = "select", display = "Spell School",
valueList = WA.SCHOOL_VALUES, valueLabels = WA.SCHOOL_LABELS,
enable = cleuCarries("spellSchool"), store = true, conditionType = "number" },
{ name = "environmentalType", type = "select", display = "Environment Type",
valueList = CLEU_ENVIRONMENT_VALUES, valueLabels = CLEU_ENVIRONMENT_LABELS,
enable = cleuCarries("environmentalType"), store = true, conditionType = "string" },
{ name = "missType", type = "select", display = "Miss Type",
valueList = CLEU_MISS_VALUES, valueLabels = CLEU_MISS_LABELS,
enable = cleuCarries("missType"), store = true, conditionType = "string" },
{ name = "extraSpellId", type = "number", display = "Extra Spell ID", operator = "==",
enable = cleuCarries("extraSpellId"), store = true, conditionType = "number" },
{ name = "extraSpellName", type = "string", display = "Extra Spell Name",
enable = cleuCarries("extraSpellName"), store = true, conditionType = "string" },
{ name = "extraSchool", type = "select", display = "Extra School",
valueList = WA.SCHOOL_VALUES, valueLabels = WA.SCHOOL_LABELS,
enable = cleuCarries("extraSchool"), store = true, conditionType = "number" },
{ name = "auraType", type = "select", display = "Aura Type",
valueList = CLEU_AURA_TYPE_VALUES, valueLabels = CLEU_AURA_TYPE_LABELS,
enable = cleuCarries("auraType"), store = true, conditionType = "string" },
{ name = "amount", type = "number", display = "Amount",
enable = cleuCarries("amount"), store = true, conditionType = "number" },
{ name = "overkill", type = "number", display = "Overkill",
enable = cleuCarries("overkill"), store = true, conditionType = "number" },
{ name = "overhealing", type = "number", display = "Overhealing",
enable = cleuCarries("overhealing"), store = true, conditionType = "number" },
{ name = "school", type = "select", display = "School",
valueList = WA.SCHOOL_VALUES, valueLabels = WA.SCHOOL_LABELS,
enable = cleuCarries("school"), store = true, conditionType = "number" },
{ name = "resisted", type = "number", display = "Resisted",
enable = cleuCarries("resisted"), store = true, conditionType = "number" },
{ name = "blocked", type = "number", display = "Blocked",
enable = cleuCarries("blocked"), store = true, conditionType = "number" },
{ name = "absorbed", type = "number", display = "Absorbed",
enable = cleuCarries("absorbed"), store = true, conditionType = "number" },
{ name = "critical", type = "tristate", display = "Critical",
enable = cleuCarries("critical"), store = true, conditionType = "bool" },
{ name = "glancing", type = "tristate", display = "Glancing",
enable = cleuCarries("glancing"), store = true, conditionType = "bool" },
{ name = "crushing", type = "tristate", display = "Crushing",
enable = cleuCarries("crushing"), store = true, conditionType = "bool" },
{ name = "isOffHand", type = "tristate", display = "Is Off Hand",
enable = cleuCarries("isOffHand"), store = true, conditionType = "bool" },
{ name = "number", type = "number", display = "Number",
enable = cleuCarries("number"), store = true, conditionType = "number" },
{ name = "powerType", type = "select", display = "Power Type",
valueList = CLEU_POWER_VALUES, valueLabels = CLEU_POWER_LABELS,
enable = cleuCarries("powerType"), store = true, conditionType = "number" },
{ name = "extraAmount", type = "number", display = "Extra Amount",
enable = cleuCarries("extraAmount"), store = true, conditionType = "number" },
{ name = "use_cloneId", type = "toggle", display = "Clone per Event", default = false },
{ name = "duration", type = "range", display = "Show For (s)",
min = 0.5, max = 10, step = 0.5, default = 1 },
{ name = "icon", type = "hidden", store = true },
},
-- Settings an import can carry that this client cannot honour: a subevent
-- its log never fires, or a unit token the dropdowns do not offer.
importCheck = function(trigger)
local prefix, suffix = trigger.subeventPrefix, trigger.subeventSuffix
if prefix ~= nil and not CLEU_PREFIX_LABELS[prefix] then
return "uses subevent prefix " .. tostring(prefix) .. ", which this client never fires"
end
if prefix ~= nil and CLEU_ACTUAL_PREFIXES[prefix] and suffix ~= nil and not CLEU_SUFFIX_LABELS[suffix] then
return "uses subevent " .. prefix .. tostring(suffix) .. ", which this client never fires"
end
if trigger.use_sourceUnit and not CLEU_UNIT_SET[trigger.sourceUnit or ""] then
return "uses source unit " .. tostring(trigger.sourceUnit) .. ", which this trigger does not offer"
end
if trigger.use_destUnit and not CLEU_UNIT_SET[trigger.destUnit or ""] then
return "uses destination unit " .. tostring(trigger.destUnit) .. ", which this trigger does not offer"
end
return nil
end,
}
end -- combatlog
-- Status bool: has the player learned this spell. IsSpellKnown is a native
-- global taking a numeric spellID (3.3.5 semantics).
PROTOTYPES["spellknown"] = {
displayName = "Spell Known",
wa2Event = "Spell Known",
category = "spell",
progressType = "none",
events = function() return { "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Book_09",
iconFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return "Interface\\Icons\\INV_Misc_Book_09" end
local _, _, ic = GetSpellInfo(id)
return ic or "Interface\\Icons\\INV_Misc_Book_09"
end,
nameFunc = function(trigger)
local id = WA.ResolveSpellID(trigger.spellName)
if not id then return (trigger.spellName and trigger.spellName ~= "") and trigger.spellName or "?" end
return GetSpellInfo(id) or trigger.spellName or "?"
end,
init = function(trigger)
return "local spellId = " .. fmt(WA.ResolveSpellID(trigger.spellName) or 0) .. "\n"
.. "local known = IsSpellKnown(spellId) and true or false"
end,
args = {
{ name = "spellName", type = "spell", display = "Spell" },
{ name = "known", type = "hidden", required = true, test = "known", store = true, conditionType = "bool", display = "Known" },
},
}
-- The stance bar's occupied slots, and the form sitting in each. Positions, not
-- DBC ids: the bar's order is what a class's spellbook produced, so slot 2 is
-- Defensive Stance on a warrior and Aquatic Form on a druid. Both calls are live
-- here (pfUI reads GetShapeshiftFormInfo's third return and calls
-- GetNumShapeshiftForms; ../pfUI/modules/actionbar.lua:484,1208).
-- The occupied slot, or 0 for no form. Derived from each slot's `active` return
-- rather than from GetShapeshiftForm(), which no addon on this client is known
-- to call.
function WA.ShapeshiftBarIndex()
for i = 1, GetNumShapeshiftForms() do
local _, _, active = GetShapeshiftFormInfo(i)
if active then return i end
end
return 0
end
-- The bar's slots and their captions, both from one walk.
function WA.ShapeshiftForms()
local values, labels = { 0 }, { [0] = "0 - No Form" }
for i = 1, GetNumShapeshiftForms() do
table.insert(values, i)
local _, name = GetShapeshiftFormInfo(i)
labels[i] = i .. " - " .. (name or "Form " .. i)
end
return values, labels
end
-- Status: current shapeshift/stance form, offered on both numberings. `form` is
-- the bar position and is what an upstream import carries; `formId` is the
-- SpellShapeshiftForm.dbc id, which vanilla numbers differently from modern WoW
-- and differently per class -- a raw number rather than a hardcoded per-class
-- select, so this doesn't ship a wrong ID table.
PROTOTYPES["stance"] = {
displayName = "Stance/Form",
wa2Event = "Stance/Form/Aura",
category = "unit",
progressType = "none",
-- The plural event fires when the bar's *contents* change (a form learned),
-- which renumbers every position above it.
events = function() return { "UPDATE_SHAPESHIFT_FORM", "UPDATE_SHAPESHIFT_FORMS" } end,
force_events = true,
icon = "Interface\\Icons\\Ability_Druid_CatForm",
iconFunc = function() return "Interface\\Icons\\Ability_Druid_CatForm" end,
nameFunc = function() return "Stance/Form" end,
init = function(trigger)
-- The bar-position match is a named local so `inverse` can negate it as
-- one term. With no form filter set it is `true`, so an inverted trigger
-- with nothing selected shows never -- upstream's reading of the pair.
local match = multiSelectSource(trigger, "form", "form") or "true"
return "local formId = GetShapeshiftFormID() or 0\n"
.. "local inForm = formId ~= 0\n"
.. "local form = WeakestAuras.ShapeshiftBarIndex()\n"
.. "local formMatch = (" .. match .. ")"
end,
args = {
{ name = "form", type = "multiselect", display = "Form",
valueList = function() return (WA.ShapeshiftForms()) end,
valueLabels = function() local _, labels = WA.ShapeshiftForms(); return labels end,
default = 0, store = true, conditionType = "number",
test = function(trigger) return trigger.inverse and "not formMatch" or "formMatch" end },
{ name = "inverse", type = "toggle", display = "Inverse Form Match" },
{ name = "formId", type = "number", display = "Form ID", operator = "==",
store = true, conditionType = "number" },
{ name = "inForm", type = "hidden", store = true, conditionType = "bool", display = "In Any Form" },
},
}
-- Status number: bag+bank item count. C_Item.GetItemCount returns a single
-- value (no multi-return truncation risk from the `and`-chain below).
PROTOTYPES["itemcount"] = {
displayName = "Item Count",
wa2Event = "Item Count",
category = "item",
progressType = "none",
events = function() return { "BAG_UPDATE", "BANKFRAME_OPENED", "BANKFRAME_CLOSED" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Bag_10",
iconFunc = function(trigger)
local _, ic = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return ic or "Interface\\Icons\\INV_Misc_Bag_10"
end,
nameFunc = function(trigger)
local name = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return name or (trigger.itemName and trigger.itemName ~= "" and trigger.itemName) or "?"
end,
init = function(trigger)
return "local count, itemKnown = WeakestAuras.ItemCount("
.. fmt(trigger.itemName or "") .. ", "
.. fmt(trigger.includeBank and true or false) .. ")"
end,
args = {
{ name = "itemName", type = "item", display = "Item" },
-- No gate on the item resolving. A name that matches nothing the player
-- holds is indistinguishable from holding none of it, and "warn me when
-- I'm out" is the main reason to want this trigger -- so an unresolved
-- item counts 0 and shows, rather than compiling a display that can
-- never appear.
{ name = "includeBank", type = "toggle", display = "Include Bank" },
{ name = "count", type = "number", display = "Count", operator = ">=", store = true, conditionType = "number" },
{ name = "itemKnown", type = "hidden", store = true, conditionType = "bool", display = "Item Identified" },
},
}
-- Status bool: is this item currently equipped (any slot). C_Item.
-- IsEquippedItem accepts an itemID/link/name directly (same shapes as
-- GetItemInfo), so the raw config string is passed through rather than
-- resolved to an ID first.
PROTOTYPES["itemequipped"] = {
displayName = "Item Equipped",
wa2Event = "Item Equipped",
category = "item",
progressType = "none",
events = function() return { "UNIT_INVENTORY_CHANGED", "PLAYER_EQUIPMENT_CHANGED" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Chest_Cloth_17",
iconFunc = function(trigger)
local _, ic = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return ic or "Interface\\Icons\\INV_Chest_Cloth_17"
end,
nameFunc = function(trigger)
local name = itemNameIcon(WA.ResolveItemID(trigger.itemName))
return name or (trigger.itemName and trigger.itemName ~= "" and trigger.itemName) or "?"
end,
init = function(trigger)
return "local itemRef = " .. fmt(trigger.itemName or "") .. "\n"
.. "local equipped = (itemRef ~= \"\" and C_Item.IsEquippedItem(itemRef)) and true or false"
end,
args = {
{ name = "itemName", type = "item", display = "Item" },
{ name = "equipped", type = "hidden", required = true, test = "equipped", store = true, conditionType = "bool", display = "Equipped" },
},
}
-- Declared out here, and only assigned inside the block below, because
-- buildOptions reaches for it when a class change invalidates the subclass
-- selection. Everything else the itemtypeequipped prototype needs stays scoped.
local itemSubclassValues
do -- scoped; see the local-ceiling note in the file header
local ITEM_EQUIP_SLOTS = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }
local ITEM_EQUIP_SLOT_LABELS = {
[0] = "Any Slot", [1] = "Head", [2] = "Neck", [3] = "Shoulder", [4] = "Shirt",
[5] = "Chest", [6] = "Waist", [7] = "Legs", [8] = "Feet", [9] = "Wrist",
[10] = "Hands", [11] = "Finger 1", [12] = "Finger 2", [13] = "Trinket 1",
[14] = "Trinket 2", [15] = "Back", [16] = "Main Hand", [17] = "Off Hand",
[18] = "Ranged", [19] = "Tabard",
}
local ITEM_CLASS_IDS, ITEM_CLASS_LABELS = {}, {}
local ITEM_SUBCLASS_IDS, ITEM_SUBCLASS_LABELS = {}, {}
local fallbackItemClasses = {
[0] = "Consumable", [1] = "Container", [2] = "Weapon", [3] = "Gem",
[4] = "Armor", [5] = "Reagent", [6] = "Projectile", [7] = "Trade Goods",
[8] = "Item Enhancement", [9] = "Recipe", [10] = "Currency",
[11] = "Quiver", [12] = "Quest", [13] = "Key", [14] = "Permanent",
[15] = "Miscellaneous",
}
for classID = 0, 15 do
local label = C_Item.GetItemClassInfo(classID)
if not label or label == "" then label = fallbackItemClasses[classID] end
if label then
table.insert(ITEM_CLASS_IDS, classID)
ITEM_CLASS_LABELS[classID] = label
ITEM_SUBCLASS_IDS[classID] = {}
ITEM_SUBCLASS_LABELS[classID] = {}
for subclassID = 0, 31 do
local subclass = C_Item.GetItemSubClassInfo(classID, subclassID)
if subclass and subclass ~= "" then
table.insert(ITEM_SUBCLASS_IDS[classID], subclassID)
ITEM_SUBCLASS_LABELS[classID][subclassID] = subclass
end
end
end
end
function itemSubclassValues(classID)
local ids = ITEM_SUBCLASS_IDS[tonumber(classID) or -1]
local labels = ITEM_SUBCLASS_LABELS[tonumber(classID) or -1]
if ids and #ids > 0 then return ids, labels end
return { 0 }, { [0] = "Unknown" }
end
PROTOTYPES["itemtypeequipped"] = {
displayName = "Item Type Equipped",
wa2Event = "Item Type Equipped",
category = "item",
progressType = "none",
events = function() return { "PLAYER_EQUIPMENT_CHANGED", "UNIT_INVENTORY_CHANGED", "WA_DELAYED_PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Bag_10",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Bag_10" end,
nameFunc = function() return "Item Type Equipped" end,
migrate = function(trigger)
if trigger.itemClassID ~= nil then trigger.itemClassID = tonumber(trigger.itemClassID) or trigger.itemClassID end
if trigger.itemSubclassID ~= nil then trigger.itemSubclassID = tonumber(trigger.itemSubclassID) or trigger.itemSubclassID end
-- The subclass filter was one always-on value before it became a set. A
-- saved trigger carrying the value and no mode meant exactly that value,
-- where a trigger with neither is fresh and takes the defaults.
if trigger.itemSubclassID ~= nil and trigger.use_itemSubclassID == nil then
trigger.use_itemSubclassID = "single"
end
end,
init = function(trigger)
-- The subclass set as source. "nil" means any subclass of the chosen
-- class; an empty literal means none, which is what a set nothing was
-- ticked in has to mean for unticking to narrow the match.
local function subclassSetSource()
local mode = trigger.use_itemSubclassID
if mode ~= "single" and mode ~= "multi" then return "nil" end
local parts = {}
if mode == "single" then
local id = tonumber(trigger.itemSubclassID)
if id then table.insert(parts, "[" .. id .. "] = true") end
else
for value, selected in pairs(trigger.itemSubclassID_multi or {}) do
local id = tonumber(value)
if selected and id then table.insert(parts, "[" .. id .. "] = true") end
end
end
return "{ " .. table.concat(parts, ", ") .. " }"
end
return "local wantedClassID = tonumber(" .. fmt(trigger.itemClassID or "") .. ") or -1\n"
.. "local wantedSubclasses = " .. subclassSetSource() .. "\n"
.. "local selectedSlot = " .. fmt(tonumber(trigger.itemSlot) or 0) .. "\n"
.. "local inverse = " .. fmt(trigger.inverse and true or false) .. "\n"
.. "local itemIDValue, itemNameValue, itemIconValue, itemClassValue, itemSubclassValue, itemClassIDValue, itemSubclassIDValue, equippedValue, matchingValue, itemKnownValue = WeakestAuras.ItemTypeEquipped(wantedClassID, wantedSubclasses, selectedSlot)"
end,
args = {
{ name = "itemClassID", type = "select", display = "Item Class", required = true,
valueList = ITEM_CLASS_IDS, valueLabels = ITEM_CLASS_LABELS, default = 2,
test = function() return nil end, reloadOptions = true },
{ name = "itemSubclassID", type = "multiselect", display = "Item Subclass",
valueList = function(trigger) return itemSubclassValues(trigger.itemClassID) end,
valueLabels = function(trigger) return ITEM_SUBCLASS_LABELS[tonumber(trigger.itemClassID) or -1] end,
default = 0, test = function() return nil end },
{ name = "itemSlot", type = "select", required = true, init = "selectedSlot", display = "Equipment Slot",
valueList = ITEM_EQUIP_SLOTS, valueLabels = ITEM_EQUIP_SLOT_LABELS, default = 0 },
{ name = "inverse", type = "toggle", display = "Inverse" },
{ name = "itemID", type = "hidden", init = "itemIDValue", store = true, storeAlways = true, conditionType = "number", display = "Item ID" },
{ name = "itemName", type = "hidden", init = "itemNameValue", store = true, storeAlways = true, conditionType = "string", display = "Item Name" },
{ name = "name", type = "hidden", init = "itemNameValue", store = true, storeAlways = true, conditionType = "string", display = "Name" },
{ name = "icon", type = "hidden", init = "itemIconValue", store = true, storeAlways = true, display = "Icon" },
{ name = "itemClassName", type = "hidden", init = "itemClassValue", store = true, storeAlways = true, conditionType = "string", display = "Item Class" },
{ name = "itemSubclassName", type = "hidden", init = "itemSubclassValue", store = true, storeAlways = true, conditionType = "string", display = "Item Subclass" },
{ name = "foundClassID", type = "hidden", init = "itemClassIDValue", store = true, storeAlways = true, conditionType = "number", display = "Found Item Class ID" },
{ name = "foundSubclassID", type = "hidden", init = "itemSubclassIDValue", store = true, storeAlways = true, conditionType = "number", display = "Found Item Subclass ID" },
{ name = "equipped", type = "hidden", init = "equippedValue", store = true, storeAlways = true, conditionType = "bool", display = "Equipped" },
{ name = "matching", type = "hidden", required = true, init = "matchingValue", store = true, storeAlways = true, conditionType = "bool", display = "Matching Type",
test = function(trigger) return trigger.inverse and "not matching" or "matching" end },
{ name = "itemKnown", type = "hidden", init = "itemKnownValue", store = true, storeAlways = true, conditionType = "bool", display = "Item Metadata Available" },
},
}
end -- item type/class prototype
PROTOTYPES["itemset"] = {
displayName = "Item Set Equipped",
wa2Event = "Item Set",
category = "item",
progressType = "static",
progressValue = "count",
progressTotal = "total",
events = function() return { "PLAYER_EQUIPMENT_CHANGED", "GET_ITEM_INFO_RECEIVED", "WA_DELAYED_PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Chest_Cloth_17",
iconFunc = function() return "Interface\\Icons\\INV_Chest_Cloth_17" end,
migrate = function(trigger) renameArg(trigger, "itemSetID", "itemSetId") end,
nameFunc = function(trigger)
local _, _, name = WA.ItemSetEquipped(trigger.itemSetId)
return name or "Item Set Equipped"
end,
init = function(trigger)
return "local countValue, totalValue, setNameValue, setKnownValue = WeakestAuras.ItemSetEquipped("
.. fmt(trigger.itemSetId or 0) .. ")"
end,
args = {
{ name = "itemSetId", type = "text", display = "Item Set ID", required = true, default = "0" },
{ name = "inverse", type = "toggle", display = "Inverse" },
{ name = "count", type = "hidden", init = "countValue", store = true, conditionType = "number", display = "Equipped Pieces" },
{ name = "total", type = "hidden", init = "totalValue", store = true, conditionType = "number", display = "Set Pieces" },
{ name = "setName", type = "hidden", init = "setNameValue", store = true, conditionType = "string", display = "Set Name" },
{ name = "setKnown", type = "hidden", init = "setKnownValue", store = true, conditionType = "bool", display = "Set Identified" },
{ name = "active", type = "hidden", required = true, test = function(trigger)
return trigger.inverse and "count == 0" or "count > 0"
end, store = true, init = "countValue > 0", conditionType = "bool", display = "Active" },
},
}
PROTOTYPES["equipmentset"] = {
displayName = "Equipment Set Equipped",
wa2Event = "Equipment Set",
category = "item",
progressType = "static",
progressValue = "count",
progressTotal = "total",
events = function() return { "PLAYER_EQUIPMENT_CHANGED", "WEAR_EQUIPMENT_SET", "EQUIPMENT_SETS_CHANGED", "EQUIPMENT_SWAP_FINISHED", "WA_DELAYED_PLAYER_ENTERING_WORLD" } end,
force_events = true,
migrate = function(trigger) renameArg(trigger, "equipmentSetName", "itemSetName") end,
icon = "Interface\\Icons\\INV_Misc_Gear_03",
iconFunc = function(trigger)
local _, icon = WA.EquipmentSetInfo(trigger.itemSetName, trigger.partial)
return icon or "Interface\\Icons\\INV_Misc_Gear_03"
end,
nameFunc = function(trigger)
local name = WA.EquipmentSetInfo(trigger.itemSetName, trigger.partial)
return name or "Equipment Set Equipped"
end,
init = function(trigger)
return "local setNameValue, setIconValue, countValue, totalValue, activeValue, setIDValue, ignoredValue = WeakestAuras.EquipmentSetInfo("
.. fmt(trigger.itemSetName or "") .. ", " .. fmt(trigger.partial and true or false) .. ")"
end,
args = {
{ name = "itemSetName", type = "text", display = "Equipment Set", required = true },
{ name = "partial", type = "toggle", display = "Allow Partial Matches" },
{ name = "inverse", type = "toggle", display = "Inverse" },
{ name = "name", type = "hidden", init = "setNameValue", store = true, conditionType = "string", display = "Set Name" },
{ name = "icon", type = "hidden", init = "setIconValue", store = true, display = "Icon" },
{ name = "count", type = "hidden", init = "countValue", store = true, conditionType = "number", display = "Equipped Pieces" },
{ name = "total", type = "hidden", init = "totalValue", store = true, conditionType = "number", display = "Set Pieces" },
{ name = "setID", type = "hidden", init = "setIDValue", store = true, conditionType = "number", display = "Set ID" },
{ name = "ignored", type = "hidden", init = "ignoredValue", store = true, conditionType = "number", display = "Ignored Slots" },
{ name = "active", type = "hidden", required = true, init = "activeValue", store = true, conditionType = "bool", display = "Active",
test = function(trigger) return trigger.inverse and "not active" or "active" end },
},
}
-- Status: static facts about a unit. UnitClassBase (ClassicAPI backport)
-- returns two values -- captured through pre-declared locals set inside an
-- if-block rather than an `and`-chain, for the same multi-return-truncation
-- reason documented above the itemcooldown prototype.
PROTOTYPES["unitcharacteristics"] = {
displayName = "Unit Characteristics",
wa2Event = "Unit Characteristics",
category = "unit",
progressType = "none",
-- UNIT_HEALTH drives the `dead` store; the other facts here cannot change
-- without a unit-change event firing anyway.
events = function(trigger)
return appendUnitChangeEvents(trigger,
appendMultiUnitEvents(trigger, { "UNIT_LEVEL", "UNIT_HEALTH", "PLAYER_ENTERING_WORLD" }), "target")
end,
subjectUnit = function(trigger) return singleUnitToken(trigger, "target") end,
force_events = true,
statesParameter = "unit",
icon = "Interface\\Icons\\INV_Misc_QuestionMark",
iconFunc = function() return "Interface\\Icons\\INV_Misc_QuestionMark" end,
nameFunc = function(trigger) return multiUnitName(trigger, "target") end,
migrate = function(trigger) WA.MultiSelectMigrateGate(trigger, "classification") end,
init = function(trigger)
return multiUnitInit(trigger, "target") .. "\n"
.. "local unitName = UnitName(unit)\n"
.. "local level = UnitLevel(unit) or 0\n"
.. "local classFile, classId = UnitClassBase(unit)\n"
.. "local creatureType = UnitCreatureType(unit) or \"\"\n"
.. "local classification = UnitClassification(unit) or \"\"\n"
.. "local isPlayer = UnitIsPlayer(unit) and true or false\n"
.. "local dead = UnitIsDeadOrGhost(unit) and true or false"
end,
args = {
{ name = "unit", type = "unit", display = "Unit", default = "target",
valueList = WA.unit_tokens_multi, valueLabels = WA.unit_labels_multi,
store = true, conditionType = "string" },
{ name = "exists", type = "hidden", required = true, test = "UnitExists(unit)" },
{ name = "unitName", type = "string", store = true, conditionType = "string", display = "Name" },
{ name = "level", type = "number", display = "Level", operator = ">=", store = true, conditionType = "number" },
{ name = "classFile", type = "hidden", store = true, conditionType = "string", display = "Class" },
{ name = "creatureType", type = "hidden", store = true, conditionType = "string", display = "Creature Type" },
{ name = "classification", type = "multiselect", display = "Classification",
valueList = { "normal", "elite", "rareelite", "rare", "worldboss" },
valueLabels = { normal = "Normal", elite = "Elite", rareelite = "Rare Elite",
rare = "Rare", worldboss = "World Boss" },
default = "elite", store = true, conditionType = "string" },
{ name = "isPlayer", type = "hidden", store = true, conditionType = "bool", display = "Is Player" },
{ name = "dead", type = "hidden", store = true, conditionType = "bool", display = "Dead" },
},
}
-- Custom status and event triggers use upstream's saved field names. Legacy
-- local status records keep their state-first call signature; upstream-shaped
-- functions receive event first.
PROTOTYPES["custom"] = {
displayName = "Custom",
category = "custom",
custom = true,
progressType = "none",
events = function(trigger) return parseEventList(trigger and trigger.events) end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Gear_08",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Gear_08" end,
nameFunc = function() return "Custom" end,
migrate = function(trigger)
if trigger.customTrigger ~= nil then
if trigger.custom == nil then
trigger.custom = trigger.customTrigger
trigger.weakestAurasLegacyStateArgs = true
end
trigger.customTrigger = nil
end
if trigger.customEvents ~= nil then
if trigger.events == nil then trigger.events = trigger.customEvents end
trigger.customEvents = nil
end
end,
-- No `custom` here: the boilerplate is the editor's opening text, not a
-- setting, and stamping it at MergeDefaults writes Lua into every aura whose
-- author only ever glanced at this category. The code field seeds the same
-- signature the first time it paints.
defaults = {
custom_type = "status",
check = "event",
events = "",
custom_hide = "timed",
duration = 1,
customDuration = "",
customName = "",
customIcon = "",
},
args = {},
}
-- Combo points on the current target, static progress (n/5). Always active
-- (like Health/Power) -- the count IS the display; add a condition or filter for
-- "only at 5". GetComboPoints on this client answers for the player's points on
-- the target; passing ("player","target") matches ClassicAPI's 3.3.5 semantics
-- and is harmless if the native form ignores the extra arg.
PROTOTYPES["combopoints"] = {
displayName = "Combo Points",
category = "unit",
progressType = "static",
progressValue = "comboPoints",
progressTotal = "maxComboPoints",
events = function() return { "PLAYER_COMBO_POINTS", "PLAYER_TARGET_CHANGED" } end,
force_events = true,
icon = "Interface\\Icons\\Ability_Rogue_Eviscerate",
iconFunc = function() return "Interface\\Icons\\Ability_Rogue_Eviscerate" end,
nameFunc = function() return "Combo Points" end,
init = function()
return "local comboPoints = GetComboPoints(\"player\", \"target\") or 0\n"
.. "local maxComboPoints = 5\n"
.. "local percentComboPoints = comboPoints / maxComboPoints * 100\n"
.. "local comboPointDeficit = maxComboPoints - comboPoints"
end,
args = {
{ name = "comboPoints", type = "number", display = "Combo Points", operator = ">=",
store = true, conditionType = "number" },
{ name = "maxComboPoints", type = "hidden", store = true, conditionType = "number", display = "Max Combo Points" },
-- Hidden rather than filter rows: the percentage and the deficit of a
-- 0-5 integer say nothing the count does not, and they exist for the
-- conditions an imported Power-trigger aura writes against them.
{ name = "percentComboPoints", type = "hidden", store = true, conditionType = "number",
display = "Combo Points (%)" },
{ name = "comboPointDeficit", type = "hidden", store = true, conditionType = "number",
display = "Combo Point Deficit" },
},
}
-- Temporary weapon enchant, timed progress. Oils/sharpening stones/shaman
-- imbues/rogue poisons. Fed by WA.WeaponEnchantInfo (which remembers the
-- original duration for a proper depleting bar) + WA_SLOW_TICK for the expiry
-- flip. name comes from the enchant record when resolvable.
PROTOTYPES["weaponenchant"] = {
displayName = "Weapon Enchant (Temporary)",
wa2Event = "Weapon Enchant",
category = "item",
progressType = "timed",
events = function() return { "UNIT_INVENTORY_CHANGED", "PLAYER_ENTERING_WORLD", "WA_SLOW_TICK" } end,
force_events = true,
loadFunc = function() WA.EnsureSlowTick() end,
icon = "Interface\\Icons\\INV_Potion_105",
iconFunc = function() return "Interface\\Icons\\INV_Potion_105" end,
migrate = function(trigger) renameArg(trigger, "hand", "weapon") end,
nameFunc = function(trigger)
local h = trigger.weapon or "main"
return (h == "off" and "Off Hand Enchant") or (h == "ranged" and "Ranged Enchant") or "Main Hand Enchant"
end,
init = function(trigger)
return "local weapon = " .. fmt(trigger.weapon or "main") .. "\n"
.. "local has, expirationTime, duration, enchantId = WeakestAuras.WeaponEnchantInfo(weapon)\n"
.. "local name = \"Weapon Enchant\"\n"
.. "if enchantId then local ei = C_Item.GetEnchantInfo(enchantId) if ei and ei.name then name = ei.name end end\n"
.. "local icon = WeakestAuras.WeaponEnchantIcon(weapon, enchantId)\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0"
end,
args = {
{ name = "weapon", type = "select", required = true, display = "Weapon",
valueList = { "main", "off", "ranged" },
valueLabels = { main = "Main Hand", off = "Off Hand", ranged = "Ranged" },
default = "main" },
-- Upstream's arg name and values verbatim, so a WA2 import lands it with
-- no mapping. `showOnMissing` is what the whole rogue poison-reminder
-- family is built on, and a missing enchant leaves duration and
-- expirationTime at 0, which the progress guard already tolerates.
{ name = "showOn", type = "select", required = true, display = "Show",
valueList = { "showOnActive", "showOnMissing", "showAlways" },
valueLabels = { showOnActive = "Enchant Found", showOnMissing = "Enchant Missing",
showAlways = "Always" },
default = "showOnActive", reloadOptions = true,
test = function(trigger)
local mode = trigger.showOn or "showOnActive"
if mode == "showOnMissing" then return "not has"
elseif mode == "showAlways" then return "true"
else return "has" end
end },
{ name = "remaining", type = "number", display = "Remaining Time", operator = "<=",
enable = function(trigger) return (trigger.showOn or "showOnActive") == "showOnActive" end,
test = function(trigger)
if not trigger.use_remaining then return nil end
if (trigger.showOn or "showOnActive") ~= "showOnActive" then return nil end
return string.format("remaining %s %s",
safeOp(trigger.remaining_operator or "<="), fmt(trigger.remaining or 0))
end },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = "Enchant Name" },
{ name = "icon", type = "hidden", store = true },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
},
}
-- Reputation with a named faction, static progress within the current standing
-- bar. Niche; the user types the faction name exactly (WA.FactionStanding walks
-- the list for it). standingID 1..8 = Hated..Exalted.
PROTOTYPES["reputation"] = {
displayName = "Reputation",
wa2Event = "Faction Reputation",
category = "unit",
progressType = "static",
progressValue = "repValue",
progressTotal = "repMax",
events = function() return { "UPDATE_FACTION", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_BannerPVP_02",
iconFunc = function() return "Interface\\Icons\\INV_BannerPVP_02" end,
nameFunc = function(trigger)
return (trigger.factionName and trigger.factionName ~= "") and trigger.factionName or "Reputation"
end,
init = function(trigger)
return "local standingID, barMin, barMax, barValue, factionName = WeakestAuras.FactionStanding(" .. fmt(trigger.factionName or "") .. ")\n"
.. "local exists = standingID ~= nil\n"
.. "local repValue = (barValue and barMin) and (barValue - barMin) or 0\n"
.. "local repMax = (barMax and barMin) and (barMax - barMin) or 1\n"
.. "standingID = standingID or 0"
end,
args = {
{ name = "factionName", type = "text", display = "Faction Name" },
{ name = "exists", type = "hidden", required = true, test = "exists" },
{ name = "standingID", type = "number", display = "Standing (1-8)", operator = ">=",
store = true, conditionType = "number" },
{ name = "repValue", type = "hidden", store = true, conditionType = "number", display = "Bar Value" },
{ name = "repMax", type = "hidden", store = true, conditionType = "number", display = "Bar Max" },
},
}
-- Player experience, static progress toward the next level. Guarded for max
-- level (UnitXPMax 0). restedXP is the bonus-rest pool (nil off rested).
PROTOTYPES["experience"] = {
displayName = "Experience",
wa2Event = "Experience",
category = "unit",
progressType = "static",
progressValue = "xp",
progressTotal = "xpMax",
events = function() return { "PLAYER_XP_UPDATE", "PLAYER_LEVEL_UP", "UPDATE_EXHAUSTION", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_Note_01",
iconFunc = function() return "Interface\\Icons\\INV_Misc_Note_01" end,
nameFunc = function() return "Experience" end,
init = function()
return "local xp = UnitXP(\"player\") or 0\n"
.. "local xpMax = UnitXPMax(\"player\") or 0\n"
.. "local restedXP = GetXPExhaustion() or 0\n"
.. "local xpPercent = (xpMax > 0) and (xp / xpMax * 100) or 0"
end,
args = {
{ name = "xp", type = "number", display = "XP", operator = ">=", store = true, conditionType = "number" },
{ name = "xpMax", type = "hidden", store = true, conditionType = "number", display = "Max XP" },
{ name = "xpPercent", type = "number", display = "XP (%)", operator = ">=", store = true, conditionType = "number" },
{ name = "restedXP", type = "hidden", store = true, conditionType = "number", display = "Rested XP" },
},
}
-- Character stats (raw values, not ratings -- vanilla has no rating system).
-- Every value comes from WA.CharacterStat, which owns the client's return
-- conventions; the args here only name what to read. Always active -- a status
-- surface for conditions/text, no required gate.
--
-- Only what the client itself holds. Crit, hit, mp5 and weapon skill are not
-- fields on this client and cannot be read at all, so they are absent rather
-- than approximated -- see design/plans/CHARACTER_STATS_PLAN.md.
PROTOTYPES["charstats"] = {
displayName = "Character Stats",
wa2Event = "Character Stats",
category = "unit",
progressType = "none",
-- PLAYER_DAMAGE_DONE_MODS is what moves when spell power changes, and
-- UNIT_INVENTORY_CHANGED is vanilla's equipment-change event.
events = function()
return { "UNIT_STATS", "UNIT_ATTACK_POWER", "UNIT_RANGED_ATTACK_POWER", "UNIT_ATTACK",
"UNIT_ATTACK_SPEED", "UNIT_DAMAGE", "UNIT_DEFENSE", "UNIT_RESISTANCES", "UNIT_AURA",
"PLAYER_DAMAGE_DONE_MODS", "UNIT_INVENTORY_CHANGED", "PLAYER_ENTERING_WORLD" }
end,
subjectUnit = function() return "player" end,
force_events = true,
icon = "Interface\\Icons\\INV_Misc_GroupLooking",
iconFunc = function() return "Interface\\Icons\\INV_Misc_GroupLooking" end,
nameFunc = function() return "Character Stats" end,
args = {
{ type = "header", display = "Primary Stats" },
{ name = "strength", type = "number", display = "Strength",
init = [[WeakestAuras.CharacterStat("strength")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "agility", type = "number", display = "Agility",
init = [[WeakestAuras.CharacterStat("agility")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "stamina", type = "number", display = "Stamina",
init = [[WeakestAuras.CharacterStat("stamina")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "intellect", type = "number", display = "Intellect",
init = [[WeakestAuras.CharacterStat("intellect")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "spirit", type = "number", display = "Spirit",
init = [[WeakestAuras.CharacterStat("spirit")]],
multiEntry = true, store = true, conditionType = "number" },
{ type = "header", display = "Attack" },
{ name = "attackPower", type = "number", display = "Attack Power",
init = [[WeakestAuras.CharacterStat("attackPower")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "rangedAttackPower", type = "number", display = "Ranged Attack Power",
init = [[WeakestAuras.CharacterStat("rangedAttackPower")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "mainHandSpeed", type = "number", display = "Main Hand Speed",
init = [[WeakestAuras.CharacterStat("mainHandSpeed")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "offHandSpeed", type = "number", display = "Off Hand Speed",
init = [[WeakestAuras.CharacterStat("offHandSpeed")]],
multiEntry = true, store = true, conditionType = "number" },
{ type = "header", display = "Spell" },
{ name = "spellPower", type = "number", display = "Spell Power",
init = [[WeakestAuras.CharacterStat("spellPower")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "healingPower", type = "number", display = "Healing Power",
init = [[WeakestAuras.CharacterStat("healingPower")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "spellHaste", type = "number", display = "Spell Haste (%)",
init = [[WeakestAuras.CharacterStat("spellHaste")]],
multiEntry = true, store = true, conditionType = "number" },
-- Per-school spell power is a text placeholder and a condition variable,
-- not a filter: upstream offers the best school alone, and six more
-- checkboxes would buy a comparison nobody writes.
{ name = "spellPowerHoly", type = "hidden", display = "Spell Power (Holy)",
init = [[WeakestAuras.CharacterStat("spellPowerHoly")]],
store = true, conditionType = "number" },
{ name = "spellPowerFire", type = "hidden", display = "Spell Power (Fire)",
init = [[WeakestAuras.CharacterStat("spellPowerFire")]],
store = true, conditionType = "number" },
{ name = "spellPowerNature", type = "hidden", display = "Spell Power (Nature)",
init = [[WeakestAuras.CharacterStat("spellPowerNature")]],
store = true, conditionType = "number" },
{ name = "spellPowerFrost", type = "hidden", display = "Spell Power (Frost)",
init = [[WeakestAuras.CharacterStat("spellPowerFrost")]],
store = true, conditionType = "number" },
{ name = "spellPowerShadow", type = "hidden", display = "Spell Power (Shadow)",
init = [[WeakestAuras.CharacterStat("spellPowerShadow")]],
store = true, conditionType = "number" },
{ name = "spellPowerArcane", type = "hidden", display = "Spell Power (Arcane)",
init = [[WeakestAuras.CharacterStat("spellPowerArcane")]],
store = true, conditionType = "number" },
{ type = "header", display = "Defense" },
{ name = "armor", type = "number", display = "Armor",
init = [[WeakestAuras.CharacterStat("armor")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "defense", type = "number", display = "Defense",
init = [[WeakestAuras.CharacterStat("defense")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "dodge", type = "number", display = "Dodge (%)",
init = [[WeakestAuras.CharacterStat("dodge")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "parry", type = "number", display = "Parry (%)",
init = [[WeakestAuras.CharacterStat("parry")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "block", type = "number", display = "Block (%)",
init = [[WeakestAuras.CharacterStat("block")]],
multiEntry = true, store = true, conditionType = "number" },
{ type = "header", display = "Resistance" },
{ name = "resistHoly", type = "number", display = "Holy Resistance",
init = [[WeakestAuras.CharacterStat("resistHoly")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "resistFire", type = "number", display = "Fire Resistance",
init = [[WeakestAuras.CharacterStat("resistFire")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "resistNature", type = "number", display = "Nature Resistance",
init = [[WeakestAuras.CharacterStat("resistNature")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "resistFrost", type = "number", display = "Frost Resistance",
init = [[WeakestAuras.CharacterStat("resistFrost")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "resistShadow", type = "number", display = "Shadow Resistance",
init = [[WeakestAuras.CharacterStat("resistShadow")]],
multiEntry = true, store = true, conditionType = "number" },
{ name = "resistArcane", type = "number", display = "Arcane Resistance",
init = [[WeakestAuras.CharacterStat("resistArcane")]],
multiEntry = true, store = true, conditionType = "number" },
},
}
-- Loss of control on the player, timed progress. Upstream's "Crowd Controlled":
-- the first matching C_LossOfControl effect, re-read on LOSS_OF_CONTROL_UPDATE,
-- which ClassicAPI fires when an effect is added, falls off or expires. The type
-- and school options are baked into init's call rather than tested afterwards,
-- since they choose which effect is reported, not whether the one found passes.
PROTOTYPES["crowdcontrol"] = {
displayName = "Crowd Control",
wa2Event = "Crowd Controlled",
category = "unit",
progressType = "timed",
events = function() return { "LOSS_OF_CONTROL_UPDATE" } end,
force_events = true,
-- The category filter this trigger was saved with before it read
-- C_LossOfControl. Root, Silence and Disarm are one control type each; CC
-- (stuns, fears, incapacitates) has no single equivalent and Snare is not a
-- loss of control at all, so both fall back to any type.
migrate = function(trigger)
if trigger.category == nil then return end
local mapped = ({ Root = "ROOT", Silence = "SILENCE", Disarm = "DISARM" })[trigger.category]
if mapped and trigger.controlType == nil then
trigger.controlType = mapped
trigger.use_controlType = trigger.use_category
end
trigger.category, trigger.use_category = nil, nil
end,
icon = "Interface\\Icons\\Spell_Nature_Polymorph",
iconFunc = function() return "Interface\\Icons\\Spell_Nature_Polymorph" end,
nameFunc = function() return "Crowd Control" end,
init = function(trigger)
local controlType = trigger.use_controlType and trigger.controlType or nil
local schoolMask = controlType == "SCHOOL_INTERRUPT" and trigger.use_interruptSchool
and tonumber(trigger.interruptSchool) or nil
return "local active, locType, spellId, name, icon, expirationTime, duration, lockoutSchool = WeakestAuras.PlayerLossOfControl("
.. fmt(controlType) .. ", " .. fmt(schoolMask) .. ")"
end,
args = {
{ name = "controlType", type = "select", display = "Specific Type",
valueList = { "STUN", "FEAR", "ROOT", "CONFUSE", "CHARM", "POSSESS", "SILENCE",
"PACIFY", "PACIFYSILENCE", "DISARM", "SCHOOL_INTERRUPT" },
valueLabels = { STUN = "Stun", FEAR = "Fear", ROOT = "Root", CONFUSE = "Confuse",
CHARM = "Charm", POSSESS = "Possess", SILENCE = "Silence", PACIFY = "Pacify",
PACIFYSILENCE = "Pacify and Silence", DISARM = "Disarm",
SCHOOL_INTERRUPT = "Interrupt" },
reloadOptions = true, test = function() return nil end },
{ name = "interruptSchool", type = "select", display = "Interrupt School",
valueList = WA.SCHOOL_VALUES, valueLabels = WA.SCHOOL_LABELS,
default = 4, test = function() return nil end,
enable = function(trigger) return trigger.use_controlType and trigger.controlType == "SCHOOL_INTERRUPT" end },
{ name = "inverse", type = "toggle", display = "Inverse" },
{ name = "active", type = "hidden", required = true, store = true, conditionType = "bool", display = "Under Control",
test = function(trigger) return trigger.inverse and "not active" or "active" end },
{ name = "locType", type = "hidden", store = true, conditionType = "string", display = "Control Type" },
{ name = "spellId", type = "hidden", store = true, conditionType = "number", display = "Spell ID" },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = "Spell Name" },
{ name = "icon", type = "hidden", store = true },
{ name = "lockoutSchool", type = "hidden", store = true, conditionType = "string", display = "Interrupted School" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
},
}
-- Range to a unit, status. A standalone wrapper over the three range primitives,
-- selected by `rangeMode`:
-- "unit" -- UnitInRange: a real ~40yd position check (ClassicAPI), the
-- same primitive the spell-cooldown/health prototypes expose.
-- "interact" -- CheckInteractDistance(unit, index): coarse native bands
-- (inspect ~28 / trade ~11 / duel ~10 / follow ~28 yd -- index
-- 3 is the melee-ish band UnitInRange's 40yd can't resolve).
-- "spell" -- WA.SpellInRange: the engine's range check for a named spell.
-- Range changes with movement and there's no movement event, so it leans on
-- WA_FAST_TICK's 0.1s cadence to re-poll (plus a target swap) -- smooth enough
-- for a live distance readout. `distance` is the center-to-center yards from
-- UnitDistanceSquared (ClassicAPI), 0 when the position isn't available.
-- UnitInRange returns (inRange, checkedRange); captured via a direct
-- multi-assign inside the guard, never an `and`-chain (which truncates
-- multi-returns to one value here).
PROTOTYPES["rangecheck"] = {
displayName = "Range Check",
wa2Event = "Range Check",
category = "unit",
progressType = "none",
events = function(trigger)
return appendUnitChangeEvents(trigger,
{ "WA_FAST_TICK", "PLAYER_ENTERING_WORLD" }, "target")
end,
force_events = true,
loadFunc = function() WA.EnsureFastTick() end,
icon = "Interface\\Icons\\Ability_Hunter_SniperShot",
iconFunc = function() return "Interface\\Icons\\Ability_Hunter_SniperShot" end,
nameFunc = function(trigger)
local unit = WA.TriggerUnit(trigger, "target")
return UnitName(unit) or unit
end,
init = function(trigger)
local mode = trigger.rangeMode or "unit"
local src = "local unit = " .. fmt(WA.TriggerUnit(trigger, "target")) .. "\n"
.. "local exists = UnitExists(unit) and true or false\n"
.. "local inRange, checkedRange = false, false\n"
.. "local distance, checkedDistance = 0, false\n"
if mode == "interact" then
src = src .. "if exists and CheckInteractDistance then "
.. "inRange = CheckInteractDistance(unit, " .. fmt(trigger.interactIndex or 3) .. ") and true or false "
.. "checkedRange = true end"
elseif mode == "spell" then
src = src .. "local spellId = " .. fmt(WA.ResolveSpellID(trigger.spellName) or 0) .. "\n"
.. "if exists and spellId > 0 then "
.. "inRange = WeakestAuras.SpellInRange(spellId, unit) "
.. "checkedRange = true end"
else
src = src .. "if exists and UnitInRange then local _r, _c = UnitInRange(unit) "
.. "inRange = _r and true or false checkedRange = _c and true or false end"
end
-- Center-to-center yards, independent of the mode's boolean check, so a
-- distance readout and a distance filter work in every mode. A real 0
-- (self, co-located) is indistinguishable from the miss placeholder, so
-- checkedDistance records whether the position was actually read; the
-- distance filter gates on it rather than trusting the 0.
src = src .. "\nif exists and UnitDistanceSquared then "
.. "local _dsq, _chk = UnitDistanceSquared(unit) "
.. "if _chk then distance = math.sqrt(_dsq) checkedDistance = true end end"
return src
end,
args = {
{ name = "unit", type = "unit", display = "Unit", default = "target",
store = true, conditionType = "string" },
{ name = "rangeMode", type = "select", required = true, display = "Range Mode",
valueList = { "unit", "interact", "spell" },
valueLabels = { unit = "Unit (40yd)", interact = "Interact Band", spell = "Spell Range" },
default = "unit", reloadOptions = true,
-- The mode is baked into init's branch, not a runtime local, so it
-- contributes no test of its own.
test = function() return nil end },
{ name = "interactIndex", type = "select", required = true, display = "Interact Band",
valueList = { 1, 2, 3, 4 },
valueLabels = { [1] = "Inspect (~28yd)", [2] = "Trade (~11yd)", [3] = "Duel (~10yd)", [4] = "Follow (~28yd)" },
default = 3,
-- The band index is baked into the CheckInteractDistance call in init,
-- not a runtime local, so it contributes no test of its own.
test = function() return nil end,
enable = function(trigger) return (trigger.rangeMode or "unit") == "interact" end },
{ name = "spellName", type = "spell", display = "Spell (for Spell Range)",
enable = function(trigger) return (trigger.rangeMode or "unit") == "spell" end },
{ name = "exists", type = "hidden", required = true, test = "exists" },
-- `distance` is a local from init; the arg only adds the optional filter
-- and the store, so it declares no init of its own (a `local distance =
-- distance` would shadow the real one with nil). The filter gates on
-- checkedDistance so an unknown position (0 placeholder) can't pass a
-- `<=` test.
{ name = "distance", type = "number", display = "Distance", operator = "<=",
store = true, conditionType = "number",
test = function(trigger)
if not trigger.use_distance then return nil end
local op = safeOp(trigger.distance_operator or "<=")
return string.format("checkedDistance and (distance %s %s)", op, fmt(trigger.distance or 0))
end },
{ name = "inRange", type = "hidden", store = true, conditionType = "bool", display = "In Range" },
{ name = "checkedRange", type = "hidden", store = true, conditionType = "bool", display = "Range Checkable" },
{ name = "inRangeFilter", type = "toggle", display = "Only if In Range",
test = function(t) return t.inRangeFilter and "inRange" or nil end },
},
}
-- Threat, static progress (value/total = pullPct/100, so a bar fills as the mob
-- is about to change hands). No Lua threat API on 1.12: Turtle answers a
-- server-side threat query sent as an addon message, with a table of the top N
-- players on the requester's own target. WA.WatchThreat (below) consumes the
-- reply and sends the query, standing down for anything else on this client
-- that is already asking -- so this needs a party or raid and an elite target,
-- but not TWThreat. `exists` stays false (trigger hidden) until a packet naming
-- the player lands, and goes false again on a target change or when combat ends.
--
-- The two halves of a threat display come off the same rival calculation: not
-- holding the mob, the rival is whoever does and pullGap is the threat left
-- before the player rips it; holding it, the rival is the closest challenger and
-- pullGap is the lead over them. pullPct is the same race as 0..100, threatDiff
-- the plain signed difference. threatpct is the server's own number -- each row's
-- share of its own pull threshold, so it describes whichever row it sits on
-- rather than the player -- so prefer the computed fields.
PROTOTYPES["threat"] = {
displayName = "Threat",
wa2Event = "Threat Situation",
category = "unit",
progressType = "static",
progressValue = "pullPct",
progressTotal = "threatmax",
events = function() return { "WA_THREAT_CHANGED", "PLAYER_TARGET_CHANGED" } end,
force_events = true,
loadFunc = function() WA.WatchThreat() end,
icon = "Interface\\Icons\\Ability_Threaten",
iconFunc = function() return "Interface\\Icons\\Ability_Threaten" end,
nameFunc = function() return "Threat" end,
init = function() return "local ts = WeakestAuras.ThreatInfo()" end,
args = {
{ name = "exists", type = "hidden", required = true, init = "ts.exists", test = "exists" },
{ name = "pullPct", type = "number", display = "Aggro Race (%)", operator = ">=",
init = "ts.pullPct or 0", multiEntry = true, store = true, conditionType = "number" },
{ name = "pullGap", type = "number", display = "Threat Until Swap",
init = "ts.pullGap or 0", multiEntry = true, store = true, conditionType = "number" },
{ name = "threatDiff", type = "number", display = "Threat Difference",
init = "ts.threatDiff or 0", multiEntry = true, store = true, conditionType = "number" },
{ name = "threat", type = "number", display = "Own Threat",
init = "ts.threat or 0", store = true, conditionType = "number" },
{ name = "threatpct", type = "number", display = "Threat (%, server's)", operator = ">=",
init = "ts.threatpct or 0", store = true, conditionType = "number" },
{ name = "threatcount", type = "number", display = "Players Listed",
init = "ts.threatcount or 0", store = true, conditionType = "number" },
{ name = "aggro", type = "select", display = "Aggro",
valueList = { "any", "tanking", "notTanking" },
valueLabels = { any = "Ignore", tanking = "Holding Aggro", notTanking = "Not Holding Aggro" },
default = "any", test = function(trigger)
if not trigger.use_aggro or trigger.aggro == "any" then return nil end
return trigger.aggro == "tanking" and "isTanking" or "(not isTanking)"
end },
{ name = "threatmax", type = "hidden", init = "ts.threatmax or 100",
store = true, conditionType = "number", display = "Threat Max" },
{ name = "isTanking", type = "hidden", init = "ts.isTanking and true or false",
store = true, conditionType = "bool", display = "Tanking" },
{ name = "melee", type = "hidden", init = "ts.melee and true or false",
store = true, conditionType = "bool", display = "In Melee Range" },
{ name = "tankName", type = "hidden", init = "ts.tankName",
store = true, conditionType = "string", display = "Aggro Holder" },
{ name = "tankThreat", type = "hidden", init = "ts.tankThreat or 0",
store = true, conditionType = "number", display = "Aggro Holder Threat" },
{ name = "rivalName", type = "hidden", init = "ts.rivalName",
store = true, conditionType = "string", display = "Rival" },
{ name = "rivalThreat", type = "hidden", init = "ts.rivalThreat or 0",
store = true, conditionType = "number", display = "Rival Threat" },
-- Stored so a display can anchor itself to the mob's own nameplate: the
-- NAMEPLATE anchor resolves state.unit, falling back to state.guid.
{ name = "unit", type = "hidden", init = "ts.unit", store = true,
conditionType = "string", display = "Unit" },
{ name = "guid", type = "hidden", init = "ts.guid", store = true,
conditionType = "string", display = "GUID" },
{ name = "targetName", type = "hidden", init = "ts.targetName",
store = true, conditionType = "string", display = "Target Name" },
},
}
-- Totem in a given element slot, timed progress, off ClassicAPI's GetTotemInfo.
-- PLAYER_TOTEM_UPDATE covers drops, recalls, deaths and expiry; WA_SLOW_TICK
-- only keeps the Remaining Time test current. The totem's name is nil until the
-- client sees the creature, and the update event fires again when it does.
do -- scoped; see the local-ceiling note in the file header
local TOTEM_SLOT_LABELS = { [1] = "Fire", [2] = "Earth", [3] = "Water", [4] = "Air" }
local TOTEM_SLOT_ICONS = {
[1] = "Interface\\Icons\\Spell_Fire_SearingTotem",
[2] = "Interface\\Icons\\Spell_Nature_StrengthOfEarthTotem02",
[3] = "Interface\\Icons\\Spell_Nature_ManaRegenTotem",
[4] = "Interface\\Icons\\Spell_Nature_InvisibilityTotem",
}
PROTOTYPES["totem"] = {
displayName = "Totem",
wa2Event = "Totem",
category = "spell",
progressType = "timed",
events = function() return { "PLAYER_TOTEM_UPDATE", "WA_SLOW_TICK", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
loadFunc = function() WA.EnsureSlowTick() end,
icon = "Interface\\Icons\\Spell_Nature_SearingTotem",
migrate = function(trigger) renameArg(trigger, "totemSlot", "totemType") end,
iconFunc = function(trigger) return TOTEM_SLOT_ICONS[trigger.totemType or 1] or "Interface\\Icons\\Spell_Nature_SearingTotem" end,
nameFunc = function(trigger) return (TOTEM_SLOT_LABELS[trigger.totemType or 1] or "Totem") .. " Totem" end,
init = function(trigger)
return "local totemType = " .. fmt(trigger.totemType or 1) .. "\n"
.. "local _, name, startTime, duration, icon = GetTotemInfo(totemType)\n"
.. "local active = startTime > 0\n"
.. "local expirationTime = (startTime > 0 and duration > 0) and (startTime + duration) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0"
end,
args = {
{ name = "totemType", type = "select", required = true, display = "Element",
valueList = { 1, 2, 3, 4 }, valueLabels = TOTEM_SLOT_LABELS, default = 1 },
{ name = "active", type = "hidden", required = true, test = "active",
store = true, conditionType = "bool", display = "Active" },
{ name = "remaining", type = "number", display = "Remaining Time", operator = "<=" },
{ name = "name", type = "hidden", store = true, conditionType = "string", display = "Totem Name" },
{ name = "icon", type = "hidden", store = true },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Duration" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
},
}
end -- totem prototype
-- Swing timer, timed progress per weapon hand. The watcher (below) maintains
-- each hand's swing off ClassicAPI's PLAYER_SWING, plus pfUI's Flurry rescale,
-- the Slam/Hammer-of-Wrath clip warning and the queued on-next-swing ability.
-- Those extras are surfaced as condition variables
-- (blocked/clipWarning/clipDelay/queuedAbility), not a hardcoded bar tint.
PROTOTYPES["swing"] = {
displayName = "Swing Timer",
wa2Event = "Swing Timer",
category = "unit",
progressType = "timed",
events = function() return { "WA_SWING_UPDATE", "WA_SLOW_TICK", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
loadFunc = function() WA.WatchSwing(); WA.EnsureSlowTick() end,
icon = "Interface\\Icons\\Ability_Warrior_DecisiveStrike",
iconFunc = function() return "Interface\\Icons\\Ability_Warrior_DecisiveStrike" end,
migrate = function(trigger) renameArg(trigger, "swingHand", "hand") end,
nameFunc = function(trigger)
local h = trigger.hand or "main"
return ((h == "off" and "Off Hand") or (h == "ranged" and "Ranged") or "Main Hand") .. " Swing"
end,
init = function(trigger)
return "local hand = " .. fmt(trigger.hand or "main") .. "\n"
.. "local active, startTime, duration, blocked, clipWarning, clipDelay, queuedAbility = WeakestAuras.SwingInfo(hand)\n"
.. "active = active and true or false\n"
.. "startTime = startTime or 0\n"
.. "duration = duration or 0\n"
.. "blocked = blocked and true or false\n"
.. "clipWarning = clipWarning and true or false\n"
.. "clipDelay = clipDelay or 0\n"
.. "queuedAbility = queuedAbility or \"none\"\n"
.. "local expirationTime = (startTime > 0 and duration > 0) and (startTime + duration) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0"
end,
args = {
{ name = "hand", type = "select", required = true, display = "Weapon",
valueList = { "main", "off", "ranged" },
valueLabels = { main = "Main Hand", off = "Off Hand", ranged = "Ranged" },
default = "main" },
{ name = "active", type = "hidden", required = true, test = "active",
store = true, conditionType = "bool", display = "Swinging" },
{ name = "remaining", type = "number", display = "Remaining Time", operator = "<=" },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Swing Speed" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Remaining Time" },
-- Main-hand-only extras (Slam/HoW clip, ability queue), surfaced as
-- condition variables the user binds to color/text (no bar-owned tint).
{ name = "blocked", type = "hidden", store = true, conditionType = "bool", display = "Blocked (casting)" },
{ name = "clipWarning", type = "hidden", store = true, conditionType = "bool", display = "Clip Warning" },
{ name = "clipDelay", type = "hidden", store = true, conditionType = "number", display = "Clip Delay" },
{ name = "queuedAbility", type = "hidden", store = true, conditionType = "string", display = "Queued Ability" },
},
}
-- Power tick, timed progress, read from WA.WatchPowerTick's inferred cache --
-- no client event fires on the regen tick itself. Inference drifts on a missed
-- or batched power event, and in vanilla spending power does not reset the
-- tick -- both expected, not bugs.
PROTOTYPES["powertick"] = {
displayName = "Power Tick",
category = "unit",
progressType = "timed",
events = function() return { "WA_POWERTICK_UPDATE", "WA_SLOW_TICK", "PLAYER_ENTERING_WORLD" } end,
force_events = true,
loadFunc = function() WA.WatchPowerTick(); WA.EnsureSlowTick() end,
icon = "Interface\\Icons\\Spell_Nature_Lightning",
iconFunc = function() return "Interface\\Icons\\Spell_Nature_Lightning" end,
nameFunc = function() return "Power Tick" end,
init = function(trigger)
return "local active, startTime, duration = WeakestAuras.PowerTickInfo()\n"
.. "active = active and true or false\n"
.. "startTime = startTime or 0\n"
.. "duration = duration or 0\n"
.. "local expirationTime = (startTime > 0 and duration > 0) and (startTime + duration) or 0\n"
.. "local remaining = (expirationTime > 0 and (expirationTime - GetTime())) or 0"
end,
args = {
{ name = "active", type = "hidden", required = true, test = "active",
store = true, conditionType = "bool", display = "Ticking" },
{ name = "remaining", type = "number", display = "Time to Tick", operator = "<=" },
{ name = "duration", type = "hidden", store = true, conditionType = "number", display = "Tick Interval" },
{ name = "expirationTime", type = "hidden", store = true, conditionType = "timer", display = "Time to Tick" },
},
}
-- ---------------------------------------------------------------------------
-- Cooldown watchers (§4.4): one central cache per cooldown *kind* (spell/
-- item/equipment-slot), each polled on its own game-cooldown events + a
-- shared-shape slow ticker, translating the raw API into internal
-- <KIND>_COOLDOWN_CHANGED / _READY events prototypes consume declaratively.
-- Item and equipment-slot cooldowns are a near-clone of the spell-cooldown
-- watcher, so newCooldownWatcher factors the shared cache/ready-timer machinery
-- out once instead of copy-pasting it three times.
-- ---------------------------------------------------------------------------
-- spec: { pollRaw(key) -> start, duration, enabled; changedEvent, readyEvent;
-- extraEvents = { ... } (pcall-registered on the watcher's own frame, e.g.
-- SPELL_UPDATE_COOLDOWN); baseCooldown(key) -> the key's own cooldown in
-- seconds, for the kinds that can answer it }. Returns { Watch(key, wantTick),
-- Info(key) }.
-- `cache` is what the API last said and `announced` is what subscribers were
-- last told. They are separate on purpose: `cache` is refreshed on every read,
-- so no trigger ever evaluates a stale window, and comparing a poll against
-- `cache` would then let a read that happened to land between two polls swallow
-- the change event every *other* aura on that key is owed.
do -- scoped; see the local-ceiling note in the file header
-- A window that is not the key's own cooldown: the global cooldown, and the
-- ranged shot cadence, which this client reports the same way -- firing a wand
-- locks every spell for one ranged swing, and that window's length is the
-- weapon's speed, not 1.5s. Length alone cannot separate those from a real
-- cooldown, since a 1.8s wand and a 1.8s cooldown read identically, so the
-- key's own recovery time decides: a spell whose record carries no recovery has
-- no cooldown of its own, and any window on it belongs to something else.
local SHARED_MAX = 3.0 -- past any wand's swing, and short of every real cooldown in Spell.dbc
local GCD_CEILING = 1.6 -- the 1.5s global cooldown, plus the slack a server's report lands in
local gcdCeiling = GCD_CEILING
local rangedCeiling, rangedStamp = 0, 0
-- `base` is the key's own cooldown in seconds: 0 when it has none, nil when
-- unknown, which is every key that is not a spell. A window is the key's own
-- only if it is that length -- talents shorten a cooldown and nothing lengthens
-- one, so a window longer than the record's is something passing over the key,
-- and a shorter one at these lengths is the global cooldown or a shot.
--
-- Ordered for the hot path: every cooldown trigger runs this on every scan, so
-- a real cooldown -- the common case by far -- exits on the first comparison,
-- and the ranged read is reached only by a window too long to be a global
-- cooldown and short enough to be a shot.
local function isShared(duration, base)
if duration > SHARED_MAX then return false end
if duration > gcdCeiling then
local now = GetTime()
if now - rangedStamp > 1 then
rangedStamp = now
local speed = UnitRangedDamage and UnitRangedDamage("player")
rangedCeiling = (speed and speed > 0) and (speed + 0.1) or 0
end
if duration > rangedCeiling then return false end
end
if base == nil or base == 0 then return true end
return duration < base - 0.05 or duration > base + 0.05
end
-- The global cooldown's measured length, handed over by the watcher that learns
-- it. Never lowers the ceiling below the 1.5s standard: a measurement short
-- enough to do that came off something that was not a global cooldown, and it
-- would take every spell whose own cooldown is shorter than one -- the stances,
-- Shadowform -- down with it.
function WA.SetGcdLength(length)
gcdCeiling = (length and length + 0.1 > GCD_CEILING) and (length + 0.1) or GCD_CEILING
end
-- The spell's own cooldown in seconds, from its Spell.dbc record: the longer of
-- its own and its category's recovery, since most vanilla cooldowns are the
-- category's (Fire Blast is category 19's 8s). 0 for an unknown spell.
local function spellBaseCooldown(spellId)
if not (spellId and spellId > 0) then return 0 end
return (GetSpellBaseCooldown(spellId) or 0) / 1000
end
WA.SpellBaseCooldown = spellBaseCooldown
-- Debug.lua's /wa cdprobe reads the verdict through this; the watchers call the
-- local directly, which is the point of it being one.
function WA.IsSharedCooldown(duration, base)
return (duration or 0) > 0 and isShared(duration, base)
end
local function newCooldownWatcher(spec)
local w = { keys = {}, tick = {}, cache = {}, announced = {}, frame = nil, ticker = nil }
-- Emits nothing, so generated trigger code -- which already runs inside a
-- scan -- can call it without re-entering dispatch. Mutates the cached
-- entry in place: every cooldown trigger reads through here on every scan,
-- and nothing holds the table across calls -- callers take fields off it.
local function refresh(key)
local start, duration, enabled = spec.pollRaw(key)
local c = w.cache[key]
if not c then
c = {}
-- Read once per key: a spell record does not change under a session,
-- and this keeps the per-scan test down to two comparisons.
if spec.baseCooldown then c.base = spec.baseCooldown(key) end
w.cache[key] = c
end
c.start = start or 0
c.duration = duration or 0
c.enabled = enabled
return c
end
local function pollKey(key)
local c = refresh(key)
local a = w.announced[key]
local changed = not a or a.start ~= c.start or a.duration ~= c.duration
local running = c.start > 0 and c.duration > 0 and (c.start + c.duration) > GetTime()
-- Fire on a real change, and additionally every tick while a remaining-time
-- filter is interested (w.tick), so its threshold flips near real time
-- rather than only at start/end -- a running cooldown emits no natural event.
if changed or (w.tick[key] and running) then
if not a then
a = {}
w.announced[key] = a
end
a.start = c.start
a.duration = c.duration
WA.ScanEvents(spec.changedEvent, key)
end
-- Schedule the ready flip for real cooldowns, so the display clears
-- exactly on time without relying on the ticker's cadence. A shared
-- window is skipped: it is the global cooldown or a shot cadence passing
-- over the key, not its own cooldown, and arming a timer for every one of
-- those would mean a timer per watched key per cast.
if changed and running and not isShared(c.duration, c.base) then
local remain = (c.start + c.duration) - GetTime()
if remain > 0 then
C_Timer.After(remain + 0.05, function()
pollKey(key)
WA.ScanEvents(spec.readyEvent, key)
end)
end
end
return running
end
local pollAll
-- The poll runs only while something is actually on cooldown. An idle key has
-- nothing a pass could discover that the client's own cooldown events do not
-- already deliver, so a watcher whose keys are all ready costs nothing --
-- which matters because keys are never released (see Watch below). Upstream
-- suspends its equivalent the same way: cdReadyFrame hides itself, stopping
-- its OnUpdate, whenever no work is marked pending.
--
-- The events on w.frame are what start it again, so this trades the old
-- always-on pass for a dependency on one of them arriving when a cooldown
-- begins. They are the canonical ones for each kind and the per-cooldown
-- expiry timer above still closes the window regardless.
local function syncTicker(running)
if running and not w.ticker then
w.ticker = C_Timer.NewTicker(0.3, function() pollAll() end)
elseif not running and w.ticker then
w.ticker:Cancel()
w.ticker = nil
end
end
pollAll = function()
local running = false
for key in pairs(w.keys) do
if pollKey(key) then running = true end
end
syncTicker(running)
end
local function ensure()
if w.frame then return end
w.frame = CreateFrame("Frame")
w.frame:SetScript("OnEvent", pollAll)
-- Guarded: not every 1.12 build fires every one of these, and an unknown
-- event name would error the whole registration (risk (c), settle per
-- build with Debug.lua's /wa events pattern).
for i = 1, #(spec.extraEvents or {}) do
pcall(w.frame.RegisterEvent, w.frame, spec.extraEvents[i])
end
end
-- Registers a key in the central cooldown cache. wantTick asks the watcher to
-- re-emit changedEvent every ticker pass while this key is on cooldown (for
-- remaining-time filters).
--
-- Keys are never released, which is deliberate rather than unfinished:
-- upstream has no unwatch either (its `items[id] = true` is likewise set once
-- and never cleared, and the refcount on SpellDetails.watched exists to remap
-- a spell whose override changed, not to free anything on unload). The cost
-- of a key nothing displays any more is one API read per pass, and only while
-- some *other* key is running -- syncTicker above is what makes that true.
local function watch(key, wantTick)
w.keys[key] = true
if wantTick then w.tick[key] = true end
ensure()
if pollKey(key) then syncTicker(true) end
end
-- Generated cooldown-trigger code reads the cache through this. Refreshing
-- first is what makes it answer for a key nothing has registered yet -- a
-- force_events pass runs before loadFunc -- and keeps a read that arrives
-- between two polls current.
--
-- The global cooldown and a wand's shot cadence pass over every spell and
-- item, so a window that is one of those is not a cooldown of this key's own
-- and is reported as no cooldown at all unless `showgcd` asks for it. That
-- test belongs here rather than in each prototype's generated code: every
-- value a trigger derives -- duration, expirationTime, remaining, onCooldown,
-- ready, usable -- comes off this one read, and filtering per prototype is
-- how they came to disagree. Excluding is the default so a caller that omits
-- the flag cannot leak.
local function info(key, showgcd)
local c = refresh(key)
if not showgcd and c.duration > 0 and isShared(c.duration, c.base) then return 0, 0, c.enabled end
return c.start, c.duration, c.enabled
end
return { Watch = watch, Info = info }
end
-- C_Spell.GetSpellCooldown and vanilla's GetSpellCooldown(slot, BOOKTYPE_SPELL)
-- are the same read: ClassicAPI's Script_C_Spell_GetSpellCooldown calls
-- FUN_SPELL_QUERY_COOLDOWN(spellID, bookType=0), which is the helper the slot
-- form reaches after resolving the slot to a spellID (ref ClassicAPI
-- src/spell/Cooldown.cpp). The spellID form is the one to use: the slot form
-- additionally requires the spell to sit in the player's book, so it cannot
-- answer for a talent passive or a profession recipe.
--
-- What this read depends on is the *identity* handed to it -- a spellID with no
-- Spell.dbc row answers nil, and a rank other than the one actually cast has no
-- cooldown of its own. That resolution is WA.ResolveSpellID's job, not this one's.
--
-- The spellID form builds a four-field table per call, though, and this runs for
-- every watched key on each cooldown and usability event and again inside every
-- trigger run. So a spell whose exact rank has a button is read through that
-- slot -- the same query, answered positionally -- and the table form is left
-- for what the book does not hold: a talent passive, a recipe, an unlearned rank.
local spellCdWatch = newCooldownWatcher({
pollRaw = function(spellId)
local slot = WA.ExactSpellSlot(spellId)
if slot then
local ok, start, duration, enabled = pcall(GetSpellCooldown, slot, BOOKTYPE_SPELL or "spell")
if ok and start then return start, duration, enabled ~= 0 and enabled ~= false end
end
local cdInfo = C_Spell.GetSpellCooldown(spellId)
if not cdInfo then return nil, nil, nil end
return cdInfo.startTime, cdInfo.duration, cdInfo.isEnabled
end,
baseCooldown = spellBaseCooldown,
changedEvent = "SPELL_COOLDOWN_CHANGED",
readyEvent = "SPELL_COOLDOWN_READY",
extraEvents = { "SPELL_UPDATE_COOLDOWN", "SPELL_UPDATE_USABLE", "ACTIONBAR_UPDATE_COOLDOWN" },
})
function WA.WatchSpellCooldown(spellId, wantTick) spellCdWatch.Watch(spellId, wantTick) end
function WA.SpellCdInfo(spellId, showgcd) return spellCdWatch.Info(spellId, showgcd) end
-- Whether the client has a cooldown record for this spell at all. An id with no
-- Spell.dbc row -- a name that resolved to nothing, a rank that does not exist,
-- a hand-typed number -- reads back as a zero-length cooldown, which is
-- indistinguishable from "ready" unless it is asked about separately. A trigger
-- pointed at a spell the client cannot identify should stay dark rather than
-- claim the spell is permanently off cooldown.
function WA.SpellCdKnown(spellId)
if not spellId or spellId == 0 then return false end
if WA.ExactSpellSlot(spellId) then return true end
return C_Spell.GetSpellCooldown(spellId) ~= nil
end
-- GetItemCooldown returns (start, duration, enable) directly, not a table.
local itemCdWatch = newCooldownWatcher({
pollRaw = function(itemId)
local start, duration, enable = GetItemCooldown(itemId)
return start, duration, enable == 1
end,
changedEvent = "ITEM_COOLDOWN_CHANGED",
readyEvent = "ITEM_COOLDOWN_READY",
extraEvents = { "BAG_UPDATE_COOLDOWN" },
})
function WA.WatchItemCooldown(itemId, wantTick) itemCdWatch.Watch(itemId, wantTick) end
function WA.ItemCdInfo(itemId, showgcd) return itemCdWatch.Info(itemId, showgcd) end
-- GetInventoryItemCooldown is a native 1.12 global (not a ClassicAPI
-- backport), same (start, duration, enable) shape as GetItemCooldown.
local equipCdWatch = newCooldownWatcher({
pollRaw = function(slot)
local start, duration, enable = GetInventoryItemCooldown("player", slot)
return start, duration, enable == 1
end,
changedEvent = "EQUIPSLOT_COOLDOWN_CHANGED",
readyEvent = "EQUIPSLOT_COOLDOWN_READY",
extraEvents = { "BAG_UPDATE_COOLDOWN", "PLAYER_EQUIPMENT_CHANGED" },
})
function WA.WatchEquipSlotCooldown(slot, wantTick) equipCdWatch.Watch(slot, wantTick) end
function WA.EquipSlotCdInfo(slot, showgcd) return equipCdWatch.Info(slot, showgcd) end
end -- cooldown watchers
-- Threat watcher: parses the packet Turtle's server-side threat query answers
-- into a cached table describing every player in it, re-emitting
-- WA_THREAT_CHANGED so the threat prototype re-reads it (the incoming addon
-- message is just another event feeding a status update -- fits the existing
-- model with no engine change).
--
-- Packet body: "TWTv4=name:tank:threat:perc:melee;name2:...", five fields to a
-- row and no trailing separator, optionally followed by "#TMTv1=..." when the
-- meter runs in tank mode (ref TWThreat.lua handleThreatPacket). The
-- addon-message prefix is deliberately not checked: the reply is the server's,
-- and it arrives on "TWT " -- with the trailing space -- where TWThreat's own
-- traffic is "TWT", so an exact prefix test drops every packet. TWThreat itself
-- matches the "TWTv4=" marker alone. Plain string.find/sub splitting; this
-- client's Lua 5.0 has no gmatch and the fields are magic-char-free anyway.
--
-- `tank` marks whoever currently holds the mob, which is not the same as top
-- threat: a challenger only takes it at 110% of the holder's threat in melee
-- range and 130% out of it, and each row carries its own melee flag. `perc` is
-- the row's own share of that threshold, so the holder always reads 100 and a
-- challenger reads what pullPct derives below. It is kept as-is and derived
-- from rather than trusted: it is a percentage of *that row's* race, which is
-- the player's only while the player is the one being raced.
do -- scoped; see the local-ceiling note in the file header
local threatCache = { exists = false }
local threatFrame
function WA.WatchThreat()
if threatFrame then return end
-- One packet's rows reduced to what the prototype stores. `rival` is whoever
-- the player is racing for the mob -- the closest challenger when holding it,
-- the holder otherwise -- and `limit` the threat that flips it, always taken
-- against the challenger's own melee flag.
local function digest(rows, count, meRow, tankRow)
local d = {
exists = true, threatcount = count,
threat = meRow.threat, threatpct = meRow.perc, threatmax = 100,
melee = meRow.melee, isTanking = meRow.tank,
tankName = tankRow and tankRow.name, tankThreat = tankRow and tankRow.threat or 0,
unit = "target", guid = UnitGUID("target") or nil,
targetName = UnitName("target"),
rivalThreat = 0, threatDiff = 0, pullGap = 0, pullPct = 0,
}
local rival, limit
if meRow.tank then
for i = 1, count do
local r = rows[i]
if r ~= meRow then
local l = meRow.threat * (r.melee and 1.1 or 1.3)
if not rival or (l - r.threat) < (limit - rival.threat) then rival, limit = r, l end
end
end
elseif tankRow then
rival, limit = tankRow, tankRow.threat * (meRow.melee and 1.1 or 1.3)
end
if rival then
-- Rounded at the source: the threshold multiplier makes both a float, and
-- a raw one reaches a %pullGap text as fifteen digits of noise.
local challenger = meRow.tank and rival.threat or meRow.threat
d.rivalName = rival.name
d.rivalThreat = rival.threat
d.threatDiff = meRow.threat - rival.threat
d.pullGap = math.floor(limit - challenger + 0.5)
d.pullPct = limit > 0 and (math.floor(challenger / limit * 1000 + 0.5) / 10) or 0
end
return d
end
threatFrame = CreateFrame("Frame")
-- Guarded like every other event registration here (risk (c)); all three are
-- native 1.12 events so they should always take.
pcall(threatFrame.RegisterEvent, threatFrame, "CHAT_MSG_ADDON")
pcall(threatFrame.RegisterEvent, threatFrame, "PLAYER_TARGET_CHANGED")
pcall(threatFrame.RegisterEvent, threatFrame, "PLAYER_REGEN_ENABLED")
pcall(threatFrame.RegisterEvent, threatFrame, "PLAYER_REGEN_DISABLED")
threatFrame:SetScript("OnEvent", function()
-- A packet describes the mob the meter last asked about, so a new target,
-- the start of a fight or the end of one invalidates it wholesale --
-- otherwise the display keeps counting threat on something that is no
-- longer being fought.
if event ~= "CHAT_MSG_ADDON" then
if threatCache.exists then
threatCache = { exists = false }
WA.ScanEvents("WA_THREAT_CHANGED")
end
WA.ThreatPollOnEvent(event)
-- The two moments a threat list is new and nobody has asked about it
-- yet. Combat start is here because at the pull the target has not
-- changed, but the list it describes has only just come into being.
if event == "PLAYER_TARGET_CHANGED" or event == "PLAYER_REGEN_DISABLED" then
WA.ThreatQueryOnSwitch()
end
return
end
if not arg2 then return end
local s, e = string.find(arg2, "TWTv4=", 1, true)
if not s then return end
-- Counted before the body is read, and before the player's own row is
-- required: a packet that left the player out still proves somebody asked.
WA.ThreatQueryOnReply()
local body = string.sub(arg2, e + 1)
local tm = string.find(body, "#", 1, true)
if tm then body = string.sub(body, 1, tm - 1) end
local me = UnitName("player")
local rows, count, meRow, tankRow = {}, 0, nil, nil
for player in string.gmatch(body, "[^;]+") do
local name, tank, threat, perc, melee = strsplit(":", player)
if melee then
count = count + 1
rows[count] = { name = name, tank = tank == "1", threat = tonumber(threat) or 0,
perc = tonumber(perc) or 0, melee = melee == "1" }
if name == me then meRow = rows[count] end
if rows[count].tank then tankRow = rows[count] end
end
end
-- The query carries a row limit, so a packet can leave the player out; there
-- is nothing to report against until one includes them.
if not meRow then return end
threatCache = digest(rows, count, meRow, tankRow)
WA.ScanEvents("WA_THREAT_CHANGED")
end)
end
function WA.ThreatInfo() return threatCache end
-- The other half of the protocol: the query the reply above answers. The server
-- reads the requester's own selection, so this asks about `target` and nothing
-- else, and it answers whoever asked -- a reply is not broadcast to the channel.
--
-- The gate is most of the work here. One request feeds every consumer on the
-- client through CHAT_MSG_ADDON, so a second sender buys nothing and costs the
-- realm, and a request the server will refuse costs the same as one it answers.
-- Hence the classification test: Turtle's handler declines a `normal` mob
-- outright, which is measured rather than a guess at its `CanHaveThreatList`.
-- `UnitClassification` returns untranslated tokens, so unlike `UnitCreatureType`
-- it is safe to compare against; only `normal` is excluded, because `rare` and
-- `rareelite` have not been shown to fail.
local THREAT_QUERY_PREFIX = "TWT_UDTSv4"
-- What a display can use, not the maximum: the packet costs bytes and the rows
-- past the aggro holder and the player's own are only there to find the closest
-- challenger. A player far enough down a raid's list to fall outside this gets
-- no row and the trigger stays hidden, which is the trade this number sets.
local THREAT_QUERY_LIMIT = 5
local function threatTriggerLoaded()
local map = loaded_events["WA_THREAT_CHANGED"]
if not map then return false end
for _ in pairs(map) do return true end
return false
end
-- nil when a query is worth making, else the clause that refused it.
function WA.ThreatQueryRefusal()
if not threatTriggerLoaded() then return "no threat trigger is loaded" end
if not IsInGroup() then
return "not in a party or raid"
end
if not UnitExists("target") then return "no target" end
if UnitIsPlayer("target") then return "the target is a player" end
if UnitIsDead("target") then return "the target is dead" end
if not UnitCanAttack("player", "target") then return "the target is not attackable" end
if UnitClassification("target") == "normal" then
return "the server does not answer for a normal mob"
end
-- The player's own combat, not the target's. A mob somebody else is fighting
-- has a threat list with no row for a player who has not touched it, so there
-- is nothing for a display to show and, as it happens, nothing the server
-- answers with. This does not lose the pull: PLAYER_REGEN_DISABLED is the
-- player entering combat, so the clause is already true when it fires.
if not UnitAffectingCombat("player") then return "the player is not in combat" end
return nil
end
-- Who asked. A reply reaches only the client that asked for it, but every addon
-- in that client's process sees it, so a reply this file cannot account for is
-- proof that something else here is querying -- which is the whole of the
-- detection below.
--
-- Accounting is a queue of our own outstanding requests expired by age, not a
-- single "awaiting ours" flag. The flag is simpler and wrong: one request the
-- server never answers would consume the next foreign reply, and then the one
-- after that, for the rest of the session -- the poll would never stand down
-- again. Expiring by age makes that failure decay instead of latch.
--
-- The window is four times the worst round trip measured against the live
-- server (0.08-0.17s) and shorter than the poll, so a request the server drops
-- has aged out before the next one is due.
local THREAT_REPLY_WINDOW = 0.75
local ourRequests = {}
local lastForeignReply = 0
local function expireRequests(now)
while ourRequests[1] and now - ourRequests[1] > THREAT_REPLY_WINDOW do
table.remove(ourRequests, 1)
end
end
local function noteOurRequest(now)
expireRequests(now)
table.insert(ourRequests, now)
end
-- One reply, against the requests of ours still young enough to be answered.
function WA.ThreatQueryOnReply()
local now = GetTime()
expireRequests(now)
if ourRequests[1] then table.remove(ourRequests, 1) else lastForeignReply = now end
end
-- false plus the refusing clause, or true plus what went out -- the latter so a
-- caller can report the route as well as the payload, which is the only thing
-- that separates "the message never left" from "nobody answered it".
-- WA.OnThreatQuery, when something has set it, sees every outcome: (info) for a
-- request that left and (nil, reason) for one the gate stopped. Without it the
-- automatic path is unobservable -- a query that was never sent and one the
-- server ignored look identical from outside -- and that is the difference a
-- session watching this protocol most often needs.
function WA.SendThreatQuery(limit)
local why = WA.ThreatQueryRefusal()
if why then
if WA.OnThreatQuery then WA.OnThreatQuery(nil, why) end
return false, why
end
local channel = IsInRaid() and "RAID" or "PARTY"
local msg = "limit=" .. (limit or THREAT_QUERY_LIMIT)
WA.SendAddonMessageThrottled(THREAT_QUERY_PREFIX, msg, channel)
noteOurRequest(GetTime())
local info = { prefix = THREAT_QUERY_PREFIX, msg = msg, channel = channel }
if WA.OnThreatQuery then WA.OnThreatQuery(info) end
return true, info
end
-- Tab-targeting through a pack must cost one request, not one per mob.
local THREAT_SWITCH_THROTTLE = 0.3
-- The request names no unit: the server answers about whatever
-- `GetSelectedCreature()` says the player has selected, so it has to arrive
-- *after* the client's own CMSG_SET_SELECTION for the new target. Sending from
-- inside the PLAYER_TARGET_CHANGED handler races that packet and loses about ten
-- times in eleven, which reads as the server ignoring us. Never send in the
-- event's own frame; this wait is the fix and not a tuning knob.
local THREAT_SWITCH_SETTLE = 0.15
local lastSwitchSend, switchPending = 0, false
-- A target change or a pull, deferred and throttled. Deferred rather than
-- dropped, and that is the point: dropping leaves the one mob the player
-- actually settled on as the one nobody asked about. One timer serves a whole
-- burst, and the gate runs again when it fires, because what armed it may be
-- dead, gone, or a player by then.
function WA.ThreatQueryOnSwitch()
if switchPending then return end
local wait = THREAT_SWITCH_THROTTLE - (GetTime() - lastSwitchSend)
if wait < THREAT_SWITCH_SETTLE then wait = THREAT_SWITCH_SETTLE end
switchPending = true
C_Timer.After(wait, function()
switchPending = false
-- Only a request that went out arms the throttle; a refusal is not traffic.
if WA.SendThreatQuery() then lastSwitchSend = GetTime() end
end)
end
-- The timed query, and the only part of this that stands down. A switch request
-- keeps going out during a hold-off: it costs one message per target change and
-- it is the latency win, where the timer is the sustained traffic.
local THREAT_POLL = 1
-- Twice TWThreat's slowest poll, so one late tick of somebody else's timer does
-- not hand us their fight. Conservative on purpose: what a raid's worth of
-- clients does to the server is not measurable from here.
local THREAT_HOLDOFF = 2.5
local pollTicker
local function pollTick()
if GetTime() - lastForeignReply < THREAT_HOLDOFF then
-- Reported, because a poll that has stood down and a poll that never ran
-- are the same silence from outside and only one of them is right.
if WA.OnThreatQuery then WA.OnThreatQuery(nil, "something else on this client is asking") end
return
end
WA.SendThreatQuery()
end
-- The poll runs for the length of the player's own fight, which is the only
-- stretch the gate can pass anyway.
--
-- The target-change reset is the load-bearing line. A hold-off is evidence
-- about the mob it was collected on, and carrying it across a switch makes the
-- takeover asymmetric: it goes quiet on time, but starts up a whole hold-off
-- late on every elite->trash switch -- precisely the latency this exists to
-- remove. Nothing has to notice that the other addon came back, either; its
-- first reply re-arms the hold-off on its own.
function WA.ThreatPollOnEvent(event)
if event == "PLAYER_TARGET_CHANGED" then
lastForeignReply = 0
elseif event == "PLAYER_REGEN_DISABLED" then
if not pollTicker then pollTicker = C_Timer.NewTicker(THREAT_POLL, pollTick) end
elseif event == "PLAYER_REGEN_ENABLED" then
if pollTicker then pollTicker:Cancel(); pollTicker = nil end
end
end
end -- threat watcher
-- Shared dispatch for the player's completed casts: the GCD, swing and Cast
-- Succeeded watchers all consume ClassicAPI's UNIT_SPELLCAST_SUCCEEDED (arg1
-- unit, arg3 spellId), registered once and fanned out to subscribers. The event
-- fans out once per token naming the caster, so only the "player" token counts.
local castSucceededSubs = {}
local castSucceededFrame
local function subscribePlayerCastSucceeded(fn)
table.insert(castSucceededSubs, fn)
if castSucceededFrame then return end
castSucceededFrame = CreateFrame("Frame")
castSucceededFrame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
castSucceededFrame:SetScript("OnEvent", function()
if arg1 ~= "player" then return end
local spellId = arg3
for i = 1, #castSucceededSubs do castSucceededSubs[i](spellId) end
end)
end
-- ---------------------------------------------------------------------------
-- Global cooldown (§4.4). Upstream's CheckGCD reads a start and duration
-- straight off a dedicated GCD spell -- GetSpellCooldown(29515) on Classic,
-- 61304 elsewhere. Neither ID exists here: they have no 1.12 Spell.dbc row, and
-- this client's GetSpellCooldown takes a spellbook *slot* plus bookType rather
-- than a spellID, so handing it one raises "invalid spell slot" instead of
-- looking anything up.
--
-- What is readable is the same call used the way vanilla means it:
-- GetSpellCooldown(slot, BOOKTYPE_SPELL) answers a true start and duration in
-- GetTime()'s epoch, and a slot whose spell has no cooldown of its own shows
-- the global cooldown and nothing else. So the GCD is read off exactly one such
-- slot, learned from what the player actually casts -- after a completed cast
-- the caster's own slot either reports a GCD-length window (a usable probe,
-- remembered) or a longer one (the spell's own cooldown, keep looking).
--
-- Before a probe is learned the window is latched at the cast instead. Latching
-- rather than re-deriving is the point: a source that only reports a *remaining*
-- time re-reads as a fresh full-length window on every poll, which is a bar that
-- restarts from full several times a second.
-- ---------------------------------------------------------------------------
do -- scoped; see the local-ceiling note in the file header
-- The band a global cooldown can occupy. It is 1.5s, server latency lands the
-- reported window slightly over it, and a rogue's is 1.0s. A window outside the
-- band is something else -- a wand's shot cadence is the one that reaches here
-- -- and latching it would shorten both this watcher's window and the ceiling
-- the cooldown watchers filter against.
local GCD_MIN = 0.9
local GCD_MAX = 1.6
local GCD_DEFAULT = 1.5
local gcd = { start = 0, duration = 0, measured = nil, probeSlot = nil, generation = 0 }
local function gcdEnd()
if gcd.duration == 0 then return end
gcd.generation = gcd.generation + 1
gcd.start, gcd.duration = 0, 0
WA.ScanEvents("GCD_END")
end
-- Closes the window on its own schedule, so the display clears on time whether
-- or not another cooldown event happens to arrive. The generation stamp makes a
-- timer from a superseded window a no-op instead of ending the current one.
local function gcdSchedule()
local remain = (gcd.start + gcd.duration) - GetTime()
if remain <= 0 then return end
local generation = gcd.generation
C_Timer.After(remain + 0.05, function()
if gcd.generation == generation then gcdEnd() end
end)
end
local function gcdBegin(start, duration)
-- Same window seen again: keep the latched start and length. Re-deriving them
-- from a later read is what restarts a running bar.
if gcd.duration > 0 and math.abs((gcd.start + gcd.duration) - (start + duration)) < 0.1 then return end
gcd.generation = gcd.generation + 1
gcd.start, gcd.duration = start, duration
gcdSchedule()
WA.ScanEvents("GCD_UPDATE")
end
-- Accepts a window as a measurement of the global cooldown, and publishes the
-- length to the cooldown watchers' filter.
local function gcdMeasure(duration)
if not duration or duration < GCD_MIN or duration > GCD_MAX then return false end
gcd.measured = duration
WA.SetGcdLength(duration)
return true
end
local function gcdPoll()
local start, duration = WA.SpellSlotCooldown(gcd.probeSlot)
if start and gcdMeasure(duration) then
gcdBegin(start, duration)
elseif gcd.duration > 0 and (gcd.start + gcd.duration) <= GetTime() then
gcdEnd()
end
end
local function gcdOnCast(spellId)
-- Only spellbook casts: item uses and triggered spells land here too, and
-- neither starts the GCD. Nor does a ranged auto-attack, though Auto Shot and
-- Shoot are spellbook spells: each shot locks every spell for the weapon's
-- speed, which would read as a GCD on every wand shot.
if C_Spell.IsRangedAutoAttackSpell(spellId) then return end
local slot = WA.SpellSlotByID(spellId)
if not slot then return end
-- Open the window on the cast itself, so the trigger is live before any probe
-- slot has been learned and stays live for a caster whose own cooldown is
-- longer than the GCD (its slot can never serve as a probe).
gcdBegin(GetTime(), gcd.measured or GCD_DEFAULT)
-- Read a frame later: the cooldown the server applied for this cast has not
-- landed on the slot at the instant UNIT_SPELLCAST_SUCCEEDED arrives.
C_Timer.After(0.05, function()
local start, duration = WA.SpellSlotCooldown(slot)
if start and gcdMeasure(duration) then
gcd.probeSlot = slot
gcdBegin(start, duration)
end
end)
end
local gcdFrame
function WA.WatchGCD()
if gcdFrame then return end
gcdFrame = CreateFrame("Frame")
-- Guarded like every other event registration here (risk (c)).
local events = { "ACTIONBAR_UPDATE_COOLDOWN", "SPELL_UPDATE_COOLDOWN", "SPELLS_CHANGED", "LEARNED_SPELL_IN_TAB" }
for i = 1, #events do pcall(gcdFrame.RegisterEvent, gcdFrame, events[i]) end
gcdFrame:SetScript("OnEvent", function()
if event == "SPELLS_CHANGED" or event == "LEARNED_SPELL_IN_TAB" then
-- Slot numbers shift as the book grows, so the shared index and the
-- probe drawn from it both stop meaning anything.
WA.InvalidateSpellSlots()
gcd.probeSlot = nil
return
end
gcdPoll()
end)
subscribePlayerCastSucceeded(gcdOnCast)
end
-- (start, duration, enabled) of the open window, or (0, 0, true) when none is.
-- Reports an elapsed window as closed but does not close it: generated trigger
-- code calls this from inside a scan, and ending the window there would re-enter
-- dispatch. gcdSchedule's timer is what actually ends it and emits.
function WA.GcdInfo()
if gcd.duration > 0 and (gcd.start + gcd.duration) <= GetTime() then return 0, 0, true end
return gcd.start, gcd.duration, true
end
-- The watcher's internals, for Debug.lua's /wa gcd. Which slot it settled on and
-- what length it measured there are the two things the display cannot show and
-- the two the runtime is wrong about when the GCD misbehaves.
function WA.GcdDebug()
return { probeSlot = gcd.probeSlot, measured = gcd.measured, start = gcd.start,
duration = gcd.duration, watching = gcdFrame ~= nil }
end
end -- global cooldown
-- Re-emits the shared dispatch as an internal event carrying the spell id, so a
-- trigger reads the cast off the payload instead of re-polling for it.
local spellCastWatching = false
function WA.WatchSpellCast()
if spellCastWatching then return end
spellCastWatching = true
subscribePlayerCastSucceeded(function(spellId)
WA.ScanEvents("WA_SPELL_CAST_SUCCEEDED", spellId)
end)
end
-- Swing-timer watcher. ClassicAPI's PLAYER_SWING (arg1 seconds until the next
-- swing, arg2 Enum.PlayerSwingType) owns every timer reset: white hits with
-- extra attacks collapsed, on-next-swing abilities, Auto Shot / Shoot with the
-- wind-up taken off, spells that reset the swing, attack start, weapon swaps
-- and parry haste. What it does not cover stays here, ported from pfUI's
-- modules/swingtimer.lua: the Flurry rescale of an in-flight swing, the
-- Slam/Hammer of Wrath clip warning, and the queued on-next-swing ability. Each
-- hand is stored as {active,start,duration} the region animates, and the extra
-- state is surfaced as condition variables instead of a hardcoded bar tint.
do -- scoped; see the local-ceiling note in the file header
local SWING_PROC_LATENCY = 0.15
-- OctoWoW re-IDs Slam, so swing-delay casts are matched by NAME off
-- UNIT_SPELLCAST_START (rank/id-proof).
local SWING_DELAY_NAMES = { ["Slam"] = true, ["Hammer of Wrath"] = true }
-- Rank-1 canonical names (rank/locale-proof) for on-next-swing classification.
local HS_NAME = C_Spell.GetSpellName(78)
local CLEAVE_NAME = C_Spell.GetSpellName(845)
local MAUL_NAME = C_Spell.GetSpellName(6807)
local SWING_HANDS = { [0] = "main", [1] = "off", [2] = "ranged" }
local swing = {
main = { active = false, start = 0, duration = 0 },
off = { active = false, start = 0, duration = 0 },
ranged = { active = false, start = 0, duration = 0 },
}
local sw = {
mhSpeed = 0, ohSpeed = 0, raSpeed = 0,
mhBlocked = false, mhClipWarn = false, mhClipDelay = 0,
queued = "none", useSQE = false,
isWarrior = false, hsSlots = {}, cleaveSlots = {},
}
local swingFrame
local function swingUpdateSpeeds()
local ms, os = UnitAttackSpeed("player")
if ms and ms > 0 then sw.mhSpeed = ms end
sw.ohSpeed = (os and os > 0) and os or 0
local rs = UnitRangedDamage("player")
sw.raSpeed = (rs and rs > 0) and rs or 0
end
local function classifyOnSwing(spellId)
if not C_Spell.IsNextMeleeSpell(spellId) then return nil end
local name = C_Spell.GetSpellName(spellId)
if name and name == HS_NAME then return "hs"
elseif name and name == CLEAVE_NAME then return "cleave"
elseif name and name == MAUL_NAME then return "maul" end
return nil
end
-- Action-bar slot cache: queue detection fallback for builds without Nampower's
-- SPELL_QUEUE_EVENT. Warriors only (HS/Cleave); rebuilt on bar/equipment change.
local function rebuildQueueSlots()
sw.hsSlots = {}; sw.cleaveSlots = {}
if not sw.isWarrior then return end
for slot = 1, 120 do
local kind, id = GetActionInfo(slot)
local name
if kind == "spell" then name = GetSpellInfo(id)
elseif kind == "macro" and GetMacroSpell then name = GetMacroSpell(id) end
if name and name == HS_NAME then table.insert(sw.hsSlots, slot)
elseif name and name == CLEAVE_NAME then table.insert(sw.cleaveSlots, slot) end
end
end
local function anyCurrentAction(slots)
for i = 1, #slots do
if IsCurrentAction(slots[i]) then return true end
end
return false
end
-- The on-next-swing ability currently queued (hs/cleave/maul/none). Nampower's
-- SPELL_QUEUE_EVENT is authoritative once the build fires it (sw.useSQE); else
-- poll the cached action slots live.
function WA.SwingQueued()
if sw.useSQE then return sw.queued end
if not sw.isWarrior then return "none" end
if anyCurrentAction(sw.cleaveSlots) then return "cleave" end
if anyCurrentAction(sw.hsSlots) then return "hs" end
return "none"
end
-- A PLAYER_SWING reset. The event gives only the time left, which is shorter
-- than the weapon speed after parry haste or a ranged wind-up, so the bar keeps
-- the full speed and starts in the past rather than restarting from empty.
local function swingReset(hand, remaining)
swingUpdateSpeeds()
local speed = (hand == "off" and sw.ohSpeed) or (hand == "ranged" and sw.raSpeed) or sw.mhSpeed
local duration = (speed > remaining) and speed or remaining
local s = swing[hand]
s.start = GetTime() + remaining - duration; s.duration = duration; s.active = true
if hand == "main" then sw.mhBlocked = false; sw.mhClipWarn = false; sw.mhClipDelay = 0 end
WA.ScanEvents("WA_SWING_UPDATE")
end
-- Flurry: only a haste GAIN present at swing start (within the proc latency)
-- rescales the in-flight swing; preserve the elapsed fraction by shifting `start`
-- so (now-start)/newDur equals the old elapsed fraction.
local function swingRescale()
local now = GetTime()
local oldMh, oldOh = sw.mhSpeed, sw.ohSpeed
swingUpdateSpeeds()
local changed = false
local sm = swing.main
if sm.active and oldMh > 0 and sw.mhSpeed > 0 and sw.mhSpeed < oldMh
and (now - sm.start) < SWING_PROC_LATENCY then
local frac = (now - sm.start) / sm.duration
if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
sm.duration = sw.mhSpeed; sm.start = now - frac * sw.mhSpeed; changed = true
end
local so = swing.off
if so.active and oldOh > 0 and sw.ohSpeed > 0 and sw.ohSpeed < oldOh
and (now - so.start) < SWING_PROC_LATENCY then
local frac = (now - so.start) / so.duration
if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
so.duration = sw.ohSpeed; so.start = now - frac * sw.ohSpeed; changed = true
end
if changed then WA.ScanEvents("WA_SWING_UPDATE") end
end
-- A cast that does not reset the swing holds the main-hand auto until it ends;
-- the swing keeps ticking (one that resets it is PLAYER_SWING's). For a
-- named swing-delay cast (Slam / Hammer of Wrath) that will finish AFTER the
-- swing is ready, the auto is clipped by (castDur - remaining) -- surfaced as
-- clipWarning/clipDelay. Any other cast passes 0, which only sets the block.
local function swingBeginDelayCast(castDur)
if not swing.main.active then return end
sw.mhBlocked = true
if castDur > 0 then
local remaining = (swing.main.start + swing.main.duration) - GetTime()
local clip = castDur - remaining
if clip > 0 then sw.mhClipWarn = true; sw.mhClipDelay = clip end
end
WA.ScanEvents("WA_SWING_UPDATE")
end
local function swingClearDelayCast()
if sw.mhBlocked or sw.mhClipWarn then
sw.mhBlocked = false; sw.mhClipWarn = false; sw.mhClipDelay = 0
WA.ScanEvents("WA_SWING_UPDATE")
end
end
-- A landed on-next-swing ability is the queued one going off.
local function swingOnCast(spellId)
if sw.queued ~= "none" and C_Spell.IsNextMeleeSpell(spellId) then
sw.queued = "none"
WA.ScanEvents("WA_SWING_UPDATE")
end
end
function WA.WatchSwing()
if swingFrame then return end
local _, class = UnitClass("player")
sw.isWarrior = (class == "WARRIOR")
swingUpdateSpeeds()
rebuildQueueSlots()
swingFrame = CreateFrame("Frame")
local evs = { "PLAYER_SWING", "STOP_AUTOATTACK", "UNIT_ATTACK_SPEED",
"PLAYER_EQUIPMENT_CHANGED", "PLAYER_ENTERING_WORLD", "ACTIONBAR_SLOT_CHANGED",
"UNIT_SPELLCAST_START", "UNIT_SPELLCAST_STOP" }
for i = 1, #evs do swingFrame:RegisterEvent(evs[i]) end
-- Nampower's, so guarded: without it the action-slot poll answers instead.
pcall(swingFrame.RegisterEvent, swingFrame, "SPELL_QUEUE_EVENT")
swingFrame:SetScript("OnEvent", function()
if event == "PLAYER_SWING" then
local hand = SWING_HANDS[arg2]
if hand then swingReset(hand, arg1) end
elseif event == "UNIT_ATTACK_SPEED" then
if arg1 ~= "player" then return end
swingRescale()
elseif event == "UNIT_SPELLCAST_START" then
if arg1 ~= "player" or C_Spell.ResetsMeleeSwing(arg3) then return end
local castDur = 0
if SWING_DELAY_NAMES[arg4] then
local _, _, _, startMs, endMs = C_Spell.UnitCastingInfo("player")
castDur = ((endMs or 0) - (startMs or 0)) / 1000
end
swingBeginDelayCast(castDur)
elseif event == "UNIT_SPELLCAST_STOP" then
if arg1 ~= "player" then return end
swingClearDelayCast()
elseif event == "SPELL_QUEUE_EVENT" then
if arg1 == 0 then
sw.useSQE = true
local k = classifyOnSwing(arg2 or 0)
if k then sw.queued = k end
elseif arg1 == 1 then
sw.queued = "none"
end
WA.ScanEvents("WA_SWING_UPDATE")
elseif event == "STOP_AUTOATTACK" then
swing.main.active = false; swing.off.active = false
WA.ScanEvents("WA_SWING_UPDATE")
elseif event == "ACTIONBAR_SLOT_CHANGED" then
rebuildQueueSlots()
elseif event == "PLAYER_EQUIPMENT_CHANGED" or event == "PLAYER_ENTERING_WORLD" then
local _, cls = UnitClass("player")
sw.isWarrior = (cls == "WARRIOR")
swingUpdateSpeeds()
if sw.ohSpeed <= 0 then swing.off.active = false end
rebuildQueueSlots()
end
end)
subscribePlayerCastSucceeded(swingOnCast)
end
-- (active, start, duration, blocked, clipWarning, clipDelay, queuedAbility) or
-- false. blocked/clip/queued are main-hand-only concepts (false/0/"none" else).
function WA.SwingInfo(hand)
local s = swing[hand] or swing.main
if not s.active then return false end
if hand == "main" or hand == nil then
return true, s.start, s.duration, sw.mhBlocked, sw.mhClipWarn, sw.mhClipDelay, WA.SwingQueued()
end
return true, s.start, s.duration, false, false, 0, "none"
end
end -- swing timer
-- Power-tick watcher: no client event fires on the 2s energy / 5s mana regen
-- tick itself, so it's inferred from power-value increases, the same approach
-- pfUI's energytick module uses. Rage and focus have no regen tick.
do -- scoped; see the local-ceiling note in the file header
local POWER_TICK_INTERVAL = { [0] = 5, [3] = 2 }
local pt = {
active = false, start = 0, duration = 0,
lastPower = 0, powerType = 0, ignoreNextGain = false,
}
local powerTickFrame
local function powerTickResync()
pt.powerType = UnitPowerType("player")
pt.lastPower = UnitMana("player") or 0
if pt.active then
pt.active = false
WA.ScanEvents("WA_POWERTICK_UPDATE")
end
end
local function powerTickOnPowerEvent()
local newPower = UnitMana("player") or 0
local oldPower = pt.lastPower
pt.lastPower = newPower
-- In vanilla a spend does not reset the tick, so a decrease is a no-op.
if newPower < oldPower then return end
-- A spell gain (e.g. Thistle Tea) also raises power but isn't a tick; the
-- combat log's energize entry below flags the one that follows so it doesn't
-- restart the clock. Consumed on any non-decrease, not just a strict
-- increase, so a gain landing at already-full power doesn't leave the flag
-- to swallow the next real tick.
if pt.ignoreNextGain then
pt.ignoreNextGain = false
return
end
if newPower <= oldPower then return end
local interval = POWER_TICK_INTERVAL[pt.powerType]
local max = UnitManaMax("player") or 0
local full = max > 0 and newPower >= max
if not interval or full then
if pt.active then pt.active = false; WA.ScanEvents("WA_POWERTICK_UPDATE") end
return
end
pt.start = GetTime(); pt.duration = interval; pt.active = true
WA.ScanEvents("WA_POWERTICK_UPDATE")
end
function WA.WatchPowerTick()
if powerTickFrame then return end
powerTickResync()
powerTickFrame = CreateFrame("Frame")
local evs = { "UNIT_MANA", "UNIT_ENERGY", "UNIT_RAGE", "UNIT_DISPLAYPOWER",
"PLAYER_ENTERING_WORLD", "COMBAT_LOG_EVENT_UNFILTERED" }
for i = 1, #evs do powerTickFrame:RegisterEvent(evs[i]) end
powerTickFrame:SetScript("OnEvent", function()
if event == "UNIT_DISPLAYPOWER" or event == "PLAYER_ENTERING_WORLD" then
if arg1 and arg1 ~= "player" then return end
powerTickResync()
elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
-- arg8 destGUID, arg16 powerType. The energize log arrives ahead of
-- the power update, as the chat line it replaced did.
if (arg2 == "SPELL_ENERGIZE" or arg2 == "SPELL_PERIODIC_ENERGIZE")
and arg16 == pt.powerType and arg8 == UnitGUID("player") then
pt.ignoreNextGain = true
end
else
if arg1 and arg1 ~= "player" then return end
powerTickOnPowerEvent()
end
end)
end
-- (active, start, duration) or false. Rolls the window forward to the current
-- tick boundary if it expired with no power event to advance it (a tick that
-- fired while capped-and-spent, or one the client didn't report).
function WA.PowerTickInfo()
if not pt.active then return false end
local now = GetTime()
if pt.duration > 0 then
while now >= pt.start + pt.duration do
pt.start = pt.start + pt.duration
end
end
return true, pt.start, pt.duration
end
end -- power tick
-- ---------------------------------------------------------------------------
-- Event dispatch (§4.3) + the shared game-event frame
-- ---------------------------------------------------------------------------
local eventFrame = CreateFrame("Frame")
local registered = {} -- event -> true, so we RegisterEvent each game event once
local function ensureEventRegistered(event)
if registered[event] or isInternalEvent(event) then return end
registered[event] = true
local compat = EVENT_COMPAT[event]
if compat then
for i = 1, #compat do
pcall(eventFrame.RegisterEvent, eventFrame, compat[i])
end
return
end
-- Guarded: an invalid event name on this build would otherwise error out the
-- whole Add (risk (c)).
pcall(eventFrame.RegisterEvent, eventFrame, event)
end
-- The same registration for a consumer outside this file: a Custom Check
-- condition names its own events, and they have to arrive through the shared
-- frame above rather than through one of the listener's own. A second frame
-- registered for the same name would deliver the event twice -- once to each --
-- so everything that wants a game event asks here and reads it off WA.ScanEvents.
-- An internal WA_* name is accepted and registers nothing, which is correct: it
-- reaches ScanEvents already.
function WA.EnsureGameEventRegistered(event)
if type(event) ~= "string" or event == "" then return end
ensureEventRegistered(event)
end
-- The per-frame pulse behind `check = "update"`. Its own frame because an
-- OnUpdate on the shared event frame would run for every display in the addon,
-- and because hiding a frame is how an OnUpdate is switched off -- there is no
-- way to unregister one.
local frameUpdateFrame
-- Runs the OnUpdate iff some loaded trigger listens for it. 1.12 hands the
-- handler its elapsed time as the global arg1.
local function syncFrameUpdate()
local map = loaded_events["FRAME_UPDATE"]
local any = false
if map then
for _ in pairs(map) do any = true; break end
end
if not any then
if frameUpdateFrame then frameUpdateFrame:Hide() end
return
end
if not frameUpdateFrame then
frameUpdateFrame = CreateFrame("Frame")
frameUpdateFrame:SetScript("OnUpdate", function()
WA.ScanEvents("FRAME_UPDATE", arg1)
end)
end
frameUpdateFrame:Show()
end
-- Whether this trigger accepts one payload of a unit-filtered event. The filter
-- is the local stand-in for upstream's RegisterUnitEvent: the general event is
-- registered, so an unwanted unit has to be dropped here instead of never
-- arriving. Events the trigger declared no filter for always pass.
local function passesUnitFilter(ti, event, unit)
local filters = ti.unitFilters
local set = filters and filters[event]
if not set then return true end
if unit == nil then return false end
return set[string.lower(tostring(unit))] and true or false
end
-- The same for "CLEU:SUBEVENT": the combat log's subevent is its arg2.
local function passesSubeventFilter(ti, event, subevent)
local set = ti.subeventFilter
if not set or event ~= "COMBAT_LOG_EVENT_UNFILTERED" then return true end
return subevent ~= nil and set[subevent] and true or false
end
-- A trigger asking for FRAME_UPDATE at every frame is affordable only because it
-- can ask for less. Upstream's onUpdateThrottle, in seconds; 0 means every frame.
local function checkOnUpdateThrottle(ti)
local throttle = ti.onUpdateThrottle
if not throttle or throttle <= 0 then return true end
local now = GetTime()
if not ti.lastOnUpdate or (now - ti.lastOnUpdate) >= throttle then
ti.lastOnUpdate = now
return true
end
return false
end
-- watchedTriggers[id][watchedNum][observerNum]: observerNum's "TRIGGER:watchedNum"
-- subscription. Pending/timers hold one coalesced delivery per display.
local watchedTriggers = {}
local watchedPending = {}
local watchedTimers = {}
-- Records one trigger's TRIGGER:n subscriptions, refusing a reciprocal pair.
-- Two triggers watching each other would hand the delivery back and forth
-- forever; upstream drops the second half of the pair, and which half that is
-- falls out of compile order.
local function registerWatchedTriggers(id, observerNum, requested)
local byWatched = watchedTriggers[id]
for num in pairs(requested) do
if num ~= observerNum
and not (byWatched and byWatched[observerNum] and byWatched[observerNum][num]) then
byWatched = byWatched or {}
watchedTriggers[id] = byWatched
byWatched[num] = byWatched[num] or {}
byWatched[num][observerNum] = true
end
end
end
local function cancelWatchedDelivery(id)
if watchedTimers[id] then
watchedTimers[id]:Cancel()
watchedTimers[id] = nil
end
watchedPending[id] = nil
end
-- A fire-and-forget trigger takes itself down on a timer instead of on a later
-- event. C_Timer.NewTimer, not After: After returns nothing on this client, and
-- a re-fire before the deadline must retract the pending hide or the second show
-- gets cut short by the first one's timer.
local function scheduleAutoHide(ti, cloneId)
cloneId = cloneId or ""
ti.hideTimers = ti.hideTimers or {}
local old = ti.hideTimers[cloneId]
if old then old:Cancel() end
local timer
local function hideState()
if not ti.hideTimers or ti.hideTimers[cloneId] ~= timer then return end
ti.hideTimers[cloneId] = nil
local states = WA.GetTriggerStateForTrigger(ti.id, ti.triggernum)
local s = states and states[cloneId]
if s and s.show then
s.show = false; s.changed = true
WA.UpdatedTriggerState(ti.id)
end
end
timer = C_Timer.NewTimer(ti.duration, hideState)
ti.hideTimers[cloneId] = timer
end
-- The state is a parameter rather than an upvalue so one function serves every
-- call: a closure per passing event was the commonest garbage in dispatch.
local function setC(state, k, v)
if state[k] ~= v then state[k] = v; state.changed = true end
end
-- ActivateEvent-lite (§4.3): after a passing test, normalize the state's
-- progress/name/icon. Stores already wrote the raw matched fields; this fills
-- the display-shaped fields regions read. `optionsEvent` marks the options
-- preview's synthetic run, which must not arm the real hide timer -- a preview
-- that took itself down a second after being selected would be unusable.
local function activateEvent(state, ti, cloneId, optionsEvent)
local proto = ti.proto
if not state.show then state.show = true; state.changed = true end
if ti.autoHide then
-- Its progress is the countdown to its own hide timer, not anything the
-- prototype stores, and every re-fire restarts both together.
setC(state, "progressType", "timed")
setC(state, "duration", ti.duration)
setC(state, "expirationTime", GetTime() + ti.duration)
if not optionsEvent then scheduleAutoHide(ti, cloneId) end
elseif ti.customDurationFunc then
local ok, v1, v2, isStatic, inverse = WA.RunAuraFunc4(ti.id, ti.id .. ": duration function",
ti.customDurationFunc, ti.trigger)
local first = ok and tonumber(v1) or 0
local second = ok and tonumber(v2) or 0
setC(state, "inverse", ok and inverse or nil)
if ok and isStatic then
setC(state, "progressType", "static")
setC(state, "value", first)
setC(state, "total", second)
setC(state, "duration", nil)
setC(state, "expirationTime", nil)
else
if second <= first then second = first end
setC(state, "progressType", "timed")
setC(state, "duration", first)
setC(state, "expirationTime", second)
setC(state, "value", nil)
setC(state, "total", nil)
end
else
-- ti.* overrides let a per-trigger prototype (the custom trigger) pick its
-- progress shape at Add time; built-in prototypes leave them nil and fall back
-- to the static proto fields.
local progressType = ti.progressType or proto.progressType
local progressValue = ti.progressValue or proto.progressValue
local progressTotal = ti.progressTotal or proto.progressTotal
if progressType == "timed" then
setC(state, "progressType", "timed")
elseif progressType == "static" then
setC(state, "progressType", nil)
setC(state, "value", state[progressValue] or 0)
setC(state, "total", state[progressTotal] or 0)
else
setC(state, "progressType", nil)
end
end
-- ti.name/ti.icon are compile-time constants (nameFunc/iconFunc run once at
-- Add). A prototype that stores a live `name`/`icon` field (e.g. health/power
-- reading UnitName each tick) has already written state; only fall back to the
-- constant when it didn't -- which also keeps a target-unit's display name live
-- instead of frozen at the target present when the aura was compiled.
if ti.name ~= nil and state.name == nil then setC(state, "name", ti.name) end
if ti.icon ~= nil and state.icon == nil then setC(state, "icon", ti.icon) end
if ti.customNameFunc then
local ok, value = WA.RunAuraFunc(ti.id, ti.id .. ": name function",
ti.customNameFunc, ti.trigger)
if ok then setC(state, "name", value) end
end
if ti.customIconFunc then
local ok, value = WA.RunAuraFunc(ti.id, ti.id .. ": icon function",
ti.customIconFunc, ti.trigger)
if ok then setC(state, "icon", value) end
end
end
-- One member of a multi-unit family: created, refreshed, or dropped in place.
local function runMultiUnitMember(ti, states, event, unit, cloneId)
local optionsEvent = event == "OPTIONS"
local state = states[cloneId]
local existed = state ~= nil
if not state then state = {} end
local ok, passed = WA.RunAuraFunc(ti.id, ti.id, ti.triggerFunc, state, event, unit)
if (ok and passed) or optionsEvent then
if not existed then
states[cloneId] = state
state.changed = true
end
activateEvent(state, ti, cloneId, optionsEvent)
return state.changed and true or false
elseif existed then
states[cloneId] = nil
return true
end
return false
end
-- Every current member re-read, and every state whose member is gone dropped.
local function runMultiUnitFullPass(ti, states, event)
local seen = WA.AcquireScratch()
local dirty = false
WA.ForEachMultiUnit(ti.multiUnit, function(unit, cloneId)
seen[cloneId] = true
if runMultiUnitMember(ti, states, event, unit, cloneId) then dirty = true end
end)
-- Clearing a field mid-traversal is the one table edit `pairs` permits.
for cloneId in pairs(states) do
if cloneId ~= "" and not seen[cloneId] then
states[cloneId] = nil
dirty = true
end
end
WA.ReleaseScratch(seen)
return dirty
end
-- Which event means a member left for good. A leaving plate's token still
-- resolves to its unit for the duration of the handler (ClassicAPI computes the
-- token before the slot vacates), so rescanning the family here would re-create
-- the clone that just went away -- its state has to be dropped by GUID instead.
local MULTI_UNIT_REMOVE_EVENT = { nameplate = "NAME_PLATE_UNIT_REMOVED" }
-- Routes one event to the family: a removal drops one state, a unit event
-- re-reads only the member it names, and anything else (roster churn, forced
-- initialization) re-reads the whole family and sweeps what is no longer in it.
local function runMultiUnitTrigger(ti, event, unit)
local states = WA.GetTriggerStateForTrigger(ti.id, ti.triggernum)
if not states then return false end
local family = ti.multiUnit
if event == MULTI_UNIT_REMOVE_EVENT[family] then
local cloneId = type(unit) == "string" and WA.UnitCloneId(unit) or nil
if cloneId and states[cloneId] ~= nil then
states[cloneId] = nil
return true
end
return false
end
if type(unit) == "string" and UnitExists(unit) then
local cloneId = WA.UnitCloneId(unit)
local state = cloneId and states[cloneId]
-- One unit is reachable through several tokens at once (the player in a
-- raid is also raid7, and maybe target), and ClassicAPI fires the unit
-- event once per token. The state keeps the token its own family
-- iterates rather than adopting whichever token woke it.
if state then return runMultiUnitMember(ti, states, event, state.unit or unit, cloneId) end
if WA.MultiUnitHasToken(family, unit) then
return runMultiUnitMember(ti, states, event, unit, cloneId)
end
return false
end
return runMultiUnitFullPass(ti, states, event)
end
-- A combat log payload runs to 24 values (a damage event's critical flag is the
-- 21st), wider than the nine this dispatch carries. Upstream hands trigger code
-- CombatLogGetCurrentEventInfo() instead, which answers for as long as the
-- event's handler runs -- and every trigger runs inside it. The target is staged
-- in an upvalue for the reason safecall's arguments are: no user code runs
-- between the write and the call that reads it.
--
-- A chat event is wider too: ClassicAPI appends the line ID (arg11) and the
-- sender's GUID (arg12), upstream's layout. GetCurrentChatEvent() hands over all
-- twelve, but only while that event's handler runs, so it is used only when the
-- event in progress is the one being dispatched. A ScanEvents call naming a chat
-- event from anywhere else keeps the payload its caller passed.
--
-- The call is fn(state, event, ...) when `state` is given (a prototype test, a
-- TSU's allstates, a legacy trigger's state), else fn(event, ...).
local runTriggerCode
do
local cleuTarget
local function cleuWithState(state, event)
return cleuTarget(state, event, CombatLogGetCurrentEventInfo())
end
local function cleuNoState(event)
return cleuTarget(event, CombatLogGetCurrentEventInfo())
end
local chatTarget
local function chatWithState(state, event)
return chatTarget(state, event, select(2, GetCurrentChatEvent()))
end
local function chatNoState(event)
return chatTarget(event, select(2, GetCurrentChatEvent()))
end
-- Memoised per name: this runs for every trigger on every dispatch,
-- FRAME_UPDATE included, and a string.sub per call is garbage.
local isChatEvent = setmetatable({}, { __index = function(t, name)
local v = string.sub(name, 1, 9) == "CHAT_MSG_"
t[name] = v
return v
end })
function runTriggerCode(ti, errTag, fn, state, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
if event == "COMBAT_LOG_EVENT_UNFILTERED" or event == "COMBAT_LOG_EVENT" then
cleuTarget = fn
if state then return WA.RunAuraFunc(ti.id, errTag, cleuWithState, state, event) end
return WA.RunAuraFunc(ti.id, errTag, cleuNoState, event)
end
if isChatEvent[event] and GetCurrentChatEvent() == event then
chatTarget = fn
if state then return WA.RunAuraFunc(ti.id, errTag, chatWithState, state, event) end
return WA.RunAuraFunc(ti.id, errTag, chatNoState, event)
end
if state then
return WA.RunAuraFunc(ti.id, errTag, fn, state, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
end
return WA.RunAuraFunc(ti.id, errTag, fn, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
end
end
-- Runs a Trigger State Updater over its whole allstates table (upstream's `full`
-- statesParameter, §4.3): the custom function owns every clone key and reports
-- either by returning truthy or through the helper methods. Nothing here shapes
-- the states afterwards -- a TSU state supplies its own progress, name and icon,
-- which is the difference between this and activateEvent's normalization.
local function runTsuTrigger(ti, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
local states = WA.GetTriggerStateForTrigger(ti.id, ti.triggernum)
if not states then return false end
-- Per run, not once at compile: WA.Add builds a fresh allstates table on every
-- recompile, so the helpers have to be re-attached to whatever table is live.
WA.EnsureAllStates(states, ti.showNilIsFalse)
states:SetChanged(false)
local ok, returned = runTriggerCode(ti, ti.id, ti.triggerFunc, states, event,
a1, a2, a3, a4, a5, a6, a7, a8, a9)
local dirty = (ok and (returned or (returned ~= false and states:IsChanged()))) and true or false
states:SetChanged(false)
-- Every consumer downstream indexes a clone key expecting a state table. One
-- non-table poisons the whole trigger rather than one clone, so upstream drops
-- the table entirely instead of guessing which key the author meant.
local bad
for key, state in pairs(states) do
if type(state) ~= "table" then bad = key; break end
end
local allstatesKey = "allstates:" .. ti.triggernum
if bad then
WA.ReportForAura(ti.id, allstatesKey, "error", "[" .. ti.id
.. "] all states table contains a non-table at key: " .. tostring(bad), true)
local keys = {}
for key in pairs(states) do table.insert(keys, key) end
for i = 1, #keys do states[keys[i]] = nil end
WA.StopStateTimers(ti.id, ti.triggernum)
return true
end
WA.ReportForAura(ti.id, allstatesKey)
-- Coerced rather than refused, which is upstream's StateShowNil: a state the
-- author never set `show` on is hidden, and saying so is the only way they find
-- out why. Never printed -- a trigger can leave it nil on one run and set it on
-- the next, so a chat line would come and go with the states.
local showWasNil
if ti.showNilIsFalse then
for _, state in pairs(states) do
if state.show == nil then state.show = false; showWasNil = true end
end
end
WA.ReportForAura(ti.id, "shownil:" .. ti.triggernum, showWasNil and "warning" or nil,
showWasNil and ("[" .. ti.id .. "] a custom trigger leaves `show` unset on a state, which reads as hidden.") or nil)
WA.StartStopStateTimers(ti.id, ti.triggernum, states)
return dirty
end
-- Runs one trigger's compiled function for an event and reconciles its base
-- state or one new event clone.
-- Returns true if the state changed (so the caller batches UpdatedTriggerState).
local function runTriggerFunc(ti, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
if ti.tsu then return runTsuTrigger(ti, event, a1, a2, a3, a4, a5, a6, a7, a8, a9) end
if ti.multiUnit then return runMultiUnitTrigger(ti, event, a1) end
local states = WA.GetTriggerStateForTrigger(ti.id, ti.triggernum)
if not states then return false end
-- The options preview's synthetic event (§14). A test that fails on it says
-- nothing -- there is no payload to read and no reason for the world to be in
-- the state being configured -- so the state is activated regardless and the
-- prototype's own name/icon/progress fill it in. That is what makes a preview
-- show the trigger's real fields instead of an invented set.
local optionsEvent = event == "OPTIONS"
local cloneId = ""
local state
if ti.useCloneId then
state = {}
else
state = states[""]
if not state then state = {}; states[""] = state end
end
-- state.changed is false on entry (UpdatedTriggerState clears it after every
-- batch, step 7), so the flag the compiled stores / activateEvent set is a
-- true "changed since last apply" signal.
local ok, passed
if ti.custom and not ti.legacyStateArgs then
ok, passed = runTriggerCode(ti, ti.id, ti.triggerFunc, nil, event,
a1, a2, a3, a4, a5, a6, a7, a8, a9)
else
ok, passed = runTriggerCode(ti, ti.id, ti.triggerFunc, state, event,
a1, a2, a3, a4, a5, a6, a7, a8, a9)
end
if (ok and passed) or optionsEvent then
if ti.useCloneId then
cloneId = WA.GetUniqueCloneId(states)
if cloneId == nil then return false end
states[cloneId] = state
end
activateEvent(state, ti, cloneId, optionsEvent)
elseif ok and ti.untriggerFunc then
local untriggerOk, shouldHide
untriggerOk, shouldHide = runTriggerCode(ti, ti.id .. ": untrigger", ti.untriggerFunc,
ti.legacyStateArgs and state or nil, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
if untriggerOk and shouldHide and state.show then
state.show = false; state.changed = true
end
elseif state.show and not ti.autoHide and not ti.eventMode then
-- Event states end through their declared hide path. A status state follows
-- its test result directly.
state.show = false; state.changed = true
end
return state.changed and true or false
end
-- Delivers the coalesced TRIGGER:n updates one display accumulated, as
-- ("TRIGGER", watchedTriggernum, thatTrigger'sStates).
local function flushWatchedTriggers(id)
watchedTimers[id] = nil
local pending = watchedPending[id]
watchedPending[id] = nil
local byWatched = watchedTriggers[id]
local byTrigger = events[id]
if not pending or not byWatched or not byTrigger or WA.forced[id] then return end
local dirty = false
for watchedNum in pairs(pending) do
local observers = byWatched[watchedNum]
local updated = WA.GetTriggerStateForTrigger(id, watchedNum)
if observers and updated then
for observerNum in pairs(observers) do
local ti = byTrigger[observerNum]
if ti and runTriggerFunc(ti, "TRIGGER", watchedNum, updated) then
dirty = true
-- An observer that changed is itself worth watching, so a chain
-- of TRIGGER:n subscriptions carries one hop per delivery. The
-- timer for the next hop is scheduled fresh, this one having
-- already released its slot above.
WA.NotifyWatchedTriggers(id, observerNum)
end
end
end
end
if dirty then WA.UpdatedTriggerState(id) end
end
-- One of a display's triggers produced new states; anything watching it hears so
-- on a zero-delay timer rather than inline. The delay is the recursion guard that
-- matters: a watcher runs after the state pass that woke it has finished, so it
-- cannot re-enter it, and several updates in one pass collapse into one delivery.
-- Called by the state-owned hide timers as well as by dispatch.
function WA.NotifyWatchedTriggers(id, triggernum)
local byWatched = watchedTriggers[id]
if not byWatched or not byWatched[triggernum] then return end
watchedPending[id] = watchedPending[id] or {}
watchedPending[id][triggernum] = true
if watchedTimers[id] then return end
watchedTimers[id] = C_Timer.NewTimer(0, function() flushWatchedTriggers(id) end)
end
-- One registered name's subscribers, delivered under that name. `event` is the
-- name the trigger listed, which is also what passesUnitFilter keys its
-- per-event unit filters by.
-- `dirty` is taken from the scratch pool on the first hit and passed back,
-- because most scans change nothing -- FRAME_UPDATE arrives once a frame.
local function scanRegistered(event, dirty, a1, a2, a3, a4, a5, a6, a7, a8, a9)
local byId = loaded_events[event]
if not byId then return dirty end
for id, byTrigger in pairs(byId) do
for triggernum, ti in pairs(byTrigger) do
if not WA.forced[id] and passesUnitFilter(ti, event, a1)
and passesSubeventFilter(ti, event, a2)
and (event ~= "FRAME_UPDATE" or checkOnUpdateThrottle(ti)) then
if runTriggerFunc(ti, event, a1, a2, a3, a4, a5, a6, a7, a8, a9) then
dirty = dirty or WA.AcquireScratch()
dirty[id] = true
WA.NotifyWatchedTriggers(id, triggernum)
end
end
end
end
return dirty
end
local function updateDirty(dirty)
for id in pairs(dirty) do WA.UpdatedTriggerState(id) end
end
-- The producer entry point watchers and the event frame both call (§4.3):
-- walk every trigger registered for `event`, run it, batch one
-- UpdatedTriggerState per affected display. A client event that stands in for
-- retail names (EVENT_COMPAT) also wakes their subscribers, each handed the
-- name it listed rather than the one the client fired.
function WA.ScanEvents(event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
local dirty = scanRegistered(event, nil, a1, a2, a3, a4, a5, a6, a7, a8, a9)
local compat = EVENT_COMPAT_SOURCES[event]
if compat then
for i = 1, #compat do
dirty = scanRegistered(compat[i], dirty, a1, a2, a3, a4, a5, a6, a7, a8, a9)
end
end
if dirty then
WA.RunRelayoutBatch(updateDirty, dirty)
WA.ReleaseScratch(dirty)
end
end
-- A handler on this client reads the event payload off the globals arg1..arg9,
-- which exist only for the duration of the handler -- so they are read here and
-- passed explicitly rather than left for ScanEvents to pick up.
eventFrame:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" then
C_Timer.After(1, function() WA.ScanEvents("WA_DELAYED_PLAYER_ENTERING_WORLD") end)
end
WA.ScanEvents(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
end)
-- PLAYER_ENTERING_WORLD is the source for the delayed status refresh, so it
-- must be observed even when no active prototype lists the native event.
ensureEventRegistered("PLAYER_ENTERING_WORLD")
-- ---------------------------------------------------------------------------
-- Trigger-system contract (StateMachine.lua calls these)
-- ---------------------------------------------------------------------------
-- Pulls a display's tis out of loaded_events (it stops receiving events) but
-- leaves events[id] intact -- Unload uses this, Delete drops events[id] after.
local function unregisterEvents(id)
local byTrigger = events[id]
if not byTrigger then return end
-- Pending state-owned hides and watched-trigger deliveries, on the same
-- grounds as the per-trigger hide timers below.
cancelWatchedDelivery(id)
WA.StopStateTimers(id)
for triggernum, ti in pairs(byTrigger) do
-- A pending hide outlives the events that scheduled it, and would fire
-- against a display that has since unloaded or been recompiled.
if ti.hideTimers then
for _, timer in pairs(ti.hideTimers) do
if timer then timer:Cancel() end
end
ti.hideTimers = nil
end
local evs = ti.eventList or {}
for i = 1, #evs do
local token = unitChangeToken(evs[i])
if token then WA.UnwatchUnitToken(token) end
local map = loaded_events[evs[i]]
if map and map[id] then map[id][triggernum] = nil end
end
end
-- Drop now-empty per-event maps so ScanEvents doesn't walk dead ids.
for event, map in pairs(loaded_events) do
if map[id] then
local any = false
for _ in pairs(map[id]) do any = true; break end
if not any then map[id] = nil end
end
end
syncFrameUpdate()
end
-- Compile only: build each triggernum into a ti stored in events[id]. Neither
-- registers into loaded_events nor force-initializes status triggers -- both
-- wait for LoadDisplays (§11), so an unloaded display costs no events.
function GenericTrigger.Add(data)
-- Kept across the Delete below so a trigger whose compiled source is
-- unchanged can be told from one that was edited: the second is a seam for
-- dropping whatever the old code cached in aura_env, and WA.Add is not --
-- it fires per drag step.
local prev = events[data.id] or lastCompiled[data.id]
GenericTrigger.Delete(data.id)
local byTrigger = {}
events[data.id] = byTrigger
unresolvedIds[data.id] = nil
WA.ClearWarningPrefix(WA.WarningUidFor(data.id), "unresolved:")
compilingId = data.id
for triggernum = 1, #data.triggers do
local trigger = WA.GetTrigger(data, triggernum)
local proto = trigger and PROTOTYPES[trigger.type]
if proto then
local fn, source, sourceKey, untriggerFunc, customDurationFunc, customNameFunc, customIconFunc
local tsuVariablesFunc, customEvents, customUnitFilters, customWatched, customSubevents
local errTag = data.id .. ": trigger " .. triggernum
local isTsu = proto.custom and trigger.custom_type == "stateupdate"
if proto.custom then
fn, source = constructCustomFunction(trigger, errTag)
local entry = data.triggers[triggernum]
local untriggerSource = entry.untrigger and entry.untrigger.custom or ""
-- A TSU never reads the untrigger, so its text must not reach
-- sourceKey either -- editing code nothing runs is not an edit.
if isTsu then untriggerSource = "" end
if WA.TriggerCodeIsLive(trigger, "customVariables") then
tsuVariablesFunc = compileTsuVariables(trigger.customVariables,
data.id .. ": custom variables " .. triggernum)
end
if WA.TriggerCodeIsLive(trigger, "untrigger") then
untriggerFunc = compileCustomField(untriggerSource,
data.id .. ": untrigger " .. triggernum) or trueFunction
end
if WA.TriggerCodeIsLive(trigger, "customDuration") then
customDurationFunc = compileCustomField(trigger.customDuration,
data.id .. ": duration function " .. triggernum)
end
if WA.TriggerCodeIsLive(trigger, "customName") then
customNameFunc = compileCustomField(trigger.customName,
data.id .. ": name function " .. triggernum)
end
if WA.TriggerCodeIsLive(trigger, "customIcon") then
customIconFunc = compileCustomField(trigger.customIcon,
data.id .. ": icon function " .. triggernum)
end
if (trigger.custom_type == "status" or isTsu) and trigger.check == "update" then
customEvents = { "FRAME_UPDATE" }
else
customEvents, customUnitFilters, customWatched, customSubevents = parseCustomEventList(trigger.events)
end
if customWatched then
registerWatchedTriggers(data.id, triggernum, customWatched)
end
sourceKey = table.concat({ source or "", untriggerSource,
trigger.customDuration or "", trigger.customName or "", trigger.customIcon or "",
trigger.customVariables or "" }, "\n")
else
fn, source = constructFunction(proto, trigger, errTag)
end
sourceKey = sourceKey or source
local prevTi = prev and prev[triggernum]
if not prevTi or (prevTi.sourceKey or prevTi.source) ~= sourceKey then
WA.ClearAuraEnv(data.id)
end
if fn then
local ti = {
id = data.id, triggernum = triggernum, proto = proto,
trigger = trigger, triggerFunc = fn, source = source, sourceKey = sourceKey,
eventMode = (proto.eventMode or (proto.custom and trigger.custom_type == "event")) and true or false,
useCloneId = (proto.eventMode and trigger.use_cloneId) and true or false,
multiUnit = proto.statesParameter == "unit" and WA.MultiUnitFamily(trigger) or nil,
eventList = customEvents or proto.events(trigger),
name = proto.nameFunc and proto.nameFunc(trigger) or nil,
icon = proto.iconFunc and proto.iconFunc(trigger) or proto.icon,
forceEvents = proto.force_events and not (proto.custom and trigger.custom_type == "event"),
}
if proto.custom then
ti.custom = true
ti.unitFilters = customUnitFilters
ti.subeventFilter = customSubevents
ti.onUpdateThrottle = tonumber(trigger.onUpdateThrottle) or 0
ti.legacyStateArgs = trigger.weakestAurasLegacyStateArgs and true or false
if isTsu then
ti.tsu = true
ti.tsuVariablesFunc = tsuVariablesFunc
-- Upstream's saved flag for the contract where a state the code
-- built without setting `show` is hidden rather than left
-- undecided. An import predating the flag does not carry it and
-- keeps the older reading; the editor stamps it on an aura that
-- picks this mode with no `information` of its own.
ti.showNilIsFalse = (data.information and data.information.showNilIsFalse) and true or false
else
ti.untriggerFunc = untriggerFunc
ti.customDurationFunc = customDurationFunc
ti.customNameFunc = customNameFunc
ti.customIconFunc = customIconFunc
ti.progressType = trigger.customProgressType or "none"
ti.progressValue = "value"
ti.progressTotal = "total"
if trigger.custom_type == "event" and trigger.custom_hide == "timed" then
ti.autoHide = true
ti.duration = tonumber(trigger.duration) or 1
end
end
end
if proto.autoHide then
ti.autoHide = true
ti.duration = tonumber(trigger.duration) or 1
end
if proto.subjectUnit and not ti.multiUnit then
ti.unitFilters = subjectUnitFilters(ti.eventList, proto.subjectUnit(trigger))
end
-- A prototype listening to the combat log names the subevents it
-- wants, and dispatch drops the rest before the compiled test runs
-- -- the same filter a custom trigger's CLEU:SUBEVENT sets.
if proto.subevents then ti.subeventFilter = proto.subevents(trigger) end
byTrigger[triggernum] = ti
end
end
end
compilingId = nil
end
-- Load: register this display's tis into loaded_events (+ RegisterEvent their
-- game events), fire watcher loadFuncs, then force-initialize status triggers
-- (§4.3 force_events) and recombine once. Only touches ids this system
-- compiled (events[id]).
function GenericTrigger.LoadDisplays(ids)
for k = 1, #ids do
local id = ids[k]
local byTrigger = events[id]
if byTrigger and not activeIds[id] then
activeIds[id] = true
local toForce = {}
for triggernum, ti in pairs(byTrigger) do
for i = 1, #ti.eventList do
local ev = ti.eventList[i]
ensureEventRegistered(ev)
-- The occupancy watch is refcounted against the same list
-- loaded_events is keyed by, rather than started from a loadFunc:
-- unregisterEvents below walks this list too, so the release is
-- paired by construction instead of by a second lifecycle kept in
-- step with this one.
local token = unitChangeToken(ev)
if token then WA.WatchUnitToken(token) end
loaded_events[ev] = loaded_events[ev] or {}
loaded_events[ev][id] = loaded_events[ev][id] or {}
loaded_events[ev][id][triggernum] = ti
end
-- loadFunc (e.g. WatchSpellCooldown) is a "start watching" side
-- effect, so it belongs to load, not compile -- an unloaded
-- cooldown aura shouldn't spin up its watcher.
if ti.proto.loadFunc then ti.proto.loadFunc(ti.trigger) end
if ti.forceEvents then table.insert(toForce, ti) end
end
if #toForce > 0 then
for i = 1, #toForce do
local ti = toForce[i]
if runTriggerFunc(ti, ti.tsu and "STATUS" or "FORCE") then
WA.NotifyWatchedTriggers(id, ti.triggernum)
end
end
WA.UpdatedTriggerState(id)
end
end
end
syncFrameUpdate()
end
-- Re-seed an already-loaded display's status triggers (WA2's
-- ScanWithFakeEvent): same force_events pass LoadDisplays runs, for callers that
-- wiped the produced states without a load transition -- the options preview
-- retiring its fake, which otherwise leaves a still-true status trigger reading
-- as inactive until its next real event.
function GenericTrigger.ForceUpdate(ids)
for k = 1, #ids do
local id = ids[k]
local byTrigger = events[id]
if byTrigger and activeIds[id] then
local dirty = false
for triggernum, ti in pairs(byTrigger) do
if ti.forceEvents then
if runTriggerFunc(ti, ti.tsu and "STATUS" or "FORCE") then
dirty = true
WA.NotifyWatchedTriggers(id, triggernum)
end
end
end
if dirty then WA.UpdatedTriggerState(id) end
end
end
end
-- Unload: pull the display's tis from loaded_events, keeping events[id] so a
-- reload re-registers without recompiling.
function GenericTrigger.UnloadDisplays(ids)
for k = 1, #ids do
local id = ids[k]
if activeIds[id] then
activeIds[id] = nil
unregisterEvents(id)
end
end
end
function GenericTrigger.Delete(id)
GenericTrigger.UnloadDisplays({ id })
-- Handed to the next Add so it can still tell an edited trigger from an
-- untouched one. WA.Add deletes every system before it re-adds them, so by
-- the time Add runs, events[id] is already gone and the comparison it makes
-- would otherwise always report "changed" -- which drops aura_env on every
-- recompile, including the ones WA.Add fires per drag step.
if events[id] then lastCompiled[id] = events[id] end
events[id] = nil
-- Rebuilt from scratch by the next Add, which is also what makes the
-- reciprocal-watch refusal depend only on this compile's own order.
watchedTriggers[id] = nil
unresolvedIds[id] = nil
WA.ClearWarningPrefix(WA.WarningUidFor(id), "unresolved:")
cancelWatchedDelivery(id)
WA.StopStateTimers(id)
end
function GenericTrigger.Rename(oldId, newId)
local byTrigger = events[oldId]
if not byTrigger then return end
for triggernum, ti in pairs(byTrigger) do
ti.id = newId
local evs = ti.eventList or {}
for i = 1, #evs do
local map = loaded_events[evs[i]]
if map and map[oldId] then
map[newId] = map[oldId]
map[oldId] = nil
end
end
end
events[newId] = byTrigger
events[oldId] = nil
lastCompiled[oldId] = nil
if unresolvedIds[oldId] then
unresolvedIds[newId] = unresolvedIds[oldId]
unresolvedIds[oldId] = nil
end
if activeIds[oldId] then
activeIds[newId] = true
activeIds[oldId] = nil
end
if watchedTriggers[oldId] then
watchedTriggers[newId] = watchedTriggers[oldId]
watchedTriggers[oldId] = nil
end
cancelWatchedDelivery(oldId)
WA.RenameStateTimers(oldId, newId)
end
function GenericTrigger.CreateFallbackState(data, triggernum, state)
local trigger = WA.GetTrigger(data, triggernum)
local proto = trigger and PROTOTYPES[trigger.type]
if proto then
state.name = proto.nameFunc and proto.nameFunc(trigger) or proto.displayName
state.icon = proto.iconFunc and proto.iconFunc(trigger) or proto.icon
if proto.progressType == "timed" then state.progressType = "timed" end
end
state.active = false
return state
end
function GenericTrigger.GetNameAndIcon(data, triggernum)
local trigger = WA.GetTrigger(data, triggernum)
local proto = trigger and PROTOTYPES[trigger.type]
if not proto then return nil, nil end
local name = proto.nameFunc and proto.nameFunc(trigger) or proto.displayName
local icon = proto.iconFunc and proto.iconFunc(trigger) or proto.icon
return name, icon
end
-- How long a preview state's countdown runs before the preview ticker rolls it
-- forward again. WA2's fake-timer window.
local PREVIEW_WINDOW = 7
-- WA2's AddFakeInformation, applied to every state the preview kept. Three
-- things the state as produced cannot carry: a preview must not take itself down
-- on its own deadline; a trigger whose progress is a countdown but which named no
-- deadline needs one or the preview draws an empty bar forever; and a state that
-- reported no name or icon of its own falls back to the prototype's, which is
-- what the aura list row shows for the same trigger.
local function addFakeInformation(data, triggernum, state, ti)
state.autoHide = false
local declared = ti and (ti.progressType or (ti.proto and ti.proto.progressType))
if declared == "timed" and state.expirationTime == nil then
state.progressType = "timed"
end
if state.progressType == "timed" then
local expirationTime = state.expirationTime
if not (type(expirationTime) == "number" and expirationTime ~= math.huge
and expirationTime > GetTime()) then
state.duration = PREVIEW_WINDOW
state.expirationTime = GetTime() + PREVIEW_WINDOW
end
end
local name, icon = GenericTrigger.GetNameAndIcon(data, triggernum)
-- An unconfigured trigger reports "" rather than nil, and "" is truthy in Lua
-- -- so test for content, not just presence, or the preview shows a blank %n.
if state.name == nil and name and name ~= "" then state.name = name end
if state.icon == nil then state.icon = icon end
end
-- The options preview (§14). This system's answer is to run the *real* trigger
-- function under a synthetic "OPTIONS" event and keep whatever states it
-- produced, so a Trigger State Updater previews the clones its own code builds
-- and a prototype previews its own name, icon and progress. Only a run that
-- produced nothing falls back to the synthesised state. Mirrors WA2's
-- GenericTrigger.CreateFakeStates.
--
-- Two deliberate divergences from upstream: it takes `data` rather than an id,
-- as every other contract method here does; and it fabricates no unit token for
-- a multi-unit trigger, because the family's own full pass already re-reads
-- every current member, previewing one clone per member where upstream previews
-- only the family's first token.
function GenericTrigger.CreateFakeStates(data, triggernum)
local id = data.id
local states = WA.GetTriggerStateForTrigger(id, triggernum)
if not states then return end
-- nil for a trigger that did not compile; the fallback below is then the
-- whole preview, which is also what it is worth.
local ti = (events[id] or {})[triggernum]
if ti then
-- Trigger code written against a live event payload can throw when run
-- with none. Upstream's per-aura opt-out is a silent error handler for
-- exactly this pass; here the handler is not a parameter, so it is a flag
-- safecall reads. The nested safecall is what guarantees the flag is
-- cleared even if the run escapes with an error of our own.
local silence = (data.information and data.information.ignoreOptionsEventErrors) and true or false
if silence then WA.silenceErrors = true end
WA.safecall(id .. ": preview trigger " .. triggernum, runTriggerFunc, ti, "OPTIONS")
WA.silenceErrors = nil
end
local shown = 0
for _, state in pairs(states) do
if state.show then
shown = shown + 1
addFakeInformation(data, triggernum, state, ti)
end
end
if shown == 0 then
for cloneId in pairs(states) do states[cloneId] = nil end
local state = {}
GenericTrigger.CreateFallbackState(data, triggernum, state)
state.show, state.changed = true, true
-- Upstream's runtime fallback is always a timed one (duration 0, expiry
-- infinite) that AddFakeInformation then turns into the rolling window;
-- ours claims `timed` only when the prototype does, so the preview
-- supplies the progress upstream's fallback would have carried in.
state.progressType = state.progressType or "timed"
states[""] = state
addFakeInformation(data, triggernum, state, ti)
end
-- A TSU's state timers were started from whatever the OPTIONS run left in
-- `autoHide`, which addFakeInformation has since cleared -- this is what
-- cancels the deadline that would otherwise hide the preview a second later.
if ti and ti.tsu then WA.StartStopStateTimers(id, triggernum, states) end
end
-- The variables a Trigger State Updater declares, read out of its compiled
-- customVariables chunk. That chunk is user code, so it runs through the aura
-- environment; a chunk that errors or returns a non-table declares nothing.
-- Public because it is the same answer the editor reports validation against.
function WA.GetTsuVariables(id, triggernum)
local byTrigger = events[id]
local ti = byTrigger and byTrigger[triggernum]
if not ti or not ti.tsuVariablesFunc then return nil end
local ok, variables = WA.RunAuraFunc(id, id .. ": custom variables " .. triggernum,
ti.tsuVariablesFunc)
if not ok then return nil end
return variables
end
-- Condition variables (§10): every store arg that declared a conditionType.
function GenericTrigger.GetTriggerConditions(data, triggernum)
local trigger = WA.GetTrigger(data, triggernum)
local proto = trigger and PROTOTYPES[trigger.type]
if not proto then return {} end
-- A TSU declares its own; the prototype has no args to derive them from.
if proto.custom and trigger.custom_type == "stateupdate" then
local vars = WA.GetTsuVariables(data.id, triggernum)
-- A display that is not registered -- an import being translated -- has
-- no compiled trigger to ask, so the declaration chunk is compiled here
-- directly, the same evaluation registration would give it.
if vars == nil and not (events[data.id] and events[data.id][triggernum]) then
local fn = compileTsuVariables(trigger.customVariables,
tostring(data.id) .. ": custom variables " .. triggernum)
if fn then
local ok, result = WA.RunAuraFunc(data.id,
tostring(data.id) .. ": custom variables " .. triggernum, fn)
if ok then vars = result end
end
end
return WA.CleanCustomVariables(vars) or {}
end
-- A custom status/event trigger exposes the display-shaped fields its state
-- machinery actually writes (activateEvent): the progress pair whenever the
-- state carries one -- a timed event's own window, or whatever the duration
-- function returns, where the static value/total pair may land instead --
-- and the name when a name function is set. Mirrors upstream's commonConditions
-- block for custom triggers; customStacks is read by nothing here and so
-- deliberately publishes nothing.
if proto.custom then
local function live(key, source)
return WA.TriggerCodeIsLive(trigger, key)
and type(source) == "string" and string.find(source, "%S") ~= nil
end
local out = {}
local hasDurationFunc = live("customDuration", trigger.customDuration)
if hasDurationFunc or (trigger.custom_type == "event" and trigger.custom_hide ~= "custom") then
out.expirationTime = { display = "Remaining Time", type = "timer" }
out.duration = { display = "Total Duration", type = "number" }
end
if hasDurationFunc then
out.value = { display = "Progress Value", type = "number" }
out.total = { display = "Progress Total", type = "number" }
end
if live("customName", trigger.customName) then
out.name = { display = "Name", type = "string" }
end
return out
end
local out = {}
for i = 1, #proto.args do
local arg = proto.args[i]
-- A field the trigger's mode never fills is not offered as a variable.
if arg.conditionType and not (arg.enable and not arg.enable(trigger)) then
out[arg.name] = { display = arg.display or arg.name, type = arg.conditionType,
values = arg.valueLabels }
end
end
return out
end
-- Debug read-only accessor (Debug.lua's /wa gen): the generated source for
-- trigger 1 of this display.
function WA.GetGeneratedSource(id)
local byTrigger = events[id]
if not byTrigger then return nil end
local ti = byTrigger[1]
for _, t in pairs(byTrigger) do ti = ti or t end
return ti and ti.source
end
-- Debug read-only accessor (Debug.lua's /wa load): whether this system
-- actually finished registering id into loaded_events, independent of
-- StateMachine's own `loaded[id]` flag -- that flag is set true before
-- applyLoad hands off to each system's LoadDisplays (see StateMachine.lua),
-- so it can say "loaded" even when a system's registration silently never
-- happened. true/false only meaningful for an id this system compiled
-- (events[id] non-nil); nil otherwise.
function WA.IsGenericTriggerActive(id)
if not events[id] then return nil end
return activeIds[id] and true or false
end
-- ---------------------------------------------------------------------------
-- Options-side registration: one trigger *type* per prototype, its editor
-- generated from the same args (§4.1). The runtime layer is this one
-- GenericTrigger system, registered for all of them.
-- ---------------------------------------------------------------------------
-- A tristate's three values, as the editor's dropdown spells them. The arg has
-- no default: a nil use_<name> is "ignored", which is also what buildDefaults
-- leaves it as.
local TRISTATE_VALUES = { "ignore", "true", "false" }
local TRISTATE_LABELS = { ignore = "Any", ["true"] = "Yes", ["false"] = "No" }
local function buildDefaults(proto)
local d = {}
for i = 1, #proto.args do
local arg = proto.args[i]
if arg.type == "unit" then
d[arg.name] = arg.default or "player"
d.specificUnit = ""
elseif arg.type == "range" then
d[arg.name] = arg.default or 0
elseif arg.type == "string" then
if not arg.required then d["use_" .. arg.name] = false end
d[arg.name .. "_operator"] = arg.operator or "=="
d[arg.name] = arg.default or ""
elseif arg.type == "spell" or arg.type == "item" or arg.type == "talent" or arg.type == "text" then
d[arg.name] = arg.default or ""
elseif arg.type == "toggle" then
d[arg.name] = arg.default or false
elseif arg.type == "select" and arg.required then
local values = type(arg.valueList) == "function" and arg.valueList(d) or arg.valueList
d[arg.name] = arg.default or (values and values[1])
elseif arg.type == "select" and not arg.required then
d["use_" .. arg.name] = false
local values = type(arg.valueList) == "function" and arg.valueList(d) or arg.valueList
d[arg.name] = arg.default or (values and values[1])
elseif arg.type == "multiselect" then
-- `false` rather than nil so the key exists in the defaults, which is
-- what tells the importer this arg takes upstream's multiselect shape.
d["use_" .. arg.name] = false
local values = type(arg.valueList) == "function" and arg.valueList(d) or arg.valueList
d[arg.name] = arg.default or (values and values[1])
d[arg.name .. "_multi"] = {}
elseif arg.type == "list" then
if not arg.required then d["use_" .. arg.name] = false end
d[arg.name] = {}
elseif arg.type == "number" and not arg.required then
d["use_" .. arg.name] = false
d[arg.name .. "_operator"] = arg.operator or ">="
d[arg.name] = arg.default or 0
if arg.multiEntry then
d["use_" .. arg.name .. "2"] = false
d[arg.name .. "2_operator"] = arg.operator2 or "<="
d[arg.name .. "2"] = arg.default2 or 0
end
end
end
if proto.defaults then
for k, v in pairs(proto.defaults) do d[k] = v end
end
return d
end
local function buildOptions(data, triggernum)
local t = WA.GetTrigger(data, triggernum or 1)
local proto = t and PROTOTYPES[t.type]
if not proto then return WA.TriggerTypeFields(data, t) end
if proto.custom then
local fields = WA.TriggerTypeFields(data, t)
local entry = data.triggers[triggernum or 1]
entry.untrigger = entry.untrigger or {}
local function validate(txt, label)
return WA.Widgets.LuaSyntaxError(WA.WrapFunctionSource(txt), label)
end
local optionKey = tostring(data.id or "") .. ":" .. tostring(triggernum or 1)
local search = customEventSearch[optionKey] or ""
local eventValues, eventLabels = eventPickerValues(search)
local isTsu = t.custom_type == "stateupdate"
local perFrame = isTsu and t.check == "update"
local more = {
{ type = "header", name = "Custom Trigger" },
{ type = "select", name = "Event Type", key = "custom_type",
values = { "status", "event", "stateupdate" },
labels = { status = "Status", event = "Event",
stateupdate = "Trigger State Updater (Advanced)" },
get = function() return t.custom_type or "status" end,
set = function(v)
t.custom_type = v
if v ~= "status" then t.weakestAurasLegacyStateArgs = nil end
-- Upstream stamps this on every aura it creates, and a TSU state
-- that never set `show` reads differently with and without it. An
-- aura that arrived carrying `information` keeps whatever it said.
if v == "stateupdate" and data.information == nil then
data.information = { showNilIsFalse = true }
end
WA.Add(data); WA.RefreshOptions()
end },
}
if isTsu then
table.insert(more, { type = "select", name = "Check On", key = "check",
values = { "event", "update" },
labels = { event = "Event(s)", update = "Every Frame" },
get = function() return t.check or "event" end,
set = function(v) t.check = v; WA.Add(data); WA.RefreshOptions() end })
end
if not perFrame then
table.insert(more, { type = "multiline", name = "Event(s)", key = "events", height = 72,
get = function() return t.events or "" end,
set = function(v) t.events = v; WA.Add(data); WA.RefreshOptions() end })
table.insert(more, { type = "description", name =
"Type event names manually above, separated by spaces or commas. "
.. "Or search below, press Enter, and choose Insert Event." })
if isTsu then
table.insert(more, { type = "description", name =
"UNIT_AURA:player:target fires only for the listed units. TRIGGER:2 "
.. "runs this trigger when trigger 2 updates. CLEU:SPELL_DAMAGE fires "
.. "for those combat log subevents only." })
end
local invalid, redirected = invalidEventList(t.events)
if #invalid > 0 then
table.insert(more, { type = "description", name =
"|cffff5555Not registered by this client: " .. table.concat(invalid, ", ")
.. ".|r The names remain saved for addon-generated events." })
end
if #redirected > 0 then
local lines = {}
for i = 1, #redirected do
table.insert(lines, redirected[i] .. " ("
.. table.concat(EVENT_COMPAT[redirected[i]], ", ") .. ")")
end
table.insert(more, { type = "description", name =
"|cffffd100Fires here through this client's own events:|r "
.. table.concat(lines, "; ")
.. ". The trigger receives the name listed above." })
end
-- A "__" key names a session-local control (this writes a search box's
-- text, not aura data) -- the marker is what lets a reader of keys tell
-- the two apart.
table.insert(more, { type = "input", name = "Find Event", key = "__findEvent", half = true,
get = function() return customEventSearch[optionKey] or "" end,
set = function(v) customEventSearch[optionKey] = v or ""; WA.RefreshOptions() end })
table.insert(more, { type = "menu", name = "Insert Event", half = true,
values = eventValues, labels = eventLabels,
onSelect = function(v)
if v == "__NO_EVENT_MATCH__" then return end
t.events = appendEventName(t.events, v)
WA.Add(data); WA.RefreshOptions()
end })
end
if perFrame or (isTsu and t.events and string.find(t.events, "FRAME_UPDATE", 1, true)) then
table.insert(more, { type = "range", name = "Throttle (s)", key = "onUpdateThrottle",
min = 0, max = 1, step = 0.01, decimals = 2,
get = function() return tonumber(t.onUpdateThrottle) or 0 end,
set = function(v) t.onUpdateThrottle = v; WA.Add(data) end })
end
table.insert(more, { type = "code", name = "Custom Trigger", key = "custom", height = 180,
get = function() return t.custom end,
set = function(v) t.custom = v; WA.Add(data) end,
default = (isTsu and "function(allstates, event, ...)\n return true\nend")
or (t.weakestAurasLegacyStateArgs
and "function(state, event, ...)\n return true\nend")
or "function(event, ...)\n return true\nend",
validate = function(txt) return validate(txt, "custom trigger") end })
if isTsu then
table.insert(more, { type = "code", name = "Custom Variables", key = "customVariables", height = 140,
get = function() return t.customVariables or "" end,
set = function(v) t.customVariables = v; WA.Add(data); WA.RefreshOptions() end,
default = "{\n stacks = \"number\",\n}",
validate = function(txt)
return WA.Widgets.LuaSyntaxError(
WA.WrapFunctionSource("function() return \n" .. (txt or "") .. "\n end"),
"custom variables")
end })
-- The syntax check above only proves the chunk compiles. What the table
-- *says* is checked by running it, which is too much to do per keystroke,
-- so the declaration's own problems are reported off the compiled trigger.
local declared = WA.GetTsuVariables(data.id, triggernum or 1)
local problem = declared ~= nil and WA.ValidateCustomVariables(declared) or nil
if problem then
table.insert(more, { type = "description",
name = "|cffff5555Custom Variables: " .. problem .. "|r" })
end
table.insert(more, { type = "description", name =
"State fields shown here: show, changed, progressType, value, total, "
.. "duration, expirationTime, autoHide, paused, remaining, name, icon, "
.. "stacks, index, inverse." })
table.insert(more, { type = "description", name =
"This code also runs once when the aura loads, with event = \"STATUS\" "
.. "and no payload, so it can seed itself from the current world. Check "
.. "`event` before building state from an argument the load pass has no "
.. "value for." })
end
if t.weakestAurasLegacyStateArgs then
table.insert(more, { type = "select", name = "Legacy Progress", key = "customProgressType",
values = { "none", "timed", "static" },
labels = { none = "None", timed = "Timed", static = "Static" },
get = function() return t.customProgressType or "none" end,
set = function(v) t.customProgressType = v; WA.Add(data) end })
end
if t.custom_type == "event" then
table.insert(more, { type = "select", name = "Hide", key = "custom_hide",
values = { "timed", "custom" }, labels = { timed = "Timed", custom = "Custom" },
get = function() return t.custom_hide or "timed" end,
set = function(v) t.custom_hide = v; WA.Add(data); WA.RefreshOptions() end })
if t.custom_hide ~= "custom" then
table.insert(more, { type = "range", name = "Duration (s)", key = "duration",
min = 0.1, max = 60, step = 0.1,
get = function() return tonumber(t.duration) or 1 end,
set = function(v) t.duration = v; WA.Add(data) end })
end
end
-- Everything below is a second source for a value TSU state already carries,
-- so this mode shows none of it. The saved fields survive untouched, and a
-- trigger switched back out of TSU gets its editors back with its text.
if not isTsu then
if t.custom_type == "status" or t.custom_hide == "custom" then
table.insert(more, { type = "code", name = "Custom Untrigger", key = "customUntrigger", height = 140,
get = function() return entry.untrigger.custom end,
set = function(v) entry.untrigger.custom = v; WA.Add(data) end,
default = t.weakestAurasLegacyStateArgs
and "function(state, event, ...)\n return true\nend"
or "function(event, ...)\n return true\nend",
validate = function(txt) return validate(txt, "custom untrigger") end })
table.insert(more, { type = "code", name = "Duration Info", key = "customDuration", height = 140,
get = function() return t.customDuration or "" end,
set = function(v) t.customDuration = v; WA.Add(data) end,
validate = function(txt) return validate(txt, "duration info") end })
end
table.insert(more, { type = "code", name = "Name Info", key = "customName", height = 120,
get = function() return t.customName or "" end,
set = function(v) t.customName = v; WA.Add(data) end,
validate = function(txt) return validate(txt, "name info") end })
table.insert(more, { type = "code", name = "Icon Info", key = "customIcon", height = 120,
get = function() return t.customIcon or "" end,
set = function(v) t.customIcon = v; WA.Add(data) end,
validate = function(txt) return validate(txt, "icon info") end })
end
for i = 1, #more do table.insert(fields, more[i]) end
return fields
end
local fields = WA.TriggerTypeFields(data, t)
for i = 1, #proto.args do
local arg = proto.args[i]
-- An arg whose enable() is false is inapplicable to the trigger's selected
-- mode (a spell field while the range mode is "interact"): its control is
-- omitted rather than rendered as a field nothing reads. Visibility only --
-- the stored value and the arg's test are untouched, so an arg that goes
-- back into view keeps what it had.
if arg.enable and not arg.enable(t) then
-- inapplicable to this configuration
elseif arg.hidden or arg.type == "hidden" then
-- computed/stored only, no UI
elseif arg.type == "header" then
table.insert(fields, { type = "header", name = arg.display or "" })
elseif arg.type == "unit" then
-- The dropdown's "specific" entry swaps in a free-text token field
-- beside it; the dropdown's stored token survives being hidden and
-- comes back when the override is switched off.
table.insert(fields, {
type = "select", name = arg.display or "Unit", key = arg.name, half = true,
values = arg.valueList or WA.unit_tokens,
labels = arg.valueLabels or WA.unit_labels,
get = function() return t[arg.name] end,
set = function(v) t[arg.name] = v; WA.Add(data); WA.RefreshOptions() end,
})
if t[arg.name] == "specific" then
table.insert(fields, {
type = "input", name = "Unit Token", key = "specificUnit", half = true,
get = function() return t.specificUnit or "" end,
set = function(v) t.specificUnit = v; WA.Add(data) end,
})
end
elseif arg.type == "spell" then
table.insert(fields, {
type = "spell", name = arg.display or "Spell", key = arg.name,
get = function() return t[arg.name] or "" end,
set = function(v) t[arg.name] = v; WA.Add(data) end,
})
elseif arg.type == "talent" then
table.insert(fields, {
type = "talent", name = arg.display or "Talent", key = arg.name,
get = function() return t[arg.name] or "" end,
set = function(v) t[arg.name] = v; WA.Add(data) end,
resolve = function(v)
local found, _, _, _, tab, tier, column, icon = WA.TalentInfo(v,
(t.usePlacement and tonumber(t.talentTab)) or 0,
(t.usePlacement and tonumber(t.talentTier)) or 0,
(t.usePlacement and tonumber(t.talentColumn)) or 0)
return found and icon or nil
end,
})
elseif arg.type == "item" then
table.insert(fields, {
type = "item", name = arg.display or "Item", key = arg.name,
get = function() return t[arg.name] or "" end,
set = function(v) t[arg.name] = v; WA.Add(data) end,
})
elseif arg.type == "text" then
table.insert(fields, {
type = "input", name = arg.display or arg.name, key = arg.name,
get = function() return t[arg.name] or "" end,
set = function(v) t[arg.name] = v; WA.Add(data) end,
})
elseif arg.type == "range" then
-- A plain bounded number, no operator: the value *is* the setting
-- rather than one side of a comparison, so it contributes no test.
table.insert(fields, {
type = "range", name = arg.display or arg.name, key = arg.name,
min = arg.min or 0, max = arg.max or 100, step = arg.step or 1,
decimals = arg.decimals,
get = function() return t[arg.name] or arg.default or 0 end,
set = function(v) t[arg.name] = v; WA.Add(data) end,
})
elseif arg.type == "string" then
table.insert(fields, {
type = "toggle", name = arg.display or arg.name, key = "use_" .. arg.name,
get = function() return t["use_" .. arg.name] end,
set = function(v) t["use_" .. arg.name] = v; WA.Add(data); WA.RefreshOptions() end,
})
if t["use_" .. arg.name] then
table.insert(fields, {
type = "select", name = arg.display or arg.name, key = arg.name .. "_operator", half = true,
values = STRING_OPS, labels = STRING_OP_LABELS,
get = function() return t[arg.name .. "_operator"] end,
set = function(v) t[arg.name .. "_operator"] = v; WA.Add(data) end,
})
table.insert(fields, {
type = "input", name = "Text", key = arg.name, half = true,
get = function() return t[arg.name] or "" end,
set = function(v) t[arg.name] = v; WA.Add(data) end,
})
end
elseif arg.type == "toggle" then
table.insert(fields, {
type = "toggle", name = arg.display or arg.name, key = arg.name,
get = function() return t[arg.name] end,
set = function(v)
t[arg.name] = v; WA.Add(data)
if arg.reloadOptions then WA.RefreshOptions() end
end,
})
elseif arg.type == "tristate" then
-- Three states in one control. A checkbox cannot cycle nil/true/false,
-- so the dropdown writes the same use_<name> key upstream's does.
table.insert(fields, {
type = "select", name = arg.display or arg.name, key = "use_" .. arg.name, half = true,
values = TRISTATE_VALUES, labels = TRISTATE_LABELS,
get = function()
local use = t["use_" .. arg.name]
if use == nil then return "ignore" end
return use and "true" or "false"
end,
set = function(v)
if v == "ignore" then t["use_" .. arg.name] = nil
else t["use_" .. arg.name] = (v == "true") end
WA.Add(data)
end,
})
elseif arg.type == "select" and arg.required then
table.insert(fields, {
type = "select", name = arg.display or arg.name, key = arg.name,
values = type(arg.valueList) == "function" and arg.valueList(t) or (arg.valueList or arg.values),
labels = type(arg.valueLabels) == "function" and arg.valueLabels(t) or arg.valueLabels,
get = function() return t[arg.name] end,
set = function(v)
-- Subclass ids are only meaningful inside their class, so a class
-- change drops whatever the old class's list had selected --
-- both the single value and the set, since either mode can be
-- the one on display when the class is changed.
if arg.name == "itemClassID" and t.type == "itemtypeequipped" then
local subclassValues = itemSubclassValues(v)
local valid = {}
for j = 1, #subclassValues do valid[subclassValues[j]] = true end
if not valid[t.itemSubclassID] then t.itemSubclassID = subclassValues[1] end
local set = t.itemSubclassID_multi
for value in pairs(set or {}) do
if not valid[value] then set[value] = nil end
end
end
t[arg.name] = v; WA.Add(data)
if arg.reloadOptions then WA.RefreshOptions() end
end,
})
elseif arg.type == "select" then
table.insert(fields, {
type = "toggle", name = "Use " .. (arg.display or arg.name), key = "use_" .. arg.name,
get = function() return t["use_" .. arg.name] end,
set = function(v) t["use_" .. arg.name] = v; WA.Add(data); WA.RefreshOptions() end,
})
if t["use_" .. arg.name] then
table.insert(fields, {
type = "select", name = arg.display or arg.name, key = arg.name,
values = arg.valueList or arg.values, labels = arg.valueLabels,
get = function() return t[arg.name] end,
set = function(v)
t[arg.name] = v; WA.Add(data)
if arg.reloadOptions then WA.RefreshOptions() end
end,
})
end
elseif arg.type == "list" then
table.insert(fields, {
type = "toggle", name = arg.display or arg.name, key = "use_" .. arg.name,
get = function() return t["use_" .. arg.name] end,
set = function(v) t["use_" .. arg.name] = v; WA.Add(data); WA.RefreshOptions() end,
})
if t["use_" .. arg.name] then
table.insert(fields, {
type = "namelist", name = arg.listLabel or arg.display or arg.name, key = arg.name,
tooltip = arg.tooltip,
get = function()
if type(t[arg.name]) ~= "table" then t[arg.name] = {} end
return t[arg.name]
end,
onChange = function() WA.Add(data) end,
})
end
elseif arg.type == "multiselect" then
WA.MultiSelectFields(fields, {
config = t, name = arg.name, display = arg.display or arg.name,
default = arg.default,
values = type(arg.valueList) == "function" and arg.valueList(t)
or (arg.valueList or arg.values),
labels = type(arg.valueLabels) == "function" and arg.valueLabels(t)
or arg.valueLabels,
onChange = function(repaint)
WA.Add(data)
if repaint then WA.RefreshOptions() end
end,
})
elseif arg.type == "number" then
table.insert(fields, {
type = "toggle", name = arg.display or arg.name, key = "use_" .. arg.name,
get = function() return t["use_" .. arg.name] end,
set = function(v) t["use_" .. arg.name] = v; WA.Add(data); WA.RefreshOptions() end,
})
if t["use_" .. arg.name] then
table.insert(fields, {
type = "opnumber", name = arg.display or arg.name, key = arg.name,
getOp = function() return t[arg.name .. "_operator"] end,
setOp = function(v) t[arg.name .. "_operator"] = v; WA.Add(data) end,
getVal = function() return t[arg.name] end,
setVal = function(v) t[arg.name] = v; WA.Add(data) end,
})
-- The second bound is offered only once the first is on, since a
-- range with no lower half is just the upper half on its own.
if arg.multiEntry then
table.insert(fields, {
type = "toggle", name = "And " .. (arg.display or arg.name),
key = "use_" .. arg.name .. "2",
get = function() return t["use_" .. arg.name .. "2"] end,
set = function(v)
t["use_" .. arg.name .. "2"] = v; WA.Add(data); WA.RefreshOptions()
end,
})
if t["use_" .. arg.name .. "2"] then
table.insert(fields, {
type = "opnumber", name = arg.display or arg.name, key = arg.name .. "2",
getOp = function() return t[arg.name .. "2_operator"] end,
setOp = function(v) t[arg.name .. "2_operator"] = v; WA.Add(data) end,
getVal = function() return t[arg.name .. "2"] end,
setVal = function(v) t[arg.name .. "2"] = v; WA.Add(data) end,
})
end
end
end
end
end
return fields
end
local function buildSummary(proto)
return function(data)
local t = WA.GetTrigger(data, 1)
if not t then return proto.displayName end
if t.spellName and t.spellName ~= "" then
return proto.displayName .. ": " .. t.spellName
end
if t.unit then
return proto.displayName .. " (" .. WA.TriggerUnit(t, "player") .. ")"
end
return proto.displayName
end
end
local systemTypes = {}
for typeName, proto in pairs(PROTOTYPES) do
-- A prototype gated on a client mod that isn't installed registers no type at
-- all, so it never reaches the picker rather than appearing there and failing
-- when chosen. Feature-by-feature degradation, not a second load gate.
if not proto.enable or proto.enable() then
WA.RegisterTriggerType(typeName, {
displayName = proto.displayName,
category = proto.category,
defaults = buildDefaults(proto),
migrate = proto.migrate,
wa2Event = proto.wa2Event,
-- Carried so the options side and the WeakAuras2 importer can tell a
-- prototype that fans a unit family out into clones from one whose
-- unit dropdown holds single tokens only.
statesParameter = proto.statesParameter,
-- The importer reads the arg declarations for the shapes the
-- defaults cannot describe (a tristate has no default), and asks the
-- prototype's own importCheck about settings it cannot express.
args = proto.args,
importCheck = proto.importCheck,
summary = buildSummary(proto),
options = buildOptions,
})
table.insert(systemTypes, typeName)
end
end
WA.RegisterTriggerSystem(systemTypes, GenericTrigger)