Files
WeakestAuras/Conditions.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

570 lines
24 KiB
Lua

-- WeakestAuras -- conditions: trigger-state-driven display overrides ("if
-- trigger state says X, change display property Y"). Mirrors WA2's Conditions
-- (§10), with one deliberate divergence:
-- upstream compiles the whole list to one loadstring'd function per aura; we
-- *interpret* the same descriptor data. At tens of auras the interpreter is
-- plenty fast, and with no offline Lua a generated-code bug would be an in-game
-- string-assembly hunt -- an interpreter's stack traces point at real lines.
-- The descriptor format and the three-phase algorithm are upstream's exactly,
-- so a compiler can replace the interpreter later without touching saved data.
-- Upstream section refs (§N) point at design/architecture/weakauras2-reference.md
--
-- The data (per condition): { check = { trigger, variable, op, value, checks },
-- changes = { { property, value }, ... } }. trigger -1 = a global condition,
-- -2 = an AND/OR combinator over check.checks. Everything meets the rest of the
-- engine at region.states (what to read) and the region/subregion property
-- setters (what to write) -- the same setters config-apply already calls.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
-- compiled[uid] = { data, properties (WA.GetProperties map), templates
-- ([triggernum] = condition-variable map), customChecks (source -> compiled
-- function, or false where it would not compile) }. Rebuilt on every WA.Add via
-- WA.LoadConditions; keyed by uid (stable across rename) so a rename needs no
-- fixup here.
local compiled = {}
-- activated[uid][cloneId][i] = was condition i active on the previous run --
-- drives deactivation restore (a condition that turned off restores its
-- properties' saved base values) and, later, edge-triggered actions.
local activated = {}
-- scheduled[uid.."\0"..cloneId] = the one pending exact-recheck C_Timer for a
-- timer/elapsedTimer check crossing its threshold (§10 ScheduleConditionCheck).
-- scheduledFlip/scheduledRegion record what that timer was armed for, so a run
-- that arrives at the same threshold keeps it instead of re-arming.
local scheduled, scheduledFlip, scheduledRegion = {}, {}, {}
-- ---------------------------------------------------------------------------
-- Global conditions (trigger == -1): a shared pseudo-state re-checked on the
-- events below. All three APIs exist on 1.12.
-- ---------------------------------------------------------------------------
WA.globalConditions = {
incombat = { display = "In Combat", type = "bool",
get = function() return UnitAffectingCombat("player") and true or false end },
hastarget = { display = "Has Target", type = "bool",
get = function() return UnitExists("target") and true or false end },
attackabletarget = { display = "Target Attackable", type = "bool",
get = function() return (UnitExists("target") and UnitCanAttack("player", "target")) and true or false end },
-- Its own type rather than a bool that happens to return true: the check
-- carries no operator and no value, so anything comparing them would read
-- an absent value as false and invert the one condition that cannot fail.
alwaystrue = { display = "Always True", type = "alwaystrue",
get = function() return true end },
-- The only route from an aura's `config` into a condition, and the reason it
-- carries no `get`: the answer comes from running the author's Lua inside the
-- aura environment, not from reading a shared state. `check.value` holds that
-- source and `check.op` the whitespace-separated list of extra events it
-- re-evaluates on.
customcheck = { display = "Custom Check", type = "customcheck" },
}
local GLOBAL_EVENTS = { "PLAYER_REGEN_ENABLED", "PLAYER_REGEN_DISABLED", "PLAYER_TARGET_CHANGED" }
-- ---------------------------------------------------------------------------
-- Custom Check: compilation and its dynamic events (§10)
-- ---------------------------------------------------------------------------
-- [event] = { [uid] = true } -- which auras re-evaluate when a named event
-- fires. The events are the author's, so the set is unbounded and cannot live on
-- GLOBAL_EVENTS' fixed list.
local customCheckEvents = {}
-- Walks one check tree, compiling every Custom Check's source and collecting the
-- events they ask for. Compiled per WA.Add and cached on the aura's entry keyed
-- by the source itself, so two conditions sharing a snippet share one function
-- and no evaluation ever reaches loadstring. A source that fails to compile
-- caches `false`, which both stops the retry and answers the check false.
local function collectCustomChecks(check, entry, events, label)
if type(check) ~= "table" then return end
if check.trigger == -2 then
local subs = check.checks or {}
for i = 1, #subs do collectCustomChecks(subs[i], entry, events, label) end
return
end
if check.trigger ~= -1 or check.variable ~= "customcheck" then return end
local source = check.value
if type(source) == "string" and source ~= "" and entry.customChecks[source] == nil then
entry.customChecks[source] = WA.LoadFunction(source, label) or false
end
if type(check.op) == "string" then
for event in string.gmatch(check.op, "[%w_:]+") do events[event] = true end
end
end
-- This aura's event registrations replaced wholesale, so a check that dropped an
-- event stops waking on it. Registration rides the trigger system's frame: a
-- frame of our own registered for the same name would deliver it twice.
local function setCustomCheckEvents(uid, events)
for _, listeners in pairs(customCheckEvents) do listeners[uid] = nil end
for event in pairs(events) do
customCheckEvents[event] = customCheckEvents[event] or {}
customCheckEvents[event][uid] = true
if WA.EnsureGameEventRegistered then WA.EnsureGameEventRegistered(event) end
end
end
-- ---------------------------------------------------------------------------
-- Vocabulary: changeable properties + checkable variables
-- ---------------------------------------------------------------------------
-- The overridable properties for a display: the region type's own registry
-- (already carrying the AddProperties universals) plus each subtext's, keyed
-- sub.<n>.<key>. Normalized into one flat shape so the apply/base code and the
-- editor both read the same entries (§10 Private.GetProperties).
function WA.GetProperties(data)
local out = {}
local rt = WA.regionTypes[data.regionType]
if rt and rt.properties then
for key, spec in pairs(rt.properties) do
out[key] = { display = spec.display or key, setter = spec.setter, action = spec.action, type = spec.type,
min = spec.min, max = spec.max, softMin = spec.softMin, softMax = spec.softMax, step = spec.step, values = spec.values,
base = spec.base, baseIndex = spec.baseIndex, dataKey = spec.dataKey or key }
end
end
local subs = data.subRegions or {}
local perType = {}
for i = 1, #subs do
local sspec = WA.subRegionTypes[subs[i].type]
perType[subs[i].type] = (perType[subs[i].type] or 0) + 1
if sspec and sspec.properties then
-- Same label the Display Effects list gives the block this targets, so
-- the two pages name one element the same way -- which means counting
-- per type here too, not by list position. The position is still what
-- the key `sub.<i>.<key>` addresses it by.
local label = (sspec.displayName or subs[i].type) .. " " .. perType[subs[i].type] .. " "
for key, pspec in pairs(sspec.properties) do
out["sub." .. i .. "." .. key] = { display = label .. (pspec.display or key),
setter = pspec.setter, action = pspec.action, type = pspec.type, min = pspec.min, max = pspec.max,
softMin = pspec.softMin, softMax = pspec.softMax, step = pspec.step, values = pspec.values, base = pspec.base, baseIndex = pspec.baseIndex,
isSub = true, subIndex = i, subKey = pspec.dataKey or key }
end
end
end
return out
end
-- Whether the trigger is currently active. Every state carries `show`, whatever
-- system produced it, so the variable is added over each system's own table
-- rather than repeated inside all three -- which is where upstream puts it too.
local SHOW_TEMPLATE = { display = "Active", type = "bool" }
-- The checkable variable templates per trigger (§10): each trigger system's
-- GetTriggerConditions. [triggernum] = { variable = { display, type, values } }.
function WA.GetConditionTemplates(data)
local out = {}
-- A group carries no triggers at all (MergeDefaults clears them), and its
-- options window still reaches here for the conditions tab.
for triggernum = 1, #(data.triggers or {}) do
local system = WA.GetTriggerSystem(data, triggernum)
local base = system and system.GetTriggerConditions
and system.GetTriggerConditions(data, triggernum) or {}
-- A system's table is shared across every display using that system --
-- TriggerAura returns one file-local constant -- so `show` goes onto a
-- copy. Writing into it would leak this display's entry into all of them.
local templates = {}
for name, template in pairs(base) do templates[name] = template end
templates.show = SHOW_TEMPLATE
out[triggernum] = templates
end
return out
end
-- ---------------------------------------------------------------------------
-- Property read/write (the same setters config-apply uses, §6/§10)
-- ---------------------------------------------------------------------------
-- The saved base value a deactivated condition restores -- the config is the
-- source of truth, never a snapshot of live widget state (§10 GetBaseProperty).
local function getBase(data, entry)
if entry.isSub then
local sub = data.subRegions and data.subRegions[entry.subIndex]
local value = sub and sub[entry.subKey]
if entry.baseIndex and type(value) == "table" then return value[entry.baseIndex] end
return value
end
if entry.base ~= nil then return entry.base end
local value = data[entry.dataKey]
if entry.baseIndex and type(value) == "table" then return value[entry.baseIndex] end
return value
end
local function applyProperty(region, entry, value)
local target = region
if entry.isSub then
target = region.subRegions and region.subRegions[entry.subIndex]
end
if not target or not entry.setter or not target[entry.setter] then return end
-- A number setter does arithmetic on what it is handed, so a nil takes the
-- aura down. Two things produce one: a change the author never filled in, and
-- a deactivating condition restoring a property that has no base to restore
-- to. Leaving the property where it stands beats erroring in a paint.
if value == nil and entry.type == "number" then return end
if entry.type == "color" then
local c = value or { 1, 1, 1, 1 }
target[entry.setter](target, c[1] or 1, c[2] or 1, c[3] or 1, c[4] or 1)
else
target[entry.setter](target, value)
end
end
-- ---------------------------------------------------------------------------
-- Evaluation
-- ---------------------------------------------------------------------------
local function numCmp(op, a, b)
if a == nil or b == nil then return false end
if op == "==" then return a == b
elseif op == "~=" then return a ~= b
elseif op == ">" then return a > b
elseif op == ">=" then return a >= b
elseif op == "<" then return a < b
elseif op == "<=" then return a <= b end
return false
end
local function compareValue(vtype, op, actual, value)
op = op or "=="
if vtype == "number" then
return numCmp(op, tonumber(actual), tonumber(value))
elseif vtype == "bool" then
local a = actual and true or false
local b = (value == true or value == "true" or value == 1 or value == "1")
if op == "~=" then return a ~= b end
return a == b
else -- string / select
if op == "~=" then return tostring(actual) ~= tostring(value) end
return tostring(actual) == tostring(value)
end
end
-- The compiled Custom Check for this check, run inside the aura environment so
-- `aura_env.config` is what the author's `return aura_env.config.foo` reads.
-- Nothing else in this file needs the environment, so the push/pop is here
-- rather than around the whole run, where upstream puts its equivalent -- a
-- compiled condition function is one call, and there is nowhere cheaper inside
-- it to draw the line.
local function runCustomCheck(entry, check, region)
local fn = entry.customChecks and entry.customChecks[check.value or ""]
if not fn then return false end
local who = region or entry.data.id
local ok, res = WA.RunAuraFunc(who, entry.data.id .. ": custom check", fn, region and region.state)
return (ok and res) and true or false
end
-- Returns (active, nextFlip): active is this check's boolean now; nextFlip is
-- the soonest future time a timer/elapsedTimer sub-result would change (or nil),
-- so the caller can schedule an exact recheck instead of polling.
local function evalCheck(check, states, entry, now, region)
local trig = check.trigger
if trig == -2 then
-- AND/OR combinator over nested checks.
local subs = check.checks or {}
local isOr = check.variable == "OR"
local res = not isOr -- AND starts true, OR starts false
local nextFlip
for i = 1, #subs do
local r, f = evalCheck(subs[i], states, entry, now, region)
if isOr then if r then res = true end
else if not r then res = false end end
if f and (not nextFlip or f < nextFlip) then nextFlip = f end
end
return res, nextFlip
elseif trig == -1 then
local g = WA.globalConditions[check.variable]
if not g then return false end
if g.type == "alwaystrue" then return true end
if g.type == "customcheck" then return runCustomCheck(entry, check, region) end
return compareValue("bool", check.op, g.get(), check.value)
else
local state = states[trig]
local tmpl = entry.templates[trig] and entry.templates[trig][check.variable]
if not tmpl then return false end
if not state then
-- A trigger that has never produced a state has nothing to read a
-- variable off, so every check over it is false -- except `show`,
-- which that absence answers: the trigger is not active. Without
-- this, "trigger 2 is inactive" stays dark until trigger 2 first
-- fires, which is the wrong way round for a "missing buff" display.
if check.variable ~= "show" then return false end
return compareValue("bool", check.op, false, check.value)
end
local vtype = tmpl.type
if vtype == "timer" then
-- state[variable] holds an absolute expiration; compare *remaining*.
-- A fallback/missing state carries 0 here -- treat as "no timer" so a
-- "remaining < N" check doesn't read the huge negative remaining as true.
local expiry = state[check.variable]
if not expiry or expiry <= 0 then return false end
local target = tonumber(check.value) or 0
local cur = numCmp(check.op, expiry - now, target)
local flip = expiry - target -- remaining == target at this instant
return cur, (flip > now and flip or nil)
elseif vtype == "elapsedTimer" then
-- state[variable] holds an absolute past timestamp; compare *elapsed*.
local base = state[check.variable]
if not base or base <= 0 then return false end
local target = tonumber(check.value) or 0
local cur = numCmp(check.op, now - base, target)
local flip = base + target -- elapsed == target at this instant
return cur, (flip > now and flip or nil)
else
return compareValue(vtype, check.op, state[check.variable], check.value)
end
end
end
-- ---------------------------------------------------------------------------
-- The run + recheck scheduling
-- ---------------------------------------------------------------------------
local function getActivated(uid, cloneId)
activated[uid] = activated[uid] or {}
activated[uid][cloneId] = activated[uid][cloneId] or {}
return activated[uid][cloneId]
end
local runFor -- forward decl: scheduleRecheck's timer closure re-enters it.
local function cancelRecheck(key)
local timer = scheduled[key]
if timer then timer:Cancel() end
scheduled[key], scheduledFlip[key], scheduledRegion[key] = nil, nil, nil
end
local function scheduleRecheck(region, uid, cloneId, nextFlip, now)
-- Cached on the frame: this runs on every state change of every region with
-- conditions, and the concatenation was a fresh string each time.
local key = region.conditionKey
if region.conditionKeyUid ~= uid or region.conditionKeyClone ~= cloneId then
key = uid .. "\0" .. cloneId
region.conditionKey, region.conditionKeyUid, region.conditionKeyClone = key, uid, cloneId
end
if not (nextFlip and region.toShow) then
if scheduled[key] then cancelRecheck(key) end
return
end
if scheduled[key] and scheduledFlip[key] == nextFlip and scheduledRegion[key] == region then return end
cancelRecheck(key)
-- Small epsilon so the boundary has actually passed when we re-evaluate
-- (a strict "<" wouldn't flip at exactly the threshold instant).
local delay = (nextFlip - now) + 0.05
if delay < 0.05 then delay = 0.05 end
scheduled[key] = C_Timer.NewTimer(delay, function()
scheduled[key], scheduledFlip[key], scheduledRegion[key] = nil, nil, nil
if region.toShow then runFor(region, uid, false) end
end)
scheduledFlip[key], scheduledRegion[key] = nextFlip, region
end
runFor = function(region, uid, hideRegion)
local entry = compiled[uid]
if not entry then return end
local data = entry.data
local conditions = data.conditions or {}
local nConds = #conditions
local cloneId = region.cloneId or ""
local now = GetTime()
if nConds == 0 then
scheduleRecheck(region, uid, cloneId, nil, now)
return
end
local states = region.states or {}
local prev = getActivated(uid, cloneId)
-- Phase 1: evaluate every condition, collecting the earliest timer flip.
-- The three working sets are pooled scratch: this runs on every state change
-- of every region with conditions. A custom check can re-enter dispatch, so
-- each run takes its own tables rather than sharing file-level ones.
local newActive = WA.AcquireScratch()
local nextFlip
for i = 1, nConds do
local active, flip
if hideRegion then
active = false
else
active, flip = evalCheck(conditions[i].check, states, entry, now, region)
end
newActive[i] = active and true or false
if flip and (not nextFlip or flip < nextFlip) then nextFlip = flip end
end
-- Phase 2 (deactivation): conditions active before but not now write their
-- properties' base values. Phase 3 (activation): active conditions write
-- their change values, later conditions overwriting earlier -- so a
-- contested property goes to the last matching condition, and a property no
-- active condition touches is restored. changeEntry/changeValue hold one
-- entry per touched property; applied once each afterwards (no mid-run
-- flicker, §10).
local changeEntry, changeValue = WA.AcquireScratch(), WA.AcquireScratch()
for i = 1, nConds do
if prev[i] and not newActive[i] then
local changes = conditions[i].changes or {}
for c = 1, #changes do
local prop = changes[c].property
local pentry = prop and entry.properties[prop]
if pentry and not pentry.action then
changeEntry[prop], changeValue[prop] = pentry, getBase(data, pentry)
elseif pentry and pentry.action == "SoundPlay"
and type(changes[c].value) == "table"
and changes[c].value.sound_type == "Loop" then
local target = region
if pentry.isSub then target = region.subRegions and region.subRegions[pentry.subIndex] end
if target and target.SoundRepeatStop then target:SoundRepeatStop() end
end
end
end
end
for i = 1, nConds do
if newActive[i] then
local changes = conditions[i].changes or {}
for c = 1, #changes do
local prop = changes[c].property
local pentry = prop and entry.properties[prop]
if pentry and pentry.action then
if not prev[i] then
local target = region
if pentry.isSub then target = region.subRegions and region.subRegions[pentry.subIndex] end
if target and target[pentry.action] then
local value = changes[c].value
if pentry.type == "customcode" and type(value) == "string" then
value = WA.LoadFunction(value, uid .. ": condition custom code", true)
end
if pentry.type == "sound" then
target[pentry.action](target, value)
elseif pentry.type == "chat" then
target[pentry.action](target, value)
else
target[pentry.action](target, value)
end
end
end
elseif pentry then
changeEntry[prop], changeValue[prop] = pentry, changes[c].value
end
end
end
end
for prop, pentry in pairs(changeEntry) do
applyProperty(region, pentry, changeValue[prop])
end
for i = 1, nConds do prev[i] = newActive[i] end
WA.ReleaseScratch(newActive)
WA.ReleaseScratch(changeEntry)
WA.ReleaseScratch(changeValue)
scheduleRecheck(region, uid, cloneId, hideRegion and nil or nextFlip, now)
end
-- ---------------------------------------------------------------------------
-- Hooks the state machine calls (overriding Data.lua's no-op stubs). The
-- StateMachine looks these WA.* names up at call time, so overriding here --
-- loaded after StateMachine, before AddAllDisplays -- is enough.
-- ---------------------------------------------------------------------------
WA.RunConditions = function(region, uid, hideRegion)
if not uid then return end
WA.safecall(uid, runFor, region, uid, hideRegion)
end
function WA.ReleaseConditionsForClone(uid, cloneId)
if not uid then return end
local byClone = activated[uid]
if byClone then byClone[cloneId] = nil end
cancelRecheck(uid .. "\0" .. cloneId)
end
function WA.LoadConditions(data)
if WA.IsGroup(data) then return end
local uid = data.uid
local entry = {
data = data,
properties = WA.GetProperties(data),
templates = WA.GetConditionTemplates(data),
customChecks = {},
}
compiled[uid] = entry
local events = {}
local conditions = data.conditions or {}
for i = 1, #conditions do
collectCustomChecks(conditions[i].check, entry, events,
tostring(data.id) .. ": condition " .. i .. " custom check")
end
setCustomCheckEvents(uid, events)
-- Forget prior activation so a recompiled aura re-evaluates from scratch
-- (its regions get re-applied by the WA.Add that called us).
activated[uid] = nil
end
function WA.UnloadConditions(data)
local uid = data.uid
compiled[uid] = nil
activated[uid] = nil
setCustomCheckEvents(uid, {})
for key in pairs(scheduled) do
local sep = string.find(key, "\0", 1, true)
if sep and string.sub(key, 1, sep - 1) == uid then cancelRecheck(key) end
end
end
-- Read-only debug view (Debug.lua's /wa conditions): per-clone activation flags
-- and whether an exact recheck is pending.
function WA.GetConditionDebug(uid)
local pending = {}
for key in pairs(scheduled) do
local sep = string.find(key, "\0", 1, true)
if sep and string.sub(key, 1, sep - 1) == uid then
table.insert(pending, string.sub(key, sep + 1))
end
end
return compiled[uid], activated[uid], pending
end
-- ---------------------------------------------------------------------------
-- Global-condition events: re-run every shown region's conditions on a combat/
-- target change. Cheap at this scale, so it runs unconditionally rather than
-- tracking which auras actually use a global condition (§10 dynamic events).
-- ---------------------------------------------------------------------------
local function rerunShown(region, id)
if region.toShow then
local data = WeakestAurasDB.displays[id]
if data and data.uid then WA.RunConditions(region, data.uid, false) end
end
end
local globalFrame = CreateFrame("Frame")
for i = 1, #GLOBAL_EVENTS do globalFrame:RegisterEvent(GLOBAL_EVENTS[i]) end
globalFrame:SetScript("OnEvent", function()
if not WA.ForEachRegion then return end
WA.ForEachRegion(rerunShown)
end)
-- ---------------------------------------------------------------------------
-- Custom Check events: only the auras that named an event, and only their shown
-- clones. Narrower than the global sweep above on purpose -- an author is free
-- to list a high-rate event, and a fixed list of three cannot be.
--
-- Delivered by wrapping WA.ScanEvents rather than by a frame of our own: that
-- is where the trigger system already funnels both game events and its internal
-- WA_* ones, so listing either kind in a check works and neither arrives twice.
-- ---------------------------------------------------------------------------
local function rerunConditions(uid)
local entry = compiled[uid]
if not entry or not WA.ForEachClone then return end
WA.ForEachClone(entry.data.id, function(region)
if region.toShow then WA.RunConditions(region, uid, false) end
end)
end
local originalScanEvents = WA.ScanEvents
WA.ScanEvents = function(event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
originalScanEvents(event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
local listeners = customCheckEvents[event]
if not listeners then return end
for uid in pairs(listeners) do rerunConditions(uid) end
end