-- DopingControl scan/talents.lua -- Reading OTHER players' talents. -- -- Why this file exists: talents raise hit (Precision, Elemental Precision, -- Shadow Focus, Suppression, ...), so a hit number that omits them is -- systematically LOW. GetTalentInfo has no unit parameter and the server -- never transmits foreign talents (no SMSG_INSPECT_TALENT in this fork, -- no talent field in InitVisibleBits or SMSG_PARTY_MEMBER_STATS). -- -- This server nonetheless ships its OWN inspect protocol, and it is a -- plain addon-message conversation any addon may hold. -- -- ask SendAddonMessage("TW_CHAT_MSG_WHISPER", "INSTalentShow", -- "GUILD") -- reply the TARGET's client runs GetTalentInfo on ITSELF and sends one -- message per line: -- INSTalentTabInfo;;;; -- INSTalentInfo;;;;;; -- ;;; -- ;; -- INSTalentEND; -- -- Cost, as observed: ONE outgoing message per player; the reply is a few -- dozen messages, ending in INSTalentEND. -- -- Three properties of the reply path decide the design here: -- -- * The answering client checks NOTHING -- no CanInspect, no group, -- guild, range or visibility test. Anyone whose NAME we know answers. -- So the limit has to be ours: we only ask for players the scan -- actually shows (ASK_GAP apart, each name at most REASK_AFTER), and -- the whole sender can be switched off. -- * We cannot ask for single talents. What gets sent is decided by the -- target's client (Turtle's own code in patch-9.mpq), not by us -- it -- dumps the whole tree. Our outgoing traffic is one message per -- player; everything else is inbound and we drop the lines we do not -- need. -- * 1.12's wire format is PREFIXMESSAGE, and the server rewrites -- the "" tag out of the prefix on the way -- which leaves the -- TAB at the HEAD OF THE PAYLOAD (measured: first bytes 9 73 78 83). -- An anchored match on "INS..." therefore fails on every single line. -- T.Normalize is not cosmetic; without it the whole reply is dropped. -- -- The pure half (Normalize/Parse/Feed/Plausible/ShouldAsk) is offline -- tested under real Lua 5.0; only the frame, the event and SendAddonMessage -- are in-game. DopingControl = DopingControl or {} local DC = DopingControl DC_Talents = DC_Talents or {} local T = DC_Talents -- addon-message prefix of the protocol (ours to send, theirs to answer on) T.PREFIX = "TW_CHAT_MSG_WHISPER" -- the channel argument is INERT once a "" tag is in the prefix (the -- tag is the whisper substitute) -- kept exactly as Turtle's own UI sends -- it, because a value the server does not expect is a needless risk T.CHANNEL = "GUILD" T.ASK_MSG = "INSTalentShow" -- seconds between two outgoing requests (any target). Nothing documents a -- rate limit for THIS protocol, so the number is deliberately timid: one -- request per this many seconds is far below what Turtle's own UI produces -- when a player clicks through an inspect window. T.ASK_GAP = 5 -- do not ask the same player again for this long. Talents change on -- respec, which is rare and never mid-raid. T.REASK_AFTER = 1800 -- ================================================================== -- PURE SECTION (offline-testable) -- ================================================================== -- Strip the leading wire separator. See the header: the payload arrives as -- INSTalent... because the server rewrote the name tag out of the -- prefix. Leading spaces/CR/LF are stripped too -- cheap, and it keeps the -- parser from depending on which whitespace the transport happens to leave. function T.Normalize(msg) if type(msg) ~= "string" then return "" end return (string.gsub(msg, "^[ \t\r\n]+", "")) end -- Split on ";" -- Lua 5.0 has no string.split and no gmatch. Empty fields -- are preserved (the protocol uses fixed positions, so a dropped empty -- field would shift every later one). local function split(s) local out, from = {}, 1 while true do local i = string.find(s, ";", from, true) if not i then table.insert(out, string.sub(s, from)) break end table.insert(out, string.sub(s, from, i - 1)) from = i + 1 end return out end -- Classify + decode one payload. -- Returns "tab", { tree, treeName, spent, numTalents } -- or "talent", { tree, index, name, tier, column, rank, maxRank } -- or "end", nil -- or nil (not ours -- every other addon's traffic lands here too) -- -- A talent line with a MISSING or non-numeric rank is rejected rather than -- read as 0: a silently zeroed rank looks exactly like an untrained talent -- and would understate hit without any sign that something went wrong. function T.Parse(msg) msg = T.Normalize(msg) if msg == "" then return nil end if string.find(msg, "INSTalentEND", 1, true) == 1 then return "end", nil end if string.find(msg, "INSTalentTabInfo;", 1, true) == 1 then local f = split(msg) local tree = tonumber(f[2]) local spent = tonumber(f[4]) if not tree or not spent then return nil end return "tab", { tree = tree, treeName = f[3], spent = spent, numTalents = tonumber(f[5]) } end if string.find(msg, "INSTalentInfo;", 1, true) == 1 then local f = split(msg) local tree = tonumber(f[2]) local name = f[4] local rank = tonumber(f[7]) if not tree or type(name) ~= "string" or name == "" or not rank then return nil end return "talent", { tree = tree, index = tonumber(f[3]), name = name, tier = tonumber(f[5]), column = tonumber(f[6]), rank = rank, maxRank = tonumber(f[8]) } end return nil end -- A per-sender accumulator. Shape: -- { ranks = { [talentName] = rank }, -- rank > 0 only, like the own read -- spent = , -- sumRank = , -- trees = , -- complete = } function T.NewAcc() return { ranks = {}, spent = 0, sumRank = 0, trees = 0, complete = false } end -- Fold one parsed line into the accumulator. Returns the accumulator so -- callers can chain; `complete` flips exactly once, on the END marker. function T.Feed(acc, kind, data) if not acc or not kind then return acc end if kind == "end" then acc.complete = true return acc end if kind == "tab" and data then acc.trees = acc.trees + 1 acc.spent = acc.spent + (data.spent or 0) return acc end if kind == "talent" and data and data.rank and data.rank > 0 then -- keyed BY NAME, because that is what DC.TALENT_HIT matches on -- (data/talenthit.lua) and what ReadSelfTalents produces -- the two -- paths must hand DC_Hit.TalentHit the same shape or they would -- drift apart silently if not acc.ranks[data.name] then acc.sumRank = acc.sumRank + data.rank acc.ranks[data.name] = data.rank end end return acc end -- Consistency gate before a reply is believed. -- -- The client that answered told us two independent things: how many points -- it spent per tree, and every individual rank. They must agree. They are -- computed from the same source on the far side, so agreement does not -- prove the data is FRESH -- but disagreement proves we mis-parsed or lost -- messages, and that is exactly the failure this protects against (a -- half-received tree would understate hit while looking perfectly normal). -- -- All three trees must have reported; a talentless character legitimately -- sends three empty tab lines and zero talent lines, which passes. function T.Plausible(acc) if not acc or not acc.complete then return false end if acc.trees < 3 then return false end return acc.sumRank == acc.spent end -- Throttle decision, pure so the whole policy is testable without a client. -- asked = { [name] =