Files
github-actions[bot] 9588a9cf0a WeakestAuras 0.18.0
ClassicAPI 1.18 is now the only requirement

Requirements
- Needs ClassicAPI 1.18.0 or newer. SuperWoW is no longer used, and Nampower is optional.
- On an older ClassicAPI the addon stays off, and a popup links the download.
- Custom triggers on SuperWoW or Nampower events only fire while that mod is installed.

Triggers
- New Combat Log trigger, with lists of spell IDs, spell names and NPC IDs.
  WeakAuras2 auras that use one now import.
- Custom triggers can listen to the combat log with CLEU or CLEU:SUBEVENT.
- Crowd Control matches a specific type (stun, fear, root, silence, disarm and
  more) or a school lockout, with the effect's real timer.
- Saved Crowd Control auras: Root, Silence and Disarm filters carry over; CC and
  Snare become "any type", and snares are no longer detected.
- Cast bars follow pushback and channel updates.

Displays
- Icon cooldown swipes are drawn by the client's own cooldown frame.
- Sound actions play on a chosen channel, and a new Stop sound type ends a
  looping sound.

Fixes
- Cooldown auras no longer sweep on every wand shot.
- The Global Cooldown trigger ignores wand and Auto Shot shots.
2026-10-29 20:41:53 +00:00

2972 lines
120 KiB
Lua

-- WeakestAuras -- WeakAuras2 display translation and import compatibility report.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
local REGION_MAP = {
icon = "icon",
aurabar = "progressbar",
texture = "texture",
progresstexture = "progresstexture",
text = "text",
empty = "empty",
group = "group",
dynamicgroup = "dynamicgroup",
model = "model",
stopmotion = "fallback",
}
local GROUP_TYPES = { group = true, dynamicgroup = true }
-- A bar texture travels as a LibSharedMedia *name*, and the sender's media
-- registry is not ours: an unknown name resolves to a missing file and the bar
-- draws nothing at all, which reads as a broken import rather than a missing
-- skin. Every name therefore lands on a bundled texture.
--
-- Names we ship (`WA.Widgets.BarTextures()`) pass through untouched. The aliases
-- below are the common wago names matched to the closest thing we have -- flat
-- fills to Flat, gradient/gloss fills to the bundled grain that looks like them.
-- Anything unlisted falls back to the region default and is reported, so the
-- user knows a skin was substituted rather than wondering why a bar looks plain.
local BAR_TEXTURE_ALIASES = {
["Solid"] = "Flat",
["ElvUI Blank"] = "Flat",
["Details Flat"] = "Flat",
["Details Hyanda"] = "Flat",
["Details D'ictum"] = "Flat",
["Blizzard Raid Bar"] = "Blizzard",
["Blizzard Raid Bar 2"] = "Blizzard",
["Melli Dark"] = "MelliDark",
["Melli Dark Rough"] = "MelliDark",
["Minimalist"] = "Minimalist",
["X-Perl"] = "Gloss",
["X-Perl 3"] = "Gloss",
["Glaze v2"] = "Glaze2",
["Perl v2"] = "Perl2",
["Smooth v2"] = "Smoothv2",
["LiteStepLite"] = "LiteStep",
["Bantobar"] = "BantoBar",
["Skewed"] = "Smooth",
}
local BAR_TEXTURE_DEFAULT = "Blizzard"
local PROTOTYPE_KEYS = {
"width", "height", "alpha", "anchorFrameType", "anchorFrameFrame",
"anchorFrameParent", "selfPoint", "anchorPoint", "xOffset", "yOffset",
"frameStrata", "customText", "customTextUpdate", "customTextUpdateThrottle",
}
local TEXT_KEYS = {
"font", "outline", "justify", "justifyV", "spacing", "shadowColor",
"shadowXOffset", "shadowYOffset",
}
-- Wago's own bookkeeping: an id, a version stamp, the update-channel flags. It
-- travels on every display and none of it is display data, so reporting it per
-- key per display costs a 52-child pack 192 lines of the summary and buries
-- everything in it that is a real loss. Not carried, and noted once for the
-- whole import instead of dropped per display.
local WAGO_BOOKKEEPING_KEYS = {
"information", "semver", "skipWagoUpdate", "ignoreWagoUpdate",
"preferToUpdate", "wagoID", "tocversion",
}
local ANIMATION_CODE_KEYS = {
"translateFunc", "alphaFunc", "scaleFunc", "rotateFunc", "colorFunc",
}
local ACTION_CODE_KEYS = {
"custom", "customOnLoad", "customOnUnload", "message_custom",
}
local TRIGGER_CODE_KEYS = {
{ key = "custom", label = "custom" },
{ key = "customDuration", label = "duration" },
{ key = "customName", label = "name" },
{ key = "customIcon", label = "icon" },
{ key = "customTexture", label = "texture" },
{ key = "customStacks", label = "stacks" },
{ key = "customVariables", label = "variables" },
{ key = "customOverlay1", label = "overlay1" },
{ key = "customOverlay2", label = "overlay2" },
{ key = "customOverlay3", label = "overlay3" },
}
local DISPLAY_CODE_KEYS = {
{ key = "customText", label = "custom text" },
{ key = "customSort", label = "custom sort" },
{ key = "customGrow", label = "custom grow" },
{ key = "customAnchorPerUnit", label = "custom anchor" },
}
-- Group fields carried beyond the region defaults. controlledChildren is
-- deliberately not among them and never copied: the child list is rebuilt from
-- the payload's own tree, whose ids are all reassigned on install.
local GROUP_KEYS = {
"customSort", "hybridPosition", "hybridSortMode", "sortHybridTable",
}
local GROW_TYPES = {
UP = true, DOWN = true, LEFT = true, RIGHT = true,
HORIZONTAL = true, VERTICAL = true, GRID = true,
CIRCLE = true, COUNTERCIRCLE = true, CUSTOM = true,
}
-- Group fields with no local counterpart at all, each changing how the group
-- behaves rather than how one aura is decorated, so each is reported.
--
-- `growOn` is deliberately not among them. It is upstream's list of the state
-- fields whose change re-runs a custom grow; ours re-runs the grow on every
-- layout pass, so dropping it changes how often the author's function runs and
-- never what it produces. The function itself is carried.
local GROUP_DROP_KEYS = { "sortOn", "anchorOn" }
-- A group's border here is one fixed edge; upstream picks an edge file, a
-- backdrop and three insets. Reported only when the border is actually on --
-- every export carries the block whether or not it is drawn.
local GROUP_BORDER_ART_KEYS = {
"borderEdge", "borderOffset", "borderInset", "borderSize",
"borderBackdrop", "backdropColor", "background", "backgroundInset",
}
local ACTION_SPECS = {
init = {
{ key = "custom", enabled = "do_custom", body = true },
{ key = "customOnLoad", enabled = "do_custom_load", body = true },
{ key = "customOnUnload", enabled = "do_custom_unload", body = true },
},
start = {
{ key = "custom", enabled = "do_custom", body = true },
{ key = "message_custom", enabled = "do_message", body = false },
},
finish = {
{ key = "custom", enabled = "do_custom", body = true },
{ key = "message_custom", enabled = "do_message", body = false },
},
}
local ACTION_DROP_KEYS = {
"message_channel",
}
-- The glow targets Actions.lua can resolve. Upstream reaches its unit frames
-- through LibGetFrame; ours resolve the same two off the region's stored unit.
local GLOW_FRAME_TYPES = {
PARENTFRAME = true, FRAMESELECTOR = true, UNITFRAME = true, NAMEPLATE = true,
}
local AURA_TRIGGER_KEYS = {
"unit", "specificUnit", "debuffType", "useName", "auranames", "matchesShowOn",
"ownOnly", "useStacks", "stacksOperator", "stacks", "useRem", "remOperator", "rem",
"useTotal", "totalOperator", "total", "use_debuffClass", "debuffClass",
}
-- Upstream's name-pattern operators, and the closest thing this addon's aura
-- trigger has. Ours is a plain substring test (TriggerAura's `string.find` with
-- plain = true) inverted by "nomatch", so only `find('%s')` translates exactly;
-- the other two are approximations and say so.
local AURA_NAME_PATTERN_OPERATORS = {
["find('%s')"] = { op = "match" },
["=="] = { op = "match", note = "exact name match becomes a substring match" },
["match('%s')"] = { op = "match", note = "Lua pattern match becomes a substring match" },
}
local CUSTOM_TRIGGER_KEYS = {
"custom_type", "check", "events", "custom", "custom_hide", "duration",
"customDuration", "customName", "customIcon", "customVariables",
}
local CUSTOM_UNSUPPORTED_KEYS = {
"customTexture", "customStacks", "customOverlay1", "customOverlay2", "customOverlay3",
}
local AURA_CLONE_KEYS = {
"combineMode", "combinePerUnit", "showClones", "perUnitMode", "useMatch_count",
"match_count", "match_countOperator", "useMatchPerUnit_count", "matchPerUnit_count",
"matchPerUnit_countOperator",
}
local CUSTOM_TYPES = { status = true, event = true, stateupdate = true }
-- Generic-trigger condition variables upstream spells differently, theirs ->
-- ours, per prototype -- so the `name` a dozen prototypes share on both sides is
-- only redirected where it really differs. The trigger fields these belong to
-- are mapped by GENERIC_ARG_NAMES, which is the same divergence seen from the
-- editor's side rather than a second one.
local GENERIC_VARIABLE_NAMES = {
-- Upstream's aura trigger calls "did the aura match" `buffed`, and offers it
-- only when the trigger shows a state either way -- which is the one case
-- where our own `active` says anything, so the two are the same flag.
aura = { buffed = "active" },
-- Upstream's cast trigger stores the spell name as `spell`; ours is the
-- `name` every other prototype uses.
cast = { spell = "name" },
health = { deficit = "healthDeficit" },
power = { deficit = "powerDeficit" },
-- A combo aura's conditions were written against upstream's Power trigger,
-- so they name its `power` variable rather than ours.
combopoints = { power = "comboPoints", percentpower = "percentComboPoints",
deficit = "comboPointDeficit" },
charstats = { attackpower = "attackPower", spellpower = "spellPower" },
experience = { currentXP = "xp", totalXP = "xpMax", percentXP = "xpPercent" },
rangecheck = { range = "distance" },
unitcharacteristics = { name = "unitName" },
}
local SUBTEXT_KEYS = {
"text_text", "text_color", "text_fontSize", "text_font", "text_fontType",
"text_visible", "text_justify", "text_justifyV", "text_spacing",
"text_shadowColor", "text_shadowXOffset", "text_shadowYOffset",
"anchor_point", "anchorXOffset", "anchorYOffset", "rotateText",
}
local SUBTEXT_DROP_KEYS = {
"text_automaticWidth", "text_fixedWidth", "text_wordWrap",
}
local SUBBORDER_KEYS = {
"border_visible", "border_color", "border_size", "border_offset", "anchor_mode", "anchor_area",
}
-- Edge art that is a plain solid line, which is exactly what SubBorder draws
-- with WHITE8X8 sized by border_size. Anything outside this set is real art we
-- have no counterpart for, and still reports.
local SOLID_BORDER_EDGES = {
["Square Full White"] = true, ["1 Pixel"] = true, ["None"] = true,
}
local SUBGLOW_KEYS = {
"glow", "useGlowColor", "glowColor", "glowType", "glowLines", "glowFrequency",
"glowLength", "glowThickness", "glowScale", "glowBorder", "glowXOffset",
"glowYOffset", "anchor_mode", "anchor_area",
}
local SUBTICK_KEYS = {
"tick_visible", "tick_color", "tick_placement_mode", "automatic_length",
"tick_thickness", "tick_length", "use_texture", "tick_texture", "tick_blend_mode",
"tick_desaturate", "tick_rotation", "tick_xOffset", "tick_yOffset", "tick_mirror",
"tick_placements",
}
local SUBREGION_TYPES = {
subtext = true, subborder = true, subglow = true, subtick = true,
subbackground = true, subforeground = true,
}
local DROPPED_FORMATS = { Unit = true, GUID = true, GCDTime = true }
local function hasSource(value)
return type(value) == "string" and string.find(value, "%S") ~= nil
end
local function reportDrop(report, reason, detail)
table.insert(report.dropped, { reason = reason, detail = detail })
end
-- ---------------------------------------------------------------------------
-- Media paths
--
-- An export carries absolute paths into the *sender's* WeakAuras install
-- (`Interface\Addons\WeakAuras\PowerAurasMedia\Auras\Aura22`), and this client
-- has never had that addon, so the file is not there and the region paints the
-- engine's missing-texture block. The same art and sounds are bundled here, so
-- the path is rewritten onto our copy.
--
-- Three properties of the incoming string are load-bearing, all of them observed
-- in the wago corpus:
--
-- * **Case varies.** Upstream writes both `Addons` and `AddOns`, so every
-- comparison happens on a lowercased copy.
-- * **The extension is optional.** `...\Square_FullWhite` and
-- `...\Ring_10px.tga` are both real exported values for the same kind of
-- field, so a lookup that misses tries again without the extension.
-- * **Either separator can appear.** Normalised to `\` before matching.
--
-- The destination is looked up in the catalogues the pickers are built from
-- rather than computed from the source name. Rewriting onto a file we do not
-- ship would replace a dead path the report can name with one that looks live
-- and still draws nothing.
-- ---------------------------------------------------------------------------
local MEDIA_ROOTS = {
{ prefix = "interface\\addons\\weakauras\\media\\textures\\", kind = "texture" },
{ prefix = "interface\\addons\\weakauras\\poweraurasmedia\\auras\\", kind = "texture" },
{ prefix = "interface\\addons\\weakauras\\media\\sounds\\", kind = "sound" },
{ prefix = "interface\\addons\\weakauras\\poweraurasmedia\\sounds\\", kind = "sound" },
{ prefix = "interface\\addons\\weakauras\\media\\fonts\\", kind = "font" },
}
-- The bundled statusbars dropped upstream's Statusbar_ prefix to match the bare
-- names this addon's bar picker stores, so they are the one set whose incoming
-- name does not find itself in WA.textureTypes.
local BAR_TEXTURE_RENAMES = {
Statusbar_Clean = "Clean", Statusbar_Stripes = "Stripes",
Statusbar_Stripes_Thick = "ThickStripes", Statusbar_Stripes_Thin = "ThinStripes",
}
local mediaIndex
-- Files a texture path may omit the extension of, so both spellings resolve.
local function addMedia(index, path)
local _, _, file = string.find(path, "([^\\]+)$")
if not file then return end
local lower = string.lower(file)
index[lower] = path
local _, _, stem = string.find(lower, "^(.+)%.[^.]*$")
if stem and index[stem] == nil then index[stem] = path end
end
local function buildMediaIndex()
local index = { texture = {}, sound = {}, font = {} }
for category, list in pairs(WA.textureTypes or {}) do
for i = 1, #list do
addMedia(index.texture, WA.Widgets.TexturePath(category, list[i]))
end
end
for name, bar in pairs(BAR_TEXTURE_RENAMES) do
addMedia(index.texture, WA.Widgets.BarTexturePath(bar))
-- BarTexturePath returns an extensionless path, so addMedia's own stem
-- pass never fires and the upstream spelling has to be keyed by hand.
index.texture[string.lower(name)] = WA.Widgets.BarTexturePath(bar)
end
for path in pairs(WA.bundled_sound_types or {}) do addMedia(index.sound, path) end
local fonts = WA.textCore and WA.textCore.FONTS or {}
for i = 1, #fonts do addMedia(index.font, fonts[i].path) end
return index
end
-- The bundled path for a WeakAuras2 media path, or nil for anything else --
-- a path into a third-party addon, or into a WeakAuras folder whose file this
-- addon does not carry. Both stay as they are and are reported.
local function rewriteMedia(value)
if type(value) ~= "string" or value == "" then return nil end
local normalized = string.gsub(value, "/", "\\")
local lower = string.lower(normalized)
for i = 1, #MEDIA_ROOTS do
local root = MEDIA_ROOTS[i]
if string.sub(lower, 1, string.len(root.prefix)) == root.prefix then
mediaIndex = mediaIndex or buildMediaIndex()
local file = string.sub(lower, string.len(root.prefix) + 1)
local index = mediaIndex[root.kind]
local hit = index[file]
if not hit then
local _, _, stem = string.find(file, "^(.+)%.[^.]*$")
hit = stem and index[stem]
end
return hit
end
end
return nil
end
-- Fields whose value is a media path, wherever they sit. Walked recursively over
-- the finished display rather than rewritten at each translation site, so an
-- action, a condition's change value, a sub-region and a user-config default are
-- all covered by one pass and none can be forgotten when a new one is added.
--
-- `sound` also names a *condition property*, but that is a `property` key rather
-- than a `sound` one, and the paths it points at live in the change's `value`
-- table -- which this walk reaches anyway.
--
-- The kind here is documentation: `rewriteMedia` takes the kind from the path's
-- own MEDIA_ROOTS prefix, so this table only decides *whether* a key is walked.
-- That is what lets `media` -- an author option's chosen path, whose kind is
-- whatever its sibling `mediaType` says -- sit in a table keyed by one kind.
local MEDIA_KEYS = {
displayIcon = "texture", groupIcon = "texture", texture = "texture",
foregroundTexture = "texture", backgroundTexture = "texture",
sparkTexture = "texture", tick_texture = "texture",
sound = "sound", sound_path = "sound", media = "sound",
text_font = "font", font = "font",
}
local function remapMedia(node, report, label, seen)
if type(node) ~= "table" then return end
seen = seen or {}
if seen[node] then return end
seen[node] = true
for key, value in pairs(node) do
if type(value) == "table" then
remapMedia(value, report, label, seen)
elseif MEDIA_KEYS[key] then
local rewritten = rewriteMedia(value)
if rewritten and rewritten ~= value then
node[key] = rewritten
reportDrop(report, "WeakAuras media path rewritten",
label .. " " .. key .. ": " .. value .. " -> " .. rewritten)
end
end
end
end
local function take(dst, src, keys)
if type(src) ~= "table" then return end
for i = 1, #keys do
local key = keys[i]
if src[key] ~= nil then dst[key] = WA.DeepCopy(src[key]) end
end
end
local function copyBlock(dst, src, key)
if src[key] ~= nil then dst[key] = WA.DeepCopy(src[key]) end
end
-- The unit tokens a trigger may name here. `tokens` is the list the target
-- trigger actually offers, and passing the wrong one imports a display that
-- watches nothing: the single tokens plus the `specific` escape hatch by
-- default; the multi-unit families for a trigger that fans one out into a clone
-- per member (a generic prototype declaring `statesParameter = "unit"`, or the
-- aura system); and the aura system's own list, which adds upstream's `multi` on
-- top -- any-target GUID-keyed tracking, which TriggerAura alone has a producer
-- for. A token off the end of the right list refuses the whole display, since
-- importing it as some other unit is the failure that looks right and behaves
-- wrong.
local function isSupportedUnit(unit, tokens)
if unit == nil then return true end
tokens = tokens or WA.unit_tokens
for i = 1, #(tokens or {}) do
if tokens[i] == unit then return true end
end
return false
end
local function validateTriggerCode(source, detail, report, tableExpression)
if not hasSource(source) then return true end
local valid = true
if type(WA.LoadFunction) == "function" then
local candidate = source
if tableExpression then
candidate = "function() return \n" .. source .. "\n end"
end
valid = WA.LoadFunction(candidate, nil) ~= nil
end
if not valid then
reportDrop(report, "custom code", detail)
return false
end
return true
end
local function validateConditionCode(source, detail, report, body)
if not hasSource(source) then return false end
local valid = true
if type(WA.LoadFunction) == "function" then
valid = WA.LoadFunction(source, nil, body) ~= nil
end
if not valid then
reportDrop(report, "custom code", detail)
return false
end
return true
end
local function conditionLabel(data, index)
return tostring(data.id or "?") .. " condition " .. index
end
-- The second return of translateConditionCheck for a check that was never
-- filled in, as opposed to one that was and could not be translated. Its own
-- table so it can never collide with a detail string a caller prints.
local UNFILLED_CHECK = {}
local function conditionTemplate(templates, trigger, variable)
if trigger == -1 then return WA.globalConditions and WA.globalConditions[variable] end
if type(trigger) ~= "number" or trigger < 1 then return nil end
return templates[trigger] and templates[trigger][variable]
end
local function translateConditionCheck(source, templates, report, label, types)
if type(source) ~= "table" then return nil, "missing check" end
local trigger = source.trigger
if trigger == -2 then
if source.variable ~= "AND" and source.variable ~= "OR" then
return nil, "unknown combinator " .. tostring(source.variable)
end
if type(source.checks) ~= "table" then return nil, "combinator has no checks" end
local checks, filled = {}, 0
for i = 1, #source.checks do
local child, detail = translateConditionCheck(source.checks[i], templates, report,
label .. " check " .. i, types)
if child then
table.insert(checks, child)
filled = filled + 1
elseif detail ~= UNFILLED_CHECK then
filled = filled + 1
reportDrop(report, "condition check", label .. " check " .. i .. ": " .. tostring(detail))
end
end
if #checks == 0 then
return nil, (filled == 0 and UNFILLED_CHECK) or "all combinator checks are invalid"
end
return { trigger = -2, variable = source.variable, checks = checks }
end
-- A row the author added in the options UI and never filled in. Upstream
-- evaluates a check only once it names both halves (its walk falls to
-- `elseif (trigger and variable)` after the -2 and -1 cases), so this is not a
-- condition that failed to translate and must not be reported as one.
if trigger == nil or source.variable == nil then return nil, UNFILLED_CHECK end
local variable = source.variable
local renames = types and types[trigger] and GENERIC_VARIABLE_NAMES[types[trigger]]
if renames and renames[variable] then variable = renames[variable] end
local template = conditionTemplate(templates, trigger, variable)
if not template then
return nil, "unknown variable " .. tostring(source.variable) .. " for trigger " .. tostring(trigger)
end
-- A custom check's `value` is Lua that will run and its `op` the event list
-- that runs it, so unlike every other check it has to clear the same syntax
-- bar a condition's custom-code *change* does. Source that will not compile
-- drops the check rather than importing a condition that can only be false.
if trigger == -1 and variable == "customcheck" then
-- No code at all is a row the author added and never filled in, which
-- upstream generates no test for -- the same reading the trigger/variable
-- guard above gives an empty check.
if not hasSource(source.value) then return nil, UNFILLED_CHECK end
if not validateConditionCode(source.value, label .. " custom check", report) then
return nil, "custom check code"
end
end
local check = { trigger = WA.DeepCopy(trigger), variable = WA.DeepCopy(variable) }
if source.op ~= nil then check.op = WA.DeepCopy(source.op) end
if source.value ~= nil then check.value = WA.DeepCopy(source.value) end
return check
end
local function conditionCodeValue(value)
if type(value) == "table" and type(value.custom) == "string" then return value.custom end
if type(value) == "string" then return value end
return nil
end
local function translateConditionChanges(source, properties, report, label)
if type(source) ~= "table" then return nil, "missing changes" end
local changes = {}
for i = 1, #source do
local change = source[i]
if type(change) ~= "table" then
return nil, "invalid change"
end
-- A property-less change is a row the author added and never filled in.
-- Upstream's code generator emits nothing for it, so skipping it is what
-- upstream does -- and refusing the whole condition over it would take
-- out the filled rows beside it.
if change.property ~= nil and properties[change.property] == nil then
return nil, "unknown property " .. tostring(change.property)
end
if change.property ~= nil then
local property = change.property
local value = WA.DeepCopy(change.value)
-- An absent value is not an unfilled row: upstream serializes the *off*
-- setting of a bool as no value at all, so a "turn the glow off" change
-- arrives looking identical to one the author never filled in. Its code
-- generator coerces nil per property type (formatValueForAssignment), and
-- dropping the change instead leaves the property latched on once any
-- condition has set it -- which is how a pack's glow never goes out.
--
-- Only the types upstream gives a nil a meaning are coerced. The rest --
-- list, icon, sound, chat, glowexternal -- generate `nil` there too, so
-- for those the row really is unset and still drops.
if change.value == nil then
local ptype = properties[property].type
if ptype == "bool" then value = false
elseif ptype == "number" then value = 0
elseif ptype == "color" then value = { 1, 1, 1, 1 }
elseif ptype == "string" or ptype == "texture" then value = ""
end
if value == nil then
reportDrop(report, "condition change",
label .. " " .. tostring(property) .. ": no value set")
else
table.insert(changes, { property = WA.DeepCopy(property), value = value })
end
elseif property == "customcode" then
local code = conditionCodeValue(value)
if code and validateConditionCode(code, label .. " " .. property, report, true) then
table.insert(changes, { property = WA.DeepCopy(property), value = value })
elseif not code then
reportDrop(report, "custom code", label .. " " .. property)
end
else
table.insert(changes, { property = WA.DeepCopy(property), value = value })
end
end
end
return changes
end
local function translateConditions(source, data, report)
if type(source) ~= "table" then
reportDrop(report, "condition", conditionLabel(data, 1) .. ": invalid conditions")
return {}
end
data.triggers = data.triggers or {}
local templates = WA.GetConditionTemplates(data)
local properties = WA.GetProperties(data)
local types = {}
for i = 1, #data.triggers do
local entry = data.triggers[i]
types[i] = entry and entry.trigger and entry.trigger.type
end
local conditions = {}
for i = 1, #source do
local label = conditionLabel(data, i)
local item = source[i]
local check, checkDetail = translateConditionCheck(item and item.check, templates, report, label, types)
local changes, changeDetail
if check then changes, changeDetail = translateConditionChanges(item and item.changes, properties, report, label) end
if check and changes then
table.insert(conditions, { check = check, changes = changes })
elseif checkDetail == UNFILLED_CHECK then
-- Nothing was asked for, so nothing was lost.
else
reportDrop(report, "condition", label .. ": " .. tostring(checkDetail or changeDetail))
end
end
return conditions
end
local function loadLabel(data, field)
return tostring(data.id or "?") .. ": " .. field
end
local function loadValuePresent(value)
if value == nil or value == false then return false end
if type(value) == "string" then return value ~= "" end
if type(value) == "table" then
for _, child in pairs(value) do
if loadValuePresent(child) then return true end
end
return false
end
return true
end
local function selectedLoadValues(block)
local values = {}
if type(block) ~= "table" or type(block.multi) ~= "table" then return values end
for key, selected in pairs(block.multi) do
if selected then table.insert(values, key) end
end
return values
end
-- Every one of these constraints takes a set here, so upstream's shape lands
-- whole and the local values need no collapsing. `use_<field>` false is
-- upstream's "several values" rather than off -- the same overload the generic
-- trigger args cross, undone by the same reading.
local function translateLoadMultiselect(source, destination, field, report, data)
local use = source["use_" .. field]
local block = source[field]
if use == nil or not loadValuePresent(block) then return end
if use == true then
local single = type(block) == "table" and block.single or nil
if single == nil then
local selected = selectedLoadValues(block)
single = #selected == 1 and selected[1] or nil
end
if single ~= nil then
destination["use_" .. field] = "single"
destination[field] = WA.DeepCopy(single)
return
end
else
local selected = selectedLoadValues(block)
if #selected > 0 then
destination["use_" .. field] = "multi"
destination[field .. "_multi"] = {}
for i = 1, #selected do
destination[field .. "_multi"][selected[i]] = true
end
return
end
end
reportDrop(report, "load constraint", loadLabel(data, field) .. " has an unsupported multi-select")
end
-- Upstream's "Class and Specialization" names a retail/MoP-Classic spec id;
-- vanilla has no specs, so the class half is honoured and the narrowing within
-- the class is the reported loss. Cata Classic hands out talent-tree ids
-- instead of spec ids and only the druid triple (748/750/752) has been seen in
-- the wild and verified; a wrong class here would hide the display for the
-- class that owns it, which is worse than the report, so nothing else from that
-- range is guessed at. Classes this client has never had (DEATHKNIGHT and
-- later) keep their token: it can never match, which is exactly what a
-- DK-only display loading on a vanilla realm should do.
local SPEC_ID_CLASS = {
[62] = "MAGE", [63] = "MAGE", [64] = "MAGE",
[65] = "PALADIN", [66] = "PALADIN", [70] = "PALADIN",
[71] = "WARRIOR", [72] = "WARRIOR", [73] = "WARRIOR",
[102] = "DRUID", [103] = "DRUID", [104] = "DRUID", [105] = "DRUID",
[250] = "DEATHKNIGHT", [251] = "DEATHKNIGHT", [252] = "DEATHKNIGHT",
[253] = "HUNTER", [254] = "HUNTER", [255] = "HUNTER",
[256] = "PRIEST", [257] = "PRIEST", [258] = "PRIEST",
[259] = "ROGUE", [260] = "ROGUE", [261] = "ROGUE",
[262] = "SHAMAN", [263] = "SHAMAN", [264] = "SHAMAN",
[265] = "WARLOCK", [266] = "WARLOCK", [267] = "WARLOCK",
[268] = "MONK", [269] = "MONK", [270] = "MONK",
[577] = "DEMONHUNTER", [581] = "DEMONHUNTER",
[1467] = "EVOKER", [1468] = "EVOKER", [1473] = "EVOKER",
[748] = "DRUID", [750] = "DRUID", [752] = "DRUID",
}
local function translateLoadClassAndSpec(source, destination, report, data)
local use = source.use_class_and_spec
local block = source.class_and_spec
if use == nil or not loadValuePresent(block) then return end
local ids
if use == true then
local single = type(block) == "table" and block.single or nil
if single == nil then
local selected = selectedLoadValues(block)
single = #selected == 1 and selected[1] or nil
end
ids = { single }
else
ids = selectedLoadValues(block)
end
local classes, n = {}, 0
for i = 1, #ids do
local cls = SPEC_ID_CLASS[tonumber(ids[i]) or -1]
if cls and not classes[cls] then
classes[cls] = true
n = n + 1
end
end
if n == 0 then
reportDrop(report, "load constraint", loadLabel(data, "class_and_spec") .. " has no mappable class")
return
end
-- A source carrying both constraints already narrowed by class; layering the
-- widened copy over it would say nothing new.
if not destination.use_class then
destination.use_class = "multi"
destination.class_multi = classes
end
reportDrop(report, "load constraint", loadLabel(data, "class_and_spec") .. " kept as class only")
end
-- Upstream's Instance Size Type. This client cannot read an instance's size, so
-- the values land on Load.lua's approximated `instancesize` buckets; the
-- retail-and-later buckets this client can never be in are dropped from a set
-- silently -- they can never match here, so unticking them loses nothing --
-- and refuse a single, where the author's one chosen value is the whole
-- constraint.
local INSTANCE_SIZE_BUCKETS = {
none = true, party = true, ten = true, twenty = true,
twentyfive = true, fortyman = true, pvp = true,
}
local function translateLoadInstanceSize(source, destination, report, data)
local use = source.use_size
local block = source.size
if use == nil or not loadValuePresent(block) then return end
if use == true then
local single = type(block) == "table" and block.single or nil
if single == nil then
local selected = selectedLoadValues(block)
single = #selected == 1 and selected[1] or nil
end
if single ~= nil and INSTANCE_SIZE_BUCKETS[single] then
destination.use_instancesize = "single"
destination.instancesize = WA.DeepCopy(single)
return
end
else
local selected = selectedLoadValues(block)
local set, n = {}, 0
for i = 1, #selected do
if INSTANCE_SIZE_BUCKETS[selected[i]] then
set[selected[i]] = true
n = n + 1
end
end
if n > 0 then
destination.use_instancesize = "multi"
destination.instancesize_multi = set
return
end
end
reportDrop(report, "load constraint", loadLabel(data, "size") .. " has an unsupported value")
end
local function translateLoad(source, data, report)
if type(source) ~= "table" then return {} end
local destination = {}
if source.never or source.use_never then destination.never = true end
for _, field in ipairs({ "class", "race", "faction", "ingroup" }) do
translateLoadMultiselect(source, destination, field, report, data)
end
translateLoadClassAndSpec(source, destination, report, data)
translateLoadInstanceSize(source, destination, report, data)
local tristates = {
combat = { trueValue = "incombat", falseValue = "outofcombat" },
alive = { trueValue = "alive", falseValue = "dead" },
mounted = { trueValue = "mounted", falseValue = "notmounted" },
vehicle = { trueValue = "ontaxi", falseValue = "nottaxi" },
}
for field, values in pairs(tristates) do
if source["use_" .. field] and source[field] ~= nil then
destination["use_" .. field] = true
destination[field] = source[field] and values.trueValue or values.falseValue
end
end
local simple = { "level", "zone", "player", "realm", "guild" }
-- Load.lua's stance constraint takes the multi-select tier, and what a source
-- carrying one holds is the single bar position an older WeakAuras stored.
-- Coerced, since the position is matched against a number.
if source.use_stance and tonumber(source.stance) then
destination.use_stance = "single"
destination.stance = tonumber(source.stance)
end
for i = 1, #simple do
local field = simple[i]
if source["use_" .. field] and source[field] ~= nil then
destination["use_" .. field] = true
destination[field] = WA.DeepCopy(source[field])
if source[field .. "_operator"] ~= nil then
destination[field .. "_operator"] = WA.DeepCopy(source[field .. "_operator"])
end
end
end
if source.use_groupSize and source.groupSize ~= nil then
destination.use_size = true
destination.size = WA.DeepCopy(source.groupSize)
destination.size_operator = WA.DeepCopy(source.groupSize_operator or source.groupSizeOperator or ">=")
end
for _, field in ipairs({ "spellknown", "not_spellknown", "itemequiped", "not_itemequiped" }) do
if source["use_" .. field] and loadValuePresent(source[field]) then
destination["use_" .. field] = true
destination[field] = WA.DeepCopy(source[field])
end
end
local consumed = {
never = true, use_never = true, class = true, race = true, faction = true, ingroup = true,
combat = true, alive = true, mounted = true, vehicle = true, level = true, zone = true,
stance = true, player = true, realm = true, guild = true, groupSize = true,
groupSize_operator = true, groupSizeOperator = true, spellknown = true, not_spellknown = true,
itemequiped = true, not_itemequiped = true,
class_and_spec = true, use_class_and_spec = true, size = true, use_size = true,
}
consumed.use_zoneIds = true
-- `spec` is a fossil, not a loss: the load option was Shadowlands-retail-only
-- (hidden and inert on every Classic flavor, whose clients have no
-- GetSpecialization) and upstream deleted it outright in late 2022. Every
-- pack observed carrying it is a Classic export whose own client ignores it
-- -- the warrior packs gate Mortal Strike, Bloodthirst and Shield Slam all on
-- "spec 1" -- so enforcing it would hide displays their authors see.
consumed.spec = true
consumed.use_spec = true
for _, field in ipairs(simple) do consumed[field .. "_operator"] = true end
for _, field in ipairs({ "combat", "alive", "mounted", "vehicle", "class", "race", "faction", "ingroup", "level", "zone", "stance", "player", "realm", "guild", "groupSize", "spellknown", "not_spellknown", "itemequiped", "not_itemequiped" }) do
consumed["use_" .. field] = true
end
consumed.use_groupSize = true
for key, value in pairs(source) do
if not consumed[key] and type(key) == "string" and string.sub(key, 1, 4) == "use_"
and value and loadValuePresent(source[string.sub(key, 5)]) then
reportDrop(report, "load constraint", loadLabel(data, string.sub(key, 5)))
end
end
if source.use_zoneIds and loadValuePresent(source.zoneIds) then
reportDrop(report, "load constraint", loadLabel(data, "zoneIds"))
end
return destination
end
local function reportStateCompatibility(trigger, triggernum, report)
if trigger.custom_type ~= "stateupdate" then return end
local code = tostring(trigger.custom or "") .. "\n" .. tostring(trigger.customVariables or "")
if string.find(code, "additionalProgress", 1, true) then
reportDrop(report, "unsupported custom state", "trigger " .. triggernum .. ": additionalProgress")
end
if string.find(code, "tooltip", 1, true) then
reportDrop(report, "unsupported custom state", "trigger " .. triggernum .. ": state.tooltip")
end
if string.find(code, "modRate", 1, true) or string.find(code, "useModRate", 1, true) then
reportDrop(report, "unsupported custom state", "trigger " .. triggernum .. ": modRate")
end
end
-- Upstream keeps names and spell ids in two lists; ours keeps one, whose entries
-- are read as an id when they parse as a number (TriggerAura's buildEntries). So
-- the spell ids append to the name list rather than needing a second field.
local function translateAuraSpellIds(trigger, source)
local ids = source.auraspellids
if type(ids) ~= "table" or #ids == 0 then return end
trigger.auranames = trigger.auranames or {}
for i = 1, #ids do
local id = tonumber(ids[i])
if id then table.insert(trigger.auranames, tostring(id)) end
end
if #trigger.auranames > 0 then trigger.useName = true end
end
-- namePattern_name/_operator upstream, namePattern/namePatternOperator here.
-- Carrying useNamePattern without the pattern is the worst of the three
-- outcomes: the runtime skips an empty pattern, so the aura silently stops
-- filtering by name at all.
local function translateAuraNamePattern(trigger, source, triggernum, report)
if not source.useNamePattern then return end
local pattern = source.namePattern_name
if type(pattern) ~= "string" or pattern == "" then
reportDrop(report, "aura name pattern", "trigger " .. triggernum .. ": no pattern text")
return
end
local mapped = AURA_NAME_PATTERN_OPERATORS[source.namePattern_operator or "find('%s')"]
if not mapped then
reportDrop(report, "aura name pattern",
"trigger " .. triggernum .. ": unsupported operator " .. tostring(source.namePattern_operator))
return
end
trigger.useNamePattern = true
trigger.namePattern = WA.DeepCopy(pattern)
trigger.namePatternOperator = mapped.op
if mapped.note then
reportDrop(report, "aura name pattern", "trigger " .. triggernum .. ": " .. mapped.note)
end
end
-- Upstream's group-wide match count -- `useGroup_count` plus an operator and a
-- count, meaning "how many of the group matched". It is `match_count` wearing a
-- different spelling, so the engine gap AURA_CLONE_KEYS refuses over is the same
-- one; but it arrives on the trigger rather than in the clone block, and
-- refusing over it would take out the whole raid-buff family.
--
-- The trigger imports and shows one clone per matching member. Naming the loss
-- matters because the common value inverts what the display means: `== 0` is
-- "nobody in the group has this buff", and what imports is "show per member who
-- does".
local function translateAuraGroupCount(source, triggernum, report)
if not source.useGroup_count then return end
reportDrop(report, "aura group match count",
"trigger " .. triggernum .. ": " .. tostring(source.group_countOperator or "==")
.. " " .. tostring(source.group_count))
end
local function translateAuraTrigger(source, triggernum, report)
if not isSupportedUnit(source.unit, WA.unit_tokens_aura) then
report.refused = "trigger " .. triggernum .. " uses unsupported unit " .. tostring(source.unit)
return nil
end
for i = 1, #AURA_CLONE_KEYS do
local key = AURA_CLONE_KEYS[i]
if source[key] ~= nil and (key == "combineMode" or source[key]) then
report.refused = "trigger " .. triggernum .. " uses clone-dependent aura setting " .. key
return nil
end
end
local trigger = {}
take(trigger, source, AURA_TRIGGER_KEYS)
trigger.type = "aura"
translateAuraGroupCount(source, triggernum, report)
translateAuraSpellIds(trigger, source)
translateAuraNamePattern(trigger, source, triggernum, report)
-- A trigger with the name filters off and nothing to match on never shows
-- (TriggerAura's buildTriggerInfo skips it), which looks like an aura that
-- imported fine and is simply broken. Naming it is the whole difference.
if not trigger.useName and not trigger.useNamePattern then
reportDrop(report, "aura trigger matches nothing",
"trigger " .. triggernum .. ": no name or spell id survived translation")
end
return trigger
end
local function translateCustomTrigger(source, entry, triggernum, report)
if source.custom_type ~= nil and not CUSTOM_TYPES[source.custom_type] then
report.refused = "trigger " .. triggernum .. " uses unsupported custom type " .. tostring(source.custom_type)
return nil
end
local trigger = {}
take(trigger, source, CUSTOM_TRIGGER_KEYS)
trigger.type = "custom"
-- The event list travels as written -- including retail names like
-- UNIT_POWER_UPDATE and GROUP_ROSTER_UPDATE, which GenericTrigger's
-- EVENT_COMPAT registers and fires natively. Rewriting them here would make
-- an imported trigger's events box mean something different from a
-- hand-written one's, and would leave the author's own `event ==` tests
-- comparing against names the rewrite took away.
for i = 1, #CUSTOM_UNSUPPORTED_KEYS do
local key = CUSTOM_UNSUPPORTED_KEYS[i]
if source[key] ~= nil then
reportDrop(report, "unsupported custom field", "trigger " .. triggernum .. ": " .. key)
end
end
local codeFields = {
{ key = "custom", detail = "custom", tableExpression = false },
{ key = "customDuration", detail = "duration", tableExpression = false },
{ key = "customName", detail = "name", tableExpression = false },
{ key = "customIcon", detail = "icon", tableExpression = false },
{ key = "customVariables", detail = "variables", tableExpression = true },
}
for i = 1, #codeFields do
local field = codeFields[i]
if trigger[field.key] ~= nil and not validateTriggerCode(trigger[field.key],
"trigger " .. triggernum .. " " .. field.detail, report, field.tableExpression) then
trigger[field.key] = nil
end
end
local untrigger = entry and entry.untrigger
if type(untrigger) == "table" and untrigger.custom ~= nil then
if validateTriggerCode(untrigger.custom, "trigger " .. triggernum .. " uncustom", report, false) then
untrigger = { custom = WA.DeepCopy(untrigger.custom) }
else
untrigger = {}
end
else
untrigger = {}
end
reportStateCompatibility(source, triggernum, report)
return trigger, untrigger
end
-- The local prototype behind an upstream `trigger.event` key, answered from the
-- prototypes' own `wa2Event` declarations rather than from a list written out
-- here: a prototype that declares one becomes importable with no edit, and one
-- that declares none (`combatevents`, whose name collides with upstream's Combat
-- Log without sharing its source) stays refused.
-- Walked per lookup rather than cached: the trigger types register after this
-- file's chunk runs, and a cache built on the first import would not see a type
-- registered after it -- which is the property this derivation exists for.
local function genericPrototypeFor(event)
if type(event) ~= "string" then return nil end
for key, spec in pairs(WA.triggerTypes or {}) do
if spec.wa2Event == event then return key end
end
return nil
end
-- Upstream arg names the local prototype spells differently, ours -> theirs.
-- Every one of these is a stored condition variable here, so aligning the name
-- would have to walk each saved condition and each text placeholder that names
-- it; the mapping costs a line and loses nothing, since GENERIC_VARIABLE_NAMES
-- renames an imported condition's variable through the same knowledge.
local GENERIC_ARG_NAMES = {
health = { healthDeficit = "deficit" },
power = { powerDeficit = "deficit" },
-- Upstream's `hastepercent` is deliberately absent: theirs is a rating
-- conversion, ours the cast-speed multiplier, and equating them would carry
-- a wrong number across rather than none.
charstats = { attackPower = "attackpower", spellPower = "spellpower" },
experience = { xp = "currentXP", xpPercent = "percentXP" },
rangecheck = { distance = "range" },
unitcharacteristics = { unitName = "namerealm" },
-- A combo trigger arrives as a Power trigger (see COMBO_POINT_POWER_TYPE),
-- so its threshold is upstream's `power` and its operator `power_operator`.
combopoints = { comboPoints = "power" },
conditions = { groupType = "ingroup" },
combatlog = { spellIdList = "spellId", spellNameList = "spellName" },
}
-- Where a mapped arg's *values* diverge as well as its name, ours -> theirs.
-- Only one does: upstream's group_types calls a party "group", and the local
-- name is the one `WA.ConditionGroupType` reports and a saved condition on the
-- stored variable already compares against.
local GENERIC_ARG_VALUES = {
conditions = { groupType = { solo = "solo", party = "group", raid = "raid" } },
}
-- WeakAuras2 has no combo-point trigger. On every client it builds against,
-- combo points *are* a power type -- `Enum.PowerType.ComboPoints` is 4 -- so a
-- combo aura exports as a Power trigger with `powertype = 4`, which is what the
-- whole ComboFill / ring-accent family on wago is made of.
--
-- Index 4 on this client is `Happiness`, vanilla pet happiness (ClassicAPI's
-- Enum.PowerType is 1.12's, not modern's), and combo points are not a power type
-- here at all -- `GetComboPoints` is the only source. Translating the number
-- literally yields a trigger testing `UnitPowerType(unit) == 4`, which can never
-- be true for a player, so every such display imports silently invisible.
--
-- Upstream's own non-retail branch special-cases the same number to
-- `GetComboPoints(unit, unit .. '-target')`, which is exactly what
-- PROTOTYPES["combopoints"] does, so the trigger is re-pointed at that
-- prototype. It stays a redirect rather than a second `wa2Event = "Power"`
-- declaration because genericPrototypeFor returns the first match out of a
-- `pairs` walk -- two prototypes claiming one event would resolve at random.
local COMBO_POINT_POWER_TYPE = 4
-- Power types this client has a bar for. Upstream offers every expansion's,
-- and one we have no equivalent of decides what the trigger matches, so it is
-- refused rather than imported as some other resource.
local SUPPORTED_POWER_TYPES = { [0] = true, [1] = true, [2] = true, [3] = true }
-- A select arrives either bare or in upstream's { single = } / { multi = }
-- wrapper; powertype has to be read before the walker unwraps it.
local function selectNumber(value)
if type(value) == "table" then
if value.single ~= nil then return tonumber(value.single) end
for key, selected in pairs(value.multi or {}) do
if selected then return tonumber(key) end
end
return nil
end
return tonumber(value)
end
-- Upstream trigger settings with no local counterpart. `refuse` names one that
-- decides or inverts what the trigger matches, where importing without it would
-- claim the aura does something it does not; `drop` names one that only widens
-- the match, which is reported the way a dropped load constraint is. A function
-- decides from the value, for a setting whose default is harmless.
local GENERIC_UNSUPPORTED = {
cast = { spell = "refuse", spellNames = "refuse", spellId = "refuse",
castType = "drop", interruptible = "drop", sourceUnit = "drop", destUnit = "drop" },
castsucceeded = { spellNames = "refuse", spellId = "refuse" },
-- ClassicAPI's _ENERGIZE payload has no over-energize value to test.
combatlog = { overEnergize = "drop" },
-- Upstream splits an instance by raid size and by difficulty, neither of
-- which this client reports; its instance *type* list is the same split, so
-- landing it would widen "25 man" into every raid. All three widen, so all
-- three are reported rather than silently ignored.
conditions = { instance_type = "drop", instance_size = "drop",
instance_difficulty = "drop" },
crowdcontrol = { spellName = "drop" },
itemequipped = { itemSlot = "drop" },
itemset = { equipped = "drop" },
location = { zoneIds = "drop", instanceId = "drop", instanceSize = "drop",
instanceDifficulty = "drop" },
power = { showCost = "drop",
powertype = function(value)
local index = selectNumber(value)
if index == nil or SUPPORTED_POWER_TYPES[index] then return nil end
-- 4 never reaches here: it is redirected to combopoints first.
return "refuse"
end },
-- The combo route inherits upstream's Power settings, and the resource-shaped
-- ones have no counterpart on a count out of five. `requirePowerType` stays a
-- drop here where the Power prototype now carries it: GetComboPoints is not a
-- power read, so there is no displayed-type test to hang it on.
combopoints = { requirePowerType = "drop", showCost = "drop",
percentpower = "drop", deficit = "drop" },
reputation = { factionID = "refuse", watched = "refuse" },
talentknown = { talent = "refuse", spec = "refuse" },
totem = { totemName = "refuse", totemNamePattern = "refuse", clones = "refuse" },
unitcharacteristics = { class = "drop", hostility = "drop", character = "drop",
unitisunit = "drop", npcId = "drop", dead = "drop" },
-- showOn is spelled and valued as upstream spells it, so it lands with no
-- entry here at all.
weaponenchant = { enchant = "refuse", stacks = "drop" },
}
local GENERIC_CODE_DROPS = { "customDuration", "customName", "customIcon" }
-- Upstream's number filters travel as strings, and its comparison filters as
-- one-entry arrays once the user has ever opened the second bound.
local GENERIC_OPERATORS = {
["=="] = "==", ["~="] = "~=", ["<"] = "<", ["<="] = "<=", [">"] = ">", [">="] = ">=",
["find('%s')"] = "find",
}
-- Whether upstream's trigger actually applies a setting. `use_<key>` false is a
-- multiselect's "several values" mode rather than "off", so a value still present
-- under it counts as applied.
local function upstreamSetting(source, key)
local use = source["use_" .. key]
if use == false then
return type(source[key]) == "table" and loadValuePresent(source[key])
end
if use ~= nil then return true end
return loadValuePresent(source[key])
end
-- The keys a prototype's editor writes, from the defaults its own args build.
-- `use_<name>`, `<name>_operator`, `<name>_multi` and the `<name>2` pair qualify
-- a base key rather than being keys in their own right, and are taken with it.
local function genericBaseKeys(defaults)
local bases = {}
for key in pairs(defaults) do
local companion = string.sub(key, 1, 4) == "use_"
or string.find(key, "_operator$") ~= nil
or string.find(key, "_multi$") ~= nil
if not companion then
local _, _, stem = string.find(key, "^(.+)2$")
companion = stem ~= nil and defaults[stem] ~= nil
end
if not companion then table.insert(bases, key) end
end
return bases
end
-- Upstream's multiselect shape onto a local one. The mode is carried by
-- `use_<name>`, which upstream overloads: true is "one value" and *false* is
-- "several", the state a plain boolean gate would read as off. Ours spells the
-- three states out, so this is where the overload is undone.
-- `wantNumber` follows the local default's type -- a set's keys are strings once
-- they have been through a serialiser, whatever the domain is.
-- `incoming` maps upstream's values onto ours where the two vocabularies differ;
-- a value missing from it has no local counterpart and is skipped rather than
-- stored, since a filter carrying a value this client never reports would match
-- nothing while looking configured. The caller reports what was skipped.
local function landMultiSelect(trigger, base, value, use, wantNumber, incoming, skipped)
local function coerce(v)
if incoming then
local mapped = incoming[v]
if mapped == nil then table.insert(skipped, tostring(v)) end
return mapped
end
if not wantNumber then return v end
return tonumber(v) or v
end
if use == false then
local set = {}
for key, selected in pairs((type(value) == "table" and value.multi) or {}) do
if selected then
local mapped = coerce(key)
if mapped ~= nil then set[mapped] = true end
end
end
trigger["use_" .. base] = "multi"
trigger[base .. "_multi"] = set
elseif use then
local single = type(value) == "table" and value.single or value
local mapped = single ~= nil and coerce(single) or nil
if mapped == nil then
-- Nothing left to match on; an empty single filter would be a filter
-- that can never pass, where off is what the author would recognise.
trigger["use_" .. base] = false
else
trigger["use_" .. base] = "single"
trigger[base] = mapped
end
else
trigger["use_" .. base] = false
end
end
-- Upstream's multiEntry table onto a local list, entries as trimmed strings. A
-- save from before the field took several entries keeps one bare value there.
-- `csv` is the NPC ID filter's shape instead: one comma-separated string whose
-- "-" entries exclude (ParseStringCheck), kept here as one "-ID" entry each.
local function landList(value, csv)
if value == nil then return nil end
local raw = {}
if type(value) == "table" then
for i = 1, #value do raw[i] = value[i] end
elseif csv and type(value) == "string" then
for part in string.gmatch(value, "[^,]+") do table.insert(raw, part) end
else
raw[1] = value
end
local out = {}
for i = 1, #raw do
local _, _, negate, rest = string.find(tostring(raw[i]), "^%s*(%-?)%s*(.-)%s*$")
if rest ~= "" then table.insert(out, negate .. rest) end
end
return out
end
-- One value out of upstream's multiselect shape, or nil when it names several:
-- a select here holds one value and cannot express the set.
local function singleSelectValue(value, use)
if use ~= false and value.single ~= nil then return value.single end
local found
for key, selected in pairs(value.multi or {}) do
if selected then
if found ~= nil then return nil end
found = key
end
end
if found == nil then return value.single end
return found
end
-- Upstream keys Item Type Equipped's one multiselect on
-- `classID * 256 + subclassID`, where the class is its own select here and the
-- subclasses a set inside it. Two fields cannot hold a selection spanning two
-- item classes (a shield beside a list of weapons), so that one is refused
-- rather than imported as whichever class the walk happened to see first.
local ITEM_TYPE_CLASS_STRIDE = 256
local function translateItemTypeEquipped(trigger, source, label, report)
-- Upstream's slot filter is a plain select, so its `use_` key is a real
-- on/off gate; ours is always-on with an "Any Slot" entry, which is what an
-- ungated upstream trigger means.
if not upstreamSetting(source, "itemSlot") then trigger.itemSlot = 0 end
if not upstreamSetting(source, "itemTypeName") then return true end
local value = source.itemTypeName
local ids = {}
if type(value) == "table" then
if source.use_itemTypeName == false then
for key, selected in pairs(value.multi or {}) do
if selected then table.insert(ids, tonumber(key)) end
end
elseif value.single ~= nil then
table.insert(ids, tonumber(value.single))
end
elseif value ~= nil then
table.insert(ids, tonumber(value))
end
local classID, subclasses = nil, {}
for i = 1, #ids do
local combined = ids[i]
if combined then
local class = math.floor(combined / ITEM_TYPE_CLASS_STRIDE)
if classID ~= nil and classID ~= class then
report.refused = label .. " selects item types from more than one item class"
return false
end
classID = class
subclasses[math.fmod(combined, ITEM_TYPE_CLASS_STRIDE)] = true
end
end
if classID == nil then return true end
trigger.itemClassID = classID
trigger.use_itemSubclassID = "multi"
trigger.itemSubclassID_multi = subclasses
return true
end
local function translateGenericTrigger(source, triggernum, report)
local protoKey = genericPrototypeFor(source.event)
if not protoKey then
report.refused = "trigger " .. triggernum .. " event " .. tostring(source.event) .. " is not supported"
return nil
end
local label = "trigger " .. triggernum .. " " .. tostring(source.event)
-- Power type 4 is combo points upstream and pet happiness here, so the
-- trigger is re-pointed before anything reads the prototype it named.
if protoKey == "power" and upstreamSetting(source, "powertype")
and selectNumber(source.powertype) == COMBO_POINT_POWER_TYPE then
-- GetComboPoints answers for the player's points on the player's target
-- and nothing else, so a combo trigger watching another unit cannot be
-- honoured -- and importing it as the player's would claim the aura
-- watches something it does not.
if source.unit ~= nil and source.unit ~= "player" then
report.refused = label .. " reads combo points on " .. tostring(source.unit)
.. ", which this client can only answer for the player"
return nil
end
protoKey = "combopoints"
reportDrop(report, "combo points imported as their own trigger",
label .. ": power type 4 is combo points upstream and pet happiness here")
end
local spec = WA.triggerTypes[protoKey] or {}
local defaults = spec.defaults or {}
-- An inverted match is never a widening: a prototype without its own inverse
-- would show exactly when the author meant it to hide.
if defaults.inverse == nil and source.use_inverse then
report.refused = label .. " is inverted, which this trigger cannot express"
return nil
end
-- Upstream offers these on every trigger; here they are compiled only for a
-- custom one, so on a prototype they would be code nothing ever runs.
for i = 1, #GENERIC_CODE_DROPS do
if hasSource(source[GENERIC_CODE_DROPS[i]]) then
reportDrop(report, "unsupported trigger code", label .. ": " .. GENERIC_CODE_DROPS[i])
end
end
local unsupported = GENERIC_UNSUPPORTED[protoKey] or {}
for key, verdict in pairs(unsupported) do
if upstreamSetting(source, key) then
if type(verdict) == "function" then verdict = verdict(source[key]) end
if verdict == "refuse" then
report.refused = label .. " uses " .. key .. ", which has no counterpart here"
return nil
elseif verdict == "drop" then
reportDrop(report, "unsupported trigger filter", label .. ": " .. key)
end
end
end
local names = GENERIC_ARG_NAMES[protoKey] or {}
local trigger = { type = protoKey }
local listArgs = {}
for i = 1, #(spec.args or {}) do
if spec.args[i].type == "list" then listArgs[spec.args[i].name] = spec.args[i] end
end
local bases = genericBaseKeys(defaults)
for i = 1, #bases do
local base = bases[i]
local srcKey = names[base] or base
local wantNumber = type(defaults[base]) == "number"
-- A list lands from the arg declarations below: read as a scalar here,
-- its entries past the second would be lost.
if listArgs[base] then
-- landed below
-- A multiselect owns its own gate key, so it lands whole and skips the
-- gate/operator/second-bound handling the scalar filters share.
elseif defaults[base .. "_multi"] ~= nil then
local ours = (GENERIC_ARG_VALUES[protoKey] or {})[base]
local incoming, skipped
if ours then
incoming, skipped = {}, {}
for localValue, upstreamValue in pairs(ours) do incoming[upstreamValue] = localValue end
end
landMultiSelect(trigger, base, source[srcKey], source["use_" .. srcKey],
wantNumber, incoming, skipped)
for i = 1, #(skipped or {}) do
reportDrop(report, "unsupported trigger filter",
label .. ": " .. base .. " value " .. skipped[i])
end
else
local value, second = source[srcKey], nil
local operator, operator2 = source[srcKey .. "_operator"], nil
if type(value) == "table" and (value.single ~= nil or value.multi ~= nil) then
value = singleSelectValue(value, source["use_" .. srcKey])
if value == nil then
reportDrop(report, "unsupported trigger filter",
label .. ": " .. base .. " selects a set this trigger cannot express")
end
elseif type(value) == "table" then
value, second = value[1], value[2]
end
if type(operator) == "table" then operator, operator2 = operator[1], operator[2] end
if wantNumber then
value, second = tonumber(value), tonumber(second)
end
if defaults["use_" .. base] ~= nil then
local use = source["use_" .. srcKey]
if use ~= nil then trigger["use_" .. base] = use and true or false end
if value ~= nil then trigger[base] = WA.DeepCopy(value) end
if operator ~= nil then
local mapped = GENERIC_OPERATORS[operator]
if mapped then
trigger[base .. "_operator"] = mapped
else
trigger["use_" .. base] = false
reportDrop(report, "unsupported trigger filter",
label .. ": " .. base .. " " .. tostring(operator))
end
end
if second ~= nil then
if defaults[base .. "2"] ~= nil then
trigger["use_" .. base .. "2"] = true
trigger[base .. "2"] = WA.DeepCopy(second)
if GENERIC_OPERATORS[operator2] then
trigger[base .. "2_operator"] = GENERIC_OPERATORS[operator2]
end
else
reportDrop(report, "unsupported trigger filter", label .. ": second " .. base .. " bound")
end
end
elseif type(defaults[base]) == "boolean" then
-- A plain flag: upstream keeps it under `use_<name>` and leaves the
-- name itself for whatever the flag qualifies, where a toggle here is
-- the bare key and nothing else.
--
-- Written even when the source carries neither spelling. An absent
-- upstream toggle is off, and a prototype's `migrate` hook reads a
-- missing key as "saved before this toggle existed" -- Power's does,
-- to keep an old trigger's implicit narrowing -- so leaving it nil
-- would hand an import a migration meant for local data.
local flag = source["use_" .. srcKey]
if flag == nil then flag = value end
trigger[base] = flag and true or false
elseif value ~= nil then
trigger[base] = WA.DeepCopy(value)
end
end
end
-- A tristate is upstream's use_<name> on its own -- nil, true or false --
-- with no base key in the defaults for the walk above to find, so it is
-- taken from the arg declarations and lands verbatim.
for i = 1, #(spec.args or {}) do
local arg = spec.args[i]
if arg.type == "tristate" then
local use = source["use_" .. (names[arg.name] or arg.name)]
if use == true or use == false then trigger["use_" .. arg.name] = use end
end
end
for name, arg in pairs(listArgs) do
local srcKey = names[name] or name
local use = source["use_" .. srcKey]
if use ~= nil then trigger["use_" .. name] = use and true or false end
local entries = landList(source[srcKey], arg.listNegate)
if entries then trigger[name] = entries end
end
if protoKey == "itemtypeequipped"
and not translateItemTypeEquipped(trigger, source, label, report) then
return nil
end
local fansOut = spec.statesParameter == "unit"
if defaults.unit ~= nil and not isSupportedUnit(trigger.unit, fansOut and WA.unit_tokens_multi) then
report.refused = label .. " uses unsupported unit " .. tostring(trigger.unit)
return nil
end
-- The prototype's own verdict on a setting it cannot express, in its words.
if spec.importCheck then
local reason = spec.importCheck(trigger)
if reason then
report.refused = label .. " " .. reason
return nil
end
end
return trigger
end
local function translateTriggerEntry(entry, triggernum, report)
if type(entry) ~= "table" or type(entry.trigger) ~= "table" then
report.refused = "trigger " .. triggernum .. " has no trigger data"
return nil
end
local source = entry.trigger
local trigger, untrigger
if source.type == "aura2" then
trigger = translateAuraTrigger(source, triggernum, report)
elseif source.type == "custom" then
trigger, untrigger = translateCustomTrigger(source, entry, triggernum, report)
elseif source.type == "aura" then
report.refused = "trigger " .. triggernum .. " is a legacy aura trigger (type aura)"
return nil
else
trigger = translateGenericTrigger(source, triggernum, report)
end
if not trigger then return nil end
if not untrigger then
untrigger = {}
if type(entry.untrigger) == "table" and entry.untrigger.custom ~= nil then
if validateTriggerCode(entry.untrigger.custom, "trigger " .. triggernum .. " uncustom", report, false) then
untrigger.custom = WA.DeepCopy(entry.untrigger.custom)
end
end
end
return { trigger = trigger, untrigger = untrigger }
end
local function translateTriggers(source, report)
if source.triggers == nil then return nil end
if type(source.triggers) ~= "table" then
report.refused = "display has invalid trigger data"
return nil
end
local sourceTriggers = source.triggers
local triggers = {}
for i = 1, #sourceTriggers do
local entry = translateTriggerEntry(sourceTriggers[i], i, report)
if not entry then return nil end
triggers[i] = entry
end
if sourceTriggers.disjunctive == "all" or sourceTriggers.disjunctive == "any"
or sourceTriggers.disjunctive == "custom" then
triggers.disjunctive = sourceTriggers.disjunctive
elseif sourceTriggers.disjunctive ~= nil then
reportDrop(report, "unsupported trigger combination", tostring(sourceTriggers.disjunctive))
end
if sourceTriggers.activeTriggerMode ~= nil then
triggers.activeTriggerMode = WA.DeepCopy(sourceTriggers.activeTriggerMode)
end
if sourceTriggers.customTriggerLogic ~= nil then
if validateTriggerCode(sourceTriggers.customTriggerLogic, "trigger logic", report, false) then
triggers.customTriggerLogic = WA.DeepCopy(sourceTriggers.customTriggerLogic)
else
triggers.customTriggerLogic = nil
end
end
return triggers
end
local function subDetail(kind, index, detail)
return kind .. " " .. index .. ": " .. detail
end
local function knownTextSymbol(data, symbol)
local bare = symbol
local _, _, scoped = string.find(symbol, "^%d+%.(.+)$")
if scoped then bare = scoped end
if WA.dynamic_texts and WA.dynamic_texts[bare] then return true end
if string.find(bare, "^c%d*$") then return true end
if type(WA.TextSymbols) == "function" then
local names = WA.TextSymbols(data)
for i = 1, #(names or {}) do
if names[i] == bare then return true end
end
end
return false
end
local function scanTextPlaceholders(text, callback)
if type(text) ~= "string" then return end
local seen = {}
local function visit(symbol)
if symbol ~= "" and not seen[symbol] then
seen[symbol] = true
callback(symbol)
end
end
local pos = 1
while true do
local start, finish, symbol = string.find(text, "%%([%w_%.]+)", pos)
if not start then break end
if start == 1 or string.sub(text, start - 1, start - 1) ~= "%" then
visit(symbol)
end
pos = finish + 1
end
pos = 1
while true do
local start, finish, symbol = string.find(text, "%%{([^}]*)}", pos)
if not start then break end
visit(symbol)
pos = finish + 1
end
end
local function reportTextPlaceholders(text, data, index, report)
scanTextPlaceholders(text, function(symbol)
local _, _, bare = string.find(symbol, "^%d+%.(.+)$")
bare = bare or symbol
if bare == "i" then
reportDrop(report, "known-blank text placeholder", subDetail("subtext", index, "%i"))
elseif not knownTextSymbol(data, symbol) then
reportDrop(report, "unsupported text placeholder", subDetail("subtext", index, "%" .. symbol))
end
end)
end
local function translateTextFormats(source, destination, data, index, report)
scanTextPlaceholders(source.text_text, function(symbol)
local prefix = "text_text_format_" .. symbol .. "_"
local prefixLength = string.len(prefix)
local hasSettings = false
for key in pairs(source) do
if type(key) == "string" and string.sub(key, 1, prefixLength) == prefix then
hasSettings = true
end
end
if hasSettings then
local format = source[prefix .. "format"]
if not format or DROPPED_FORMATS[format]
or not (WA.format_types and WA.format_types[format]) then
reportDrop(report, "unsupported text format",
subDetail("subtext", index, "%" .. symbol .. " (" .. tostring(format) .. ")"))
else
for key, value in pairs(source) do
if type(key) == "string" and string.sub(key, 1, prefixLength) == prefix then
destination[key] = WA.DeepCopy(value)
end
end
end
end
end)
end
local function translateFont(font)
local default = WA.textCore and WA.textCore.DEFAULTS and WA.textCore.DEFAULTS.font
default = default or "Fonts\\FRIZQT__.TTF"
if type(font) ~= "string" then return nil, default end
if font == "Friz Quadrata TT" then return default, default end
-- A font travels as a LibSharedMedia name, but an author who typed a path
-- into the box exports the path, and upstream's own faces are bundled here
-- under a different folder.
font = rewriteMedia(font) or font
local aliases = WA.textCore and WA.textCore.FONT_ALIASES
if aliases and aliases[font] then font = aliases[font] end
local fonts = WA.textCore and WA.textCore.FONTS or {}
for i = 1, #fonts do
if fonts[i].path == font then return font, default end
if fonts[i].name == font then return fonts[i].path, default end
end
return nil, default
end
local function subAnchorSupported(data, key)
local values = WA.regionPrototype.GetSubRegionAnchors(data, "point")
for i = 1, #values do
if values[i] == key then return true end
end
return false
end
-- Upstream keeps the anchored part and the point in a single value
-- ("OUTER_TOPLEFT", "ICON_LEFT", "SPARK"); ours splits them across anchor_target
-- and anchor_point, the latter being one of the nine SetPoint accepts. Whether a
-- part exists is asked of the destination region type, since a bar's ICON_* has
-- no icon to sit on once the display is an icon.
local function translateSubtextAnchor(destination, source, data, index, report)
local anchor = destination.anchor_point
if anchor == nil then anchor = source.text_anchorPoint end
if type(anchor) ~= "string" or anchor == "AUTO" then
if anchor ~= nil then
reportDrop(report, "anchor substitution",
subDetail("subtext", index, tostring(anchor) .. " -> CENTER"))
end
anchor = "CENTER"
end
local point = WA.regionPrototype.ResolveAnchorPoint(anchor, "CENTER")
local target = anchor
if not subAnchorSupported(data, target) then
target = subAnchorSupported(data, point) and point or "region"
if anchor ~= point then
reportDrop(report, "anchor substitution",
subDetail("subtext", index, anchor .. " -> " .. target))
end
end
destination.anchor_target = target
destination.anchor_point = point
-- Upstream's own default is AUTO, resolved at paint time from the anchor;
-- ours stores a real point, so an AUTO (or absent) self point is resolved here.
if WA.regionPrototype.IsAnchorPoint(source.text_selfPoint) then
destination.self_point = source.text_selfPoint
else
destination.self_point = WA.regionPrototype.AutoSelfPoint(anchor, point, data.regionType)
end
end
local function translateSubtext(source, index, data, report)
local destination = {}
take(destination, source, SUBTEXT_KEYS)
destination.type = "subtext"
if destination.anchorXOffset == nil and source.text_anchorXOffset ~= nil then
destination.anchorXOffset = WA.DeepCopy(source.text_anchorXOffset)
end
if destination.anchorYOffset == nil and source.text_anchorYOffset ~= nil then
destination.anchorYOffset = WA.DeepCopy(source.text_anchorYOffset)
end
translateSubtextAnchor(destination, source, data, index, report)
if source.text_font ~= nil then
local font, default = translateFont(source.text_font)
if font then
destination.text_font = font
else
destination.text_font = default
reportDrop(report, "unsupported font", subDetail("subtext", index, tostring(source.text_font)))
end
end
-- The three width fields are one setting, and the other two are inert while
-- the width is automatic -- which is upstream's default. Only a subtext that
-- asked for a fixed width has lost anything to report.
if source.text_automaticWidth == "Fixed" then
for i = 1, #SUBTEXT_DROP_KEYS do
local key = SUBTEXT_DROP_KEYS[i]
if source[key] ~= nil then
reportDrop(report, "unsupported subtext field", subDetail("subtext", index, key))
end
end
end
translateTextFormats(source, destination, data, index, report)
reportTextPlaceholders(source.text_text, data, index, report)
return destination
end
local function translateSubborder(source, index, report)
local destination = {}
take(destination, source, SUBBORDER_KEYS)
destination.type = "subborder"
if destination.anchor_area == nil and source.border_anchor ~= nil then
destination.anchor_area = WA.DeepCopy(source.border_anchor)
end
if source.border_edge ~= nil and not SOLID_BORDER_EDGES[source.border_edge] then
reportDrop(report, "unsupported border art", subDetail("subborder", index, "border_edge"))
end
return destination
end
local function translateSubglow(source, index, report)
local destination = {}
take(destination, source, SUBGLOW_KEYS)
destination.type = "subglow"
-- glowDuration is the Proc glow's pulse period and 1 is upstream's default.
-- It only ever meant anything to Proc, whose substitution to Pixel is
-- reported just below on its own terms.
if source.glowDuration ~= nil and source.glowDuration ~= 1 then
reportDrop(report, "unsupported glow field", subDetail("subglow", index, "glowDuration"))
end
if destination.glowType == "Proc" then
destination.glowType = "Pixel"
reportDrop(report, "glow type substitution", subDetail("subglow", index, "Proc -> Pixel"))
elseif destination.glowType ~= nil
and (not WA.glow_types or not WA.glow_types[destination.glowType]) then
local original = destination.glowType
destination.glowType = "Pixel"
reportDrop(report, "glow type substitution",
subDetail("subglow", index, tostring(original) .. " -> Pixel"))
end
return destination
end
local function translateSubtick(source, index, report)
local destination = {}
take(destination, source, SUBTICK_KEYS)
destination.type = "subtick"
if destination.tick_placements == nil and source.tick_placement ~= nil then
destination.tick_placements = { WA.DeepCopy(source.tick_placement) }
end
if source.progressSources ~= nil then
if type(source.progressSources) ~= "table" then
reportDrop(report, "unsupported progress source", subDetail("subtick", index, "progressSources"))
else
local first = source.progressSources[1]
if type(first) == "table" then
if first[1] ~= nil then destination.tick_progressSource = WA.DeepCopy(first[1]) end
if first[2] ~= nil and first[2] ~= "" then
reportDrop(report, "per-property progress source",
subDetail("subtick", index, tostring(first[2])))
end
elseif first ~= nil then
reportDrop(report, "unsupported progress source", subDetail("subtick", index, "progressSources"))
end
if #source.progressSources > 1 then
reportDrop(report, "multiple progress sources", subDetail("subtick", index, "progressSources"))
end
end
end
return destination
end
local function translateSubRegion(source, index, parentType, data, report)
if type(source) ~= "table" then
reportDrop(report, "unsupported sub-region", subDetail("sub-region", index, "invalid data"))
return nil
end
local kind = source.type
-- The bar's own fill under its pre-sub-region name; upstream's Modernize
-- does the same rename.
if kind == "aurabar_bar" then kind = "subforeground" end
if not SUBREGION_TYPES[kind] then
reportDrop(report, "unsupported sub-region", subDetail("sub-region", index, tostring(kind)))
return nil
end
local spec = WA.subRegionTypes and WA.subRegionTypes[kind]
if spec and spec.supports and not spec.supports(parentType) then
reportDrop(report, "unsupported sub-region", subDetail("sub-region", index, kind .. " on " .. parentType))
return nil
end
if kind == "subtext" then return translateSubtext(source, index, data, report) end
if kind == "subborder" then return translateSubborder(source, index, report) end
if kind == "subglow" then return translateSubglow(source, index, report) end
-- The placeholders standing for the region's own art carry no fields at
-- all, upstream included. Their position is the whole content: dropping one
-- would pull every later effect down a slot, which is exactly the draw order
-- the aura's author arranged -- and the slot every `sub.<n>.*` condition
-- change on a later effect addresses.
if kind == "subbackground" or kind == "subforeground" then return { type = kind } end
return translateSubtick(source, index, report)
end
local function translateSubRegions(source, data, report)
if source.subRegions == nil then return nil end
if type(source.subRegions) ~= "table" then
reportDrop(report, "unsupported sub-regions", "invalid data")
return {}
end
local destination = {}
for i = 1, #source.subRegions do
local sub = translateSubRegion(source.subRegions[i], i, data.regionType, data, report)
if sub then table.insert(destination, sub) end
end
return destination
end
local function keysFor(localType)
local keys, seen = {}, {}
-- Seeded taken, so the loop below cannot pick it up out of a group's
-- defaults: it names children by id, and every id in an import is reassigned.
seen.controlledChildren = true
local function add(key)
if not seen[key] then
seen[key] = true
table.insert(keys, key)
end
end
local spec = WA.regionTypes[localType]
for key in pairs(spec and spec.defaults or {}) do add(key) end
if spec and spec.isGroup then
for i = 1, #GROUP_KEYS do add(GROUP_KEYS[i]) end
return keys
end
for i = 1, #PROTOTYPE_KEYS do add(PROTOTYPE_KEYS[i]) end
if localType == "text" then
for i = 1, #TEXT_KEYS do add(TEXT_KEYS[i]) end
end
return keys
end
-- Calls an author's Lua makes on the `WeakAuras` table that this addon does not
-- answer. AuraEnv's shim covers the names it can honour faithfully; a call that
-- misses it reads nil and errors where it stands, so it is named at import
-- rather than discovered as a stack trace in the client.
--
-- Calls only -- `WeakAuras.Name(` -- because these authors also use that table
-- as a scratch namespace (`WeakAuras.ComboFill1 = ...`), and those work: the
-- shim is a real writable table. Reporting the stash keys would bury the two or
-- three names that actually break under a list of twenty that do not.
local function collectForeignApi(report, source)
if type(source) ~= "string" or not report then return end
for name in string.gmatch(source, "WeakAuras[%.:]([%w_]+)%s*%(") do
if not (WA.ForeignApiNames and WA.ForeignApiNames[name]) then
report.foreignApi = report.foreignApi or {}
report.foreignApiOrder = report.foreignApiOrder or {}
if not report.foreignApi[name] then
report.foreignApi[name] = true
table.insert(report.foreignApiOrder, name)
end
end
end
end
-- The other half of the same question: upstream's `WA_` helpers are bare names
-- in the environment an author's Lua runs in, not fields on the WeakAuras table,
-- so the walk above cannot see them. AuraEnv answers the ones it can answer
-- faithfully; the rest read nil and error at the call, and are named here.
--
-- A name the same chunk defines is the author's own helper, not upstream's, and
-- is skipped -- these packs do write `WA_MyThing = function()`.
local function collectForeignEnv(report, source)
if type(source) ~= "string" or not report then return end
for name in string.gmatch(source, "(WA_[%w_]+)%s*%(") do
if not (WA.AuraEnvNames and WA.AuraEnvNames[name])
and not string.find(source, "function%s+" .. name .. "%s*%(")
and not string.find(source, name .. "%s*=%s*function") then
report.foreignEnv = report.foreignEnv or {}
report.foreignEnvOrder = report.foreignEnvOrder or {}
if not report.foreignEnv[name] then
report.foreignEnv[name] = true
table.insert(report.foreignEnvOrder, name)
end
end
end
end
-- `UnitClass`'s third return.
--
-- Retail's is (localized, token, classID) and every "is there a warlock in the
-- group" check reads position 3. Vanilla's -- which is what this client has,
-- since ClassicAPI does not hook it -- stops at two, so the comparison is
-- `nil == 9` on every unit and the check is silently false forever. That is the
-- worst shape a gap can take: no error, no report, and a display that simply
-- never lights.
--
-- ClassicAPI does answer the question, under another name: `UnitClassBase(unit)`
-- returns (token, classID), so position 2 there is position 3 here. The rewrite
-- is exact for `select(3, UnitClass(x))`, which is how the corpus spells it
-- every time but one; the multiple-assignment form takes a different number of
-- names on each side, so it is reported rather than guessed at -- naming a
-- thing it cannot do beats guessing and being quietly wrong.
local function rewriteUnitClass(source)
local rewritten, count = string.gsub(source,
"select%s*%(%s*3%s*,%s*UnitClass%s*%(", "select(2, UnitClassBase(")
-- Three or more names taking a UnitClass call: the third is the classID that
-- is not there. Two is fine -- the localized name and the token both exist.
local assigned = string.find(rewritten, "[%w_]+%s*,%s*[%w_]+%s*,%s*[%w_]+%s*=%s*UnitClass%s*%(") ~= nil
return rewritten, count > 0, assigned
end
-- Whether a rewrite left the chunk as compilable as it found it. The source may
-- be a function expression or a bare body, so both wrappings are tried and the
-- comparison is on the pair -- a chunk that did not compile before is allowed
-- not to compile after, which is the case where the author's own Lua is broken.
local function sameCompileResult(before, after)
if type(WA.LoadFunction) ~= "function" then return true end
local function compiles(source, body)
return WA.LoadFunction(source, nil, body) ~= nil
end
return compiles(before, false) == compiles(after, false)
and compiles(before, true) == compiles(after, true)
end
-- Every key whose value is user Lua, wherever it sits. Walked recursively over
-- the finished display for the same reason remapMedia is: a trigger, an
-- untrigger, an action, a condition change, an animation and a custom-grow are
-- all covered by one pass, and a key added later cannot be forgotten at some
-- individual translation site.
local CODE_KEYS = {
custom = true, untrigger = true, customText = true, customSort = true,
customAnchorPerUnit = true, customGrow = true, customTriggerLogic = true,
customDuration = true, customName = true, customIcon = true,
customTexture = true, customStacks = true, customVariables = true,
customOverlay1 = true, customOverlay2 = true, customOverlay3 = true,
translateFunc = true, alphaFunc = true, scaleFunc = true,
rotateFunc = true, colorFunc = true,
}
local function remapUnitClass(node, report, seen)
if type(node) ~= "table" then return end
seen = seen or {}
if seen[node] then return end
seen[node] = true
for key, value in pairs(node) do
if type(value) == "table" then
remapUnitClass(value, report, seen)
elseif CODE_KEYS[key] and type(value) == "string" then
local rewritten, classFixed, classAssigned = rewriteUnitClass(value)
-- The one outcome worse than leaving a call to error is corrupting the
-- chunk around it. Anything that stops compiling is discarded whole.
if rewritten ~= value and not sameCompileResult(value, rewritten) then
rewritten, classFixed = value, false
reportDrop(report, "custom code",
tostring(node.id or "code") .. ": code rewrite discarded")
end
if classFixed then report.unitClassFixed = true end
if classAssigned then report.unitClassAssigned = true end
if rewritten ~= value then node[key] = rewritten end
end
end
end
local function addCode(report, name, source, active)
if not hasSource(source) then return end
collectForeignApi(report, source)
collectForeignEnv(report, source)
table.insert(report.code, { name = name, source = source, active = active and true or false })
end
function WA.CollectImportCode(data, report, prefix)
if type(data) ~= "table" then return end
report = report or { code = {} }
report.code = report.code or {}
prefix = prefix or (tostring(data.id or "?") .. " - ")
for i = 1, #DISPLAY_CODE_KEYS do
local item = DISPLAY_CODE_KEYS[i]
local active
if item.key == "customSort" then
active = data.sort == "custom"
elseif item.key == "customGrow" then
active = data.grow == "CUSTOM"
elseif item.key == "customAnchorPerUnit" then
active = data.useAnchorPerUnit and true or false
else
active = true
end
addCode(report, prefix .. item.label, data[item.key], active)
end
local triggers = data.triggers
for n = 1, #(triggers or {}) do
local entry = triggers[n]
for _, slot in ipairs({ "trigger", "untrigger" }) do
local block = entry and entry[slot]
for i = 1, #TRIGGER_CODE_KEYS do
local item = TRIGGER_CODE_KEYS[i]
local label, liveKey = item.label, item.key
-- The untrigger block holds one field the compiler reads, under the
-- name the predicate knows it by; anything else stored there is
-- carried and never run.
if slot == "untrigger" then
label = "un" .. label
liveKey = item.key == "custom" and "untrigger" or nil
end
addCode(report, prefix .. "trigger " .. n .. " " .. label,
block and block[item.key],
liveKey and WA.TriggerCodeIsLive(entry and entry.trigger, liveKey))
end
end
end
addCode(report, prefix .. "trigger logic", triggers and triggers.customTriggerLogic,
triggers and triggers.disjunctive == "custom")
local conditions = data.conditions
-- A Custom Check is code that runs, so the check half of a condition is
-- disclosed alongside its changes. Recursive because a combinator's children
-- can each carry one.
local function addCheckCode(check, where)
if type(check) ~= "table" then return end
if check.trigger == -2 then
local subs = check.checks or {}
for i = 1, #subs do addCheckCode(subs[i], where .. " check " .. i) end
return
end
if check.trigger == -1 and check.variable == "customcheck" then
addCode(report, where .. " custom check", check.value, true)
end
end
for n = 1, #(conditions or {}) do
addCheckCode(conditions[n] and conditions[n].check, prefix .. "condition " .. n)
local changes = conditions[n] and conditions[n].changes or {}
for i = 1, #changes do
local change = changes[i]
local property = change and change.property
local source
if property == "customcode" or property == "chat"
or property == "sound" or property == "glowexternal" then
source = conditionCodeValue(change.value)
end
if source then
addCode(report, prefix .. "condition " .. n .. " " .. tostring(change.property), source, true)
end
end
end
local animation = data.animation
for _, phase in ipairs({ "start", "main", "finish" }) do
local block = animation and animation[phase]
for i = 1, #ANIMATION_CODE_KEYS do
local key = ANIMATION_CODE_KEYS[i]
local slot = string.sub(key, 1, string.len(key) - 4)
addCode(report, prefix .. "animation " .. phase .. " " .. key,
block and block[key], WA.AnimationCodeIsLive(block, slot))
end
end
local actions = data.actions
for _, phase in ipairs({ "init", "start", "finish" }) do
local block = actions and actions[phase]
for i = 1, #ACTION_CODE_KEYS do
local key = ACTION_CODE_KEYS[i]
local enabled = "do_custom"
if key == "customOnLoad" then enabled = "do_custom_load"
elseif key == "customOnUnload" then enabled = "do_custom_unload"
elseif key == "message_custom" then enabled = "do_message" end
addCode(report, prefix .. "action " .. phase .. " " .. key,
block and block[key], block and block[enabled])
end
end
end
local function animationPresetTypes(phase)
if phase == "start" then return WA.anim_start_preset_types end
if phase == "finish" then return WA.anim_finish_preset_types end
return WA.anim_main_preset_types
end
local function animationCapabilities(localType)
return {
scale = localType ~= "group" and localType ~= "dynamicgroup",
color = localType ~= "group" and localType ~= "dynamicgroup",
rotate = localType == "texture" or localType == "progresstexture",
}
end
local function validateAnimation(data, sourceType, report)
local animation = data.animation
if type(animation) ~= "table" then return end
local caps = animationCapabilities(sourceType)
for _, phase in ipairs({ "start", "main", "finish" }) do
local block = animation[phase]
if type(block) == "table" then
if block.type == "preset" and not animationPresetTypes(phase)[block.preset] then
reportDrop(report, "unsupported animation preset", phase .. ": " .. tostring(block.preset))
block.type, block.preset = "none", nil
end
if block.use_scale and not caps.scale then
reportDrop(report, "unsupported animation", phase .. ": use_scale")
block.use_scale = false
end
if block.use_rotate and not caps.rotate then
reportDrop(report, "unsupported animation", phase .. ": use_rotate")
block.use_rotate = false
end
if block.use_color and not caps.color then
reportDrop(report, "unsupported animation", phase .. ": use_color")
block.use_color = false
end
for i = 1, #ANIMATION_CODE_KEYS do
local key = ANIMATION_CODE_KEYS[i]
local source = block[key]
if hasSource(source) and type(WA.LoadFunction) == "function"
and not WA.LoadFunction(source, nil) then
block[key] = nil
reportDrop(report, "custom code", "animation " .. phase .. " " .. key)
end
end
end
end
end
local function validateActionCode(data, report)
local actions = data.actions
if type(actions) ~= "table" then return end
for _, phase in ipairs({ "init", "start", "finish" }) do
local block = actions[phase]
local specs = ACTION_SPECS[phase]
if type(block) == "table" then
for i = 1, #(specs or {}) do
local spec = specs[i]
local source = block[spec.key]
if hasSource(source) and type(WA.LoadFunction) == "function"
and not WA.LoadFunction(source, nil, spec.body) then
block[spec.key] = nil
reportDrop(report, "custom code", "action " .. phase .. " " .. spec.key)
end
end
end
end
end
-- Upstream writes an aura reference as "WeakAuras:<id>"; ours is the same shape
-- under our own name. The id inside is the *exporter's*, and every id in an
-- import is reassigned -- remapChildIdKeys (ImportExport.lua) rewrites it once
-- the whole pack is installed and the old-to-new map is complete.
local WA2_ANCHOR_PREFIX = "WeakAuras:"
local function translateAnchorReference(value)
if type(value) ~= "string" or value == "" then return nil end
local _, _, id = string.find(value, "^WeakAuras:(.+)$")
if id then return WA.ANCHOR_AURA_PREFIX .. id end
return value
end
-- Upstream's Proc glow carries two keys nothing else reads. Dropping the art
-- (below) without them would leave an aura whose saved glow still claims a start
-- animation the replacement art has no concept of.
local GLOW_PROC_KEYS = { "glow_startAnim", "glow_duration" }
-- One glow descriptor, wherever it came from: an action block's keys sit on the
-- block, a condition change's on its `value`, and the shape is upstream's own
-- either way. A frame we cannot resolve is dropped outright, because a glow
-- aimed at nothing is a control the user will keep adjusting to no effect; an
-- art we do not draw falls back rather than dropping, since a glow of the wrong
-- shape still tells them what it was meant to tell them.
local function sanitizeGlow(block, report, label)
if type(block) ~= "table" then return end
if block.glow_type and not (WA.glow_types and WA.glow_types[block.glow_type]) then
reportDrop(report, "unsupported glow art", label .. ": " .. tostring(block.glow_type))
block.glow_type = nil
end
if block.glow_frame_type and not GLOW_FRAME_TYPES[block.glow_frame_type] then
reportDrop(report, "unsupported glow frame", label .. ": " .. tostring(block.glow_frame_type))
block.glow_frame_type, block.glow_frame = nil, nil
end
-- A frame selector names a global frame or another display, and upstream
-- writes both into the one field. The aura reference carries the exporter's
-- id, which remapChildIdKeys rewrites once the pack is installed.
if block.glow_frame_type == "FRAMESELECTOR" then
block.glow_frame = translateAnchorReference(block.glow_frame)
if not block.glow_frame then
reportDrop(report, "unsupported glow frame", label .. ": no frame named")
block.glow_frame_type = nil
end
end
for i = 1, #GLOW_PROC_KEYS do
local key = GLOW_PROC_KEYS[i]
if block[key] ~= nil then
reportDrop(report, "unsupported glow art", label .. ": " .. key)
block[key] = nil
end
end
end
local function sanitizeActions(data, report)
local actions = data.actions
if type(actions) ~= "table" then return end
for _, phase in ipairs({ "init", "start", "finish" }) do
local block = actions[phase]
if type(block) == "table" then
for i = 1, #ACTION_DROP_KEYS do
local key = ACTION_DROP_KEYS[i]
if block[key] ~= nil then
reportDrop(report, "unsupported action", phase .. ": " .. key)
block[key] = nil
end
end
if block.message_type == "INSTANCE_CHAT" then
reportDrop(report, "unsupported action", phase .. ": INSTANCE_CHAT")
block.message_type, block.do_message = nil, nil
end
sanitizeGlow(block, report, phase)
-- A glow whose frame is gone would light this aura's own region
-- instead of the one the author picked, which is worse than silence.
if block.do_glow and not block.glow_frame_type then
reportDrop(report, "unsupported action", phase .. ": glow")
block.do_glow = nil
end
end
end
end
-- The condition half of the same descriptor. `translateConditionChanges` copies
-- a change's value wholesale -- correct for every other property, but a glow
-- carries frame and art names that have to survive the same check the action
-- block's do.
local function sanitizeConditionGlows(data, report)
local conditions = data.conditions
for n = 1, #(conditions or {}) do
local changes = conditions[n] and conditions[n].changes or {}
for i = 1, #changes do
local change = changes[i]
if change and change.property == "glowexternal" and type(change.value) == "table" then
sanitizeGlow(change.value, report, conditionLabel(data, n) .. " glow")
end
end
end
end
local function reportMediaOptions(options, report, seen)
if type(options) ~= "table" then return end
seen = seen or {}
if seen[options] then return end
seen[options] = true
if options.type == "media" then
reportDrop(report, "media option", options.name or options.key or "unnamed")
end
for _, value in pairs(options) do
if type(value) == "table" then reportMediaOptions(value, report, seen) end
end
end
local function noteWagoBookkeeping(source, report)
for i = 1, #WAGO_BOOKKEEPING_KEYS do
local key = WAGO_BOOKKEEPING_KEYS[i]
if source[key] ~= nil then
report.wagoKeys = report.wagoKeys or {}
report.wagoKeyOrder = report.wagoKeyOrder or {}
if not report.wagoKeys[key] then
report.wagoKeys[key] = true
table.insert(report.wagoKeyOrder, key)
end
end
end
end
-- Texture-valued fields whose value can be a fileID or an atlas name, neither of
-- which resolves on this client. The value is kept -- it is still what the author
-- chose, and the options tab shows it -- and every paint falls back to a
-- placeholder rather than the engine's missing-texture block. Reported so the
-- substitution is not a silent one.
local TEXTURE_FIELDS = {
{ key = "groupIcon", label = "group icon" },
-- `displayIcon` is the icon only under Manual. Everywhere else the display
-- asks its trigger first (WA.ResolveDisplayIcon) and reaches this value only
-- when no trigger names one, so calling it "the icon" overstates a loss the
-- user may never see. Upstream's default is Automatic, so nil is Automatic.
{ key = "displayIcon", label = "manual icon", autoLabel = "fallback icon",
autoWhen = function(source) return source.iconSource ~= 0 end },
{ key = "sparkTexture", label = "spark texture" },
{ key = "texture", label = "texture", only = "texture" },
{ key = "foregroundTexture", label = "foreground texture" },
{ key = "backgroundTexture", label = "background texture" },
}
-- A path into some *other* addon's folder, after the WeakAuras rewrite has had
-- its turn. Nothing offline can tell whether the sender's addon is installed
-- here, so the value is left alone -- but silence would be wrong too, since this
-- is the second most common reason an imported display draws nothing.
local function foreignAddonPath(value)
if type(value) ~= "string" then return false end
local lower = string.lower(string.gsub(value, "/", "\\"))
if not string.find(lower, "^interface\\addons\\") then return false end
return string.find(lower, "^interface\\addons\\weakestauras\\") == nil
end
local function reportUndrawableTextures(source, data, report)
for i = 1, #TEXTURE_FIELDS do
local field = TEXTURE_FIELDS[i]
local value = source[field.key]
if value ~= nil and value ~= ""
and (not field.only or source.regionType == field.only) then
local label = field.label
if field.autoWhen and field.autoWhen(source) then label = field.autoLabel end
if not WA.DrawableTexture(value) then
reportDrop(report, "texture this client cannot load",
tostring(source.id or "?") .. " " .. label .. ": " .. tostring(value))
elseif foreignAddonPath(data[field.key]) then
reportDrop(report, "texture from an addon that may not be installed",
tostring(source.id or "?") .. " " .. label .. ": " .. tostring(data[field.key]))
end
end
end
end
-- Lands an incoming bar texture on one this addon actually ships. textureSource
-- is forced to LSM because it is ours, not upstream's: a bar arriving with a
-- stale Picker source would look at an empty textureInput and draw nothing.
local function translateBarTexture(data, source, report)
data.textureSource = "LSM"
data.textureInput = nil
local name = source.texture
if name == nil then return end
local shipped = WA.Widgets.BarTextures()
for i = 1, #shipped do
if shipped[i] == name then return end
end
local alias = BAR_TEXTURE_ALIASES[name]
data.texture = alias or BAR_TEXTURE_DEFAULT
reportDrop(report, "bar texture substituted",
tostring(source.id or "?") .. ": " .. tostring(name) .. " -> " .. data.texture)
end
-- The model region renders only the legacy camera (SetPosition + SetFacing);
-- an aura built with upstream's transform camera (api = SetTransform) has the
-- transform's yaw folded into `rotation` here, once -- rz's rest value is 90,
-- so the delta from it is the yaw the author meant. The st_* keys travel
-- through untouched for the trip back to a retail client, which ignores
-- `rotation` under api anyway.
local function translateModel(data, source, report)
local label = tostring(source.id or "?")
-- Pinned to at least empty, or MergeDefaults seeds the fresh-aura default
-- model onto the import and an unloadable model silently becomes Ragnaros.
if data.model_path == nil then data.model_path = "" end
if source.api then
data.rotation = (tonumber(source.model_st_rz) or 90) - 90
if data.rotation < 0 then data.rotation = data.rotation + 360 end
reportDrop(report, "model camera transform approximated", label)
end
if source.modelIsUnit then
local unit = source.model_fileId
if type(unit) ~= "string" or unit == "" or string.find(tostring(unit), "^soft") then
reportDrop(report, "unit this client does not have", label .. ": " .. tostring(unit))
end
elseif not source.modelDisplayInfo then
-- A display id renders through SetDisplayInfo as is; no local table
-- can tell which retail ids this client's CreatureDisplayInfo holds.
local resolvable = WA.ResolveModelFile(source.model_fileId)
or (type(source.model_path) == "string" and source.model_path ~= "")
if not resolvable then
reportDrop(report, "model this client cannot load", label .. ": " .. tostring(source.model_fileId))
end
end
end
-- ---------------------------------------------------------------------------
-- Anchoring
--
-- Upstream's anchor frame types and ours are the same vocabulary bar one, so an
-- anchor is carried rather than flattened onto the screen.
--
-- Flattening is not the cosmetic loss it looks like. A display anchored to
-- another frame usually carries xOffset and yOffset of 0 and takes its whole
-- position from the anchor, so forcing SCREEN does not merely shift it -- it
-- collapses every display in the set onto one point. The corpus's ComboFill1-5
-- are exactly that: five textures at (0,0), each anchored to a different combo
-- point behind it, which imported as five textures stacked in one place.
--
-- PRD is retail's Personal Resource Display, which this client has no equivalent
-- of; CUSTOM resolves its frame by running the author's Lua, which would have to
-- travel through the code-disclosure path before it could be honoured. Both fall
-- back to the screen and say so.
-- ---------------------------------------------------------------------------
local ANCHOR_FRAME_TYPES = {
SCREEN = true, UIPARENT = true, SELECTFRAME = true,
MOUSE = true, NAMEPLATE = true, UNITFRAME = true,
}
local function translateAnchor(data, source, report)
local frameType = source.anchorFrameType
local label = tostring(source.id or "?")
if frameType == nil or not ANCHOR_FRAME_TYPES[frameType] then
if frameType ~= nil and frameType ~= "SCREEN" then
reportDrop(report, "anchored to a frame this client has no equivalent of",
label .. ": " .. tostring(frameType) .. " -> screen")
end
data.anchorFrameType = "SCREEN"
data.anchorFrameFrame, data.anchorFrameParent = nil, nil
return
end
data.anchorFrameType = frameType
if frameType ~= "SELECTFRAME" then
data.anchorFrameFrame = nil
return
end
local reference = translateAnchorReference(source.anchorFrameFrame)
if not reference then
reportDrop(report, "anchored to a frame that was not named",
label .. ": imported anchored to screen")
data.anchorFrameType = "SCREEN"
data.anchorFrameFrame, data.anchorFrameParent = nil, nil
return
end
data.anchorFrameFrame = reference
-- A reference to another aura is resolved by id and travels with the pack. A
-- global frame name is the sender's UI, not ours, and nothing offline can say
-- whether it exists here -- kept, since the alternative is silently moving
-- the display, and reported so the miss is not a mystery.
if string.sub(source.anchorFrameFrame, 1, string.len(WA2_ANCHOR_PREFIX)) ~= WA2_ANCHOR_PREFIX then
reportDrop(report, "anchored to a frame from the sender's UI",
label .. ": " .. tostring(source.anchorFrameFrame))
end
end
local function translateGroup(data, source, report)
local label = tostring(source.id or "?")
-- A group's stored selfPoint means something upstream that it cannot mean
-- here, and honouring it displaces the whole pack.
--
-- Upstream's group frame is 2x2 and never resized (Group.lua's create), so
-- which of its corners is pinned moves it by a pixel -- the static group even
-- overwrites selfPoint on every modify, to BOTTOMLEFT or CENTER, which is why
-- an export can carry a point nothing ever reads back. A dynamic group's
-- selfPoint is a third thing again: the corner its children grow from, which
-- is `align` here. Our group frame is sized to its children's bounding box, so
-- pinning it by a corner instead of its centre shifts every child by half that
-- box -- hundreds of pixels for a real pack.
--
-- A grid is the one case where nothing is lost: upstream's options write
-- selfPoint from gridType (its gridSelfPoints table) whenever either is
-- touched, so the corner it names is one gridType already carries.
--
-- Under a custom grow it is worse than a shift: the author's own function
-- writes coordinates in whatever corner selfPoint names, and CENTER is what
-- picking CUSTOM sets it to upstream, so anything else means the aura was
-- hand-edited afterwards and its Lua will not mean here what it meant there.
-- The drop below is the only warning that gives.
if data.selfPoint ~= nil and data.selfPoint ~= "CENTER" and data.grow ~= "GRID" then
reportDrop(report, "group anchor point", label .. ": " .. tostring(data.selfPoint)
.. " -> CENTER")
end
data.selfPoint = "CENTER"
-- A group's alpha cascades onto its children upstream; here the container
-- frame has no alpha of its own, so a translucent pack would import opaque.
if source.alpha ~= nil and source.alpha ~= 1 then
reportDrop(report, "group alpha", label .. ": " .. tostring(source.alpha))
end
-- Every grow upstream names is ours under the same name, geometry fields
-- included, so `take` carries them; the guard is for a value from a future
-- version rather than for a known gap.
if data.grow ~= nil and not GROW_TYPES[data.grow] then
reportDrop(report, "unsupported grow direction", label .. ": " .. tostring(data.grow))
data.grow = nil
end
for i = 1, #GROUP_DROP_KEYS do
local key = GROUP_DROP_KEYS[i]
if source[key] then reportDrop(report, "unsupported group field", label .. ": " .. key) end
end
if source.border then
for i = 1, #GROUP_BORDER_ART_KEYS do
if source[GROUP_BORDER_ART_KEYS[i]] ~= nil then
reportDrop(report, "group border art", label)
break
end
end
end
end
local function translateDisplay(source, report)
local sourceType = source.regionType
local localType = REGION_MAP[sourceType]
if not localType then
report.refused = "unsupported region type " .. tostring(sourceType)
return nil
end
local data = {}
if source.id ~= nil then data.id = WA.DeepCopy(source.id) end
if source.uid ~= nil then data.uid = WA.DeepCopy(source.uid) end
-- Author metadata, outside keysFor's per-type whitelist because every type
-- carries it: the Info tab shows and edits both.
if type(source.desc) == "string" then data.desc = source.desc end
if type(source.url) == "string" then data.url = source.url end
take(data, source, keysFor(localType))
data.regionType = sourceType == "aurabar" and "progressbar" or sourceType
if sourceType == "stopmotion" then
reportDrop(report, "unsupported region type", sourceType)
elseif sourceType == "model" then
translateModel(data, source, report)
end
translateAnchor(data, source, report)
if GROUP_TYPES[sourceType] then
translateGroup(data, source, report)
else
if sourceType == "aurabar" then translateBarTexture(data, source, report) end
local progress = source.progressSource
if type(progress) == "table" then
data.progressSource = WA.DeepCopy(progress[1])
if progress[1] == 0 then
if progress[3] ~= nil then data.progressSourceManualValue = WA.DeepCopy(progress[3]) end
if progress[4] ~= nil then data.progressSourceManualTotal = WA.DeepCopy(progress[4]) end
end
if progress[2] ~= nil and progress[2] ~= "" then
reportDrop(report, "per-property progress source", tostring(progress[2]))
end
end
local triggers = translateTriggers(source, report)
if report.refused then return nil end
if triggers then data.triggers = triggers end
end
local subRegions = translateSubRegions(source, data, report)
if subRegions then data.subRegions = subRegions end
if source.conditions ~= nil then
data.conditions = translateConditions(source.conditions, data, report)
end
if source.load ~= nil then
data.load = translateLoad(source.load, data, report)
end
copyBlock(data, source, "animation")
copyBlock(data, source, "actions")
copyBlock(data, source, "authorOptions")
copyBlock(data, source, "config")
if source.authorMode ~= nil then data.authorMode = WA.DeepCopy(source.authorMode) end
validateAnimation(data, localType, report)
sanitizeActions(data, report)
sanitizeConditionGlows(data, report)
validateActionCode(data, report)
reportMediaOptions(data.authorOptions, report)
WA.ValidateUserConfig(data)
remapMedia(data, report, tostring(source.id or "?"))
remapUnitClass(data, report)
reportUndrawableTextures(source, data, report)
noteWagoBookkeeping(source, report)
local codeSource = WA.DeepCopy(source)
codeSource.animation = data.animation
codeSource.actions = data.actions
codeSource.customText = data.customText
codeSource.conditions = data.conditions
codeSource.customSort = data.customSort
codeSource.customAnchorPerUnit = data.customAnchorPerUnit
-- The disclosure has to show what will run, not what the author sent, or the
-- listed source and the saved source disagree wherever a call was rewritten.
-- `codeSource` is a copy of the upstream payload, so it needs the same pass;
-- rewriting is idempotent, and the fields it shares by reference with `data`
-- have already been done.
remapUnitClass(codeSource, report)
WA.CollectImportCode(codeSource, report, tostring(source.id or "?") .. " - ")
-- Current-schema by construction -- fresh tables, current field names,
-- upstream's own draw order -- so the importer's migrations must not touch
-- it. The source's internalVersion counts upstream Modernize passes, not
-- our schema, and is never carried.
data.internalVersion = WA.SCHEMA_VERSION
return data
end
-- A dynamic group arranges its children itself and cannot own a nested group --
-- the one placement WA.CanPlaceAura refuses outright. Silently flattening the
-- pack would put a sub-group's children where their author never arranged them,
-- so the whole import stops instead.
local function placementRefusal(parentSource, childSource)
if parentSource.regionType ~= "dynamicgroup" then return nil end
if not GROUP_TYPES[childSource.regionType] then return nil end
return "\"" .. tostring(childSource.id) .. "\" is a group inside a dynamic group, which cannot be placed here"
end
-- The displays under the root, parents before their own children and siblings in
-- the author's order. The installer walks the same list and depends on that
-- ordering to have a parent's assigned id in hand by the time it reaches a child.
--
-- payload.c is a flat list in both transmission versions and means two different
-- things. v1421 strips parent and controlledChildren and sends the root's direct
-- children; v2000 keeps both and sends every descendant, so there the tree exists
-- only in those fields. The version decides which, never the presence of a parent
-- field -- a v2000 pack read as v1421 loses every sub-group's nesting, and the
-- reverse finds no tree at all.
local function buildTree(payload, report)
local rootSource = payload.d
local children = payload.c
if children == nil then return {} end
if type(children) ~= "table" then
report.refused = "payload has invalid child displays"
return nil
end
local count = #children
if count > 0 and not GROUP_TYPES[rootSource.regionType] then
report.refused = "child displays require a group root"
return nil
end
-- Every id below is a table key, and a nil one raises rather than refusing.
if count > 0 and type(rootSource.id) ~= "string" then
report.refused = "the group display has no id"
return nil
end
for i = 1, count do
if type(children[i]) ~= "table" or type(children[i].id) ~= "string" then
report.refused = "child display " .. i .. " has no id"
return nil
end
end
local nodes = {}
if payload.v ~= 2000 then
for i = 1, count do
local refusal = placementRefusal(rootSource, children[i])
if refusal then
report.refused = refusal
return nil
end
table.insert(nodes, { source = children[i], parent = rootSource.id })
end
return nodes
end
local byId = { [rootSource.id] = rootSource }
for i = 1, count do
local child = children[i]
if byId[child.id] then
report.refused = "two displays share the id \"" .. child.id .. "\""
return nil
end
byId[child.id] = child
end
-- Following controlledChildren rather than each child's parent is what keeps
-- siblings in the author's order; `seen` is also the cycle guard, since a
-- payload can name a group as its own ancestor.
local seen = {}
local function walk(parentSource)
local list = parentSource.controlledChildren
if type(list) ~= "table" then return end
for i = 1, #list do
if report.refused then return end
local child = byId[list[i]]
if child and child ~= rootSource and not seen[child] then
seen[child] = true
local refusal = placementRefusal(parentSource, child)
if refusal then
report.refused = refusal
return
end
table.insert(nodes, { source = child, parent = parentSource.id })
walk(child)
end
end
end
walk(rootSource)
if report.refused then return nil end
for i = 1, count do
if not seen[children[i]] then
reportDrop(report, "aura not imported",
tostring(children[i].id) .. ": no group in this import claims it")
end
end
return nodes
end
-- A group whose children all refused installs as an empty box: it holds
-- nothing, draws nothing, and the user has to hunt it down and delete it. It
-- leaves with them, and so does any group left empty by its leaving. A group its
-- author shipped empty stays -- that one is deliberate, so only a group that
-- *lost* children is pruned.
local function pruneEmptyGroups(children, nodes, report)
local authored = {}
for i = 1, #nodes do authored[nodes[i].parent] = true end
local pruning = true
while pruning do
pruning = false
local hasChild = {}
for i = 1, #children do hasChild[children[i].parent] = true end
for i = #children, 1, -1 do
local child = children[i]
if GROUP_TYPES[child.regionType] and authored[child.id] and not hasChild[child.id] then
table.remove(children, i)
reportDrop(report, "aura not imported",
tostring(child.id) .. ": an empty group, none of its auras could be imported")
pruning = true
end
end
end
end
function WA.WA2Translate(payload)
local report = { created = {}, dropped = {}, code = {}, refused = nil }
if type(payload) ~= "table" then
report.refused = "payload is not a table"
return nil, report
end
if type(payload.d) ~= "table" then
report.refused = "payload has no display"
return nil, report
end
-- The root is translated before the tree is walked, so that a root refusing on
-- its own terms says so rather than being reported as a malformed group.
local ok, root = pcall(translateDisplay, payload.d, report)
if not ok then
report.refused = "translation failed: " .. tostring(root)
return nil, report
end
if not root then return nil, report end
local nodes = buildTree(payload, report)
if not nodes then return nil, report end
-- A child that refuses is dropped by name rather than refusing the pack: the
-- rest of it behaves exactly as its author arranged, and a named absence is
-- a visible failure where refusing thirty auras over one leaves the user
-- nothing they can act on. Only the root refusing refuses the import, and a
-- child's own report is merged only once it survives, so a dropped display
-- never contributes code to the disclosure list.
local children, dropped = {}, {}
local nodeCount = #nodes
for i = 1, nodeCount do
local node = nodes[i]
local label = tostring(node.source.id or "?")
if dropped[node.parent] then
dropped[label] = true
reportDrop(report, "aura not imported", label .. ": its group was not imported")
else
local childReport = { created = {}, dropped = {}, code = {}, refused = nil }
local childOk, child = pcall(translateDisplay, node.source, childReport)
if childOk and child then
child.parent = node.parent
table.insert(children, child)
for n = 1, #childReport.dropped do
table.insert(report.dropped, childReport.dropped[n])
end
for n = 1, #childReport.code do
table.insert(report.code, childReport.code[n])
end
for n = 1, #(childReport.foreignApiOrder or {}) do
local name = childReport.foreignApiOrder[n]
report.foreignApi = report.foreignApi or {}
report.foreignApiOrder = report.foreignApiOrder or {}
if not report.foreignApi[name] then
report.foreignApi[name] = true
table.insert(report.foreignApiOrder, name)
end
end
for n = 1, #(childReport.wagoKeyOrder or {}) do
local name = childReport.wagoKeyOrder[n]
report.wagoKeys = report.wagoKeys or {}
report.wagoKeyOrder = report.wagoKeyOrder or {}
if not report.wagoKeys[name] then
report.wagoKeys[name] = true
table.insert(report.wagoKeyOrder, name)
end
end
for n = 1, #(childReport.foreignEnvOrder or {}) do
local name = childReport.foreignEnvOrder[n]
report.foreignEnv = report.foreignEnv or {}
report.foreignEnvOrder = report.foreignEnvOrder or {}
if not report.foreignEnv[name] then
report.foreignEnv[name] = true
table.insert(report.foreignEnvOrder, name)
end
end
if childReport.unitClassFixed then report.unitClassFixed = true end
if childReport.unitClassAssigned then report.unitClassAssigned = true end
else
dropped[label] = true
reportDrop(report, "aura not imported",
label .. ": " .. tostring(childOk and childReport.refused or child))
end
end
end
pruneEmptyGroups(children, nodes, report)
if nodeCount > 0 and #children == 0 then
report.refused = "none of this group's " .. nodeCount .. " auras could be imported"
return nil, report
end
return { root = root, children = children }, report
end
-- How many *distinct* details one reason spells out before it just counts. A
-- pack of eighty children reports the same drop eighty times, and a summary
-- nobody can read to the end is a review step in name only -- so identical
-- details collapse to one entry carrying its repeat count, and the budget below
-- buys that many separate problems rather than that many copies of the first.
local DETAIL_LIMIT = 6
function WA.ImportSummary(pending, report)
local root = pending and pending.root or {}
local childCount = #(pending and pending.children or {})
local header = "Importing \"" .. tostring(root.id or "Imported") .. "\" ("
.. tostring(root.regionType or "unknown") .. ")"
if childCount == 1 then
header = header .. " and 1 aura inside it"
elseif childCount > 1 then
header = header .. " and " .. childCount .. " auras inside it"
end
local lines = { header }
-- Above the rest of the summary, not down with the version block: this is
-- the one warning that says settings are being dropped right now.
if report and report.schemaAhead then
table.insert(lines, "")
table.insert(lines, "Made with a newer WeakestAuras (data version "
.. tostring(report.schemaAhead) .. "; this one reads " .. tostring(WA.SCHEMA_VERSION)
.. "). It will import, but anything that version added may come in at defaults.")
end
if #(report and report.code or {}) > 0 then
table.insert(lines, "")
table.insert(lines, "Runs this author's Lua:")
for i = 1, #report.code do
local item = report.code[i]
local state = item.active and "active" or "inactive"
table.insert(lines, " " .. tostring(item.name) .. " (" .. state .. ")")
local source = string.gsub(item.source or "", "\n", "\n ")
table.insert(lines, " " .. source)
end
end
if #(report and report.foreignApiOrder or {}) > 0 then
table.insert(lines, "")
table.insert(lines, "That Lua calls WeakAuras functions this addon does not have. Each errors "
.. "where it stands; the rest of the aura is unaffected.")
for i = 1, #report.foreignApiOrder do
table.insert(lines, " WeakAuras." .. tostring(report.foreignApiOrder[i]))
end
end
if #(report and report.foreignEnvOrder or {}) > 0 then
table.insert(lines, "")
table.insert(lines, "It also calls WeakAuras helpers that live in the environment rather than "
.. "on that table, and these are not provided. Each errors where it stands.")
for i = 1, #report.foreignEnvOrder do
table.insert(lines, " " .. tostring(report.foreignEnvOrder[i]) .. "()")
end
end
if report and report.unitClassFixed then
table.insert(lines, "")
table.insert(lines, "Rewrote UnitClass's class-id read. This client's UnitClass returns only "
.. "the localized name and the class token, so select(3, UnitClass(unit)) is nil on "
.. "every unit and the check it feeds is silently false; each was rewritten to "
.. "select(2, UnitClassBase(unit)), which answers the same id.")
end
if report and report.unitClassAssigned then
table.insert(lines, "")
table.insert(lines, "That Lua also reads UnitClass's third return by assignment, which this "
.. "client does not have and which takes a different number of names to rewrite. "
.. "UnitClassBase(unit) returns the token and the class id.")
end
if #(report and report.dropped or {}) > 0 then
local groups, byReason = {}, {}
for i = 1, #report.dropped do
local item = report.dropped[i]
local group = byReason[item.reason]
if not group then
group = { reason = item.reason, count = 0, details = {}, repeats = {}, seen = {} }
byReason[item.reason] = group
table.insert(groups, group)
end
group.count = group.count + 1
local detail = tostring(item.detail)
local at = group.seen[detail]
if at then
group.repeats[at] = group.repeats[at] + 1
else
table.insert(group.details, detail)
at = #group.details
group.seen[detail] = at
group.repeats[at] = 1
end
end
table.insert(lines, "")
table.insert(lines, "Not imported:")
for i = 1, #groups do
local group = groups[i]
local distinct = #group.details
local shown = distinct
if shown > DETAIL_LIMIT then shown = DETAIL_LIMIT end
local parts = {}
for n = 1, shown do
parts[n] = group.details[n]
if group.repeats[n] > 1 then
parts[n] = parts[n] .. " (x" .. group.repeats[n] .. ")"
end
end
local line = " " .. tostring(group.reason) .. " (" .. group.count .. ") "
.. table.concat(parts, ", ")
if distinct > shown then
line = line .. ", and " .. (distinct - shown) .. " more"
end
table.insert(lines, line)
end
end
if #(report and report.wagoKeyOrder or {}) > 0 then
table.insert(lines, "")
table.insert(lines, "Wago bookkeeping (" .. table.concat(report.wagoKeyOrder, ", ")
.. ") is not carried.")
end
if report and report.sourceVersion then
table.insert(lines, "")
table.insert(lines, "Exported from " .. (report.sourceAddon or "WeakestAuras")
.. " " .. tostring(report.sourceVersion))
local mods = report.sourceMods
if type(mods) == "table" then
local parts = {}
if mods.classicapi then table.insert(parts, "ClassicAPI " .. tostring(mods.classicapi)) end
if mods.nampower then table.insert(parts, "Nampower " .. tostring(mods.nampower)) end
if #parts > 0 then
table.insert(lines, " built with " .. table.concat(parts, ", "))
end
end
if report.sourceAddon == "WeakestAuras"
and WA.VersionNewer(report.sourceVersion, WA.version) then
table.insert(lines, " That WeakestAuras is newer than this one -- settings it added may not survive.")
end
end
return table.concat(lines, "\n")
end