-- Vampify -- the one-line display. -- -- Shows total + HPS + the equipped sources' combined Vamp percentage with their count. Per-source -- detail lives in /vf status, not on screen. -- -- Compact-bar rewrite (follow-up change request, 2026-08-22). The line used to open with a "> Vamp " prefix -- (a bound glyph plus the addon's own name) and end with the SESSION heal/damage ratio -- (stats.pct, still computed by VampifyAggregate.fight -- see V.format's own comment for why that -- figure and the one shown here are not the same thing). Both are gone now, with nothing put back -- in their place: the prefix -- because the bar already IS Vampify (nothing else lives at that screen position), and the ratio -- because it answers "how much came back this session" rather than "what is the gear configured to -- return", which is what a player glancing at the bar while re-gearing actually wants. The bound -- glyph itself (V.boundFlag) is untouched and still tested below -- only its wiring into this line -- was removed; nothing currently calls it. VampifyDisplay = {} local V = VampifyDisplay -- Which set the mouseover breakdown currently shows: "session" (since login, the default), -- "lifetime" (never auto-resets), or "last" (the current-or-most-recently-completed fight, third -- tab added 2026-08-25 -- "ebenso dritten detailtab einfuegen: last"). A field on V rather than a -- file-local, so it is reachable from both V.build() and V.showTooltip() regardless of which is -- defined first in this file -- see the comment on frame:SetScript("OnMouseUp", ...) below for why -- that ordering actually bites in Lua 5.0. Not persisted: it is a display preference, not data, and -- resets to "session" on reload. -- -- Tab order [SESSION | LIFETIME | LAST], LAST appended rather than inserted first: SESSION stays -- the login default (unchanged muscle memory for anyone already used to the two-tab toggle), and a -- single right-click still lands on LIFETIME exactly as it always has -- LAST costs one extra click -- to reach instead of reordering two tabs people already navigate today. Putting LAST first (it is -- arguably the most frequently needed tab) is a reasonable alternative too; this is the more -- conservative of the two, not the only defensible one. V.tipScope = "session" -- Normalizes any V.tipScope value to one of the three real scopes, defaulting an unrecognised or -- nil value to "session" (the same "session is the safe default" convention -- core/commands.lua's VampifyResetSession already uses for its own scope argument) -- a single -- choke point so every reader below agrees on what an invalid scope means instead of each -- reimplementing its own fallback. function V.normalizeScope(scope) if scope == "lifetime" or scope == "last" then return scope end return "session" end -- True unless `scope` is "last" and the core-side last-fight read API has not shipped yet -- (mandatory fallback for when core/ has not yet added last-fight support). Gated on -- VampifyAggregate.resetScope's -- PRESENCE specifically, not on VampifyAggregate.spellBreakdown's (which already existed, boolean- -- only, before "last" existed) -- resetScope is a brand new symbol that ships in the exact same -- core/aggregate.lua change as "last" scope support in every read function, so its presence is a -- reliable, cheap capability probe: trusting spellBreakdown/splitTotals/spellSplit/ -- spellSourceBreakdown's OWN "last" branch without this guard would, against an un-upgraded core, -- silently read a DIFFERENT scope's real data (a truthy third argument reads as lifetime under the -- old boolean-only spellBreakdown; an unrecognised scope string falls through to session in the -- other three) and label it "Last" -- wrong data shown with confidence is worse than an honest -- empty panel, so this is checked before trusting any of the four "last" calls below. function V.lastScopeReady() return VampifyAggregate ~= nil and type(VampifyAggregate.resetScope) == "function" end -- Rotates through the tab order [SESSION | LIFETIME | LAST] -- the single right-click handler on -- the compact bar (V.build's frame:OnMouseUp) and every scope-toggle button's own OnClick funnel -- through this one function, so the two input paths can never disagree on what "next scope" means. -- Pure (no WoW call, no VampifyAggregate read) and offline-testable on its own. function V.nextScope(scope) if scope == "session" then return "lifetime" end if scope == "lifetime" then return "last" end return "session" end -- Each ability's share of the TOTAL Vampirism healing, in percent, one decimal. -- -- Moved to core/model.lua as VampifyModel.shareOfTotal (2026-08-24) -- core/aggregate.lua needed -- the same largest-remainder rounding and calling this gui/ function from core/ was backwards -- (core/ must not depend on gui/). Kept here as a thin alias: everything in this file already -- calls it as V.shareOfTotal, and the offline tests reference that name directly. V.shareOfTotal = VampifyModel.shareOfTotal -- Copies V.shareOfTotal's per-row percentages into a CALLER-OWNED buffer, immediately, before -- anything else runs. Exists because of an in-game bug (2026-08-24, post-mockup pass): V.shareOfTotal -- (== VampifyModel.shareOfTotal, core/model.lua:255-307) returns a POOLED, MODULE-LEVEL buffer -- -- not a copy -- and VampifyAggregate.spellSplit/.splitTotals/.spellSourceBreakdown ALSO call into -- that exact same pooled function internally, for their own unrelated ST/AoE-of-ONE-row rounding -- (core/aggregate.lua's local roundSplit). A caller that reads V.shareOfTotal's result across a -- loop that ALSO calls any of those three functions (as the row loop below now does, for each -- row's two-color bar) gets the buffer overwritten -- and truncated to a couple of elements -- -- out from under it after the very first iteration: every row after the first read a stale or -- absent value. Reproduced and pinned by an existing regression test (that test's own header -- comment has the full trace). -- Every call site that reads a share array across more than one statement now snapshots it here -- FIRST -- see V.showTooltip and V.buildDetailLines below. function V.snapshotShares(shares, n, out) out = out or {} local i for i = 1, n do out[i] = shares[i] or 0 end for i = n + 1, table.getn(out) do out[i] = nil end return out end function V.boundFlag(channels, aoeAvailable) local lower = false if channels then for _, state in pairs(channels) do if state == "undecided" then lower = true end end end local upper = not aoeAvailable if lower and upper then return "uncertain" end if lower then return "lower" end if upper then return "upper" end return nil end -- Sum + round pass for V.formatSources below -- the compact bar's own gear-percent segment reuses -- V.formatSources directly (2026-08-24 in-game feedback pass dropped a separate, "Gear"-suffixed -- wording that used to live here: a redundant unit suffix, since -- V.formatSources's bare "9% (4)" already says the same thing shorter). Whole number when it lands -- exactly on one, otherwise one decimal place (rounded to one decimal first, which also kills the float noise a -- repeated sum can pick up). local function sumSourcesPct(sources) sources = sources or {} local n = table.getn(sources) local sum = 0 for i = 1, n do sum = sum + (sources[i] or 0) end local r = math.floor(sum * 10 + 0.5) / 10 local pctStr if r == math.floor(r) then pctStr = string.format("%d%%", r) else pctStr = string.format("%.1f%%", r) end return pctStr, n end -- sources: the per-source percent list (e.g. {3, 2, 2, 2}) that feeds core/commands.lua's own -- sourcePercents -- VampifyDetect.getSources() plus manual overrides, reached here via -- VampifyState.sourcePercents (see commands.lua's comment on that assignment). Summed and counted -- HERE rather than read back from commands.lua's sumPercent/nSources fraction: those are computed -- as sum/100 in capture/detect.lua's D.summarise and would have to be multiplied by 100 again to -- get back to percent units, a round-trip this function has no reason to repeat when the source -- list it needs anyway is right there. function V.formatSources(sources) local pctStr, n = sumSourcesPct(sources) return pctStr.." ("..n..")" end -- stats.overheal is optional: nil or 0 leaves the line exactly as it was before overheal existed -- (callers that never set it, and the existing tests, must see no difference). Vampirism emits no -- heal event, so overheal here is CALCULATED from the health deficit at hit time (see -- VampifyModel.effective / VampifyState.effHeal), not measured -- it is exactly as trustworthy as -- that deficit read, no more. -- -- sources is passed straight to V.formatSources -- see that function's comment for where it comes -- from and why it replaces stats.pct (the session heal/damage ratio) on this line. function V.format(stats, sources) stats = stats or {} local oh = "" if stats.overheal and stats.overheal > 0 then oh = string.format(" (%d OH)", math.floor(stats.overheal)) end return string.format("%d%s | %.1f HPS | %s", math.floor(stats.heal or 0), oh, stats.hps or 0, V.formatSources(sources)) end -- ---- compact-bar segment formatters (mockup redesign, 2026-08-24) ----------------------------- -- -- The bar used to be ONE FontString rendering V.format's whole line. The mockup segments it -- -- heal(+OH), an ST/AoE badge group, " HPS" (colored), then the gear percent -- each of which -- needs its OWN FontString so it can be individually colored, hidden, or reflowed when a segment -- is absent. These are the pure text pieces the WoW-wiring shell below (V.build/V.update) places -- into that many FontStrings; V.format itself is UNCHANGED (still the old single-line text) -- -- nothing in this file calls it any more, but an existing regression test still pins -- its exact behaviour, so it stays rather than being repurposed out from under that test. -- "608" -- no "Heal" word and no icon (2026-08-24, in-game feedback pass: the leading -- blood-drop icon was purely decorative, and the word was redundant with it -- the number is the -- first, most prominent thing on the bar in a Vampirism-only addon; nothing else it could be -- needs saying). -- -- The "(N OH)" suffix this used to carry is GONE (2026-08-24 pixel measurement pass: the -- bar's plain text alone measured 457px against a 340px frame, and this suffix was pure length -- with no length budget to spend -- the same figure already lives in the row-detail panel and the -- breakdown panel's own Total line, so nothing is actually lost, only de-duplicated off the -- bar). stats.overheal itself is intentionally left untouched by this change -- V.update still -- computes it (cfg.showOverheal also drives a SEPARATE combat-text feature outside this file, see -- gui/options.lua's cbOverheal), this function just no longer reads it. function V.formatHealSegment(stats) stats = stats or {} return V.formatAmount(stats.heal or 0) end -- "27.2 HPS" -- its own segment (rather than folded into V.format's one string) so the WoW-wiring -- shell can color it independently (green, per the mockup) without re-coloring the rest of the bar. -- Kept its unit suffix (unlike the heal/gear segments) -- "HPS" is not redundant here, it is the -- one thing telling this number apart from every OTHER bare number on the bar. function V.formatHpsSegment(stats) stats = stats or {} return string.format("%.1f HPS", stats.hps or 0) end -- ST/AoE badge-group text for the compact bar, from VampifyAggregate.splitTotals's `out` shape -- (out.stHeal, .aoeHeal, .total, .stPct, .aoePct -- the VampifyAggregate.splitTotals interface this -- file programs against). `out` is the ALREADY-POPULATED table (the -- WoW-wiring shell calls splitTotals itself and passes the result here), not the function -- same -- injection convention as V.buildDetailLines' ctx.* callbacks, kept pure and testable with a plain -- stub table. Returns nil, nil when there is nothing to show: out itself absent (splitTotals not -- wired up yet -- the fallback contract, "badge group hidden, rest of the bar stays") or -- out.total <= 0 (nothing measured yet -- must not fabricate a 0 ST / 0 AoE badge pair). -- -- History: first shortened from "29.7k ST | 2.9k AoE (9%)" (measured 119px for the AoE half -- ALONE) down to bare k-notation amounts as the pill's adjoining value label; NOW UNUSED by the -- wiring entirely (2026-08-24, fourth correction, in-game report: the adjoining numbers were -- redundant with the pill and the main reason the bar overshot its own compactness target). Kept as -- a pure, tested formatter (same convention as V.format above -- a prior generation's bar text, -- superseded but not deleted) in case some future compact surface wants bare ST/AoE k-values -- again without a third competing formatter being written from scratch. function V.formatBadgeGroup(out) if not out or not out.total or out.total <= 0 then return nil, nil end return V.formatAmount(out.stHeal or 0), V.formatAmount(out.aoeHeal or 0) end -- ---- two-color proportional bar geometry ------------------------------------------------------- -- -- Shared pixel-width math for every two-color bar this redesign adds: the breakdown panel's own -- ST/AoE split row, and each ability row's two-color fill (see V.buildDetailLines' neighbourhood -- below for -- no, see the WoW-wiring shell's setRow -- where both call this). -- -- rightW is the COMPLEMENT of leftW (maxW - leftW), never independently rounded off leftPct's own -- twin percentage -- that is what keeps a 100/0 or 0/100 split exact (no stray 1px sliver of the -- empty color) and guarantees the two segments always tile the full maxW with no gap or overlap -- regardless of rounding noise in leftPct/rightPct themselves not summing to exactly 100. -- maxW <= 0, or both percentages <= 0 (nothing to show -- e.g. a spell with zero recorded heal), -- returns 0, 0 -- callers are expected to have already decided whether to draw/show the bar at all -- (see the panel's total==0 guard in V.showTooltip below), but this stays safe even if one calls it -- anyway rather than divide-by-zero or hand back a negative width. function V.splitBarWidths(maxW, leftPct, rightPct) maxW = maxW or 0 leftPct = leftPct or 0 rightPct = rightPct or 0 if maxW <= 0 or (leftPct <= 0 and rightPct <= 0) then return 0, 0 end local leftW = math.floor(maxW * (leftPct / 100) + 0.5) if leftW < 0 then leftW = 0 end if leftW > maxW then leftW = maxW end return leftW, maxW - leftW end -- V.pillSegments (the cap-width-clamp math for the compact-bar pill's rounded ends) is REMOVED -- (in-game correction: drop the rounded ends entirely -- the pill lost its end caps -- entirely, see gui/display.lua's layoutPill/buildPill for the current rectangular shape). Its -- whole reason to exist was deciding how a share narrower than one cap gets clamped up, and how a -- share of exactly 0 recolors its own now-orphaned cap -- neither question exists any more with no -- caps to clamp or recolor. The pill's geometry is now exactly V.splitBarWidths above (unchanged), -- already used elsewhere in this file for the same "two segments summing to a bar width" shape and -- already covered by the existing splitBarWidths regression tests -- nothing else needed -- porting. The old test file that tested only this function is deleted along -- with it rather than left testing a function that no longer exists. -- x-offset (from whatever origin `rightEdge`/`minX` are themselves measured from) to draw a -- RIGHT-ANCHORED label of pixel width `textW`, so its right edge lands `pad` px inside `rightEdge` -- -- regardless of how narrow the color segment it visually sits over is. Fixes a real bug (in-game -- report, 2026-08-24): the split row's AoE label used to anchor to its OWN segment's left edge, so at a -- narrow AoE share the label (wider than its own segment) ran out past the panel's right border. -- Anchoring the label to a FIXED boundary (the bar's own right edge, not the segment's) instead -- means it can never run off that side no matter how small the segment gets. -- Clamped at the OTHER end too (`minX`) -- a label wider than the whole available span is pushed -- back to minX rather than given a negative offset, which would run it off the panel's LEFT side -- instead: "never overflow" means both directions, not just the one that was reported. function V.rightAnchoredLabelX(rightEdge, textW, pad, minX) pad = pad or 0 minX = minX or 0 local x = (rightEdge or 0) - pad - (textW or 0) if x < minX then x = minX end return x end -- Chooses which of the split row's two label variants fits INSIDE a color segment of width segW, -- so the label never lies about which segment it belongs to by spilling onto its neighbour -- (2026-08-24 pixel measurement pass, Finding 5: the label must stay inside its own color segment -- -- measured, the AoE label used to right-anchor to the BAR's fixed right edge -- (V.rightAnchoredLabelX above) regardless of its OWN segment's width, so a narrow AoE share -- (9%) put 42 of the label's pixels on top of the blue ST fill it does not belong to). -- V.rightAnchoredLabelX is NOT wrong and stays in use elsewhere (the panel's row-detail value -- labels, which sit on a FIXED-width row, not a variable-width color segment) -- this is a -- different problem: here the available space itself shrinks with the segment. -- -- Two-tier degradation, both measured against the ACTUAL segment (not the whole bar): -- 1. full "Single-Target: 29.7k (91%)" -- used if it fits with `pad` clearance on both sides -- 2. short "29.7k" -- the bare amount, if the full label does not fit -- 3. nil -- neither fits (segment too thin even for the bare -- amount) -- caller hides the label rather than draw truncated/overlapping text; the -- segment's own COLOR still shows something is there, which is honest -- a hidden number -- beats a lying one. -- textWidthFn(text) -- injected (FontString:GetStringWidth in production) so this stays pure. function V.pickSplitLabel(fullText, shortText, segW, pad, textWidthFn) pad = pad or 0 segW = segW or 0 local fullW = textWidthFn(fullText) if segW >= fullW + 2 * pad then return fullText, fullW end local shortW = textWidthFn(shortText) if segW >= shortW + 2 * pad then return shortText, shortW end return nil, nil end -- "575" (< 1000, unabbreviated) / "14.0k" (1000 <= |n| < 1,000,000, one decimal) / "8.8M" -- (|n| >= 1,000,000, one decimal). -- -- THE addon-wide large-number formatter (2026-08-24 in-game feedback, superseding an -- earlier thousands-separator ask: switch to a "k" suffix once a value reaches the thousands -- and, -- for the million range, use "8.8M" rather than the ugly "8790.0k" that "k" alone would produce -- for 8790016). Used -- EVERYWHERE a large number is shown -- compact bar, split row, ability rows, the panel's Total -- line, the row-detail panel -- rather than a second, competing formatter per screen: this file -- used to have a narrower version of exactly this ("V.formatSplitAmount", the split row's own two -- labels only) which is now just this function under its general name; every other call site -- switched to it rather than growing its own. -- -- One decimal at both the k and M scale -- enough to tell close values apart (14.0k vs 14.3k) -- without turning into a second exact number; the whole point is a number a reader can take in at -- a glance. Ratio is deliberately NOT this formatter's job any more (follow-up in-game feedback, -- 2026-08-24: the ratio is already readable straight off the graphical bar) -- -- the bar length already carries that, so this only ever needs to carry the MAGNITUDE. -- 1,000,000 (not "whenever the k-form would print 4+ integer digits") is the fixed M threshold -- -- a predictable cutover a reader can learn once, rather than one that moves around depending on -- the exact value. -- -- Handles a negative input (sign preserved, magnitude formatted the same way) even though nothing -- in this addon's own data can currently go negative -- cheap defensiveness, not a real case. function V.formatAmount(n) n = n or 0 local sign = "" if n < 0 then sign = "-" n = -n end if n >= 1000000 then return sign .. string.format("%.1fM", n / 1000000) elseif n >= 1000 then return sign .. string.format("%.1fk", n / 1000) end -- Floored, not rounded, below 1000 -- matches this addon's own established convention -- (V.format's overheal branch, V.formatIdLine, ... all "floored, matching the accumulator, -- never rounded up") for exact, unabbreviated numbers: a displayed figure must never claim -- more than what has actually accumulated. Abbreviated k/M values above DO round (a normal -- printf %.1f) -- losing sub-unit precision is already the deal at that scale, and this -- matches the pre-existing formatSplitAmount behaviour nothing ever complained about. return sign .. tostring(math.floor(n)) end -- "Single-Target: 28.4k (61%)" / "AoE Cleave: 18.2k (39%)" -- the split row's own two half-labels. function V.formatSplitLabel(kind, heal, pct) return string.format("%s: %s (%d%%)", kind, V.formatAmount(heal), math.floor((pct or 0) + 0.5)) end -- Two spell ids can carry the same name (two ranks of Lightning Strike showed up as separate rows -- in-game). Merges the per-spell breakdown (VampifyAggregate.spellBreakdown's rows, keyed by spell -- id) onto the name the player actually distinguishes -- but keeps every merged-away id reachable, -- as out[j].ids, a list of { spell, damage, heal, overheal, hits }, biggest healer first WITHIN the -- row. A single-spell row still gets a one-element ids list, so a caller never has to special-case -- "was this name backed by one spell or several". -- -- File scope rather than nested inside the CreateFrame block below (where it used to live, as a -- bare local): it is pure Lua over its two table arguments plus VampifyConst, exactly like -- V.shareOfTotal/V.formatSources/V.format above, and belongs with them so it can be exercised -- offline without a CreateFrame stub. -- -- POOLING at both levels, same convention as VampifyAggregate.spellBreakdown and VampifyConst. -- resetList: the panel repaints on every hover-enter, on the right-click scope toggle, and -- -- while pinned -- at most once a second via V.update's own gate (V.shouldRepaintPin, see below), -- so neither the row entries nor their ids sublists are rebuilt from scratch when the ability set -- is unchanged from the previous call -- only a comparator would need to look past -- `used`/`table.getn`, and this file's own sort loops already do. local function mergeIdEntry(ids, idx, r) local e = ids[idx] if not e then e = {}; ids[idx] = e end e.spell = r.spell e.damage = r.damage e.heal = r.heal e.overheal = r.overheal or 0 e.hits = r.hits or 0 return e end function V.mergeByName(rows, out) local n, used = table.getn(rows), 0 for i = 1, n do local r = rows[i] local name = VampifyConst.spellName(r.spell) local hit = nil for j = 1, used do if out[j].name == name then hit = out[j]; break end end if hit then hit.damage = hit.damage + r.damage hit.heal = hit.heal + r.heal hit.overheal = hit.overheal + (r.overheal or 0) hit.hits = hit.hits + (r.hits or 0) local idsN = table.getn(hit.ids) mergeIdEntry(hit.ids, idsN + 1, r) table.setn(hit.ids, idsN + 1) else used = used + 1 local e = out[used] if not e then e = { ids = {} }; out[used] = e end e.name = name e.damage = r.damage e.heal = r.heal e.overheal = r.overheal or 0 e.hits = r.hits or 0 VampifyConst.resetList(e.ids) mergeIdEntry(e.ids, 1, r) table.setn(e.ids, 1) end end for i = used + 1, table.getn(out) do out[i] = nil end table.setn(out, used) -- Re-sort after merging: two merged halves can outrank a row that was above both. for i = 2, used do local e, j = out[i], i - 1 while j >= 1 and out[j].heal < e.heal do out[j + 1] = out[j]; j = j - 1 end out[j + 1] = e end -- Within each row, the ids sublist is sorted biggest healer first too -- independent of the -- row order above, since a row's own rank says nothing about which of its ids contributed most. for i = 1, used do local ids = out[i].ids local m = table.getn(ids) for a = 2, m do local e, b = ids[a], a - 1 while b >= 1 and ids[b].heal < e.heal do ids[b + 1] = ids[b]; b = b - 1 end ids[b + 1] = e end end return out end -- Each row's share of the LARGEST single row's heal, in percent (0..100, the biggest row always -- reads 100) -- NOT share of the total (that is still V.shareOfTotal/VampifyModel.shareOfTotal, -- untouched, still used for the id-level percentages in the row-detail panel). This is BAR-LENGTH -- math only (2026-08-24 pixel measurement pass, Finding 1): share-of-total was being used to -- size the ability-row bars, so even the single largest ability only ever reached ~19% of the -- panel's width -- 80% of every bar's own space sat empty, and the ability NAME (drawn to the -- right of the bar in the old column layout, now drawn INSIDE it, see setRow below) overran the -- bar in 9 of 10 rows because the bar itself was starved of the width the name needed to sit -- inside. Share-of-max fixes that by construction: the biggest healer gets the FULL row width, and -- every other row is sized relative to it -- the same normalization Details!/Recount/ShaguDPS use, -- and the reason "icon+name inside the bar" works there. The underlying PERCENT-OF-TOTAL figure -- (what actually gets shown, where it is still shown -- e.g. via V.shareOfTotal for id-level -- shares) is completely unchanged by this function; only how a row's LENGTH is derived changes. -- -- out: caller-owned, filled in place (positions 1..n set, anything past n cleared) -- no pooled -- module-level buffer of its own, unlike VampifyModel.shareOfTotal, precisely BECAUSE that pooled- -- buffer design already caused one real in-game bug (see V.snapshotShares' own header -- comment for the full trace) -- this function sidesteps the whole hazard class by never owning -- state a second caller could read out from under a first one. function V.shareOfMax(rows, out) out = out or {} local n = table.getn(rows) local maxHeal = 0 local i for i = 1, n do local h = rows[i].heal or 0 if h > maxHeal then maxHeal = h end end for i = 1, n do if maxHeal > 0 then out[i] = ((rows[i].heal or 0) / maxHeal) * 100 else out[i] = 0 end end for i = n + 1, table.getn(out) do out[i] = nil end return out end -- How many individual ability rows the breakdown panel draws before collapsing the rest into one -- "Other (N)" row (Finding 4, 2026-08-24: a real character's ~21 tracked abilities would draw -- a 581px-tall panel with sub-4px slivers for the smallest rows). ONE named constant, so the panel -- height/row count is changed in exactly one place, not re-derived at each call site. V.TOP_ROWS = 8 -- Caps `merged` (V.mergeByName's already-sorted, biggest-healer-first output) at V.TOP_ROWS real -- rows, folding whatever is left into ONE synthetic "Other (N)" row at the end -- summed damage/ -- heal/overheal/hits, and every folded-away row's OWN ids concatenated onto the Other row's ids -- list (reusing mergeIdEntry, the same per-id copy mergeByName itself uses). That last part is -- what makes "hover the Other row and see what is in it" work for free: V.buildDetailLines already -- walks entry.ids and resolves each id's own name/damage/heal via ctx.nameFn -- an id merged in -- from a folded-away ability is a real spell id with real per-ability numbers, so the existing -- detail-panel code lists it correctly with no special-casing, exactly as if it were one more rank -- of a multi-rank spell (which is the whole reason mergeIdEntry already existed). -- -- out[V.TOP_ROWS + 1] (the Other row itself, when one exists) is a POOLED, PERSISTENT table reused -- across repaints, same convention as mergeByName's own out[used] reuse above -- only its fields -- are overwritten each call, the table identity itself does not change. rowMeta.isOther (read by -- setRow in the WoW-wiring shell below) is what tells the Other row apart from a real ability row -- so it renders in a distinct neutral color rather than a fabricated ST/AoE split of a row that, -- by construction, mixes many unrelated abilities (visually more subdued, since it mixes ST and -- AoE). function V.buildTopRows(merged, n, out) n = n or V.TOP_ROWS out = out or {} local total = table.getn(merged) local keep = total if keep > n then keep = n end local i for i = 1, keep do out[i] = merged[i] end if total > n then local other = out[n + 1] if not other then other = { ids = {}, isOther = true }; out[n + 1] = other end other.name = "Other (" .. (total - n) .. ")" other.damage, other.heal, other.overheal, other.hits = 0, 0, 0, 0 other.isOther = true VampifyConst.resetList(other.ids) local idsUsed = 0 for i = n + 1, total do local r = merged[i] other.damage = other.damage + r.damage other.heal = other.heal + r.heal other.overheal = other.overheal + (r.overheal or 0) other.hits = other.hits + (r.hits or 0) local ids = r.ids local m = table.getn(ids) local j for j = 1, m do idsUsed = idsUsed + 1 mergeIdEntry(other.ids, idsUsed, ids[j]) end end table.setn(other.ids, idsUsed) table.setn(out, n + 1) else for i = keep + 1, table.getn(out) do out[i] = nil end table.setn(out, keep) end return out end -- ---- row-detail panel: pure text-building -------------------------------------------------- -- -- The row-detail panel (hover a data row in the PINNED breakdown -> a second panel with per-id -- origin, hit averages, effective rate, overheal, and the other scope's totals) is built from the -- functions below, every one of them pure Lua returning a string (or nil, meaning "the frame layer -- omits this line"). None of them touch CreateFrame, VampifyConst, or any WoW API -- the data they -- need (item-origin lookup, spellbook name set, spell-name lookup) is INJECTED by the caller, same -- reasoning as VampifyConst.spellName being reached for only inside mergeByName above and not -- baked into these. That keeps them exercisable offline exactly like V.shareOfTotal/V.format, and -- keeps the WoW-API shell (further down, inside the CreateFrame guard) a thin adapter that only -- supplies the real functions/tables and calls these to get text. -- id -> { kind = "item"|"ability"|"unknown", label = }. -- -- itemOriginFn(id): the item-proc lookup (VampifyConst.itemProcOrigin in production) -- returns an -- item name or nil. Tried FIRST: a proc id can never itself be a spellbook entry, but if some -- future id were resolvable both ways, the item is the more specific fact and -- wins. -- spellbookSet: a set (name -> true) of the player's current spellbook, built by the WoW-API shell -- from GetSpellName(i, BOOKTYPE_SPELL); membership is checked by NAME, not id, because spellbook -- slot indices are not spell ids and the shell has no cheaper way to ask "is this ability mine". -- nameFn(id): VampifyConst.spellName in production -- may return nil for an unresolved id, which -- must read as "no spellbook match", not error. function V.resolveOrigin(id, itemOriginFn, spellbookSet, nameFn) local itemName = itemOriginFn and itemOriginFn(id) if itemName then return { kind = "item", label = "Item: " .. itemName } end local name = nameFn and nameFn(id) if name and spellbookSet and spellbookSet[name] then return { kind = "ability", label = "Class ability" } end return { kind = "unknown", label = "origin unknown (#" .. tostring(id) .. ")" } end -- One ability-id's own line: " (#) dmg, vamp, %". damage/heal -- go through V.formatAmount (k/M-notation once they run large, 2026-08-24 in-game feedback -- -- see that function's own header comment); share is already a percent (largest-remainder rounded -- by V.shareOfTotal, one decimal here to match the main panel's own percent column). function V.formatIdLine(name, id, damage, heal, sharePct) return string.format("%s (#%d) %s dmg, %s vamp, %.1f%%", name, id, V.formatAmount(damage or 0), V.formatAmount(heal or 0), sharePct or 0) end -- "hits: (avg dmg -> vamp per hit)", or an explicit not-recorded line -- for hits == 0 -- legacy rows (persisted before the hit counter existed) carry hits == 0, not -- nil (see VampifyAggregate.spellBreakdown's own comment), so this must not read hits == 0 as -- "zero hits landed" and print a bogus "avg 0 dmg -> 0 vamp". function V.formatHitsLine(hits, damage, heal) hits = hits or 0 if hits <= 0 then return "hits: not recorded yet" end return string.format("hits: %d (avg %s dmg -> %s vamp per hit)", hits, V.formatAmount((damage or 0) / hits), V.formatAmount((heal or 0) / hits)) end -- Classifies a row's effective return rate against the equipped gear's source total: "floor" when -- the row returns MORE than gear would predict (the per-hit floor is binding -- small hits still -- return at least one HP per source), "aoe" when it returns LESS, "neutral" within +/-0.2 -- percentage points of gear (the tolerance band matches the panel's own note text on the main -- breakdown, gui/display.lua's V.showTooltip). -- -- NAMING NOTE (bug fix, in-game report 2026-08-25, Earth Shock #10414 -- a pure single-target -- spell whose detail panel showed "(AoE damping)" purely because its measured rate sat below gear, -- with no AoE hit ever recorded for it): "aoe" here is ONLY a numeric direction (below gear beyond -- tolerance), never a causal claim by itself. AoE damping is one of SEVERAL possible reasons a rate -- can sit below its gear nominal -- a lower spell rank (smaller hits let the per-hit floor bite -- harder, or simply a different true rate), a per-source floor rounding a small hit up, a gear -- change mid-session against a now-stale nominal, or partial resist/absorb bookkeeping -- and this -- function has no way to tell which. V.formatRateLine below is the one place that turns "aoe" into -- WORDS, and it now only says "AoE damping" when the caller hands it POSITIVE evidence (an actual -- classified AoE hit) that AoE is the real cause -- see its own comment. function V.classifyRate(effectivePct, gearPct) local diff = effectivePct - gearPct if diff > 0.2 then return "floor" end if diff < -0.2 then return "aoe" end return "neutral" end -- Effective return rate, in percent, from heal/damage -- shared by V.formatRateLine and -- V.formatFloorBadge below so both agree on the same number without either recomputing it -- differently. 0 for zero/negative damage rather than dividing by zero. function V.effectiveRatePct(heal, damage) if not damage or damage <= 0 then return 0 end return (heal or 0) / damage * 100 end -- "Return: % vs % Nominal" plus an explanatory parenthetical when applicable, or nil -- when there is no gear percent to compare against (an empty/zero VampifyState.sourcePercents -- omits the line entirely rather than printing "vs 0% Nominal", which would misread as "everything -- is AoE-damped"). Badge-worded (2026-08-24 badge pass) -- the floor-binding case used -- to carry its own parenthetical here too; it is now the separate standalone V.formatFloorBadge -- below, so a floor-binding row does not say the same thing twice across two lines. -- -- hasAoeHits/idCount (bug fix, in-game report 2026-08-25 -- see V.classifyRate's own comment -- for the Earth Shock symptom this answers): the parenthetical is now chosen by CHECKING PROVABLE -- causes, in order, never by guessing from the rate alone -- -- 1. hasAoeHits true (this row has at least one hit VampifyAggregate.spellSplit actually -- classified as off-target, in the CURRENT scope): "(AoE damping)" -- the one case with direct -- evidence. -- 2. hasAoeHits false but idCount > 1 (the row merges more than one spell id -- V.mergeByName -- folds every rank of the same-named spell into one row): "(mixed ranks)" -- downranking is a -- real, PROVABLE-FROM-THE-DATA alternative cause (a lower rank's smaller hits -- let the per-hit floor bite harder, dragging a blended average down even with zero AoE) -- -- more than one id under one name IS direct evidence multiple ranks were actually cast, not a -- guess. -- 3. Neither: "(unexplained)" -- chosen over silently dropping the parenthetical (which would -- read as "this number is unremarkable") and over asserting either cause without evidence -- -- an honest "we do not know why" beats a confident wrong answer. function V.formatRateLine(heal, damage, gearPct, hasAoeHits, idCount) if not gearPct or gearPct <= 0 then return nil end local eff = V.effectiveRatePct(heal, damage) local cls = V.classifyRate(eff, gearPct) local suffix = "" if cls == "aoe" then if hasAoeHits then suffix = " (AoE damping)" elseif idCount and idCount > 1 then suffix = " (mixed ranks)" else suffix = " (unexplained)" end end return string.format("Return: %.1f%% vs %.1f%% Nominal%s", eff, gearPct, suffix) end -- One merged-id's OWN effective return rate ("-> 12.3% return this rank"), shown ONLY for a row -- that merges more than one spell id (V.buildDetailLines gates the call on idCount > 1) -- a -- single-id row already gets this exact number from the row-level "Return: X% vs Y% Nominal" line, -- so repeating it per id there would say the same thing twice. For a multi-rank row the row-level -- rate is a BLENDED average across differently-sized hits (see V.formatRateLine's "(mixed ranks)" -- comment); this exposes each rank's own number so a player can see directly whether one specific -- rank is the one dragging the blend down, rather than only being told the blend exists. nil for -- zero/negative damage (no rate to show), same guard as V.effectiveRatePct. function V.formatIdRateLine(heal, damage) if not damage or damage <= 0 then return nil end return string.format(" -> %.1f%% return this rank", (heal or 0) / damage * 100) end -- "Multiple ranks combined (N)" note, shown once near the top of a merged row's detail (right after -- the split-status note) when idCount > 1 -- nil for a single-id row (nothing to disclose). Pure -- disclosure, not a diagnosis: it states the provable fact (more than one spell id landed under -- this name) without claiming that fact explains any particular number -- V.formatRateLine's own -- "(mixed ranks)" parenthetical is where that connection, when applicable, actually gets made. function V.formatMultiRankNote(idCount) if not idCount or idCount <= 1 then return nil end return "Multiple ranks combined (" .. idCount .. ") -- the return rate below is a blended" .. " average across differently-sized hits; see each rank's own line for its individual rate." end -- Greedy word wrap into pieces that each measure at most maxWidth. -- -- Exists because gui/display.lua's row-detail panel lays its lines out on a fixed cursor -- one -- DETAIL_LINE_H slot per line ENTRY -- while a FontString handed a long string wraps on its own and -- silently grows past that slot, drawing the next entry on top of its own overflow (in-game report, -- 2026-08-25: overlapping text, the "Multiple ranks combined (13)" note over the two rows -- below it). Splitting the entry BEFORE it reaches the cursor makes the renderer's "one entry = one -- slot" assumption true instead of merely assumed; it is not a workaround for a missing measurement -- API, it is the layout contract. -- -- Deliberately NOT built on the client measuring the wrapped result for us: 1.12 has no -- GetStringHeight (that is a 2.3.0 addition), and FontString:GetHeight() does not reliably report a -- wrapped height -- both confirmed in-game. GetStringWidth, which -- this function's `measure` is in the client, DOES exist and returns the string's SINGLE-LINE width -- regardless of any SetWidth -- exactly the primitive a greedy wrap needs. -- -- measure(text) -> width. Any measure that cannot answer (returns nil -- GetStringWidth does that -- before a region's first render pass) collapses the whole thing to one unsplit piece, which is the -- safe direction: the caller is expected to supply its own fallback metric for that case rather -- than have this function invent one. Always returns at least one piece, so a caller iterating the -- result can never silently lose a line (and, with it, that line's bar). function V.wrapText(text, maxWidth, measure) if not text or text == "" then return { "" } end if not maxWidth or maxWidth <= 0 or not measure then return { text } end local probe = measure(text) if not probe then return { text } end if probe <= maxWidth then return { text } end -- Splits one word that does not fit on a line of its own into character-sized chunks. Without -- this the piece would be handed to the client over-long and wrapped THERE, which is the very -- overflow this function exists to prevent. Grows a chunk one character at a time and closes it -- one character before it would break the budget; a budget too narrow for even one character -- would loop forever, so a chunk is never allowed to close empty. local function splitWord(word, out) local chunk = "" local i for i = 1, string.len(word) do local ch = string.sub(word, i, i) local grown = chunk .. ch if chunk ~= "" and measure(grown) > maxWidth then table.insert(out, chunk) chunk = ch else chunk = grown end end if chunk ~= "" then table.insert(out, chunk) end end local out = {} local line = "" -- string.gfind, not gmatch: gmatch is 5.1+ and is a nil call under the client's Lua 5.0. local word for word in string.gfind(text, "%S+") do if line == "" then if measure(word) > maxWidth then splitWord(word, out) -- splitWord's last chunk stays open as the current line, so the next word can still -- join it rather than starting a needless new one. line = out[table.getn(out)] or "" table.remove(out) else line = word end elseif measure(line .. " " .. word) <= maxWidth then line = line .. " " .. word else table.insert(out, line) if measure(word) > maxWidth then splitWord(word, out) line = out[table.getn(out)] or "" table.remove(out) else line = word end end end if line ~= "" then table.insert(out, line) end if table.getn(out) == 0 then return { text } end return out end -- Standalone "[Floor Active]" marker for a row whose effective return sits above gear beyond -- V.classifyRate's tolerance band -- the per-hit floor is binding (small hits still return at least -- one HP per source). Same gating as V.formatRateLine (no gear percent -> nil) and the same -- effective-rate math (V.effectiveRatePct); kept as its own function rather than a second return -- value off formatRateLine so both stay a plain string-or-nil the frame layer can push() as-is. function V.formatFloorBadge(heal, damage, gearPct) if not gearPct or gearPct <= 0 then return nil end local eff = V.effectiveRatePct(heal, damage) if V.classifyRate(eff, gearPct) ~= "floor" then return nil end return "[Floor Active]" end -- " HP (% lost)", or nil when there is nothing to report -- mirrors V.format's own -- overheal branch (gui/display.lua above), which likewise shows nothing for oh <= 0 rather than an -- "(0 OH)" that would clutter every row. function V.formatOverhealLine(oh, heal) oh = oh or 0 if oh <= 0 then return nil end local pct = 0 if heal and heal > 0 then pct = oh / heal * 100 end return string.format("%s HP (%.1f%% lost)", V.formatAmount(oh), pct) end -- The row's twin figure from the OTHER scope (session<->lifetime): ": dmg, -- vamp" when a same-named row exists there, else "no data". `row` is nil or a merged row -- (same shape as V.mergeByName's output) already looked up by the caller in a SEPARATE pooled -- buffer -- see the WoW-wiring section below for why it must not reuse tipBuf/mergeBuf. function V.formatOtherScopeLine(scopeName, row) if not row then return "no " .. scopeName .. " data" end return string.format("%s: %s dmg, %s vamp", scopeName, V.formatAmount(row.damage or 0), V.formatAmount(row.heal or 0)) end -- Combines an already-resolved origin (V.resolveOrigin's return shape) with the id it belongs to -- into ONE self-contained badge line -- "[Item: ] #" / "[Class ability] #" -- -- so a reader looking at just this line does not have to glance back at the id line above it to -- know which spell id it is talking about (2026-08-24 badge pass -- the two used to be -- separate, only loosely connected lines). -- -- Returns NIL (no line at all) for kind=="unknown" -- in-game correction: why does this show -- "origin unknown"? (asked about spell 41825 "Tidal Wave"). Investigated: V.resolveOrigin has only -- two positive buckets (an item-proc match via C.ORIGINS, or a name match against the player's -- VISIBLE spellbook via GetSpellName) -- neither is a bug, but together they cannot see a genuine -- third category: a hidden/passive proc effect that is never granted to the spellbook at all -- (verified: 41825 is a custom server-side passive damage proc off the Water Shield buff, -- not a talent and not a castable spell -- structurally invisible to GetSpellName's walk; no known -- 1.12 API or list closes this gap, and building one is a separate, larger task, not this fix). -- "[origin unknown] #41825" read as "we don't even know what this spell is" -- false, its NAME is -- already shown correctly one line above (V.formatIdLine). The honest fix, per the agreed fallback -- (show the spell name and id without the misleading origin claim): make no origin claim -- at all when there is none to make, rather than dress up "we could not classify this" as if it -- were "we do not know what this is". The caller's own push() helper already no-ops on a nil text -- (V.buildDetailLines), so returning nil here is enough to simply omit the line. function V.formatOriginBadge(origin, spellId) if origin.kind == "item" or origin.kind == "ability" then return "[" .. origin.label .. "] #" .. tostring(spellId) end return nil end -- One row from ctx.sourceBreakdownFn's per-id item-source breakdown (the pure-function contract for -- the not-yet-built VampifyAggregate.spellSourceBreakdown -- see V.buildDetailLines below for the -- fallback when it is absent). Expected row shape: { label, heal, share (0..100), floorHits, ... }. -- "