DopingControl v0.6.1

This commit is contained in:
2026-08-03 09:47:19 +02:00
commit 6859d73285
41 changed files with 15056 additions and 0 deletions
+496
View File
@@ -0,0 +1,496 @@
-- 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
+1103
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, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
-- InvSlots() -> array of the 16 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
+1052
View File
File diff suppressed because it is too large Load Diff
+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
+375
View File
@@ -0,0 +1,375 @@
-- 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
-- ==================================================================
-- 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> }
function T.NewAcc()
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false }
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
-- 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
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 a = acc[sender]
if not a then
a = T.NewAcc()
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.
local elapsed = 0
f:SetScript("OnUpdate", function()
elapsed = elapsed + (arg1 or 0)
if elapsed < 1 then
return
end
elapsed = 0
if not enabled() or table.getn(queue) == 0 then
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
end)
-- forget everything (respec, or a deliberate re-read)
function T.Clear()
acc = {}
ranks = {}
readAt = {}
asked = {}
queue = {}
queued = {}
lastAny = nil
end
end