497 lines
20 KiB
Lua
497 lines
20 KiB
Lua
-- 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.
|
|
-- 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 } (path A; texture is the 1st
|
|
-- UnitBuff return, 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 } }
|
|
-- (store shape; textures 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 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
|
|
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 },
|
|
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
|
|
|
|
-- raises an error on bad units -> always called through pcall
|
|
local function tipBuffName(unit, buffIndex)
|
|
scanTip:ClearLines()
|
|
scanTip:SetUnitBuff(unit, buffIndex)
|
|
local textObj = getglobal("DopingControlScanTipTextLeft1")
|
|
if textObj then
|
|
return textObj:GetText()
|
|
end
|
|
return nil
|
|
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 = nil
|
|
if tip then
|
|
local ok2, nm = pcall(tipBuffName, unit, i)
|
|
if ok2 then
|
|
name = nm
|
|
end
|
|
end
|
|
local id = nil
|
|
if type(auraID) == "number" and auraID > 0 then
|
|
id = auraID
|
|
end
|
|
-- texture (UnitBuff return 1) rides along so Assemble can key
|
|
-- it under the tooltip name (auras.textures)
|
|
table.insert(rawA, { name = name, id = id, texture = texture })
|
|
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
|