DopingControl 0.7.0
This commit is contained in:
+908
@@ -0,0 +1,908 @@
|
||||
-- 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).
|
||||
-- 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)
|
||||
|
||||
-- slot ids (match DC.SLOTS_ADDONS)
|
||||
A.SLOT_IDS = { "BIGWIGS", "TWT", "EPGP", "SUPERWOW", "RINSE" }
|
||||
|
||||
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",
|
||||
}
|
||||
A.PREFIXES = PREFIX_SLOT
|
||||
|
||||
-- passive store: A.data[name][slot] = entry
|
||||
A.data = {}
|
||||
A.dataCount = 0
|
||||
|
||||
-- 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
|
||||
|
||||
local PARSER = {
|
||||
BIGWIGS = A.ParseBigWigs,
|
||||
TWT = A.ParseTWT,
|
||||
RINSE = A.ParseRinse,
|
||||
EPGP = A.ParseEPGP,
|
||||
SUPERWOW = A.ParseSwow,
|
||||
}
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- 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
|
||||
-- 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
|
||||
|
||||
-- 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
|
||||
end
|
||||
|
||||
function A.Reset()
|
||||
A.data = {}
|
||||
A.dataCount = 0
|
||||
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
|
||||
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
|
||||
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"
|
||||
|
||||
-- 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
|
||||
if p.online == false then
|
||||
r = { kind = "unknown", why = "offline" }
|
||||
elseif slot == "RINSE" and not A.DISPELLERS[p.class or ""] then
|
||||
r = { kind = "na", why = NA_CLASS }
|
||||
elseif isOwn then
|
||||
local e = ctx.ownEntries[slot]
|
||||
if e then
|
||||
r = { kind = "present", e = e }
|
||||
if not e.presenceOnly then
|
||||
table.insert(lists[slot], e)
|
||||
end
|
||||
else
|
||||
r = { kind = "missing",
|
||||
why = "not installed on your own character" }
|
||||
end
|
||||
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 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 }
|
||||
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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
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
|
||||
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:SetScript("OnEvent", function()
|
||||
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
|
||||
+519
@@ -0,0 +1,519 @@
|
||||
-- DopingControl scan/aura.lua
|
||||
-- Buff reading for one unit, two independent paths, merged:
|
||||
-- Path A (unit token): UnitBuff(unit, i), i = 1..32 -- buff NAME via a
|
||||
-- hidden tooltip (DopingControlScanTip, the standard 1.12
|
||||
-- hidden-tooltip scanner pattern), spell ID via the
|
||||
-- 4th return value (a SuperWoW extension; unverified on other client
|
||||
-- builds -- covered by the probe battery).
|
||||
-- UnitBuff compacts its list: stop at the first nil texture.
|
||||
-- Path B (GUID): GetUnitField(guid, "aura") -- nampower descriptor table,
|
||||
-- 48 ONE-BASED slots of spell ids (1-32 buffs, 33-48 debuffs). The
|
||||
-- table is SPARSE (in-game verified: e.g. slots
|
||||
-- 1,2,5,6 filled with 3,4 empty), so iterate all 32 buff slots and
|
||||
-- skip empties instead of breaking at the first gap.
|
||||
-- Both paths are pcall-wrapped per call (hard rule).
|
||||
--
|
||||
-- Merge (pure, offline-tested): union of the ID sets, names from path A.
|
||||
-- Buff icons: auras.textures[name] = icon path (UnitBuff return 1) for
|
||||
-- every path-A buff that yielded a name; ids without a name are OPTIONALLY
|
||||
-- resolved via SuperWoW SpellInfo(id) (return 3 = icon) when the function
|
||||
-- exists. textures is OPTIONAL/partial by design -- every consumer
|
||||
-- nil-guards, nothing downstream may require it.
|
||||
-- Buff EFFECT lines: auras.effects[name] = the tooltip's line 2 text
|
||||
-- (same hidden-tooltip read as the name, TextLeft2) for every path-A buff
|
||||
-- that yielded both a name and a non-empty line 2. This feeds the item
|
||||
-- identification ladder for generic buff names ("Well Fed" -> which food,
|
||||
-- via DC.ResolveItem's discrim patterns). effects is OPTIONAL/partial
|
||||
-- exactly like textures -- every consumer nil-guards.
|
||||
-- Disagreements between the two ID sets are counted (symmetric difference,
|
||||
-- only when BOTH paths yielded at least one ID -- an absent path is missing
|
||||
-- data, not a disagreement) and accumulated by the engine into
|
||||
-- DC.probeStats. Zero USABLE auras (name- or id-carrying) from both paths
|
||||
-- => aurasRead = false (a failed read, never "has nothing";
|
||||
-- texture-only path-A rows carry no evidence and do not count).
|
||||
--
|
||||
-- Unknown auras are collected, never silently dropped:
|
||||
-- an aura is unknown when neither its name is in DC.CONSUMABLES /
|
||||
-- DC.CLASSBUFFS nor its spell ID in DC.SPELL_TO_SLOT. Path-B-only IDs
|
||||
-- normally carry no name and are reported as "spell:<id>", UNLESS the
|
||||
-- caller supplies idNames (see A.Assemble below): with a resolved name,
|
||||
-- a path-B-only aura is judged the same way a path-A one is (known
|
||||
-- consumable/class-buff name => not unknown at all) and, if still
|
||||
-- unknown, is reported as that name instead of "spell:<id>" -- and only
|
||||
-- once, even when the same aura also showed up unresolved via path A
|
||||
-- (this is what keeps the same aura from being counted twice: once as a
|
||||
-- name from path A, once as "spell:<id>" from path B, which used to
|
||||
-- happen whenever path A never delivered a spell ID at all, making the
|
||||
-- id-based dedup a no-op).
|
||||
--
|
||||
-- DEBUFFS:
|
||||
-- ReadUnit additionally reads UnitDebuff(unit, i) 1..16 (same hidden
|
||||
-- tooltip via SetUnitDebuff, texture = return 1, spell id = 4th return if
|
||||
-- present) and the debuff half of the SAME GetUnitField "aura" fetch
|
||||
-- (one-based slots 33-48). AssembleDebuffs merges them into the store
|
||||
-- shape players[i].debuffs = { names, ids, textures } (sibling of auras).
|
||||
-- Debuff READABILITY rides on the existing aurasRead flag -- zero debuffs
|
||||
-- is a LEGITIMATE clean state (empty sets), NEVER a read failure, and it
|
||||
-- never influences aurasRead. Debuff names are NOT collected into
|
||||
-- DC.unknownSeen (that channel is consumable tracking);
|
||||
-- nothing is silently dropped either way, because the DEBUFFS tab builds
|
||||
-- its columns dynamically from exactly this data.
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline (WoW wiring only inside functions
|
||||
-- that run in-game; tooltip frame is created lazily).
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Aura = DC_Aura or {}
|
||||
local A = DC_Aura
|
||||
|
||||
-- ==================================================================
|
||||
-- Pure part
|
||||
-- ==================================================================
|
||||
|
||||
-- Assemble(rawA, rawB, idNames) -> auras, aurasRead, disagreements, unknown
|
||||
-- rawA : array of { name = string|nil, id = number|nil,
|
||||
-- texture = string|nil, effect = string|nil }
|
||||
-- (path A; texture is the 1st UnitBuff return,
|
||||
-- effect the tooltip's line 2 -- both optional)
|
||||
-- rawB : array of spell ids (numbers, path B)
|
||||
-- idNames : OPTIONAL { [spellId] = "Buff Name" } for path-B ids,
|
||||
-- filled by the WoW-side caller (SuperWoW SpellInfo lookup --
|
||||
-- see A.ReadUnit). Omitted in tests/offline callers, in which
|
||||
-- case unknown path-B ids are reported as "spell:<id>" exactly
|
||||
-- as before (no behavior change without it).
|
||||
-- Returns:
|
||||
-- auras : { names = { [name]=true }, ids = { [id]=true },
|
||||
-- textures = { [name]=iconPath },
|
||||
-- effects = { [name]=effectLine } }
|
||||
-- (store shape; textures AND effects may be empty/
|
||||
-- partial -- consumers nil-guard)
|
||||
-- aurasRead : bool -- true iff at least one USABLE aura arrived: a
|
||||
-- path-A entry carrying a name or an id, or a path-B id.
|
||||
-- Entries with NEITHER (UnitBuff texture present but the
|
||||
-- tooltip read failed and no 4th return) are information-
|
||||
-- free: counting them would flip a partially failed read
|
||||
-- from UNKNOWN to all-slots-MISSING -- exactly what the
|
||||
-- "zero auras => read failed" rule
|
||||
-- exists to prevent.
|
||||
-- disagreements : number of ids present in exactly one of the two ID
|
||||
-- sets; 0 unless both sets are non-empty
|
||||
-- unknown : array of unknown-aura keys (name, or "spell:<id>")
|
||||
-- trim(s) -> s with leading/trailing whitespace stripped. Lua-5.0-safe:
|
||||
-- string.find with a captured "middle" group, no string-method syntax.
|
||||
local function trim(s)
|
||||
local _, _, middle = string.find(s, "^%s*(.-)%s*$")
|
||||
return middle
|
||||
end
|
||||
|
||||
-- isKnownAuraName(name, cons, cbuf) -> true if `name` is a recognized
|
||||
-- consumable/class-buff key -- tried EXACT first, then (only if that
|
||||
-- misses) its trimmed form. SpellInfo() aura names can carry stray
|
||||
-- leading/trailing whitespace on this client (measured live:
|
||||
-- SpellInfo(7254) = "Nature Protection " with a trailing space) that a
|
||||
-- hand-authored table key never has; without this fallback such a name
|
||||
-- never matches its own table entry. Exact-first matters because a
|
||||
-- table key legitimately containing whitespace (should one ever exist)
|
||||
-- must still win over a coincidental trimmed collision.
|
||||
local function isKnownAuraName(name, cons, cbuf)
|
||||
if not name then
|
||||
return false
|
||||
end
|
||||
if cons[name] or cbuf[name] then
|
||||
return true
|
||||
end
|
||||
local trimmed = trim(name)
|
||||
if trimmed ~= name and (cons[trimmed] or cbuf[trimmed]) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function A.Assemble(rawA, rawB, idNames)
|
||||
rawA = rawA or {}
|
||||
rawB = rawB or {}
|
||||
idNames = idNames or {}
|
||||
local names = {}
|
||||
local ids = {}
|
||||
local idsA = {}
|
||||
local idsB = {}
|
||||
local textures = {} -- [buffName] = icon path
|
||||
local effects = {} -- [buffName] = tooltip effect line (line 2)
|
||||
local namedIds = {} -- ids that arrived WITH a name (path A rows)
|
||||
local usable = 0 -- entries that carry actual evidence (see header)
|
||||
|
||||
local nA = table.getn(rawA)
|
||||
for i = 1, nA do
|
||||
local e = rawA[i]
|
||||
local hasInfo = false
|
||||
if e.name then
|
||||
names[e.name] = true
|
||||
hasInfo = true
|
||||
if e.texture then
|
||||
textures[e.name] = e.texture
|
||||
end
|
||||
if type(e.effect) == "string" and e.effect ~= "" then
|
||||
effects[e.name] = e.effect
|
||||
end
|
||||
end
|
||||
if type(e.id) == "number" and e.id > 0 then
|
||||
idsA[e.id] = true
|
||||
ids[e.id] = true
|
||||
hasInfo = true
|
||||
if e.name then
|
||||
namedIds[e.id] = true
|
||||
end
|
||||
end
|
||||
if hasInfo then
|
||||
usable = usable + 1
|
||||
end
|
||||
end
|
||||
|
||||
local nB = table.getn(rawB)
|
||||
for i = 1, nB do
|
||||
local id = rawB[i]
|
||||
if type(id) == "number" and id > 0 then
|
||||
idsB[id] = true
|
||||
ids[id] = true
|
||||
usable = usable + 1
|
||||
end
|
||||
end
|
||||
|
||||
local aurasRead = usable > 0
|
||||
|
||||
-- Optional icon resolution for ids that carried no name (path-B-only
|
||||
-- ids, or nameless path-A rows): SuperWoW SpellInfo(id) -> name, rank,
|
||||
-- icon (3rd return). Runtime-only enhancement -- SpellInfo is nil in
|
||||
-- offline tests and may be absent in-game (non-SuperWoW client);
|
||||
-- textures simply stays partial then. Path-A textures win (never
|
||||
-- overwritten): UnitBuff saw the actual aura, SpellInfo is a lookup.
|
||||
if SpellInfo then
|
||||
for id in pairs(ids) do
|
||||
if not namedIds[id] then
|
||||
local okSI, nm, _, icon = pcall(SpellInfo, id)
|
||||
if okSI and nm and icon and textures[nm] == nil then
|
||||
textures[nm] = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- disagreement count: symmetric difference of the ID sets, but only
|
||||
-- when both paths actually delivered IDs (else it is path absence)
|
||||
local disagreements = 0
|
||||
if next(idsA) ~= nil and next(idsB) ~= nil then
|
||||
for id in pairs(idsA) do
|
||||
if not idsB[id] then
|
||||
disagreements = disagreements + 1
|
||||
end
|
||||
end
|
||||
for id in pairs(idsB) do
|
||||
if not idsA[id] then
|
||||
disagreements = disagreements + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- unknown-aura collection (no silent discard)
|
||||
local unknown = {}
|
||||
local unknownNamesA = {} -- names already added to `unknown` from path A
|
||||
local cons = DC.CONSUMABLES or {}
|
||||
local cbuf = DC.CLASSBUFFS or {}
|
||||
local s2s = DC.SPELL_TO_SLOT or {}
|
||||
for i = 1, nA do
|
||||
local e = rawA[i]
|
||||
-- name-known OR id-known -> known. Deliberately NOT an
|
||||
-- if/elseif chain gated on "no name": a row can carry a name
|
||||
-- that fails to match a table key exactly (SpellInfo() aura-
|
||||
-- name spelling vs. the hand-authored table key) while still
|
||||
-- carrying a correct spell id -- the id must be checked
|
||||
-- regardless of whether a name is present (belied a 26-man
|
||||
-- raid: a correctly ID-tracked consumable was discarded 38
|
||||
-- times because its live aura-name spelling did not match the
|
||||
-- table key it was filed under).
|
||||
local known = isKnownAuraName(e.name, cons, cbuf)
|
||||
if not known and type(e.id) == "number" and s2s[e.id] then
|
||||
known = true
|
||||
end
|
||||
if not known then
|
||||
if e.name then
|
||||
table.insert(unknown, e.name)
|
||||
unknownNamesA[e.name] = true
|
||||
elseif e.id then
|
||||
table.insert(unknown, "spell:" .. e.id)
|
||||
end
|
||||
-- entry with neither name nor id carries no information: skip
|
||||
end
|
||||
end
|
||||
-- path-B-only ids: with a resolved name (idNames, WoW-side SpellInfo
|
||||
-- lookup) an id is judged by name exactly like a path-A entry --
|
||||
-- known consumable/class-buff name is not unknown, and a name already
|
||||
-- reported from path A is not reported again (this is the dedup: on
|
||||
-- clients where UnitBuff never yields a spell ID, idsA stays empty
|
||||
-- and the id-based check below is a no-op, so the same aura would
|
||||
-- otherwise be counted once as a name and once as "spell:<id>").
|
||||
-- Without a resolved name, behavior is unchanged: "spell:<id>".
|
||||
for i = 1, nB do
|
||||
local id = rawB[i]
|
||||
if type(id) == "number" and id > 0 and not idsA[id] and not s2s[id] then
|
||||
local nm = idNames[id]
|
||||
if not nm then
|
||||
table.insert(unknown, "spell:" .. id)
|
||||
elseif isKnownAuraName(nm, cons, cbuf) then
|
||||
-- known by resolved name (exact or trimmed -- same rule
|
||||
-- as the path-A loop above): not unknown, nothing to add
|
||||
elseif unknownNamesA[nm] then
|
||||
-- already reported via path A under this name: skip
|
||||
else
|
||||
table.insert(unknown, nm)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return { names = names, ids = ids, textures = textures,
|
||||
effects = effects },
|
||||
aurasRead, disagreements, unknown
|
||||
end
|
||||
|
||||
-- SplitAuraField(t) -> buffIds, debuffIds (two arrays of spell ids)
|
||||
-- Splits ONE GetUnitField(guid, "aura") descriptor table into its buff
|
||||
-- half (one-based slots 1-32) and debuff half (slots 33-48). The table is
|
||||
-- SPARSE (in-game verified, see header) -- every slot is visited, empties
|
||||
-- and non-positive/non-number values are skipped, no break at gaps.
|
||||
-- Pure -- offline-tested in test_scan_parse.lua.
|
||||
function A.SplitAuraField(t)
|
||||
local buffs = {}
|
||||
local debuffs = {}
|
||||
if type(t) == "table" then
|
||||
for i = 1, 32 do
|
||||
local v = t[i]
|
||||
if type(v) == "number" and v > 0 then
|
||||
table.insert(buffs, v)
|
||||
end
|
||||
end
|
||||
for i = 33, 48 do
|
||||
local v = t[i]
|
||||
if type(v) == "number" and v > 0 then
|
||||
table.insert(debuffs, v)
|
||||
end
|
||||
end
|
||||
end
|
||||
return buffs, debuffs
|
||||
end
|
||||
|
||||
-- AssembleDebuffs(rawDA, rawDB) -> debuffs
|
||||
-- rawDA : array of { name = string|nil, id = number|nil,
|
||||
-- texture = string|nil } (UnitDebuff path, same row
|
||||
-- shape as the buff path A)
|
||||
-- rawDB : array of spell ids (GetUnitField debuff half, slots 33-48)
|
||||
-- Returns the store shape:
|
||||
-- { names = { [name]=true }, ids = { [id]=true },
|
||||
-- textures = { [name]=iconPath } }
|
||||
-- Merge mirrors A.Assemble: union of the ID sets, names + textures from
|
||||
-- the tooltip path, optional SpellInfo icon resolution for nameless ids.
|
||||
-- NO read flag is derived here (readability = the existing aurasRead
|
||||
-- flag; empty sets are a legitimate clean state) and NO unknown list is
|
||||
-- returned (debuffs never feed DC.unknownSeen -- see header).
|
||||
function A.AssembleDebuffs(rawDA, rawDB)
|
||||
rawDA = rawDA or {}
|
||||
rawDB = rawDB or {}
|
||||
local names = {}
|
||||
local ids = {}
|
||||
local textures = {}
|
||||
local namedIds = {}
|
||||
|
||||
local nA = table.getn(rawDA)
|
||||
for i = 1, nA do
|
||||
local e = rawDA[i]
|
||||
if e.name then
|
||||
names[e.name] = true
|
||||
if e.texture then
|
||||
textures[e.name] = e.texture
|
||||
end
|
||||
end
|
||||
if type(e.id) == "number" and e.id > 0 then
|
||||
ids[e.id] = true
|
||||
if e.name then
|
||||
namedIds[e.id] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local nB = table.getn(rawDB)
|
||||
for i = 1, nB do
|
||||
local id = rawDB[i]
|
||||
if type(id) == "number" and id > 0 then
|
||||
ids[id] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- optional icon resolution for nameless ids (mirrors A.Assemble:
|
||||
-- SpellInfo is a SuperWoW runtime lookup, nil offline; tooltip-path
|
||||
-- textures are never overwritten)
|
||||
if SpellInfo then
|
||||
for id in pairs(ids) do
|
||||
if not namedIds[id] then
|
||||
local okSI, nm, _, icon = pcall(SpellInfo, id)
|
||||
if okSI and nm and icon and textures[nm] == nil then
|
||||
textures[nm] = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return { names = names, ids = ids, textures = textures }
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- WoW part (runtime only; every call pcall-wrapped)
|
||||
-- ==================================================================
|
||||
|
||||
-- Hidden tooltip scanner (standard 1.12 pattern).
|
||||
-- The frame is NAMED, and the name is REQUIRED rather than cosmetic: the
|
||||
-- line FontStrings are read back via getglobal("<name>TextLeft1").
|
||||
local scanTip = nil
|
||||
|
||||
local function ensureTip()
|
||||
if not scanTip and CreateFrame then
|
||||
scanTip = CreateFrame("GameTooltip", "DopingControlScanTip",
|
||||
nil, "GameTooltipTemplate")
|
||||
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||
end
|
||||
return scanTip
|
||||
end
|
||||
|
||||
-- shared accessor (core/probe.lua reuses the same hidden tooltip instead
|
||||
-- of creating a second frame under the same global name)
|
||||
function A.GetScanTip()
|
||||
return ensureTip()
|
||||
end
|
||||
|
||||
-- Returns name (tooltip line 1) AND effect (tooltip line 2 -- the buff's
|
||||
-- effect text, feeds the item identification ladder for generic names).
|
||||
-- raises an error on bad units -> always called through pcall
|
||||
local function tipBuffName(unit, buffIndex)
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetUnitBuff(unit, buffIndex)
|
||||
local name, effect = nil, nil
|
||||
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||
if textObj then
|
||||
name = textObj:GetText()
|
||||
end
|
||||
local effObj = getglobal("DopingControlScanTipTextLeft2")
|
||||
if effObj then
|
||||
effect = effObj:GetText()
|
||||
end
|
||||
return name, effect
|
||||
end
|
||||
|
||||
-- debuff twin of tipBuffName (same hidden tooltip, SetUnitDebuff);
|
||||
-- raises an error on bad units -> always called through pcall
|
||||
local function tipDebuffName(unit, debuffIndex)
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetUnitDebuff(unit, debuffIndex)
|
||||
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
||||
if textObj then
|
||||
return textObj:GetText()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ReadUnit(unit, guid) -> rawA, rawB, rawDA, rawDB, idNames
|
||||
-- rawA/rawB : buff shapes as taken by A.Assemble
|
||||
-- rawDA/rawDB: debuff shapes as taken by A.AssembleDebuffs (rawDA from
|
||||
-- the UnitDebuff 1..16 loop, rawDB = debuff half slots
|
||||
-- 33-48 of the SAME GetUnitField fetch as rawB)
|
||||
-- idNames : { [spellId] = "Buff Name" } for the ids in rawB, resolved
|
||||
-- via SuperWoW SpellInfo (same lookup A.Assemble already
|
||||
-- uses for icons); taken by A.Assemble's optional 3rd
|
||||
-- parameter. Extra return value -- existing callers that
|
||||
-- only capture rawA..rawDB are unaffected.
|
||||
function A.ReadUnit(unit, guid)
|
||||
local rawA = {}
|
||||
local rawDA = {}
|
||||
if UnitBuff then
|
||||
local tip = ensureTip()
|
||||
for i = 1, 32 do
|
||||
-- 4th return = SuperWoW spell id (unverified on other builds; probe Q1)
|
||||
local ok, texture, stacks, dtype, auraID = pcall(UnitBuff, unit, i)
|
||||
if not ok or not texture then
|
||||
break
|
||||
end
|
||||
local name, effect = nil, nil
|
||||
if tip then
|
||||
local ok2, nm, eff = pcall(tipBuffName, unit, i)
|
||||
if ok2 then
|
||||
name = nm
|
||||
effect = eff
|
||||
end
|
||||
end
|
||||
local id = nil
|
||||
if type(auraID) == "number" and auraID > 0 then
|
||||
id = auraID
|
||||
end
|
||||
-- texture (UnitBuff return 1) and the tooltip effect line ride
|
||||
-- along so Assemble can key them under the tooltip name
|
||||
-- (auras.textures / auras.effects)
|
||||
table.insert(rawA, { name = name, id = id, texture = texture,
|
||||
effect = effect })
|
||||
end
|
||||
end
|
||||
if UnitDebuff then
|
||||
-- mirror of the buff loop: 16 debuff slots on 1.12, list is
|
||||
-- compacted (stop at the first nil texture), name via the same
|
||||
-- hidden tooltip, spell id via 4th return if present
|
||||
local tip = ensureTip()
|
||||
for i = 1, 16 do
|
||||
local ok, texture, stacks, dtype, auraID = pcall(UnitDebuff, unit, i)
|
||||
if not ok or not texture then
|
||||
break
|
||||
end
|
||||
local name = nil
|
||||
if tip then
|
||||
local ok2, nm = pcall(tipDebuffName, unit, i)
|
||||
if ok2 then
|
||||
name = nm
|
||||
end
|
||||
end
|
||||
local id = nil
|
||||
if type(auraID) == "number" and auraID > 0 then
|
||||
id = auraID
|
||||
end
|
||||
table.insert(rawDA, { name = name, id = id, texture = texture })
|
||||
end
|
||||
end
|
||||
|
||||
local rawB = {}
|
||||
local rawDB = {}
|
||||
if GetUnitField and guid then
|
||||
local ok, t = pcall(GetUnitField, guid, "aura")
|
||||
if ok and type(t) == "table" then
|
||||
-- ONE fetch carries both halves (sparse 48-slot table):
|
||||
-- buffs 1-32, debuffs 33-48 -- split without extra API calls
|
||||
rawB, rawDB = A.SplitAuraField(t)
|
||||
end
|
||||
end
|
||||
|
||||
-- name resolution for path-B ids (SuperWoW SpellInfo lookup, same
|
||||
-- pattern/guarding as the icon resolution in A.Assemble): path-B ids
|
||||
-- never carry a name of their own, so every id here is a candidate.
|
||||
-- Runtime-only, pcall-wrapped, nil-safe; stays empty offline/without
|
||||
-- SuperWoW, which leaves A.Assemble's unknown-aura reporting exactly
|
||||
-- as before ("spell:<id>").
|
||||
local idNames = {}
|
||||
if SpellInfo then
|
||||
local nB = table.getn(rawB)
|
||||
for i = 1, nB do
|
||||
local id = rawB[i]
|
||||
if type(id) == "number" and id > 0 then
|
||||
local okSI, nm = pcall(SpellInfo, id)
|
||||
if okSI and nm then
|
||||
idNames[id] = nm
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return rawA, rawB, rawDA, rawDB, idNames
|
||||
end
|
||||
+1142
File diff suppressed because it is too large
Load Diff
+222
@@ -0,0 +1,222 @@
|
||||
-- DopingControl scan/gear.lua
|
||||
-- Equipment + enchant reading for one unit: GetInventoryItemLink for the 16
|
||||
-- equipment-tab slots (DC.SLOTS_EQUIPMENT), enchant id parsed from the item
|
||||
-- link ("item:ID:ENCHANT:..."), item details via GetItemInfo with the
|
||||
-- MEASURED Turtle 1.18.1 return order
|
||||
-- : 1 name, 2 link, 3 QUALITY, 4 reqLevel, 5 itemType,
|
||||
-- 6 ITEMSUBTYPE, 7 maxStack, 8 equipSlot, 9 ICONPATH, 10 vendorPrice.
|
||||
-- Reading position 4 as itemLevel or 10 as texture breaks SILENTLY with
|
||||
-- plausible-looking values -- do not "fix" this back to the vanilla order.
|
||||
--
|
||||
-- Readability rule (binding):
|
||||
-- ANY slot returned a link => gearRead = true; slots with nil => "EMPTY"
|
||||
-- ALL 13 slots returned nil => gearRead = false (failed read, NOT naked)
|
||||
-- A link that came back but does not parse leaves its slot nil while
|
||||
-- gearRead stays true -- core/model.lua shows that cell as UNKNOWN
|
||||
-- ("no data"), never as MISSING (resolution: a parse anomaly must not be
|
||||
-- claimed as "no item").
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline: ParseLink / ParseLinkName /
|
||||
-- ParseWeaponEnchantLine / Assemble are pure (offline-tested);
|
||||
-- ReadUnit, ItemInfo and ReadSelfWeaponEnchantName
|
||||
-- touch the WoW API and run in-game only, pcall-wrapped.
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Gear = DC_Gear or {}
|
||||
local G = DC_Gear
|
||||
|
||||
-- Fallback inventory-slot list (= invSlot column of DC.SLOTS_EQUIPMENT,
|
||||
-- data/enchants.lua) so this file works standalone in tests.
|
||||
local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 6, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
|
||||
|
||||
-- InvSlots() -> array of the 17 inventory slot numbers, in column order.
|
||||
-- Prefers DC.SLOTS_EQUIPMENT (single source of truth once data/ is loaded).
|
||||
function G.InvSlots()
|
||||
if DC.SLOTS_EQUIPMENT then
|
||||
local out = {}
|
||||
local n = table.getn(DC.SLOTS_EQUIPMENT)
|
||||
for i = 1, n do
|
||||
table.insert(out, DC.SLOTS_EQUIPMENT[i].invSlot)
|
||||
end
|
||||
return out
|
||||
end
|
||||
return FALLBACK_INV_SLOTS
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- Pure part
|
||||
-- ==================================================================
|
||||
|
||||
-- ParseLink(link) -> itemId, enchantId (numbers) | nil, nil on malformed.
|
||||
-- Enchant id is the 2nd field of the link payload (standard 1.12 link
|
||||
-- pattern, widened to also capture the item id).
|
||||
function G.ParseLink(link)
|
||||
if type(link) ~= "string" then
|
||||
return nil, nil
|
||||
end
|
||||
local _, _, idStr, enchStr = string.find(link, "item:(%d+):(%d+)")
|
||||
if not idStr then
|
||||
return nil, nil
|
||||
end
|
||||
return tonumber(idStr), tonumber(enchStr)
|
||||
end
|
||||
|
||||
-- ParseLinkName(link) -> "[bracket] name" | nil (fallback when GetItemInfo
|
||||
-- has no cached data yet; standard 1.12 pattern).
|
||||
function G.ParseLinkName(link)
|
||||
if type(link) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local _, _, name = string.find(link, "%[(.+)%]")
|
||||
return name
|
||||
end
|
||||
|
||||
-- ParseWeaponEnchantLine(text) -> name|nil (pure).
|
||||
-- The main-hand tooltip's temporary-enchant line reads "<Name> (N min)" on
|
||||
-- the English client (e.g. "Dense Sharpening Stone (28 min)") -- strip the
|
||||
-- "(N min)" part, keep the name. Any other line (item name, "Speed 2.70",
|
||||
-- "+9 Damage", a bare "(28 min)") and any non-string input -> nil.
|
||||
function G.ParseWeaponEnchantLine(text)
|
||||
if type(text) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local _, _, name = string.find(text, "^(.-)%s*%((%d+) min%)%s*$")
|
||||
if name and name ~= "" then
|
||||
return name
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Assemble(rawLinks, slots, itemInfo) -> gear, gearRead
|
||||
-- rawLinks : { [invSlot] = link string | nil }
|
||||
-- slots : array of invSlot numbers (default G.InvSlots())
|
||||
-- itemInfo : nil, or function(itemId) -> name, quality, subType, texture
|
||||
-- (already de-shifted; in-game wrapper is G.ItemInfo, tests
|
||||
-- pass a stub)
|
||||
-- Returns the store shape:
|
||||
-- gearRead = false => gear = nil
|
||||
-- gearRead = true => gear[invSlot] = { id, enchant, quality, subType,
|
||||
-- texture, name } | "EMPTY" (no link) | nil
|
||||
-- (link present but unparseable -> UNKNOWN cell)
|
||||
-- The subType field feeds DC.ENCH_EXEMPT in core/model.lua (wand/holdable
|
||||
-- exemptions); the model explicitly expects it.
|
||||
function G.Assemble(rawLinks, slots, itemInfo)
|
||||
rawLinks = rawLinks or {}
|
||||
slots = slots or G.InvSlots()
|
||||
local n = table.getn(slots)
|
||||
|
||||
local gear = {}
|
||||
local any = false
|
||||
for i = 1, n do
|
||||
local inv = slots[i]
|
||||
local link = rawLinks[inv]
|
||||
if link ~= nil then
|
||||
any = true
|
||||
local id, ench = G.ParseLink(link)
|
||||
if id then
|
||||
local entry = { id = id, enchant = ench }
|
||||
if itemInfo then
|
||||
local name, quality, subType, texture = itemInfo(id)
|
||||
entry.name = name
|
||||
entry.quality = quality
|
||||
entry.subType = subType
|
||||
entry.texture = texture
|
||||
end
|
||||
if not entry.name then
|
||||
entry.name = G.ParseLinkName(link)
|
||||
end
|
||||
gear[inv] = entry
|
||||
end
|
||||
-- unparseable link: slot stays nil (UNKNOWN), see header
|
||||
end
|
||||
end
|
||||
|
||||
if not any then
|
||||
return nil, false
|
||||
end
|
||||
for i = 1, n do
|
||||
local inv = slots[i]
|
||||
if rawLinks[inv] == nil then
|
||||
gear[inv] = "EMPTY"
|
||||
end
|
||||
end
|
||||
return gear, true
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- WoW part (runtime only; every foreign-unit call pcall-wrapped)
|
||||
-- ==================================================================
|
||||
|
||||
-- ItemInfo(itemId) -> name, quality, subType, texture | nil (uncached/err).
|
||||
-- De-shifts the Turtle 1.18.1 GetItemInfo order (see header).
|
||||
function G.ItemInfo(itemId)
|
||||
if not GetItemInfo then
|
||||
return nil
|
||||
end
|
||||
local ok, name, link, quality, reqLevel, itemType, subType,
|
||||
maxStack, equipSlot, iconPath = pcall(GetItemInfo, itemId)
|
||||
if not ok or not name then
|
||||
return nil
|
||||
end
|
||||
return name, quality, subType, iconPath
|
||||
end
|
||||
|
||||
-- ReadSelfWeaponEnchantName() -> name|nil (WoW runtime only).
|
||||
-- Self path for store.weaponMainName: GetWeaponEnchantInfo() carries
|
||||
-- no NAME for the own unit, but the equipped main hand's tooltip lists the
|
||||
-- temp enchant as its own "<Name> (N min)" line (English client). Scans
|
||||
-- the shared hidden tooltip (scan/aura.lua accessor -- never a second
|
||||
-- frame under the same global name) over SetInventoryItem("player", 16)
|
||||
-- and returns the first line that parses; nil-safe when no such line.
|
||||
function G.ReadSelfWeaponEnchantName()
|
||||
local tip = DC_Aura and DC_Aura.GetScanTip and DC_Aura.GetScanTip()
|
||||
if not tip then
|
||||
return nil
|
||||
end
|
||||
local ok = pcall(function()
|
||||
tip:ClearLines()
|
||||
tip:SetInventoryItem("player", 16)
|
||||
end)
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
local n = 30
|
||||
if tip.NumLines then
|
||||
local ok2, v = pcall(tip.NumLines, tip)
|
||||
if ok2 and type(v) == "number" then
|
||||
n = v
|
||||
end
|
||||
end
|
||||
for i = 1, n do
|
||||
local obj = getglobal("DopingControlScanTipTextLeft" .. i)
|
||||
if obj then
|
||||
local name = G.ParseWeaponEnchantLine(obj:GetText())
|
||||
if name then
|
||||
return name
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ReadUnit(unit) -> rawLinks table for G.Assemble.
|
||||
-- Vanilla serves GetInventoryItemLink only for the own/inspected unit;
|
||||
-- SuperWoW serves it for all friendly units (unverified beyond sight,
|
||||
-- probe questions 4/5) -- either way nil slots are handled by the
|
||||
-- EMPTY-vs-gearRead rule above, so no capability branching here.
|
||||
function G.ReadUnit(unit)
|
||||
local raw = {}
|
||||
if GetInventoryItemLink then
|
||||
local slots = G.InvSlots()
|
||||
local n = table.getn(slots)
|
||||
for i = 1, n do
|
||||
local ok, link = pcall(GetInventoryItemLink, unit, slots[i])
|
||||
if ok and link then
|
||||
raw[slots[i]] = link
|
||||
end
|
||||
end
|
||||
end
|
||||
return raw
|
||||
end
|
||||
+1053
File diff suppressed because it is too large
Load Diff
+212
@@ -0,0 +1,212 @@
|
||||
-- DopingControl scan/pull.lua
|
||||
-- Silent snapshot on a pull timer: BigWigs' Pulltimer plugin broadcasts a
|
||||
-- CHAT_MSG_ADDON every time ANYONE in the raid/party starts (or rebroadcasts)
|
||||
-- a pull timer -- one more moment where "who has their flask up" matters,
|
||||
-- and one the addon currently misses entirely (only READY_CHECK triggers an
|
||||
-- automatic snapshot -- scan/engine.lua's header -- and some raids' ready
|
||||
-- checks route through a DIFFERENT addon's own protocol instead: prefix
|
||||
-- "TW_RF", payload "<Name> Ready", never raising the native READY_CHECK
|
||||
-- event at all (seen in a live capture). A whole Molten
|
||||
-- Core night went unsnapshotted this way on 24 Sep 2026.
|
||||
--
|
||||
-- Wire format (source-confirmed against the installed BigWigs addon):
|
||||
-- BigWigsComm:BigWigs_SendSync sends
|
||||
-- SendAddonMessage("BigWigs", "<syncName> <rest>", "RAID")
|
||||
-- (Plugins/Comm.lua:57-67) and receives the identical shape back on
|
||||
-- CHAT_MSG_ADDON with prefix == "BigWigs" (Plugins/Comm.lua:43-54:
|
||||
-- local _, _, sync, rest = string.find(message, "(%S+)%s*(.*)$")
|
||||
-- -- the exact split used below). The pull-timer sync names
|
||||
-- (Plugins/Pulltimer.lua:27-32):
|
||||
-- "PulltimerSync" -- a (possibly soft/relayed) timer start,
|
||||
-- rest = duration in seconds
|
||||
-- "PulltimerBroadcastSync" -- an explicit rebroadcast, same rest shape
|
||||
-- "PulltimerStopSync" -- pull aborted -- no duration, never a start
|
||||
-- "PulltimerReqSync" -- "is a timer running" -- no duration
|
||||
-- DoPull only ever Syncs a POSITIVE duration (Plugins/Pulltimer.lua:203-
|
||||
-- 223: an explicit 0 either stops an existing bar or resolves to 6 before
|
||||
-- sending), and BigWigs_Pulltimer itself discards anything `tonumber`
|
||||
-- rejects (Plugins/Pulltimer.lua:256-260) -- so "sync name matches AND
|
||||
-- rest is a positive number" is both necessary and sufficient for "a pull
|
||||
-- just started".
|
||||
-- No pfUI pull timer exists on this client (checked: no "pull" or
|
||||
-- "Pulltimer" hit anywhere under Interface/AddOns/pfUI) -- BigWigs is the
|
||||
-- only pull timer installed, so it is the only wire this file speaks.
|
||||
-- Live confirmation the "BigWigs"/RAID channel is real traffic on this
|
||||
-- server, not merely installed-but-idle code: captured chat addon
|
||||
-- messages carry the same "<sync> <rest>" shape (a ward-fired notice).
|
||||
-- No PulltimerSync sample was captured, so the exact in-game duration
|
||||
-- argument is confirmed from source, not from a captured packet.
|
||||
--
|
||||
-- Self-pull: does the SENDER's own client receive its own CHAT_MSG_ADDON on
|
||||
-- this fork? Unverified in-game either way -- but BigWigs' own code does not
|
||||
-- trust it: BigWigs_SendSync calls SendAddonMessage AND ALSO invokes its own
|
||||
-- CHAT_MSG_ADDON handler directly, passing the sender's own name, right
|
||||
-- after (Plugins/Comm.lua:64-65). That is strong circumstantial evidence the
|
||||
-- authors did not want a raid leader's own /pull to depend on the echo. This
|
||||
-- file does the analogous thing: it hooks the global SendAddonMessage
|
||||
-- (guarded, pcall-wrapped, every return value passed through unchanged) and
|
||||
-- feeds prefix=="BigWigs" traffic through the SAME parser the CHAT_MSG_ADDON
|
||||
-- handler uses. If the echo DOES fire on this client, the debounce below
|
||||
-- absorbs the harmless double trigger; if it does not, the hook is the only
|
||||
-- path a self-started pull has.
|
||||
--
|
||||
-- The scan itself rides the SAME entry point as the ready-check path
|
||||
-- (DC_Scan.Start, scan/engine.lua) and is silent BY CONSTRUCTION: E.Start
|
||||
-- never shows any UI frame -- the ready-check path's own auto-OPEN is a
|
||||
-- SEPARATE decision made by core/init.lua's READY_CHECK handler
|
||||
-- (db.readyCheckOpen -> DC_Matrix.Show()), which this file never touches or
|
||||
-- even requires loaded. The resulting store gets a `trigger = "pull"` tag
|
||||
-- (E.NewStore, scan/engine.lua) purely so dev/raiddump.lua's S line and the
|
||||
-- store itself say which scans came from a pull timer; store.source stays
|
||||
-- "scan" either way -- every real gate keeps reading THAT field.
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline: P.ParsePull/P.ShouldTrigger are
|
||||
-- pure; the frame, the event and the SendAddonMessage hook sit behind
|
||||
-- `if CreateFrame then`, the same convention as every other scan/*.lua file.
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Pull = DC_Pull or {}
|
||||
local P = DC_Pull
|
||||
|
||||
-- at most one pull-triggered scan every this many seconds
|
||||
P.DEBOUNCE = 15
|
||||
|
||||
-- ==================================================================
|
||||
-- Pure part
|
||||
-- ==================================================================
|
||||
|
||||
-- ParsePull(prefix, msg) -> duration (number > 0) | nil
|
||||
-- Recognizes a pull-timer START message on the BigWigs wire (see header).
|
||||
-- Everything else (wrong prefix, a stop/req sync, a non-numeric or
|
||||
-- non-positive rest) returns nil -- including a message from some OTHER
|
||||
-- addon that happens to share the "BigWigs" prefix by accident, which would
|
||||
-- simply fail the sync-name check.
|
||||
function P.ParsePull(prefix, msg)
|
||||
if prefix ~= "BigWigs" or type(msg) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local _, _, sync, rest = string.find(msg, "(%S+)%s*(.*)$")
|
||||
if sync ~= "PulltimerSync" and sync ~= "PulltimerBroadcastSync" then
|
||||
return nil
|
||||
end
|
||||
local dur = tonumber(rest)
|
||||
if dur and dur > 0 then
|
||||
return dur
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ShouldTrigger(now, lastAt, opts) -> bool
|
||||
-- opts = { enabled, inGroup, simOwnsStore, scanRunning }
|
||||
-- Every gate named in the brief, in one pure, orthogonal predicate: the
|
||||
-- setting, group membership, the sim-store lock and the debounce window.
|
||||
-- `scanRunning` is a SEPARATE gate from the debounce window -- a long scan
|
||||
-- (a large raid, batched 5 units/frame in scan/engine.lua) can still be
|
||||
-- running well past 15s, and a second pull during it must not restart it:
|
||||
-- unlike a manual/ready-check Start(true), a pull-triggered Start is never
|
||||
-- `force`, so DC_Scan.Start would already no-op on its own -- this gate just
|
||||
-- avoids bumping `lastAt` (and thus the debounce window) for a call that was
|
||||
-- never going to do anything.
|
||||
function P.ShouldTrigger(now, lastAt, opts)
|
||||
opts = opts or {}
|
||||
if not opts.enabled then
|
||||
return false
|
||||
end
|
||||
if opts.simOwnsStore then
|
||||
return false
|
||||
end
|
||||
if not opts.inGroup then
|
||||
return false
|
||||
end
|
||||
if opts.scanRunning then
|
||||
return false
|
||||
end
|
||||
if lastAt and (now - lastAt) < P.DEBOUNCE then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- WoW part
|
||||
-- ==================================================================
|
||||
if CreateFrame then
|
||||
|
||||
local lastAt = nil
|
||||
|
||||
-- Raid count first, party count only when it is 0 -- same convention as
|
||||
-- core/init.lua's (private) GroupSize(): both read the same two API
|
||||
-- calls and would drift identically if either ever changed shape.
|
||||
local function inGroup()
|
||||
local n = GetNumRaidMembers and GetNumRaidMembers() or 0
|
||||
if n and n > 0 then
|
||||
return true
|
||||
end
|
||||
n = GetNumPartyMembers and GetNumPartyMembers() or 0
|
||||
return (n or 0) > 0
|
||||
end
|
||||
|
||||
local function pullEnabled(db)
|
||||
-- default ON, like every other auto-scan switch in this file family
|
||||
-- (db.readyCheckScan, core/config.lua) -- NEVER `db.pullTimerScan`
|
||||
-- alone (would misread a saved `false` as truthy-missing).
|
||||
return not (db and db.pullTimerScan == false)
|
||||
end
|
||||
|
||||
-- Shared by the CHAT_MSG_ADDON handler and the SendAddonMessage hook
|
||||
-- below -- one decision path, fed from two entry points (see header).
|
||||
local function maybeTrigger(prefix, msg)
|
||||
local dur = P.ParsePull(prefix, msg)
|
||||
if not dur then
|
||||
return
|
||||
end
|
||||
local db = DopingControlDB
|
||||
local now = GetTime and GetTime() or 0
|
||||
local scanRunning = (DC_Scan and DC_Scan.IsRunning
|
||||
and DC_Scan.IsRunning()) or false
|
||||
local go = P.ShouldTrigger(now, lastAt, {
|
||||
enabled = pullEnabled(db),
|
||||
inGroup = inGroup(),
|
||||
simOwnsStore = DC.SimOwnsStore(db),
|
||||
scanRunning = scanRunning,
|
||||
})
|
||||
if not go then
|
||||
return
|
||||
end
|
||||
lastAt = now
|
||||
if DC_Scan and DC_Scan.Start then
|
||||
DC_Scan.Start(nil, "pull")
|
||||
end
|
||||
end
|
||||
|
||||
local f = CreateFrame("Frame", "DopingControlPullFrame")
|
||||
f:RegisterEvent("CHAT_MSG_ADDON")
|
||||
f:SetScript("OnEvent", function()
|
||||
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender -- the
|
||||
-- channel/sender are not needed here, unlike scan/talents.lua: ANY
|
||||
-- raid/party member's pull (including our own, see below) is a
|
||||
-- trigger, so there is nothing left to filter on.
|
||||
maybeTrigger(arg1, arg2)
|
||||
end)
|
||||
|
||||
-- Self-pull backstop -- see the file header. Installed once; pcall-
|
||||
-- wrapped so a malformed call from ANY addon can never take the real
|
||||
-- SendAddonMessage down with it, and every return value is passed
|
||||
-- through unchanged so no other addon (BigWigs included) can tell it
|
||||
-- was wrapped.
|
||||
if SendAddonMessage and not P.hooked then
|
||||
P.hooked = true
|
||||
local orig = SendAddonMessage
|
||||
P.origSendAddonMessage = orig -- exposed for tests
|
||||
SendAddonMessage = function(prefix, msg, chatType, target)
|
||||
-- our own logic is guarded so it can never break the caller's
|
||||
-- send; the original runs unguarded so its errors still reach
|
||||
-- the calling addon exactly as before the hook
|
||||
pcall(maybeTrigger, prefix, msg)
|
||||
return orig(prefix, msg, chatType, target)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
-- DopingControl scan/reach.lua
|
||||
-- Unit readability probe: online FALLBACK via UnitIsConnected, distance via
|
||||
-- UnitXP_SP3 "distanceBetween". Distance is DIAGNOSTIC ONLY -- it is written
|
||||
-- into the store but NEVER used as a filter (readability is
|
||||
-- decided from the read RESULT, not from a range assumption).
|
||||
--
|
||||
-- ONLINE IS A FALLBACK ONLY (measured
|
||||
-- in-game): the raid roster online flag (GetRaidRosterInfo return 8) is
|
||||
-- SERVER TRUTH and wins in scan/engine.lua scanOne -- UnitIsConnected can
|
||||
-- read nil mid-zoning while the player is actually online (false-offline
|
||||
-- bug). The reach online result is only consulted for roster entries
|
||||
-- WITHOUT a roster flag (the party path).
|
||||
--
|
||||
-- Pure Lua 5.0, dofile-loadable offline: the WoW calls live in ReadUnit,
|
||||
-- which is only invoked in-game; Normalize is pure and offline-tested.
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Reach = DC_Reach or {}
|
||||
local R = DC_Reach
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Normalize(haveOnline, onlineRaw, distOk, distVal) -> online, distance
|
||||
-- haveOnline : bool -- UnitIsConnected existed AND its pcall succeeded.
|
||||
-- UnitIsConnected returns 1/nil, where nil is read
|
||||
-- as offline -- BUT it can also read nil mid-zoning
|
||||
-- while the player is online (measured in-game),
|
||||
-- which is why the engine only uses this value as a
|
||||
-- fallback when there is no roster online flag.
|
||||
-- onlineRaw : raw UnitIsConnected return (1/nil/false)
|
||||
-- distOk : pcall success of UnitXP("distanceBetween", ...)
|
||||
-- distVal : its first return
|
||||
-- Returns:
|
||||
-- online : true|false|nil -- nil = could not determine; the caller
|
||||
-- (engine scanOne) consults this at all
|
||||
-- only for entries without a roster flag
|
||||
-- distance : number|nil -- nil = not measurable (no UnitXP_SP3, or
|
||||
-- the call failed / returned a non-number)
|
||||
-- ------------------------------------------------------------------
|
||||
function R.Normalize(haveOnline, onlineRaw, distOk, distVal)
|
||||
local online = nil
|
||||
if haveOnline then
|
||||
if onlineRaw then
|
||||
online = true
|
||||
else
|
||||
online = false
|
||||
end
|
||||
end
|
||||
local distance = nil
|
||||
if distOk and type(distVal) == "number" then
|
||||
distance = distVal
|
||||
end
|
||||
return online, distance
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- ReadUnit(unit) -> online, distance (WoW runtime only)
|
||||
-- Every foreign-unit call is pcall-wrapped (hard rule).
|
||||
-- Missing UnitXP_SP3 only drops the diagnostic distance
|
||||
-- -- the online read runs regardless.
|
||||
-- ------------------------------------------------------------------
|
||||
function R.ReadUnit(unit)
|
||||
local haveOnline = false
|
||||
local onlineRaw = nil
|
||||
if UnitIsConnected then
|
||||
local ok, v = pcall(UnitIsConnected, unit)
|
||||
if ok then
|
||||
haveOnline = true
|
||||
onlineRaw = v
|
||||
end
|
||||
end
|
||||
local distOk = false
|
||||
local distVal = nil
|
||||
if UnitXP then
|
||||
distOk, distVal = pcall(UnitXP, "distanceBetween", "player", unit)
|
||||
end
|
||||
return R.Normalize(haveOnline, onlineRaw, distOk, distVal)
|
||||
end
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
-- DopingControl scan/resist.lua
|
||||
-- Pure resistance-reconstruction math: turn item TOOLTIP lines into a
|
||||
-- per-school sum, and look up a race's innate bonus. Mirrors scan/hit.lua's
|
||||
-- pure half (ParseHitLine/SumGear) one level down -- the constants live in
|
||||
-- data/resist.lua, this file only turns them into behavior.
|
||||
--
|
||||
-- NO WoW-glue function lives here on purpose: unlike scan/hit.lua/scan/
|
||||
-- gear.lua/scan/aura.lua, this module never calls SetInventoryItem itself.
|
||||
-- The RESIST tab's numbers are reconstructed from tooltip lines
|
||||
-- scan/hit.lua's item scan ALREADY fetched (see that file's itemHit()) --
|
||||
-- one more table walk over data already in hand, not a second tooltip
|
||||
-- operation per item. That is the entire point of hanging resistance
|
||||
-- reconstruction off the HIT tab's existing per-item cache instead of
|
||||
-- building an independent RESIST scan pass: 26 players x 19 slots would be
|
||||
-- ~500 extra SetInventoryItem calls per scan, and scan/hit.lua already pays
|
||||
-- that cost once per unique item.
|
||||
--
|
||||
-- Pure Lua 5.0, no WoW API, dofile-loadable offline: everything here is
|
||||
-- offline-tested (test_resist.lua).
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Resist = DC_Resist or {}
|
||||
local R = DC_Resist
|
||||
|
||||
-- ParseResistLine(text) -> kind, value | nil
|
||||
-- kind : a DC.RESIST_SCHOOLS name ("Fire", "Nature", ...) or "all"
|
||||
-- value : the flat resistance amount as a number
|
||||
-- Returns nil (a single value) when no pattern matches -- an unrecognized
|
||||
-- phrasing is ignored, never guessed at (same contract as
|
||||
-- DC_Hit.ParseHitLine).
|
||||
function R.ParseResistLine(text)
|
||||
if type(text) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local pats = DC.RESIST_PATTERNS
|
||||
if type(pats) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local n = table.getn(pats)
|
||||
for i = 1, n do
|
||||
local e = pats[i]
|
||||
local _, _, cap = string.find(text, e.pattern)
|
||||
if cap then
|
||||
local v = tonumber(cap)
|
||||
if v then
|
||||
return e.kind, v
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- SumLines(lines) -> sum
|
||||
-- lines : the FULL tooltip line array of ONE item, element 1 = item name
|
||||
-- (same convention as scan/hit.lua's itemHit -- line 1 is never
|
||||
-- a stat line, so the scan starts at 2; a caller that already
|
||||
-- stripped the name may safely pass an array starting at the
|
||||
-- real first stat line too, since no genuine item name matches
|
||||
-- "^%+%d+ <School> Resistance" or "^Equip: %+%d+ All
|
||||
-- Resistances" in the first place).
|
||||
-- sum : { [UnitResistance school index] = total } -- ONLY schools
|
||||
-- that actually appeared; a plain item with no resistance stat
|
||||
-- returns {} (empty, not nil) -- an empty sum is a real "this
|
||||
-- item carries no resistance", exactly like SumGear's zero.
|
||||
-- The "All Resistances" trap this table exists to close: a naive parser
|
||||
-- keyed only on the five single-school patterns would silently skip
|
||||
-- "Equip: +8 All Resistances." and undercount every one of the five
|
||||
-- columns by that amount. Handled here by fanning ONE matched line out
|
||||
-- over every entry of DC.RESIST_SCHOOLS.
|
||||
function R.SumLines(lines)
|
||||
local sum = {}
|
||||
lines = lines or {}
|
||||
local n = table.getn(lines)
|
||||
for i = 2, n do
|
||||
local kind, v = R.ParseResistLine(lines[i])
|
||||
if kind then
|
||||
if kind == "all" then
|
||||
local schools = DC.RESIST_SCHOOLS or {}
|
||||
for s = 1, table.getn(schools) do
|
||||
local school = schools[s].school
|
||||
sum[school] = (sum[school] or 0) + v
|
||||
end
|
||||
else
|
||||
local num = DC.RESIST_SCHOOL_NUM and DC.RESIST_SCHOOL_NUM[kind]
|
||||
if num then
|
||||
sum[num] = (sum[num] or 0) + v
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return sum
|
||||
end
|
||||
|
||||
-- RaceBonus(race) -> { [UnitResistance school index] = bonus }
|
||||
-- ALWAYS returns a table (empty for "no bonus", an unrecognized race
|
||||
-- token/display name, or nil input) so callers can fold it onto a gear sum
|
||||
-- without a nil check. The returned table is a FRESH COPY, never the
|
||||
-- shared DC.RACE_RESIST entry itself -- DC.RACE_RESIST aliases two
|
||||
-- spellings of the same race to ONE table object, so handing that
|
||||
-- reference out would let a caller's in-place addition (E.AssembleResists
|
||||
-- folds this straight onto the gear sum) corrupt the shared constant for
|
||||
-- every future lookup of that race.
|
||||
function R.RaceBonus(race)
|
||||
local out = {}
|
||||
local tbl = race and DC.RACE_RESIST and DC.RACE_RESIST[race]
|
||||
if tbl then
|
||||
for school, v in pairs(tbl) do
|
||||
out[school] = v
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
@@ -0,0 +1,431 @@
|
||||
-- DopingControl scan/talents.lua
|
||||
-- Reading OTHER players' talents.
|
||||
--
|
||||
-- Why this file exists: talents raise hit (Precision, Elemental Precision,
|
||||
-- Shadow Focus, Suppression, ...), so a hit number that omits them is
|
||||
-- systematically LOW. GetTalentInfo has no unit parameter and the server
|
||||
-- never transmits foreign talents (no SMSG_INSPECT_TALENT in this fork,
|
||||
-- no talent field in InitVisibleBits or SMSG_PARTY_MEMBER_STATS).
|
||||
--
|
||||
-- This server nonetheless ships its OWN inspect protocol, and it is a
|
||||
-- plain addon-message conversation any addon may hold.
|
||||
--
|
||||
-- ask SendAddonMessage("TW_CHAT_MSG_WHISPER<Name>", "INSTalentShow",
|
||||
-- "GUILD")
|
||||
-- reply the TARGET's client runs GetTalentInfo on ITSELF and sends one
|
||||
-- message per line:
|
||||
-- INSTalentTabInfo;<tree>;<treeName>;<pointsSpent>;<numTalents>
|
||||
-- INSTalentInfo;<tree>;<index>;<name>;<tier>;<column>;
|
||||
-- <currRank>;<maxRank>;<meetsPrereq>;
|
||||
-- <ptier>;<pcolumn>;<isLearnable>
|
||||
-- INSTalentEND;
|
||||
--
|
||||
-- Cost, as observed: ONE outgoing message per player; the reply is a few
|
||||
-- dozen messages, ending in INSTalentEND.
|
||||
--
|
||||
-- Three properties of the reply path decide the design here:
|
||||
--
|
||||
-- * The answering client checks NOTHING -- no CanInspect, no group,
|
||||
-- guild, range or visibility test. Anyone whose NAME we know answers.
|
||||
-- So the limit has to be ours: we only ask for players the scan
|
||||
-- actually shows (ASK_GAP apart, each name at most REASK_AFTER), and
|
||||
-- the whole sender can be switched off.
|
||||
-- * We cannot ask for single talents. What gets sent is decided by the
|
||||
-- target's client (Turtle's own code in patch-9.mpq), not by us -- it
|
||||
-- dumps the whole tree. Our outgoing traffic is one message per
|
||||
-- player; everything else is inbound and we drop the lines we do not
|
||||
-- need.
|
||||
-- * 1.12's wire format is PREFIX<TAB>MESSAGE, and the server rewrites
|
||||
-- the "<Name>" tag out of the prefix on the way -- which leaves the
|
||||
-- TAB at the HEAD OF THE PAYLOAD (measured: first bytes 9 73 78 83).
|
||||
-- An anchored match on "INS..." therefore fails on every single line.
|
||||
-- T.Normalize is not cosmetic; without it the whole reply is dropped.
|
||||
--
|
||||
-- The pure half (Normalize/Parse/Feed/Plausible/ShouldAsk) is offline
|
||||
-- tested under real Lua 5.0; only the frame, the event and SendAddonMessage
|
||||
-- are in-game.
|
||||
|
||||
DopingControl = DopingControl or {}
|
||||
local DC = DopingControl
|
||||
|
||||
DC_Talents = DC_Talents or {}
|
||||
local T = DC_Talents
|
||||
|
||||
-- addon-message prefix of the protocol (ours to send, theirs to answer on)
|
||||
T.PREFIX = "TW_CHAT_MSG_WHISPER"
|
||||
-- the channel argument is INERT once a "<Name>" tag is in the prefix (the
|
||||
-- tag is the whisper substitute) -- kept exactly as Turtle's own UI sends
|
||||
-- it, because a value the server does not expect is a needless risk
|
||||
T.CHANNEL = "GUILD"
|
||||
T.ASK_MSG = "INSTalentShow"
|
||||
|
||||
-- seconds between two outgoing requests (any target). Nothing documents a
|
||||
-- rate limit for THIS protocol, so the number is deliberately timid: one
|
||||
-- request per this many seconds is far below what Turtle's own UI produces
|
||||
-- when a player clicks through an inspect window.
|
||||
T.ASK_GAP = 5
|
||||
-- do not ask the same player again for this long. Talents change on
|
||||
-- respec, which is rare and never mid-raid.
|
||||
T.REASK_AFTER = 1800
|
||||
-- how long an unfinished reply stays open. A whole reply lands within a
|
||||
-- fraction of a second, so a line arriving this much later cannot belong to
|
||||
-- it -- it is the start of a NEW reply and must not be folded into the old
|
||||
-- accumulator (see T.AccStale).
|
||||
T.ACC_TTL = 10
|
||||
|
||||
-- ==================================================================
|
||||
-- PURE SECTION (offline-testable)
|
||||
-- ==================================================================
|
||||
|
||||
-- Strip the leading wire separator. See the header: the payload arrives as
|
||||
-- <TAB>INSTalent... because the server rewrote the name tag out of the
|
||||
-- prefix. Leading spaces/CR/LF are stripped too -- cheap, and it keeps the
|
||||
-- parser from depending on which whitespace the transport happens to leave.
|
||||
function T.Normalize(msg)
|
||||
if type(msg) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return (string.gsub(msg, "^[ \t\r\n]+", ""))
|
||||
end
|
||||
|
||||
-- Split on ";" -- Lua 5.0 has no string.split and no gmatch. Empty fields
|
||||
-- are preserved (the protocol uses fixed positions, so a dropped empty
|
||||
-- field would shift every later one).
|
||||
local function split(s)
|
||||
local out, from = {}, 1
|
||||
while true do
|
||||
local i = string.find(s, ";", from, true)
|
||||
if not i then
|
||||
table.insert(out, string.sub(s, from))
|
||||
break
|
||||
end
|
||||
table.insert(out, string.sub(s, from, i - 1))
|
||||
from = i + 1
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Classify + decode one payload.
|
||||
-- Returns "tab", { tree, treeName, spent, numTalents }
|
||||
-- or "talent", { tree, index, name, tier, column, rank, maxRank }
|
||||
-- or "end", nil
|
||||
-- or nil (not ours -- every other addon's traffic lands here too)
|
||||
--
|
||||
-- A talent line with a MISSING or non-numeric rank is rejected rather than
|
||||
-- read as 0: a silently zeroed rank looks exactly like an untrained talent
|
||||
-- and would understate hit without any sign that something went wrong.
|
||||
function T.Parse(msg)
|
||||
msg = T.Normalize(msg)
|
||||
if msg == "" then
|
||||
return nil
|
||||
end
|
||||
if string.find(msg, "INSTalentEND", 1, true) == 1 then
|
||||
return "end", nil
|
||||
end
|
||||
if string.find(msg, "INSTalentTabInfo;", 1, true) == 1 then
|
||||
local f = split(msg)
|
||||
local tree = tonumber(f[2])
|
||||
local spent = tonumber(f[4])
|
||||
if not tree or not spent then
|
||||
return nil
|
||||
end
|
||||
return "tab", { tree = tree, treeName = f[3], spent = spent,
|
||||
numTalents = tonumber(f[5]) }
|
||||
end
|
||||
if string.find(msg, "INSTalentInfo;", 1, true) == 1 then
|
||||
local f = split(msg)
|
||||
local tree = tonumber(f[2])
|
||||
local name = f[4]
|
||||
local rank = tonumber(f[7])
|
||||
if not tree or type(name) ~= "string" or name == "" or not rank then
|
||||
return nil
|
||||
end
|
||||
return "talent", { tree = tree, index = tonumber(f[3]), name = name,
|
||||
tier = tonumber(f[5]), column = tonumber(f[6]),
|
||||
rank = rank, maxRank = tonumber(f[8]) }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- A per-sender accumulator. Shape:
|
||||
-- { ranks = { [talentName] = rank }, -- rank > 0 only, like the own read
|
||||
-- spent = <sum of pointsSpent over the trees that reported>,
|
||||
-- sumRank = <sum of every rank we stored>,
|
||||
-- trees = <how many tab lines arrived>,
|
||||
-- complete = <INSTalentEND seen>,
|
||||
-- at = <when the first line arrived, nil offline> }
|
||||
function T.NewAcc(now)
|
||||
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false,
|
||||
at = now }
|
||||
end
|
||||
|
||||
-- Has this accumulator been waiting so long that the next line must belong to
|
||||
-- a different reply? Only the END marker drops an accumulator, so a reply
|
||||
-- whose tail was lost would otherwise stay open forever and swallow the next
|
||||
-- one: tab lines add up twice while ranks are deduplicated by name, so
|
||||
-- T.Plausible rejects a perfectly good retry. Without a clock (offline) or an
|
||||
-- opening stamp nothing expires -- the old behaviour, unchanged.
|
||||
function T.AccStale(acc, now)
|
||||
if not acc or not acc.at or not now then
|
||||
return false
|
||||
end
|
||||
return (now - acc.at) > T.ACC_TTL
|
||||
end
|
||||
|
||||
-- Fold one parsed line into the accumulator. Returns the accumulator so
|
||||
-- callers can chain; `complete` flips exactly once, on the END marker.
|
||||
function T.Feed(acc, kind, data)
|
||||
if not acc or not kind then
|
||||
return acc
|
||||
end
|
||||
if kind == "end" then
|
||||
acc.complete = true
|
||||
return acc
|
||||
end
|
||||
if kind == "tab" and data then
|
||||
acc.trees = acc.trees + 1
|
||||
acc.spent = acc.spent + (data.spent or 0)
|
||||
return acc
|
||||
end
|
||||
if kind == "talent" and data and data.rank and data.rank > 0 then
|
||||
-- keyed BY NAME, because that is what DC.TALENT_HIT matches on
|
||||
-- (data/talenthit.lua) and what ReadSelfTalents produces -- the two
|
||||
-- paths must hand DC_Hit.TalentHit the same shape or they would
|
||||
-- drift apart silently
|
||||
if not acc.ranks[data.name] then
|
||||
acc.sumRank = acc.sumRank + data.rank
|
||||
acc.ranks[data.name] = data.rank
|
||||
end
|
||||
end
|
||||
return acc
|
||||
end
|
||||
|
||||
-- Consistency gate before a reply is believed.
|
||||
--
|
||||
-- The client that answered told us two independent things: how many points
|
||||
-- it spent per tree, and every individual rank. They must agree. They are
|
||||
-- computed from the same source on the far side, so agreement does not
|
||||
-- prove the data is FRESH -- but disagreement proves we mis-parsed or lost
|
||||
-- messages, and that is exactly the failure this protects against (a
|
||||
-- half-received tree would understate hit while looking perfectly normal).
|
||||
--
|
||||
-- All three trees must have reported; a talentless character legitimately
|
||||
-- sends three empty tab lines and zero talent lines, which passes.
|
||||
function T.Plausible(acc)
|
||||
if not acc or not acc.complete then
|
||||
return false
|
||||
end
|
||||
if acc.trees < 3 then
|
||||
return false
|
||||
end
|
||||
return acc.sumRank == acc.spent
|
||||
end
|
||||
|
||||
-- Throttle decision, pure so the whole policy is testable without a client.
|
||||
-- asked = { [name] = <time we last asked> }
|
||||
-- lastAny = <time of the last request to ANYBODY>
|
||||
function T.ShouldAsk(name, now, asked, lastAny)
|
||||
if type(name) ~= "string" or name == "" then
|
||||
return false
|
||||
end
|
||||
now = now or 0
|
||||
if lastAny and (now - lastAny) < T.ASK_GAP then
|
||||
return false
|
||||
end
|
||||
local last = asked and asked[name]
|
||||
if last and (now - last) < T.REASK_AFTER then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ==================================================================
|
||||
-- WOW SECTION -- frame, event, sending
|
||||
-- ==================================================================
|
||||
if CreateFrame then
|
||||
|
||||
local acc = {} -- [sender] = accumulator, dropped once complete
|
||||
local ranks = {} -- [sender] = { [talent] = rank }, the result
|
||||
local readAt = {} -- [sender] = when that result landed
|
||||
local asked = {} -- [name] = when we last asked
|
||||
local lastAny = nil -- when we last asked anybody
|
||||
local queue = {} -- names waiting to be asked
|
||||
local queued = {} -- [name] = true, so the queue cannot hold dupes
|
||||
|
||||
local function enabled()
|
||||
local d = DopingControlDB
|
||||
-- default ON: an addon that computes hit and silently leaves out a
|
||||
-- readable component is the bug this file fixes
|
||||
return not (d and d.readTalents == false)
|
||||
end
|
||||
|
||||
-- ranks for a player, or nil if we have not got a plausible reply yet.
|
||||
-- nil (not {}) is load-bearing, exactly as for the own character:
|
||||
-- "not read" and "read, no talents" are different states and only the
|
||||
-- second one may count as a measured zero.
|
||||
function T.RanksFor(name)
|
||||
return ranks[name]
|
||||
end
|
||||
|
||||
function T.ReadAt(name)
|
||||
return readAt[name]
|
||||
end
|
||||
|
||||
-- diagnostics for /dc talents
|
||||
function T.Stats()
|
||||
local nRanks, nPending = 0, 0
|
||||
for _ in pairs(ranks) do
|
||||
nRanks = nRanks + 1
|
||||
end
|
||||
for _ in pairs(acc) do
|
||||
nPending = nPending + 1
|
||||
end
|
||||
return { known = nRanks, pending = nPending,
|
||||
queued = table.getn(queue), enabled = enabled() }
|
||||
end
|
||||
|
||||
function T.Ask(name)
|
||||
if not enabled() or type(name) ~= "string" or name == "" then
|
||||
return false
|
||||
end
|
||||
if not SendAddonMessage then
|
||||
return false
|
||||
end
|
||||
-- pcall: a malformed name must never take the scan down with it
|
||||
local ok = pcall(SendAddonMessage, T.PREFIX .. "<" .. name .. ">",
|
||||
T.ASK_MSG, T.CHANNEL)
|
||||
if ok then
|
||||
asked[name] = GetTime()
|
||||
lastAny = asked[name]
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- set below, once the frame exists: arms the paced sender
|
||||
local arm
|
||||
|
||||
-- Called by the scan with the players it can see. Nothing is sent here
|
||||
-- -- names are only lined up; the timer below paces them out.
|
||||
function T.Request(names)
|
||||
if not enabled() or type(names) ~= "table" then
|
||||
return
|
||||
end
|
||||
local now = GetTime()
|
||||
for i = 1, table.getn(names) do
|
||||
local n = names[i]
|
||||
if type(n) == "string" and n ~= "" and not queued[n]
|
||||
and T.ShouldAsk(n, now, asked, nil) then
|
||||
queued[n] = true
|
||||
table.insert(queue, n)
|
||||
end
|
||||
end
|
||||
if arm and table.getn(queue) > 0 then
|
||||
arm()
|
||||
end
|
||||
end
|
||||
|
||||
local f = CreateFrame("Frame", "DopingControlTalentFrame")
|
||||
f:RegisterEvent("CHAT_MSG_ADDON")
|
||||
f:SetScript("OnEvent", function()
|
||||
-- 1.12: arg1 prefix, arg2 message, arg3 channel, arg4 sender
|
||||
if arg1 ~= T.PREFIX then
|
||||
return
|
||||
end
|
||||
local sender = arg4
|
||||
if type(sender) ~= "string" or sender == "" then
|
||||
return
|
||||
end
|
||||
local kind, data = T.Parse(arg2)
|
||||
if not kind then
|
||||
return
|
||||
end
|
||||
local now = GetTime()
|
||||
local a = acc[sender]
|
||||
if a and T.AccStale(a, now) then
|
||||
-- the previous reply lost its END line; this one starts fresh
|
||||
a = nil
|
||||
end
|
||||
if not a then
|
||||
a = T.NewAcc(now)
|
||||
acc[sender] = a
|
||||
end
|
||||
T.Feed(a, kind, data)
|
||||
if a.complete then
|
||||
acc[sender] = nil
|
||||
if T.Plausible(a) then
|
||||
ranks[sender] = a.ranks
|
||||
readAt[sender] = GetTime()
|
||||
-- a fresh talent set changes computed hit, so the view has
|
||||
-- to be rebuilt -- without this the number only corrects
|
||||
-- itself on the next scan
|
||||
if DC_Matrix and DC_Matrix.Refresh then
|
||||
DC_Matrix.Refresh()
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- Paced sender: one request per T.ASK_GAP, and only ever from the
|
||||
-- queue the scan filled. Deliberately a plain timer rather than a burst
|
||||
-- after each scan -- 40 requests in one frame is what a rate limit
|
||||
-- would punish, and nothing documents where that limit sits.
|
||||
--
|
||||
-- It is installed only while there is something to send and takes itself
|
||||
-- off again the moment the queue runs dry (or the feature is switched
|
||||
-- off): an OnUpdate runs 60+ times a second for the whole session, and a
|
||||
-- player who never scans must not pay for a queue that is always empty.
|
||||
-- Re-arming needs no extra wiring -- every scan calls T.Request.
|
||||
local elapsed = 0
|
||||
|
||||
local function disarm()
|
||||
f:SetScript("OnUpdate", nil)
|
||||
end
|
||||
|
||||
local function drain()
|
||||
elapsed = elapsed + (arg1 or 0)
|
||||
if elapsed < 1 then
|
||||
return
|
||||
end
|
||||
elapsed = 0
|
||||
if not enabled() or table.getn(queue) == 0 then
|
||||
disarm()
|
||||
return
|
||||
end
|
||||
local now = GetTime()
|
||||
if lastAny and (now - lastAny) < T.ASK_GAP then
|
||||
return
|
||||
end
|
||||
local name = table.remove(queue, 1)
|
||||
if name then
|
||||
queued[name] = nil
|
||||
if T.ShouldAsk(name, now, asked, lastAny) then
|
||||
T.Ask(name)
|
||||
end
|
||||
end
|
||||
if table.getn(queue) == 0 then
|
||||
disarm()
|
||||
end
|
||||
end
|
||||
|
||||
arm = function()
|
||||
if f:GetScript("OnUpdate") then
|
||||
return
|
||||
end
|
||||
-- a long idle gap must not fire an immediate burst
|
||||
elapsed = 0
|
||||
f:SetScript("OnUpdate", drain)
|
||||
end
|
||||
|
||||
-- forget everything (respec, or a deliberate re-read)
|
||||
function T.Clear()
|
||||
disarm()
|
||||
acc = {}
|
||||
ranks = {}
|
||||
readAt = {}
|
||||
asked = {}
|
||||
queue = {}
|
||||
queued = {}
|
||||
lastAny = nil
|
||||
end
|
||||
|
||||
end
|
||||
Reference in New Issue
Block a user