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

3887 lines
162 KiB
Lua

-- WeakestAuras -- in-game probes and verification commands for the runtime
-- engine. Registers /wa probe, states, libs, addons, gen, load,
-- conditions, codeprobe, textprobe, csprobe, texprobe, levelprobe, plateprobe,
-- wa2probe, and wa2.
if WeakestAuras.disabled then return end
local WA = WeakestAuras
WA.Debug = {}
local D = WA.Debug
local MAX_LOG_LINES = 500
local MAX_SLOT = 40 -- safe superset of any plausible buff/debuff cap on this client
-- ---------------------------------------------------------------------------
-- Output window: a scrollable EditBox so dumps can be Ctrl+C'd out instead of
-- scrolling off in chat. UIPanelScrollFrameTemplate is a stock Blizzard
-- template referenced by name, same "no XML of our own" approach the aura
-- list already uses with FauxScrollFrameTemplate (see OptionsFrame.lua).
-- ---------------------------------------------------------------------------
local buffer = {}
local frame, editBox
local function refresh()
editBox:SetText(table.concat(buffer, "\n"))
end
local function ensureFrame()
if frame then return end
frame = CreateFrame("Frame", "WA_DebugFrame", UIParent)
frame:SetWidth(560); frame:SetHeight(400)
frame:SetPoint("CENTER", UIParent, "CENTER", 250, 0)
frame:SetBackdrop(WA.Widgets.PANEL_BACKDROP)
frame:SetBackdropColor(0, 0, 0, 1)
frame:SetFrameStrata("DIALOG")
frame:SetToplevel(true)
frame:SetMovable(true)
frame:EnableMouse(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", function() frame:StartMoving() end)
frame:SetScript("OnDragStop", function() frame:StopMovingOrSizing() end)
frame:Hide()
local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
title:SetPoint("TOP", 0, -14)
title:SetText("WeakestAuras Debug")
title:SetTextColor(1, 0.82, 0)
local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", -4, -4)
close:SetScript("OnClick", function() frame:Hide() end)
local clearBtn = WA.Widgets.button(frame, "Clear", function() D.Clear() end)
clearBtn:SetWidth(70)
clearBtn:SetPoint("TOPLEFT", 12, -14)
local selectBtn = WA.Widgets.button(frame, "Select All", function()
editBox:SetFocus()
editBox:HighlightText()
end)
selectBtn:SetWidth(90)
selectBtn:SetPoint("LEFT", clearBtn, "RIGHT", 6, 0)
local scroll = CreateFrame("ScrollFrame", "WA_DebugFrameScroll", frame, "UIPanelScrollFrameTemplate")
scroll:SetPoint("TOPLEFT", 12, -44)
scroll:SetPoint("BOTTOMRIGHT", -30, 12)
editBox = CreateFrame("EditBox", "WA_DebugFrameEditBox", scroll)
editBox:SetMultiLine(true)
editBox:SetAutoFocus(false)
editBox:SetFontObject(ChatFontNormal)
editBox:SetWidth(500)
editBox:SetHeight(2000) -- generously tall; the scroll frame clips/scrolls it
editBox:SetTextInsets(4, 4, 4, 4)
editBox:SetScript("OnEscapePressed", function() editBox:ClearFocus() end)
scroll:SetScrollChild(editBox)
end
function D.Log(line)
ensureFrame()
table.insert(buffer, line)
if #buffer > MAX_LOG_LINES then
table.remove(buffer, 1)
end
refresh()
frame:Show()
end
function D.Clear()
buffer = {}
if editBox then refresh() end
end
function D.Show()
ensureFrame()
frame:Show()
end
-- ---------------------------------------------------------------------------
-- /wa dump [unit] [filter] -- settles: exact AuraData field shapes, whether
-- `name` ever carries a "(Rank N)" suffix, where the list actually terminates
-- (the buff-cap blind-spot question), and whether the positional values the
-- aura scan reads line up with the table field for field on the running build.
-- ---------------------------------------------------------------------------
local function dumpOne(unit, filter)
D.Log(string.format("--- dump unit=%s filter=%s ---", unit, filter))
local found, highest = 0, 0
for i = 1, MAX_SLOT do
local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, filter)
if aura then
found = found + 1
highest = i
local remain = -1
if aura.expirationTime and aura.expirationTime > 0 then
remain = aura.expirationTime - GetTime()
end
D.Log(string.format(
" i=%d name=%s apps=%s spellId=%s dispel=%s helpful=%s harmful=%s dur=%s exp=%s remain=%.1f src=%s srcGUID=%s",
i, tostring(aura.name), tostring(aura.applications), tostring(aura.spellId), tostring(aura.dispelName),
tostring(aura.isHelpful), tostring(aura.isHarmful), tostring(aura.duration), tostring(aura.expirationTime),
remain, tostring(aura.sourceUnit), tostring(aura.sourceGUID)))
-- The same aura as positional values, which UnitAuraBySlot shares with
-- UnitAura: the scan runs on these, so they are read beside the table
-- rather than trusted to match it.
local pName, pIcon, pCount, pDispel, pDur, pExp, pSrc,
_, _, pSpellId, _, _, pCastByPlayer, _, pTimeMod = C_UnitAuras.UnitAura(unit, i, filter)
D.Log(string.format(
" positional: 1=%s 3=%s 4=%s 5=%s 6=%s 7=%s 10=%s 13=%s 15=%s icon2=%s",
tostring(pName), tostring(pCount), tostring(pDispel), tostring(pDur),
tostring(pExp), tostring(pSrc), tostring(pSpellId),
tostring(pCastByPlayer), tostring(pTimeMod), tostring(pIcon == aura.icon)))
end
end
D.Log(string.format("--- end dump: %d aura(s), highest occupied slot=%d (scanned 1..%d) ---", found, highest, MAX_SLOT))
end
function D.Dump(unit, filter)
if not unit or unit == "" then unit = "player" end
if filter and filter ~= "" then
dumpOne(unit, string.upper(filter))
else
dumpOne(unit, "HELPFUL")
dumpOne(unit, "HARMFUL")
end
end
-- ---------------------------------------------------------------------------
-- /wa watch [unit] -- settles: does UNIT_AURA actually fire on every aura
-- change (including a same-buff refresh), or does it miss some the way
-- pfUI's own comment claims on their server? Diffs a snapshot on a timer and
-- flags any change that happened without a UNIT_AURA fire in that window.
-- ---------------------------------------------------------------------------
local WATCH_FILTERS = { "HELPFUL", "HARMFUL" }
local watch = {
active = {}, -- [unit] = true
lastSnapshot = {}, -- [unit] = { [filter..index] = packed string }
eventSeen = {}, -- [unit] = true/false since last tick
ticker = nil,
eventFrame = nil,
}
local function snapshotUnit(unit)
local snap = {}
for f = 1, #WATCH_FILTERS do
local filter = WATCH_FILTERS[f]
for i = 1, MAX_SLOT do
local aura = C_UnitAuras.GetAuraDataByIndex(unit, i, filter)
if aura then
snap[filter .. i] = tostring(aura.name) .. "|" .. tostring(aura.applications) .. "|" ..
tostring(aura.duration) .. "|" .. tostring(aura.expirationTime)
end
end
end
return snap
end
local function diffAndLog(unit)
local old = watch.lastSnapshot[unit] or {}
local new = snapshotUnit(unit)
for f = 1, #WATCH_FILTERS do
local filter = WATCH_FILTERS[f]
for i = 1, MAX_SLOT do
local key = filter .. i
if old[key] ~= new[key] then
D.Log(string.format("[watch:%s] %s slot %d changed: %s -> %s (UNIT_AURA seen: %s)",
unit, filter, i, tostring(old[key]), tostring(new[key]), tostring(watch.eventSeen[unit] and true or false)))
end
end
end
watch.lastSnapshot[unit] = new
watch.eventSeen[unit] = false
end
local function tickWatch()
for unit in pairs(watch.active) do
diffAndLog(unit)
end
end
local function ensureWatchRunning()
if not watch.eventFrame then
watch.eventFrame = CreateFrame("Frame")
watch.eventFrame:RegisterEvent("UNIT_AURA")
watch.eventFrame:SetScript("OnEvent", function()
if watch.active[arg1] then
watch.eventSeen[arg1] = true
end
end)
end
if not watch.ticker then
watch.ticker = C_Timer.NewTicker(0.5, tickWatch)
end
end
function D.ToggleWatch(unit)
if not unit or unit == "" then unit = "player" end
if watch.active[unit] then
watch.active[unit] = nil
D.Log("[watch] stopped watching " .. unit)
else
watch.active[unit] = true
watch.lastSnapshot[unit] = snapshotUnit(unit)
watch.eventSeen[unit] = false
D.Log("[watch] started watching " .. unit .. " (diffing every 0.5s against UNIT_AURA)")
ensureWatchRunning()
end
end
-- ---------------------------------------------------------------------------
-- /wa events [EVENT ...] -- raw event firehose, unfiltered and independent of
-- /wa watch's per-unit bookkeeping: logs every fire with its whole payload, so
-- the actual stream can be read instead of inferred from diffs (does it ever
-- double-fire for one change? fire for a unit nobody's watching?). Defaults to
-- UNIT_AURA. Naming events is how an unfamiliar one's argument order gets
-- settled -- a doc listing an order is not evidence of it on this client.
-- ---------------------------------------------------------------------------
local eventLogFrame
local eventLogActive = false
local eventLogCount = 0
local eventLogNames = {}
-- The payload as "arg1=x arg3=y", skipping the trailing nils a shorter event
-- leaves behind.
local function eventLogPayload()
local vals = { arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9 }
local parts = {}
for i = 1, 9 do
if vals[i] ~= nil then
table.insert(parts, "arg" .. i .. "=" .. tostring(vals[i]))
end
end
if #parts == 0 then return "(no args)" end
return table.concat(parts, " ")
end
function D.ToggleEventLog(rest)
if eventLogActive then
eventLogActive = false
for i = 1, #eventLogNames do
eventLogFrame:UnregisterEvent(eventLogNames[i])
end
D.Log(string.format("[events] stopped (%d fire(s) logged this session)", eventLogCount))
return
end
local wanted = {}
for name in string.gmatch(rest or "", "%S+") do
table.insert(wanted, string.upper(name))
end
if #wanted == 0 then wanted = { "UNIT_AURA" } end
if not eventLogFrame then
eventLogFrame = CreateFrame("Frame")
eventLogFrame:SetScript("OnEvent", function()
eventLogCount = eventLogCount + 1
D.Log(string.format("[events] #%d t=%.2f %s %s",
eventLogCount, GetTime(), event, eventLogPayload()))
end)
end
eventLogNames = {}
for i = 1, #wanted do
local name = wanted[i]
-- RegisterEvent throws on a name this client doesn't know, which is
-- itself the answer when the question is whether the event exists here.
if pcall(function() eventLogFrame:RegisterEvent(name) end) then
table.insert(eventLogNames, name)
else
D.Log("[events] " .. name .. " -- refused by the client, no such event")
end
end
eventLogActive = true
if #eventLogNames == 0 then
D.Log("[events] started, but nothing registered")
else
D.Log("[events] started on: " .. table.concat(eventLogNames, ", "))
end
end
-- ---------------------------------------------------------------------------
-- /wa linkprobe -- which entry point, if any, a shift-clicked item actually
-- reaches on this client. Vanilla FrameXML routes a bag shift-click straight
-- into ChatEdit_InsertLink (and only when the chat box is open);
-- HandleModifiedItemClick is a later-expansion function that may not exist
-- here at all, and a replacement bag UI bypasses both. Wraps every candidate
-- and logs which one fires, so the answer comes from a click rather than a doc.
-- ---------------------------------------------------------------------------
local linkProbeOn = false
function D.LinkProbe()
local names = {
"HandleModifiedItemClick", "ChatEdit_InsertLink", "SetItemRef",
"ContainerFrameItemButton_OnClick", "PickupContainerItem",
"IsModifiedClick", "GetContainerItemLink",
}
for i = 1, #names do
D.Log("[link] " .. names[i] .. " = " .. type(getglobal(names[i])))
end
D.Log("[link] chat edit box visible = "
.. tostring(ChatFrameEditBox and ChatFrameEditBox:IsVisible() and true or false))
if linkProbeOn then
D.Log("[link] already logging -- shift-click an item in your bags now")
return
end
linkProbeOn = true
-- Each wrapper logs and calls through, so nothing it touches changes
-- behaviour. Left installed for the session: these are cold paths.
for i = 1, #names do
local name = names[i]
local orig = getglobal(name)
if type(orig) == "function" then
setglobal(name, function(a1, a2, a3, a4)
D.Log("[link] " .. name .. "(" .. tostring(a1) .. ", " .. tostring(a2) .. ")")
return orig(a1, a2, a3, a4)
end)
end
end
D.Log("[link] logging installed -- now shift-click an item in your bags")
end
-- ---------------------------------------------------------------------------
-- /wa swipelog [seconds] -- settles: what is done to a live icon's cooldown
-- swipe while it flickers. Every SetCooldown on ClassicAPI's frame blanks the
-- sweep for a frame, and so may hiding or reparenting it, so the counts say
-- which of those is reaching the frame and how often. Wraps the methods on
-- every icon region live at the start, as plain fields shadowing the frame's
-- own, and puts back exactly what was there when the window ends.
-- ---------------------------------------------------------------------------
local SWIPE_LOG_METHODS = {
swipe = { "SetCooldown", "SetCooldownFromExpirationTime", "Clear", "SetPaused", "Pause",
"Hide", "Show", "SetFrameLevel", "SetParent" },
region = { "Hide", "Show", "SetParent", "SetFrameLevel" },
}
local SWIPE_LOG_DETAIL = 12
local swipeLogActive = false
function D.SwipeLog(rest)
if swipeLogActive then
D.Log("[swipelog] already running")
return
end
local seconds = tonumber(rest) or 10
local counts, wrapped, detailed = {}, {}, 0
local startedAt = GetTime()
local function wrap(obj, method, label)
local original = rawget(obj, method)
local through = obj[method]
table.insert(wrapped, { obj = obj, method = method, original = original })
obj[method] = function(self, ...)
counts[label] = (counts[label] or 0) + 1
if detailed < SWIPE_LOG_DETAIL and string.find(label, "Cooldown", 1, true) then
detailed = detailed + 1
local args = { ... }
D.Log(string.format("[swipelog] +%.3fs %s (%s, %s)", GetTime() - startedAt, label,
tostring(args[1]), tostring(args[2])))
end
return through(self, ...)
end
end
local icons = 0
WA.ForEachRegion(function(region, id)
if region.swipe then
icons = icons + 1
local name = tostring(id) .. "/" .. tostring(region.cloneId or "")
for _, m in ipairs(SWIPE_LOG_METHODS.swipe) do wrap(region.swipe, m, name .. " swipe:" .. m) end
for _, m in ipairs(SWIPE_LOG_METHODS.region) do wrap(region, m, name .. " region:" .. m) end
end
end)
local percentage = CooldownFrame_SetDisplayAsPercentage
CooldownFrame_SetDisplayAsPercentage = function(cd, fraction)
counts["CooldownFrame_SetDisplayAsPercentage"] = (counts["CooldownFrame_SetDisplayAsPercentage"] or 0) + 1
return percentage(cd, fraction)
end
swipeLogActive = true
D.Log(string.format("[swipelog] watching %d icon region(s) for %ds -- let it flicker", icons, seconds))
C_Timer.After(seconds, function()
for i = #wrapped, 1, -1 do
local w = wrapped[i]
w.obj[w.method] = w.original
end
CooldownFrame_SetDisplayAsPercentage = percentage
swipeLogActive = false
local labels = {}
for label in pairs(counts) do table.insert(labels, label) end
table.sort(labels)
D.Log(string.format("[swipelog] calls over %ds:", seconds))
for _, label in ipairs(labels) do
D.Log(string.format(" %-60s %5d (%.1f/s)", label, counts[label], counts[label] / seconds))
end
if #labels == 0 then D.Log(" none -- nothing touched any swipe or its icon") end
end)
end
-- ---------------------------------------------------------------------------
-- /wa swipestress [N] -- settles: what does an animating cooldown swipe cost,
-- in frame time, at realistic icon counts? The swipe is ClassicAPI's native
-- Cooldown frame, so this is the engine's draw cost alone. With no argument it
-- sweeps 0/10/40/100 rigs and prints the marginal cost per icon; with N it
-- measures one count and leaves the rigs up.
--
-- Frames are counted from an OnUpdate over a wall-clock window rather than
-- read from GetFramerate, which is smoothed over an unspecified span and so
-- lags a step change like this one. The meter's own tick is in every phase
-- including the baseline, so it cancels out of the difference.
--
-- The rigs arm a 600s countdown: barely any of it elapses during a sweep, so
-- every sweep stays a >270-degree span, the most geometry it draws.
-- ---------------------------------------------------------------------------
local SWIPE_STRESS_SIZE = 24
local SWIPE_STRESS_COLS = 10
local SWIPE_STRESS_ARM = 600
local SWIPE_STRESS_SETTLE = 1
local SWIPE_STRESS_WINDOW = 4
local swipeStressRigs = {}
local swipeStressMeter
local swipeStressRun
local function swipeStressTick()
if swipeStressRun then swipeStressRun.frames = swipeStressRun.frames + 1 end
end
local function swipeStressRig(index)
if swipeStressRigs[index] then return swipeStressRigs[index] end
local col = math.fmod(index - 1, SWIPE_STRESS_COLS)
local row = math.floor((index - 1) / SWIPE_STRESS_COLS)
local pitch = SWIPE_STRESS_SIZE + 2
local rig = CreateFrame("Frame", nil, UIParent)
rig:SetWidth(SWIPE_STRESS_SIZE)
rig:SetHeight(SWIPE_STRESS_SIZE)
rig:SetPoint("CENTER", UIParent, "CENTER",
(col - (SWIPE_STRESS_COLS - 1) / 2) * pitch, 200 - row * pitch)
local tex = rig:CreateTexture(nil, "ARTWORK")
tex:SetAllPoints(rig)
tex:SetTexture("Interface\\Icons\\Spell_Nature_LightningShield")
tex:SetTexCoord(0.07, 0.93, 0.07, 0.93)
rig.swipe = WA.regionPrototype.CreateSwipe(rig)
swipeStressRigs[index] = rig
return rig
end
local function swipeStressShow(count)
local existing = #swipeStressRigs
local total = count > existing and count or existing
for i = 1, total do
if i <= count then
local rig = swipeStressRig(i)
rig.swipe:SetCooldownDuration(SWIPE_STRESS_ARM)
rig:Show()
else
local rig = swipeStressRigs[i]
rig.swipe:Clear()
rig:Hide()
end
end
end
local swipeStressMeasure, swipeStressReport
local function swipeStressPhase()
local run = swipeStressRun
if not run then return end
run.index = run.index + 1
local count = run.counts[run.index]
if not count then
swipeStressReport()
return
end
swipeStressShow(count)
C_Timer.After(SWIPE_STRESS_SETTLE, swipeStressMeasure)
end
function swipeStressMeasure()
local run = swipeStressRun
if not run then return end
run.frames = 0
run.started = GetTime()
swipeStressMeter:Show()
C_Timer.After(SWIPE_STRESS_WINDOW, function()
local r = swipeStressRun
if not r then return end
swipeStressMeter:Hide()
local elapsed = GetTime() - r.started
local fps = elapsed > 0 and r.frames / elapsed or 0
local ms = fps > 0 and 1000 / fps or 0
local count = r.counts[r.index]
r.ms[count] = ms
D.Log(string.format("[swipestress] %3d swipes: %.1f fps, %.2f ms/frame", count, fps, ms))
swipeStressPhase()
end)
end
function swipeStressReport()
local run = swipeStressRun
swipeStressRun = nil
local base = run.ms[0]
if not base then return end
for i = 1, #run.counts do
local count = run.counts[i]
local ms = run.ms[count]
if count > 0 and ms then
D.Log(string.format("[swipestress] %3d swipes: %+.2f ms/frame over baseline, %.0f us per swipe",
count, ms - base, (ms - base) * 1000 / count))
end
end
D.Log("[swipestress] done -- rigs left up at the last count, /wa swipestress 0 to clear")
end
function D.SwipeStress(rest)
if swipeStressRun then
D.Log("[swipestress] a sweep is already running")
return
end
local count = tonumber(rest)
if count == 0 then
swipeStressShow(0)
D.Log("[swipestress] cleared")
return
end
if not swipeStressMeter then
swipeStressMeter = CreateFrame("Frame", nil, UIParent)
swipeStressMeter:SetScript("OnUpdate", swipeStressTick)
swipeStressMeter:Hide()
end
local counts = count and { count } or { 0, 10, 40, 100 }
swipeStressRun = { counts = counts, index = 0, frames = 0, ms = {} }
D.Log(string.format("[swipestress] measuring %d phase(s), %.0fs each -- hold still and don't move the camera",
#counts, SWIPE_STRESS_SETTLE + SWIPE_STRESS_WINDOW))
swipeStressPhase()
end
-- ---------------------------------------------------------------------------
-- /wa track <spellName> -- settles: is the player's own duration/
-- expirationTime actually monotonic and does it reset cleanly on recast, or
-- does it exhibit the same flakiness DoiteAuras had to work around?
-- ---------------------------------------------------------------------------
local trackSpell
local trackTicker
local function trackTick()
if not trackSpell then return end
local found
for i = 1, MAX_SLOT do
local aura = C_UnitAuras.GetAuraDataByIndex("player", i, "HELPFUL")
if not aura then break end
if aura.name == trackSpell then
found = aura
break
end
end
if found then
local remain = -1
if found.expirationTime and found.expirationTime > 0 then
remain = found.expirationTime - GetTime()
end
D.Log(string.format("[track] t=%.1f %s stacks=%s dur=%s exp=%s remain=%.1f",
GetTime(), trackSpell, tostring(found.applications), tostring(found.duration),
tostring(found.expirationTime), remain))
else
D.Log(string.format("[track] t=%.1f %s NOT FOUND", GetTime(), trackSpell))
end
end
function D.Track(spellName)
if not spellName or spellName == "" then
trackSpell = nil
if trackTicker then trackTicker:Cancel(); trackTicker = nil end
D.Log("[track] stopped")
return
end
trackSpell = spellName
D.Log("[track] now tracking \"" .. spellName .. "\" (logging every 1s)")
if not trackTicker then
trackTicker = C_Timer.NewTicker(1, trackTick)
end
end
-- ---------------------------------------------------------------------------
-- /wa states <id> -- dumps triggerState[id]: the combination flags and every
-- trigger's per-clone states. The single most useful command for debugging the
-- state machine -- shows what the producers wrote and what the glue resolved.
-- ---------------------------------------------------------------------------
local STATE_FIELDS = {
"show", "changed", "active", "progressType", "name", "stacks", "duration",
"expirationTime", "value", "total", "spellId", "unit", "unitCaster",
"initialTime", "refreshTime", "stackGainTime", "stackLostTime",
}
local function dumpState(triggernum, cloneId, state)
local parts = {}
for i = 1, #STATE_FIELDS do
local f = STATE_FIELDS[i]
if state[f] ~= nil then
local v = state[f]
if f == "expirationTime" and type(v) == "number" and v > 0 then
v = string.format("%s (rem %.1f)", tostring(v), v - GetTime())
end
table.insert(parts, f .. "=" .. tostring(v))
end
end
D.Log(string.format(" [%d][%q] %s", triggernum, cloneId, table.concat(parts, " ")))
end
function D.States(id)
if not id or id == "" then
D.Log("[states] usage: /wa states <aura id>")
return
end
local ts = WA.GetDisplayTriggerState and WA.GetDisplayTriggerState(id)
if not ts then
D.Log(string.format("[states] no triggerState for %q (unknown id, or a group)", id))
return
end
local ftCount = WA.regionPrototype.CountFrameTick and WA.regionPrototype.CountFrameTick() or 0
D.Log(string.format("--- states %q: show=%s disjunctive=%s numTriggers=%d triggerCount=%d activeMode=%s (global FrameTick subscribers: %d) ---",
id, tostring(ts.show), tostring(ts.disjunctive), ts.numTriggers, ts.triggerCount, tostring(ts.activeTriggerMode), ftCount))
for triggernum = 1, ts.numTriggers do
D.Log(string.format(" trigger %d: active=%s", triggernum, tostring(ts.triggers[triggernum])))
local allstates = ts[triggernum]
if allstates then
for cloneId, state in pairs(allstates) do
dumpState(triggernum, cloneId, state)
end
end
end
D.Log("--- end states ---")
end
-- ---------------------------------------------------------------------------
-- /wa conditions <id> -- dumps data.conditions (each check + its changes) and
-- the live per-clone activation flags + whether an exact recheck timer is
-- pending. The verification tool for condition timing: confirms a
-- timer/elapsedTimer flip is scheduled rather than polled, and which
-- conditions the interpreter currently has active.
-- ---------------------------------------------------------------------------
function D.Conditions(id)
if not id or id == "" then
D.Log("[conditions] usage: /wa conditions <aura id>")
return
end
local data = WeakestAurasDB.displays[id]
if not data then
D.Log("[conditions] unknown id " .. tostring(id))
return
end
local conds = data.conditions or {}
local entry, act, pending
if WA.GetConditionDebug then entry, act, pending = WA.GetConditionDebug(data.uid) end
D.Log(string.format("--- conditions %q: %d condition(s) ---", id, #conds))
-- A Custom Check's `value` is a whole function body and its `op` an event
-- list, so the generic line below says nothing useful about either. Whether
-- the source compiled is the question this dump exists to answer for it.
local function dumpCheck(prefix, chk)
if chk.trigger == -2 then
D.Log(string.format("%scheck: %s of %d", prefix, tostring(chk.variable),
#(chk.checks or {})))
for k = 1, #(chk.checks or {}) do
dumpCheck(prefix .. " ", chk.checks[k])
end
return
end
if chk.trigger == -1 and chk.variable == "customcheck" then
local compiledFn = entry and entry.customChecks and entry.customChecks[chk.value or ""]
local state = "not compiled"
if compiledFn then state = "compiled"
elseif compiledFn == false then state = "REFUSED (see the aura's warning)" end
D.Log(string.format("%scheck: customcheck [%s] events=%s",
prefix, state, tostring(chk.op)))
return
end
D.Log(string.format("%scheck: trigger=%s var=%s op=%s value=%s",
prefix, tostring(chk.trigger), tostring(chk.variable), tostring(chk.op), tostring(chk.value)))
end
for i = 1, #conds do
local c = conds[i]
dumpCheck(string.format(" [%d] ", i), c.check or {})
local changes = c.changes or {}
for j = 1, #changes do
D.Log(string.format(" change: %s = %s", tostring(changes[j].property), tostring(changes[j].value)))
end
end
if WA.GetConditionDebug then
if act then
for cloneId, flags in pairs(act) do
local parts = {}
for i = 1, #conds do table.insert(parts, tostring(flags[i] and 1 or 0)) end
D.Log(string.format(" active[%q] = [%s]", cloneId, table.concat(parts, ",")))
end
end
if pending and #pending > 0 then
D.Log(" pending recheck for clone(s): " .. table.concat(pending, ", "))
else
D.Log(" no pending timer recheck")
end
end
D.Log("--- end conditions ---")
end
-- ---------------------------------------------------------------------------
-- /wa gen <id> -- dumps the Lua source ConstructFunction generated for this
-- display's generic trigger(s). A string-
-- assembly bug surfaces as readable source here rather than an opaque
-- loadstring error at Add-time.
-- ---------------------------------------------------------------------------
function D.Gen(id)
if not id or id == "" then
D.Log("[gen] usage: /wa gen <aura id>")
return
end
local src = WA.GetGeneratedSource and WA.GetGeneratedSource(id)
if not src then
D.Log(string.format("[gen] no generated source for %q (not a generic trigger, or unknown id)", id))
return
end
D.Log(string.format("--- generated source for %q ---", id))
D.Log(src)
D.Log("--- end generated source ---")
end
-- ---------------------------------------------------------------------------
-- /wa load <id> -- dumps data.load (the enabled constraints) and whether the
-- display is currently loaded (WA.IsDisplayLoaded) + what a fresh WA.EvalLoad
-- says at the time of the check. The verification tool for load state:
-- confirms an aura loads/unloads as its class/level/zone/combat/... change, and
-- that a transient-only failure reads as standby rather than not loaded.
-- ---------------------------------------------------------------------------
function D.Load(id)
if not id or id == "" then
D.Log("[load] usage: /wa load <aura id>")
return
end
local data = WeakestAurasDB.displays[id]
if not data then
D.Log("[load] unknown id " .. tostring(id))
return
end
local state, nLoaded, nStandby, nLeaves = WA.DisplayLoadState(id)
if WA.IsGroup(data) then
D.Log(string.format("[load] %q is a group: state=%s (%d loaded, %d standby of %d leaves)",
id, state, nLoaded, nStandby, nLeaves))
return
end
local L = data.load or {}
local isLoaded = WA.IsDisplayLoaded and WA.IsDisplayLoaded(id)
local evalNow = WA.EvalLoad and WA.EvalLoad(data)
local evalStatic = WA.EvalLoadStatic and WA.EvalLoadStatic(data)
D.Log(string.format("--- load %q: state=%s loaded=%s evalNow=%s evalStatic=%s ---",
id, state, tostring(isLoaded), tostring(evalNow), tostring(evalStatic)))
if WA.IsGenericTriggerActive then
local genActive = WA.IsGenericTriggerActive(id)
if genActive ~= nil then
D.Log(string.format(" GenericTrigger registration: active=%s (if this is false while loaded=true above, the system's LoadDisplays never actually finished for this id)", tostring(genActive)))
end
end
if L.never then D.Log(" never = true (force-disabled)") end
local proto = WA.loadPrototype or {}
local anyConstraint = L.never
for i = 1, #proto do
local arg = proto[i]
-- Branched, not `and`/`or`-ed -- see WA.EvalLoad's own gate.
local active
if arg.isActive then
active = arg.isActive(L) and true or false
else
active = L["use_" .. arg.name] and true or false
end
if active then
anyConstraint = true
if arg.name == "class" then
if L.use_class == "single" then
D.Log(" class = " .. tostring(L.class) .. " (single)")
else
local picked = {}
for ci = 1, #WA.CLASS_TOKENS do
local token = WA.CLASS_TOKENS[ci]
if L.classes and L.classes[token] then table.insert(picked, token) end
end
D.Log(" class = " .. (#picked > 0 and table.concat(picked, ",") or "(none picked)") .. " (multi)")
end
else
local opKey = arg.name .. "_operator"
local opPart = L[opKey] and (" op=" .. tostring(L[opKey])) or ""
D.Log(string.format(" %s =%s value=%s%s", arg.name, opPart, tostring(L[arg.name]),
arg.optional and " (transient -- ignored by evalStatic)" or ""))
end
end
end
if L.use_class ~= nil and L.use_class ~= "single" and L.use_class ~= "multi" then
D.Log(" note: use_class=" .. tostring(L.use_class) .. " is a stray/legacy value (ignored, not \"single\"/\"multi\")")
end
if not anyConstraint then D.Log(" (no constraints -- always loaded)") end
D.Log("--- end load ---")
end
-- ---------------------------------------------------------------------------
-- /wa probe -- checks two client capabilities before relying on either:
-- (1) is C_EncodingUtil's full CBOR chain present (import/
-- export), and (2) does the border's WHITE8X8 edge texture actually render on
-- this client. Also reports the glow overlay pool count (leak check). Spawns a
-- visible bordered test frame at CENTER,0,150 -- watch it in-world, not here.
-- ---------------------------------------------------------------------------
local probeFrame
-- ---------------------------------------------------------------------------
-- /wa rows -- measured geometry of the aura list: the box, how many rows it
-- decided fit and what that leaves unused, and every part of the first two
-- rows. The one thing the headless harness structurally cannot see is where a
-- frame actually lands (its mock has no geometry engine), so a row whose
-- highlight doesn't cover its own text, or a list wasting a row's worth of
-- height, is answered here rather than by reading anchors.
-- ---------------------------------------------------------------------------
-- Live and pooled region frames per type. The number to watch is the total:
-- flipping a display's region type back and forth must not grow it, since a
-- frame this client cannot destroy is stranded for the session if it is not
-- recycled.
function D.Regions()
local live, pooled, liveTotal, pooledTotal = WA.RegionCensus()
D.Log(string.format("--- regions: %d live, %d pooled, %d total ---",
liveTotal, pooledTotal, liveTotal + pooledTotal))
local names = {}
for regionType in pairs(live) do table.insert(names, regionType) end
for regionType in pairs(pooled) do
if not live[regionType] then table.insert(names, regionType) end
end
table.sort(names)
for i = 1, #names do
local regionType = names[i]
D.Log(string.format(" %-15s live=%d pooled=%d", regionType,
live[regionType] or 0, pooled[regionType] or 0))
end
if #names == 0 then D.Log(" (no regions built yet)") end
end
function D.Rows()
local S = WA.OptionsState
if not S or not S.listBg then
D.Log("[rows] options window has never been built -- open it first")
return
end
local function edges(name, f)
if not f then D.Log(" " .. name .. " = (nil)") return end
D.Log(string.format(" %-7s top=%.1f bottom=%.1f h=%.1f left=%.1f right=%.1f w=%.1f shown=%s",
name, f:GetTop() or -1, f:GetBottom() or -1, f:GetHeight() or -1,
f:GetLeft() or -1, f:GetRight() or -1, f:GetWidth() or -1, tostring(f:IsShown() and true or false)))
end
local h = S.listBg:GetHeight() or 0
local used = S.visibleRows * S.ROW_H
D.Log(string.format("--- rows: listBg h=%.1f, ROW_H=%d, visibleRows=%d ---", h, S.ROW_H, S.visibleRows))
D.Log(string.format(" rows occupy %d px of %.1f, leaving %.1f unused (a further row needs %d)",
used, h, h - used - 2, S.ROW_H))
edges("listBg", S.listBg)
edges("scroll", S.scroll)
for i = 1, 2 do
local row = S.rows and S.rows[i]
if row then
D.Log(" -- row " .. i .. " (" .. tostring(row.id) .. ")")
edges("row", row)
edges("sel", row.sel)
edges("icon", row.icon)
edges("thumb", row.thumb)
edges("title", row.title)
edges("sub", row.sub)
edges("strip", row.statusStrip)
edges("eye", row.eye)
end
end
D.Log("--- end rows ---")
end
-- A leaf carrying one custom option of every type, for exercising the Custom
-- tab by hand. Built here rather than shipped as an import string because an
-- export string can only be produced by the client's own C_EncodingUtil --
-- a hand-rolled CBOR/zlib/base64 blob cannot be checked against tinycbor
-- offline, so it is not a fixture anything can trust.
function D.ConfigTest()
local data = WA.NewAura("icon")
data.authorOptions = {
{ type = "toggle", key = "showIt", name = "Show It", default = true, width = 1 },
{ type = "input", key = "label", name = "Label", default = "hello", width = 1 },
{ type = "number", key = "count", name = "Count", default = 3, min = 0, max = 10, step = 1, width = 1 },
{ type = "range", key = "size", name = "Size", default = 32, min = 8, max = 64, step = 1, width = 1 },
{ type = "select", key = "mode", name = "Mode", default = 2,
values = { "Alpha", "Beta", "Gamma" }, width = 1 },
{ type = "color", key = "tint", name = "Tint", default = { 1, 0.5, 0, 1 }, width = 1 },
{ type = "multiselect", key = "flags", name = "Flags", default = { true, false, true },
values = { "One", "Two", "Three" }, width = 1 },
{ type = "description", text = "Change a control, switch tabs, come back.", width = 2 },
{ type = "header", useName = true, name = "Groups", text = "", width = 2 },
{ type = "group", key = "grp", name = "Simple Group", groupType = "simple",
useCollapse = true, collapse = false, width = 2, subOptions = {
{ type = "toggle", key = "inner", name = "Inner Toggle", default = false, width = 1 },
{ type = "input", key = "innerText", name = "Inner Text", default = "nested", width = 1 },
} },
{ type = "group", key = "arr", name = "Array Group", groupType = "array",
limitType = "none", size = 10, nameSource = 1, hideReorder = false,
useCollapse = true, collapse = false, width = 2, subOptions = {
{ type = "input", key = "entryName", name = "Entry Name", default = "entry", width = 1 },
{ type = "toggle", key = "entryOn", name = "Enabled", default = true, width = 1 },
} },
}
data.config = {}
WA.MergeDefaults(data)
WA.Add(data)
if WA.RefreshList then WA.RefreshList() end
D.Log("--- configtest ---")
D.Log(" created \"" .. tostring(data.id) .. "\" with "
.. #data.authorOptions .. " custom options")
D.Log(" open the options window, pick it, and use the Custom tab")
return data
end
-- Which vendored LibWidgets actually won, and whether it still has everything
-- this addon calls. Every addon vendoring the library shares one global
-- instance (highest MINOR wins, ties to whoever registered first), so a sibling
-- addon's older copy can silently be the one running -- see libs\LibWidgetsDev.lua.
function D.Libs()
local W = WA.Widgets
D.Log("--- libs ---")
-- Whichever addon's LibStub loaded first is the one in force for everyone
-- (equal LIBSTUB_MINOR means later copies skip). Upstream's NewLibrary calls
-- string.match, absent here, so a copy that hasn't been patched to
-- string.find throws on every registration -- worth being able to see which
-- one is live rather than inferring it from a crash.
D.Log(" string.match available = " .. tostring(string.match ~= nil))
D.Log(" LibStub minor = " .. tostring(LibStub and LibStub.minor))
D.Log(" LIBWIDGETS_DEV = " .. tostring(LIBWIDGETS_DEV))
D.Log(" live LibWidgets MINOR = " .. tostring(LibWidgets and LibWidgets.MINOR))
if LibStub and LibStub.minors then
D.Log(" LibStub minors[LibWidgets-1.0] = " .. tostring(LibStub.minors["LibWidgets-1.0"]))
end
if LibWidgets then
-- How the live LibStub would have read our MINOR (9). A backported
-- string.match with string.find's return contract yields a position
-- rather than the captured digits, which silently turns every version
-- into the same number and hands the race to whoever registered first.
D.Log(" MINOR 9 parses as: via string.match = " .. tostring(LibWidgets.PARSE_MATCH)
.. ", via string.find capture = " .. tostring(LibWidgets.PARSE_FIND))
D.Log(" minors[LibWidgets-1.0] before our registration = " .. tostring(LibWidgets.PRE_MINOR))
end
if LibStub and LibStub.libs then
-- Every major reads back as LibStub's own minor (2) even though the
-- libraries declare 3/6/8/44, while our own write of 9 into the same
-- table reads back correctly. A plain table can't do that, so dump the
-- raw value beside the indexed one and the metatables: if rawget differs,
-- something has installed an __index on LibStub's bookkeeping.
D.Log(" metatables: LibStub=" .. tostring(getmetatable(LibStub))
.. " minors=" .. tostring(getmetatable(LibStub.minors))
.. " libs=" .. tostring(getmetatable(LibStub.libs)))
local majors = {}
for major in pairs(LibStub.libs) do
local raw, indexed = rawget(LibStub.minors, major), LibStub.minors[major]
local entry = major .. "=" .. tostring(indexed)
if raw ~= indexed then entry = entry .. "(raw " .. tostring(raw) .. ")" end
table.insert(majors, entry)
end
table.sort(majors)
D.Log(" all LibStub majors: " .. (#majors > 0 and table.concat(majors, ", ") or "(none)"))
end
-- The decisive line: the numbers above look the same whether this copy won
-- the version race or the dev flag forced it, since forcing it rewrites the
-- registered minor to our own.
if LibWidgets and LibWidgets.REPAIRED then
D.Log(" LibStub refused the upgrade, so our MINOR " .. tostring(LibWidgets.MINOR)
.. " took over the older live copy (MINOR " .. tostring(LibWidgets.DISPLACED_MINOR)
.. ") on its own. This is the normal path on this client and needs no dev flag.")
elseif LibWidgets and LibWidgets.DEV_OVERRIDE then
D.Log(" our copy is live ONLY because LIBWIDGETS_DEV forced it -- it lost the"
.. " race to a copy registered at MINOR " .. tostring(LibWidgets.DISPLACED_MINOR))
elseif LibWidgets and LibWidgets.DEV_OVERRIDE == false then
D.Log(" our copy won the version race on its own (displaced MINOR "
.. tostring(LibWidgets.DISPLACED_MINOR) .. ") -- the dev flag isn't doing anything")
else
D.Log(" our copy is NOT live: some other addon's copy is (it predates DEV_OVERRIDE)")
end
-- Behavioural probe. Rather than keep hunting for whichever LibStub is live
-- (LibWindow-1.1 is registered with no file on disk anywhere, so it isn't an
-- addon's), just ask it: register a throwaway major at minor 1, then at 99.
-- A working LibStub returns a table for the second call and records 99.
local probeMajor = "WeakestAurasLibStubProbe-1.0"
if LibStub and not LibStub.libs[probeMajor] then
LibStub:NewLibrary(probeMajor, 1)
local second = LibStub:NewLibrary(probeMajor, 99)
D.probeResult = (second and "honours versions" or "IGNORES versions -- rejected minor 99 over minor 1")
.. " (recorded minor = " .. tostring(LibStub.minors[probeMajor]) .. ")"
end
if D.probeResult then D.Log(" LibStub arbitration probe: " .. D.probeResult) end
local missing = W and W.libWidgetsMissing or {}
if #missing == 0 then
D.Log(" every required function present")
else
D.Log(" MISSING: " .. table.concat(missing, ", "))
end
end
-- Every addon the engine enumerated, loaded or not. MPQ-resident addons (packed
-- into Data\patch-X.mpq under Interface\AddOns\) enumerate exactly like disk
-- ones but load *before* them, so anything listed here with no folder in
-- Interface\AddOns is MPQ-resident -- which is how a library can be registered
-- with no file for it anywhere on disk. Cheaper than opening the archives.
function D.Addons()
D.Log("--- addons (" .. GetNumAddOns() .. " enumerated) ---")
local line = {}
for i = 1, GetNumAddOns() do
local name = GetAddOnInfo(i)
local loaded = IsAddOnLoaded(i)
table.insert(line, (name or "?") .. (loaded and "" or "(not loaded)"))
if #line == 6 then
D.Log(" " .. table.concat(line, ", "))
line = {}
end
end
if #line > 0 then D.Log(" " .. table.concat(line, ", ")) end
end
-- Both spell-cooldown sources for one spell, side by side. The slot read is what
-- the watcher uses; the C_Spell read is the documented spellID call it fell back
-- from. Run it while the spell is on cooldown -- if the slot reports a window and
-- C_Spell reports zeros, the spellID form does not see this client's cooldowns.
function D.CdProbe(rest)
D.Log("--- cdprobe ---")
if not rest or rest == "" then
D.Log(" usage: /wa cdprobe <spell name or id>")
D.Log("--- end cdprobe ---")
return
end
local book = BOOKTYPE_SPELL or "spell"
local id = WA.ResolveSpellID(rest)
D.Log(" input " .. rest .. " -> spellID " .. tostring(id))
if not id then
D.Log(" does not resolve: a name only resolves through the player's own spellbook")
D.Log("--- end cdprobe ---")
return
end
local name = C_Spell.GetSpellName(id)
D.Log(" name = " .. tostring(name))
local slot = WA.SpellSlotByID(id)
if slot then
local sname, srank = GetSpellName(slot, book)
D.Log(" slot " .. slot .. " = " .. tostring(sname) .. " " .. tostring(srank))
local start, duration = WA.SpellSlotCooldown(slot)
if start then
D.Log(string.format(" slot cooldown: start %.2f, duration %.2f, %.2f left",
start, duration, (start + duration) - GetTime()))
else
D.Log(" slot cooldown: unreadable")
end
else
D.Log(" slot: none -- this spell is not in the player's book")
end
local info = C_Spell.GetSpellCooldown(id)
if info then
D.Log(string.format(" C_Spell.GetSpellCooldown(%d): start %s, duration %s",
id, tostring(info.startTime), tostring(info.duration)))
else
D.Log(" C_Spell.GetSpellCooldown(" .. id .. ") = nil")
end
D.Log(string.format(" GetTime() = %.2f", GetTime()))
-- Raw: the point of the dump is the window before the GCD filter, which the
-- next two lines then report the filter's verdict on.
local wStart, wDuration = WA.SpellCdInfo(id, true)
local base = WA.SpellBaseCooldown and WA.SpellBaseCooldown(id) or 0
D.Log(" watcher answers: start " .. tostring(wStart) .. ", duration " .. tostring(wDuration))
D.Log(" own cooldown in the spell record = " .. tostring(base)
.. "s (0 = none of its own, so any window on it is shared)")
local shared = WA.IsSharedCooldown and WA.IsSharedCooldown(wDuration, base)
D.Log(" IsSharedCooldown(that window) = " .. tostring(shared))
D.Log(" => a Cooldown Ready trigger reads "
.. (((wDuration or 0) <= 0 or shared or ((wStart or 0) + (wDuration or 0)) <= GetTime())
and "READY" or "on cooldown"))
-- Every rank, since a name resolves to one specific rank's ID and only the
-- rank actually cast carries the cooldown.
if name and GetNumSpellTabs then
local shown = 0
for tab = 1, (GetNumSpellTabs() or 0) do
local _, _, offset, numSlots = GetSpellTabInfo(tab)
for i = (offset or 0) + 1, (offset or 0) + (numSlots or 0) do
local n, r = GetSpellName(i, book)
if n == name then
local s, d = WA.SpellSlotCooldown(i)
D.Log(" rank slot " .. i .. " (" .. tostring(r) .. "): start "
.. tostring(s) .. ", duration " .. tostring(d))
shown = shown + 1
end
end
end
if shown == 0 then D.Log(" no spellbook slot carries that name") end
end
D.Log("--- end cdprobe ---")
end
-- Live view of the global-cooldown watcher, plus the two reads it deliberately
-- does not make. Upstream's GCD spells (29515 Classic, 61304 elsewhere) and the
-- spellID-shaped GetSpellCooldown call are both reported here rather than only
-- argued about in a comment: this is what settles whether they are dead on a
-- given build.
function D.Gcd()
D.Log("--- gcd ---")
local book = BOOKTYPE_SPELL or "spell"
if not WA.GcdDebug then D.Log(" no GCD watcher in this build"); D.Log("--- end gcd ---"); return end
WA.WatchGCD()
local g = WA.GcdDebug()
D.Log(" watching = " .. tostring(g.watching)
.. ", book tabs = " .. tostring(GetNumSpellTabs()))
if g.probeSlot then
local name, rank = GetSpellName(g.probeSlot, book)
local ok, start, duration = pcall(GetSpellCooldown, g.probeSlot, book)
D.Log(" probe slot " .. g.probeSlot .. " = " .. tostring(name) .. " " .. tostring(rank))
D.Log(" slot reads " .. (ok and (tostring(start) .. " + " .. tostring(duration)) or ("ERROR " .. tostring(start))))
else
D.Log(" probe slot: none learned -- cast an instant that has no cooldown of its own, then run this again")
end
D.Log(" measured GCD length = " .. tostring(g.measured or "none yet (assuming 1.5)"))
local ranged = UnitRangedDamage and UnitRangedDamage("player")
D.Log(" ranged swing = " .. tostring(ranged or "no UnitRangedDamage")
.. " -- a shot locks every spell for this long, and a window of it is read as shared, not as a cooldown")
local start, duration = WA.GcdInfo()
if duration > 0 then
D.Log(string.format(" window: start %.2f, length %.2f, %.2f left", start, duration, (start + duration) - GetTime()))
else
D.Log(" window: closed")
end
local info = C_Spell.GetSpellCooldown(61304)
D.Log(" C_Spell.GetSpellCooldown(61304) = " .. (info and (tostring(info.startTime) .. " + " .. tostring(info.duration)) or "nil (no Spell.dbc row)"))
local ok, err = pcall(GetSpellCooldown, 61304, book)
D.Log(" GetSpellCooldown(61304, bookType) = " .. (ok and "returned a value" or ("ERROR " .. tostring(err))))
D.Log("--- end gcd ---")
end
function D.Probe()
local E = C_EncodingUtil
D.Log("--- probe ---")
if E then
local fns = { "SerializeCBOR", "DeserializeCBOR", "CompressString", "DecompressString", "EncodeBase64", "DecodeBase64" }
local parts = {}
for i = 1, #fns do
table.insert(parts, fns[i] .. "=" .. (E[fns[i]] and "yes" or "NO"))
end
D.Log(" C_EncodingUtil: " .. table.concat(parts, " "))
else
D.Log(" C_EncodingUtil: ABSENT (import/export disabled)")
end
D.Log(" WA.hasImportExport = " .. tostring(WA.hasImportExport))
if WA.hasImportExport then
-- Round-trip a small table to confirm the chain actually works, not just
-- that the functions exist.
local ok, err = pcall(function()
local blob = C_EncodingUtil.EncodeBase64(C_EncodingUtil.CompressString(C_EncodingUtil.SerializeCBOR({ a = 1, b = "x", c = { 2, 3 } })))
local back = C_EncodingUtil.DeserializeCBOR(C_EncodingUtil.DecompressString(C_EncodingUtil.DecodeBase64(blob)))
if type(back) ~= "table" or back.a ~= 1 or back.b ~= "x" or back.c[2] ~= 3 then
error("round-trip mismatch")
end
end)
D.Log(" CBOR round-trip: " .. (ok and "OK" or ("FAILED -- " .. tostring(err))))
end
if WA.GetGlowPoolStats then
local created, free = WA.GetGlowPoolStats()
D.Log(string.format(" glow overlay pool: %d created, %d free (in-use = %d)", created, free, created - free))
end
if not probeFrame then
probeFrame = CreateFrame("Frame", nil, UIParent)
probeFrame:SetWidth(48); probeFrame:SetHeight(48)
probeFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 150)
local ic = probeFrame:CreateTexture(nil, "ARTWORK")
ic:SetAllPoints(probeFrame)
ic:SetTexture("Interface\\Icons\\Spell_Nature_LightningShield")
local border = CreateFrame("Frame", nil, probeFrame)
border:SetPoint("TOPLEFT", -2, 2)
border:SetPoint("BOTTOMRIGHT", 2, -2)
border:SetBackdrop({ edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 3 })
border:SetBackdropBorderColor(1, 0.2, 0.2, 1)
end
probeFrame:Show()
D.Log(" border texture: spawned a red WHITE8X8-edged test icon at CENTER,0,150 -- if you see a red border in-world, the border subregion renders here.")
D.Log("--- end probe ---")
end
-- ---------------------------------------------------------------------------
-- /wa wa2probe -- can this client inflate what WeakAuras2 exports?
--
-- WeakAuras2 ships raw deflate (LibDeflate's CompressDeflate): no zlib header,
-- no checksum, and therefore no magic bytes to auto-detect from. The method has
-- to be passed to DecompressString explicitly, and the negative test here is the
-- load-bearing one -- it proves that omitting it actually fails on this client
-- rather than being harmlessly redundant. The zlib cases are controls: they are
-- the path ImportExport.lua already takes, so a failure there means the probe
-- itself is wrong, not the client.
-- ---------------------------------------------------------------------------
local function wa2Try(fn, a, b)
if type(fn) ~= "function" then return nil, "absent" end
local ok, result = pcall(fn, a, b)
if not ok then return nil, tostring(result) end
return result
end
local function wa2Yesno(v)
if v then return "yes" end
return "NO"
end
function D.Wa2Probe()
D.Log("--- wa2probe ---")
local E = C_EncodingUtil
if type(E) ~= "table" then
D.Log("C_EncodingUtil: ABSENT")
D.Log("VERDICT: NO-GO -- no compression API, WeakAuras2 import is impossible")
D.Log("--- end wa2probe ---")
return
end
D.Log("CompressString=" .. type(E.CompressString)
.. " DecompressString=" .. type(E.DecompressString))
local enum = Enum and Enum.CompressionMethod
local deflate = enum and enum.Deflate
local zlib = enum and enum.Zlib
D.Log("Enum.CompressionMethod: " .. (enum and ("Deflate=" .. tostring(deflate)
.. " Zlib=" .. tostring(zlib)) or "ABSENT (assuming Deflate=0, Zlib=1)"))
local RAW = deflate or 0
local ZLIB = zlib or 1
-- Repetitive so compression is measurable, with a NUL and bytes above 0x7F
-- so the round trip proves the API is 8-bit clean rather than string-safe.
local sample = string.rep("weakauras", 20)
.. string.char(0) .. string.char(127) .. string.char(128) .. string.char(255)
.. string.rep("aura", 10)
D.Log("sample: " .. string.len(sample) .. " bytes, includes NUL and >0x7F")
local rawBlob, rawErr = wa2Try(E.CompressString, sample, RAW)
if not rawBlob then
D.Log("CompressString(s, Deflate): FAILED -- " .. tostring(rawErr))
D.Log("VERDICT: NO-GO -- cannot produce raw deflate")
D.Log("--- end wa2probe ---")
return
end
D.Log("CompressString(s, Deflate): " .. string.len(rawBlob) .. " bytes"
.. " first2=" .. string.byte(rawBlob, 1) .. "," .. string.byte(rawBlob, 2))
local rawBack = wa2Try(E.DecompressString, rawBlob, RAW)
local rawOk = rawBack == sample
D.Log("round trip, explicit Deflate: " .. wa2Yesno(rawOk))
-- The negative test. A raw deflate payload has no magic bytes, so the
-- two-argument-less call should NOT be able to read it.
local sniffed, sniffErr = wa2Try(E.DecompressString, rawBlob)
local sniffFailed = (sniffed ~= sample)
D.Log("round trip, method OMITTED: " .. (sniffFailed and "refused (correct)" or "SUCCEEDED -- unexpected")
.. (sniffErr and (" [" .. string.sub(tostring(sniffErr), 1, 40) .. "]") or ""))
-- Controls: the zlib path ImportExport.lua already relies on.
local zBlob = wa2Try(E.CompressString, sample, ZLIB)
local zBack = zBlob and wa2Try(E.DecompressString, zBlob, ZLIB)
local zSniff = zBlob and wa2Try(E.DecompressString, zBlob)
D.Log("control, zlib explicit: " .. wa2Yesno(zBack == sample)
.. " zlib auto-detected: " .. wa2Yesno(zSniff == sample))
if rawOk and sniffFailed then
D.Log("VERDICT: GO -- raw deflate round-trips, and the method argument is required")
elseif rawOk then
D.Log("VERDICT: GO, with a caveat -- raw deflate round-trips, but omitting the")
D.Log(" method also worked. Pass it anyway; do not rely on auto-detection.")
else
D.Log("VERDICT: NO-GO -- raw deflate does not round-trip on this client")
end
D.Log("--- end wa2probe ---")
end
-- ---------------------------------------------------------------------------
-- /wa wa2 <string> -- run a WeakAuras export string through WA.WA2Decode and
-- report what came out. The chat edit box caps input at 255 characters, so a
-- full wago string cannot be pasted here; the usable subjects are short strings
-- and the refusal paths.
-- ---------------------------------------------------------------------------
function D.Wa2(text)
if not text or text == "" then
D.Log("[wa2] usage: /wa wa2 <!WA:2! string> (chat caps the paste at 255 characters)")
return
end
local bytes, reason = WA.WA2Decode(text)
if not bytes then
D.Log("[wa2] refused: " .. tostring(reason))
return
end
local payload, payloadReason = WA.WA2Deserialize(bytes)
if type(payload) ~= "table" or payload.m ~= "d" or type(payload.d) ~= "table"
or type(payload.d.regionType) ~= "string" then
D.Log("[wa2] " .. string.len(bytes) .. " bytes, but the payload is not aura-shaped"
.. (payloadReason and (" [" .. tostring(payloadReason) .. "]") or ""))
return
end
local head = {}
for i = 1, math.min(16, string.len(bytes)) do
table.insert(head, string.format("%02X", string.byte(bytes, i)))
end
D.Log("[wa2] " .. string.len(bytes) .. " bytes id=" .. tostring(payload.d.id)
.. " regionType=" .. payload.d.regionType .. " head=" .. table.concat(head, " "))
end
-- ---------------------------------------------------------------------------
-- /wa plateprobe -- which link in the NAMEPLATE anchor chain is broken.
--
-- Three things have to happen for a plate-anchored aura to land, and a failure
-- looks the same from outside whichever one gave way: the producer stores a
-- unit, C_NamePlate resolves that unit to a frame, and something re-runs
-- ApplyPosition once a frame exists where there was none. This logs all three
-- on a change, so toggling nameplates says which one did not move.
-- ---------------------------------------------------------------------------
local plateProbe = { active = false, seen = {} }
local plateProbeFrame
-- A stable short name for a frame, since a plate rarely has one of its own.
-- The type check is not defensive padding: NAME_PLATE_UNIT_ADDED carries a unit
-- *token* in arg1, this client's strings have no metatable, and indexing one
-- raises -- which a swallowed error then turned into "the event never fired".
local function plateProbeName(frame)
if not frame then return "nil" end
if type(frame) ~= "table" then return tostring(frame) end
local name = frame.GetName and frame:GetName()
if name and name ~= "" then return name end
return string.gsub(tostring(frame), "table: 0*", "#")
end
local function plateProbeCount()
local ok, plates = pcall(C_NamePlate.GetNamePlates)
if not ok or type(plates) ~= "table" then return "?" end
return tostring(#plates)
end
-- What a frame reports about being drawable at all, which is a different
-- question from being anchored: `shown` is the frame's own flag, `visible`
-- accounts for the whole parent chain, and a zero alpha or an off-screen rect
-- draws nothing while both flags read true.
local function plateProbeVisibility(frame)
if not frame then return "absent" end
local function call(method)
local fn = frame[method]
if not fn then return "n/a" end
local ok, value = pcall(fn, frame)
if not ok then return "err" end
return value
end
local left, bottom = call("GetLeft"), call("GetBottom")
return "shown=" .. tostring(call("IsShown"))
.. " visible=" .. tostring(call("IsVisible"))
.. " alpha=" .. tostring(call("GetAlpha"))
.. " strata=" .. tostring(call("GetFrameStrata"))
.. " level=" .. tostring(call("GetFrameLevel"))
.. " scale=" .. tostring(call("GetScale"))
.. " rect=" .. tostring(left) .. "," .. tostring(bottom)
.. " " .. tostring(call("GetWidth")) .. "x" .. tostring(call("GetHeight"))
end
-- One line per NAMEPLATE-anchored region: what it read, what that resolves to,
-- and where the region actually sits. A region parked on WeakestAurasHiddenFrames
-- while the resolve column shows a frame means nothing re-applied the position.
-- Anchored-but-invisible is the other half, so both the region and the plate it
-- joined report their whole drawable state.
local function plateProbeRegions(lines)
if not WA.ForEachRegion then return end
WA.ForEachRegion(function(region)
local data = WeakestAurasDB.displays[region.id]
if not data or data.anchorFrameType ~= "NAMEPLATE" then return end
local unit = region.state and (region.state.unit or region.state.unitId)
local frame = WA.GetUnitNameplate and WA.GetUnitNameplate(unit, region.state and region.state.guid)
local parent = region.GetParent and region:GetParent()
table.insert(lines, " " .. tostring(region.id)
.. " state.unit=" .. tostring(unit)
.. " resolves=" .. plateProbeName(frame)
.. " parent=" .. plateProbeName(parent)
.. " toShow=" .. tostring(region.toShow))
table.insert(lines, " region " .. plateProbeVisibility(region))
if parent then
local grand = parent.GetParent and parent:GetParent()
table.insert(lines, " parent " .. plateProbeVisibility(parent)
.. " of=" .. plateProbeName(grand)
.. (grand == WorldFrame and " (WorldFrame)" or ""))
end
end)
end
local function plateProbeSnapshot(reason)
local guid = UnitGUID("target")
local byUnit = C_NamePlate.GetNamePlateForUnit("target")
local byGuid = guid and C_NamePlate.GetNamePlateForGUID(guid) or nil
local lines = { " plates=" .. plateProbeCount()
.. " target=" .. tostring(UnitName("target"))
.. " ForUnit=" .. plateProbeName(byUnit)
.. " ForGUID=" .. plateProbeName(byGuid) }
plateProbeRegions(lines)
local key = table.concat(lines, "|")
-- Only a changed picture is worth a line; the poll runs once a second and an
-- unchanged one would bury the transition it exists to show.
if key == plateProbe.last then return end
plateProbe.last = key
D.Log("[plate] " .. reason)
for i = 1, #lines do D.Log(lines[i]) end
end
local function plateProbeStop()
plateProbe.active = false
plateProbe.last = nil
if plateProbeFrame then plateProbeFrame:UnregisterAllEvents() end
if plateProbe.ticker then plateProbe.ticker:Cancel(); plateProbe.ticker = nil end
D.Log("--- end plateprobe ---")
end
function D.PlateProbe()
if plateProbe.active then plateProbeStop(); return end
D.Log("--- plateprobe ---")
D.Log(" toggle nameplates on and off with a target selected; /wa plateprobe again to stop")
if not plateProbeFrame then plateProbeFrame = CreateFrame("Frame") end
plateProbeFrame:SetScript("OnEvent", function()
D.Log("[plate] " .. tostring(event) .. " arg1=" .. plateProbeName(arg1)
.. " unit=" .. tostring(UnitName and type(arg1) == "string" and UnitName(arg1)))
plateProbe.last = nil
plateProbeSnapshot(tostring(event))
end)
pcall(plateProbeFrame.RegisterEvent, plateProbeFrame, "NAME_PLATE_CREATED")
pcall(plateProbeFrame.RegisterEvent, plateProbeFrame, "NAME_PLATE_UNIT_ADDED")
pcall(plateProbeFrame.RegisterEvent, plateProbeFrame, "NAME_PLATE_UNIT_REMOVED")
plateProbe.active = true
plateProbe.last = nil
plateProbeSnapshot("start")
-- Polled as well as evented: if the toggle produces no churn event at all,
-- the poll still shows the resolve column changing, which is the answer.
plateProbe.ticker = C_Timer.NewTicker(1, function()
if not plateProbe.active then return end
plateProbeSnapshot("poll")
end)
end
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- /wa levelprobe -- how high SetFrameLevel goes here, and how much of that a
-- sub-region stack spends.
--
-- Sub-region draw order is base + SUB_LEVEL + SUB_STEP * (index - 1), on a
-- region that may itself be nested in a group, so the ceiling is a real bound
-- and not a theoretical one. Nothing headless can answer it: the mock stores
-- whatever it is handed and reads it straight back.
-- ---------------------------------------------------------------------------
local levelProbeHost
local levelProbeVisual
-- The question the arithmetic rests on and nothing headless can reach: a child
-- frame levelled *below* its parent -- does it draw under the parent's own art,
-- or does being a child put it on top regardless? The whole subbackground row
-- depends on the first answer, since an effect ordered behind the region is a
-- child of it holding a lower number.
--
-- Three boxes over one red parent: a child above it, a child below it, and the
-- parent alone for reference. Whether the middle box's blue shows is the answer.
local function levelProbeVisualFrame()
if levelProbeVisual then return levelProbeVisual end
local frame = CreateFrame("Frame", nil, UIParent)
frame:SetWidth(360); frame:SetHeight(210)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 120)
frame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
frame:SetBackdropColor(0, 0, 0, 0.85)
local title = frame:CreateFontString(nil, "OVERLAY")
title:SetFont("Fonts\\FRIZQT__.TTF", 11, "")
title:SetPoint("TOP", frame, "TOP", 0, -8)
title:SetText("Does a child below its parent's level draw under it?")
local function pair(x, childOffset, caption)
local parent = CreateFrame("Frame", nil, frame)
parent:SetWidth(96); parent:SetHeight(96)
parent:SetPoint("TOPLEFT", frame, "TOPLEFT", x, -34)
parent:SetFrameLevel(frame:GetFrameLevel() + 10)
local red = parent:CreateTexture(nil, "ARTWORK")
red:SetAllPoints(parent)
red:SetTexture(0.8, 0.1, 0.1, 1)
if childOffset then
local child = CreateFrame("Frame", nil, parent)
child:SetWidth(60); child:SetHeight(60)
child:SetPoint("CENTER", parent, "CENTER")
child:SetFrameLevel(parent:GetFrameLevel() + childOffset)
local blue = child:CreateTexture(nil, "ARTWORK")
blue:SetAllPoints(child)
blue:SetTexture(0.1, 0.3, 0.9, 1)
end
local label = frame:CreateFontString(nil, "OVERLAY")
label:SetFont("Fonts\\FRIZQT__.TTF", 10, "")
label:SetJustifyH("CENTER")
label:SetPoint("TOP", parent, "BOTTOM", 0, -4)
label:SetText(caption)
return parent
end
pair(20, 2, "child ABOVE\n(blue must show)")
local under = pair(132, -2, "child BELOW\n(blue must be hidden)")
pair(244, nil, "parent alone\n(reference)")
levelProbeVisual = frame
levelProbeVisual.under = under
return levelProbeVisual
end
function D.LevelProbe()
local proto = WA.regionPrototype
D.Log("--- levelprobe ---")
-- Frames cannot be destroyed here, so the probe keeps its two rather than
-- making a fresh pair on every run.
if not levelProbeHost then
levelProbeHost = CreateFrame("Frame", nil, UIParent)
levelProbeHost.child = CreateFrame("Frame", nil, levelProbeHost)
end
local host = levelProbeHost
D.Log(string.format("UIParent %s -> child %s -> grandchild %s",
tostring(UIParent:GetFrameLevel()), tostring(host:GetFrameLevel()),
tostring(host.child:GetFrameLevel())))
local probes = { 0, 1, 10, 63, 64, 100, 127, 128, 200, 255, 256, 500, 1000, 10000, 65535 }
local ceiling
for i = 1, #probes do
local want = probes[i]
local ok = pcall(function() host:SetFrameLevel(want) end)
local got = host:GetFrameLevel()
local verdict
if not ok then
verdict = "ERROR"
elseif got == want then
verdict = "ok"
ceiling = want
else
verdict = "CLAMPED"
end
D.Log(string.format(" SetFrameLevel(%d) -> %s [%s]", want, tostring(got), verdict))
end
D.Log("highest value read back unchanged: " .. tostring(ceiling))
-- Measured against live regions rather than assumed: what matters is the
-- deepest base actually in play, since a nested region starts higher.
local deepest, deepestId = 0, nil
for id in pairs(WeakestAurasDB.displays or {}) do
local region = WA.PeekRegion(id, "")
if region and region.GetFrameLevel then
local base = region:GetFrameLevel()
if base > deepest then deepest, deepestId = base, id end
end
end
D.Log(string.format("deepest live region base: %d (%s)", deepest, tostring(deepestId)))
D.Log(string.format(" ten effects on it reach %d (SUB_LEVEL %d, SUB_STEP %d)",
deepest + proto.SUB_LEVEL + proto.SUB_STEP * 9, proto.SUB_LEVEL, proto.SUB_STEP))
local visual = levelProbeVisualFrame()
visual:Show()
local under = visual.under
D.Log(string.format("child-below-parent: parent at %d, its child asked for %d",
under:GetFrameLevel(), under:GetFrameLevel() - 2))
D.Log(" middle box blue hidden -> a child below its parent draws under it (subbackground works)")
D.Log(" middle box blue showing -> being a child wins over the level, and it does not")
D.Log("--- end levelprobe ---")
end
-- /wa texprobe -- the Texture questions no headless harness can answer.
--
-- The log records capability read-backs and the frame puts the visual tests
-- beside labels that say exactly what to compare. The client is the authority
-- for arbitrary-angle rotation, out-of-range texcoords, rect modification and
-- desaturation; the mock cannot model any of those results.
-- ---------------------------------------------------------------------------
local texProbeFrame
local TEXPROBE_ART = "Interface\\AddOns\\WeakestAuras\\textures\\shapes\\arrows_target.tga"
local TEXPROBE_COLOR_ART = "Interface\\Icons\\Spell_Nature_LightningShield"
local function texProbeLabel(parent, text, point, relative, relPoint, x, y)
local label = parent:CreateFontString(nil, "OVERLAY")
label:SetFont("Fonts\\FRIZQT__.TTF", 11, "")
label:SetJustifyH("CENTER")
label:SetText(text)
label:SetPoint(point, relative, relPoint, x, y)
return label
end
local function texProbeBox(parent, x, y)
local box = CreateFrame("Frame", nil, parent)
box:SetWidth(96); box:SetHeight(96)
box:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
box:SetBackdrop({ edgeFile = "Interface\\Buttons\\WHITE8X8", edgeSize = 1 })
box:SetBackdropBorderColor(0.5, 0.5, 0.5, 1)
return box
end
local function texProbeTexture(box, rotation)
local texture = box:CreateTexture(nil, "ARTWORK")
texture:SetAllPoints(box)
texture:SetTexture(TEXPROBE_ART)
if rotation then
local angle = math.pi * (135 - rotation) / 180
local vx = math.cos(angle) / math.sqrt(2)
local vy = math.sin(angle) / math.sqrt(2)
local ulx, uly = 0.5 + vx, 0.5 - vy
local llx, lly = 0.5 - vy, 0.5 - vx
local urx, ury = 0.5 + vy, 0.5 + vx
local lrx, lry = 0.5 - vx, 0.5 + vy
texture:SetTexCoord(ulx, uly, llx, lly, urx, ury, lrx, lry)
else
texture:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1)
end
return texture
end
local function texProbeCall(texture, method, a, b, c, d)
local fn = texture and texture[method]
if not fn then return false, "ABSENT" end
return pcall(fn, texture, a, b, c, d)
end
function D.TexProbe()
D.Log("--- texprobe ---")
if not texProbeFrame then
texProbeFrame = CreateFrame("Frame", nil, UIParent)
texProbeFrame:SetWidth(620); texProbeFrame:SetHeight(580)
texProbeFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 80)
texProbeFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
texProbeFrame:SetBackdropColor(0, 0, 0, 0.8)
texProbeLabel(texProbeFrame, "Texture probe: compare each sample with its label", "TOP", texProbeFrame, "TOP", 0, -10)
local rotated = texProbeBox(texProbeFrame, 18, -34)
texProbeTexture(rotated, 30)
texProbeLabel(texProbeFrame, "30 deg rotation\n(inside-box / sqrt2)", "TOP", rotated, "BOTTOM", 0, -4)
local normal = texProbeBox(texProbeFrame, 128, -34)
texProbeTexture(normal)
texProbeLabel(texProbeFrame, "0 deg reference", "TOP", normal, "BOTTOM", 0, -4)
local outside = texProbeBox(texProbeFrame, 238, -34)
local outsideTexture = texProbeTexture(outside)
outsideTexture:SetTexCoord(-0.25, -0.25, -0.25, 1.25, 1.25, -0.25, 1.25, 1.25)
texProbeLabel(texProbeFrame, "out-of-range\n(-.25 to 1.25)", "TOP", outside, "BOTTOM", 0, -4)
local rectOn = texProbeBox(texProbeFrame, 348, -34)
local rectOnTexture = texProbeTexture(rectOn)
texProbeCall(rectOnTexture, "SetTexCoordModifiesRect", true)
rectOnTexture:SetTexCoord(0, 0, 0, 1, 0.5, 0, 0.5, 1)
texProbeLabel(texProbeFrame, "half texcoord\nrect setting ON", "TOP", rectOn, "BOTTOM", 0, -4)
local rectOff = texProbeBox(texProbeFrame, 458, -34)
local rectOffTexture = texProbeTexture(rectOff)
texProbeCall(rectOffTexture, "SetTexCoordModifiesRect", false)
rectOffTexture:SetTexCoord(0, 0, 0, 1, 0.5, 0, 0.5, 1)
texProbeLabel(texProbeFrame, "half texcoord\nrect setting OFF", "TOP", rectOff, "BOTTOM", 0, -4)
local regular = texProbeBox(texProbeFrame, 183, -174)
local regularTexture = texProbeTexture(regular)
regularTexture:SetTexture(TEXPROBE_COLOR_ART)
texProbeLabel(texProbeFrame, "colored normal", "TOP", regular, "BOTTOM", 0, -4)
local desaturated = texProbeBox(texProbeFrame, 293, -174)
local desaturatedTexture = texProbeTexture(desaturated)
desaturatedTexture:SetTexture(TEXPROBE_COLOR_ART)
local desatOK, desatReturn = texProbeCall(desaturatedTexture, "SetDesaturated", true)
texProbeLabel(texProbeFrame, "SetDesaturated(true)", "TOP", desaturated, "BOTTOM", 0, -4)
-- Texture corner-transform canaries. Each box has one exact expected
-- look (its label says it); any box going wrong after a ClassicAPI
-- update names its regression directly -- offset magnitude (rulers,
-- at two effective scales), rotation chirality (the bar), the wedge
-- geometry and its multi-texture split (the spinners), and the
-- native cooldown swipe at both aspect ratios. Solid fills, so the
-- drawn quads are read directly. The diagnostic series that
-- established the client's corner contract lives in git history.
do
local UL_V = UPPER_LEFT_VERTEX or 1
local function solidBox(x, y, label)
local box = texProbeBox(texProbeFrame, x, y)
local tex = box:CreateTexture(nil, "ARTWORK")
tex:SetAllPoints(box)
tex:SetTexture(1, 1, 1, 0.9)
texProbeLabel(texProbeFrame, label, "TOP", box, "BOTTOM", 0, -4)
return tex, box
end
local TICKS = { 12, 24, 48, 96 }
local TICK_COLORS = {
{ 1, 1, 0 }, { 0, 1, 0 }, { 1, 0.4, 0 }, { 1, 0, 0 },
}
local function addTicks(box, vertical)
for i = 1, 4 do
local tick = box:CreateTexture(nil, "OVERLAY")
tick:SetTexture(TICK_COLORS[i][1], TICK_COLORS[i][2], TICK_COLORS[i][3], 1)
if vertical then
tick:SetWidth(1); tick:SetHeight(20)
tick:SetPoint("TOPLEFT", box, "TOPLEFT", TICKS[i], 0)
else
tick:SetWidth(20); tick:SetHeight(1)
tick:SetPoint("TOPLEFT", box, "TOPLEFT", 0, -TICKS[i])
end
end
end
local rotTex = solidBox(18, -314, "rot 30\n(clean square?)")
rotTex:SetRotation(math.rad(30))
texProbeFrame._rotTex = rotTex
local xTex, xBox = solidBox(18 + 110, -314, "UL right 24px\nticks 12|24|48|96")
xTex:SetVertexOffset(UL_V, 24, 0)
addTicks(xBox, true)
texProbeFrame._pullTexture = xTex
-- Per-texture colors, so a wedge's texture split reads off its
-- color: t1 RED, t2 GREEN, t3 BLUE.
local function wedgeBox(x, y, a1, a2, label)
local box = texProbeBox(texProbeFrame, x, y)
local spinner = WA.Spinner.Create(box, "ARTWORK")
spinner.textures[1]:SetTexture(1, 0.2, 0.2, 0.9)
spinner.textures[2]:SetTexture(0.2, 1, 0.2, 0.9)
spinner.textures[3]:SetTexture(0.2, 0.4, 1, 0.9)
spinner:SetWidth(96); spinner:SetHeight(96)
spinner:SetProgress(a1, a2)
texProbeLabel(texProbeFrame, label, "TOP", box, "BOTTOM", 0, -4)
return spinner
end
local wedgeControl = wedgeBox(18 + 4 * 110, -314, 0, 45, "wedge 0-45\n(red top slice)")
texProbeFrame._wedgeTex = wedgeControl.textures[1]
wedgeBox(18, -454, 10, 350, "wedge 10-350\n(red+green+blue)")
-- The ruler at a second effective scale (a half-scaled child
-- frame): the fold landing on the GREEN tick again proves the
-- offset conversion reads the live per-region scale chain.
local scaledBox = texProbeBox(texProbeFrame, 18 + 2 * 110, -314)
local scaledHolder = CreateFrame("Frame", nil, scaledBox)
scaledHolder:SetScale(0.5)
scaledHolder:SetWidth(96); scaledHolder:SetHeight(96)
scaledHolder:SetPoint("BOTTOMLEFT", scaledBox, "BOTTOMLEFT", 0, 0)
local scaledTex = scaledHolder:CreateTexture(nil, "ARTWORK")
scaledTex:SetAllPoints(scaledHolder)
scaledTex:SetTexture(1, 1, 1, 0.9)
scaledTex:SetVertexOffset(UL_V, 24, 0)
for i = 1, 4 do
local tick = scaledHolder:CreateTexture(nil, "OVERLAY")
tick:SetTexture(TICK_COLORS[i][1], TICK_COLORS[i][2], TICK_COLORS[i][3], 1)
tick:SetWidth(2); tick:SetHeight(20)
tick:SetPoint("TOPLEFT", scaledHolder, "TOPLEFT", TICKS[i], 0)
end
texProbeFrame._scaledTex = scaledTex
texProbeLabel(texProbeFrame, "ruler in 0.5x frame\nfold at green?", "TOP", scaledBox, "BOTTOM", 0, -4)
-- Rotation chirality on a shape that cannot be misread: a long thin
-- bar rotated +30 degrees. Counter-clockwise (the retail contract)
-- lifts the bar's RIGHT end; clockwise drops it. The white bar is
-- the unrotated reference, the yellow one carries the rotation.
local barBox = texProbeBox(texProbeFrame, 18 + 3 * 110, -314)
local barRef = barBox:CreateTexture(nil, "ARTWORK")
barRef:SetWidth(80); barRef:SetHeight(8)
barRef:SetPoint("CENTER", barBox, "CENTER", 0, 28)
barRef:SetTexture(1, 1, 1, 0.9)
local barRot = barBox:CreateTexture(nil, "ARTWORK")
barRot:SetWidth(80); barRot:SetHeight(8)
barRot:SetPoint("CENTER", barBox, "CENTER", 0, -14)
barRot:SetTexture(1, 0.9, 0.2, 0.9)
barRot:SetRotation(math.rad(30))
texProbeFrame._arrowRot = barRot
texProbeLabel(texProbeFrame, "bar rot 30\n(right end UP = CCW ok)", "TOP", barBox, "BOTTOM", 0, -4)
-- The native cooldown swipe at both aspect ratios. Held at a
-- quarter elapsed: dark except the bright 12-to-3-o'clock wedge.
local function swipeCanary(x, y, w, h, label)
local box = texProbeBox(texProbeFrame, x, y)
local holder = CreateFrame("Frame", nil, box)
holder:SetWidth(w); holder:SetHeight(h)
holder:SetPoint("CENTER", box, "CENTER", 0, 0)
local icon = holder:CreateTexture(nil, "BACKGROUND")
icon:SetAllPoints(holder)
icon:SetTexture(TEXPROBE_COLOR_ART)
local swipe = WA.regionPrototype.CreateSwipe(holder)
CooldownFrame_SetDisplayAsPercentage(swipe, 0.25)
texProbeLabel(texProbeFrame, label, "TOP", box, "BOTTOM", 0, -4)
end
swipeCanary(18 + 110, -454, 96, 96, "swipe hold 75%\n(bright 12 to 3)")
swipeCanary(18 + 2 * 110, -454, 96, 48, "swipe 96x48\n(covers fully)")
end
texProbeFrame._texProbe = {
regular = regular, desatOK = desatOK, desatReturn = desatReturn,
}
end
texProbeFrame:Show()
local p = texProbeFrame._texProbe
D.Log(" art: " .. TEXPROBE_ART)
D.Log(" desaturation comparison art: " .. TEXPROBE_COLOR_ART)
D.Log(" rotation sample: 30 degrees with the sqrt2-safe 8-argument SetTexCoord -- compare with 0 degree reference")
D.Log(" out-of-range sample: transparent margin, edge smear, or tiled art?")
local scratch = p.regular:CreateTexture(nil, "ARTWORK")
local getOK, defaultValue = texProbeCall(scratch, "GetTexCoordModifiesRect")
D.Log(" GetTexCoordModifiesRect default: " .. (getOK and tostring(defaultValue) or tostring(defaultValue)))
local setOnOK = texProbeCall(scratch, "SetTexCoordModifiesRect", true)
local setOffOK = texProbeCall(scratch, "SetTexCoordModifiesRect", false)
D.Log(" SetTexCoordModifiesRect calls: on=" .. tostring(setOnOK) .. " off=" .. tostring(setOffOK)
.. " -- compare the ON/OFF sample box widths")
-- Vertex index 1 = UPPER_LEFT_VERTEX; an invalid index (or too few args) is
-- a Lua error in ClassicAPI's backport, which would read as ABSENT here.
-- Zero offsets leave the scratch texture untouched.
local vertexOK, vertexReturn = texProbeCall(scratch, "SetVertexOffset", 1, 0, 0)
D.Log(" SetVertexOffset: " .. (vertexOK and ("PRESENT (return " .. tostring(vertexReturn) .. ")") or ((scratch.SetVertexOffset and "present but errored: " .. tostring(vertexReturn)) or "ABSENT")))
D.Log(" SetRotation: " .. ((scratch.SetRotation and "PRESENT") or "ABSENT"))
D.Log(" SetDesaturated(true): " .. (p.desatOK and "call accepted" or "ABSENT/FAILED")
.. " (return " .. tostring(p.desatReturn) .. ") -- compare normal and desaturated samples")
do
D.Log(" effective scale: " .. tostring(texProbeFrame:GetEffectiveScale()))
local function dumpCorners(name, tex)
if not (tex and tex.GetCorners) then
D.Log(" " .. name .. ": GetCorners absent (older DLL build)")
return
end
local ok, blx, bly, tlx, tly, brx, bry, trx, try2, rt, rl, rb, rr = pcall(tex.GetCorners, tex)
if not ok or type(blx) ~= "number" then
D.Log(" " .. name .. " corners: unavailable (" .. tostring(blx) .. ")")
return
end
D.Log(string.format(" %s BL(%.5f,%.5f) TL(%.5f,%.5f) BR(%.5f,%.5f) TR(%.5f,%.5f)",
name, blx, bly, tlx, tly, brx, bry, trx, try2))
D.Log(string.format(" rect t=%.5f l=%.5f b=%.5f r=%.5f", rt, rl, rb, rr))
end
-- The corner array populates on layout resolve; on the call that CREATES
-- the frame these can read zeros. Run /wa texprobe a second time for
-- resolved values.
dumpCorners("ULright24", texProbeFrame._pullTexture)
dumpCorners("wedge0-45 tex1", texProbeFrame._wedgeTex)
D.Log(" every canary box's label states its expected look; a wrong box names its regression")
D.Log(" rulers: which tick does the white quad's pulled corner reach (yellow 12, green 24, orange 48, red 96)?")
D.Log(" corner dumps read zeros on the call that creates the frame; run texprobe twice for numbers")
end
D.Log(" visual answers: rotate 30 degrees; margin behavior; whether ON changes the half-width box; whether art greys")
D.Log("--- end texprobe ---")
end
-- ---------------------------------------------------------------------------
-- /wa texprobe corners -- what the 8-argument SetTexCoord corner form does to
-- sampling and to geometry, and whether the two can be separated.
--
-- Spinner.lua writes vertex offsets only, because on this client texcoords and
-- vertex offsets both resolve into the one drawn-corner array: the engine
-- shrinks a region's drawn rect by its texcoord span before storing the
-- corners, so a partial-span corner texcoord moves the very geometry the
-- offsets are measured against. A textured circular progress needs the
-- sampling half without the geometry half, which is what these fixtures
-- measure -- per-corner sampling against bounding-rect sampling, the induced
-- crop and where it anchors, and whether SetTexCoordModifiesRect(false) takes
-- the crop away.
--
-- The numbers carry most of it: corner dumps are normalized to the box, so an
-- untouched quad reads BL(0,1) TL(0,0) BR(1,1) TR(1,0) and anything else is
-- the crop in box fractions. The art answers only what a number cannot --
-- which part of the sheet each box ends up showing.
-- ---------------------------------------------------------------------------
local texCornerFrame
local TEXCORNER_SHEET = "Interface\\AddOns\\WeakestAuras\\textures\\RaidTargetIcons"
-- GetTexCoord's arity is itself an answer: eight returns mean the engine keeps
-- per-corner texcoords, four mean the corner form collapsed into a rect.
local function cornerProbeTexCoords(name, tex)
local ok, a, b, c, d, e, f, g, h = pcall(tex.GetTexCoord, tex)
if not ok then
D.Log(" " .. name .. " tc: GetTexCoord failed (" .. tostring(a) .. ")")
return
end
local vals = { a, b, c, d, e, f, g, h }
local count, text = 0, ""
for i = 1, 8 do
if type(vals[i]) == "number" then
count = i
text = text .. string.format("%.3f ", vals[i])
end
end
D.Log(" " .. name .. " tc: " .. count .. " values -- " .. text)
end
local function cornerProbeQuad(name, tex)
if not tex.GetCorners then
D.Log(" " .. name .. " quad: GetCorners absent (older DLL build)")
return
end
local ok, blx, bly, tlx, tly, brx, bry, trx, try2, rt, rl, rb, rr = pcall(tex.GetCorners, tex)
if not ok or type(blx) ~= "number" then
D.Log(" " .. name .. " quad: unavailable (" .. tostring(blx) .. ")")
return
end
local w, h = rr - rl, rb - rt
if w == 0 or h == 0 then
D.Log(" " .. name .. " quad: rect not resolved yet")
return
end
D.Log(string.format(" %s quad: BL(%.3f,%.3f) TL(%.3f,%.3f) BR(%.3f,%.3f) TR(%.3f,%.3f)",
name, (blx - rl) / w, (bly - rt) / h, (tlx - rl) / w, (tly - rt) / h,
(brx - rl) / w, (bry - rt) / h, (trx - rl) / w, (try2 - rt) / h))
end
local function cornerProbeBox(x, y, label)
local box = texProbeBox(texCornerFrame, x, y)
local tex = box:CreateTexture(nil, "ARTWORK")
tex:SetAllPoints(box)
tex:SetTexture(TEXCORNER_SHEET)
texProbeLabel(texCornerFrame, label, "TOP", box, "BOTTOM", 0, -4)
return tex
end
-- Shape a texture into the 0-90 wedge with the spinner's own corner math, so
-- the fixture measures the code that would ship rather than a hand-written
-- approximation. `applyOffsets` is the second writer.
local function cornerProbeWedge(tex, applyOffsets)
local coord = WA.TextureCoords.Create(tex)
coord:SetAngle(96, 96, 0, 90)
tex:SetTexCoord(coord.ULx, coord.ULy, coord.LLx, coord.LLy,
coord.URx, coord.URy, coord.LRx, coord.LRy)
if applyOffsets then coord:Apply() end
end
function D.TexCornerProbe()
D.Log("--- texprobe corners ---")
if not texCornerFrame then
texCornerFrame = CreateFrame("Frame", nil, UIParent)
texCornerFrame:SetWidth(478); texCornerFrame:SetHeight(316)
texCornerFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 60)
texCornerFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
texCornerFrame:SetBackdropColor(0, 0, 0, 0.85)
texProbeLabel(texCornerFrame, "Corner-form SetTexCoord: read every box against the full-span reference",
"TOP", texCornerFrame, "TOP", 0, -10)
local COL = 110
local fixtures = {}
fixtures.full = cornerProbeBox(18, -34, "full span\n(reference sheet)")
fixtures.full:SetTexCoord(0, 0, 0, 1, 1, 0, 1, 1)
fixtures.cell = cornerProbeBox(18 + COL, -34, "corner .25 span\n(one cell? shrunk?)")
fixtures.cell:SetTexCoord(0, 0, 0, 0.25, 0.25, 0, 0.25, 0.25)
fixtures.cellRectOff = cornerProbeBox(18 + 2 * COL, -34, "same, rect mod OFF\n(box still full size?)")
texProbeCall(fixtures.cellRectOff, "SetTexCoordModifiesRect", false)
fixtures.cellRectOff:SetTexCoord(0, 0, 0, 0.25, 0.25, 0, 0.25, 0.25)
fixtures.inset = cornerProbeBox(18 + 3 * COL, -34, "inset .25-.75\n(crop anchored where?)")
fixtures.inset:SetTexCoord(0.25, 0.25, 0.25, 0.75, 0.75, 0.25, 0.75, 0.75)
-- Full bounding span, one corner pulled in: a crop keyed on the span
-- leaves this box alone, so whether the art shears separates true
-- per-corner sampling from bounding-rect sampling.
fixtures.shear = cornerProbeBox(18, -174, "LR pulled to u .5\n(art sheared?)")
fixtures.shear:SetTexCoord(0, 0, 0, 1, 1, 0, 0.5, 1)
fixtures.swap90 = cornerProbeBox(18 + COL, -174, "90 deg corner swap\n(sheet turned)")
fixtures.swap90:SetTexCoord(0, 1, 1, 1, 0, 0, 1, 0)
fixtures.wedgeTC = cornerProbeBox(18 + 2 * COL, -174, "wedge 0-90 texcoords\n(no vertex offsets)")
cornerProbeWedge(fixtures.wedgeTC, false)
fixtures.wedgeBoth = cornerProbeBox(18 + 3 * COL, -174, "wedge tc + offsets\n(two writers)")
cornerProbeWedge(fixtures.wedgeBoth, true)
texCornerFrame._fixtures = fixtures
texCornerFrame._order = {
"full", "cell", "cellRectOff", "inset", "shear", "swap90", "wedgeTC", "wedgeBoth",
}
texCornerFrame._scratch = texCornerFrame:CreateTexture(nil, "ARTWORK")
end
texCornerFrame:Show()
local getOK, defaultValue = texProbeCall(texCornerFrame._scratch, "GetTexCoordModifiesRect")
D.Log(" GetTexCoordModifiesRect default: " .. tostring(defaultValue) .. (getOK and "" or " (call failed)"))
D.Log(" sheet: " .. TEXCORNER_SHEET .. " -- every box draws it; only the texcoords differ")
D.Log(" quad numbers are box fractions; an untouched quad reads BL(0,1) TL(0,0) BR(1,1) TR(1,0)")
local function dumpAll(when)
D.Log(" [" .. when .. "]")
for _, name in ipairs(texCornerFrame._order) do
local tex = texCornerFrame._fixtures[name]
if tex then
cornerProbeTexCoords(name, tex)
cornerProbeQuad(name, tex)
end
end
end
-- The corner array fills on the layout resolve that follows this call, so
-- the dumps wait a tick. The second pass answers whether a two-writer quad
-- settles or keeps moving.
C_Timer.After(0.2, function() dumpAll("resolved") end)
C_Timer.After(1.2, function()
dumpAll("one second later")
D.Log("--- end texprobe corners ---")
end)
end
-- ---------------------------------------------------------------------------
-- /wa progtex [id] -- what a circular progress texture's two spinners are
-- actually made of.
--
-- The headless harness resolves every one of these correctly, so a wedge that
-- looks wrong in the client is a disagreement between the region's state and
-- what the client draws from it. This prints the state so the two can be
-- compared: which family is drawing, what each spinner carries, the angle pair
-- each was handed, and per wedge texture its visibility, texcoords and the
-- drawn corners the client resolved (GetCorners, normalized to the region's
-- own rect, so an untouched quad reads BL(0,1) TL(0,0) BR(1,1) TR(1,0)).
-- ---------------------------------------------------------------------------
local function progTexDumpSpinner(label, region, spinner)
if not spinner then D.Log(" " .. label .. ": absent"); return end
D.Log(string.format(" %s: %s angles %s..%s size %s x %s crop %s/%s rot %s mirror %s",
label, tostring(spinner.textures[1]:GetTexture()),
tostring(spinner.angle1), tostring(spinner.angle2),
tostring(spinner.width), tostring(spinner.height),
tostring(spinner.crop_x), tostring(spinner.crop_y),
tostring(spinner.texRotation), tostring(spinner.mirror)))
for i = 1, 3 do
local tex = spinner.textures[i]
if tex:IsShown() then
local a, b, c, d, e, f, g, h = tex:GetTexCoord()
D.Log(string.format(" t%d tc UL(%.3f,%.3f) LL(%.3f,%.3f) UR(%.3f,%.3f) LR(%.3f,%.3f)",
i, a or 0, b or 0, c or 0, d or 0, e or 0, f or 0, g or 0, h or 0))
if tex.GetCorners then
local ok, blx, bly, tlx, tly, brx, bry, trx, try2, rt, rl, rb, rr = pcall(tex.GetCorners, tex)
local w = (ok and type(rr) == "number") and (rr - rl) or 0
local hh = (ok and type(rb) == "number") and (rb - rt) or 0
if w ~= 0 and hh ~= 0 then
D.Log(string.format(" t%d quad BL(%.3f,%.3f) TL(%.3f,%.3f) BR(%.3f,%.3f) TR(%.3f,%.3f)",
i, (blx - rl) / w, (bly - rt) / hh, (tlx - rl) / w, (tly - rt) / hh,
(brx - rl) / w, (bry - rt) / hh, (trx - rl) / w, (try2 - rt) / hh))
else
D.Log(" t" .. i .. " quad: rect not resolved")
end
end
else
D.Log(" t" .. i .. " hidden")
end
end
end
function D.ProgTex(rest)
D.Log("--- progtex ---")
local wanted = rest ~= "" and rest or nil
local found = 0
for id, data in pairs(WeakestAurasDB.displays or {}) do
if data.regionType == "progresstexture" and (not wanted or id == wanted) then
local region = WA.PeekRegion(id, "")
if region then
found = found + 1
D.Log(string.format("%s: orientation %s progress %s arc %s..%s",
id, tostring(region.orientation), tostring(region.progress),
tostring(region.startAngle), tostring(region.endAngle)))
D.Log(string.format(" region %sx%s plain fg %s plain bg %s crop %s/%s",
tostring(region.regionWidth), tostring(region.regionHeight),
region.foreground:IsShown() and "SHOWN" or "hidden",
region.background:IsShown() and "SHOWN" or "hidden",
tostring(region.crop_x), tostring(region.crop_y)))
progTexDumpSpinner("foreground", region, region.foregroundSpinner)
progTexDumpSpinner("background", region, region.backgroundSpinner)
end
end
end
if found == 0 then D.Log(" no progresstexture region is live" .. (wanted and (" named " .. wanted) or "")) end
D.Log("--- end progtex ---")
end
-- ---------------------------------------------------------------------------
-- /wa codeprobe -- checks the EditBox behavior needed by the code editor:
-- engine behaviour a syntax-highlighting code editor rests on, none of which
-- raw color escapes, indexing, caret movement, and sentinel round-trips.
--
-- Half of this is unavoidably a visual test -- whether a colour code *renders*
-- as colour, and where a selection actually lands, are things only an eye can
-- answer -- so the frame parks itself on screen with the steps that need
-- clicking, and only the machine-checkable parts go to the log.
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- /wa textprobe -- the FontString questions no headless harness can answer.
--
-- Every answer this reports is already recorded in design/client/gotchas.md; it
-- stays a command so a client patch can be re-tested in one keystroke rather
-- than re-argued.
--
-- The inline texture escape is the one that decides a feature. An engine that
-- does not honour |T...|t prints the texture path as text instead, which is
-- machine-checkable without an eye because the two outcomes have wildly
-- different string widths -- an icon is about one line wide, the literal path is
-- thirty-odd characters. Several escape forms are measured, since a client could
-- honour the sized form and not the auto-sized one. The SimpleHTML and
-- message-frame sections then ask whether any *other* text widget has an
-- inline-image path.
--
-- The coin measurements answer a separate question from the escape's: whether
-- the three MoneyFrame art files resolve on this client at all. An escape whose
-- path the client cannot load still measures as an icon, so a coin has to be
-- read against a path known to exist rather than against the literal-text
-- width.
--
-- Then the height readings -- GetHeight against the engine's own
-- GetStringHeight, including a string carrying an icon TALLER than its font,
-- which is the one case the two can disagree on -- and the font list's own
-- read-backs.
-- ---------------------------------------------------------------------------
local textProbeFrame
local TEXTPROBE_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
-- 1.12 has no per-coin UI-GoldIcon/UI-SilverIcon/UI-CopperIcon files -- those
-- are a later-expansion split. Vanilla ships ONE 64x16 sheet of four 16px
-- cells and FrameXML crops it, so the escape addresses a cell through its
-- texW:texH:left:right:top:bottom tail: a 4x1 grid, cell index per coin.
local TEXTPROBE_COINS = {
{ name = "gold", cell = "4:1:0:1:0:1" },
{ name = "silver", cell = "4:1:1:2:0:1" },
{ name = "copper", cell = "4:1:2:3:0:1" },
}
local TEXTPROBE_COIN_SHEET = "Interface\\MoneyFrame\\UI-MoneyIcons"
function D.TextProbe()
D.Log("--- textprobe ---")
if not textProbeFrame then
textProbeFrame = CreateFrame("Frame", nil, UIParent)
textProbeFrame:SetWidth(400); textProbeFrame:SetHeight(340)
textProbeFrame:SetPoint("CENTER", UIParent, "CENTER", 0, -160)
textProbeFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
textProbeFrame:SetBackdropColor(0, 0, 0, 0.7)
textProbeFrame.lines = {}
for i = 1, 9 do
local fs = textProbeFrame:CreateFontString(nil, "OVERLAY")
fs:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
fs:SetJustifyH("LEFT")
fs:SetPoint("TOPLEFT", 8, -6 - (i - 1) * 20)
textProbeFrame.lines[i] = fs
end
-- A scratch string with no size of its own, so GetStringWidth reports
-- what the engine actually laid out rather than a width we imposed, and
-- a scratch texture for the coin paths. Both are kept on the frame:
-- nothing here can be destroyed, so a re-run must reuse them.
textProbeFrame.scratch = textProbeFrame:CreateFontString(nil, "OVERLAY")
textProbeFrame.scratchTex = textProbeFrame:CreateTexture(nil, "OVERLAY")
end
textProbeFrame:Show()
local scratch = textProbeFrame.scratch
scratch:SetWidth(0)
scratch:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
scratch:SetText("M")
local emWidth = scratch:GetStringWidth() or 0
D.Log(string.format(" reference: one 12px 'M' is %.1fpx wide", emWidth))
-- No live markup in a label. Once escapes render, a name written as
-- "|T<path>:0|t" stops being a description of the form and becomes an
-- instance of it -- pointing at a file called "<path>", which draws the
-- missing-texture box beside every row it labels and reads as a rendering
-- fault in the thing being probed. Same rule for anything sent to chat.
local forms = {
{ name = "auto <path>:0", text = "|T" .. TEXTPROBE_ICON .. ":0|t" },
{ name = "sized <path>:14", text = "|T" .. TEXTPROBE_ICON .. ":14|t" },
{ name = "h:w <path>:14:14", text = "|T" .. TEXTPROBE_ICON .. ":14:14|t" },
{ name = "offs <path>:14:14:0:0", text = "|T" .. TEXTPROBE_ICON .. ":14:14:0:0|t" },
}
local anyRendered = false
for i = 1, #forms do
scratch:SetText(forms[i].text)
local w = scratch:GetStringWidth() or 0
-- An icon occupies roughly one line; the literal path is 35+ characters,
-- so the gap between the two outcomes is an order of magnitude.
local rendered = w > 0 and w < emWidth * 4
if rendered then anyRendered = true end
D.Log(string.format(" escape %s -> %.1fpx %s", forms[i].name, w,
rendered and "RENDERS AS ICON" or "printed as text"))
if textProbeFrame.lines[i] then
textProbeFrame.lines[i]:SetText(forms[i].name .. " " .. forms[i].text)
end
end
D.Log(" verdict: inline texture escapes " ..
(anyRendered and "WORK in a FontString here (the recorded answer)"
or "do not work in a FontString here -- which contradicts the recorded answer"))
D.Log(" ICON_LIST = " .. type(ICON_LIST) .. ", ICON_TAG_LIST = " .. type(ICON_TAG_LIST))
-- What the addon resolved at load, beside what the probe just measured. A
-- disagreement is the interesting outcome: the kill switch can be thrown
-- between load and this run.
D.Log(" WA.hasInlineText = " .. tostring(WA.hasInlineText)
.. ", ClassicAPI " .. tostring(WA.ClassicAPIVersionString()))
-- The Money format's coin art. A width measurement cannot answer this one:
-- the escape's advance is counted before the file is loaded, so a path that
-- does not exist still measures as an icon. The SetTexture read-back is what
-- separates "no such file" from "renders" -- clear first, because a refused
-- path leaves whatever the texture already held.
local coinTex = textProbeFrame.scratchTex
coinTex:SetTexture(nil)
coinTex:SetTexture(TEXTPROBE_COIN_SHEET)
D.Log(" coin sheet " .. TEXTPROBE_COIN_SHEET .. " -> "
.. (coinTex:GetTexture() and "RESOLVES" or "REFUSED -- no such file here"))
coinTex:SetTexture(nil)
for i = 1, #TEXTPROBE_COINS do
local coin = TEXTPROBE_COINS[i]
local esc = "|T" .. TEXTPROBE_COIN_SHEET .. ":0:0:0:0:" .. coin.cell .. "|t"
scratch:SetText(esc)
D.Log(string.format(" coin %s cell %s -> %.1fpx escape", coin.name, coin.cell,
scratch:GetStringWidth() or 0))
local line = textProbeFrame.lines[4 + i]
if line then line:SetText(coin.name .. " 12" .. esc .. " 34") end
end
D.Log(" GetCoinTextureString = " .. type(GetCoinTextureString)
.. ", GOLD_AMOUNT_TEXTURE = " .. type(GOLD_AMOUNT_TEXTURE))
-- Height. GetStringHeight is the engine's own reading and GetHeight is the
-- region's; they agree on plain text. A TALL icon is where they can part,
-- since only GetStringHeight is documented to count the line growth an icon
-- bigger than its font forces -- which decides whether auto-sizing a string
-- carrying %i needs the engine reading or can keep the region one.
local function stringHeight(fs)
if type(fs.GetStringHeight) ~= "function" then return nil end
local ok, h = pcall(fs.GetStringHeight, fs)
return ok and h or nil
end
local function reportHeights(label, text)
scratch:SetText(text)
local gh = scratch:GetHeight() or 0
local sh = stringHeight(scratch)
D.Log(string.format(" %s: GetHeight = %.1f, GetStringHeight = %s", label, gh,
sh and string.format("%.1f", sh) or "absent"))
return gh, sh
end
local h1 = reportHeights("1 line", "one line")
local h3 = reportHeights("3 lines", "a\nb\nc")
D.Log(" -> GetHeight " .. ((h3 > h1 * 2) and "tracks line count" or "does NOT track line count"))
local hPlainGet, hPlainString = reportHeights("plain 12px", "Mg")
local hTallGet, hTallString = reportHeights("with a :32 icon", "Mg|T" .. TEXTPROBE_ICON .. ":32|t")
D.Log(" -> a tall icon grows GetHeight: " .. ((hTallGet > hPlainGet * 1.5) and "YES" or "NO"))
D.Log(" -> a tall icon grows GetStringHeight: " ..
((not hTallString or not hPlainString) and "n/a, method absent"
or (hTallString > hPlainString * 1.5) and "YES" or "NO"))
if textProbeFrame.lines[8] then
textProbeFrame.lines[8]:SetText("tall Mg|T" .. TEXTPROBE_ICON .. ":32|t Mg")
end
if textProbeFrame.lines[9] then
textProbeFrame.lines[9]:SetText("tint |T" .. TEXTPROBE_ICON .. ":0:0:0:0:64:64:4:60:4:60:255:64:64|t red?")
end
-- Scale. An inline icon is an engine region the DLL anchors to the owning
-- FontString, and it converts its placement offsets through the region's
-- effective scale chain -- so a FontString on a scaled frame is the one
-- shape this panel's own unscaled rows cannot speak for, and it is the shape
-- every aura has (region scale, group scale, uiScale). Three nestings of the
-- same string, read against each other.
if not textProbeFrame.scaleRows then
textProbeFrame.scaleRows = {}
local scales = { 1, 0.75, 1.5 }
for i = 1, #scales do
local host = CreateFrame("Frame", nil, textProbeFrame)
host:SetWidth(120); host:SetHeight(20)
host:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 200, -6 - (i - 1) * 24)
host:SetScale(scales[i])
local fs = host:CreateFontString(nil, "OVERLAY")
fs:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
fs:SetJustifyH("LEFT")
fs:SetPoint("LEFT", host, "LEFT", 0, 0)
fs:SetText("x" .. scales[i] .. " A|T" .. TEXTPROBE_ICON .. ":0|t B")
textProbeFrame.scaleRows[i] = fs
end
end
-- Font-size sweep, testing whether the glyph artifacts on text FOLLOWING an
-- icon track the icon advance's fractional part. The DLL reserves
-- `w + 1.5 * fontH * 0.18` per icon and hands the result to the next run
-- unsnapped, so a fractional advance leaves every following glyph rounding
-- off a fractional pen.
--
-- The advance is MEASURED, not computed: the DLL's fontH comes from the
-- engine's own size mapping rather than from the size passed to SetFont, so
-- arithmetic on the requested size predicts the wrong fraction. Differencing
-- two GetStringWidth readings over the same letters gives the real one.
if not textProbeFrame.sizeRows then
textProbeFrame.sizeRows = {}
for i = 10, 16 do
local fs = textProbeFrame:CreateFontString(nil, "OVERLAY")
fs:SetFont("Fonts\\FRIZQT__.TTF", i, "")
fs:SetJustifyH("LEFT")
fs:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 200, -90 - (i - 10) * 19)
fs:SetText(i .. "px HH|T" .. TEXTPROBE_ICON .. ":0|t HHHH")
textProbeFrame.sizeRows[i] = fs
end
end
for size = 10, 16 do
scratch:SetFont("Fonts\\FRIZQT__.TTF", size, "")
scratch:SetText("HHHHHH")
local plain = scratch:GetStringWidth() or 0
scratch:SetText("HH|T" .. TEXTPROBE_ICON .. ":0|tHHHH")
local withIcon = scratch:GetStringWidth() or 0
local advance = withIcon - plain
D.Log(string.format(" %dpx icon advance = %.3fpx, fraction %.3f", size, advance,
advance - math.floor(advance)))
end
scratch:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
-- Edge bleed. The artifact is a small mark touching the icon rather than
-- anything spread across the following glyphs, so the suspect is the icon
-- REGION's own rect: it is placed at fractional coordinates (the pool
-- converges its offsets against an effective-scale chain), and a quad
-- sampling a texture across a non-integer boundary smears its outermost
-- texels outward. INV_Misc_QuestionMark is bordered in near-black, which is
-- what such a sliver would be made of.
--
-- Cropping is the discriminator: pulling the sampled rect inside the art's
-- own border leaves no border texel to bleed. If the uncropped row marks and
-- the cropped ones do not, it is edge bleed and the fix is a half-texel
-- inset in the DLL's texcoords rather than anything about the pen.
if not textProbeFrame.edgeRows then
textProbeFrame.edgeRows = {}
local variants = {
{ label = "full ", tail = "16:16" },
{ label = "crop 4", tail = "16:16:0:0:64:64:4:60:4:60" },
{ label = "crop 8", tail = "16:16:0:0:64:64:8:56:8:56" },
}
for i = 1, #variants do
local fs = textProbeFrame:CreateFontString(nil, "OVERLAY")
fs:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
fs:SetJustifyH("LEFT")
fs:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 8, -192 - (i - 1) * 20)
fs:SetText(variants[i].label .. " HH|T" .. TEXTPROBE_ICON
.. ":" .. variants[i].tail .. "|tHHHH")
textProbeFrame.edgeRows[i] = fs
end
-- The control every icon row above lacks: the same glyphs at the same
-- sizes with NO escape anywhere on the line. Marks here too would mean
-- the artifacts hunted through this panel were never about inline icons
-- at all, and belong to this client's text rendering or to another addon.
for i = 1, 2 do
local fs = textProbeFrame:CreateFontString(nil, "OVERLAY")
fs:SetFont("Fonts\\FRIZQT__.TTF", i == 1 and 20 or 32, "")
fs:SetJustifyH("LEFT")
fs:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 8, -256 - (i - 1) * 34)
fs:SetText((i == 1 and "20px" or "32px") .. " no icon HH 12px")
end
end
-- The SetFont size ceiling, and whether SetTextHeight is the way past it.
-- Past the ceiling the client rasterises at the ceiling and ignores the size
-- asked for, so text simply stops growing with nothing to report a failure --
-- a width sweep is the only way to find where it sits, since a clamped size
-- lays out exactly like the one below it.
--
-- The second reading is the one auto-sizing depends on: SetTextHeight scales
-- what was rasterised, and whether the scaled string MEASURES bigger as well
-- as DRAWING bigger decides whether a text region in Auto mode can size itself
-- to a font above the ceiling.
local function sweepWidth(path, size)
scratch:SetFont(path, size, "")
scratch:SetText("HHHHHH")
return scratch:GetStringWidth() or 0
end
local SWEEP_SIZES = { 8, 12, 16, 20, 24, 32, 48, 64 }
local sweepFonts = {
{ name = "Friz Quadrata", path = "Fonts\\FRIZQT__.TTF" },
{ name = "Roboto Mono", path = "Interface\\AddOns\\WeakestAuras\\fonts\\RobotoMono.ttf" },
}
for i = 1, #sweepFonts do
local path = sweepFonts[i].path
local parts, ceiling, last = {}, SWEEP_SIZES[1], sweepWidth(path, SWEEP_SIZES[1])
table.insert(parts, string.format("%d=%.0f", SWEEP_SIZES[1], last))
for j = 2, #SWEEP_SIZES do
local size = SWEEP_SIZES[j]
local w = sweepWidth(path, size)
if w > last + 0.5 then ceiling = size end
last = w
table.insert(parts, string.format("%d=%.0f", size, w))
end
D.Log(" [size] " .. sweepFonts[i].name .. ": " .. table.concat(parts, " "))
D.Log(string.format(" -> SetFont stops widening above %dpx%s", ceiling,
(ceiling >= SWEEP_SIZES[#SWEEP_SIZES]) and " (no ceiling in range)" or ""))
scratch:SetFont(path, 48, "")
scratch:SetText("HHHHHH")
local before = scratch:GetStringWidth() or 0
scratch:SetTextHeight(48)
local after = scratch:GetStringWidth() or 0
D.Log(string.format(" -> SetTextHeight(48) after SetFont(48): %.0f -> %.0f (%s)",
before, after, (after > before + 0.5) and "measures bigger too"
or "draws scaled, measures unchanged"))
end
scratch:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
-- Which ORDER of the SetFont/SetTextHeight pair actually draws at the size
-- asked for. Every row gets a FRESH FontString: a height override survives on
-- the one it was set on, and reusing a string carries the previous row's
-- override into the next one -- which is exactly how the first run of this
-- panel misread itself.
--
-- Row 1 is the plain request, the one that does not grow. Rows 2 and 3 ask for
-- a height EQUAL to the size SetFont was given, before and after the string;
-- row 4 asks for one that differs. Read the WIDTHS: the rows that scale
-- measure wider, and the difference between rows 2/3 and row 4 is the whole
-- reason textCore rasterises at the ceiling rather than at the size asked for.
local HEIGHT_ORDERS = {
{ label = "font 40", font = 40 },
{ label = "font 40 + h40 pre", font = 40, height = 40, before = true },
{ label = "font 40 + h40 post", font = 40, height = 40 },
{ label = "font 17 + h40 post", font = 17, height = 40 },
}
local function applyOrder(fs, order, text)
fs:SetFont("Fonts\\FRIZQT__.TTF", order.font, "")
if order.height and order.before then fs:SetTextHeight(order.height) end
fs:SetText(text)
if order.height and not order.before then fs:SetTextHeight(order.height) end
end
if not textProbeFrame.orderScratch then
textProbeFrame.orderScratch = {}
for i = 1, #HEIGHT_ORDERS do
local fs = textProbeFrame:CreateFontString(nil, "BACKGROUND")
fs:SetAlpha(0)
fs:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 8, 40)
textProbeFrame.orderScratch[i] = fs
end
end
for i = 1, #HEIGHT_ORDERS do
local order = HEIGHT_ORDERS[i]
local fs = textProbeFrame.orderScratch[i]
applyOrder(fs, order, "HHHHHH")
local face, reported = fs:GetFont()
D.Log(string.format(" [order] %-20s width %.1f, GetFont %s @ %s",
order.label, fs:GetStringWidth() or 0,
tostring(face and string.gsub(face, ".*\\", "") or "NONE"), tostring(reported)))
end
D.Log(" -> textCore's measured size ceiling: "
.. tostring(WA.textCore and WA.textCore.SizeCeiling and WA.textCore.SizeCeiling()))
-- Whether a face swap still lands on a string that is already scaled. If it
-- does not, changing font or outline on a big aura would silently do nothing --
-- every aura above the ceiling carries a height override for life.
if not textProbeFrame.swapScratch then
textProbeFrame.swapScratch = textProbeFrame:CreateFontString(nil, "BACKGROUND")
textProbeFrame.swapScratch:SetAlpha(0)
textProbeFrame.swapScratch:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 8, 40)
end
local swap = textProbeFrame.swapScratch
applyOrder(swap, HEIGHT_ORDERS[4], "HHHHHH")
local scaledW = swap:GetStringWidth() or 0
swap:SetFont("Interface\\AddOns\\WeakestAuras\\fonts\\RobotoMono.ttf", 17, "")
swap:SetTextHeight(40)
local swapFace = swap:GetFont()
D.Log(string.format(" [swap] scaled %.1f -> face swap %.1f, now %s", scaledW,
swap:GetStringWidth() or 0,
tostring(swapFace and string.gsub(swapFace, ".*\\", "") or "NONE")))
-- What the ceiling IS. The rasteriser's limit is in device pixels, not in the
-- units SetFont takes, so the ceiling above is only this client at this
-- resolution and this UI scale -- and the conversion is what makes it
-- reportable to whoever owns the rasteriser.
-- Parenthesised: gsub returns the count as a second value, and tonumber would
-- take it as a base.
local physH = tonumber((string.gsub(GetCVar("gxResolution") or "", ".*x", ""))) or 0
local unitH = GetScreenHeight() or 0
local pxPerUnit = (unitH > 0) and (physH / unitH) or 0
local ceilingUnits = WA.textCore and WA.textCore.SizeCeiling and WA.textCore.SizeCeiling()
D.Log(string.format(" [scale] %s, screen %.0f units, uiScale %s, %.3f px/unit",
tostring(GetCVar("gxResolution")), unitH, tostring(GetCVar("uiScale")), pxPerUnit))
if ceilingUnits then
D.Log(string.format(" -> the %d-unit ceiling is %.1f device px", ceilingUnits,
ceilingUnits * pxPerUnit))
end
-- Whether that ceiling is counted in device pixels or in font units, which is
-- the difference between "no lever exists" and "a scaled host frame buys
-- sharpness". A frame scaled 2x doubles the device pixels a given font size
-- asks for: if the limit is in pixels the plateau halves in units, if it is in
-- units it does not move.
if not textProbeFrame.scaledHost then
textProbeFrame.scaledHost = CreateFrame("Frame", nil, textProbeFrame)
textProbeFrame.scaledHost:SetWidth(10); textProbeFrame.scaledHost:SetHeight(10)
textProbeFrame.scaledHost:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 8, 40)
textProbeFrame.scaledHost:SetScale(2)
textProbeFrame.scaledProbe = textProbeFrame.scaledHost:CreateFontString(nil, "BACKGROUND")
textProbeFrame.scaledProbe:SetAlpha(0)
textProbeFrame.scaledProbe:SetPoint("TOPLEFT", textProbeFrame.scaledHost, "TOPLEFT", 0, 0)
end
local scaledPlateau, scaledLast = nil, nil
for size = 6, 40 do
textProbeFrame.scaledProbe:SetFont("Fonts\\FRIZQT__.TTF", size, "")
textProbeFrame.scaledProbe:SetText("HHHHHH")
local w = textProbeFrame.scaledProbe:GetStringWidth() or 0
if scaledLast and w > scaledLast + 0.5 then scaledPlateau = size end
scaledLast = w
end
D.Log(" [scale] the ceiling on a 2x-scaled frame: " .. tostring(scaledPlateau)
.. " units -- halved means the limit is DEVICE PIXELS, unchanged means FONT UNITS")
-- Whether a font object built by the client itself escapes the cap. FrameXML
-- creates its big faces (zone text at 32) at load; if those carry a bigger
-- glyph cache than SetFont will build, picking one is a way to draw big text
-- sharply and the font list would grow a few fixed-size entries.
local objProbe = textProbeFrame.orderScratch[1]
if ZoneTextFont then
objProbe:SetFontObject(ZoneTextFont)
objProbe:SetText("HHHHHH")
local objPath, objSize = objProbe:GetFont()
local objW = objProbe:GetStringWidth() or 0
objProbe:SetFont(objPath or "Fonts\\FRIZQT__.TTF", objSize or 32, "")
objProbe:SetText("HHHHHH")
D.Log(string.format(" [fontobject] ZoneTextFont @ %s: width %.1f, same face via SetFont: %.1f%s",
tostring(objSize), objW, objProbe:GetStringWidth() or 0,
(objW > (objProbe:GetStringWidth() or 0) + 0.5) and " <- IT ESCAPES THE CAP" or ""))
end
-- The same four for the eye, since a width reading cannot say whether the
-- GLYPHS grew or only the spacing between them did -- which is the difference
-- between text that got bigger and text that got stretched.
if not textProbeFrame.heightRows then
textProbeFrame.heightRows = {}
for i = 1, #HEIGHT_ORDERS do
local fs = textProbeFrame:CreateFontString(nil, "OVERLAY")
fs:SetJustifyH("LEFT")
fs:SetPoint("TOPLEFT", textProbeFrame, "TOPLEFT", 8, -300 - (i - 1) * 46)
applyOrder(fs, HEIGHT_ORDERS[i], "Ag " .. i)
textProbeFrame.heightRows[i] = fs
end
textProbeFrame:SetHeight(520)
end
scratch:SetWidth(120)
scratch:SetText("a long enough string that it has to wrap across several lines at this width")
local hWrap = scratch:GetHeight() or 0
local sWrap = stringHeight(scratch)
D.Log(string.format(" wrapping at width 120: GetHeight = %.1f, GetStringHeight = %s -- %s",
hWrap, sWrap and string.format("%.1f", sWrap) or "absent",
(hWrap > h1 * 1.5) and "sees the wrap" or "does NOT see the wrap"))
scratch:SetWidth(0)
-- The font list and the flag combinations, read back the way textCore does.
local core = WA.textCore
if core then
for i = 1, #core.FONTS do
local f = core.FONTS[i]
scratch:SetFont(f.path, 12, "")
D.Log(" font " .. f.name .. " -> " .. (scratch:GetFont() and "loads" or "REFUSED"))
end
scratch:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
for i = 1, #core.FLAGS do
local flags = core.FLAGS[i]
local applied = (flags == "None") and "" or flags
scratch:SetFont("Fonts\\FRIZQT__.TTF", 12, applied)
D.Log(" flags " .. flags .. " -> " .. (scratch:GetFont() and "accepted" or "REFUSED"))
end
scratch:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
end
-- The two text widgets nothing in this addon uses. A FontString having no
-- escape parser does not prove the engine has none, and these are the only
-- other places an icon could sit inside a run of text.
--
-- SimpleHTML is the interesting one: on later clients it takes <img src=...>,
-- which is a real inline-image path rather than an escape. It builds its
-- content out of child regions, so whether the tag was honoured is readable
-- without an eye -- an honoured <img> has to produce a Texture region.
local function regionKinds(f)
local counts = {}
if not f or not f.GetNumRegions then return counts end
local n = f:GetNumRegions() or 0
local regions = { f:GetRegions() }
for i = 1, n do
local r = regions[i]
if r and r.GetObjectType then
local t = r:GetObjectType()
counts[t] = (counts[t] or 0) + 1
end
end
return counts
end
local function describe(counts)
local parts = {}
for kind, n in pairs(counts) do table.insert(parts, kind .. "x" .. n) end
if #parts == 0 then return "no regions" end
table.sort(parts)
return table.concat(parts, " ")
end
local madeHtml, html = pcall(CreateFrame, "SimpleHTML", nil, UIParent)
if not madeHtml or not html then
D.Log(" SimpleHTML: CreateFrame refused the type -- not available here")
else
html:SetWidth(300); html:SetHeight(40)
html:SetPoint("TOP", textProbeFrame, "BOTTOM", 0, -8)
-- SimpleHTML draws nothing until its element fonts are set, and the
-- per-element signature is not confirmed here, so try both shapes.
pcall(html.SetFontObject, html, "p", GameFontHighlight)
pcall(html.SetFontObject, html, GameFontHighlight)
pcall(html.SetFont, html, "p", "Fonts\\FRIZQT__.TTF", 12, "")
local okPlain = pcall(html.SetText, html, "<html><body><p>plain text</p></body></html>")
local before = regionKinds(html)
local okImg = pcall(html.SetText, html,
"<html><body><p><img src=\"" .. TEXTPROBE_ICON .. "\" width=\"16\" height=\"16\"/>after</p></body></html>")
local after = regionKinds(html)
D.Log(" SimpleHTML SetText: plain=" .. tostring(okPlain) .. " img=" .. tostring(okImg))
D.Log(" SimpleHTML regions plain -> " .. describe(before))
D.Log(" SimpleHTML regions with img -> " .. describe(after))
D.Log(" verdict: <img> " ..
(((after.Texture or 0) > (before.Texture or 0)) and "CREATES A TEXTURE -- inline images are possible"
or "adds no texture -- the tag is not honoured"))
end
local madeMsg, msg = pcall(CreateFrame, "MessageFrame", nil, UIParent)
if not madeMsg or not msg then
D.Log(" MessageFrame: CreateFrame refused the type")
else
msg:SetWidth(300); msg:SetHeight(20)
msg:SetPoint("TOP", textProbeFrame, "BOTTOM", 0, -56)
pcall(msg.SetFontObject, msg, GameFontHighlight)
local plainKinds, escKinds
pcall(msg.AddMessage, msg, "plain")
plainKinds = regionKinds(msg)
pcall(msg.AddMessage, msg, "|T" .. TEXTPROBE_ICON .. ":0|t")
escKinds = regionKinds(msg)
D.Log(" MessageFrame regions plain -> " .. describe(plainKinds))
D.Log(" MessageFrame regions escape -> " .. describe(escKinds))
end
-- The chat frame is a ScrollingMessageFrame. It carries an anti-spoof rule
-- the other widgets do not: the DLL strips a |T an addon injects into chat,
-- so a path here is the designed outcome rather than a missing parser.
DEFAULT_CHAT_FRAME:AddMessage("WA textprobe -- chat escape test: [|T" .. TEXTPROBE_ICON
.. ":0|t] <- an icon between the brackets, a path, or nothing?")
D.Log(" a chat line was printed: an icon means a ScrollingMessageFrame honours")
D.Log(" escapes; nothing between the brackets means the chat anti-spoof stripped it.")
D.Log(" panels are up under CENTER,0,-160. Left column: the four escape forms, the")
D.Log(" three coins, a tall :32 icon, a red-tinted cropped icon. Right column: the")
D.Log(" same string at frame scale 1 / 0.75 / 1.5, then a font-size sweep 10..16px.")
D.Log(" Bottom left: the same icon uncropped, then cropped 4 and 8 texels in. All")
D.Log(" three are the same size, so only the sampled rect differs -- a mark on the")
D.Log(" uncropped row alone is the icon's own border texels bleeding past a")
D.Log(" fractional region edge. Under those, two rows with NO escape on the line at")
D.Log(" all -- the control: marks there mean none of this was about inline icons.")
D.Log(" SimpleHTML and the MessageFrame are below.")
D.Log("--- end textprobe ---")
end
-- /wa csprobe -- whether a ColorSelect built at runtime is a working colour
-- wheel, which decides whether our own picker can host the engine's wheel or
-- has to draw an HSV area out of gradients instead.
--
-- ColorPickerFrame is a <ColorSelect> declaring its wheel and value textures as
-- child elements with no file of their own (design/client/ColorPickerFrame.xml),
-- so the art is engine-drawn and the only question is how a frame with no XML
-- hands those textures over. The setter may want a Texture object or a path,
-- and either may be absent here -- the 3.3.5 list is not evidence for this
-- client.
--
-- The reported half is machine-checkable; the wheel *drawing* is not, so the
-- probe leaves its frame on screen. Two things need an eye: whether a wheel
-- appears where the outline is, and whether dragging on it moves the preview.
local csProbeFrame
function D.ColorSelectProbe()
D.Log("--- csprobe ---")
local ok, cs = pcall(CreateFrame, "ColorSelect", nil, UIParent)
if not ok or not cs then
D.Log(" CreateFrame(\"ColorSelect\") FAILED: " .. tostring(cs))
D.Log(" Our own picker cannot host an engine wheel; gradients it is.")
D.Log("--- end csprobe ---")
return
end
D.Log(" CreateFrame(\"ColorSelect\") = ok")
-- Read off the instance rather than the metatable: __index may be a function
-- here, and a direct type() test answers the same question either way.
local names = {
"SetColorRGB", "GetColorRGB", "SetColorHSV", "GetColorHSV",
"SetColorWheelTexture", "GetColorWheelTexture",
"SetColorWheelThumbTexture", "GetColorWheelThumbTexture",
"SetColorValueTexture", "GetColorValueTexture",
"SetColorValueThumbTexture", "GetColorValueThumbTexture",
}
local present, absent = {}, {}
for i = 1, #names do
local n = names[i]
if type(cs[n]) == "function" then table.insert(present, n) else table.insert(absent, n) end
end
D.Log(" present: " .. (#present > 0 and table.concat(present, ", ") or "(none)"))
D.Log(" ABSENT: " .. (#absent > 0 and table.concat(absent, ", ") or "(none)"))
if not csProbeFrame then
csProbeFrame = CreateFrame("Frame", nil, UIParent)
csProbeFrame:SetWidth(260); csProbeFrame:SetHeight(210)
csProbeFrame:SetPoint("CENTER", 220, 0)
csProbeFrame:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
tile = true, tileSize = 32, edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
csProbeFrame:SetFrameStrata("FULLSCREEN_DIALOG")
csProbeFrame:EnableMouse(true)
csProbeFrame:SetMovable(true)
csProbeFrame:RegisterForDrag("LeftButton")
csProbeFrame:SetScript("OnDragStart", function() this:StartMoving() end)
csProbeFrame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
local title = csProbeFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", 0, -14)
title:SetText("csprobe -- drag me")
local hint = csProbeFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
hint:SetPoint("BOTTOM", 0, 16)
hint:SetText("wheel + bar should draw; drag them")
csProbeFrame.hint = hint
end
csProbeFrame:Show()
if csProbeFrame.cs then csProbeFrame.cs:Hide() end
cs:SetParent(csProbeFrame)
cs:SetWidth(184); cs:SetHeight(128)
cs:SetPoint("TOPLEFT", csProbeFrame, "TOPLEFT", 18, -38)
cs:EnableMouse(true)
csProbeFrame.cs = cs
-- The wheel's own art is engine-drawn, so the texture handed over is an
-- empty canvas; the thumbs are the one pair with a real file, cropped out of
-- the sheet the FrameXML crops (same TexCoords).
local wheelTex = cs:CreateTexture(nil, "ARTWORK")
wheelTex:SetWidth(128); wheelTex:SetHeight(128)
wheelTex:SetPoint("TOPLEFT", cs, "TOPLEFT", 0, 0)
local valueTex = cs:CreateTexture(nil, "ARTWORK")
valueTex:SetWidth(32); valueTex:SetHeight(128)
valueTex:SetPoint("LEFT", wheelTex, "RIGHT", 24, 0)
local wheelThumb = cs:CreateTexture(nil, "OVERLAY")
wheelThumb:SetTexture("Interface\\Buttons\\UI-ColorPicker-Buttons")
wheelThumb:SetWidth(10); wheelThumb:SetHeight(10)
wheelThumb:SetTexCoord(0, 0.15625, 0, 0.625)
local valueThumb = cs:CreateTexture(nil, "OVERLAY")
valueThumb:SetTexture("Interface\\Buttons\\UI-ColorPicker-Buttons")
valueThumb:SetWidth(48); valueThumb:SetHeight(14)
valueThumb:SetTexCoord(0.25, 1.0, 0, 0.875)
D.Log(" thumb art read-back = " .. tostring(wheelThumb:GetTexture()))
local function try(label, fn)
local passed, err = pcall(fn)
D.Log(" " .. label .. " = " .. (passed and "ok" or ("ERR " .. tostring(err))))
return passed
end
try("SetColorWheelTexture(texture)", function() cs:SetColorWheelTexture(wheelTex) end)
try("SetColorValueTexture(texture)", function() cs:SetColorValueTexture(valueTex) end)
try("SetColorWheelThumbTexture(texture)", function() cs:SetColorWheelThumbTexture(wheelThumb) end)
try("SetColorValueThumbTexture(texture)", function() cs:SetColorValueThumbTexture(valueThumb) end)
-- A setter that wanted a path instead would have thrown above and take one
-- here, so both shapes are asked rather than inferred from the first result.
local cs2 = CreateFrame("ColorSelect", nil, UIParent)
cs2:Hide()
try("SetColorWheelTexture(path)", function()
cs2:SetColorWheelTexture("Interface\\Buttons\\UI-ColorPicker-Buttons")
end)
if type(cs.GetColorWheelTexture) == "function" then
local got = cs:GetColorWheelTexture()
D.Log(" GetColorWheelTexture() after set = " .. tostring(got)
.. (got == wheelTex and " (the one we handed it)" or ""))
end
local fired = 0
cs:SetScript("OnColorSelect", function() fired = fired + 1 end)
cs:SetColorRGB(0.2, 0.4, 0.8)
local r, g, b = cs:GetColorRGB()
D.Log(" SetColorRGB(.2,.4,.8) -> GetColorRGB = "
.. string.format("%.3f %.3f %.3f", r or -1, g or -1, b or -1))
D.Log(" OnColorSelect fired on a scripted set = " .. tostring(fired > 0)
.. " (" .. fired .. "x)")
if type(cs.SetColorHSV) == "function" then
cs:SetColorHSV(0.5, 1, 1)
local hr, hg, hb = cs:GetColorRGB()
D.Log(" SetColorHSV(.5,1,1) -> GetColorRGB = "
.. string.format("%.3f %.3f %.3f", hr or -1, hg or -1, hb or -1)
.. " (cyan 0,1,1 means HSV drives the same colour)")
end
-- The live preview: whatever the wheel is dragged to lands here, which is the
-- only proof the engine is still driving the widget after a runtime build.
local preview = csProbeFrame.preview
if not preview then
preview = csProbeFrame:CreateTexture(nil, "OVERLAY")
preview:SetWidth(32); preview:SetHeight(32)
preview:SetPoint("TOPRIGHT", csProbeFrame, "TOPRIGHT", -20, -38)
csProbeFrame.preview = preview
end
preview:SetTexture(0.2, 0.4, 0.8, 1)
cs:SetScript("OnColorSelect", function()
preview:SetTexture(this:GetColorRGB())
end)
cs:Show()
D.Log(" A frame is on screen to the right. Report two things:")
D.Log(" 1. does a colour WHEEL and a vertical value BAR actually draw in it?")
D.Log(" 2. does dragging on them move the 32x32 preview square's colour?")
D.Log("--- end csprobe ---")
end
-- pfUI's copy, used only as a test subject: it is a vendored monospace TTF
-- already on this client, so it answers "can SetFont load one at all" without
-- committing to vendoring our own before the answer is known.
local PROBE_FONT = "Interface\\AddOns\\pfUI\\fonts\\RobotoMono.ttf"
local codeProbeFrame, codeProbeEdit, codeProbeMeasure
-- The Q-INDEX/Q-ARROW subject. Raw byte offsets into "|cffff0000abc|rdef":
-- the escape is 0..9, "abc" 10..12, "|r" 13..14, "def" 15..17, end 18.
local PROBE_TEXT = "|cffff0000abc|rdef"
-- Confirmed in-game: a \1 survives Insert -> GetText intact, so the 1-byte
-- control character is the sentinel rather than upstream's 3-byte U+E000.
local SENTINEL = "\1"
-- Reads the caret as a raw byte offset. This client has no GetCursorPosition
-- (ClassicAPI TODO #96), so the position is recovered by inserting a byte the
-- text cannot contain and finding it -- the same trick the caret
-- save/restore rests on, which this therefore also proves out. Destructive:
-- restoring the text resets the caret, so each read needs the key presses
-- redone.
local function probeReadCaret()
local before = codeProbeEdit:GetText() or ""
codeProbeEdit:Insert(SENTINEL)
local at = string.find(codeProbeEdit:GetText() or "", SENTINEL, 1, true)
codeProbeEdit:SetText(before)
return at and (at - 1)
end
-- From the end of PROBE_TEXT, four Lefts land on raw 12 if the engine steps
-- over the |r escape as one unit, and on raw 14 if it walks through it byte by
-- byte (three presses to cross two invisible bytes -- the UX failure that kills
-- live colorizing).
local function probeCaretVerdict(at)
if at == 12 or at == 13 then
return "stepped OVER the escape -- Q-ARROW PASSES, live colorizing is viable"
elseif at == 14 then
return "stopped INSIDE the |r escape -- Q-ARROW FAILS, caret handling needs revision"
elseif at == 15 then
return "just before 'd' -- that is only 3 Lefts; redo with 4"
elseif at == 18 then
return "still at the end -- the arrow presses did not register"
end
return "unexpected; click at the very end, press Left exactly 4 times, then Tab"
end
local function codeProbeBuild()
if codeProbeFrame then return end
codeProbeFrame = CreateFrame("Frame", "WA_CodeProbeFrame", UIParent)
codeProbeFrame:SetWidth(420); codeProbeFrame:SetHeight(200)
codeProbeFrame:SetPoint("CENTER", UIParent, "CENTER", -260, 0)
codeProbeFrame:SetBackdrop(WA.Widgets.PANEL_BACKDROP)
codeProbeFrame:SetBackdropColor(0, 0, 0, 1)
codeProbeFrame:SetFrameStrata("DIALOG")
codeProbeFrame:SetToplevel(true)
codeProbeFrame:SetMovable(true)
codeProbeFrame:EnableMouse(true)
codeProbeFrame:RegisterForDrag("LeftButton")
codeProbeFrame:SetScript("OnDragStart", function() codeProbeFrame:StartMoving() end)
codeProbeFrame:SetScript("OnDragStop", function() codeProbeFrame:StopMovingOrSizing() end)
local title = codeProbeFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
title:SetPoint("TOP", 0, -12)
title:SetText("Code editor probe")
title:SetTextColor(1, 0.82, 0)
local close = CreateFrame("Button", nil, codeProbeFrame, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", -4, -4)
close:SetScript("OnClick", function() codeProbeFrame:Hide() end)
-- A real multi-line box, matching what NewCodeEditBox would decorate: the
-- answers must come from the same widget shape the editor will use.
codeProbeEdit = CreateFrame("EditBox", "WA_CodeProbeEdit", codeProbeFrame)
codeProbeEdit:SetMultiLine(true)
codeProbeEdit:SetAutoFocus(false)
codeProbeEdit:SetFontObject(GameFontHighlightSmall)
codeProbeEdit:SetTextInsets(4, 4, 4, 4)
codeProbeEdit:SetPoint("TOPLEFT", 14, -36)
codeProbeEdit:SetWidth(392); codeProbeEdit:SetHeight(60)
-- Colorizing multiplies the buffer, so an editor must lift both limits or
-- the user's code is silently truncated. Lift them here too, or a long
-- probe string would answer the wrong question.
codeProbeEdit:SetMaxBytes(0)
codeProbeEdit:SetMaxLetters(0)
codeProbeEdit:SetScript("OnEscapePressed", function() codeProbeEdit:ClearFocus() end)
-- Tab, not a button: clicking anything else risks moving focus, and a
-- refocused EditBox may reset its caret -- which would silently corrupt the
-- one measurement this is here to take.
codeProbeEdit:SetScript("OnTabPressed", function()
local at = probeReadCaret()
if not at then
D.Log(" [Q-ARROW] caret read FAILED -- sentinel did not survive Insert")
return
end
D.Log(" [Q-ARROW] caret at raw byte " .. at .. " -- " .. probeCaretVerdict(at))
end)
local bg = CreateFrame("Frame", nil, codeProbeFrame)
bg:SetPoint("TOPLEFT", codeProbeEdit, "TOPLEFT", -4, 4)
bg:SetPoint("BOTTOMRIGHT", codeProbeEdit, "BOTTOMRIGHT", 4, -4)
bg:SetBackdrop(WA.Widgets.EDITBOX_BACKDROP)
bg:SetBackdropColor(0, 0, 0, 0.7)
bg:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8)
local hint = codeProbeFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
hint:SetPoint("TOPLEFT", 14, -104)
hint:SetPoint("RIGHT", -14, 0)
hint:SetJustifyH("LEFT")
hint:SetText("Q-INDEX: click a button, see which run highlights.\n"
.. "Q-ARROW: click at the very END of the text, press Left 4 times,\n"
.. "then press TAB -- the caret offset and verdict go to the log.\n"
.. "(Each Tab read restores the text, so redo the presses to re-measure.)")
hint:SetTextColor(0.8, 0.8, 0.8)
-- The Q-INDEX pair. Raw bytes of "|cffff0000abc|rdef": the escape is 0..9,
-- "abc" 10..12, "|r" 13..14, "def" 15..17. Visible text is "abcdef", so
-- "def" is 3..5 there. Whichever button lights up "def" names the indexing.
local b1 = WA.Widgets.button(codeProbeFrame, "HighlightText(15,18)", function()
codeProbeEdit:SetText(PROBE_TEXT)
codeProbeEdit:SetFocus()
codeProbeEdit:HighlightText(15, 18)
D.Log(" [Q-INDEX] HighlightText(15,18) -- if 'def' is selected, indices are RAW BYTES")
end)
b1:SetWidth(150)
b1:SetPoint("BOTTOMLEFT", 14, 14)
local b2 = WA.Widgets.button(codeProbeFrame, "HighlightText(3,6)", function()
codeProbeEdit:SetText(PROBE_TEXT)
codeProbeEdit:SetFocus()
codeProbeEdit:HighlightText(3, 6)
D.Log(" [Q-INDEX] HighlightText(3,6) -- if 'def' is selected, indices are VISIBLE CHARS")
end)
b2:SetWidth(150)
b2:SetPoint("LEFT", b1, "RIGHT", 8, 0)
end
-- Inserts `s` at the caret and reports whether it survives a GetText()
-- round-trip -- the sentinel the caret save/restore relies on.
local function probeSentinel(label, s, wantByte)
codeProbeEdit:SetText("")
codeProbeEdit:SetFocus()
local ok, err = pcall(function() codeProbeEdit:Insert(s) end)
if not ok then
D.Log(" [Q-SENTINEL] " .. label .. ": Insert errored -- " .. tostring(err))
return
end
local got = codeProbeEdit:GetText() or ""
local found = nil
for i = 1, string.len(got) do
if string.byte(got, i) == wantByte then found = i; break end
end
D.Log(" [Q-SENTINEL] " .. label .. ": GetText len " .. string.len(got)
.. ", byte " .. wantByte .. (found and (" found at " .. found) or " NOT FOUND"))
codeProbeEdit:SetText("")
codeProbeEdit:ClearFocus()
end
function D.CodeProbe()
D.Log("--- code editor probe ---")
codeProbeBuild()
codeProbeFrame:Show()
-- Does GetText hand back the raw escapes, or the rendered text? Everything
-- else assumes the former: an editor that reads back stripped text would
-- lose the user's own literal pipes.
local src = "|cffff0000red|r plain"
codeProbeEdit:SetText(src)
local got = codeProbeEdit:GetText() or ""
D.Log(" [round-trip] set " .. string.len(src) .. " bytes, GetText returned " .. string.len(got)
.. " (raw = " .. string.len(src) .. ", stripped would be 9)")
D.Log(" [round-trip] identical: " .. (got == src and "YES" or "NO -- got \"" .. got .. "\""))
D.Log(" ^^ LOOK AT THE BOX: 'red' should be red with no visible |cffff0000.")
probeSentinel("\\1 (ASCII control)", "\1", 1)
-- U+E000 in UTF-8; upstream's own sentinel, kept as the fallback if the
-- control byte is eaten.
probeSentinel("U+E000", "\238\128\128", 238)
-- Restore the Q-INDEX subject so the two buttons act on the same text the
-- hint describes.
codeProbeEdit:SetText(PROBE_TEXT)
-- Which text-height reading this client offers. GetStringHeight is a 3.3.5
-- method and is absent here -- calling it errors, which is exactly how the
-- multiline box's text measuring first broke. A FontString sizes its own
-- region to its text, so GetHeight is the one that works.
if not codeProbeMeasure then
codeProbeMeasure = codeProbeFrame:CreateFontString(nil, "BACKGROUND", "GameFontHighlightSmall")
codeProbeMeasure:SetPoint("BOTTOMLEFT", codeProbeFrame, "BOTTOMLEFT", 14, 40)
codeProbeMeasure:SetAlpha(0)
codeProbeMeasure:SetJustifyH("LEFT")
end
codeProbeMeasure:SetWidth(200)
codeProbeMeasure:SetText("one\ntwo\nthree")
D.Log(" [measure] FontString:GetStringHeight = "
.. (codeProbeMeasure.GetStringHeight and "present" or "ABSENT")
.. ", GetHeight over 3 lines = "
.. tostring(codeProbeMeasure.GetHeight and codeProbeMeasure:GetHeight() or "ABSENT"))
local ok, err = pcall(function() codeProbeEdit:SetFont(PROBE_FONT, 12, "") end)
D.Log(" [Q-FONT] SetFont(RobotoMono, 12): " .. (ok and "no error" or ("FAILED -- " .. tostring(err))))
if ok then
local f, sz = codeProbeEdit:GetFont()
D.Log(" [Q-FONT] GetFont now: " .. tostring(f) .. " @ " .. tostring(sz)
.. " -- if this is not RobotoMono the call was a silent no-op")
end
-- Settled 2026-08-02 on this client; the buttons stay so another build can be
-- re-checked rather than assumed.
D.Log(" [Q-INDEX] answered: RAW BYTES. The two buttons re-check it.")
D.Log(" [Q-ARROW] OPEN: click at the very END of the box, press Left 4 times, then TAB.")
D.Log(" live colorizing: WeakestAurasDB.codeEditorLive = "
.. tostring(WeakestAurasDB and WeakestAurasDB.codeEditorLive)
.. " (nil = on, the default; toggle with /wa codelive)")
D.Log("--- end code probe ---")
end
-- Live colouring is on unless turned off here. Unset means on, so the toggle
-- resolves the default before negating it -- `not nil` would read as "it was
-- off" and leave the setting where it started.
function D.CodeLive()
if not WeakestAurasDB then D.Log("no saved variables yet"); return end
local on = WeakestAurasDB.codeEditorLive
if on == nil then on = true end
WeakestAurasDB.codeEditorLive = not on
D.Log("live code colouring: " .. (WeakestAurasDB.codeEditorLive and "ON" or "OFF")
.. " -- reopen the options tab to apply.")
if WA.RefreshOptions then WA.RefreshOptions() end
end
-- Point size of the code boxes' fixed-width face.
function D.CodeFont(rest)
if not WeakestAurasDB then D.Log("no saved variables yet"); return end
local n = tonumber(rest)
if n and n >= 6 and n <= 16 then
WeakestAurasDB.codeEditorFontSize = n
elseif rest and rest ~= "" then
D.Log("usage: /wa codefont <6-16>")
return
end
D.Log("code font size: "
.. tostring(WeakestAurasDB.codeEditorFontSize or WA.Widgets.CODE_FONT_SIZE)
.. " -- reopen the options tab to apply.")
if WA.RefreshOptions then WA.RefreshOptions() end
end
-- Spaces per indent level for Tab in a code box; "tabs" switches to hard tabs.
function D.CodeTab(rest)
if not WeakestAurasDB then D.Log("no saved variables yet"); return end
local arg = string.lower(rest or "")
if arg == "tabs" then
WeakestAurasDB.codeEditorTabWidth = false
else
local n = tonumber(arg)
if n and n >= 1 and n <= 8 then
WeakestAurasDB.codeEditorTabWidth = n
elseif arg ~= "" then
D.Log("usage: /wa codetab <1-8|tabs>")
return
end
end
local cur = WeakestAurasDB.codeEditorTabWidth
if cur == nil then cur = 2 end
D.Log("code indent width: " .. (cur == false and "hard tabs" or (tostring(cur) .. " spaces"))
.. " -- reopen the options tab to apply.")
if WA.RefreshOptions then WA.RefreshOptions() end
end
-- ---------------------------------------------------------------------------
-- /wa commprobe -- the server-side unknowns behind sharing auras through chat,
-- none of which a type check can answer: whether SendAddonMessage's WHISPER
-- channel delivers on this realm (every neighbouring addon only ever uses
-- RAID/PARTY/GUILD), what the byte cap really is, what send rate survives, and
-- whether a link type of our own renders, is clickable, and reaches SetItemRef.
-- ---------------------------------------------------------------------------
local COMM_PREFIX = "WKA"
-- Ladder bodies are cut from the base64 alphabet a real payload is made of, so
-- a byte this chat system mangles surfaces here rather than mid-transfer.
local COMM_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local commFrame, commRefHooked
local commSent, commOrder = {}, {}
local function commBody(n)
local out, alen = {}, string.len(COMM_ALPHABET)
for i = 1, n do
local k = math.fmod(i - 1, alen) + 1
table.insert(out, string.sub(COMM_ALPHABET, k, k))
end
return table.concat(out)
end
-- Logs every arrival as it lands rather than only tallying at the end: if the
-- realm reorders, delays or drops a chunk, the ordering is itself the finding.
local function commOnEvent()
if event ~= "CHAT_MSG_ADDON" or arg1 ~= COMM_PREFIX then return end
local msg, channel, sender = arg2 or "", arg3, arg4
local _, _, tag, body = string.find(msg, "^([^:]+):(.*)$")
if not tag then
D.Log(" [recv] unparseable from " .. tostring(sender) .. " (" .. tostring(channel)
.. "), " .. string.len(msg) .. "B: " .. string.sub(msg, 1, 40))
return
end
local rec = commSent[tag]
local got = string.len(body)
local note
if not rec then
note = "no record of sending this tag"
elseif got ~= rec.bodyLen then
note = "TRUNCATED/PADDED -- sent " .. rec.bodyLen .. "B body, got " .. got .. "B"
elseif body ~= commBody(got) then
note = "CORRUPT -- length right, bytes differ"
else
note = "intact"
rec.ok = true
end
if rec then rec.arrived = true end
D.Log(" [recv] " .. tag .. " from " .. tostring(sender) .. " via " .. tostring(channel)
.. ", msg " .. string.len(msg) .. "B: " .. note)
end
local function commEnsureListener()
if commFrame then return end
commFrame = CreateFrame("Frame")
commFrame:RegisterEvent("CHAT_MSG_ADDON")
commFrame:SetScript("OnEvent", commOnEvent)
end
-- ChatThrottleLib's v13/v14 global hook is `Hook_SendAddonMessage(prefix, text,
-- chattype)` -- three parameters, so it drops the whisper target and the client
-- rejects the call as an unknown chat type. Several addons vendor a copy (aux,
-- DPSMate), and whichever loads last owns the global, so a whisper here has to
-- go to the function CTL saved rather than through the global. Its own v15
-- rewrite neither hooks the global nor keeps that field, hence the fallback.
local function commRawSender()
if ChatThrottleLib and type(ChatThrottleLib.ORIG_SendAddonMessage) == "function" then
return ChatThrottleLib.ORIG_SendAddonMessage, "CTL.ORIG_SendAddonMessage"
end
return SendAddonMessage, "global SendAddonMessage (no ORIG saved)"
end
local function commSend(tag, bodyLen, channel, target, raw)
local msg = tag .. ":" .. commBody(bodyLen)
local rec = { bodyLen = bodyLen, wire = string.len(msg), channel = channel }
commSent[tag] = rec
table.insert(commOrder, tag)
local fn = raw and commRawSender() or SendAddonMessage
local ok, err = pcall(fn, COMM_PREFIX, msg, channel, target)
if not ok then
rec.sendFailed = true
D.Log(" [send] " .. tag .. " (" .. channel .. (raw and ", raw" or ", hooked") .. ", "
.. rec.wire .. "B) ERRORED -- " .. tostring(err))
end
return ok
end
local function commReport(header)
D.Log(header)
for i = 1, #commOrder do
local tag = commOrder[i]
local rec = commSent[tag]
local state
if rec.sendFailed then state = "send errored"
elseif rec.ok then state = "OK"
elseif rec.arrived then state = "arrived MANGLED"
else state = "NEVER ARRIVED" end
D.Log(" " .. tag .. " (" .. rec.channel .. ", " .. rec.wire .. "B on the wire): " .. state)
end
end
-- Our own link type, end to end: rendered into a chat frame, clicked by hand,
-- caught here. Wrap-and-forward because this client's SetItemRef cannot be
-- hooked securely, and because pfUI replaces the same global -- logging the
-- function identity is how the composition question gets answered.
local function commHookSetItemRef()
if commRefHooked then return end
commRefHooked = true
local orig = SetItemRef
SetItemRef = function(link, text, button)
local _, _, sender, name = string.find(link or "", "^weakestauras:([^:]+):(.*)$")
if sender then
D.Log(" [link] SetItemRef REACHED -- sender=\"" .. sender .. "\" name=\"" .. name
.. "\" button=" .. tostring(button))
return
end
if orig then return orig(link, text, button) end
end
end
function D.CommProbe(rest)
local _, _, sub, tail = string.find(rest or "", "^(%S*)%s*(.-)$")
if string.lower(sub or "") == "throttle" then return D.CommThrottle(tail) end
commSent, commOrder = {}, {}
commEnsureListener()
D.Log("--- comm probe ---")
local names = {
"SendAddonMessage", "RegisterAddonMessagePrefix", "SetItemRef",
"ChatFrame_AddMessageEventFilter", "GetCurrentKeyBoardFocus",
}
for i = 1, #names do
D.Log(" [api] " .. names[i] .. " = " .. type(getglobal(names[i])))
end
D.Log(" [api] NUM_CHAT_WINDOWS = " .. tostring(NUM_CHAT_WINDOWS)
.. ", ChatFrameEditBox = " .. type(ChatFrameEditBox)
.. (ChatFrameEditBox and (" (Insert=" .. type(ChatFrameEditBox.Insert)
.. " IsVisible=" .. type(ChatFrameEditBox.IsVisible)
.. " Show=" .. type(ChatFrameEditBox.Show) .. ")") or ""))
D.Log(" [api] pfUI = " .. type(pfUI) .. ", pfUI chat module = "
.. ((pfUI and pfUI.chat) and "loaded" or "not loaded"))
D.Log(" [api] SetItemRef identity before our wrap: " .. tostring(SetItemRef))
-- Whether AddMessage is already someone else's wrapper decides whether the
-- incoming-rewrite hook composes or clobbers.
local shared = 0
for i = 1, (NUM_CHAT_WINDOWS or 0) do
local cf = getglobal("ChatFrame" .. i)
if cf and cf.AddMessage then
if cf.AddMessage ~= ChatFrame1.AddMessage then shared = shared + 1 end
end
end
D.Log(" [api] chat frames with an AddMessage differing from ChatFrame1's: " .. shared
.. " (nonzero means at least one is individually wrapped)")
commHookSetItemRef()
DEFAULT_CHAT_FRAME:AddMessage(
"|Hweakestauras:Probe:Test Aura|h|cff8800ff[Probe - Test Aura]|h|r"
.. " <- CLICK THIS. Coloured and clickable = the link type works.")
D.Log(" [link] wrapper installed, sample line printed to the default chat frame.")
D.Log(" ^^ CLICK IT, and shift-click it too. Then run this again with pfUI's chat module toggled.")
local _, rawWhich = commRawSender()
D.Log(" [ctl] ChatThrottleLib = " .. type(ChatThrottleLib)
.. (ChatThrottleLib and (" v" .. tostring(ChatThrottleLib.version)
.. ", ORIG_SendAddonMessage = " .. type(ChatThrottleLib.ORIG_SendAddonMessage)) or ""))
D.Log(" [ctl] raw whisper attempts go through " .. rawWhich)
-- Sent through the hooked global and through the saved original both, so a
-- refusal separates "this realm has no whisper channel" from "a third-party
-- hook ate the target argument".
local me = UnitName("player")
commSend("S001", 60, "WHISPER", me)
commSend("R001", 60, "WHISPER", me, true)
if tail and tail ~= "" then commSend("R002", 60, "WHISPER", tail, true) end
local inGuild = IsInGuild()
local inParty = GetNumPartyMembers() > 0
commSend("G001", 60, "GUILD")
if inParty then
commSend("P001", 60, "PARTY")
else
D.Log(" [send] PARTY skipped -- not in a party, so silence would prove nothing.")
end
-- The cap is nominally 255 for prefix\tmessage, but nominal is exactly what
-- this is here to check. Ridden over whichever channel is known to deliver,
-- so the answer doesn't depend on the whisper question resolving first.
local ladderChan, ladderTarget, ladderRaw
if inGuild then ladderChan = "GUILD"
elseif inParty then ladderChan = "PARTY"
else ladderChan, ladderTarget, ladderRaw = "WHISPER", me, true end
D.Log(" [send] byte ladder rides " .. ladderChan)
local ladder = { 180, 200, 220, 240, 250 }
for i = 1, #ladder do
local n = ladder[i]
commSend("L" .. n, n - 5, ladderChan, ladderTarget, ladderRaw)
end
D.Log(" [send] " .. #commOrder .. " messages away; tally in 10s.")
C_Timer.After(10, function()
commReport(" --- comm probe tally ---")
D.Log(" S001 dead but R001 alive = a hooked global ate the target, not the realm.")
D.Log(" Both dead = no whisper channel here, and sharing takes the group-channel fallback.")
D.Log(" Then: /wa commprobe throttle [channel] [rate] [seconds]")
D.Log("--- end comm probe ---")
end)
end
-- Deliberately opt-in and separate: this is the one probe that can get the
-- character disconnected for addon spam, so it never runs as part of the sweep
-- above and it starts gentle.
function D.CommThrottle(rest)
local _, _, chan, rateStr, secsStr = string.find(rest or "", "^(%S*)%s*(%S*)%s*(%S*)$")
chan = string.upper(chan or "")
if chan == "" then chan = "WHISPER" end
local rate = tonumber(rateStr) or 4
local secs = tonumber(secsStr) or 5
if rate < 1 then rate = 1 elseif rate > 20 then rate = 20 end
if secs < 1 then secs = 1 elseif secs > 10 then secs = 10 end
local total = rate * secs
commSent, commOrder = {}, {}
commEnsureListener()
D.Log("--- comm throttle: " .. total .. " messages at " .. rate .. "/sec over "
.. secs .. "s on " .. chan .. " ---")
D.Log(" A disconnect here IS the answer. Step the rate up only after a clean run.")
local me = UnitName("player")
local sent = 0
local ticker
ticker = C_Timer.NewTicker(1 / rate, function()
sent = sent + 1
local isWhisper = (chan == "WHISPER")
commSend("T" .. sent, 95, chan, isWhisper and me or nil, isWhisper)
if sent >= total and ticker and ticker.Cancel then ticker:Cancel() end
end)
C_Timer.After(secs + 6, function()
local arrived = 0
for i = 1, #commOrder do
if commSent[commOrder[i]].ok then arrived = arrived + 1 end
end
D.Log(" [throttle] " .. arrived .. " of " .. #commOrder
.. " sent messages came back intact at " .. rate .. "/sec.")
if arrived < #commOrder then
commReport(" --- which ones ---")
end
D.Log("--- end comm throttle ---")
end)
end
-- ---------------------------------------------------------------------------
-- /wa ver [version] -- what this client believes its own version is, and, with
-- an argument, a beacon injected as though a peer had sent it. The update notice
-- is otherwise unreachable without a second account running a build that does
-- not exist yet. The outcome is reported because most claims are *meant* to be
-- ignored -- equal, already beaten, unparseable, or too far ahead to believe.
-- ---------------------------------------------------------------------------
function D.Version(rest)
local C = WA.Comm
D.Log("--- version ---")
D.Log(" GetAddOnMetadata = " .. type(GetAddOnMetadata))
D.Log(" WA.version = " .. tostring(WA.version)
.. " (parses as " .. tostring(WA.ParseVersion(WA.version)) .. ")")
D.Log(" highest claim heard this session = " .. tostring(C.latestSeenVersion))
D.Log(" updateNotify = " .. tostring(WA.Options().updateNotify))
if not rest or rest == "" then
D.Log(" /wa ver <version> feeds a beacon as if a peer had sent it")
D.Log("--- end version ---")
return
end
local before = C.latestSeenVersion
C.FeedBeacon(rest)
if C.latestSeenVersion ~= before then
D.Log(" fed " .. rest .. " -- believed")
elseif not WA.ParseVersion(rest) then
D.Log(" fed " .. rest .. " -- dropped: not x.y or x.y.z with each part under 1000")
else
D.Log(" fed " .. rest .. " -- ignored: not newer than " .. tostring(WA.version)
.. ", not higher than " .. tostring(before) .. ", or more than one major ahead")
end
D.Log("--- end version ---")
end
-- A Trigger State Updater's clones and the hide timers holding them, side by
-- side. The two disagree exactly when a clone is going away for a reason other
-- than its own deadline: a state with no armed record is being removed by
-- something else, and a record whose armedDelay does not match the deadline the
-- aura asked for is the timer being given the wrong length.
function D.Tsu(id)
if not id or id == "" then
D.Log("[tsu] usage: /wa tsu <aura id>")
return
end
local ts = WA.GetDisplayTriggerState and WA.GetDisplayTriggerState(id)
if not ts then
D.Log(string.format("[tsu] no triggerState for %q", id))
return
end
local now = GetTime()
local armed = {}
if WA.ForEachStateTimer then
WA.ForEachStateTimer(id, function(triggernum, cloneId, record)
armed[triggernum .. "\001" .. tostring(cloneId)] = record
end)
end
D.Log(string.format("--- tsu %q at t=%.2f ---", id, now))
for triggernum = 1, ts.numTriggers do
local allstates = ts[triggernum]
if allstates then
for cloneId, state in pairs(allstates) do
local record = armed[triggernum .. "\001" .. tostring(cloneId)]
D.Log(string.format(
" [%d] %q show=%s autoHide=%s paused=%s expiration=%s (in %s) remaining=%s",
triggernum, tostring(cloneId), tostring(state.show), tostring(state.autoHide),
tostring(state.paused), tostring(state.expirationTime),
state.expirationTime and string.format("%.2f", state.expirationTime - now) or "n/a",
tostring(state.remaining)))
if record and record.handle then
D.Log(string.format(
" timer armed %.2f ago for %.2f -> fires in %.2f",
now - (record.armedAt or now), record.armedDelay or -1,
(record.armedAt or now) + (record.armedDelay or 0) - now))
else
D.Log(" timer: NONE ARMED")
end
end
end
end
D.Log("--- end tsu ---")
end
-- Logs every arm/fire/cancel of a state-owned hide timer. A clone vanishing
-- with no "fire" line ahead of it was removed by something other than its own
-- timer, which is the distinction /wa tsu can only sample.
function D.ToggleTsuTrace()
if WA.OnStateTimer then
WA.OnStateTimer = nil
D.Log("[tsu] timer trace off")
return
end
WA.OnStateTimer = function(what, id, triggernum, cloneId, seconds)
D.Log(string.format("[tsu] %s %s[%s] clone %q %.2fs (t=%.2f)",
what, tostring(id), tostring(triggernum), tostring(cloneId),
tonumber(seconds) or -1, GetTime()))
-- A cancel is the interesting one: it means something took the timer away
-- rather than the deadline arriving, and the only thing that identifies
-- *what* is the caller. Guarded because the debug library's presence on
-- this client is not something to assume.
if what == "cancel" and debug and debug.traceback then
local ok, trace = pcall(debug.traceback, "", 2)
if ok and trace then
local n = 0
for line in string.gmatch(trace, "[^\n]+") do
n = n + 1
if n > 6 then break end
D.Log(" " .. line)
end
end
end
end
D.Log("[tsu] timer trace on -- /wa tsutrace again to stop")
end
-- ---------------------------------------------------------------------------
-- /wa threat -- Turtle's server-side threat query, watched and driven by hand.
--
-- The query is an addon-message RPC: SendAddonMessage("TWT_UDTSv4"[.."_TM"],
-- "limit=N", "RAID"/"PARTY"), which the server answers with a CHAT_MSG_ADDON
-- carrying "TWTv4=name:tank:threat:perc:melee;...". A reply reaches only the
-- client that asked, but every addon in that client's process sees it, so a
-- reply nothing of ours asked for is proof that something else here is querying
-- -- the attribution below is what turns that into a usable signal.
--
-- Nothing here sends unless a subcommand says to. The dump, the attribution and
-- the target log are passive, which is what makes it safe to leave running.
-- ---------------------------------------------------------------------------
local THREAT_QUERY_PREFIX = "TWT_UDTSv4"
local THREAT_REPLY_MARKER = "TWTv4="
-- A reply arriving later than this is not attributable to a request of ours.
-- Requests are claimed oldest-first and expire out of the window rather than
-- being tracked by a single "awaiting ours" flag: a request the server never
-- answers would otherwise consume every later reply forever, and the probe
-- would report "nobody else is asking" exactly when somebody is.
local THREAT_REPLY_WINDOW = 3.0
local threatProbe = { on = false, sent = {},
requests = 0, replies = 0, ours = 0, foreign = 0, queries = 0, started = 0 }
local function threatChannel(want)
if want and want ~= "" then return string.upper(want) end
if IsInRaid() then return "RAID" end
if IsInGroup() then return "PARTY" end
return nil
end
local function threatTargetLabel()
if not UnitExists("target") then return "none" end
-- The GUID's tail, because "Blackrock Sentry" is four mobs in a pack and the
-- question a run of unanswered queries raises first is whether they were even
-- about the same creature.
local guid = UnitGUID("target")
return "\"" .. tostring(UnitName("target")) .. "\""
.. " " .. tostring(UnitClassification("target"))
.. (guid and ("#" .. string.sub(guid, -8)) or "")
.. (UnitIsPlayer("target") and " PLAYER" or "")
.. (UnitIsDead("target") and " DEAD" or "")
.. (UnitAffectingCombat("target") and " incombat" or "")
end
local function threatStamp()
return string.format("%7.2f", GetTime() - threatProbe.started)
end
-- A request that is never answered is otherwise only an absence in the log, and
-- an absence is the one thing a reader skips.
local function threatTrack()
threatProbe.requests = threatProbe.requests + 1
local req = { at = GetTime(), n = threatProbe.requests }
table.insert(threatProbe.sent, req)
C_Timer.After(THREAT_REPLY_WINDOW + 0.1, function()
if req.claimed then return end
D.Log(" [twt] " .. threatStamp() .. " NO REPLY to #" .. req.n
.. " after " .. THREAT_REPLY_WINDOW .. "s")
end)
return req.n
end
local function threatSend(limit, channel, tankMode)
local chan = threatChannel(channel)
if not chan then
D.Log(" [twt] " .. threatStamp() .. " NOT SENT -- no party or raid, and SendAddonMessage"
.. " needs a channel. \"/wa threat send 5 GUILD\" forces one.")
return false
end
local prefix = THREAT_QUERY_PREFIX .. (tankMode and "_TM" or "")
local msg = "limit=" .. limit
D.Log(" [twt] " .. threatStamp() .. " SEND #" .. threatTrack() .. " " .. prefix
.. " \"" .. msg .. "\" -> " .. chan .. " target " .. threatTargetLabel())
-- The global rather than Comm.lua's route: ChatThrottleLib would pace the
-- send, and a request whose timestamp is not the moment it left the client
-- measures the wrong latency. Its v13/v14 hook takes exactly these three
-- parameters, so nothing is dropped here the way a whisper's target would be.
local ok, err = pcall(SendAddonMessage, prefix, msg, chan)
if not ok then D.Log(" [twt] send ERRORED -- " .. tostring(err)) end
return ok
end
-- Every query the engine makes, typed or automatic, lands here through
-- WA.OnThreatQuery -- so a request counts against the same attribution the
-- probe's own sends do, and its reply reads OURS rather than as somebody else's.
-- Refusals repeat once per target change, so an unchanged reason is logged at
-- most every few seconds; walking around out of combat would otherwise bury the
-- log in "nothing is in combat".
local function threatOnQuery(info, why)
if why then
local now = GetTime()
if why == threatProbe.lastRefusal and now - (threatProbe.lastRefusalAt or 0) < 5 then
return
end
threatProbe.lastRefusal, threatProbe.lastRefusalAt = why, now
D.Log(" [twt] " .. threatStamp() .. " gate REFUSED -- " .. why)
return
end
threatProbe.lastRefusal = nil
D.Log(" [twt] " .. threatStamp() .. " ENGINE SEND #" .. threatTrack()
.. " " .. tostring(info.prefix) .. " \"" .. tostring(info.msg) .. "\" -> "
.. tostring(info.channel) .. " target " .. threatTargetLabel())
end
-- Oldest-first, expiring. Returns the request this reply answers, or nil for a
-- reply nothing of ours asked for.
local function threatClaim(now)
while true do
local req = threatProbe.sent[1]
if not req then return nil end
table.remove(threatProbe.sent, 1)
if now - req.at <= THREAT_REPLY_WINDOW then
req.claimed = true
return req
end
end
end
-- The reply's own `perc` beside the aggro race the watcher computes from raw
-- threat, which is the only way to read what `perc` is a percentage of. Reads
-- WA.ThreatInfo() rather than re-parsing: WA.WatchThreat registers its frame
-- before this one does, and 1.12 dispatches an event in registration order, so
-- the digest is already the one this packet produced.
local function threatDigestLine()
local ts = WA.ThreatInfo and WA.ThreatInfo()
if not (ts and ts.exists) then return " digest: none (no row for the player)" end
return " digest: rows=" .. tostring(ts.threatcount)
.. " me threat=" .. tostring(ts.threat) .. " twt-perc=" .. tostring(ts.threatpct)
.. " | computed pullPct=" .. tostring(ts.pullPct) .. " pullGap=" .. tostring(ts.pullGap)
.. " rival=" .. tostring(ts.rivalName) .. " tanking=" .. tostring(ts.isTanking)
end
local function threatOnEvent()
if event == "PLAYER_TARGET_CHANGED" then
D.Log(" [twt] " .. threatStamp() .. " target -> " .. threatTargetLabel())
return
end
if event ~= "CHAT_MSG_ADDON" then return end
local prefix, body, chan, sender = arg1, arg2, arg3, arg4
-- The reply carries the *server's* prefix, which is not known to differ from
-- the one the request goes out on, so the body is the only honest
-- discriminator: a reply carries the TWTv4 marker, a request "limit=N".
local isReply = body and string.find(body, THREAT_REPLY_MARKER, 1, true)
local isQuery = not isReply and prefix and string.find(prefix, THREAT_QUERY_PREFIX, 1, true)
-- Everything TWThreat-shaped, not only the replies: a request seen here is a
-- request the server relayed to the channel rather than swallowing (Q4).
if not isReply and not isQuery and not (prefix and string.find(prefix, "TWT", 1, true)) then
return
end
local head = " [twt] " .. threatStamp() .. " RECV arg1=\"" .. tostring(prefix)
.. "\" arg3=\"" .. tostring(chan) .. "\" arg4=\"" .. tostring(sender)
.. "\" " .. string.len(body or "") .. "B"
if isQuery then
threatProbe.queries = threatProbe.queries + 1
D.Log(head .. " <- A QUERY, not a reply: the server relays requests to the channel.")
D.Log(" body: " .. tostring(body))
return
end
if not isReply then
D.Log(head .. " <- TWThreat's own addon traffic, not the server's reply.")
return
end
threatProbe.replies = threatProbe.replies + 1
local now = GetTime()
local req = threatClaim(now)
if req then
threatProbe.ours = threatProbe.ours + 1
D.Log(head .. string.format(" <- OURS, %.2fs after #%d", now - req.at, req.n))
else
threatProbe.foreign = threatProbe.foreign + 1
D.Log(head .. " <- UNSOLICITED: nothing of ours asked inside "
.. THREAT_REPLY_WINDOW .. "s")
end
D.Log(" body: " .. tostring(body))
D.Log(threatDigestLine())
end
local function threatEnv()
D.Log(" [env] SendAddonMessage = " .. type(SendAddonMessage)
.. ", ChatThrottleLib = " .. type(ChatThrottleLib)
.. (ChatThrottleLib and (" v" .. tostring(ChatThrottleLib.version)) or ""))
D.Log(" [env] raid " .. GetNumRaidMembers()
.. ", party " .. GetNumPartyMembers()
.. " -> channel " .. tostring(threatChannel()))
local loaded = function(name) return IsAddOnLoaded(name) and true or false end
D.Log(" [env] TWThreat loaded = " .. tostring(loaded("TWThreat")) .. ", TWT = " .. type(TWT)
.. (TWT_CONFIG and (", tankMode = " .. tostring(TWT_CONFIG.tankMode)
.. ", anyMob = " .. tostring(TWT_CONFIG.anyMob)) or ", no TWT_CONFIG"))
D.Log(" [env] other readers: BigWigs = " .. tostring(loaded("BigWigs"))
.. ", SuperCleveRoidMacros = " .. tostring(loaded("SuperCleveRoidMacros")))
D.Log(" [env] target " .. threatTargetLabel()
.. ", creatureType " .. tostring(UnitCreatureType("target"))
.. ", guid " .. tostring(UnitGUID("target")))
D.Log(" [env] player in combat = " .. tostring(UnitAffectingCombat("player") and true or false))
end
local function threatStart()
if threatProbe.on then return end
threatProbe.on = true
threatProbe.started = GetTime()
-- Stood up first so its CHAT_MSG_ADDON handler runs ahead of the probe's and
-- the digest line describes the packet just logged.
if WA.WatchThreat then WA.WatchThreat() end
WA.OnThreatQuery = function(info, why)
WA.safecall("Debug.threatQuery", threatOnQuery, info, why)
end
if not threatProbe.frame then
threatProbe.frame = CreateFrame("Frame")
threatProbe.frame:SetScript("OnEvent", function()
WA.safecall("Debug.threat", threatOnEvent)
end)
end
-- Outside the create guard: stopping unregisters, and the frame it
-- unregistered is the one a restart gets handed back.
threatProbe.frame:RegisterEvent("CHAT_MSG_ADDON")
threatProbe.frame:RegisterEvent("PLAYER_TARGET_CHANGED")
D.Log("--- threat probe on (passive; nothing is sent until you say so) ---")
end
local function threatReport()
D.Log(" [twt] tally: " .. threatProbe.requests .. " request(s) of ours, "
.. threatProbe.replies .. " reply packet(s) -- " .. threatProbe.ours
.. " answering ours, " .. threatProbe.foreign .. " UNSOLICITED, "
.. threatProbe.queries .. " request(s) of somebody's seen on the wire")
D.Log(" [twt] Q1 UNSOLICITED > 0 while TWThreat is off HERE = the server answers the")
D.Log(" whole channel, and a reply cannot prove who asked.")
D.Log(" [twt] Q4 \"request(s) seen on the wire\" > 0 = the server relays requests too.")
end
local function threatHelp()
D.Log(" [twt] /wa threat send [limit] [channel] -- one query (default limit 5, channel auto)")
D.Log(" [twt] /wa threat query [limit] -- one query through the engine's own gate")
D.Log(" [twt] /wa threat tm [limit] -- the same with TWThreat's _TM tank-mode prefix")
D.Log(" [twt] /wa threat report -- the tally so far")
D.Log(" [twt] /wa threat off -- stop listening")
end
function D.Threat(rest)
local _, _, sub, tail = string.find(rest or "", "^(%S*)%s*(.-)$")
sub = string.lower(sub or "")
if sub == "off" then
threatProbe.on = false
WA.OnThreatQuery = nil
if threatProbe.frame then threatProbe.frame:UnregisterAllEvents() end
threatReport()
D.Log("--- threat probe off ---")
return
end
threatStart()
local _, _, limitArg, chanArg = string.find(tail or "", "^(%S*)%s*(%S*)$")
local limit = tonumber(limitArg) or 5
if sub == "" then
threatEnv()
threatHelp()
elseif sub == "send" then
threatSend(limit, chanArg)
elseif sub == "tm" then
threatSend(limit, chanArg, true)
elseif sub == "query" then
WA.SendThreatQuery(tonumber(limitArg))
elseif sub == "report" then
threatReport()
else
D.Log(" [twt] unknown \"" .. sub .. "\".")
threatHelp()
end
end
-- ---------------------------------------------------------------------------
-- Slash dispatch. Called from OptionsFrame.lua's /wa handler whenever the
-- command has an argument; a bare /wa still opens the options window.
-- ---------------------------------------------------------------------------
function D.HandleSlash(msg)
local _, _, cmd, rest = string.find(msg, "^(%S+)%s*(.-)$")
cmd = string.lower(cmd or "")
if cmd == "dump" then
local _, _, unit, filter = string.find(rest, "^(%S*)%s*(%S*)$")
D.Dump(unit, filter)
elseif cmd == "watch" then
D.ToggleWatch(rest)
elseif cmd == "events" then
D.ToggleEventLog(rest)
elseif cmd == "linkprobe" then
D.LinkProbe()
elseif cmd == "commprobe" then
D.CommProbe(rest)
elseif cmd == "threat" then
D.Threat(rest)
elseif cmd == "swipestress" then
D.SwipeStress(rest)
elseif cmd == "swipelog" then
D.SwipeLog(rest)
elseif cmd == "track" then
D.Track(rest)
elseif cmd == "states" then
D.States(rest)
elseif cmd == "tsu" then
D.Tsu(rest)
elseif cmd == "tsutrace" then
D.ToggleTsuTrace()
elseif cmd == "conditions" then
D.Conditions(rest)
elseif cmd == "gen" then
D.Gen(rest)
elseif cmd == "load" then
D.Load(rest)
elseif cmd == "probe" then
D.Probe()
elseif cmd == "gcd" then
D.Gcd()
elseif cmd == "cdprobe" then
D.CdProbe(rest)
elseif cmd == "ver" then
D.Version(rest)
elseif cmd == "codeprobe" then
D.CodeProbe()
elseif cmd == "textprobe" then
D.TextProbe()
elseif cmd == "csprobe" then
D.ColorSelectProbe()
elseif cmd == "plateprobe" then
D.PlateProbe()
elseif cmd == "texprobe" then
if string.lower(rest or "") == "corners" then D.TexCornerProbe() else D.TexProbe() end
elseif cmd == "progtex" then
D.ProgTex(rest)
elseif cmd == "levelprobe" then
D.LevelProbe()
elseif cmd == "wa2probe" then
D.Wa2Probe()
elseif cmd == "wa2" then
D.Wa2(rest)
elseif cmd == "codelive" then
D.CodeLive()
elseif cmd == "codetab" then
D.CodeTab(rest)
elseif cmd == "codefont" then
D.CodeFont(rest)
elseif cmd == "rows" then
D.Rows()
elseif cmd == "regions" then
D.Regions()
elseif cmd == "configtest" then
D.ConfigTest()
elseif cmd == "prof" then
WA.Profiling.Command(rest)
elseif cmd == "libs" then
D.Libs()
elseif cmd == "addons" then
D.Addons()
elseif cmd == "export" then
if WA.ShowExport then WA.ShowExport(rest) end
elseif cmd == "import" then
if WA.ShowImport then WA.ShowImport() end
elseif cmd == "clear" then
D.Clear()
elseif cmd == "show" then
D.Show()
elseif cmd == "hide" then
ensureFrame()
frame:Hide()
else
D.Log("[debug] unknown command \"" .. cmd .. "\". Available: dump [unit] [filter], watch [unit], events [EVENT ...], linkprobe, commprobe [charname|throttle [channel] [rate] [secs]], threat [send|query|tm|report|off], swipelog [secs], swipestress [N], track <spellName>, states <id>, conditions <id>, gen <id>, load <id>, probe, gcd, cdprobe <spell>, ver [version], codeprobe, textprobe, csprobe, texprobe [corners], progtex [id], levelprobe, plateprobe, wa2probe, wa2 <string>, codelive, codetab <1-8|tabs>, codefont <6-16>, rows, regions, configtest, prof [start|stop|report [n]|reset], libs, addons, export <id>, import, clear, show, hide")
end
end