WeakAura Patterns

WeakAura Patterns

Some of the patterns I use in or for my WeakAuras. This will grow over time.

Queueing & Custom Events

It's technically a basic pattern once you've dabbled with WeakAuras and custom code in general but absolutely worth documenting.

You may find yourself in a situation where:

  • you need to listen to loads of events, but updating on every single of them is unperformant
  • you need to listen to an unknown amount of events and you don't know when the last one occurs within a window

It's advisable to enqueue a custom event with your possibly aggregated data and then react to said event in a separate branch.

This would be a TSU trigger:

function (states, event, ...)
  -- for the sake of a high-frequency example
  if event == "COMBAT_LOG_EVENT_UNFILTERED" then 
    -- do evil

    -- whatever your criteria are
    if matchesCriteria or aggregatedData then
      aura_env.queue()
    end

    -- important to prevent these events from updating the aura
    return false
  end

  if event == aura_env.customEventName then
    local id = ...

    -- make sure the event is actually from us
    if id ~= aura_env.id then
      return false
    end

    -- perform state update based on either the payload from the custom event or aggregated data
    return true
  end

  -- ignore STATUS, OPTIONS etc.
  return false
end

and in aura_env:

aura_env.customEventName = "SOMETHING_DEFINITELY_UNIQUE"
aura_env.timer = nil

function aura_env.queue()
 if aura_env.timer then
  if not aura_env.timer:IsCancelled() then
   aura_env.timer:Cancel()
  end

  aura_env.timer = nil
 end

 -- 3 is seconds here, whatever you need. 0 means next frame
 aura_env.timer = C_Timer.NewTimer(3, function()
  WeakAuras.ScanEvents(aura_env.customEventName, aura_env.id)
 end)
end

Don't forget to include the custom event name in your events to trigger from!

Example: Breath of Eons

Breath of Eons applies a debuff with a dynamic duration on every target the casting Evoker is flying over, meaning we have a variable application time, duration and amount of affected targets.

Eventually, if at least one target lives for the duration of the debuff, it'll expire and do some damage.

Goal here is to announce the total damage done by the spell, no visual.

We'll need to listen to COMBAT_LOG_EVENT_UNFILTERED filtering for SPELL_DAMAGE and SPELL_MISSED (to catch full absorbs) in the trigger:

--- CLEU:SPELL_DAMAGE:SPELL_MISSED, XEPHUI_BREATH_OF_EONS_ANNOUNCER
function (event, ...)
 if event == "COMBAT_LOG_EVENT_UNFILTERED" then
  local _, subEvent = ...
  local key = nil
  local addition = 0

  if subEvent == "SPELL_DAMAGE" then
   local _, _, _, sourceGUID, _, _, _, _, _, _, _, spellId, _, _, amount, _, _, _, _, absorbed = ...

   if spellId == 409632 then
    key = sourceGUID
    addition = amount + (absorbed or 0) -- to account for partial absorbs
   end
  elseif subEvent == "SPELL_MISSED" then
   local _, _, _, sourceGUID, _, _, _, _, _, _, _, spellId, _, _, missType, _, amount = ...

   if missType == "ABSORB" and spellId == 409632 then
    key = sourceGUID
    addition = amount
   end
  end

  if addition > 0 and key then
   aura_env.bySource[key] = aura_env.bySource[key] or 0
   aura_env.bySource[key] = aura_env.bySource[key] + addition
   aura_env.queue()
  end

  return false
 end

 if event == aura_env.customEventName then
  local id = ...

  if id ~= aura_env.id then
   return false
  end

  aura_env.timer = nil

  local message = aura_env.getMessage()

  if aura_env.config.announce then
   SendChatMessage(message, "SAY")
  else
   print(message)
  end

  return false
 end

 return false
end

and its accompanying Init Action:

aura_env.customEventName = "XEPHUI_BREATH_OF_EONS_ANNOUNCER"
aura_env.timer = nil

---@type table<string, number>
aura_env.bySource = {}

function aura_env.queue()
 if aura_env.timer then
  if not aura_env.timer:IsCancelled() then
   aura_env.timer:Cancel()
  end

  aura_env.timer = nil
 end

 -- 3 seconds here to definitely catch all events which may be significantly delayed based on debuff application time
 aura_env.timer = C_Timer.NewTimer(3, function()
  WeakAuras.ScanEvents(aura_env.customEventName, aura_env.id)
 end)
end

-- custom formatter for float precision purposes, Blizz provides a similar function but it truncates
---@param number number
---@return string
local function formatNumber(number)
 if number > 1000000 then
  return string.format("%1.2fM", number / 1000000)
 end

 if number > 1000 then
  return string.format("%1.2fK", number / 1000)
 end

 return number
end

local baseBlueprint = C_Spell.GetSpellName(403631) .. " hit for %s!"
local maxContributorBlueprint = "Highest contributor: %s with %s (%s%%)"

---@return string
function aura_env.getMessage()
 local total = 0
 local individualMax = 0
 local maxSource = nil

 for sourceGUID, amount in pairs(aura_env.bySource) do
   total = total + amount

   if amount > individualMax then
    individualMax = amount
    maxSource = sourceGUID
   end
 end

 table.wipe(aura_env.bySource)

 local base = string.format(baseBlueprint, formatNumber(total))

 if aura_env.config.includeContributor and individualMax > 0 and maxSource ~= nil then
  local token = UnitTokenFromGUID(maxSource)
  local name = token and UnitName(token) or nil

  if name then
   return string.format(
    base .. " " .. maxContributorBlueprint,
    name,
    formatNumber(individualMax),
    string.format("%.2f", individualMax / total * 100)
   )
  end
 end

 return base
end

Why does this work?

The order of events is as follows:

  • multiple, slightly delayed CLEU from player 1 -> store values -> queue
  • multiple, slightly delayed CLEU from player 2 -> store values -> kill timer -> queue again
  • multiple, slightly delayed CLEU from player 3 -> store values -> kill timer -> queue again
  • multiple, slightly delayed CLEU from player 4 -> store values -> kill timer -> queue again
  • note these will very most likely also be in entirely variable orders
  • eventually there's no new CLEU events
  • custom event -> read values -> clear state -> announce

Write your code in some editor

Seriously. The amount of badly indented custom WA code out there that clearly wasn't written in an editor without formatting, autocompletion etc. is mind-boggling.

I understand there's obviously a gap both in expectation and experience between professional developers and hobbyists but in my opinion, especially as hobbyist the gains are even larger.

Some tooling I use:

  • VS Code is obviously a good and easy choice for this
    • use the Lua extension
    • as well as StyLua for formatting
    • naturally WoW API for autocompletion where possible

You'll avoid:

  • accidental shadowing
  • accidental global variables
  • headaches from limited editing options ingame
    • no shade to the WA team, but editing quality is naturally better in an environment made for it. WoW is not that environment!

You'll gain:

  • general quality of life you gain from using a tool that's made for it
  • versioning if you wanna use git
  • an actual file system that you can search and copy from
  • types, if you're into that (please be)

Finding Spell IDs

Regardless of whether you're working with custom code or just create some auras that should work across locales, you have to get your game ids from somewhere. For things like auras, talents, cast ids, anything that is immediately visible in game, addons fill that gap nicely - personally I'm just using idTip.

But what about damage/healing ids? Debuffs on players in a dungeon, fleeting things you either can't look at directly or effectively not in the heat of the moment? The answer at this point is obvious: use logs.

Any spell icon is a link to wowhead, e.g. this Eruption. The id is directly in the url: 395160. This only leaves you with having to find a log but chances are, you're not the first one to use that spell or play the content it's used in so luckily that won't be an issue.

For fringe cases like early alpha/beta/ptr cycles, you can always log target dummies in some capital yourself.