Files
DopingControl/scan/addons.lua
T
2026-10-18 13:41:37 +02:00

1208 lines
40 KiB
Lua

-- DopingControl scan/addons.lua
-- Addons tab backend: finds out which raid addons the OTHER raiders run.
-- They do not run DopingControl, so detection rides only on what their own
-- addons send or answer:
-- BigWigs prefix "BigWigs" (RAID): query "BWVQ BigWigs"; replies
-- "PEPO_BWVR <rev> <nick>", "BWVR <rev> <nick> <Fork>", the
-- old "MC:REV" style or a bare number. Any other BigWigs sync
-- proves presence without a version.
-- Threat prefix "TWT": query "TWT_WHO"; replies "TWT_ME:<ver>";
-- passive "TWTVersion:<ver>" at login. (Server threat packets
-- use the prefix "TWT " with a trailing space - a different
-- prefix, ignored here.)
-- EPGP shootyepgp, prefix "SEPGP_PREFIX" (GUILD): "VERSION;<ver>;<n>"
-- at the sender's login only; no query answer.
-- SuperWoW prefix "SWOW_Q" (GUILD) is answered by shootyepgp on prefix
-- "SWOW_R" with "1" (SuperWoW present) or "0". A reply also
-- proves shootyepgp itself is installed.
-- Rinse prefix "Rinse": query "REPORT_ADDON_VERSION", reply
-- "V_<ver>" (only the UpsiRinse fork answers).
-- RABuffs passive only. Every client in a raid broadcasts on prefix
-- "RAB/BT" (RAID) every 30 s: "<key> <number>" pairs ("h2oc 20
-- fort 1500 ..."), but only when it has something to report
-- (mana users, tracked timed buffs). The version messages
-- "RAB/V" ("<ver> <deci>") and "RAB/VC" are disabled upstream;
-- parsed if they ever arrive, never sent. Its own version check
-- runs over visible chat and is never used. Silence is weak
-- evidence: "not seen" only after RAB_SILENT seconds in the raid.
-- BCS BetterCharacterStats, passive only. Its sole traffic is prefix
-- "bcs" on PARTY: "TREE,<name>[,<bonus>]", sent by druids in
-- Tree of Life form. Too rare to prove anything: without a
-- responder answer (below) a member is unknown, never missing.
-- ADDONCHECK the shootyepgp responder. One query "Q1:<id>:<folders>" on
-- the group channel, every guild member's shootyepgp answers
-- "A1:<id>:<Folder>=L/<ver>;<Folder>=D;<Folder>=M" (loaded /
-- installed but disabled / not installed), split over several
-- messages when long. That is certain evidence per folder and
-- beats every passive rule. Older shootyepgp versions and
-- non-guildmates stay silent: silence is never red for BCS/RAB.
-- Honest-unknown rule: silence is only "no reply" after a query window has
-- passed for a member who was in the group when the query went out;
-- before that the cell is unknown, and members who cannot answer (not in
-- the guild, a class that cannot dispel) are n/a.
-- The parsers, the evaluation and the query state machine are pure Lua 5.0
-- (dofile-loadable offline); the WoW wiring sits behind "if CreateFrame".
DopingControl = DopingControl or {}
local DC = DopingControl
DC_Addons = DC_Addons or {}
local A = DC_Addons
A.WINDOW = 6 -- seconds a query waits for replies
A.AUTO_THROTTLE = 30 -- seconds between automatic queries
A.MAX_NAMES = 400 -- passive-store cap (guild chatter from non-raiders)
A.RAB_SILENT = 70 -- seconds in the raid before RABuffs silence counts
-- (two 30 s broadcast cycles plus margin)
-- slot ids (match DC.SLOTS_ADDONS)
A.SLOT_IDS = { "BIGWIGS", "TWT", "EPGP", "SUPERWOW", "RINSE", "RABUFFS", "BCS" }
A.DISPELLERS = {
DRUID = true, PRIEST = true, PALADIN = true, SHAMAN = true, MAGE = true,
}
-- prefix -> slot (hash lookup = the whole cost of an unrelated message)
local PREFIX_SLOT = {
["BigWigs"] = "BIGWIGS",
["TWT"] = "TWT",
["Rinse"] = "RINSE",
["SEPGP_PREFIX"] = "EPGP",
["SWOW_R"] = "SUPERWOW",
["RAB/BT"] = "RABUFFS",
["RAB/V"] = "RABUFFS",
["RAB/VC"] = "RABUFFS",
["bcs"] = "BCS",
["ADDONCHECK"] = "CHECK", -- responder answers, own parser
}
A.PREFIXES = PREFIX_SLOT
-- ADDONCHECK protocol: folder name (as on the client) -> column
A.CHECK_PREFIX = "ADDONCHECK"
A.CHECK_NAMES = { "BigWigs", "TWThreat", "shootyepgp", "UpsiRinse", "Rinse",
"RABuffs", "BetterCharacterStats" }
local CHECK_SLOT = {
BigWigs = "BIGWIGS", TWThreat = "TWT", shootyepgp = "EPGP",
UpsiRinse = "RINSE", Rinse = "RINSE", RABuffs = "RABUFFS",
BetterCharacterStats = "BCS",
}
local CHECK_CHANNEL = { RAID = true, PARTY = true, GUILD = true, BATTLEGROUND = true }
-- passive store: A.data[name][slot] = entry
A.data = {}
A.dataCount = 0
-- roster time: A.since[name] = when the name was first seen in the group
-- (feeds the RABuffs "not seen" rule)
A.since = {}
-- responder store: A.resp[name][folder] = { s = "L"|"D"|"M", ver = text|nil }
-- (evicted with the roster in Prune, capped like the passive store)
A.resp = {}
A.respCount = 0
A.checkId = nil -- id of the ADDONCHECK query in flight (answers to any
-- other id are ignored)
A.checkSeq = 0 -- survives Reset so ids never repeat inside a session
-- query state
A.query = { at = nil, sent = {}, roster = {} }
A.lastStatus = nil -- last StartQuery verdict text (shown next to the button)
-- ------------------------------------------------------------------
-- text + version helpers
-- ------------------------------------------------------------------
-- Reply text from other people goes into UI strings: drop colour escapes,
-- trailing "<...>" decorations, control characters, and cap the length.
function A.Clean(s)
if type(s) ~= "string" then
return nil
end
s = string.gsub(s, "|", "")
s = string.gsub(s, "[%c]", "")
s = string.gsub(s, "%s*<.*>%s*$", "")
s = string.gsub(s, "^%s+", "")
s = string.gsub(s, "%s+$", "")
if string.len(s) > 24 then
s = string.sub(s, 1, 24)
end
if s == "" then
return nil
end
return s
end
-- "r4521" / "v1.2.3" / "1.10.0" -> { 4521 } / { 1, 2, 3 } / { 1, 10, 0 };
-- anything else -> nil (shown raw, never compared).
function A.Segments(text)
if type(text) ~= "string" then
return nil
end
local s = string.gsub(text, "^[rRvV]", "")
if not string.find(s, "^%d+$") and not string.find(s, "^%d+%.[%d%.]*%d$") then
return nil
end
if string.find(s, "%.%.") then
return nil
end
local out = {}
for d in string.gfind(s, "(%d+)") do
table.insert(out, tonumber(d))
end
return out
end
-- numeric segment compare, missing segments count as 0: -1 / 0 / 1
function A.Compare(a, b)
local na, nb = table.getn(a), table.getn(b)
local n = na
if nb > n then
n = nb
end
for i = 1, n do
local x, y = a[i] or 0, b[i] or 0
if x < y then
return -1
elseif x > y then
return 1
end
end
return 0
end
local function entry(text, variant, raw, channel)
return {
text = text,
ver = A.Segments(text),
variant = variant,
raw = raw,
}
end
-- ------------------------------------------------------------------
-- reply parsers. Each returns an entry table or nil (not a reply);
-- a second return value "query" marks the query messages themselves.
-- ------------------------------------------------------------------
function A.ParseBigWigs(msg)
if type(msg) ~= "string" then
return nil
end
local _, _, sync, rest = string.find(msg, "^(%S+)%s*(.*)$")
if not sync then
return nil
end
if sync == "BWVQ" then
return nil, "query"
end
if not string.find(sync, "BWVR") then
-- some other BigWigs sync (boss engaged, ...): presence, no version
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
local rev, fork
if string.find(rest, ":") then
-- old style "MC:1234 BWL:1234": the highest number is the revision
for d in string.gfind(rest, "(%d+)") do
local n = tonumber(d)
if rev == nil or n > rev then
rev = n
end
end
fork = "Legacy"
elseif tonumber(rest) then
rev = tonumber(rest)
fork = "Legacy"
else
local f1, f2, f3
local _, _, a, b, c = string.find(rest, "^(%S*)%s*(%S*)%s*(%S*)")
f1, f2, f3 = a, b, c
rev = tonumber(f1)
if f3 and f3 ~= "" then
fork = A.Clean(f3)
elseif sync == "PEPO_BWVR" then
fork = "Pepo"
else
fork = "unknown"
end
end
if rev == nil or rev < 0 then
-- reply without a usable revision (zone unknown): BigWigs is there
local e = entry("yes", fork, msg)
e.presenceOnly = true
return e
end
return entry("r" .. rev, fork, msg)
end
function A.ParseTWT(msg)
if type(msg) ~= "string" then
return nil
end
if string.find(msg, "^TWT_WHO") then
return nil, "query"
end
local _, _, v = string.find(msg, "^TWT_ME:(.+)$")
if not v then
_, _, v = string.find(msg, "^TWTVersion:(.+)$")
end
v = A.Clean(v)
if not v then
return nil
end
return entry(v, nil, msg)
end
function A.ParseRinse(msg)
if type(msg) ~= "string" then
return nil
end
if msg == "REPORT_ADDON_VERSION" then
return nil, "query"
end
local _, _, v = string.find(msg, "^V_(.+)$")
v = A.Clean(v)
if not v then
return nil
end
return entry(v, nil, msg)
end
function A.ParseEPGP(msg)
if type(msg) ~= "string" then
return nil
end
local _, _, v = string.find(msg, "^VERSION;([^;]+)")
v = A.Clean(v)
if v then
return entry(v, nil, msg)
end
if string.find(msg, ";") then
-- any other shootyepgp traffic (EP awards, ...) proves it runs
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
return nil
end
-- "1" -> present, "0" -> absent; anything else is not an answer
function A.ParseSwow(msg)
if msg == "1" then
local e = entry("yes", nil, msg)
e.flag = true
return e
elseif msg == "0" then
local e = entry("no", nil, msg)
e.flag = false
return e
end
return nil
end
-- RABuffs. "RAB/V" payload "<ver> <deci>" (pattern RABuffs itself uses)
-- gives the version; a "RAB/BT" payload is "<key> <number>" pairs and only
-- proves presence. Anything else is ignored.
function A.ParseRABuffs(msg)
if type(msg) ~= "string" then
return nil
end
local _, _, v = string.find(msg, "^([%w.]+) %d+[.,]%d+%s*$")
v = A.Clean(v)
if v then
return entry(v, nil, msg)
end
if not string.find(msg, "^%a%w+ %d+") then
return nil
end
-- every byte must belong to a pair, else it is not a broadcast
local rest = string.gsub(msg, "%a%w+ %d+", "")
if string.find(rest, "%S") then
return nil
end
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
-- BetterCharacterStats: only "TREE,<name>[,<bonus>]" exists
function A.ParseBCS(msg)
if type(msg) ~= "string" or not string.find(msg, "^TREE,") then
return nil
end
local e = entry("yes", nil, msg)
e.presenceOnly = true
return e
end
local PARSER = {
BIGWIGS = A.ParseBigWigs,
TWT = A.ParseTWT,
RINSE = A.ParseRinse,
EPGP = A.ParseEPGP,
SUPERWOW = A.ParseSwow,
RABUFFS = A.ParseRABuffs,
BCS = A.ParseBCS,
}
-- ------------------------------------------------------------------
-- passive store
-- ------------------------------------------------------------------
local function put(name, slot, e, now)
local byName = A.data[name]
if not byName then
if A.dataCount >= A.MAX_NAMES then
A.data = {}
A.dataCount = 0
end
byName = {}
A.data[name] = byName
A.dataCount = A.dataCount + 1
end
local old = byName[slot]
-- a presence-only hint never replaces a real version
if e.presenceOnly and old and not old.presenceOnly then
return false
end
e.at = now
byName[slot] = e
return true
end
-- OnAddonMessage(prefix, msg, channel, sender, now) -> changed (bool)
-- Early return on an unrelated prefix: one table lookup, no allocation.
function A.OnAddonMessage(prefix, msg, channel, sender, now)
local slot = PREFIX_SLOT[prefix]
if not slot or type(sender) ~= "string" or sender == A.own then
return false
end
if slot == "CHECK" then
return A.OnCheckAnswer(msg, channel, sender, now)
end
-- BigWigs itself only honours its syncs on RAID
if slot == "BIGWIGS" and channel ~= "RAID" then
return false
end
local e = PARSER[slot](msg)
if not e then
return false
end
e.channel = channel
local changed = put(sender, slot, e, now)
if slot == "SUPERWOW" then
-- the responder lives inside shootyepgp: an answer proves it
local p = entry("yes", nil, msg)
p.presenceOnly = true
p.channel = channel
if put(sender, "EPGP", p, now) then
changed = true
end
end
return changed
end
-- Build the query text for a fresh id (alphanumeric only) and remember it.
function A.NewCheckQuery(now)
A.checkSeq = A.checkSeq + 1
local id = "d" .. string.format("%x", math.mod(math.floor((now or 0) * 1000), 1048576))
.. "x" .. string.format("%x", A.checkSeq)
A.checkId = id
return "Q1:" .. id .. ":" .. table.concat(A.CHECK_NAMES, ",")
end
-- Parse "A1:<id>:<Folder>=L/<ver>;<Folder>=D;..." -> id, list of
-- { folder, s, ver } or nil. Strict: one malformed part drops the message.
-- Folders we never asked about are skipped.
function A.ParseCheckAnswer(msg)
if type(msg) ~= "string" or string.len(msg) > 255 then
return nil
end
local _, _, id, list = string.find(msg, "^A1:(%w+):(.+)$")
if not id then
return nil
end
local out = {}
for part in string.gfind(list, "([^;]+)") do
local _, _, folder, st, rest = string.find(part, "^([%w_%-%.]+)=([LDM])(.*)$")
if not folder then
return nil
end
local ver
if st == "L" then
local _, _, v = string.find(rest, "^/(.*)$")
if not v then
return nil
end
v = A.Clean(v)
if v and v ~= "?" then
ver = v
end
elseif rest ~= "" then
return nil
end
if CHECK_SLOT[folder] then
table.insert(out, { folder = folder, s = st, ver = ver })
end
end
return id, out
end
-- responder answer -> store. Returns changed (bool).
function A.OnCheckAnswer(msg, channel, sender, now)
if not CHECK_CHANNEL[channel] or not A.checkId then
return false
end
local id, list = A.ParseCheckAnswer(msg)
if not id or id ~= A.checkId or table.getn(list) == 0 then
return false
end
local byName = A.resp[sender]
if not byName then
if A.respCount >= A.MAX_NAMES then
A.resp = {}
A.respCount = 0
end
byName = {}
A.resp[sender] = byName
A.respCount = A.respCount + 1
end
for i = 1, table.getn(list) do
local r = list[i]
byName[r.folder] = { s = r.s, ver = r.ver, at = now }
end
return true
end
-- Responder verdict for one cell: nil (no answer) | { present = entry } |
-- { missing = why }. Rinse counts present when either fork is loaded and
-- missing only when both forks answered D/M.
function A.ResponderState(name, slot)
local byName = A.resp[name]
if not byName then
return nil
end
local want, got, disabled = 0, 0, false
for i = 1, table.getn(A.CHECK_NAMES) do
local folder = A.CHECK_NAMES[i]
if CHECK_SLOT[folder] == slot then
want = want + 1
local r = byName[folder]
if r then
got = got + 1
if r.s == "L" then
local e = entry(r.ver or "yes", nil, "addoncheck " .. folder .. "=L")
if not r.ver then
e.presenceOnly = true
end
e.at = r.at
e.responder = true
return { present = e }
elseif r.s == "D" then
disabled = true
end
end
end
end
if want == 0 or got < want then
return nil
end
return { missing = disabled and "installed but disabled (shootyepgp addon check)"
or "not installed (shootyepgp addon check)" }
end
-- drop everybody not in the given name set (members who left)
function A.Prune(nameSet)
for name in pairs(A.data) do
if not nameSet[name] then
A.data[name] = nil
A.dataCount = A.dataCount - 1
end
end
for name in pairs(A.resp) do
if not nameSet[name] then
A.resp[name] = nil
A.respCount = A.respCount - 1
end
end
end
-- Remember when each roster name first appeared; forget names that left.
-- names = array of member names.
function A.NoteRoster(names, now)
local set = {}
for i = 1, table.getn(names) do
local nm = names[i]
set[nm] = true
if not A.since[nm] then
A.since[nm] = now
end
end
for nm in pairs(A.since) do
if not set[nm] then
A.since[nm] = nil
end
end
end
function A.Reset()
A.data = {}
A.dataCount = 0
A.since = {}
A.resp = {}
A.respCount = 0
A.checkId = nil
A.query = { at = nil, sent = {}, roster = {} }
A.lastStatus = nil
end
-- ------------------------------------------------------------------
-- query
-- env = { now, inCombat, raid, party, guild, send(prefix, msg, channel),
-- roster = array of names }
-- StartQuery(env, auto) -> started (bool), reason (string|nil)
-- ------------------------------------------------------------------
function A.StartQuery(env, auto)
local q = A.query
if env.inCombat then
A.lastStatus = "not in combat"
return false, "in combat"
end
if q.at and env.now - q.at < A.WINDOW then
return false, "query running"
end
if auto and q.at and env.now - q.at < A.AUTO_THROTTLE then
return false, "throttled"
end
if not env.raid and not env.party then
A.lastStatus = "not in a group"
return false, "no group"
end
local send = env.send
local chan = env.raid and "RAID" or "PARTY"
local sent = {}
if env.raid then
pcall(send, "BigWigs", "BWVQ BigWigs", "RAID")
sent.BIGWIGS = true
end
pcall(send, "TWT", "TWT_WHO", chan)
sent.TWT = true
pcall(send, "Rinse", "REPORT_ADDON_VERSION", chan)
sent.RINSE = true
-- one responder query on the same group channel (shootyepgp answers)
pcall(send, A.CHECK_PREFIX, A.NewCheckQuery(env.now), chan)
if env.guild then
pcall(send, "SWOW_Q", "1", "GUILD")
sent.SUPERWOW = true
sent.EPGP = true
end
local roster = {}
local names = env.roster or {}
for i = 1, table.getn(names) do
roster[names[i]] = true
end
A.query = { at = env.now, sent = sent, roster = roster }
A.lastStatus = nil
return true
end
-- AutoCheck: the throttled variant used when the tab is opened
function A.AutoCheck(env)
return A.StartQuery(env, true)
end
function A.IsSilent(name, slot, now)
local q = A.query
if not q.at or not q.sent[slot] or not q.roster[name] then
return false
end
return now >= q.at + A.WINDOW
end
-- ------------------------------------------------------------------
-- own row: local knowledge instead of replies (several addons never
-- answer their own sender).
-- g = { loaded = function(name) -> bool, meta = function(addon, field),
-- bigwigsRev = number|nil, twtVer = string|nil, superwow = any }
-- returns slot -> entry | false (known not installed)
-- ------------------------------------------------------------------
function A.LocalEntries(g)
local out = {}
local function mk(text, variant)
local e = entry(A.Clean(text) or "yes", variant, "local")
e.islocal = true
return e
end
-- BigWigs
if g.loaded("BigWigs") then
local rev = g.bigwigsRev or tonumber(g.meta("BigWigs", "X-Revision"))
local fork = A.Clean(g.meta("BigWigs", "X-Fork"))
if rev and rev > 0 then
out.BIGWIGS = mk("r" .. rev, fork or "unknown")
else
out.BIGWIGS = mk("yes", fork or "unknown")
out.BIGWIGS.presenceOnly = true
end
else
out.BIGWIGS = false
end
-- threat meter
if g.twtVer then
out.TWT = mk(g.twtVer)
elseif g.loaded("TWThreat") then
out.TWT = mk(g.meta("TWThreat", "Version") or "yes")
else
out.TWT = false
end
-- shootyepgp
if g.loaded("shootyepgp") then
out.EPGP = mk(g.meta("shootyepgp", "Version") or "yes")
else
out.EPGP = false
end
-- SuperWoW: a global the DLL sets, no version needed
if g.superwow then
local e = mk("yes")
e.flag = true
out.SUPERWOW = e
else
local e = mk("no")
e.flag = false
out.SUPERWOW = e
end
-- Rinse (the fork first: it is the one that answers queries)
if g.loaded("UpsiRinse") then
out.RINSE = mk(g.meta("UpsiRinse", "Version") or "yes")
elseif g.loaded("Rinse") then
out.RINSE = mk(g.meta("Rinse", "Version") or "yes")
else
out.RINSE = false
end
-- RABuffs / BetterCharacterStats: only the addon list is known locally
local function localPassive(addon)
if not g.loaded(addon) then
return false
end
local ver = g.meta(addon, "Version")
local e = mk(ver or "yes")
if not ver then
e.presenceOnly = true
end
return e
end
out.RABUFFS = localPassive("RABuffs")
out.BCS = localPassive("BetterCharacterStats")
return out
end
-- ------------------------------------------------------------------
-- evaluation
-- ------------------------------------------------------------------
local function variantKey(e)
return e.variant or ""
end
-- Group the present entries of one slot by fork/variant and find the
-- reference variant (most members, ties broken by name) plus the newest
-- parseable version inside each variant.
local function buildGroups(list)
local byVar, vars = {}, {}
for i = 1, table.getn(list) do
local e = list[i]
local k = variantKey(e)
local g = byVar[k]
if not g then
g = { key = k, count = 0, parseable = 0, uniform = true,
first = e.text, best = nil }
byVar[k] = g
table.insert(vars, g)
end
g.count = g.count + 1
if e.text ~= g.first then
g.uniform = false
end
if e.ver then
g.parseable = g.parseable + 1
if not g.best or A.Compare(e.ver, g.best.ver) > 0 then
g.best = e
end
end
end
local ref
for i = 1, table.getn(vars) do
local g = vars[i]
if not ref or g.count > ref.count
or (g.count == ref.count and g.key < ref.key) then
ref = g
end
end
return byVar, ref
end
-- Decide check / bang for one present entry. Returns mark, note.
local function markFor(e, byVar, ref)
if e.flag ~= nil then
return "check", nil -- SuperWoW has no version
end
local g = byVar[variantKey(e)]
if g ~= ref then
local who = (e.variant and e.variant ~= "") and e.variant or "other"
local refName = (ref.key ~= "") and ref.key or "main"
return "bang", "fork " .. who .. " (the raid mostly runs "
.. refName .. ", newest " .. (ref.best and ref.best.text or ref.first) .. ")"
end
if e.ver then
if g.best and A.Compare(e.ver, g.best.ver) < 0 then
return "bang", "newest in raid: " .. g.best.text
end
return "check", nil
end
-- unparseable text: fine only when nothing different is around
if g.parseable == 0 and g.uniform then
return "check", nil
end
return "bang", "version cannot be compared"
.. (g.best and (" (newest in raid: " .. g.best.text .. ")") or "")
end
local NA_CLASS = "this class cannot dispel, so Rinse is not expected"
local NA_GUILD = "not in your guild - shootyepgp only talks on guild chat"
local NA_NOGLD = "you are not in a guild - this check needs guild chat"
local CONFIRM = " Only confirmed when the player runs shootyepgp with the"
.. " addon check."
local WHY_RAB = "No RABuffs message seen. Weak evidence only: RABuffs"
.. " broadcasts only when the member has mana or a tracked timed buff,"
.. " so silence does not prove it is missing." .. CONFIRM
local WHY_RAB_WAIT = "no RABuffs message yet - RABuffs broadcasts every 30 s,"
.. " so this needs about a minute in the raid." .. CONFIRM
local WHY_BCS = "No BetterCharacterStats message seen. It rarely sends, so"
.. " silence proves nothing." .. CONFIRM
-- Annotate(players, ctx): sets p.addons[slot] and p.addonsAnswered on every
-- player table. ctx:
-- now clock
-- own own character name (nil in test mode)
-- ownEntries LocalEntries result (or nil)
-- myGuild bool, I am in a guild
-- inGuild(p) bool, this player is in my guild
-- lookup(p, slot) -> entry | nil
-- silent(p, slot) -> bool (queried, window over, no reply)
-- queryAt query time (nil = nothing asked yet)
function A.Annotate(players, ctx)
local n = table.getn(players)
local resolved = {} -- resolved[i][slot] = entry | false | "na" ... see below
local lists = {} -- present entries per slot (for the groups)
for s = 1, table.getn(A.SLOT_IDS) do
lists[A.SLOT_IDS[s]] = {}
end
local info = {} -- info[i][slot] = { kind=..., e=..., why=... }
for i = 1, n do
local p = players[i]
local row = {}
info[i] = row
local isOwn = ctx.own ~= nil and p.name == ctx.own and ctx.ownEntries
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
local r
local ownE = isOwn and ctx.ownEntries[slot] or nil
local resp = (not isOwn and ctx.responder) and ctx.responder(p, slot) or nil
if p.online == false then
r = { kind = "unknown", why = "offline" }
elseif ownE then
-- own character: the local addon list is certain, so an
-- installed addon shows even where the class rule below
-- would call it not expected
r = { kind = "present", e = ownE }
if not ownE.presenceOnly then
table.insert(lists[slot], ownE)
end
elseif resp and resp.present then
-- shootyepgp answered: certain, beats every passive rule
r = { kind = "present", e = resp.present }
if not resp.present.presenceOnly then
table.insert(lists[slot], resp.present)
end
elseif resp and resp.missing
and not (slot == "RINSE" and not A.DISPELLERS[p.class or ""]) then
r = { kind = "missing", why = resp.missing }
elseif slot == "RINSE" and not A.DISPELLERS[p.class or ""] then
r = { kind = "na", why = NA_CLASS }
elseif isOwn then
r = { kind = "missing",
why = "not installed on your own character" }
elseif (slot == "EPGP" or slot == "SUPERWOW")
and not ctx.inGuild(p) then
r = { kind = "na", why = ctx.myGuild and NA_GUILD or NA_NOGLD }
else
local e = ctx.lookup(p, slot)
if e then
r = { kind = "present", e = e }
if not e.presenceOnly then
table.insert(lists[slot], e)
end
elseif slot == "RABUFFS" then
-- passive only: silence is weak evidence, shown as
-- "not seen" and only after two broadcast cycles
local secs = ctx.inGroupFor and ctx.inGroupFor(p)
if secs and secs >= A.RAB_SILENT then
r = { kind = "notseen", why = WHY_RAB }
else
r = { kind = "unknown", why = WHY_RAB_WAIT }
end
elseif slot == "BCS" then
r = { kind = "unknown", why = WHY_BCS }
elseif ctx.silent(p, slot) then
r = { kind = "missing" }
else
local why
if ctx.queryAt then
why = "waiting for a reply, or this addon was not"
.. " queried in this kind of group"
else
why = "not asked yet - use Check addons"
end
r = { kind = "unknown", why = why }
end
end
row[slot] = r
end
end
-- newest per slot and variant
local groups, refs = {}, {}
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
if table.getn(lists[slot]) > 0 then
groups[slot], refs[slot] = buildGroups(lists[slot])
end
end
for i = 1, n do
local p = players[i]
local out = {}
local answered = false
for s = 1, table.getn(A.SLOT_IDS) do
local slot = A.SLOT_IDS[s]
local r = info[i][slot]
local res
if r.kind == "present" then
local e = r.e
if e.flag == false then
res = { status = "missing", text = e.text, raw = e.raw,
at = e.at, islocal = e.islocal,
why = e.islocal and "SuperWoW is not loaded"
or "shootyepgp reports: no SuperWoW" }
answered = true
else
local mark, note, newest
if e.presenceOnly then
-- present, version unknown: fine unless somebody
-- else reported one to compare against
if refs[slot] and refs[slot].best then
mark = "bang"
newest = refs[slot].best.text
note = "version unknown (newest in raid: "
.. newest .. ")"
else
mark = "check"
end
else
mark, note = markFor(e, groups[slot], refs[slot])
local g = groups[slot][variantKey(e)]
newest = g.best and g.best.text or nil
end
res = { status = (mark == "check") and "ok" or "old",
mark = mark, text = e.text, raw = e.raw,
at = e.at, variant = e.variant,
islocal = e.islocal, note = note,
presenceOnly = e.presenceOnly,
newest = newest }
answered = true
end
elseif r.kind == "missing" then
res = { status = "missing", why = r.why
or "no reply - addon missing, or an old version / fork"
.. " that does not answer" }
elseif r.kind == "na" then
res = { status = "na", why = r.why }
elseif r.kind == "notseen" then
res = { status = "notseen", why = r.why }
else
res = { status = "unknown", why = r.why }
end
out[slot] = res
end
p.addons = out
p.addonsAnswered = answered
end
end
-- ------------------------------------------------------------------
-- WoW wiring
-- ------------------------------------------------------------------
-- the real-game ctx: passive store + query state + guild lookup
function A.RealCtx(env)
local now = env.now
return {
now = now,
own = env.own,
ownEntries = env.ownEntries,
myGuild = env.myGuild,
inGuild = env.inGuild,
queryAt = A.query.at,
lookup = function(p, slot)
local byName = A.data[p.name]
return byName and byName[slot] or nil
end,
silent = function(p, slot)
return A.IsSilent(p.name, slot, now)
end,
responder = function(p, slot)
return A.ResponderState(p.name, slot)
end,
inGroupFor = function(p)
local since = A.since[p.name]
return since and (now - since) or nil
end,
}
end
-- the simulated raid: players carry p.addonSim = { guild = bool,
-- <slot> = entry (replied) | false (queried, silent) | nil (unknown) }
function A.SimCtx(now)
return {
now = now,
myGuild = true,
queryAt = now,
inGuild = function(p)
return p.addonSim ~= nil and p.addonSim.guild == true
end,
lookup = function(p, slot)
local v = p.addonSim and p.addonSim[slot]
if type(v) ~= "table" then
return nil
end
local e = entry(v.text, v.variant, "sim: " .. v.text)
e.flag = v.flag
e.presenceOnly = v.presenceOnly
e.at = now
return e
end,
silent = function(p, slot)
return p.addonSim ~= nil and p.addonSim[slot] == false
end,
inGroupFor = function(p)
-- sim: a RABUFFS = false member has been around long enough
if p.addonSim ~= nil and p.addonSim.RABUFFS == false then
return A.RAB_SILENT
end
return nil
end,
}
end
-- Annotate a store's players for display (called before the model builds).
function A.AnnotateStore(store, env)
if not store or not store.players then
return
end
if store.source == "sim" then
A.Annotate(store.players, A.SimCtx(env and env.now or 0))
return
end
if env and env.roster and env.now then
A.NoteRoster(env.roster, env.now)
end
A.Annotate(store.players, A.RealCtx(env))
-- evict against the live roster (not the store: before the first scan
-- finishes the store is empty and would wipe everything)
if env and env.roster and table.getn(env.roster) > 0 then
local set = {}
for i = 1, table.getn(env.roster) do
set[env.roster[i]] = true
end
A.Prune(set)
end
end
-- tooltip/age helper (pure): seconds -> "5s ago" / "2m ago" / "1h ago"
function A.AgeText(sec)
if type(sec) ~= "number" or sec < 0 then
return "just now"
end
if sec < 60 then
return math.floor(sec) .. "s ago"
elseif sec < 3600 then
return math.floor(sec / 60) .. "m ago"
end
return math.floor(sec / 3600) .. "h ago"
end
if CreateFrame then
local function names()
local list = {}
local own = UnitName("player")
if own then
table.insert(list, own)
end
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
if nr > 0 then
list = {}
for i = 1, nr do
local nm = GetRaidRosterInfo(i)
if nm then
table.insert(list, nm)
end
end
else
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
for i = 1, np do
local nm = UnitName("party" .. i)
if nm then
table.insert(list, nm)
end
end
end
return list
end
local function guildName(unit)
if GetGuildInfo then
return (GetGuildInfo(unit))
end
return nil
end
-- name -> true for raid/party members in MY guild, rebuilt per call (the
-- display refresh is event driven, not per frame)
local function guildSet()
local set = {}
local mine = guildName("player")
if not mine then
return set, false
end
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
for i = 1, nr do
local unit = "raid" .. i
if guildName(unit) == mine then
set[UnitName(unit)] = true
end
end
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
for i = 1, np do
local unit = "party" .. i
if guildName(unit) == mine then
set[UnitName(unit)] = true
end
end
local own = UnitName("player")
if own then
set[own] = true
end
return set, true
end
function A.GameEnv()
local now = GetTime()
local nr = GetNumRaidMembers and GetNumRaidMembers() or 0
local np = GetNumPartyMembers and GetNumPartyMembers() or 0
local gs, inGuild = guildSet()
local g = {
loaded = function(name)
return IsAddOnLoaded and IsAddOnLoaded(name) and true or false
end,
meta = function(addon, field)
return GetAddOnMetadata and GetAddOnMetadata(addon, field) or nil
end,
bigwigsRev = (type(BigWigs) == "table") and tonumber(BigWigs.revision) or nil,
twtVer = (type(TWT) == "table") and TWT.addonVer or nil,
superwow = SUPERWOW_VERSION,
}
local own = UnitName("player")
A.own = own
return {
now = now,
raid = nr > 0,
party = nr == 0 and np > 0,
guild = inGuild,
myGuild = inGuild,
inCombat = UnitAffectingCombat and UnitAffectingCombat("player") and true or false,
send = SendAddonMessage,
roster = names(),
own = own,
ownEntries = A.LocalEntries(g),
inGuild = function(p)
return gs[p.name] == true
end,
}
end
-- Check addons button / tab open. Returns started, reason.
function A.Check(auto)
local ok, env = pcall(A.GameEnv)
if not ok then
return false, "error"
end
local started, reason = A.StartQuery(env, auto)
if started then
A.finishAt = GetTime() + A.WINDOW + 0.3
A.Wake()
end
return started, reason
end
-- one frame: passive listener + a throttled updater that repaints the tab
-- when replies came in and again when the query window ends
local listener = CreateFrame("Frame", "DopingControlAddonsEventFrame", UIParent)
listener:RegisterEvent("CHAT_MSG_ADDON")
listener:RegisterEvent("RAID_ROSTER_UPDATE")
listener:RegisterEvent("PARTY_MEMBERS_CHANGED")
listener:RegisterEvent("PLAYER_ENTERING_WORLD")
listener:SetScript("OnEvent", function()
if event ~= "CHAT_MSG_ADDON" then
A.NoteRoster(names(), GetTime())
return
end
if A.OnAddonMessage(arg1, arg2, arg3, arg4, GetTime()) then
A.dirty = true
A.Wake()
end
end)
local updater = CreateFrame("Frame", "DopingControlAddonsUpdateFrame", UIParent)
updater.dcT = 0
updater:Hide()
updater:SetScript("OnUpdate", function()
this.dcT = this.dcT + arg1
if this.dcT < 0.5 then
return
end
this.dcT = 0
local now = GetTime()
local repaint = false
if A.dirty then
A.dirty = false
repaint = true
end
if A.finishAt and now >= A.finishAt then
A.finishAt = nil
repaint = true
end
if repaint and DC_Matrix and DC_Matrix.RefreshAddons then
DC_Matrix.RefreshAddons()
end
if not A.dirty and not A.finishAt then
this:Hide()
end
end)
function A.Wake()
updater:Show()
end
end -- if CreateFrame