DopingControl v0.6.4

This commit is contained in:
2026-08-30 15:37:17 +02:00
parent ecab5ba845
commit 695dd9dcd4
23 changed files with 2278 additions and 296 deletions
+36 -13
View File
@@ -19,6 +19,12 @@
-- resolved via SuperWoW SpellInfo(id) (return 3 = icon) when the function
-- exists. textures is OPTIONAL/partial by design -- every consumer
-- nil-guards, nothing downstream may require it.
-- Buff EFFECT lines: auras.effects[name] = the tooltip's line 2 text
-- (same hidden-tooltip read as the name, TextLeft2) for every path-A buff
-- that yielded both a name and a non-empty line 2. This feeds the item
-- identification ladder for generic buff names ("Well Fed" -> which food,
-- via DC.ResolveItem's discrim patterns). effects is OPTIONAL/partial
-- exactly like textures -- every consumer nil-guards.
-- Disagreements between the two ID sets are counted (symmetric difference,
-- only when BOTH paths yielded at least one ID -- an absent path is missing
-- data, not a disagreement) and accumulated by the engine into
@@ -68,8 +74,9 @@ local A = DC_Aura
-- Assemble(rawA, rawB, idNames) -> auras, aurasRead, disagreements, unknown
-- rawA : array of { name = string|nil, id = number|nil,
-- texture = string|nil } (path A; texture is the 1st
-- UnitBuff return, optional)
-- texture = string|nil, effect = string|nil }
-- (path A; texture is the 1st UnitBuff return,
-- effect the tooltip's line 2 -- both optional)
-- rawB : array of spell ids (numbers, path B)
-- idNames : OPTIONAL { [spellId] = "Buff Name" } for path-B ids,
-- filled by the WoW-side caller (SuperWoW SpellInfo lookup --
@@ -78,9 +85,10 @@ local A = DC_Aura
-- as before (no behavior change without it).
-- Returns:
-- auras : { names = { [name]=true }, ids = { [id]=true },
-- textures = { [name]=iconPath } }
-- (store shape; textures may be empty/partial --
-- consumers nil-guard)
-- textures = { [name]=iconPath },
-- effects = { [name]=effectLine } }
-- (store shape; textures AND effects may be empty/
-- partial -- consumers nil-guard)
-- aurasRead : bool -- true iff at least one USABLE aura arrived: a
-- path-A entry carrying a name or an id, or a path-B id.
-- Entries with NEITHER (UnitBuff texture present but the
@@ -131,6 +139,7 @@ function A.Assemble(rawA, rawB, idNames)
local idsA = {}
local idsB = {}
local textures = {} -- [buffName] = icon path
local effects = {} -- [buffName] = tooltip effect line (line 2)
local namedIds = {} -- ids that arrived WITH a name (path A rows)
local usable = 0 -- entries that carry actual evidence (see header)
@@ -144,6 +153,9 @@ function A.Assemble(rawA, rawB, idNames)
if e.texture then
textures[e.name] = e.texture
end
if type(e.effect) == "string" and e.effect ~= "" then
effects[e.name] = e.effect
end
end
if type(e.id) == "number" and e.id > 0 then
idsA[e.id] = true
@@ -259,7 +271,8 @@ function A.Assemble(rawA, rawB, idNames)
end
end
return { names = names, ids = ids, textures = textures },
return { names = names, ids = ids, textures = textures,
effects = effects },
aurasRead, disagreements, unknown
end
@@ -376,15 +389,22 @@ function A.GetScanTip()
return ensureTip()
end
-- Returns name (tooltip line 1) AND effect (tooltip line 2 -- the buff's
-- effect text, feeds the item identification ladder for generic names).
-- raises an error on bad units -> always called through pcall
local function tipBuffName(unit, buffIndex)
scanTip:ClearLines()
scanTip:SetUnitBuff(unit, buffIndex)
local name, effect = nil, nil
local textObj = getglobal("DopingControlScanTipTextLeft1")
if textObj then
return textObj:GetText()
name = textObj:GetText()
end
return nil
local effObj = getglobal("DopingControlScanTipTextLeft2")
if effObj then
effect = effObj:GetText()
end
return name, effect
end
-- debuff twin of tipBuffName (same hidden tooltip, SetUnitDebuff);
@@ -420,20 +440,23 @@ function A.ReadUnit(unit, guid)
if not ok or not texture then
break
end
local name = nil
local name, effect = nil, nil
if tip then
local ok2, nm = pcall(tipBuffName, unit, i)
local ok2, nm, eff = pcall(tipBuffName, unit, i)
if ok2 then
name = nm
effect = eff
end
end
local id = nil
if type(auraID) == "number" and auraID > 0 then
id = auraID
end
-- texture (UnitBuff return 1) rides along so Assemble can key
-- it under the tooltip name (auras.textures)
table.insert(rawA, { name = name, id = id, texture = texture })
-- texture (UnitBuff return 1) and the tooltip effect line ride
-- along so Assemble can key them under the tooltip name
-- (auras.textures / auras.effects)
table.insert(rawA, { name = name, id = id, texture = texture,
effect = effect })
end
end
if UnitDebuff then
+21 -7
View File
@@ -120,6 +120,15 @@ function E.NormalizeWeapon(isSelf, v, readable)
end
return false
end
-- Stock 1.12 GetWeaponEnchantInfo takes NO arguments: on a client without
-- SuperWoW the extra unit is ignored and the first return is the PLAYER's
-- hasMainHandEnchant (1|nil). Only the SuperWoW name channel answers about
-- the foreign unit, and it is always string-or-nil (measured: 11 names,
-- 14 nils, "" never). A non-string therefore says nothing about this unit
-- => not readable, never "has an imbue".
if v ~= nil and type(v) ~= "string" then
return nil
end
if v == nil then
if readable == true then
return false
@@ -998,10 +1007,12 @@ if CreateFrame then
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.
if DC.SimOwnsStore(db) then
-- Test mode OR demo mode was on (or switched on while this scan
-- was in flight): the simulator fully replaces the data source
-- ("real data never beats the simulation") -- drop the result.
-- This is the load-bearing gate: it is the only thing between a
-- finished scan and DC.store = E.NewStore(...) below.
results = {}
return
end
@@ -1055,8 +1066,11 @@ if CreateFrame then
-- 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.
-- roster snapshot. DC.SimOwnsStore is NOT checked here -- an explicit
-- Start is user intent, and the callers that are NOT user intent
-- (READY_CHECK, the roster-change and window-open auto-scans) check it
-- themselves. The scan may therefore run in test/demo mode; what it may
-- never do is publish, so finish() drops the result instead.
function E.Start(force)
if running then
if not force then
@@ -1087,7 +1101,7 @@ if CreateFrame then
eventFrame:SetScript("OnEvent", function()
if event == "READY_CHECK" then
local db = DopingControlDB
if db and db.readyCheckScan and not db.testMode then
if db and db.readyCheckScan and not DC.SimOwnsStore(db) then
E.Start()
end
return
+2 -2
View File
@@ -29,9 +29,9 @@ local G = DC_Gear
-- Fallback inventory-slot list (= invSlot column of DC.SLOTS_EQUIPMENT,
-- data/enchants.lua) so this file works standalone in tests.
local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
local FALLBACK_INV_SLOTS = { 1, 2, 3, 15, 5, 6, 9, 10, 7, 8, 16, 17, 18, 11, 12, 13, 14 }
-- InvSlots() -> array of the 16 inventory slot numbers, in column order.
-- InvSlots() -> array of the 17 inventory slot numbers, in column order.
-- Prefers DC.SLOTS_EQUIPMENT (single source of truth once data/ is loaded).
function G.InvSlots()
if DC.SLOTS_EQUIPMENT then
+7 -6
View File
@@ -122,9 +122,11 @@ 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.
-- 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
@@ -888,9 +890,8 @@ 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.
-- (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
+62 -6
View File
@@ -67,6 +67,11 @@ T.ASK_GAP = 5
-- do not ask the same player again for this long. Talents change on
-- respec, which is rare and never mid-raid.
T.REASK_AFTER = 1800
-- how long an unfinished reply stays open. A whole reply lands within a
-- fraction of a second, so a line arriving this much later cannot belong to
-- it -- it is the start of a NEW reply and must not be folded into the old
-- accumulator (see T.AccStale).
T.ACC_TTL = 10
-- ==================================================================
-- PURE SECTION (offline-testable)
@@ -147,9 +152,24 @@ end
-- spent = <sum of pointsSpent over the trees that reported>,
-- sumRank = <sum of every rank we stored>,
-- trees = <how many tab lines arrived>,
-- complete = <INSTalentEND seen> }
function T.NewAcc()
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false }
-- complete = <INSTalentEND seen>,
-- at = <when the first line arrived, nil offline> }
function T.NewAcc(now)
return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false,
at = now }
end
-- Has this accumulator been waiting so long that the next line must belong to
-- a different reply? Only the END marker drops an accumulator, so a reply
-- whose tail was lost would otherwise stay open forever and swallow the next
-- one: tab lines add up twice while ranks are deduplicated by name, so
-- T.Plausible rejects a perfectly good retry. Without a clock (offline) or an
-- opening stamp nothing expires -- the old behaviour, unchanged.
function T.AccStale(acc, now)
if not acc or not acc.at or not now then
return false
end
return (now - acc.at) > T.ACC_TTL
end
-- Fold one parsed line into the accumulator. Returns the accumulator so
@@ -281,6 +301,9 @@ if CreateFrame then
return ok
end
-- set below, once the frame exists: arms the paced sender
local arm
-- Called by the scan with the players it can see. Nothing is sent here
-- -- names are only lined up; the timer below paces them out.
function T.Request(names)
@@ -296,6 +319,9 @@ if CreateFrame then
table.insert(queue, n)
end
end
if arm and table.getn(queue) > 0 then
arm()
end
end
local f = CreateFrame("Frame", "DopingControlTalentFrame")
@@ -313,9 +339,14 @@ if CreateFrame then
if not kind then
return
end
local now = GetTime()
local a = acc[sender]
if a and T.AccStale(a, now) then
-- the previous reply lost its END line; this one starts fresh
a = nil
end
if not a then
a = T.NewAcc()
a = T.NewAcc(now)
acc[sender] = a
end
T.Feed(a, kind, data)
@@ -338,14 +369,26 @@ if CreateFrame then
-- queue the scan filled. Deliberately a plain timer rather than a burst
-- after each scan -- 40 requests in one frame is what a rate limit
-- would punish, and nothing documents where that limit sits.
--
-- It is installed only while there is something to send and takes itself
-- off again the moment the queue runs dry (or the feature is switched
-- off): an OnUpdate runs 60+ times a second for the whole session, and a
-- player who never scans must not pay for a queue that is always empty.
-- Re-arming needs no extra wiring -- every scan calls T.Request.
local elapsed = 0
f:SetScript("OnUpdate", function()
local function disarm()
f:SetScript("OnUpdate", nil)
end
local function drain()
elapsed = elapsed + (arg1 or 0)
if elapsed < 1 then
return
end
elapsed = 0
if not enabled() or table.getn(queue) == 0 then
disarm()
return
end
local now = GetTime()
@@ -359,10 +402,23 @@ if CreateFrame then
T.Ask(name)
end
end
end)
if table.getn(queue) == 0 then
disarm()
end
end
arm = function()
if f:GetScript("OnUpdate") then
return
end
-- a long idle gap must not fire an immediate burst
elapsed = 0
f:SetScript("OnUpdate", drain)
end
-- forget everything (respec, or a deliberate re-read)
function T.Clear()
disarm()
acc = {}
ranks = {}
readAt = {}