-- DopingControl scan/engine.lua -- Scan orchestration: roster snapshot, per-unit reads (reach/aura/gear/ -- weapon), OnUpdate batching (5 units per frame), store -- assembly and the DC.OnStoreUpdated() completion hook -- (wired by core/init.lua). -- -- Triggers: -- * manual: DC_Scan.Start(force) -- * READY_CHECK: registered DEFENSIVELY via pcall (unverified as a -- native client event on 1.12; if the -- registration throws, the feature silently does not exist). Honors -- db.readyCheckScan and never fires in test mode (sim store). -- * roster change (RAID_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED): raid -- indices are VOLATILE after these events, -- so a running scan is restarted with a fresh roster snapshot; a -- finished store is flagged stale via DC.storeInvalid. -- No UNIT_AURA registration at all: UNIT_AURA is unreliable for raidN -- tokens on 1.12 -- we scan on demand instead. -- -- Diagnostics filled here: -- DC.probeStats = { scans, unitsPathA, unitsPathB, unitsBoth, -- idDisagreements, lastScan = { unitsA, unitsB, both, -- disagreements } } -- -- cumulative A/B aura-path coverage + ID-set -- disagreement counts (scan/aura.lua merge) -- DC.unknownSeen = { [nameOrSpellKey] = count } -- auras no table knows -- (no silent discard, dumped by /dc unknown) -- -- Last-known cache: DC.lastKnown -- see the -- "Last-known cache" block below. THE IRON RULE: cached data is DISPLAY-ONLY; -- it never counts as HAS/MISSING in gaps, ready pills, sums, footer or -- report. A flask seen 20 minutes ago is not verified preparation. -- -- RESIST tab data: resistances are read via THREE DIRECT fallback paths -- (unit token, GUID, GetUnitField descriptor -- see readResists/ -- AssembleResists below), independent of the aura/gear reads, plus a -- FOURTH gear-reconstruction path for foreign players (added 2026-08-02): -- the server never transmits UNIT_FIELD_RESISTANCES for anyone but the -- owning client, so the three direct paths can only ever answer for the -- own character -- every foreign player's resistances are rebuilt from -- equipped-item tooltips (scan/hit.lua's per-item cache, scan/resist.lua's -- pure parser) plus the racial bonus (data/resist.lua DC.RACE_RESIST). All -- four ride into the store as p.resists/p.resistsRead/p.resistsSource -- ("token"|"guid"|"field"|"gear"|nil for self). -- -- HIT tab data: scan/hit.lua sums the +hit of the equipped items from -- their tooltips -- there is no hit API on this client -- and rides into -- the store as p.hit (its own hitRead flag lives INSIDE that table) plus -- p.race, which the display layer needs for the racial weapon-skill -- estimate of foreign players. The hit read reuses the item links the gear -- read already fetched, so it costs one extra link call (the waist, which -- has no enchant column) plus one tooltip build per item that is not in -- scan/hit.lua's per-item cache. This SAME read now also produces -- hit.resistGear/resistGearRead (see above), which is why scanOne runs the -- hit read BEFORE the resist read despite RESIST sitting before HIT on the -- tab bar. -- -- WEAPON SKILL is readable for the OWN CHARACTER ONLY, and is stated as -- such rather than guessed for everyone else: MEASURED via -- GetSkillLineInfo and stored as hit.skill/hit.skillExact, while foreign -- weapon skill has no channel and stays an estimate made at display time. -- -- TALENT HIT (data/talenthit.lua) has TWO sources that meet in the same -- fields: GetTalentInfo for the own character, and this server's inspect -- protocol (scan/talents.lua) for everyone else. Both fill -- hit.talentMelee/talentRanged/talentSpell/talentOffhand/talentSchools -- with hit.talentsRead = true. A player whose reply has not arrived yet -- keeps talentsRead = false and zeroed talent fields, and their displayed -- number EXCLUDES talents until it does. -- -- Pure Lua 5.0, dofile-loadable offline: NormalizeWeapon / -- NormalizeWeaponName / AssembleResists / BuildPlayer / NewStore / -- UpdateLastKnown / SweepLastKnown are pure (offline-tested); everything -- that touches frames or the WoW API sits behind `if CreateFrame then`. DopingControl = DopingControl or {} local DC = DopingControl DC_Scan = DC_Scan or {} local E = DC_Scan E.BATCH = 5 -- units processed per OnUpdate frame DC.probeStats = DC.probeStats or { scans = 0, unitsPathA = 0, unitsPathB = 0, unitsBoth = 0, idDisagreements = 0, lastScan = { unitsA = 0, unitsB = 0, both = 0, disagreements = 0 }, } DC.unknownSeen = DC.unknownSeen or {} -- ================================================================== -- Pure part -- ================================================================== -- NormalizeWeapon(isSelf, v, readable) -> weaponMain (true|false|nil, store shape) -- self : v = 1st return of GetWeaponEnchantInfo() (hasMainEnchant); -- the own unit is always readable => never nil -- foreign: v = 1st return of GetWeaponEnchantInfo(unit) (SuperWoW): a name -- string => true. `readable` says whether the unit's main hand -- could be read at all and is what makes nil decidable. -- -- Measured 2026-08-02 across a 26-man raid, every unit with a readable main -- hand: nil came back 14 times, a name 11 times, the empty string NEVER. So -- the old ""-means-no-imbue contract (documented here as "unverified") is -- dead code, and nil carries BOTH real cases at once: "carries no imbue" and -- "we could not read this unit". Treating nil as unknown -- as this function -- did -- meant the WPN column could never report a gap: all 14 players -- without a weapon buff showed a grey "?" and were counted as unreadable, so -- they never reached the gap tally, the footer or a whisper. That is the one -- thing a raid buff checker exists to say. -- -- `readable` separates the two: a unit whose main-hand item link resolves is -- readable, so nil from the enchant call means there is genuinely no imbue -- (=> false, a real gap). Without the hint the old behaviour stands (nil), -- which keeps every existing caller and the self path untouched. function E.NormalizeWeapon(isSelf, v, readable) if isSelf then if v then return true end return false end if v == nil then if readable == true then return false end return nil end if v == "" then return false end return true end -- NormalizeWeaponName(v) -> weaponMainName (string|nil, store shape). -- v = 1st return of GetWeaponEnchantInfo(unit) -- (SuperWoW name path): non-empty string => the temp-enchant name; -- anything else => nil (measured in-game: nil at cross-continent -- distance -- acceptable, the weapon cell stays UNKNOWN per the existing -- weaponMain rules). function E.NormalizeWeaponName(v) if type(v) == "string" and v ~= "" then return v end return nil end -- BuildPlayer(base, online, distance, auraRes, gearRes, weaponMain, -- weaponMainName, resRes) -- base : { name, class (english token), guid|nil, subgroup|nil } -- online : bool (roster-first, reach fallback -- merged by the caller) -- auraRes : nil | { read = bool, auras = { names={}, ids={} }, -- debuffs = nil | { names={}, ids={}, textures={} } } -- (readability rides on -- read/aurasRead -- empty debuff sets are a legitimate clean -- state, never a failure) -- gearRes : nil | { read = bool, gear = table|nil } -- weaponMainName : string|nil -- main-hand temp-enchant NAME -- (self: tooltip scan via -- DC_Gear.ReadSelfWeaponEnchantName, foreign: SuperWoW -- GetWeaponEnchantInfo(unit) name); rides into the store as -- p.weaponMainName -- DC_Model uses it as the weapon HAS -- detail when set -- resRes : nil | { read = bool, resists = table|nil, source = string|nil } -- (E.AssembleResists' three returns, same read/wrapper -- convention as auraRes/gearRes) -- rides into the store as -- p.resists/p.resistsRead/p.resistsSource; independent of -- aurasRead/gearRead (a resistance read is neither an aura -- nor a gear read) -- hitRes : nil | { hit = table|nil, race = string|nil } (DC_Hit.ReadUnit's -- two returns). DELIBERATELY SHAPED DIFFERENTLY from the -- wrappers above: there is no `read` key, because the hit -- table carries its OWN hitRead flag and rides into the store -- EVEN WHEN the read failed (p.hit.hitRead == false). Every -- consumer must therefore test p.hit and p.hit.hitRead -- -- the zeros in a failed read are not a measurement. `race` is -- the race token, stored as p.race: the display layer needs it -- to estimate a FOREIGN player's weapon skill (racial -- specialization), which no API can read. -- -- Hit-granting AURAS (DC.HIT_AURAS, data/hit.lua) are folded -- in RIGHT HERE, not inside scan/hit.lua's ReadUnit -- that -- module only ever sees item tooltips, never the aura scan. -- No extra client call: p.auras was already assembled above -- by the SAME scan. The split is kept so the cell tooltip can -- show every source apart: hit.gearMelee/hit.gearSpell are the -- tooltip-parsed sums (unchanged from scan/hit.lua), -- hit.auraMelee/hit.auraRanged/hit.auraSpell are the aura -- sums, hit.talentMelee/talentRanged/talentSpell/ -- talentOffhand/talentSchools are the TALENT sums the scan -- filled in for the own character (normalized to zeros here -- for everyone else, with hit.talentsRead saying which it -- was), and hit.melee/hit.spell become the TOTAL -- the field -- every existing display-layer caller already reads. -- hit.aurasRead mirrors p.aurasRead at the moment this ran, so -- a failed aura channel shows as "aura sources unknown" -- (ui/matrix.lua) instead of a silent zero; hit.talentsRead -- does the same job for talents. -- Deliberately independent of any expectation/role table -- -- BuildPlayer never sees one -- so a hit-granting aura counts -- here even when its class-buff column (e.g. EMB) is not -- expected/active for this player at all. -- -> one store player entry. Readability rule -- (binding): online==false => "offline"; else aurasRead==false -- (aura paths yielded ZERO auras) => "read failed"; else nil. function E.BuildPlayer(base, online, distance, auraRes, gearRes, weaponMain, weaponMainName, resRes, hitRes) local p = { name = base.name, class = base.class, online = (online and true) or false, guid = base.guid, subgroup = base.subgroup, distance = distance, aurasRead = false, auras = nil, weaponMain = weaponMain, weaponMainName = weaponMainName, gearRead = false, gear = nil, resistsRead = false, resists = nil, resistsSource = nil, hit = nil, race = nil, unreadableReason = nil, } if auraRes and auraRes.read then p.aurasRead = true p.auras = auraRes.auras p.debuffs = auraRes.debuffs end if gearRes and gearRes.read then p.gearRead = true p.gear = gearRes.gear end if resRes and resRes.read then p.resistsRead = true p.resists = resRes.resists p.resistsSource = resRes.source end -- hit: attached WHATEVER the outcome (see the hitRes doc above) -- -- readability lives in p.hit.hitRead, not in a wrapper flag if hitRes then p.hit = hitRes.hit p.race = hitRes.race end -- hit-granting auras (DC.HIT_AURAS, data/hit.lua) folded on top of the -- gear sum -- see the hitRes doc above for the field split and why -- this runs here rather than in scan/hit.lua if p.hit then -- SET bonuses are part of the scanned totals but are reported -- separately, so the tooltip can print the number as an addition -- (items + set bonuses + auras + talents) rather than one merged -- figure. "gear" therefore means ITEMS ONLY from here on: the set -- part is subtracted out, never counted twice. p.hit.setMelee = p.hit.setMelee or 0 p.hit.setSpell = p.hit.setSpell or 0 p.hit.setSchools = p.hit.setSchools or {} p.hit.gearMelee = (p.hit.melee or 0) - p.hit.setMelee p.hit.gearSpell = (p.hit.spell or 0) - p.hit.setSpell p.hit.aurasRead = p.aurasRead -- TALENTS (data/talenthit.lua): filled from GetTalentInfo for the -- own character and from this server's inspect protocol -- (scan/talents.lua) for everyone else. Whoever has not answered -- YET keeps talentsRead = false and clean zeros. They are -- normalized HERE so no consumer has to nil-check them and so a -- hand-built or older store (sim, tests) folds identically. if p.hit.talentsRead == nil then p.hit.talentsRead = false end p.hit.talentMelee = p.hit.talentMelee or 0 p.hit.talentRanged = p.hit.talentRanged or 0 p.hit.talentSpell = p.hit.talentSpell or 0 p.hit.talentOffhand = p.hit.talentOffhand or 0 p.hit.talentSchools = p.hit.talentSchools or {} -- the aura half only exists when the aura channel worked: a FAILED -- read must not leave a zero that reads like a measurement, which -- is why auraMelee/auraRanged/auraSpell stay nil there (the cell -- tooltip tells "no aura present" from "aura sources unknown" by -- exactly this) local auraMelee, auraSpell = 0, 0 if p.aurasRead and DC_Hit and DC_Hit.AuraHit then local a = DC_Hit.AuraHit(p.auras and p.auras.names) p.hit.auraMelee = a.melee p.hit.auraRanged = a.ranged p.hit.auraSpell = a.spell auraMelee = a.melee auraSpell = a.spell end -- the TOTALS every display-layer caller reads. The ranged column -- has no total of its own on purpose: it shares the melee GEAR -- number (this client has no separate ranged-hit item stat) but -- has its own aura and talent halves, so it is composed as -- gearMelee + auraRanged + talentRanged where it is shown. p.hit.melee = p.hit.gearMelee + auraMelee + p.hit.talentMelee p.hit.spell = p.hit.gearSpell + auraSpell + p.hit.talentSpell end if not p.online then p.unreadableReason = "offline" elseif not p.aurasRead then p.unreadableReason = "read failed" end return p end -- AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback) -- -> resists (table|nil), resistsRead (bool), source (string|nil) -- -- Three DIRECT read paths, tried in priority order (token > guid > field); -- each row array may be empty (the WoW-only caller skips a path once an -- earlier one already delivered usable data -- see scanOne below): -- tokenRows/guidRows : { school = 2..6, ok = bool, total = number|nil } -- (raw pcall(UnitResistance, unitOrGuid, school) -- results -- `total` is UnitResistance's 2nd -- return; base/bonus/minus are not used here) -- fieldRows : { school = 2..6, val = number|nil } (raw -- GetUnitField(guid, "resistances") readout, -- already split into rows by the caller) -- -- A path counts as USABLE when at least one of its rows has ok==true (or, -- for fieldRows, a numeric val) -- the first usable path wins outright, -- its rows become `resists` (missing schools simply absent from the -- table; classify() defaults an absent school to 0). Field-path values -- are trusted ONLY when positive (a lone unverified foreign zero is not -- accepted from that path -- see the honesty rule below); token/guid -- values are trusted at face value, including legitimate zeros. -- -- Honesty rule (binding): resistsRead = true only when -- isSelf, OR the assembled `resists` contains at least one total > 0. A -- foreign UnitResistance call can silently return 0 for an out-of-sight/ -- out-of-range unit -- a LONE zero from a foreign read is not trusted (it -- could be a real 0 resistance or a failed read that happens to default -- to 0); a MIX with at least one real positive value is. `source` names -- the path that won ("token"|"guid"|"field") for foreign reads, and is -- always nil for the self read (self is unconditionally trusted, so the -- specific path is not interesting -- it is always "token" in practice, -- UnitResistance("player", school)). -- -- FOURTH path -- gear reconstruction (added 2026-08-02, see -- scan/resist.lua and data/resist.lua's headers for the full rationale): -- the server never transmits UNIT_FIELD_RESISTANCES for a foreign player -- at all (Player::InitVisibleBits() only sets the bit for the owning -- client), so paths 1-3 above are not merely "the usual case" for a -- foreign unit -- they can NEVER succeed for one. This is the only path -- that can ever answer for a foreign player, tried only once none of the -- three DIRECT paths produced anything trustworthy. -- gearFallback : nil, or { resistGear = { [school]=total }, -- resistGearRead = bool, race = string|nil } -- exactly -- DC_Hit.ReadUnit's hit.resistGear/hit.resistGearRead -- plus its race return, so the WoW-only caller can just -- hand its `hit`/`race` locals through unpacked. -- Gated on resistGearRead, NOT on "did any school come back positive": a -- tooltip-based read has no silent-zero ambiguity the way UnitResistance -- does for an out-of-range foreign unit (a GameTooltip:SetInventoryItem -- call either returns real lines or it visibly fails, see -- scan/hit.lua's header) -- once resistGearRead is true, an all-zero -- gear+racial total is a GENUINE verified zero, not a guess. -- NEVER taken for the own character (`not isSelf`): the own character's -- token path is always usable (see the honesty rule above) and must keep -- priority over any reconstruction, per the addon's binding rule that a -- MEASURED value always outranks a rebuilt one. -- Racial bonus (DC_Resist.RaceBonus, data/resist.lua's DC.RACE_RESIST) is -- added on top here rather than inside scan/hit.lua: it is not a gear -- fact, and scan/hit.lua's job stops at "what the equipped items say". -- Existence-guarded like every other DC_Resist call -- without that -- module this path simply never activates. function E.AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback) -- `any` decides which path WINS, so it has to mean "this path carried -- evidence". For a foreign unit an all-zero row set is not evidence -- (see readResists): it would otherwise beat a descriptor path that -- actually has the numbers. Zeros are still kept in `out` -- a mixed -- read (0 fire, 24 nature) is legitimate and complete. local function usableRows(rows) local out = {} local any = false rows = rows or {} for i = 1, table.getn(rows) do local r = rows[i] if r.ok and type(r.total) == "number" then out[r.school] = r.total if isSelf or r.total > 0 then any = true end end end return out, any end local resists, source local tok, tokAny = usableRows(tokenRows) if tokAny then resists, source = tok, "token" else local gid, gidAny = usableRows(guidRows) if gidAny then resists, source = gid, "guid" else local fld = {} local fldAny = false local rows = fieldRows or {} for i = 1, table.getn(rows) do local r = rows[i] -- field path: positive values only (see header) -- -- a zero/nil field entry carries no evidence if type(r.val) == "number" and r.val > 0 then fld[r.school] = r.val fldAny = true end end if fldAny then resists, source = fld, "field" end end end if resists ~= nil then local anyPositive = false for _, v in pairs(resists) do if v > 0 then anyPositive = true end end if isSelf or anyPositive then if isSelf then source = nil end return resists, true, source end -- all-zero foreign read from a direct path: falls through to the -- gear fallback below exactly like "no direct path answered at -- all" -- an untrusted all-zero carries no more evidence than no -- read at all (see the honesty rule above) end -- FOURTH path: gear reconstruction (see the header addendum above) -- -- never for the own character, and only once resistGearRead proves at -- least one item tooltip was actually read this scan. if not isSelf and gearFallback and gearFallback.resistGearRead then local g = {} for school, v in pairs(gearFallback.resistGear or {}) do g[school] = v end if DC_Resist and DC_Resist.RaceBonus then local racial = DC_Resist.RaceBonus(gearFallback.race) for school, v in pairs(racial) do g[school] = (g[school] or 0) + v end end return g, true, "gear" end return nil, false, nil end -- NewStore(players, scanAt) -> store (source = "scan") function E.NewStore(players, scanAt) return { scanAt = scanAt, source = "scan", players = players or {}, } end -- ================================================================== -- Last-known cache -- ================================================================== -- DC.lastKnown[name] = { auras=, aurasAt=, -- debuffs=, debuffsAt=, gear=, gearAt=, weaponMain=, -- weaponMainName=, weaponAt= } -- Runtime-only: lives on DC, NEVER in DopingControlDB/SavedVariables. -- Holds REFERENCES to the store tables (every scan builds fresh tables, -- so a cache entry never aliases a live player's current data). DC.lastKnown = DC.lastKnown or {} -- Hard entry cap for the cap-safety sweep. E.CACHE_CAP = 80 -- UpdateLastKnown(cache, p, now) -- both halves of the cache rule: -- * successful channels overwrite the cache channel + its At stamp: -- aurasRead=true (debuffs ride on the aura read -- they share its -- success and its stamp), gearRead=true, weaponMain non-nil (false -- included: a verified "no temp enchant" IS a successful read, and it -- clears any stale cached name); -- * failed channels attach the cached references onto the store player: -- p.lastAuras/p.lastAurasAt, p.lastGear/p.lastGearAt, -- p.lastWeaponMain/p.lastWeaponMainName/p.lastWeaponAt -- nil when no -- cache. (No lastDebuffs: the DEBUFFS tab has no last-known channel -- -- live afflictions only.) -- Failure never erases an older cache entry, and a failed read never -- CREATES an entry. function E.UpdateLastKnown(cache, p, now) if not p.name then return end local entry = cache[p.name] if p.aurasRead or p.gearRead or p.weaponMain ~= nil then if not entry then entry = {} cache[p.name] = entry end end if p.aurasRead then entry.auras = p.auras entry.aurasAt = now entry.debuffs = p.debuffs entry.debuffsAt = now end if p.gearRead then entry.gear = p.gear entry.gearAt = now end if p.weaponMain ~= nil then entry.weaponMain = p.weaponMain entry.weaponMainName = p.weaponMainName entry.weaponAt = now end if not entry then return end if not p.aurasRead then p.lastAuras = entry.auras p.lastAurasAt = entry.aurasAt end if not p.gearRead then p.lastGear = entry.gear p.lastGearAt = entry.gearAt end if p.weaponMain == nil then p.lastWeaponMain = entry.weaponMain p.lastWeaponMainName = entry.weaponMainName p.lastWeaponAt = entry.weaponAt end end -- SweepLastKnown(cache, rosterSet, cap) -- cap-safety eviction (item 1). -- rosterSet = { [name]=true } of the CURRENT roster. Roster members are -- never evicted (their rows may need the data right now); once the cache -- holds more than cap entries, roster-absent entries drop oldest-first -- (oldest = smallest NEWEST At across the entry's channels; name is the -- deterministic tiebreaker) until the size is back at cap. function E.SweepLastKnown(cache, rosterSet, cap) cap = cap or E.CACHE_CAP rosterSet = rosterSet or {} local size = 0 for _ in pairs(cache) do size = size + 1 end if size <= cap then return end local cand = {} for name, entry in pairs(cache) do if not rosterSet[name] then local newest = 0 if entry.aurasAt and entry.aurasAt > newest then newest = entry.aurasAt end if entry.debuffsAt and entry.debuffsAt > newest then newest = entry.debuffsAt end if entry.gearAt and entry.gearAt > newest then newest = entry.gearAt end if entry.weaponAt and entry.weaponAt > newest then newest = entry.weaponAt end table.insert(cand, { name = name, at = newest }) end end table.sort(cand, function(a, b) if a.at ~= b.at then return a.at < b.at end return a.name < b.name end) local i = 1 local n = table.getn(cand) while size > cap and i <= n do cache[cand[i].name] = nil size = size - 1 i = i + 1 end end -- ================================================================== -- WoW part -- ================================================================== if CreateFrame then local queue = {} -- pending roster entries local results = {} -- store player entries, roster order local running = false local restartPending = false -- BuildRoster() -> array of { unit, name, class, online, guid, -- subgroup }. online is true|false for raid entries (roster flag, -- GetRaidRosterInfo return 8 = SERVER TRUTH) and nil for party -- entries (no roster flag exists there -- scanOne falls back to the -- reach result); "player" is trivially true. -- Snapshot semantics: raid indices are volatile, so this -- is (re)taken at scan (re)start, never cached across roster events. -- Public: core/probe.lua reuses it for the probe battery. function E.BuildRoster() local list = {} local nraid = 0 if GetNumRaidMembers then nraid = GetNumRaidMembers() or 0 end if nraid > 0 then for i = 1, nraid do -- 11 returns on 1.12; fileName (english class token) is -- position 6, online position 8 local ok, name, rank, subgroup, level, class, fileName, zone, online = pcall(GetRaidRosterInfo, i) if ok and name then local unit = "raid" .. i local guid = nil if UnitExists then -- SuperWoW: 2nd return of UnitExists = GUID local ok2, ex, g = pcall(UnitExists, unit) if ok2 and ex and g then guid = g end end table.insert(list, { unit = unit, name = name, class = fileName, online = (online and true) or false, guid = guid, subgroup = subgroup, }) end end else local units = { "player", "party1", "party2", "party3", "party4" } for i = 1, 5 do local unit = units[i] local exists = false local guid = nil if UnitExists then local ok, ex, g = pcall(UnitExists, unit) if ok and ex then exists = true if g then guid = g end end end if exists then local name = UnitName(unit) local classToken = nil if UnitClass then local ok, loc, token = pcall(UnitClass, unit) if ok then classToken = token end end -- party path has NO roster online flag: online stays -- nil so scanOne falls back to the reach result; -- the own unit is trivially online local online = nil if unit == "player" then online = true end if name then table.insert(list, { unit = unit, name = name, class = classToken, online = online, guid = guid, subgroup = nil, }) end end end end return list end -- readWeapon(unit) -> weaponMain, weaponMainName -- (the NAME rides along -- self via the hidden-tooltip scan, foreign -- via the SuperWoW GetWeaponEnchantInfo(unit) name return) local function readWeapon(unit) if not GetWeaponEnchantInfo then return nil, nil end local isSelf = false if UnitIsUnit then local ok, v = pcall(UnitIsUnit, unit, "player") if ok and v then isSelf = true end end if isSelf then local ok, hasMain = pcall(GetWeaponEnchantInfo) if not ok then return nil, nil end local main = E.NormalizeWeapon(true, hasMain) local name = nil if main and DC_Gear and DC_Gear.ReadSelfWeaponEnchantName then name = DC_Gear.ReadSelfWeaponEnchantName() end return main, name end local ok, mainName = pcall(GetWeaponEnchantInfo, unit) if not ok then return nil, nil end -- A nil name is ambiguous on its own (see E.NormalizeWeapon): it means -- either "no imbue" or "this unit is not readable". The main-hand item -- link decides -- if the slot resolves, the unit IS readable and the -- missing imbue is a real gap. Measured: all 14 nil cases in a raid had -- a resolvable main hand, i.e. they were genuinely unbuffed. local readable = nil if mainName == nil and GetInventoryItemLink then local lok, link = pcall(GetInventoryItemLink, unit, 16) readable = (lok and link ~= nil) or false end return E.NormalizeWeapon(false, mainName, readable), E.NormalizeWeaponName(mainName) end local function bumpUnknown(unknown) local n = table.getn(unknown) for i = 1, n do local key = unknown[i] DC.unknownSeen[key] = (DC.unknownSeen[key] or 0) + 1 end end -- readResists(unit, guid, isSelf, hit, race) -> resists, resistsRead, -- source (E.AssembleResists' three returns). WoW-only gather for the -- RESIST tab: three DIRECT read paths, each ATTEMPTED only once the -- earlier one has already proven unusable (skips the extra client -- calls on the common case -- token alone almost always answers for a -- same-zone unit) -- plus the gear-reconstruction fallback (see -- E.AssembleResists' header), which is why the caller now hands in the -- `hit`/`race` results of this SAME scan's hit read (scanOne runs the -- hit read before this call precisely so hit.resistGear/ -- resistGearRead are available here; no extra client call of its own). -- School range 2..6 (fire/nature/frost/shadow/arcane); school 0 -- (physical) and 1 (holy, always 0) are never queried. local function readResists(unit, guid, isSelf, hit, race) local tokenRows = {} if UnitResistance then for school = 2, 6 do local ok, base, total = pcall(UnitResistance, unit, school) table.insert(tokenRows, { school = school, ok = ok, total = total }) end end local guidRows = {} local fieldRows = {} -- A path counts as usable only if it produced EVIDENCE. For the -- own character any number is evidence (a real 0 resistance is a -- measurement). For a FOREIGN unit it is not: UnitResistance -- returns ok=true with total=0 for every school of a player the -- client cannot resolve, which is indistinguishable from a -- genuinely unresisted character. Treating that as "usable" -- claimed the token path and left the GUID and descriptor paths -- below unreachable -- so every foreign player read as UNKNOWN -- even though GetUnitField(guid, "resistances") had the numbers. local tokenUsable = false for i = 1, table.getn(tokenRows) do local r = tokenRows[i] if r.ok and type(r.total) == "number" and (isSelf or r.total > 0) then tokenUsable = true end end if not tokenUsable and guid and UnitResistance then -- SuperWoW accepts a GUID directly as the first argument on -- several UnitXXX calls (source-confirmed for mob armor; -- unverified for a player's elemental schools -- pcall-guarded -- and gated by the same honesty rule as every other path) for school = 2, 6 do local ok, base, total = pcall(UnitResistance, guid, school) table.insert(guidRows, { school = school, ok = ok, total = total }) end end local guidUsable = false for i = 1, table.getn(guidRows) do local r = guidRows[i] if r.ok and type(r.total) == "number" and (isSelf or r.total > 0) then guidUsable = true end end if not tokenUsable and not guidUsable and guid and GetUnitField then local ok, t = pcall(GetUnitField, guid, "resistances") if ok and type(t) == "table" then -- 7-entry table, index convention verified defensively: -- tried as 1-based for school 0..6 (t[school+1]); a -- non-number entry is simply skipped (AssembleResists -- also drops non-positive field values -- see its header) for school = 2, 6 do table.insert(fieldRows, { school = school, val = t[school + 1] }) end end end local gearFallback = nil if hit then gearFallback = { resistGear = hit.resistGear, resistGearRead = hit.resistGearRead, race = race } end return E.AssembleResists(isSelf, tokenRows, guidRows, fieldRows, gearFallback) end -- scan one roster entry into a store player entry. -- Online source (measured in-game): the roster -- online flag (GetRaidRosterInfo return 8) is SERVER TRUTH and WINS -- over UnitIsConnected, which can read nil mid-zoning while the -- player is actually online (false-offline bug). ONLY entries -- without a roster flag (entry.online == nil, party path) fall back -- to the reach result. Distance stays from reach (diagnostic only). local function scanOne(entry) local reachOnline, distance = DC_Reach.ReadUnit(entry.unit) local online = entry.online if online == nil then online = reachOnline -- no roster flag: reach fallback end local auraRes = nil local gearRes = nil local weaponMain = nil local weaponMainName = nil local resRes = nil local hitRes = nil if online then local rawA, rawB, rawDA, rawDB, idNames = DC_Aura.ReadUnit(entry.unit, entry.guid) local auras, aurasRead, disagreements, unknown = DC_Aura.Assemble(rawA, rawB, idNames) -- debuffs: assembled alongside, readability -- rides on aurasRead; debuff names NEVER feed DC.unknownSeen auraRes = { read = aurasRead, auras = auras, debuffs = DC_Aura.AssembleDebuffs(rawDA, rawDB) } bumpUnknown(unknown) -- diagnostic path coverage (plan: disagreements between path A -- and path B are counted into DC.probeStats) local ps = DC.probeStats local hadA = table.getn(rawA) > 0 local hadB = table.getn(rawB) > 0 if hadA then ps.unitsPathA = ps.unitsPathA + 1 ps.lastScan.unitsA = ps.lastScan.unitsA + 1 end if hadB then ps.unitsPathB = ps.unitsPathB + 1 ps.lastScan.unitsB = ps.lastScan.unitsB + 1 end if hadA and hadB then ps.unitsBoth = ps.unitsBoth + 1 ps.lastScan.both = ps.lastScan.both + 1 end ps.idDisagreements = ps.idDisagreements + disagreements ps.lastScan.disagreements = ps.lastScan.disagreements + disagreements local rawLinks = DC_Gear.ReadUnit(entry.unit) local gear, gearRead = DC_Gear.Assemble(rawLinks, DC_Gear.InvSlots(), DC_Gear.ItemInfo) gearRes = { read = gearRead, gear = gear } weaponMain, weaponMainName = readWeapon(entry.unit) local isSelf = false if UnitIsUnit then local ok, v = pcall(UnitIsUnit, entry.unit, "player") isSelf = (ok and v) and true or false end -- hit: the item links from the gear read above are handed over -- so the tooltip pass does not fetch them a second time. -- Declared OUTSIDE the if-block (unlike before this scope held -- only hitRes) because readResists below now needs `hit`/ -- `race` too, for the gear-reconstruction fallback -- see -- E.AssembleResists' header for why the hit read must run -- BEFORE the resist read. local hit, race = nil, nil if DC_Hit and DC_Hit.ReadUnit then hit, race = DC_Hit.ReadUnit(entry.unit, rawLinks) if isSelf and hit and DC_Hit.ReadSelfSkill then -- the own character is the ONLY one whose weapon skill -- can be MEASURED (rank + modifier); for everyone else -- it is unreadable in principle and stays an estimate if hit.skillName then local exact = DC_Hit.ReadSelfSkill(hit.skillName) if exact then hit.skill = exact hit.skillExact = true end end local rangedSkillName = DC.WEAPON_SKILL_OF_SUBTYPE and DC.WEAPON_SKILL_OF_SUBTYPE[hit.rangedSub] if rangedSkillName then local rexact = DC_Hit.ReadSelfSkill(rangedSkillName) if rexact then hit.rangedSkill = rexact hit.rangedSkillExact = true end end end -- estimate whatever was not measured: base skill + racial -- specialization + the user's per-player weapon-skill-book -- flag. Snapshot semantics -- a flag toggled after the scan -- reaches the display layer, not this table. if DC_Hit.ApplyEstimate then local db = DopingControlDB local books = nil if db and db.skillBooks then books = db.skillBooks[entry.name] end DC_Hit.ApplyEstimate(hit, race, books) end -- TALENT hit (data/talenthit.lua). TWO sources, ONE -- mathematics: -- * own character -- GetTalentInfo, which has no unit -- parameter and never will (the server transmits no -- foreign talents at all: no SMSG_INSPECT_TALENT in -- this fork, no talent field in InitVisibleBits); -- * every other player -- this server's own inspect -- protocol (scan/talents.lua): we ask by name, their -- client answers with its whole tree. -- Both hand the SAME name->rank table to the same -- DC_Hit.TalentHit, so a foreign hit number cannot drift -- from an own one. talentsRead stays false only while no -- plausible reply has arrived yet -- that is a "not yet", -- not "impossible". The dual-wield gate uses the weapon -- subtypes the item pass just resolved. if hit and DC_Hit.TalentHit then local ranks = nil if isSelf then if DC_Hit.ReadSelfTalents then ranks = DC_Hit.ReadSelfTalents() end elseif DC_Talents and DC_Talents.RanksFor then ranks = DC_Talents.RanksFor(entry.name) end if ranks then local dw = DC_Hit.DualWield(hit.mainSub, hit.offSub) local t = DC_Hit.TalentHit(entry.class, ranks, dw) hit.talentMelee = t.melee hit.talentRanged = t.ranged hit.talentSpell = t.spell hit.talentOffhand = t.offhand hit.talentSchools = t.schools hit.talentsRead = true end end hitRes = { hit = hit, race = race } end local resists, resistsRead, source = readResists(entry.unit, entry.guid, isSelf, hit, race) resRes = { read = resistsRead, resists = resists, source = source } end local p = E.BuildPlayer(entry, online, distance, auraRes, gearRes, weaponMain, weaponMainName, resRes, hitRes) -- last-known cache: successful channels refresh -- DC.lastKnown, failed channels get the cached references attached -- as p.last* -- display-only for the model (IRON RULE) local now = 0 if GetTime then now = GetTime() end E.UpdateLastKnown(DC.lastKnown, p, now) table.insert(results, p) end -- one reused batch frame (allocating a throwaway frame per -- batch would leak; we use ONE named frame). -- CAUTION: hidden frames receive no OnUpdate on 1.12, so the frame is -- Show()n for the duration of a scan (it has no regions -- draws -- nothing) and Hide()n again in finish(). local scanFrame = CreateFrame("Frame", "DopingControlScanFrame") scanFrame:Hide() local function beginQueue() queue = E.BuildRoster() results = {} DC.probeStats.lastScan = { unitsA = 0, unitsB = 0, both = 0, disagreements = 0 } end local function finish() running = false scanFrame:SetScript("OnUpdate", nil) scanFrame:Hide() -- cap-safety sweep on the last-known cache: runs on -- every finished pass, roster members are never evicted local rosterSet = {} for i = 1, table.getn(results) do rosterSet[results[i].name] = true end E.SweepLastKnown(DC.lastKnown, rosterSet, E.CACHE_CAP) local db = DopingControlDB if db and db.testMode then -- Test mode was switched ON while this scan was in flight: the -- simulator fully replaces the data source ("real -- data never beats the simulation") -- drop the result. results = {} return end local now = 0 if GetTime then now = GetTime() end DC.probeStats.scans = DC.probeStats.scans + 1 -- Line up talent requests for everyone this scan actually saw -- (scan/talents.lua paces and de-duplicates them; a player whose -- talents we already hold is skipped there, not here). Only -- players with a KNOWN CLASS are worth asking: DC_Hit.TalentHit -- keys its table by class, so a reply we cannot attribute would be -- traffic for nothing. Own character excluded -- GetTalentInfo is -- authoritative and free for that one. if DC_Talents and DC_Talents.Request then local me = UnitName and UnitName("player") or nil local names = {} for i = 1, table.getn(results) do local p = results[i] if p.name and p.name ~= me and p.class and p.class ~= "" then table.insert(names, p.name) end end DC_Talents.Request(names) end DC.store = E.NewStore(results, now) DC.storeInvalid = nil results = {} if type(DC.OnStoreUpdated) == "function" then DC.OnStoreUpdated() end end local function tick() if restartPending then -- roster changed mid-scan: indices volatile => full restart restartPending = false beginQueue() end local count = 0 while table.getn(queue) > 0 and count < E.BATCH do local entry = table.remove(queue, 1) scanOne(entry) count = count + 1 end if table.getn(queue) == 0 and not restartPending then finish() end end -- Start(force) -> bool (scan started/restarted). -- Already running: no-op unless force, which restarts with a fresh -- roster snapshot. Test mode is NOT checked here -- an explicit Start -- is user intent; only the READY_CHECK auto-trigger honors testMode. function E.Start(force) if running then if not force then return false end restartPending = true return true end running = true restartPending = false beginQueue() scanFrame:Show() -- hidden frames get no OnUpdate on 1.12 scanFrame:SetScript("OnUpdate", tick) tick() -- first batch immediately (solo/party scans finish at once) return true end function E.IsRunning() return running end -- event wiring local eventFrame = CreateFrame("Frame", "DopingControlScanEventFrame") eventFrame:RegisterEvent("RAID_ROSTER_UPDATE") eventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") -- READY_CHECK: unverified as a native 1.12 event -- register defensively pcall(eventFrame.RegisterEvent, eventFrame, "READY_CHECK") eventFrame:SetScript("OnEvent", function() if event == "READY_CHECK" then local db = DopingControlDB if db and db.readyCheckScan and not db.testMode then E.Start() end return end -- RAID_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED if running then restartPending = true elseif DC.store and DC.store.source == "scan" then DC.storeInvalid = true -- finished store no longer matches roster end end) end