DopingControl 0.7.0

This commit is contained in:
2026-10-15 12:29:40 +02:00
commit e6561d3bce
48 changed files with 19254 additions and 0 deletions
+908
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+222
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -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
+79
View File
@@ -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
View File
@@ -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
+431
View File
@@ -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