-- 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:", 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:" -- 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:" 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:" 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:") -- 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:"). -- Without a resolved name, behavior is unchanged: "spell:". 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("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:"). 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