--[[-------------------------------------------------------------------------- ManiaxUpgrades - the tooltip itself. THE RE-RENDER TECHNIQUE, AND WHY THIS ONE The rank word has to sit on line 2, directly under the name, and every stat the upgrade changed needs a line of its own under the stat it changed. A tooltip can only be appended to: `AddLine` puts text at the bottom, and there is no "insert at line N". The two ways round that on 3.3.5a are: (a) ClearLines() and rebuild the whole tooltip from a captured copy. Rejected. ClearLines() drops the tooltip's item association, so GetItem() stops answering, the money line (Sell Price) and any `OnTooltipAddMoney` output are gone with no way to put them back from Lua, and every other add-on hooking the same OnTooltipSetItem after us sees an empty tooltip. It is also destructive mid-`SetX`, which is exactly when this hook runs. (b) Grow the tooltip by as many lines as are being inserted, then rewrite every FontString in place, sliding the lines that come after an insertion point down and writing the new content into the gap. Chosen. (b) never clears anything. The tooltip keeps its item, its money line and its owner; other hooks still see a real tooltip; and the only new state is a handful of extra lines, which `Show()` then sizes correctly. Every original line's colour is carried across by hand - `GetTextColor()` before, `SetTextColor()` after - because SetText alone would repaint every grey and green line white. Line 1 (the name) is never moved, so the item's quality colour is untouched: the server already sends epic purple for a rank-4 promotion, and guessing at it here would be a second source of truth. THE BONUS Daniel's ruling from the Ascension reference screenshot: the rank word and every stat the upgrade touched print in the same green as their own "Superior" line (NS.BONUS_COLOR). An ADDITIVE stat is TWO lines, not one - the base's own number, untouched, and then a new "+N " line under it. Never rewrite an additive base line's text to the clone's total. Amended 2026-09-06, off a helm reading "421 Armor" over "+28 Armor": a line carrying a TOTAL is exempt and stays ONE line, green, showing the clone's own figure. Splitting a total makes the reader add two numbers to learn the one number the line exists to state - and the gloves in the same ruling show the other half, a suffix's "+7 Stamina" still standing as its own green line. Told apart by shape, never by stat name: an additive line spells its own "+" right before the number, a total does not. See IsTotalLine. A clone template is a copy of the base with the numbers scaled, so the two tooltips have the same line shapes. Each line is reduced to a shape by replacing every number with a marker - "+5 Intellect" and "+2 Intellect" both become "+<#> Intellect" - and the clone's line is matched to the base's line of the same shape, in the order both encounter it, so the same shape appearing twice pairs first-with-first. Nothing is parsed by stat name, so a stat this add-on has never heard of still works. Three outcomes per line, decided against the BASE item's own (unsuffixed, un-upgraded) tooltip: - the base has this exact number too (nothing moved): left exactly as the server sent it, no new line; - the base has the same stat at a smaller number, and the line is ADDITIVE: the line is rewritten to the BASE's own number (its original string, not a reparsed float, so "1.90" does not come back as "1.9"), and a new green line is inserted under it with the delta - "+3 Intellect"; - the base has it at a smaller number and the line is a TOTAL ("449 Armor", "47 - 123 Damage", "(44.7 damage per second)"): the clone's line already reads the right figure, so it is left exactly as sent and only recoloured. No rebase, no inserted row; - the base does not have this stat at all: the clone's line already reads the (only) value there is, so it is left in place and simply recoloured green rather than duplicated as a second line. A random suffix ("of the X") behaves like the third case at the tooltip level - the client itself appends its stat lines after the item's own, they are never merged into an existing line - so the same recolour-in-place treatment applies: scan the item's OWN entry with no suffix, and any line in the actual tooltip beyond what that unsuffixed scan has is a suffix addition, recoloured green in place. This applies independently of the clone/base handling above, and both compose on a clone that is also suffixed. Rewriting a number means the escape sequences have to be stepped over rather than through: "|cff1eff00" is eight hex digits and a naive pass would read it as a stat value. Colour codes and inline textures are masked out, the numbers are substituted, and the masks are put back for a restored BASE line; a freshly inserted delta line strips colour codes instead (it is flat green) but still restores any inline texture. If the base item is not in the client's cache yet, GetItemInfo returns nil and asking for it is what queues the query. Then, and only then, every line is left exactly as the clone tooltip already reads - no colouring, no guessing - and the next hover has the base. ----------------------------------------------------------------------------]] local NS = ManiaxUpgrades if not NS then return end local _G = _G local format, gsub, match, tonumber = string.format, string.gsub, string.match, tonumber -- One hidden tooltip, reused. Named so its FontStrings are reachable as -- ManiaxUpgradesScanTooltipTextLeftN, which is the only way to read them. local scan = CreateFrame("GameTooltip", "ManiaxUpgradesScanTooltip", UIParent, "GameTooltipTemplate") scan:SetOwner(UIParent, "ANCHOR_NONE") -- entry -> { shape = { {n1, n2, ...}, ... } }. Session-lifetime; an item -- template does not change under a running client. Shared by both scans this -- file does: a clone's pre-upgrade base (different entry) and an item's own -- unsuffixed self (same entry, no suffix on the link) - both are just "scan -- this entry with SetHyperlink('item:')", so one cache serves both. local baseCache = {} -- Re-entrancy: our own SetHyperlink on the scan tooltip must never be able to -- re-enter the hook, whatever else in the UI is hooking tooltips. local busy = false local NUMBER = "%d+%.?%d*" local MARK = "<#>" -- Escape sequences are masked out rather than stripped, because a number in -- this line may have to be REPLACED and then the line put back together. -- "|cff1eff00" is eight hex digits; read through, it is four stat values. -- One marker per kind, each with its own queue, so unmasking restores them in -- the order they were taken. None of the markers contains a digit. local M_TEX, M_COL, M_RST = string.char(26), string.char(27), string.char(28) local function Mask(text) local tex, col = {}, {} local masked = gsub(text, "|T.-|t", function(tok) tex[#tex + 1] = tok; return M_TEX end) masked = gsub(masked, "|c%x%x%x%x%x%x%x%x", function(tok) col[#col + 1] = tok; return M_COL end) masked = gsub(masked, "|r", M_RST) return masked, tex, col end local function Unmask(masked, tex, col) local i = 0 local out = gsub(masked, M_TEX, function() i = i + 1; return tex[i] end) local j = 0 out = gsub(out, M_COL, function() j = j + 1; return col[j] end) return (gsub(out, M_RST, "|r")) end -- The upgrade tint, as floats: a fresh line is coloured with SetTextColor, -- not an inline code, so it needs the numeric triplet rather than the hex -- string NS.BONUS_COLOR carries for inline use. local function HexColor(hex) return tonumber(hex:sub(1, 2), 16) / 255, tonumber(hex:sub(3, 4), 16) / 255, tonumber(hex:sub(5, 6), 16) / 255 end local BONUS_R, BONUS_G, BONUS_B = HexColor(NS.BONUS_COLOR) --- Split an item link into its id and suffix id. suffixId is 0 when the item -- carries none, negative for a random suffix ("of the X"), positive for a -- random property. -- Format: item::::::::: local function ParseLink(link) local id, suffix = match(link, "item:(%d+):%-?%d*:%-?%d*:%-?%d*:%-?%d*:%-?%d*:(%-?%d*):") if not id then return nil end return tonumber(id), tonumber(suffix) or 0 end --- Reduce a line to its shape, and keep everything needed to rebuild it. -- @return shape, numbers, source strings, masked text, texture queue, colour queue local function Shape(text) if not text or text == "" then return nil end local masked, tex, col = Mask(text) local nums, strs local shape = gsub(masked, NUMBER, function(n) nums = nums or {} strs = strs or {} nums[#nums + 1] = tonumber(n) strs[#strs + 1] = n return MARK end) if not nums then return nil end return shape, nums, strs, masked, tex, col end --- Put the BASE item's numbers back into the clone's line - the base's -- original number STRING, not a reparsed float, so "1.90" does not come back -- as "1.9" and "05" does not become "5". Colour codes and textures are -- restored, since this is still the server's own line, just showing the -- lower number it actually has. local function Rebase(masked, tex, col, baseStrs) local i = 0 local out = gsub(masked, NUMBER, function(n) i = i + 1 return baseStrs[i] or n end) return Unmask(out, tex, col) end --- Does this shape carry a TOTAL rather than an addition? -- -- "421 Armor", "34 - 51 Damage" and "(12.4 damage per second)" each state -- what the item HAS. Splitting one of those into the base's own number plus -- an inserted "+28 Armor" underneath makes the reader add two numbers to -- learn the one figure the line exists to give - Daniel's ruling, -- 2026-09-06, off a helm reading "421 Armor" over "+28 Armor". For these the -- clone's line already reads the total, so the whole treatment is to leave -- the text alone and colour it. -- -- Told apart by the shape, never by stat name: an additive line spells its -- own "+" immediately before the number ("+<#> Strength"), a total does not. -- So a suffix's "+7 Stamina" still gets its own green line - which is the -- gloves half of the same ruling - and nothing here has to know what "Armor" -- means, or be taught the next stat this client prints. local function IsTotalLine(shape) return not shape:find("+" .. MARK, 1, true) end --- Build a brand new line from a shape template and the delta pieces - e.g. -- shape "<#> Armor" and parts {"6"} becomes "+6 Armor". Some shapes already -- carry their own literal "+" right before the marker (stat lines always -- do: "+<#> Intellect") - matching that optional "+" along with the marker -- and replacing the whole thing with exactly one avoids printing "++3". -- Colour codes are dropped rather than restored, because this line is flat -- green throughout; an inline texture (rare on a stat line) is kept. local function DeltaText(shape, tex, parts) local i = 0 local out = gsub(shape, "%+?" .. MARK, function() i = i + 1 return "+" .. (parts[i] or "?") end) out = gsub(out, M_COL, "") out = gsub(out, M_RST, "") local j = 0 out = gsub(out, M_TEX, function() j = j + 1; return tex[j] end) return out end -- A line past this point is metadata the suffix recolour never touches: -- durability, the level and item-level requirements, a crafted-by line, the -- money frame. Matched by pattern, not position, and checked against every -- one of them so client ordering doesn't matter - the first match on the -- ACTUAL tooltip (see BuildRows/RenderSuffix) stops the recolour walk for -- good, whether or not that line also happens to match something in the -- base scan. This is the line a mispaired base scan (see ScanBase) used to -- reach "Durability 35 / 35" as if the suffix had added it. local function IsTrailingMeta(text) if not text or text == "" then return false end return match(text, "^Durability %d") ~= nil or match(text, "^Requires Level") ~= nil or match(text, "^Item Level") ~= nil or match(text, "^<.+>$") ~= nil or match(text, "^Sell Price") ~= nil end -- A fresh shallow copy of a text -> count table, so a caller can consume -- entries (decrementing as it matches lines) without corrupting the shared, -- session-cached original underneath it. local function CopyCounts(t) local out = {} for k, v in pairs(t) do out[k] = v end return out end --- Read one entry's tooltip (no enchant, no suffix, no unique id - the -- template on its own) into two indexes: `shapes`, a shape -> list-of- -- number-sets map for the clone/base delta computation, and `texts`, an -- exact-line-text -> count multiset for deciding whether an actual line is -- something a suffix added. Serves both the clone's pre-upgrade base and, -- independently, an item's own unsuffixed self. -- @return the index pair, or nil when the entry is not in the item cache yet. local function ScanBase(entry) local cached = baseCache[entry] if cached then return cached end -- Asking is what triggers the client's query when the answer is nil. if not GetItemInfo(entry) then NS:Debug("entry " .. entry .. " not cached; requested") return nil end scan:SetOwner(UIParent, "ANCHOR_NONE") scan:ClearLines() -- Every field past the id spelled out, never omitted. An omitted suffix -- or unique id let the client treat this as a wildcard and resolve it to -- the player's OWN worn/bagged instance of the item when one exists - for -- a suffixed item, that instance IS the suffixed one, so the "base" scan -- came back identical to the actual tooltip and nothing looked added. scan:SetHyperlink("item:" .. entry .. ":0:0:0:0:0:0:0:0") -- Both indexes cover the WHOLE scanned tooltip, metadata included: the -- clone/base delta side needs the durability/level buckets to exist (so it -- can see delta == 0 and correctly say nothing to do), and the boundary -- that keeps the suffix recolour off that same metadata lives entirely on -- the consumer side below, not here. local shapes, texts = {}, {} for i = 2, scan:NumLines() do local fs = _G["ManiaxUpgradesScanTooltipTextLeft" .. i] local text = fs and fs:GetText() if text and text ~= "" then texts[text] = (texts[text] or 0) + 1 local shape, nums, strs = Shape(text) if shape then local bucket = shapes[shape] if not bucket then bucket = {}; shapes[shape] = bucket end bucket[#bucket + 1] = { nums = nums, strs = strs } end end end scan:Hide() local index = { shapes = shapes, texts = texts } baseCache[entry] = index return index end --- The delta magnitudes for one line, one per number position, or nil when -- there is nothing to say. Refuses on a shape mismatch, on any negative -- movement, and on a line where nothing moved. Unsigned - DeltaText is the -- one that decides where the "+" goes, since a stat line's shape already -- carries its own literal one. A damage range yields {"2", "5"}, one number -- yields {"3"}. local function DeltaParts(cloneNums, baseNums) if not baseNums or #cloneNums ~= #baseNums then return nil end local parts, moved = {}, false for k = 1, #cloneNums do local d = cloneNums[k] - baseNums[k] if d < -0.0001 then return nil end if d > 0.0001 then moved = true end -- Whole numbers print whole; only DPS is ever fractional. if d == math.floor(d) then parts[k] = format("%d", d) else parts[k] = format("%.1f", d) end end if not moved then return nil end return parts end --- Capture every line, work out what changed, and build the full ordered -- list of rows the tooltip should show - originals, the base's own number -- where a bonus applies, and a new green row after each such line. local function BuildRows(name, n, baseMap, suffixMap) local usedClone = {} local suffixLeft = suffixMap and CopyCounts(suffixMap.texts) or nil local rows = {} -- Once true (a durability/level/item-level/crafted-by/money line seen), -- the suffix recolour stops looking at anything for the rest of the -- tooltip - by content or by position. The clone/base delta logic below -- is unaffected: it already refuses a line where nothing moved. local pastMeta = false for i = 1, n do local lf = _G[name .. "TextLeft" .. i] local rf = _G[name .. "TextRight" .. i] local row = {} if lf then row.lt = lf:GetText() row.lr, row.lg, row.lb = lf:GetTextColor() end if rf then row.rt = rf:GetText() row.rr, row.rg, row.rb = rf:GetTextColor() row.rshown = rf:IsShown() and row.rt and row.rt ~= "" end local insert if i == 1 then -- The suffix is going away server-side; strip it here either way so an -- un-refreshed itemcache does not show "(Rank 3)" above "Radiant". if row.lt then row.lt = gsub(row.lt, NS.RANK_SUFFIX, "") end elseif row.lt then -- Captured once, before anything below can rewrite row.lt: the suffix -- check has to compare the line the client actually sent, never the -- base-restored text the clone/base branch may substitute in below - -- comparing the rewritten text against the clone's OWN unsuffixed -- scan (which never saw a rewrite) always misses, which is what -- coloured a rebased base line green instead of leaving it alone. local original = row.lt if not pastMeta and IsTrailingMeta(original) then pastMeta = true end local shape, nums, _, masked, tex, col = Shape(original) -- The metadata boundary gates the clone/base delta too, not just the -- suffix recolour below it. Durability, the level and item-level lines -- and the crafted-by line all carry numbers that legitimately differ -- between a clone and its base, so "it moved" is true of them and means -- nothing: the old code rebased "Durability 80 / 80" to the base's own -- durability and hung a green "+5 / +5" under it, and once a total line -- stopped being split (IsTotalLine) that became a green "Durability -- 80 / 80" instead - which is the same wrong answer the suffix pass was -- already fenced off from. Same fence, same reason. if shape and baseMap and not pastMeta then local bucket = baseMap.shapes[shape] if bucket then local k = (usedClone[shape] or 0) + 1 usedClone[shape] = k local base = bucket[k] local parts = base and DeltaParts(nums, base.nums) if parts then if IsTotalLine(shape) then -- One line, green, reading the total the clone already has. -- No rebase and no insert: the number is right as it stands. row.lr, row.lg, row.lb = BONUS_R, BONUS_G, BONUS_B else row.lt = Rebase(masked, tex, col, base.strs) insert = { lt = DeltaText(shape, tex, parts), lr = BONUS_R, lg = BONUS_G, lb = BONUS_B, rshown = false, } end end else -- The base does not have this stat at all: the clone's line -- already reads the only value there is - colour it in place -- rather than duplicating it as a second line. row.lr, row.lg, row.lb = BONUS_R, BONUS_G, BONUS_B end end -- Independent of the clone/base handling above: is this EXACT line -- (by content, not shape or position) one the item's OWN unsuffixed -- tooltip does not have? The client appends suffix stat lines after -- the item's own rather than merging them, so the same in-place -- recolour applies here too - but never past the metadata boundary. if suffixLeft and not pastMeta then local left = suffixLeft[original] if left and left > 0 then suffixLeft[original] = left - 1 else row.lr, row.lg, row.lb = BONUS_R, BONUS_G, BONUS_B end end end rows[#rows + 1] = row if insert then rows[#rows + 1] = insert end end return rows end --- Write one row into the FontStrings at `target`. local function Write(name, target, row) local lf = _G[name .. "TextLeft" .. target] local rf = _G[name .. "TextRight" .. target] if lf then lf:SetText(row.lt or "") if row.lr then lf:SetTextColor(row.lr, row.lg, row.lb) end if row.lt and row.lt ~= "" then lf:Show() else lf:Hide() end end if rf then if row.rshown then rf:SetText(row.rt) if row.rr then rf:SetTextColor(row.rr, row.rg, row.rb) end rf:Show() else rf:SetText("") rf:Hide() end end end --- WotLK's SetTooltipMoney (the sell-price line) anchors GameTooltipMoneyFrameN -- to the BOTTOMLEFT of whatever line was last when it ran, before our hook -- ever sees the tooltip. Growing the tooltip afterward doesn't move that -- frame - it is still glued to the SAME FontString object, which now holds -- different content because everything shifted down underneath it - so the -- money line ends up drawn over whatever line we put there instead of below -- the tooltip's new actual last line. Move it, every time we grow. local function ReanchorMoney(name, total) local i = 1 while true do local mf = _G[name .. "MoneyFrame" .. i] if not mf then break end if mf.IsShown and mf:IsShown() then mf:ClearAllPoints() mf:SetPoint("TOPLEFT", _G[name .. "TextLeft" .. total], "BOTTOMLEFT", 0, 0) end i = i + 1 end end --- A clone: rank word on line 2, every changed stat gets a green delta line -- under it, and any random-suffix line is recoloured in place. local function RenderClone(tooltip, itemId, baseEntry, rank, hasSuffix) local n = tooltip:NumLines() if not n or n < 1 then return end local name = tooltip:GetName() if not name then return end local info = NS.RANKS[rank] if not info then return end local baseMap = ScanBase(baseEntry) local suffixMap = hasSuffix and ScanBase(itemId) or nil -- 1. Capture and annotate. Nothing is written until the whole tooltip has -- been read, and everything computed here comes from the FontStrings as -- they stand right now. local body = BuildRows(name, n, baseMap, suffixMap) local rankRow = { lt = "|cff" .. NS.BONUS_COLOR .. info.word .. "|r", lr = 1, lg = 1, lb = 1, rshown = false, } -- Final order: the name, the rank line, then the rest with any delta rows -- already interleaved by BuildRows. local total = 1 + 1 + (#body - 1) if total < n then return end -- defensive; cannot happen -- 2. Grow by exactly as many rows as are being added. local grow = total - n for _ = 1, grow do tooltip:AddDoubleLine(" ", " ") end if tooltip:NumLines() ~= total then NS:Debug("tooltip did not grow; leaving it alone") return end -- 3. Write forward. Every read happened in step 1, so there is nothing left -- to clobber by writing in ascending order. Write(name, 1, body[1]) Write(name, 2, rankRow) for i = 2, #body do Write(name, i + 1, body[i]) end -- 4. Re-anchor the money frame (if the client already showed one) to the -- new actual last line, then resize - the tooltip is several lines -- taller and some lines are wider. ReanchorMoney(name, total) tooltip:Show() end --- A suffix with no clone involved: nothing shifts and no rank line appears, -- so the only change is recolouring the lines the item's own unsuffixed -- tooltip does not have - decided by exact line content, never by shape or -- position, and never past the durability/level/item-level/crafted-by/money -- block at the bottom. local function RenderSuffix(tooltip, itemId) local n = tooltip:NumLines() if not n or n < 1 then return end local name = tooltip:GetName() if not name then return end local suffixMap = ScanBase(itemId) if not suffixMap then return end local left = CopyCounts(suffixMap.texts) for i = 2, n do local lf = _G[name .. "TextLeft" .. i] local text = lf and lf:GetText() if text and text ~= "" then if IsTrailingMeta(text) then break end local remaining = left[text] if remaining and remaining > 0 then left[text] = remaining - 1 else lf:SetTextColor(BONUS_R, BONUS_G, BONUS_B) end end end end local function Render(tooltip, itemId, baseEntry, rank, hasSuffix) if baseEntry then RenderClone(tooltip, itemId, baseEntry, rank, hasSuffix) else RenderSuffix(tooltip, itemId) end end local function OnTooltipSetItem(tooltip) if busy then return end if not tooltip.GetItem then return end local _, link = tooltip:GetItem() if not link then return end local id, suffixId = ParseLink(link) if not id then return end local baseEntry, rank = NS:Resolve(id) local hasSuffix = suffixId ~= 0 if not baseEntry and not hasSuffix then return end -- OnTooltipSetItem can fire more than once for the same content; a second -- pass would shift the tooltip down again and annotate the annotations. if tooltip.mxuLink == link then return end tooltip.mxuLink = link busy = true local ok, err = pcall(Render, tooltip, id, baseEntry, rank, hasSuffix) busy = false if not ok then tooltip.mxuLink = nil NS:Debug("render failed: " .. tostring(err)) end end local function OnTooltipCleared(tooltip) tooltip.mxuLink = nil end -- GameTooltip covers bags, character sheet, action bars and the world; -- ItemRefTooltip is a chat link; ShoppingTooltip1/2 are the comparison -- tooltips that appear beside it. All four are the same object type and take -- the same treatment. local hooked = {} local function Hook(tooltip) if not tooltip or hooked[tooltip] then return end hooked[tooltip] = true tooltip:HookScript("OnTooltipSetItem", OnTooltipSetItem) tooltip:HookScript("OnTooltipCleared", OnTooltipCleared) tooltip:HookScript("OnHide", OnTooltipCleared) end Hook(GameTooltip) Hook(ItemRefTooltip) Hook(ShoppingTooltip1) Hook(ShoppingTooltip2) -- ItemRefShoppingTooltip1/2 belong to the chat-link tooltip and only exist on -- clients that ship them; hook them when they do. Hook(_G["ItemRefShoppingTooltip1"]) Hook(_G["ItemRefShoppingTooltip2"])