Files
DopingControl/scan/hit.lua
T
2026-08-03 09:47:19 +02:00

1053 lines
44 KiB
Lua

-- 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 = <number>, -- generic physical hit % from GEAR (melee AND
-- -- ranged); BuildPlayer folds DC.HIT_AURAS on top
-- -- and turns this into the melee-column TOTAL
-- spell = <number>, -- general spell hit % from GEAR; BuildPlayer
-- -- folds auras on top the same way
-- schools = { [school] = <number> }, -- school-only spell hit extras
-- sources = { { item = <name|nil>, kind = , value = }, ... },
-- mainSub = <itemSubType|nil>, -- main hand
-- offSub = <itemSubType|nil>, -- off hand
-- rangedSub = <itemSubType|nil>, -- ranged slot
-- skillName = <weapon skill of the main hand|nil>,
-- skill = <number|nil>, -- main-hand weapon skill: MEASURED for
-- -- the own character, otherwise the
-- -- estimate H.ApplyEstimate filled in
-- skillExact = <true|nil>, -- true only when `skill` was MEASURED
-- book = <bool|nil>, -- a skill book was ASSUMED (never on a
-- -- measured skill)
-- rangedSkill = <number|nil>, -- same for the ranged weapon
-- rangedSkillExact = <true|nil>,
-- hitRead = <bool> }
-- 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 enchant tab
-- has no waist column, but a belt most certainly can carry hit. 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" | <school name> (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 = <number>, spell = <number>, 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[<school>], 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 "<Set name> (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: <body>" -> 2, <body> (inactive form, ITEM_SET_BONUS_GRAY)
-- "Set: <body>" -> nil, <body> (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 = <bool> } | 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 = <how many the player wears>,
-- 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("<name>TextLeft<i>"), 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); slots outside its
-- coverage -- the waist, which the enchant tab has no column
-- for -- are fetched here.
-- 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