-- DopingControl scan/hit.lua -- Hit reading for one unit: the +hit percentages an equipped set of items -- grants, plus the weapon information the hit CAPS depend on. -- -- There is no hit API on this client family (see data/hit.lua) -- every -- number here is summed from item TOOLTIP text. The read path is -- GameTooltip:SetInventoryItem(unit, slot) on the SHARED hidden tooltip -- (scan/aura.lua's DC_Aura.GetScanTip -- never a second frame under the -- same global name), NOT SetHyperlink: SetInventoryItem shows the item as -- it is actually worn (temporary weapon enchants included) and sidesteps -- item-link edge cases entirely. -- -- Performance (this is the expensive part of a scan -- one tooltip build -- per equipped item per player): -- * slots without an item link are skipped without touching the tooltip; -- * nothing beyond the equipment slots is ever scanned; -- * the parsed result of an item is CACHED by item id + enchant id, so -- the second player wearing the same epic costs one table lookup. The -- cache lives in memory only (never saved to disk), and a cap-safety -- wipe keeps it bounded -- it is a pure accelerator, so a wipe only -- costs one re-scan; -- * the caller can hand in the item links scan/gear.lua already fetched -- for this unit, which removes a second GetInventoryItemLink pass. -- -- WHAT IS COUNTED, and how: -- * PER ITEM: lines that start with the equip trigger ("Equip: Improves -- your chance to hit by 1%"). -- * PER SET: set bonuses, counted ONCE for the whole set and only when -- the piece threshold is met (H.CollectSet / H.SumSets). The set block -- is printed on the tooltip of EVERY piece, which is why a per-item -- sum would multiply it -- but the block also names the set, the worn -- piece count and each bonus's threshold, so grouping by set is all -- that is needed. The worn count comes from OUR OWN tally of the -- player's pieces, never from a cached tooltip's "(x/y)": that number -- belongs to whichever unit the tooltip was drawn for. -- -- Store shape produced BY THIS MODULE'S ReadUnit (players[i].hit, before -- scan/engine.lua's BuildPlayer augments it with hit-granting auras -- -- see AuraHit below and BuildPlayer's own header comment): -- { melee = , -- generic physical hit % from GEAR (melee AND -- -- ranged); BuildPlayer folds DC.HIT_AURAS on top -- -- and turns this into the melee-column TOTAL -- spell = , -- general spell hit % from GEAR; BuildPlayer -- -- folds auras on top the same way -- schools = { [school] = }, -- school-only spell hit extras -- sources = { { item = , kind = , value = }, ... }, -- mainSub = , -- main hand -- offSub = , -- off hand -- rangedSub = , -- ranged slot -- skillName = , -- skill = , -- main-hand weapon skill: MEASURED for -- -- the own character, otherwise the -- -- estimate H.ApplyEstimate filled in -- skillExact = , -- true only when `skill` was MEASURED -- book = , -- a skill book was ASSUMED (never on a -- -- measured skill) -- rangedSkill = , -- same for the ranged weapon -- rangedSkillExact = , -- hitRead = } -- Readability rule (mirrors scan/gear.lua): ANY slot returned a link => -- hitRead = true; ALL slots nil => hitRead = false (a failed read, not "a -- naked player"). Unlike the aura/gear channels the table rides into the -- store EVEN WHEN hitRead is false, because the flag lives inside it -- -- every consumer must test hit.hitRead before believing the zeros. -- -- Weapon skill: MEASURED for the own character (H.ReadSelfSkill -> -- hit.skill + hit.skillExact), ESTIMATED for everyone else -- foreign -- weapon skill is unreadable in principle (no inspect-skill channel -- exists). The estimate is deliberately NOT baked into the store: it -- depends on the user's per-player weapon-skill-book flags, and baking it -- in would freeze those flags until the next scan. Consumers resolve it at -- display time: -- local skill = hit.skillExact and hit.skill -- or DC_Hit.SkillFor(hit.mainSub, player.race, -- db.skillBooks and db.skillBooks[player.name]) -- (player.race is the race token the scan stored alongside the hit table.) -- -- TALENTS are the third source (gear, auras, talents). GetTalentInfo has -- no unit parameter, so ranks come from two places that must produce the -- SAME shape: -- * H.TalentHit is PURE (class token + learned ranks -> the four -- buckets and the school extras, see data/talenthit.lua) and is -- offline-tested like the rest of the pure half; -- * H.ReadSelfTalents (WoW glue) reads the ranks for the own character; -- * for every other player scan/talents.lua asks over the server's own -- inspect protocol and hands the same name->rank table to H.TalentHit. -- Until a reply arrives that player keeps hit.talentsRead = false and -- zeroed talent fields, and every consumer must say so -- their number -- EXCLUDES talents, which for a hybrid or a mage is easily 3-10%. -- The talent fields the engine adds on top of this module's store shape: -- talentMelee / talentRanged / talentSpell / talentOffhand (numbers), -- talentSchools ({ [lowercase school] = number }), talentsRead (bool). -- -- Pure Lua 5.0, dofile-loadable offline: ParseHitLine / SumGear / AddSum / -- IsHitSourceLine / SkillFor / MeleeCap / RangedCap / DualWield / -- TalentHit / SchoolHit are pure (offline tested); ReadUnit, -- ReadSelfSkill and ReadSelfTalents touch the WoW API and run in-game -- only, pcall-wrapped. -- -- RESIST-TAB REUSE (added 2026-08-02, see scan/resist.lua's header for the -- full rationale): the server never transmits UNIT_FIELD_RESISTANCES for a -- foreign player at all, so scan/engine.lua's three direct read paths can -- NEVER answer for anyone but the own character -- reconstruction from -- equipped-item tooltips is the only path that can. Rather than run a -- second per-item tooltip scan for that, itemHit() below hands the SAME -- tooltip lines it already fetched for +hit to DC_Resist.SumLines (pure, -- scan/resist.lua) and caches the result alongside the hit sum; H.ReadUnit -- accumulates it into hit.resistGear/hit.resistGearRead the same way it -- accumulates hit.melee/hit.spell. Zero extra SetInventoryItem calls. -- hit.resistGearRead is the STRICT honesty flag scan/engine.lua's -- gear-reconstruction fallback requires: true only when at least one -- equipped item's tooltip was ACTUALLY read (H.SumItemResists), never -- merely "a link existed" (hit.hitRead's looser bar) -- a player whose -- tooltips are wholly unreadable must keep showing "?" on RESIST, not a -- reconstructed 0. DC_Resist is existence-guarded throughout, so this -- module works unchanged without it (resistGearRead simply stays false). DopingControl = DopingControl or {} local DC = DopingControl DC_Hit = DC_Hit or {} local H = DC_Hit -- Equipment slots that can carry +hit, in inventory-slot order. This is -- deliberately its OWN list and not DC_Gear.InvSlots(): the two lists -- happen to agree today (both cover the waist, invSlot 6 -- see -- data/enchants.lua's bug-history note), but this file must not assume -- that stays true, so it keeps every hit-bearing slot spelled out here. -- Shirt (4) and tabard (19) never carry stats and are left out. H.SLOTS = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 } H.SLOT_MAIN = 16 H.SLOT_OFF = 17 H.SLOT_RANGED = 18 -- Hard cap for the per-item parse cache (in memory only, see header). H.CACHE_CAP = 300 -- Hard cap for the per-player breakdown list (tooltip detail only -- no -- realistic set of gear reaches it; the cap exists so a broken tooltip -- cannot grow the store without bound). H.SOURCE_CAP = 16 -- ================================================================== -- Pure part -- ================================================================== -- round to one decimal (0.2 is not representable in binary floating point, -- so 8 - 3*0.2 lands at 7.399999...; every cap leaves this function) local function round1(v) return math.floor(v * 10 + 0.5) / 10 end -- ParseHitLine(text) -> kind, value | nil -- kind : "melee" | "spell" | "both" | (see DC.HIT_PATTERNS) -- value : the percentage as a number -- Returns nil (a single value) when no pattern matches -- an unknown -- phrasing is ignored, never guessed at. function H.ParseHitLine(text) if type(text) ~= "string" then return nil end local pats = DC.HIT_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 -- IsHitSourceLine(text) -> bool -- The gate that separates PER-ITEM hit from SET-BONUS hit: only a line -- carrying the equip trigger is a per-item hit source. A set-bonus line -- ("(2) Set: Improves your chance to hit by 2%") is printed on EVERY piece -- of the set, so adding it per item would multiply it. -- -- That is a reason to count set bonuses ONCE PER SET, not a reason to drop -- them -- see H.CollectSet / H.SumSets below. The Devilsaur two-piece -- bonus is +2% hit; leaving it out understates hit by more than most -- enchants add. function H.IsHitSourceLine(text) if type(text) ~= "string" then return false end if string.find(text, "^Equip:") then return true end return false end -- AddSum(dst, add) -> dst -- fold one hit sum into another (in place). -- Only the three hit fields are touched, so it can be applied directly to -- a store hit table that carries weapon/readability fields as well. function H.AddSum(dst, add) if type(dst) ~= "table" or type(add) ~= "table" then return dst end dst.melee = (dst.melee or 0) + (add.melee or 0) dst.spell = (dst.spell or 0) + (add.spell or 0) if add.schools then dst.schools = dst.schools or {} for school, v in pairs(add.schools) do dst.schools[school] = (dst.schools[school] or 0) + v end end return dst end -- SumItemResists(items) -> resistGear, resistGearRead -- items : array of the itemHit() cache entries collected while walking -- H.SLOTS for ONE unit -- one entry per slot whose tooltip was -- SUCCESSFULLY read (a slot with no item, or whose tooltip read -- failed, contributes no entry at all -- see H.ReadUnit). -- resistGear : { [UnitResistance school index] = total } summed -- over every entry's .resist table (itself possibly -- {} for a plain item -- see DC_Resist.SumLines). A -- school with no contribution is simply absent, not -- zero -- callers default with `or 0`, same as every -- other resist table in this addon. -- resistGearRead : true iff AT LEAST ONE entry carried a .resist table -- (i.e. its tooltip was actually read, whether or not -- that item happened to grant any resistance). -- FALSE means every equipped item's tooltip failed -- -- the exact scan/hit.lua-documented failure mode -- (SetOwner missing on the scan tooltip, or the unit -- simply out of tooltip range) -- and the caller MUST -- show "?", never a reconstructed 0. This is the -- honesty gate scan/engine.lua's AssembleResists -- gear-fallback checks before trusting resistGear at -- all. -- `.resist` being nil on every entry (DC_Resist not loaded, see the file -- header) degrades the same way: nothing is ever marked read, so the -- fallback simply never activates -- no module, no reconstruction, never a -- guess. function H.SumItemResists(items) local sum = {} local read = false items = items or {} local n = table.getn(items) for i = 1, n do local it = items[i] if it and it.resist then read = true for school, v in pairs(it.resist) do sum[school] = (sum[school] or 0) + v end end end return sum, read end -- SumGear(lines) -> sum, sources -- lines : array of raw tooltip line strings (already gated by -- IsHitSourceLine when they come from an item scan) -- sum : { melee = , spell = , schools = { ... } } -- sources : array of { kind = , value = } in line order -- the -- breakdown a tooltip shows; the caller attaches the item name. -- Folding rules (see DC.HIT_PATTERNS): "both" counts into melee AND -- spell; a school-specific line counts ONLY into schools[], never -- into the general spell number -- it buys hit against one school only. -- Lines that parse to nothing are ignored. function H.SumGear(lines) local sum = { melee = 0, spell = 0, schools = {} } local sources = {} lines = lines or {} local n = table.getn(lines) for i = 1, n do local kind, v = H.ParseHitLine(lines[i]) if kind then if kind == "melee" then sum.melee = sum.melee + v elseif kind == "spell" then sum.spell = sum.spell + v elseif kind == "both" then sum.melee = sum.melee + v sum.spell = sum.spell + v else sum.schools[kind] = (sum.schools[kind] or 0) + v end table.insert(sources, { kind = kind, value = v }) end end return sum, sources end -- ------------------------------------------------------------------ -- SET BONUSES -- -- Shape of the set block in an item tooltip: -- -- L10 gold "Devilsaur Armor (0/2)" <- ITEM_SET_NAME -- L11 grey " Devilsaur Leggings" <- piece list -- L14 grey "(2) Set: Improves your chance to hit by 2%." <- ITEM_SET_BONUS_GRAY -- -- So the client states both halves itself: how many pieces are worn, and -- what each bonus costs. The 1.12 GlobalStrings confirm the two forms -- -- ITEM_SET_BONUS_GRAY = "(%d) Set: %s" for a bonus that is NOT active and -- ITEM_SET_BONUS = "Set: %s" for one that IS. -- -- Counting rule: group by SET NAME, count each bonus at most ONCE, and -- take it only when its piece threshold is met. The worn count is taken -- from OUR OWN tally of the player's equipped pieces, never from the -- "(x/y)" of a cached tooltip -- that number belongs to whichever unit was -- scanned when the item entered the cache and would otherwise leak between -- players. -- ------------------------------------------------------------------ -- ParseSetHeader(text) -> name, worn, total | nil -- The gold " (x/y)" line that opens the set block. function H.ParseSetHeader(text) if type(text) ~= "string" then return nil end local _, _, name, worn, total = string.find(text, "^(.+) %((%d+)/(%d+)%)%s*$") if not name then return nil end return name, tonumber(worn), tonumber(total) end -- ParseSetBonus(text) -> threshold|nil, body | nil -- "(2) Set: " -> 2, (inactive form, ITEM_SET_BONUS_GRAY) -- "Set: " -> nil, (ACTIVE form, ITEM_SET_BONUS) -- Anything else -> nil -- A nil threshold with a body is NOT "no threshold" -- it means the client -- already decided the bonus is active for the unit it drew the tooltip -- for, which is why such an entry must not be cached across players. function H.ParseSetBonus(text) if type(text) ~= "string" then return nil end local _, _, n, body = string.find(text, "^%((%d+)%) Set: (.+)$") if n then return tonumber(n), body end local _, _, body2 = string.find(text, "^Set: (.+)$") if body2 then return nil, body2 end return nil end -- CollectSet(lines) -> { name, worn, total, bonuses = { {n=, text=} }, -- contextual = } | nil -- Reads the set block out of ONE item's tooltip lines. `contextual` marks -- a block that carries at least one active-form line, i.e. one whose -- meaning depends on the unit the tooltip was drawn for -- the caller must -- not cache those. function H.CollectSet(lines) if type(lines) ~= "table" then return nil end local set = nil for i = 1, table.getn(lines) do local text = lines[i] if not set then local name, worn, total = H.ParseSetHeader(text) if name then set = { name = name, worn = worn, total = total, bonuses = {}, contextual = false } end else local n, body = H.ParseSetBonus(text) if body then if not n then set.contextual = true end table.insert(set.bonuses, { n = n, text = body }) end end end return set end -- SumSets(sets) -> sum, sources -- sets : { [setName] = { pieces = , -- bonuses = { {n=, text=} } } } -- Each bonus contributes AT MOST ONCE, and only when the player wears -- enough pieces. A bonus with no threshold (active form) is taken as met: -- the client only prints it that way once it applies. function H.SumSets(sets) local sum = { melee = 0, spell = 0, schools = {} } local sources = {} if type(sets) ~= "table" then return sum, sources end -- deterministic order: sets are keyed by name, and two sets granting -- hit must not swap places in the tooltip between two scans local names = {} for name in pairs(sets) do table.insert(names, name) end table.sort(names) for i = 1, table.getn(names) do local name = names[i] local s = sets[name] local pieces = (s and s.pieces) or 0 local bonuses = (s and s.bonuses) or {} local seen = {} for b = 1, table.getn(bonuses) do local bonus = bonuses[b] local met = (bonus.n == nil) or (pieces >= bonus.n) -- the same bonus text can arrive from several pieces of the -- set; key on it so it lands in the sum exactly once if met and not seen[bonus.text] then seen[bonus.text] = true local kind, v = H.ParseHitLine(bonus.text) if kind then if kind == "melee" then sum.melee = sum.melee + v elseif kind == "spell" then sum.spell = sum.spell + v elseif kind == "both" then sum.melee = sum.melee + v sum.spell = sum.spell + v else sum.schools[kind] = (sum.schools[kind] or 0) + v end table.insert(sources, { kind = kind, value = v, set = name, pieces = bonus.n or pieces }) end end end end return sum, sources end -- SkillFor(subType, race, books) -> skill, skillName | nil -- The ESTIMATE for a foreign player's weapon skill: -- base (level-60 cap) + racial specialization + weapon skill book. -- `books` is one player's opt-in flag table (db.skillBooks[playerName]), -- keyed by weapon SKILL name; nil is fine. Returns nil when the subtype is -- not a weapon at all (shield, totem, nothing equipped) -- the caller then -- has no skill claim to make. -- The own character never needs this: its skill is measured -- (H.ReadSelfSkill) and a measured value already includes any book. function H.SkillFor(subType, race, books) local map = DC.WEAPON_SKILL_OF_SUBTYPE local skillName = map and map[subType] if not skillName then return nil end local skill = DC.BASE_WEAPON_SKILL or 300 local byRace = DC.RACE_WEAPON_SKILL local racial = race and byRace and byRace[race] if racial and racial[skillName] then skill = skill + racial[skillName] end if books and books[skillName] then skill = skill + (DC.SKILL_BOOK_BONUS or 0) end return skill, skillName end -- ApplyEstimate(hit, race, books) -> hit (in place) -- Fills the weapon-skill fields a display layer needs for a player whose -- skill could NOT be measured -- which is everyone except the own -- character (foreign weapon skill is unreadable in principle): -- hit.skill main-hand weapon skill estimate (base + racial + book) -- hit.book true when a weapon skill book was ASSUMED for the -- main-hand skill (false whenever the skill is measured -- -- a measured value already contains the book) -- hit.rangedSkill same estimate for the ranged weapon's own skill line -- A MEASURED value (skillExact / rangedSkillExact) is never overwritten. -- `books` is one player's flag table, db.skillBooks[playerName]; nil-safe. -- -- Note for consumers: this is a SNAPSHOT taken when the unit was scanned. -- Toggling a book flag afterwards does not travel back into the store, so -- a display layer that wants the flag to take effect immediately should -- recompute with H.SkillFor(hit.mainSub, player.race, books) whenever -- hit.skillExact is not set. function H.ApplyEstimate(hit, race, books) if type(hit) ~= "table" then return hit end if not hit.skillExact then local skill = H.SkillFor(hit.mainSub, race, books) if skill then hit.skill = skill end hit.book = (hit.skillName and books and books[hit.skillName] and true) or false else hit.book = false end if not hit.rangedSkillExact then local rskill = H.SkillFor(hit.rangedSub, race, books) if rskill then hit.rangedSkill = rskill end end return hit end -- AuraHit(auraNames) -> { melee = , ranged = , spell = } -- The hit granted by AURAS (DC.HIT_AURAS, data/hit.lua) rather than gear. -- auraNames : player.auras.names ({[name]=true,...}, nil-safe) -- the SAME -- aura scan the class-buff tab already reads; no second scan needed. -- Sums every DC.HIT_AURAS entry whose name is present in auraNames. A -- hit-granting aura may bump any subset of the three columns (e.g. a -- spell-only buff) -- absent fields on an entry default to 0, never nil, -- so the caller can always add the result straight onto a gear sum. function H.AuraHit(auraNames) local sum = { melee = 0, ranged = 0, spell = 0 } local auras = DC.HIT_AURAS if type(auraNames) ~= "table" or type(auras) ~= "table" then return sum end for name, entry in pairs(auras) do if auraNames[name] then sum.melee = sum.melee + (entry.melee or 0) sum.ranged = sum.ranged + (entry.ranged or 0) sum.spell = sum.spell + (entry.spell or 0) end end return sum end -- rankValue(arr, rank) -- the per-rank value of one talent bucket. -- A rank ABOVE the listed count means the table is stale (a tree was -- rebalanced and the talent gained a rank): it clamps to the highest -- KNOWN value, which under-reports rather than extrapolating a number -- nobody verified. Rank 0 / no such bucket contribute nothing. local function rankValue(arr, rank) if type(arr) ~= "table" or not rank or rank < 1 then return 0 end local n = table.getn(arr) if n < 1 then return 0 end if rank > n then rank = n end return arr[rank] or 0 end -- DualWield(mainSub, offSub) -> bool -- Both hands hold something that maps to a WEAPON skill. A shield, totem, -- idol, libram or held-in-off-hand item is not dual wielding -- that is -- exactly what the gaps in DC.WEAPON_SKILL_OF_SUBTYPE encode. function H.DualWield(mainSub, offSub) local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} return (map[mainSub] ~= nil) and (map[offSub] ~= nil) end -- TalentHit(classToken, learnedRanks, dualWield) -- -> { melee = , ranged = , spell = , offhand = , schools = { ... } } -- classToken : UnitClass file token ("MAGE", "WARRIOR", ...) -- learnedRanks : { [talent name] = rank } -- what H.ReadSelfTalents -- returns; matching is BY NAME (see data/talenthit.lua) -- dualWield : true while two WEAPONS are equipped (H.DualWield) -- -- gates the hunter's dual-wield-only extra -- Every field is a number and `schools` is always a table, so the result -- can be added onto a hit table without nil checks. An unknown class, a -- nil rank table or a class with no hit talents all yield clean zeros -- -- which is also what a player whose talents have not arrived yet gets. -- `offhand` is returned SEPARATELY and never folded into `melee`: it buys -- hit for off-hand swings only. function H.TalentHit(classToken, learnedRanks, dualWield) local sum = { melee = 0, ranged = 0, spell = 0, offhand = 0, schools = {} } local byClass = DC.TALENT_HIT if type(byClass) ~= "table" or type(learnedRanks) ~= "table" then return sum end local list = classToken and byClass[classToken] if type(list) ~= "table" then return sum end local n = table.getn(list) for i = 1, n do local t = list[i] local rank = nil if type(t) == "table" and t.name then rank = tonumber(learnedRanks[t.name]) end if rank and rank > 0 then sum.melee = sum.melee + rankValue(t.melee, rank) sum.ranged = sum.ranged + rankValue(t.ranged, rank) sum.spell = sum.spell + rankValue(t.spell, rank) sum.offhand = sum.offhand + rankValue(t.offhand, rank) if dualWield then sum.melee = sum.melee + rankValue(t.dwExtra, rank) end if type(t.schools) == "table" then for school, arr in pairs(t.schools) do sum.schools[school] = (sum.schools[school] or 0) + rankValue(arr, rank) end end end end return sum end -- SchoolHit(hit, school) -> total, generic, specific -- What ONE school column of the HIT tab is worth for this player: -- generic : spell hit that helps EVERY school (hit.spell -- gear, auras -- and generic talents, already summed by the engine) -- specific : what only THIS school gets -- the gear affix parsed from -- tooltips plus the school-specific talents -- total : the number the column shows -- `school` may be given in either spelling: the lowercase column id -- ("fire", DC.SLOTS_HIT) or the capitalized tooltip word ("Fire", -- DC.HIT_SCHOOLS). The two live side by side because they come from two -- different sources; DC.HIT_SCHOOL_KEY bridges them (data/hit.lua). -- Note the input: `hit` is a STORE hit table (after scan/engine.lua's -- BuildPlayer folded auras and talents in). Handing in the raw table -- scan/hit.lua's ReadUnit returns yields the gear-only picture, which is -- correct but is not what a cell should display. function H.SchoolHit(hit, school) if type(hit) ~= "table" then return 0, 0, 0 end local generic = hit.spell or 0 if type(school) ~= "string" then return generic, generic, 0 end local lower = string.lower(school) local gearKey = (DC.HIT_SCHOOL_KEY and DC.HIT_SCHOOL_KEY[lower]) or school local specific = 0 if type(hit.schools) == "table" then specific = specific + (hit.schools[gearKey] or 0) end if type(hit.talentSchools) == "table" then specific = specific + (hit.talentSchools[lower] or 0) end return generic + specific, generic, specific end -- capFor(base, skill) -- linear weapon-skill scaling: every point above -- the base skill lowers the cap by DC.CAP_PER_SKILL, never below zero. local function capFor(base, skill) local baseSkill = DC.BASE_WEAPON_SKILL or 300 local extra = (skill or baseSkill) - baseSkill if extra < 0 then extra = 0 end local v = (base or 0) - extra * (DC.CAP_PER_SKILL or 0) if v < 0 then v = 0 end return round1(v) end -- MeleeCap(mainSub, offSub, skill, capTable) -- -> { yellow = , white = , dualWield = , skillName = } -- mainSub/offSub : GetItemInfo itemSubType of the two weapon slots -- skill : effective weapon skill (measured or estimated); -- nil falls back to the base skill -- capTable : DC.HIT_CAPS by default -- yellow = cap for special attacks. -- white = cap for auto attacks: the dual-wield cap while two WEAPONS are -- held, otherwise identical to yellow (a single weapon's white -- swings carry no dual-wield penalty). -- dualWield = both hands hold something that maps to a weapon skill -- a -- shield/totem/held-in-off-hand off hand is not dual wielding. -- skillName = weapon skill of the MAIN hand, nil when it is unknown or -- empty (bare fists are the caller's business, see ReadUnit). function H.MeleeCap(mainSub, offSub, skill, capTable) capTable = capTable or DC.HIT_CAPS or {} local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} local skillName = map[mainSub] local dualWield = H.DualWield(mainSub, offSub) local yellow = capFor(capTable.meleeYellow, skill) local white = yellow if dualWield then white = capFor(capTable.meleeWhiteDW, skill) end return { yellow = yellow, white = white, dualWield = dualWield, skillName = skillName } end -- RangedCap(rangedSub, skill, capTable) -> { cap = , skillName = } | nil -- nil means "this player has no ranged hit question": nothing in the -- ranged slot, or a relic/wand rather than a ranged ATTACK weapon (see -- DC.RANGED_ATTACK_SUBTYPE -- wand mechanics are disputed, so wands never -- drive this column). -- `skill` is the skill of the RANGED weapon (bows/guns/crossbows/thrown -- each have their own skill line), not the main hand's. function H.RangedCap(rangedSub, skill, capTable) capTable = capTable or DC.HIT_CAPS or {} local ranged = DC.RANGED_ATTACK_SUBTYPE or {} if not rangedSub or not ranged[rangedSub] then return nil end local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} return { cap = capFor(capTable.ranged, skill), skillName = map[rangedSub] } end -- ================================================================== -- WoW part (runtime only; every call pcall-wrapped) -- ================================================================== -- The shared hidden tooltip's global name -- the line FontStrings are read -- back as getglobal("TextLeft"), so the name must match the frame -- created in scan/aura.lua. local TIP_NAME = "DopingControlScanTip" -- per-item parse cache, [itemId:enchantId] = { sum, sources, name } local itemCache = {} local itemCacheSize = 0 -- ClearCache() -- in-memory cache reset (cap safety, and a handy manual -- reset when item tooltips changed under us). function H.ClearCache() itemCache = {} itemCacheSize = 0 end function H.CacheSize() return itemCacheSize end -- read all left-hand tooltip lines of one equipped item; nil on failure local function scanItemLines(unit, slot) 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(unit, slot) 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 local lines = {} for i = 1, n do local obj = getglobal(TIP_NAME .. "TextLeft" .. i) if obj then local text = obj:GetText() if text then table.insert(lines, text) end end end return lines end -- parse ONE equipped item -> { sum, sources, name, set } (cached by item -- id + enchant id). Random suffixes are not part of the key on purpose: no -- 1.12 suffix grants hit, and a temporary weapon enchant cannot either -- -- the key stays cheap. -- -- `set` is the item's set block (H.CollectSet). It is item data and caches -- fine -- EXCEPT when it contains an active-form bonus line, which the -- client only prints for a unit that already wears enough pieces. Such an -- entry describes the unit, not the item, so it is returned but never -- stored: the next player re-reads the tooltip and gets their own truth. local function itemHit(unit, slot, itemId, enchantId) local key = tostring(itemId) .. ":" .. tostring(enchantId) local hitEntry = itemCache[key] if hitEntry then return hitEntry end local lines = scanItemLines(unit, slot) if not lines then return nil -- unreadable tooltip: do NOT cache a zero end local keep = {} -- line 1 is the item name, never a stat line for i = 2, table.getn(lines) do if H.IsHitSourceLine(lines[i]) then table.insert(keep, lines[i]) end end local sum, sources = H.SumGear(keep) local set = H.CollectSet(lines) -- RESIST tab reuse (see file header addendum): the FULL lines array -- this pass already fetched for +hit is handed to DC_Resist's pure -- parser for resistance stats -- one more in-memory table walk, no -- extra SetInventoryItem call. Resistance lines are plain stat text -- (no "Equip:" trigger, unlike +hit -- see data/resist.lua), so `lines` -- is passed unfiltered rather than `keep`. Existence-guarded: without -- scan/resist.lua loaded, `resist` stays nil and H.SumItemResists -- treats this item as "not scanned for resist" (never a guessed zero). local resist = nil if DC_Resist and DC_Resist.SumLines then resist = DC_Resist.SumLines(lines) end hitEntry = { sum = sum, sources = sources, name = lines[1], set = set, resist = resist } if set and set.contextual then return hitEntry -- unit-specific: use it, never cache it end if itemCacheSize >= H.CACHE_CAP then -- cap-safety wipe: the cache is a pure accelerator, a reset only -- costs one re-scan (no correctness meaning, no eviction order) H.ClearCache() end itemCache[key] = hitEntry itemCacheSize = itemCacheSize + 1 return hitEntry end -- ReadSelfSkill(skillName) -> effective, rank, modifier | nil -- The own character's MEASURED weapon skill: rank (4th return of -- GetSkillLineInfo) plus its modifier (6th return), which is how a -- temporary or permanent skill bonus shows up. -- Gotcha: a COLLAPSED skill category is not enumerated by -- GetNumSkillLines/GetSkillLineInfo. We deliberately do NOT expand -- categories (that would change the player's skill window); an -- unenumerable skill simply comes back nil and the caller falls back to -- the estimate. function H.ReadSelfSkill(skillName) if not skillName or not GetNumSkillLines or not GetSkillLineInfo then return nil end local okN, n = pcall(GetNumSkillLines) if not okN or type(n) ~= "number" then return nil end for i = 1, n do local ok, name, isHeader, isExpanded, rank, numTemp, modifier = pcall(GetSkillLineInfo, i) if ok and not isHeader and name == skillName then local base = tonumber(rank) or 0 local mod = tonumber(modifier) or 0 return base + mod, base, mod end end return nil end -- ReadSelfTalents() -> { [talent name] = rank } | nil -- The OWN character's learned talent ranks, walked as -- GetNumTalentTabs -> GetNumTalents(tab) -> GetTalentInfo(tab, i). -- GetTalentInfo takes no unit, so this function is only ever called for -- the own character; other players' ranks arrive over the protocol in -- scan/talents.lua and are handed to the same DC_Hit.TalentHit. -- -- nil vs empty table is the load-bearing distinction: -- nil the API is absent or enumerated nothing at all (talent data not -- available yet) -> the caller must report talents as UNREAD -- {} the enumeration worked and the character has spent no points -> -- a MEASURED zero -- Only ranks above 0 are stored, so the table stays small and a lookup -- miss is indistinguishable from "not learned", which is what it means. function H.ReadSelfTalents() if not GetNumTalentTabs or not GetNumTalents or not GetTalentInfo then return nil end local okT, tabs = pcall(GetNumTalentTabs) if not okT or type(tabs) ~= "number" then return nil end local ranks = {} local enumerated = false for tab = 1, tabs do local okN, n = pcall(GetNumTalents, tab) if okN and type(n) == "number" then for i = 1, n do -- 1.12: name, iconTexture, tier, column, rank, maxRank, ... local ok, name, _, _, _, rank = pcall(GetTalentInfo, tab, i) if ok and type(name) == "string" and name ~= "" then enumerated = true local r = tonumber(rank) or 0 if r > 0 then ranks[name] = r end end end end end if not enumerated then return nil end return ranks end -- ReadUnit(unit, rawLinks) -> hit, race -- rawLinks : OPTIONAL { [invSlot] = link } that scan/gear.lua already -- fetched for this unit. Slots it covers are taken from it -- (no second GetInventoryItemLink pass); any slot H.SLOTS -- needs that rawLinks did not cover is fetched here instead. -- hit : the store shape documented in the file header -- race : race token from UnitRace (2nd return, e.g. "NightElf"); -- needed for the racial weapon-skill estimate of foreign -- players and stored next to the hit table by the engine. function H.ReadUnit(unit, rawLinks) local hit = { melee = 0, spell = 0, schools = {}, sources = {}, mainSub = nil, offSub = nil, rangedSub = nil, skillName = nil, hitRead = false, -- RESIST tab reuse (see file header addendum): resistGear starts -- empty and resistGearRead false so a caller that never reaches -- the accumulation below (e.g. an early return elsewhere) still -- sees a well-formed, honestly-unread pair rather than nil fields. resistGear = {}, resistGearRead = false, } local race = nil if UnitRace then -- 1.12 returns the display name first, the file token second local ok, display, token = pcall(UnitRace, unit) if ok then race = token or display end end -- which slots the caller's link table already covers local covered = {} if rawLinks and DC_Gear and DC_Gear.InvSlots then local gs = DC_Gear.InvSlots() for i = 1, table.getn(gs) do covered[gs[i]] = true end end local mainHasItem = false -- set pieces this unit wears, keyed by set name -- filled in the slot -- loop, folded into the sum ONCE after it (H.SumSets) local setsWorn = {} -- RESIST tab reuse: every itemHit() entry that came back non-nil this -- pass (tooltip actually read, whether cached or freshly scanned) -- -- handed to H.SumItemResists after the loop, see that function's -- header for why "entry existed" is the correct bar here. local resistItems = {} local n = table.getn(H.SLOTS) for i = 1, n do local slot = H.SLOTS[i] local link = nil if rawLinks and covered[slot] then link = rawLinks[slot] elseif GetInventoryItemLink then local ok, l = pcall(GetInventoryItemLink, unit, slot) if ok then link = l end end if link then hit.hitRead = true -- any link at all = the read worked local itemId, enchantId = nil, nil if DC_Gear and DC_Gear.ParseLink then itemId, enchantId = DC_Gear.ParseLink(link) end if slot == H.SLOT_MAIN then mainHasItem = true end if itemId then local entry = itemHit(unit, slot, itemId, enchantId or 0) if entry then H.AddSum(hit, entry.sum) table.insert(resistItems, entry) local ns = table.getn(entry.sources) for s = 1, ns do if table.getn(hit.sources) < H.SOURCE_CAP then table.insert(hit.sources, { item = entry.name, kind = entry.sources[s].kind, value = entry.sources[s].value, }) end end -- set membership: tally the pieces THIS unit wears per -- set name. The bonus list comes from whichever piece -- is seen first -- every piece of a set prints the same -- list, which is exactly why it must be counted once. if entry.set and entry.set.name then local sname = entry.set.name if not setsWorn[sname] then setsWorn[sname] = { pieces = 0, bonuses = entry.set.bonuses } end setsWorn[sname].pieces = setsWorn[sname].pieces + 1 end end -- weapon subtypes drive the caps; only the three weapon -- slots need the extra item lookup if slot == H.SLOT_MAIN or slot == H.SLOT_OFF or slot == H.SLOT_RANGED then local sub = nil if DC_Gear and DC_Gear.ItemInfo then local _, _, s = DC_Gear.ItemInfo(itemId) sub = s end if slot == H.SLOT_MAIN then hit.mainSub = sub elseif slot == H.SLOT_OFF then hit.offSub = sub else hit.rangedSub = sub end end end end end -- SET BONUSES, folded in ONCE per set (H.SumSets) now that the whole -- inventory has been walked and the piece count per set is known. -- Doing it here rather than per item is the entire fix: the bonus line -- is printed on every piece, so a per-item sum would multiply it by -- the number of pieces worn. local setSum, setSources = H.SumSets(setsWorn) H.AddSum(hit, setSum) -- kept SEPARATELY as well, so the tooltip can show the hit number as a -- plain addition (items + set bonuses + auras + talents) instead of one -- merged "from gear" figure. The totals above already contain it; these -- three fields only say how much of it came from sets. hit.setMelee = setSum.melee hit.setSpell = setSum.spell hit.setSchools = setSum.schools -- RESIST tab reuse (see file header addendum): fold every scanned -- item's resistance contribution into ONE sum, and record whether ANY -- item's tooltip was actually read (the honesty gate scan/engine.lua's -- gear-reconstruction fallback checks -- see H.SumItemResists). hit.resistGear, hit.resistGearRead = H.SumItemResists(resistItems) for s = 1, table.getn(setSources) do if table.getn(hit.sources) < H.SOURCE_CAP then local src = setSources[s] table.insert(hit.sources, { item = src.set .. " (" .. src.pieces .. " pieces)", kind = src.kind, value = src.value, set = src.set, }) end end -- main-hand weapon skill name: bare fists run on Unarmed, but an item -- whose subtype could not be resolved stays UNKNOWN (nil) -- claiming -- "Unarmed" for a weapon we simply failed to identify would be a -- wrong assumption, not a missing one. A FAILED read claims nothing -- at all: "no main hand" is only meaningful once something was read. local map = DC.WEAPON_SKILL_OF_SUBTYPE or {} if hit.mainSub then hit.skillName = map[hit.mainSub] elseif hit.hitRead and not mainHasItem then hit.skillName = "Unarmed" end return hit, race end