TotemBar 0.4.1
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
||||
-- TotemBar - core/assign.lua
|
||||
-- Assignment receiver seam: the contract + pure logic by which an external
|
||||
-- assigner (future "ShamiPower") hands TotemBar a proposed totem set.
|
||||
-- WoW-API-light so the logic below is offline-testable under real Lua 5.0.
|
||||
-- The pending-suggestion UI lives in ui.lua and is reached through the
|
||||
-- optional hook slots (ShowAssignPanel/HideAssignPanel/isTotemKnown/
|
||||
-- RefreshAll) that ui.lua fills in at load time.
|
||||
--
|
||||
-- An "assignment set" is a table keyed by element -> totem spell name:
|
||||
-- { Fire = "Searing Totem", Air = "Windfury Totem", ... }
|
||||
-- Any subset of elements; a missing element means "nothing for that slot".
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- True if `key` is one of the four totem elements.
|
||||
function TotemBar.isElement(key)
|
||||
if not key then
|
||||
return false
|
||||
end
|
||||
local elements = TotemBar.TOTEM_ELEMENTS
|
||||
for i = 1, table.getn(elements) do
|
||||
if elements[i] == key then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Validates an assignment set. Returns true, or false plus a reason string.
|
||||
function TotemBar.validateAssignment(set)
|
||||
if type(set) ~= "table" then
|
||||
return false, "set must be a table"
|
||||
end
|
||||
local count = 0
|
||||
for k, v in pairs(set) do
|
||||
if not TotemBar.isElement(k) then
|
||||
return false, "unknown element key: " .. tostring(k)
|
||||
end
|
||||
if type(v) ~= "string" or v == "" then
|
||||
return false, "totem name for " .. tostring(k) .. " must be a non-empty string"
|
||||
end
|
||||
count = count + 1
|
||||
end
|
||||
if count == 0 then
|
||||
return false, "set is empty"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Shallow copy of a set, keeping only valid element keys.
|
||||
function TotemBar.copySet(set)
|
||||
local out = {}
|
||||
if type(set) == "table" then
|
||||
for k, v in pairs(set) do
|
||||
if TotemBar.isElement(k) then
|
||||
out[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Fresh copy (element -> name) of the currently chosen totems.
|
||||
function TotemBar.GetChosenSet()
|
||||
local out = {}
|
||||
local chosen = TotemBarDB and TotemBarDB.chosen
|
||||
if chosen then
|
||||
local elements = TotemBar.TOTEM_ELEMENTS
|
||||
for i = 1, table.getn(elements) do
|
||||
local e = elements[i]
|
||||
if chosen[e] then
|
||||
out[e] = chosen[e]
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Splits a set into applied (isKnown(name) true) and skipped (false).
|
||||
function TotemBar.filterKnown(set, isKnown)
|
||||
local applied, skipped = {}, {}
|
||||
for k, v in pairs(set) do
|
||||
if isKnown(v) then
|
||||
applied[k] = v
|
||||
else
|
||||
skipped[k] = v
|
||||
end
|
||||
end
|
||||
return applied, skipped
|
||||
end
|
||||
|
||||
-- Current pending assignment: { set = {element=name,...}, label = string } or nil.
|
||||
-- In-memory only (not a SavedVariable) - an assignment is ephemeral coordination.
|
||||
TotemBar.pending = nil
|
||||
|
||||
-- Optional hook, filled by an external assigner: called with the applied
|
||||
-- set table after the player accepts. Nil by default.
|
||||
TotemBar.onAssignmentApplied = nil
|
||||
|
||||
-- THE SEAM. An external assigner calls this to propose a set. Validates,
|
||||
-- stores it as the pending suggestion (replacing any prior), and asks the
|
||||
-- UI to show the panel. Does NOT apply. Returns true, or false + reason.
|
||||
function TotemBar.ReceiveAssignment(set, label)
|
||||
local ok, reason = TotemBar.validateAssignment(set)
|
||||
if not ok then
|
||||
return false, reason
|
||||
end
|
||||
TotemBar.pending = { set = TotemBar.copySet(set), label = label }
|
||||
if TotemBar.ShowAssignPanel then
|
||||
TotemBar.ShowAssignPanel()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Drops any pending suggestion and hides the panel (decline / post-apply).
|
||||
function TotemBar.ClearAssignment()
|
||||
TotemBar.pending = nil
|
||||
if TotemBar.HideAssignPanel then
|
||||
TotemBar.HideAssignPanel()
|
||||
end
|
||||
end
|
||||
|
||||
-- Applies the pending suggestion: sets each KNOWN totem as the chosen
|
||||
-- default for its element (unknown totems are skipped), refreshes the bar,
|
||||
-- clears pending, hides the panel, and fires onAssignmentApplied. No cast.
|
||||
function TotemBar.ApplyPending()
|
||||
local p = TotemBar.pending
|
||||
if not p then
|
||||
return
|
||||
end
|
||||
local isKnown = TotemBar.isTotemKnown or function() return true end
|
||||
local applied = TotemBar.filterKnown(p.set, isKnown)
|
||||
TotemBarDB.chosen = TotemBarDB.chosen or {}
|
||||
for element, name in pairs(applied) do
|
||||
TotemBarDB.chosen[element] = name
|
||||
end
|
||||
if TotemBar.RefreshAll then
|
||||
TotemBar.RefreshAll()
|
||||
end
|
||||
TotemBar.pending = nil
|
||||
if TotemBar.HideAssignPanel then
|
||||
TotemBar.HideAssignPanel()
|
||||
end
|
||||
if TotemBar.onAssignmentApplied then
|
||||
TotemBar.onAssignmentApplied(applied)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- TotemBar - core/bindlogic.lua
|
||||
-- PURE helpers for keybindings + hover-bind mode (no WoW API), offline-testable.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- Binding-name suffix for a totem: uppercase, non-alphanumeric runs -> "_",
|
||||
-- trimmed. "Grace of Air Totem" -> "GRACE_OF_AIR_TOTEM". MUST match the
|
||||
-- Bindings.xml generation and the BINDING_NAME_ globals.
|
||||
function TotemBar.bindingSuffix(name)
|
||||
if not name then
|
||||
return ""
|
||||
end
|
||||
local up = string.upper(name)
|
||||
up = string.gsub(up, "[^A-Z0-9]+", "_")
|
||||
up = string.gsub(up, "^_+", "")
|
||||
up = string.gsub(up, "_+$", "")
|
||||
return up
|
||||
end
|
||||
|
||||
-- Modifier prefix for a key string, order ALT-CTRL-SHIFT (each arg is 1/nil
|
||||
-- as IsAltKeyDown()/IsControlKeyDown()/IsShiftKeyDown() return on 1.12).
|
||||
function TotemBar.modifierPrefix(isAlt, isCtrl, isShift)
|
||||
local p = ""
|
||||
if isAlt then p = p .. "ALT-" end
|
||||
if isCtrl then p = p .. "CTRL-" end
|
||||
if isShift then p = p .. "SHIFT-" end
|
||||
return p
|
||||
end
|
||||
|
||||
-- Binding COMMAND for a hovered thing, or nil. A flyout icon (totemName
|
||||
-- given) -> the named per-totem binding (casts that specific totem, same
|
||||
-- action as the Esc menu). A bar button (by global frame name) -> the
|
||||
-- matching NAMED binding that already exists in Bindings.xml (NOT a
|
||||
-- "CLICK ..." binding - those are unreliable on 1.12, see bind.lua).
|
||||
function TotemBar.actionForButton(frameName, totemName)
|
||||
if totemName and totemName ~= "" then
|
||||
return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totemName)
|
||||
end
|
||||
if not frameName then
|
||||
return nil
|
||||
end
|
||||
if frameName == "TotemBarButtonRecall" then
|
||||
return "TOTEMBAR_RECALL"
|
||||
end
|
||||
if frameName == "TotemBarButtonDropSet" then
|
||||
return "TOTEMBAR_DROPSET"
|
||||
end
|
||||
local elements = TotemBar.TOTEM_ELEMENTS
|
||||
for i = 1, table.getn(elements) do
|
||||
if frameName == "TotemBarButton" .. elements[i] then
|
||||
return "TOTEMBAR_CAST_" .. string.upper(elements[i])
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Compact a binding key string for a small button overlay:
|
||||
-- "SHIFT-NUMPAD7" -> "sN7", "BUTTON4" -> "M4", "MOUSEWHEELUP" -> "MwU".
|
||||
function TotemBar.shortenKey(key)
|
||||
if not key or key == "" then
|
||||
return ""
|
||||
end
|
||||
local s = key
|
||||
s = string.gsub(s, "ALT%-", "a")
|
||||
s = string.gsub(s, "CTRL%-", "c")
|
||||
s = string.gsub(s, "SHIFT%-", "s")
|
||||
s = string.gsub(s, "MOUSEWHEELUP", "MwU")
|
||||
s = string.gsub(s, "MOUSEWHEELDOWN", "MwD")
|
||||
s = string.gsub(s, "NUMPAD", "N")
|
||||
s = string.gsub(s, "BUTTON", "M")
|
||||
s = string.gsub(s, "SPACE", "Sp")
|
||||
return s
|
||||
end
|
||||
+2108
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
-- TotemBar - core/config.lua
|
||||
-- SavedVariables model. TotemBarDB is declared in the .toc as a
|
||||
-- SavedVariable; ensureDefaults() fills in anything missing (first run,
|
||||
-- or a saved file from an older version that lacks newer fields).
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
TotemBarDB = TotemBarDB or {}
|
||||
|
||||
-- TotemBarDB shape:
|
||||
-- chosen[element] = totemName ("Fire" -> "Searing Totem", ...)
|
||||
-- gapSeconds = spam-cycle reset gap (seconds)
|
||||
-- locked = boolean, whether the bar can be dragged
|
||||
-- autoRecall = boolean, whether recallAndCastAll() prepends
|
||||
-- Totemic Recall (toggled via the Recall button's
|
||||
-- right-click, see ui.lua); default ON
|
||||
-- point/relPoint/x/y = saved frame anchor (see ui.lua)
|
||||
-- showDurationRing/ringStyle/showPulseBars/showPulseWaves/
|
||||
-- showTimerText = Pulse UI (see spec). showPulseBars = the countdown
|
||||
-- bar (primary "when's the next pulse" readout);
|
||||
-- showPulseWaves = the ripple (event feedback only);
|
||||
-- independently toggleable.
|
||||
-- barLayout = bar arrangement: "1x6"|"2x3"|"3x2" (rows x cols),
|
||||
-- cycled via the options panel (see ui.lua's
|
||||
-- ApplyBarLayout / options.lua's layout button).
|
||||
-- showDropSetButton = boolean, whether the "Drop all totems" (DropSet)
|
||||
-- button is shown on the bar; default ON, so existing
|
||||
-- users keep it. Visibility only: the button is always
|
||||
-- created, and its keybinding (BINDING_NAME_TOTEMBAR_
|
||||
-- DROPSET) plus the "Totems" macro keep working while
|
||||
-- it is hidden - same idea as the "Show bar" toggle.
|
||||
-- buttonGap = px gap between bar buttons, range 10-30, default
|
||||
-- TotemBar.DEFAULT_BUTTON_GAP (see core/cast.lua);
|
||||
-- live-applied via ui.lua's TotemBar.SetButtonGap,
|
||||
-- set from the options panel's "Button spacing" slider.
|
||||
function TotemBar.ensureDefaults()
|
||||
TotemBarDB.chosen = TotemBarDB.chosen or {}
|
||||
TotemBarDB.gapSeconds = TotemBarDB.gapSeconds or TotemBar.DEFAULT_GAP_SECONDS
|
||||
if TotemBarDB.locked == nil then
|
||||
TotemBarDB.locked = false
|
||||
end
|
||||
if TotemBarDB.autoRecall == nil then
|
||||
TotemBarDB.autoRecall = true
|
||||
end
|
||||
TotemBarDB.point = TotemBarDB.point or "CENTER"
|
||||
TotemBarDB.relPoint = TotemBarDB.relPoint or "CENTER"
|
||||
TotemBarDB.x = TotemBarDB.x or 0
|
||||
TotemBarDB.y = TotemBarDB.y or 0
|
||||
TotemBarDB.scale = TotemBarDB.scale or 1.0
|
||||
TotemBarDB.minimapAngle = TotemBarDB.minimapAngle or 225
|
||||
if TotemBarDB.hidden == nil then
|
||||
TotemBarDB.hidden = false
|
||||
end
|
||||
TotemBarDB.recallGuardSeconds = TotemBarDB.recallGuardSeconds or TotemBar.DEFAULT_RECALL_GUARD
|
||||
TotemBarDB.recallRefundPct = TotemBarDB.recallRefundPct or 0.25
|
||||
TotemBarDB.buttonGap = TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP
|
||||
|
||||
-- Pulse UI:
|
||||
-- duration ring + pulse bars, all on by default; ringStyle "round" vs
|
||||
-- "square" is the in-game comparison toggle.
|
||||
if TotemBarDB.showDurationRing == nil then
|
||||
TotemBarDB.showDurationRing = true
|
||||
end
|
||||
TotemBarDB.ringStyle = TotemBarDB.ringStyle or "round"
|
||||
if TotemBarDB.showPulseBars == nil then
|
||||
TotemBarDB.showPulseBars = true
|
||||
end
|
||||
if TotemBarDB.showPulseWaves == nil then
|
||||
TotemBarDB.showPulseWaves = true
|
||||
end
|
||||
-- dead setting removed in v0.2.2, clear persisted dust
|
||||
TotemBarDB.pulseGlow = nil
|
||||
if TotemBarDB.showTimerText == nil then
|
||||
TotemBarDB.showTimerText = true
|
||||
end
|
||||
|
||||
-- Bar contents: the DropSet button is optional, shown by default so an
|
||||
-- upgrade never makes it disappear (visibility only, see the shape note).
|
||||
if TotemBarDB.showDropSetButton == nil then
|
||||
TotemBarDB.showDropSetButton = true
|
||||
end
|
||||
|
||||
TotemBarDB.barLayout = TotemBarDB.barLayout or "1x6"
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
-- TotemBar - core/known.lua
|
||||
-- knownTotems() is PURE (no WoW API) and offline-testable.
|
||||
-- The WoW-API spellbook scan it consumes (scanSpellbook) now lives in
|
||||
-- core/spellindex.lua, alongside the other spellbook-index accessors
|
||||
-- (findSpellIndex/findHighestRankSlot/highestKnownRank) -- kept minimal
|
||||
-- and separate so the pure filtering logic below never has to touch the
|
||||
-- real API to be tested.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- Pure: given a flat list of spell names (strings) known to the player
|
||||
-- and an element name ("Fire"/"Earth"/"Water"/"Air"), returns an array
|
||||
-- of the subset of those names that are totems belonging to that
|
||||
-- element, in the static map's order.
|
||||
--
|
||||
-- - Names not present in the static map (non-totem spells) are ignored.
|
||||
-- - Totems in the static map that aren't in spellNames (not known) are
|
||||
-- omitted from the result.
|
||||
-- - An unknown/unmapped element returns an empty array.
|
||||
function TotemBar.knownTotems(spellNames, element)
|
||||
local result = {}
|
||||
local candidates = TotemBar.TOTEMS_BY_ELEMENT[element]
|
||||
if not candidates then
|
||||
return result
|
||||
end
|
||||
|
||||
local known = {}
|
||||
if spellNames then
|
||||
for i = 1, table.getn(spellNames) do
|
||||
known[spellNames[i]] = true
|
||||
end
|
||||
end
|
||||
|
||||
local n = 0
|
||||
for i = 1, table.getn(candidates) do
|
||||
local name = candidates[i]
|
||||
if known[name] then
|
||||
n = n + 1
|
||||
result[n] = name
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
-- TotemBar.scanSpellbook() and TotemBar.highestKnownRank() used to be
|
||||
-- defined here as thin WoW-API wrappers, each doing its own fresh linear
|
||||
-- GetSpellName() scan. They're now cache-backed views provided by
|
||||
-- core/spellindex.lua (loaded earlier in the TOC) instead, so a totem
|
||||
-- drop no longer re-walks the spellbook once per caller. Same names,
|
||||
-- same signatures, same return values -- callers here are unchanged.
|
||||
@@ -0,0 +1,326 @@
|
||||
-- TotemBar - core/manacost.lua
|
||||
-- Mana-cost reading + refund/duration helpers. The PURE functions here are
|
||||
-- offline-tested; the WoW-API pieces (hidden-tooltip scan, talent scan, refund
|
||||
-- learner) are appended in a later task and are guarded so this file loads
|
||||
-- fine under plain Lua for the tests.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- Pure: mana cost from a tooltip line like "155 Mana" -> 155, else nil.
|
||||
function TotemBar.parseManaCost(text)
|
||||
if not text then
|
||||
return nil
|
||||
end
|
||||
-- Anchor to the "Mana" keyword so a numeric line like "30 sec cooldown"
|
||||
-- can't be mistaken for the cost (don't rely on tooltip line ordering).
|
||||
local _, _, n = string.find(text, "^(%d+) [Mm]ana")
|
||||
if n then
|
||||
return tonumber(n)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Pure: sum costFn(chosen[element]) over the elements that have a chosen totem.
|
||||
function TotemBar.sumChosenCost(chosen, elements, costFn)
|
||||
local total = 0
|
||||
if not chosen then
|
||||
return 0
|
||||
end
|
||||
for i = 1, table.getn(elements) do
|
||||
local name = chosen[elements[i]]
|
||||
if name then
|
||||
local c = costFn(name)
|
||||
if c then
|
||||
total = total + c
|
||||
end
|
||||
end
|
||||
end
|
||||
return total
|
||||
end
|
||||
|
||||
-- Pure: sum cost of active totems that are still out (remaining > 0).
|
||||
function TotemBar.sumActiveCost(activeTotems, elements, now, costFn, remainingFn)
|
||||
local total = 0
|
||||
if not activeTotems then
|
||||
return 0
|
||||
end
|
||||
for i = 1, table.getn(elements) do
|
||||
local rec = activeTotems[elements[i]]
|
||||
if rec then
|
||||
local rem = remainingFn(rec.start, rec.duration, now)
|
||||
if rem and rem > 0 then
|
||||
local c = costFn(rec.totemName)
|
||||
if c then
|
||||
total = total + c
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return total
|
||||
end
|
||||
|
||||
-- Pure: is a KNOWN mana shortfall in front of us? Both halves must be known
|
||||
-- numbers -- an unresolved cost (the normal state until the tooltip scan runs,
|
||||
-- and permanently for a totem that isn't in this character's book) is "I don't
|
||||
-- know", never "too expensive". Greying a bar on "I don't know" would grey the
|
||||
-- whole bar at login; refusing to record a timer on it would silently drop the
|
||||
-- countdown of a totem that is standing. Both callers therefore fail OPEN here,
|
||||
-- the same policy as confidentNoneOut/recallReady in core/cast.lua.
|
||||
function TotemBar.notEnoughMana(cost, mana)
|
||||
if not cost or not mana then
|
||||
return false
|
||||
end
|
||||
return mana < cost
|
||||
end
|
||||
|
||||
-- Clearcasting (Elemental Focus) used to exempt every mana verdict here. That was
|
||||
-- WRONG and is gone (2026-08-28, player-reported): Elemental Focus zeroes the next
|
||||
-- DAMAGE spell, not a totem summon, so a totem costs full price while Clearcasting
|
||||
-- is up. Because totems are the only thing this addon casts, the exemption
|
||||
-- disabled the mana gate and the out-of-mana dim outright whenever the buff was
|
||||
-- up. TotemBar.hasClearcasting/CLEARCAST_ICON were removed with it -- the icon was
|
||||
-- never verified in-game and now has no consumer. TotemBar.hasBuffWithIcon stays:
|
||||
-- the out-of-range tint still uses it.
|
||||
|
||||
-- Live mana verdict for one totem by name: true only when its cost is known,
|
||||
-- the player's mana is below it, and no Clearcasting is up. Reads the cached
|
||||
-- tooltip cost (see getTotemManaCost), so this is a table lookup plus one
|
||||
-- UnitMana call -- cheap enough for the 5Hz display tick that calls it.
|
||||
function TotemBar.totemOutOfMana(name)
|
||||
if not name or type(UnitMana) ~= "function" then
|
||||
return false
|
||||
end
|
||||
return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player"))
|
||||
end
|
||||
|
||||
-- Pure: floored refund.
|
||||
function TotemBar.refundAmount(pct, activeCost)
|
||||
if not pct or not activeCost then
|
||||
return 0
|
||||
end
|
||||
return math.floor(pct * activeCost)
|
||||
end
|
||||
|
||||
-- Pure: learn refund pct from an observed mana gain; nil if out of sane range.
|
||||
function TotemBar.learnRefundPct(manaGained, activeCost)
|
||||
if not manaGained or not activeCost or activeCost <= 0 then
|
||||
return nil
|
||||
end
|
||||
local pct = manaGained / activeCost
|
||||
if pct < 0.05 or pct > 1.0 then
|
||||
return nil
|
||||
end
|
||||
return pct
|
||||
end
|
||||
|
||||
-- Pure: helpful totems get Totemic Mastery's +20% duration.
|
||||
function TotemBar.durationWithMastery(baseDuration, isHelpful, hasMastery)
|
||||
if hasMastery and isHelpful then
|
||||
return baseDuration * 1.2
|
||||
end
|
||||
return baseDuration
|
||||
end
|
||||
|
||||
-- The pure fire-DAMAGE totems do NOT get the +20% helpful-totem duration.
|
||||
-- Everything else is treated as helpful. VERIFY in-game and adjust.
|
||||
TotemBar.NON_HELPFUL_TOTEMS = {
|
||||
["Searing Totem"] = true,
|
||||
["Magma Totem"] = true,
|
||||
["Fire Nova Totem"] = true,
|
||||
}
|
||||
|
||||
function TotemBar.isHelpfulTotem(name)
|
||||
if not name then
|
||||
return false
|
||||
end
|
||||
if TotemBar.NON_HELPFUL_TOTEMS[name] then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Pure: brings a pfUI-libtotem-reported duration onto the same scale as the
|
||||
-- one recordCast stores. libtotem serves FLAT book durations; own tracking
|
||||
-- applies Totemic Mastery's +20% to helpful totems. ui.lua's timer prefers
|
||||
-- libtotem while it reports the slot active and falls back to own tracking
|
||||
-- once libtotem evicts it -- so at the book duration the display flipped
|
||||
-- source and the countdown jumped back UP (...3, 2, 1, 24) with the duration
|
||||
-- ring re-filling, instead of running out. Scaling both sides by the same
|
||||
-- factor makes them agree on the endpoint.
|
||||
function TotemBar.gtiDurationWithMastery(gtiDuration, gtiName, hasMastery)
|
||||
if not gtiDuration then
|
||||
return nil
|
||||
end
|
||||
return TotemBar.durationWithMastery(gtiDuration, TotemBar.isHelpfulTotem(gtiName), hasMastery)
|
||||
end
|
||||
|
||||
-- ===== WoW-API layer (not offline-executed) =====
|
||||
|
||||
-- TotemBar.findHighestRankSlot(name) used to be defined here as its own
|
||||
-- fresh linear GetSpellName() scan per call. It's now a cache-backed
|
||||
-- accessor provided by core/spellindex.lua (loaded earlier in the TOC) --
|
||||
-- same name, same signature, same resolved slot (the HIGHEST known rank,
|
||||
-- which is the one CastSpellByName actually casts; a first-match scan
|
||||
-- would return rank 1 -> cost far too low, the bug behind "the mana
|
||||
-- values are wrong"). Shared: ui.lua's tooltips resolve through this too -
|
||||
-- SetSpell with a first-name-match slot shows the RANK 1 tooltip (wrong
|
||||
-- mana/duration).
|
||||
|
||||
local scanTip = nil
|
||||
local manaCache = {} -- name -> cost (only positive results cached)
|
||||
|
||||
-- Reads a totem's mana cost via a hidden tooltip scan (no GetSpellManaCost on
|
||||
-- 1.12). Reflects cost talents (Tidal Focus, Restorative Totems) automatically.
|
||||
-- Cached by name; cache cleared on SPELLS_CHANGED (talent/rank changes).
|
||||
function TotemBar.getTotemManaCost(name)
|
||||
if not name then return nil end
|
||||
if manaCache[name] then return manaCache[name] end
|
||||
local idx = TotemBar.findHighestRankSlot(name)
|
||||
if not idx then return nil end
|
||||
if not scanTip then
|
||||
scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
|
||||
end
|
||||
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetSpell(idx, BOOKTYPE_SPELL)
|
||||
local cost = nil
|
||||
local lines = scanTip:NumLines() or 0
|
||||
for i = 1, lines do
|
||||
local fs = getglobal("TotemBarScanTooltipTextLeft" .. i)
|
||||
local text = fs and fs:GetText()
|
||||
local c = TotemBar.parseManaCost(text)
|
||||
if c then
|
||||
cost = c
|
||||
break
|
||||
end
|
||||
end
|
||||
if cost then manaCache[name] = cost end
|
||||
return cost
|
||||
end
|
||||
|
||||
-- Dev aid (/tb manadump): dump the RAW mana-cost scan for the chosen totems +
|
||||
-- Totemic Recall to <WoW>\imports\totembar_manadump.txt, so the resolved
|
||||
-- slot/rank, every tooltip line, and the parsed cost can be verified
|
||||
-- off-client (ground truth for the mana-cost feature). Chat fallback if no
|
||||
-- SuperWoW ExportFile.
|
||||
function TotemBar.dumpManaScan()
|
||||
local els = TotemBar.TOTEM_ELEMENTS
|
||||
local chosen = (TotemBarDB and TotemBarDB.chosen) or {}
|
||||
local list = {}
|
||||
for i = 1, table.getn(els) do
|
||||
local nm = chosen[els[i]]
|
||||
if nm then list[table.getn(list) + 1] = nm end
|
||||
end
|
||||
list[table.getn(list) + 1] = "Totemic Recall"
|
||||
if not scanTip then
|
||||
scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
|
||||
end
|
||||
local out = "TotemBar mana scan dump\n"
|
||||
local total = 0
|
||||
for i = 1, table.getn(list) do
|
||||
local nm = list[i]
|
||||
local idx = TotemBar.findHighestRankSlot(nm)
|
||||
out = out .. "\n[" .. nm .. "] slot=" .. tostring(idx)
|
||||
if idx then
|
||||
local rn, rr = GetSpellName(idx, BOOKTYPE_SPELL)
|
||||
out = out .. " resolved='" .. tostring(rn) .. "' rank='" .. tostring(rr) .. "'\n"
|
||||
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||
scanTip:ClearLines()
|
||||
scanTip:SetSpell(idx, BOOKTYPE_SPELL)
|
||||
local lines = scanTip:NumLines() or 0
|
||||
local cost = nil
|
||||
for L = 1, lines do
|
||||
local fs = getglobal("TotemBarScanTooltipTextLeft" .. L)
|
||||
local text = fs and fs:GetText()
|
||||
out = out .. " L" .. L .. ": " .. tostring(text) .. "\n"
|
||||
if not cost then
|
||||
local c = TotemBar.parseManaCost(text)
|
||||
if c then cost = c end
|
||||
end
|
||||
end
|
||||
out = out .. " parsedCost=" .. tostring(cost) .. "\n"
|
||||
if cost and nm ~= "Totemic Recall" then total = total + cost end
|
||||
else
|
||||
out = out .. " (not found in spellbook)\n"
|
||||
end
|
||||
end
|
||||
out = out .. "\nsumChosenCost(live)=" .. tostring(
|
||||
TotemBar.sumChosenCost(chosen, els, TotemBar.getTotemManaCost)) .. "\n"
|
||||
out = out .. "dumpTotal(chosen)=" .. total .. "\n"
|
||||
if ExportFile then
|
||||
ExportFile("totembar_manadump", out)
|
||||
end
|
||||
if DEFAULT_CHAT_FRAME then
|
||||
DEFAULT_CHAT_FRAME:AddMessage(
|
||||
"TotemBar: mana dump written (imports\\totembar_manadump.txt). chosen sum=" .. total)
|
||||
end
|
||||
end
|
||||
|
||||
-- Totemic Mastery (TWoW: +20% helpful-totem duration): cached scan of the
|
||||
-- talent trees by name.
|
||||
local masteryCached = false
|
||||
local function scanMastery()
|
||||
masteryCached = false
|
||||
local tabs = (GetNumTalentTabs and GetNumTalentTabs()) or 0
|
||||
for tab = 1, tabs do
|
||||
local num = GetNumTalents(tab)
|
||||
for i = 1, num do
|
||||
local tname, _, _, _, rank = GetTalentInfo(tab, i)
|
||||
if tname == "Totemic Mastery" and rank and rank > 0 then
|
||||
masteryCached = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TotemBar.hasTotemicMastery()
|
||||
return masteryCached
|
||||
end
|
||||
|
||||
-- Recall-refund auto-learn. Snapshot the summed cost of the totems currently
|
||||
-- out just before a DELIBERATE Totemic Recall; when the mana-gain message
|
||||
-- arrives shortly after, learn the real refund %.
|
||||
TotemBar.recallPendingCost = 0
|
||||
local recallExpectUntil = 0
|
||||
|
||||
function TotemBar.snapshotRecallCost()
|
||||
TotemBar.recallPendingCost = TotemBar.sumActiveCost(
|
||||
TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(),
|
||||
TotemBar.getTotemManaCost, TotemBar.remaining)
|
||||
recallExpectUntil = GetTime() + 2
|
||||
end
|
||||
|
||||
-- Events: refresh mastery, clear the cost cache on spell changes, and learn
|
||||
-- the refund % from the recall mana-gain message.
|
||||
-- Guarded: CreateFrame is nil under plain offline Lua, so this whole block is
|
||||
-- skipped there (loadfile/dofile must not error for the test suite).
|
||||
if CreateFrame then
|
||||
local mcEvents = CreateFrame("Frame", "TotemBarManaCostEventFrame", UIParent)
|
||||
mcEvents:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
mcEvents:RegisterEvent("CHARACTER_POINTS_CHANGED")
|
||||
mcEvents:RegisterEvent("SPELLS_CHANGED")
|
||||
mcEvents:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
|
||||
mcEvents:SetScript("OnEvent", function()
|
||||
if event == "PLAYER_ENTERING_WORLD" or event == "CHARACTER_POINTS_CHANGED" then
|
||||
scanMastery()
|
||||
elseif event == "SPELLS_CHANGED" then
|
||||
manaCache = {}
|
||||
scanMastery()
|
||||
elseif event == "CHAT_MSG_SPELL_SELF_BUFF" then
|
||||
if GetTime() > recallExpectUntil then return end
|
||||
local msg = arg1
|
||||
if not msg then return end
|
||||
-- Learn only from a Totemic Recall mana-gain within the window.
|
||||
-- NOTE: exact message text is locale/format dependent - VERIFY in-game
|
||||
-- (adjust the "Totemic Recall" + number pattern if needed).
|
||||
if string.find(msg, "Totemic Recall") then
|
||||
local _, _, num = string.find(msg, "(%d+)")
|
||||
local gained = tonumber(num)
|
||||
local pct = TotemBar.learnRefundPct(gained, TotemBar.recallPendingCost)
|
||||
if pct and TotemBarDB then
|
||||
TotemBarDB.recallRefundPct = pct
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- TotemBar - core/optionslogic.lua
|
||||
-- PURE helpers for the options panel + minimap button (no WoW API), so the
|
||||
-- fiddly bits (orbit math, slider clamping, the macro contract) are
|
||||
-- offline-testable under real Lua 5.0. The UI (minimap.lua / options.lua)
|
||||
-- calls these.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- Orbit offset (x, y) for a minimap button at angleDeg on a circle of the
|
||||
-- given radius. 0deg -> (radius,0); 90 -> (0,radius); 180 -> (-radius,0).
|
||||
function TotemBar.angleToOffset(angleDeg, radius)
|
||||
local rad = math.rad(angleDeg)
|
||||
return math.cos(rad) * radius, math.sin(rad) * radius
|
||||
end
|
||||
|
||||
-- Clamp v into [minVal, maxVal].
|
||||
function TotemBar.clampValue(v, minVal, maxVal)
|
||||
if v < minVal then
|
||||
return minVal
|
||||
end
|
||||
if v > maxVal then
|
||||
return maxVal
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
-- The fixed spec for the "Totems" convenience macro: name, body, and a BARE
|
||||
-- icon file name (no Interface\Icons\ prefix, no extension - TurtleWoW's
|
||||
-- CreateMacro/EditMacro prepend the path themselves).
|
||||
function TotemBar.macroSpec()
|
||||
return "Totems", "/script TotemBar.recallAndCastAll()", "Spell_Nature_TremorTotem"
|
||||
end
|
||||
|
||||
-- Bar-layout grid data + math (feature: selectable 1x6 / 2x3 / 3x2 bar
|
||||
-- arrangement, see ui.lua's ApplyBarLayout). Pure so the button positions
|
||||
-- and pixel-size math can be pinned offline instead of eyeballed in-game.
|
||||
|
||||
-- Explicit per-layout button positions {col, row} (0-based), button order:
|
||||
-- Fire, Earth, Water, Air, Recall, DropSet. "2x3" groups the four element
|
||||
-- totems as a 2x2 block in the first two columns (user request); "3x2"
|
||||
-- groups them as the top 2x2 block with the utility row below.
|
||||
-- DropSet is deliberately the LAST slot in every layout: when it is hidden
|
||||
-- (TotemBarDB.showDropSetButton = false) the remaining five buttons keep
|
||||
-- their exact slots - the element 2x2 block and Recall never move, and no
|
||||
-- second positions table is needed. The multi-row layouts then leave the
|
||||
-- trailing grid cell empty, which is invisible (transparent backdrop) and
|
||||
-- still usable as drag surface.
|
||||
TotemBar.BAR_LAYOUT_POSITIONS = {
|
||||
["1x6"] = { {0,0},{1,0},{2,0},{3,0},{4,0},{5,0} },
|
||||
["2x3"] = { {0,0},{1,0},{0,1},{1,1},{2,0},{2,1} },
|
||||
["3x2"] = { {0,0},{1,0},{0,1},{1,1},{0,2},{1,2} },
|
||||
}
|
||||
|
||||
-- Pixel dimensions of the bar frame for a grid of `count` buttons arranged
|
||||
-- in at most `cols` columns of `size`x`size` buttons with `gap` spacing.
|
||||
-- `rowPitchExtra` widens the vertical pitch between rows (multi-row layouts:
|
||||
-- room for the timer text hanging below each row plus the next row's ring
|
||||
-- overhang - see ui.lua's BUTTON_GAP comment); pass 0 for a single row.
|
||||
-- height = top gap + rows*size + (rows-1)*(gap+extra) + bottom gap.
|
||||
-- `cols` is capped at `count` because a grid can never be wider than it has
|
||||
-- buttons: with the DropSet button hidden, "1x6" is a row of FIVE and the
|
||||
-- frame must shrink with it instead of trailing dead space. The multi-row
|
||||
-- layouts still need all of their columns at five buttons, so their size is
|
||||
-- unchanged (they just leave the last grid cell empty).
|
||||
function TotemBar.barDimensions(count, cols, size, gap, rowPitchExtra)
|
||||
if cols > count then
|
||||
cols = count
|
||||
end
|
||||
local rows = math.ceil(count / cols)
|
||||
local width = cols * (size + gap) + gap
|
||||
local height = gap + rows * size + (rows - 1) * (gap + rowPitchExtra) + gap
|
||||
return width, height, rows
|
||||
end
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
-- TotemBar - core/pulse.lua
|
||||
-- PURE pulse/ring math for the Pulse UI (no WoW API; offline-tested via
|
||||
-- tools/luatests/test_pulse.lua). ui.lua calls these from its throttled
|
||||
-- timer tick - keep everything allocation-free except buildRingTexCoords
|
||||
-- (called once at load).
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- 0..1 phase toward the next pulse. Phase origin is anchorAt (the last
|
||||
-- OBSERVED pulse, stamped by ui.lua's combat-message handler) when set,
|
||||
-- else placedAt (dead reckoning). Lua 5.0: math.mod, not the % operator.
|
||||
function TotemBar.pulseRatio(placedAt, anchorAt, interval, now)
|
||||
if not interval or interval <= 0 then
|
||||
return nil
|
||||
end
|
||||
local origin = anchorAt or placedAt
|
||||
if not origin or not now then
|
||||
return nil
|
||||
end
|
||||
local elapsed = now - origin
|
||||
if elapsed < 0 then
|
||||
return 0
|
||||
end
|
||||
return math.mod(elapsed, interval) / interval
|
||||
end
|
||||
|
||||
-- How far before the predicted next tick an observed gain may still re-anchor
|
||||
-- the phase (seconds) -- absorbs normal message/latency jitter.
|
||||
TotemBar.PULSE_ANCHOR_TOLERANCE = 0.25
|
||||
|
||||
-- Pure: may an observed periodic self-gain re-anchor this record's pulse phase?
|
||||
--
|
||||
-- The periodic-gain chat lines carry NO caster (1.12's ownerless
|
||||
-- POWERGAIN/PERIODICAURAHEAL ...SELFSELF templates), so a second shaman's
|
||||
-- identically-named totem produces byte-identical text to our own. Accepting
|
||||
-- every message re-stamped the anchor off-beat, roughly twice per interval,
|
||||
-- and the ripple/pulse arc jumped backwards. Rate-limiting to at most one
|
||||
-- anchor per interval locks the display onto ONE tick train instead. A record
|
||||
-- with no anchor yet (fresh cast) always gets its free first stamp, so a
|
||||
-- recast re-acquires phase immediately.
|
||||
function TotemBar.shouldAnchorPulse(lastAnchor, interval, now)
|
||||
if not lastAnchor or not now then
|
||||
return true
|
||||
end
|
||||
if not interval or interval <= 0 then
|
||||
return true
|
||||
end
|
||||
return (now - lastAnchor) >= (interval - TotemBar.PULSE_ANCHOR_TOLERANCE)
|
||||
end
|
||||
|
||||
-- 0..1 single fill from placement to detonation (Fire Nova). Clamped at
|
||||
-- both ends; callers treat >=1 as "detonated" and hide the bar.
|
||||
function TotemBar.oneshotRatio(placedAt, delay, now)
|
||||
if not placedAt or not delay or delay <= 0 or not now then
|
||||
return nil
|
||||
end
|
||||
local r = (now - placedAt) / delay
|
||||
if r < 0 then
|
||||
return 0
|
||||
end
|
||||
if r > 1 then
|
||||
return 1
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
-- Blueprint state D ("pulse imminent"): glow from 85% fill on.
|
||||
TotemBar.PULSE_IMMINENT_THRESHOLD = 0.85
|
||||
function TotemBar.pulseImminent(ratio)
|
||||
if ratio and ratio >= TotemBar.PULSE_IMMINENT_THRESHOLD then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Flipbook frame index for the duration ring: 0 = empty ... frameCount-1 =
|
||||
-- full, rounded to nearest so the ring reads full right after placement.
|
||||
-- Generic over frameCount; callers pass the arc's actual frame count (Rev 2:
|
||||
-- 62, cells 0..61 of the ring_round.tga flipbook).
|
||||
function TotemBar.ringFrameIndex(remaining, duration, frameCount)
|
||||
if not remaining or not duration or duration <= 0 or not frameCount then
|
||||
return 0
|
||||
end
|
||||
local ratio = remaining / duration
|
||||
if ratio < 0 then
|
||||
ratio = 0
|
||||
end
|
||||
if ratio > 1 then
|
||||
ratio = 1
|
||||
end
|
||||
return math.floor(ratio * (frameCount - 1) + 0.5)
|
||||
end
|
||||
|
||||
-- Precomputed SetTexCoord rectangles for a square cols x cols grid texture,
|
||||
-- row-major, 1-based array indexed by frame+1. Built ONCE at load; the
|
||||
-- per-tick path just indexes it (zero allocation, zero math).
|
||||
function TotemBar.buildRingTexCoords(frames, cols)
|
||||
local coords = {}
|
||||
local cell = 1 / cols
|
||||
for i = 0, frames - 1 do
|
||||
local col = math.mod(i, cols)
|
||||
local row = math.floor(i / cols)
|
||||
coords[i + 1] = {
|
||||
l = col * cell,
|
||||
r = (col + 1) * cell,
|
||||
t = row * cell,
|
||||
b = (row + 1) * cell,
|
||||
}
|
||||
end
|
||||
return coords
|
||||
end
|
||||
|
||||
-- Duration counterpart of core/cast.lua's resolveRemaining, SAME precedence:
|
||||
-- GTI (pfUI libtotem) is authoritative while it reports the slot active;
|
||||
-- otherwise fall back to own tracking. Returns the TOTAL duration whose
|
||||
-- remaining time resolveRemaining would have returned, or nil.
|
||||
function TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, ownRemaining, ownDuration)
|
||||
-- FIX 2026-07-15: mirror resolveRemaining -- trust GTI only while it reports the
|
||||
-- slot active WITH positive time left; otherwise fall back to own tracking (a
|
||||
-- stale-active GTI slot must not block the timer, same Fire/Earth bug).
|
||||
if gtiActive and gtiRemaining and gtiRemaining > 0 then
|
||||
return gtiDuration
|
||||
end
|
||||
if ownRemaining and ownRemaining > 0 then
|
||||
return ownDuration
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Ripple wave phase: seconds since the last pulse mapped to 0..1 over the
|
||||
-- wave's lifetime, or nil when no wave is showing. For tick totems the wave
|
||||
-- starts AT each pulse (phase wraps via pulseRatio); waveDur is clamped to
|
||||
-- half the interval so the wave always dies before the next one spawns.
|
||||
function TotemBar.waveFrac(ratio, interval, waveDur)
|
||||
if not ratio or not interval or interval <= 0 or not waveDur or waveDur <= 0 then
|
||||
return nil
|
||||
end
|
||||
local dur = waveDur
|
||||
if dur > interval * 0.5 then
|
||||
dur = interval * 0.5
|
||||
end
|
||||
local age = ratio * interval
|
||||
if age > dur then
|
||||
return nil
|
||||
end
|
||||
return age / dur
|
||||
end
|
||||
|
||||
-- One-shot (Fire Nova): wave for waveDur seconds AFTER detonation.
|
||||
function TotemBar.oneshotWaveFrac(placedAt, delay, now, waveDur)
|
||||
if not placedAt or not delay or not now or not waveDur or waveDur <= 0 then
|
||||
return nil
|
||||
end
|
||||
local age = (now - placedAt) - delay
|
||||
if age < 0 or age > waveDur then
|
||||
return nil
|
||||
end
|
||||
return age / waveDur
|
||||
end
|
||||
|
||||
-- Anticipation glow: eased 0..1 ramp over the last (1 - rampStart) fraction
|
||||
-- of the pulse phase; nil below rampStart (no glow) or on bad inputs.
|
||||
function TotemBar.glowRamp(ratio, rampStart)
|
||||
if not ratio or not rampStart or rampStart >= 1 or ratio < rampStart then
|
||||
return nil
|
||||
end
|
||||
local t = (ratio - rampStart) / (1 - rampStart)
|
||||
if t > 1 then
|
||||
t = 1
|
||||
end
|
||||
return t * t
|
||||
end
|
||||
|
||||
-- Traffic-light color for a remaining-time fraction (1 = full, 0 = empty):
|
||||
-- green above 0.5, blending green->yellow->orange->red as time runs out.
|
||||
-- Returns r, g, b as three numbers (no table - called per tick).
|
||||
function TotemBar.timeColor(frac)
|
||||
if not frac then
|
||||
return 1, 1, 1
|
||||
end
|
||||
if frac < 0 then frac = 0 end
|
||||
if frac > 1 then frac = 1 end
|
||||
if frac >= 0.5 then
|
||||
-- green (0.25,0.8,0.25) -> yellow (0.95,0.85,0.2) over 1.0..0.5
|
||||
local t = (1 - frac) * 2
|
||||
return 0.25 + 0.70 * t, 0.80 + 0.05 * t, 0.25 - 0.05 * t
|
||||
elseif frac >= 0.25 then
|
||||
-- yellow -> orange (1.0,0.55,0.1) over 0.5..0.25
|
||||
local t = (0.5 - frac) * 4
|
||||
return 0.95 + 0.05 * t, 0.85 - 0.30 * t, 0.20 - 0.10 * t
|
||||
end
|
||||
-- orange -> red (0.90,0.15,0.10) over 0.25..0
|
||||
local t = (0.25 - frac) * 4
|
||||
return 1.00 - 0.10 * t, 0.55 - 0.40 * t, 0.10
|
||||
end
|
||||
@@ -0,0 +1,392 @@
|
||||
-- TotemBar - core/pulsecal.lua
|
||||
-- Dev-only pulse-calibration telemetry (/tb pulsecal): captures raw
|
||||
-- totem-related combat lines + our own placements into a fixed ring buffer
|
||||
-- and exports them via SuperWoW ExportFile, so the dev environment can
|
||||
-- measure REAL TurtleWoW pulse intervals offline and bake them back into
|
||||
-- core/pulsedata.lua (verified=true).
|
||||
-- Pure buffer/serializer on top (offline-tested); WoW-gated capture below.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
TotemBar.PULSECAL_CAP = 2000
|
||||
|
||||
-- Dev-only diagnostic probe (/tb tprobe) for the destroyed-totem GUID-latch
|
||||
-- paths in core/cast.lua ("Destroyed-totem detection (GUID liveness)"). Built
|
||||
-- because two in-game measurement attempts against those latch paths were
|
||||
-- both silently wiped by a /reload before their taps could be read (see
|
||||
-- 2026-08-19/20 session) -- unlike pulsecal above (start, drop totems, dump
|
||||
-- once by hand), this probe survives /reload on its own: it re-exports its
|
||||
-- WHOLE ring buffer after every kept event (not only on demand) and re-arms
|
||||
-- itself from TotemBarDB.tprobeArmed at ADDON_LOADED (see the WoW-gated
|
||||
-- section below). Shares pulsecalPush/pulsecalFormat above for the ring
|
||||
-- buffer itself -- only the filter and the per-line detail string are new.
|
||||
TotemBar.TPROBE_CAP = 300
|
||||
|
||||
-- Pure: push one record into the ring buffer. state = { n = total pushed,
|
||||
-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no
|
||||
-- allocation growth while capturing).
|
||||
function TotemBar.pulsecalPush(buf, cap, state, t, ev, msg)
|
||||
local slot = state.idx
|
||||
local rec = buf[slot]
|
||||
if not rec then
|
||||
rec = {}
|
||||
buf[slot] = rec
|
||||
end
|
||||
rec.t = t
|
||||
rec.e = ev
|
||||
rec.m = msg
|
||||
state.n = state.n + 1
|
||||
state.idx = slot + 1
|
||||
if state.idx > cap then
|
||||
state.idx = 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Pure: serialize surviving records in chronological order, one
|
||||
-- "t;event;msg" line each (t with millisecond precision). Allocates - dump
|
||||
-- time only, never per capture.
|
||||
function TotemBar.pulsecalFormat(buf, cap, state)
|
||||
local count = state.n
|
||||
if count > cap then
|
||||
count = cap
|
||||
end
|
||||
if count == 0 then
|
||||
return ""
|
||||
end
|
||||
local start = state.idx - count
|
||||
if start < 1 then
|
||||
start = start + cap
|
||||
end
|
||||
local lines = {}
|
||||
for i = 0, count - 1 do
|
||||
local slot = start + i
|
||||
if slot > cap then
|
||||
slot = slot - cap
|
||||
end
|
||||
local rec = buf[slot]
|
||||
lines[i + 1] = string.format("%.3f;%s;%s", rec.t, rec.e, rec.m)
|
||||
end
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
-- ===== /tb tprobe -- pure filter + line-builder (offline-tested) =====
|
||||
|
||||
-- Pure: does `name` look like a totem to a plain substring eye -- "Totem" or
|
||||
-- "Searing" (the latter because Searing Totem's own pulse-cast spell is
|
||||
-- suspected to be named "Searing Bolt"/"Attack" rather than "Searing Totem",
|
||||
-- suspicion (a) in the probe's own brief -- a name check that only looked
|
||||
-- for "Totem" would silently drop exactly the lines needed to confirm or
|
||||
-- kill that suspicion). Case-sensitive plain substring, same convention the
|
||||
-- WoW-gated capture below (and pulsecal's own filter above) already uses --
|
||||
-- not a design decision unique to this function. nil-safe.
|
||||
function TotemBar.tprobeNameHit(name)
|
||||
if not name then
|
||||
return false
|
||||
end
|
||||
if string.find(name, "Totem", 1, true) then
|
||||
return true
|
||||
end
|
||||
if string.find(name, "Searing", 1, true) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Events always kept regardless of name (a death is diagnostic on its own --
|
||||
-- there is no "name" to filter on for CHAT_MSG_COMBAT_*_DEATH, and UNIT_DIED
|
||||
-- firing for something that never matched a totem name is itself evidence,
|
||||
-- e.g. against suspicion (b)/(c) in the probe's brief).
|
||||
local TPROBE_ALWAYS_EVENTS = {
|
||||
UNIT_DIED = true,
|
||||
CHAT_MSG_COMBAT_FRIENDLY_DEATH = true,
|
||||
CHAT_MSG_COMBAT_HOSTILE_DEATH = true,
|
||||
}
|
||||
|
||||
-- Pure: should this event be kept? nameUnit is UnitName(arg1) as resolved by
|
||||
-- the caller, nameSpell is SpellInfo(arg4) -- kept if EITHER hits
|
||||
-- tprobeNameHit, or if the event is one of TPROBE_ALWAYS_EVENTS above.
|
||||
function TotemBar.tprobeShouldKeep(event, nameUnit, nameSpell)
|
||||
if event and TPROBE_ALWAYS_EVENTS[event] then
|
||||
return true
|
||||
end
|
||||
if TotemBar.tprobeNameHit(nameUnit) then
|
||||
return true
|
||||
end
|
||||
if TotemBar.tprobeNameHit(nameSpell) then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Pure: the "msg" half of one kept line (the ring buffer's "t;event;msg"
|
||||
-- shape from pulsecalPush/pulsecalFormat above supplies t and event already).
|
||||
-- nameUnit and nameSpell are BOTH kept, deliberately never collapsed into a
|
||||
-- single "resolved name" -- the whole point of the probe is to see whether
|
||||
-- they AGREE (suspicion (a): does a Searing Totem pulse's SpellInfo name
|
||||
-- ever equal "Searing Totem" at all?). Every argument may be nil; tostring()
|
||||
-- turns that into the literal string "nil", never a concat error.
|
||||
function TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
|
||||
return "a1=" .. tostring(a1)
|
||||
.. ";a2=" .. tostring(a2)
|
||||
.. ";a3=" .. tostring(a3)
|
||||
.. ";a4=" .. tostring(a4)
|
||||
.. ";nameUnit=" .. tostring(nameUnit)
|
||||
.. ";nameSpell=" .. tostring(nameSpell)
|
||||
.. ";owner=" .. tostring(ownerName)
|
||||
.. ";hp=" .. tostring(health)
|
||||
.. ";latched=" .. tostring(latched)
|
||||
end
|
||||
|
||||
-- Pure: would the PRODUCTIVE handler (core/cast.lua's guidFrame) have
|
||||
-- latched a GUID for this event's totem, and what does that element's
|
||||
-- record show right now? nameUnit is tried first (arg1's own UnitName --
|
||||
-- the identity the productive UNIT_MODEL_CHANGED path itself keys off),
|
||||
-- nameSpell as a fallback (a totem that only ever surfaces through a SPELL
|
||||
-- name elementOfFn doesn't recognise -- e.g. "Searing Bolt" -- correctly
|
||||
-- resolves to nil here, which is itself the diagnostic: the productive
|
||||
-- UNIT_CASTEVENT path (core/cast.lua's TotemBar.elementFromCastName) has
|
||||
-- exactly the same blind spot). activeTotems/elementOfFn are injected (same
|
||||
-- dependency-injection convention core/cast.lua's own TotemBar.elementForGuid
|
||||
-- uses) so this needs no WoW globals to test. Returns nil both when no
|
||||
-- element matches AND when the matched element's record has no guid yet --
|
||||
-- either way there is no guid to report, matching the brief's own
|
||||
-- "latched=<guid|nil>" literally.
|
||||
function TotemBar.tprobeLatchedGuid(activeTotems, elementOfFn, nameUnit, nameSpell)
|
||||
if not elementOfFn then
|
||||
return nil
|
||||
end
|
||||
local element = elementOfFn(nameUnit) or elementOfFn(nameSpell)
|
||||
if not element or not activeTotems then
|
||||
return nil
|
||||
end
|
||||
local rec = activeTotems[element]
|
||||
if not rec then
|
||||
return nil
|
||||
end
|
||||
return rec.guid
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- WoW-gated capture (skipped entirely by the offline test runner).
|
||||
if CreateFrame then
|
||||
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
|
||||
|
||||
local capturing = false
|
||||
local buf = {}
|
||||
local state = { n = 0, idx = 1 }
|
||||
|
||||
-- Everything that could plausibly carry a totem pulse on 1.12 (no
|
||||
-- COMBAT_LOG_EVENT_UNFILTERED here - all localized text in arg1).
|
||||
local CAPTURE_EVENTS = {
|
||||
"CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
|
||||
"CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
|
||||
"CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS",
|
||||
"CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
|
||||
"CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
|
||||
"CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE",
|
||||
"CHAT_MSG_SPELL_SELF_DAMAGE",
|
||||
"CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
|
||||
"CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
|
||||
"CHAT_MSG_SPELL_AURA_GONE_SELF",
|
||||
"CHAT_MSG_SPELL_AURA_GONE_PARTY",
|
||||
}
|
||||
|
||||
local calFrame = CreateFrame("Frame", "TotemBarPulseCalFrame", UIParent)
|
||||
calFrame:SetScript("OnEvent", function()
|
||||
if not capturing then
|
||||
return
|
||||
end
|
||||
if event == "UNIT_CASTEVENT" then
|
||||
-- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
|
||||
-- Only record casts whose spell name mentions Totem (SpellInfo
|
||||
-- is a SuperWoW API; guarded because the event only exists there
|
||||
-- anyway). arg4 is guarded too (adversarial review, 2026-08-19
|
||||
-- destroyed-totem session) -- SpellInfo(nil) is unproven safe on
|
||||
-- this client, and a UNIT_CASTEVENT with no spellId is possible
|
||||
-- in principle (e.g. a non-cast event type on this arg3 slot).
|
||||
local sname = (SpellInfo and arg4) and SpellInfo(arg4)
|
||||
if sname and string.find(sname, "Totem", 1, true) then
|
||||
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event,
|
||||
tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname))
|
||||
end
|
||||
return
|
||||
end
|
||||
-- Plain substring guard keeps the hot path cheap; "Totem"-less buff
|
||||
-- names (Mana Spring / Healing Stream gain lines) are the exception,
|
||||
-- so include the "You gain" prefix too.
|
||||
local msg = arg1
|
||||
if msg and (string.find(msg, "Totem", 1, true) or string.find(msg, "You gain", 1, true)) then
|
||||
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, msg)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Record our own placements as reference marks (t0 for interval math).
|
||||
-- Wrapping recordCast is safe here: core/pulsecal.lua loads AFTER
|
||||
-- core/cast.lua (see .toc) and ui.lua resolves TotemBar.recordCast at
|
||||
-- call time.
|
||||
local origRecordCast = TotemBar.recordCast
|
||||
TotemBar.recordCast = function(element, totemName)
|
||||
origRecordCast(element, totemName)
|
||||
if capturing then
|
||||
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(),
|
||||
"TB_PLACED", tostring(element) .. ";" .. tostring(totemName))
|
||||
end
|
||||
end
|
||||
|
||||
function TotemBar.PulseCal(sub)
|
||||
if sub == "start" then
|
||||
if not capturing then
|
||||
capturing = true
|
||||
for i = 1, table.getn(CAPTURE_EVENTS) do
|
||||
calFrame:RegisterEvent(CAPTURE_EVENTS[i])
|
||||
end
|
||||
-- SuperWoW-only event; pcall-guarded in case a client build
|
||||
-- rejects unknown event names.
|
||||
pcall(function() calFrame:RegisterEvent("UNIT_CASTEVENT") end)
|
||||
end
|
||||
ChatOut:AddMessage("TotemBar: pulsecal capture STARTED (drop totems, then /tb pulsecal dump).")
|
||||
elseif sub == "stop" then
|
||||
capturing = false
|
||||
calFrame:UnregisterAllEvents()
|
||||
ChatOut:AddMessage("TotemBar: pulsecal capture stopped (" .. state.n .. " records kept).")
|
||||
elseif sub == "dump" then
|
||||
if not ExportFile then
|
||||
ChatOut:AddMessage("TotemBar: pulsecal dump needs SuperWoW (ExportFile missing).")
|
||||
return
|
||||
end
|
||||
-- Filename WITHOUT extension - the client appends .txt itself.
|
||||
ExportFile("totembar_pulsecal", TotemBar.pulsecalFormat(buf, TotemBar.PULSECAL_CAP, state))
|
||||
ChatOut:AddMessage("TotemBar: pulsecal dump written (imports\\totembar_pulsecal.txt, "
|
||||
.. state.n .. " records).")
|
||||
else
|
||||
local on = capturing and "ON" or "OFF"
|
||||
ChatOut:AddMessage("TotemBar: pulsecal " .. on .. ", " .. state.n
|
||||
.. " records. Usage: /tb pulsecal start|stop|dump")
|
||||
end
|
||||
end
|
||||
|
||||
-- ===== /tb tprobe -- reload-survivable diagnostic probe =====
|
||||
-- Pure observation only: never touches TotemBar.activeTotems or any
|
||||
-- other productive state, only READS TotemBar.activeTotems/elementOf to
|
||||
-- report what the productive handler would have seen (see
|
||||
-- tprobeLatchedGuid above).
|
||||
local tprobeCapturing = false
|
||||
local tprobeBuf = {}
|
||||
local tprobeState = { n = 0, idx = 1 }
|
||||
|
||||
local function tprobeExport()
|
||||
if ExportFile then
|
||||
-- Filename WITHOUT extension - the client appends .txt itself.
|
||||
ExportFile("tb_tprobe", TotemBar.pulsecalFormat(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState))
|
||||
end
|
||||
end
|
||||
|
||||
-- Takes explicit args (not the WoW globals directly) so the pcall below
|
||||
-- wraps a real function value, not a closure built fresh every event.
|
||||
local function tprobeHandleEvent(ev, a1, a2, a3, a4)
|
||||
local nameUnit = nil
|
||||
if a1 and type(UnitName) == "function" then
|
||||
nameUnit = UnitName(a1)
|
||||
end
|
||||
local nameSpell = nil
|
||||
if a4 and type(SpellInfo) == "function" then
|
||||
nameSpell = SpellInfo(a4)
|
||||
end
|
||||
if not TotemBar.tprobeShouldKeep(ev, nameUnit, nameSpell) then
|
||||
return
|
||||
end
|
||||
local ownerName = nil
|
||||
if a1 and type(UnitName) == "function" then
|
||||
ownerName = UnitName(a1 .. "owner")
|
||||
end
|
||||
local health = nil
|
||||
if a1 and type(UnitHealth) == "function" then
|
||||
health = UnitHealth(a1)
|
||||
end
|
||||
local latched = TotemBar.tprobeLatchedGuid(TotemBar.activeTotems, TotemBar.elementOf, nameUnit, nameSpell)
|
||||
local detail = TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
|
||||
TotemBar.pulsecalPush(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState, GetTime(), ev, detail)
|
||||
-- Export on EVERY kept event, not only on /tb tprobe dump - the
|
||||
-- whole reason this probe exists is that two earlier measurement
|
||||
-- attempts were wiped by a /reload before a manual dump could run.
|
||||
tprobeExport()
|
||||
end
|
||||
|
||||
local tprobeFrame = CreateFrame("Frame", "TotemBarTProbeFrame", UIParent)
|
||||
tprobeFrame:SetScript("OnEvent", function()
|
||||
if not tprobeCapturing then
|
||||
return
|
||||
end
|
||||
-- pcall-wrapped end to end (per spec): observation must never be
|
||||
-- able to break anything else this client does.
|
||||
pcall(tprobeHandleEvent, event, arg1, arg2, arg3, arg4)
|
||||
end)
|
||||
|
||||
local function tprobeArm()
|
||||
if tprobeCapturing then
|
||||
return
|
||||
end
|
||||
tprobeCapturing = true
|
||||
-- Native 1.12 events, safe to register directly.
|
||||
tprobeFrame:RegisterEvent("UNIT_MODEL_CHANGED")
|
||||
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
|
||||
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
|
||||
-- SuperWoW/nampower-only events; pcall-guarded exactly like
|
||||
-- calFrame's own UNIT_CASTEVENT registration above.
|
||||
pcall(function() tprobeFrame:RegisterEvent("UNIT_CASTEVENT") end)
|
||||
pcall(function() tprobeFrame:RegisterEvent("UNIT_DIED") end)
|
||||
end
|
||||
|
||||
local function tprobeDisarm()
|
||||
tprobeCapturing = false
|
||||
tprobeFrame:UnregisterAllEvents()
|
||||
end
|
||||
|
||||
function TotemBar.TProbe(sub)
|
||||
if sub == "on" then
|
||||
tprobeArm()
|
||||
if TotemBarDB then
|
||||
TotemBarDB.tprobeArmed = true
|
||||
end
|
||||
ChatOut:AddMessage("TotemBar: tprobe capture STARTED (" .. tprobeState.n .. " records kept so far).")
|
||||
elseif sub == "off" then
|
||||
tprobeDisarm()
|
||||
if TotemBarDB then
|
||||
TotemBarDB.tprobeArmed = false
|
||||
end
|
||||
ChatOut:AddMessage("TotemBar: tprobe capture stopped (" .. tprobeState.n .. " records kept).")
|
||||
elseif sub == "dump" then
|
||||
if not ExportFile then
|
||||
ChatOut:AddMessage("TotemBar: tprobe dump needs SuperWoW (ExportFile missing).")
|
||||
return
|
||||
end
|
||||
tprobeExport()
|
||||
ChatOut:AddMessage("TotemBar: tprobe dump written (imports\\tb_tprobe.txt, "
|
||||
.. tprobeState.n .. " records).")
|
||||
else
|
||||
local on = tprobeCapturing and "ON" or "OFF"
|
||||
ChatOut:AddMessage("TotemBar: tprobe " .. on .. ", " .. tprobeState.n
|
||||
.. " records. Usage: /tb tprobe on|off|dump|status")
|
||||
end
|
||||
end
|
||||
|
||||
-- Re-arm after /reload if it was left on. SavedVariables survive a
|
||||
-- /reload; this frame's event registrations do not (a fresh Lua state
|
||||
-- runs on every reload) - so an on->reload->still-on session needs its
|
||||
-- own ADDON_LOADED hook to re-attach tprobeFrame's events, same "wait for
|
||||
-- ADDON_LOADED before trusting TotemBarDB" convention core/config.lua's
|
||||
-- ensureDefaults (called from ui.lua's own ADDON_LOADED handler) and
|
||||
-- minimap.lua's build-on-load both already use, rather than assuming
|
||||
-- load order against either of those.
|
||||
local tprobeInitFrame = CreateFrame("Frame", "TotemBarTProbeInitFrame", UIParent)
|
||||
tprobeInitFrame:RegisterEvent("ADDON_LOADED")
|
||||
tprobeInitFrame:SetScript("OnEvent", function()
|
||||
if event == "ADDON_LOADED" and arg1 == "TotemBar" then
|
||||
tprobeInitFrame:UnregisterEvent("ADDON_LOADED")
|
||||
if TotemBarDB and TotemBarDB.tprobeArmed then
|
||||
tprobeArm()
|
||||
ChatOut:AddMessage("TotemBar: tprobe re-armed after reload (" .. tprobeState.n .. " records kept).")
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
-- TotemBar - core/pulsedata.lua
|
||||
-- Pulse metadata per totem name (PURE data, no WoW API). Drives the pulse
|
||||
-- progress bar in ui.lua. Totems absent from PULSE_DATA get NO pulse bar
|
||||
-- (better no info than invented info) - notably Searing Totem (irregular
|
||||
-- attack cadence, not a fixed pulse), Grounding (event-consumed) and every
|
||||
-- aura totem (continuous aura, no verified pulse mechanic).
|
||||
--
|
||||
-- Values are server-dump-derived (TWoW server dump, snapshot 2024-07,
|
||||
-- patch_1172 branch; tw_world.sql + SpellAuras.cpp:8647-8714), not book
|
||||
-- values - source="server" records that provenance. verified=false stays
|
||||
-- reserved for IN-GAME confirmation: /tb pulsecal (core/pulsecal.lua)
|
||||
-- captures the real timings; once measured in-game, update the value AND
|
||||
-- flip verified=true.
|
||||
--
|
||||
-- firstTick: "immediate" means the aura's periodic tick fires at t=0 (the
|
||||
-- totem is on SpellAuras.cpp's CalculatePeriodic exception list);
|
||||
-- "delayed" means the first tick lands only after one full amplitude has
|
||||
-- elapsed. NOTE: the pulseRatio math is identical either way - the bar
|
||||
-- reaches 1.0 at every tick instant, including the t=0 wrap for
|
||||
-- "immediate" totems - so firstTick is currently documentative only (no
|
||||
-- consumer branches on it yet). Kept for future use, e.g. a distinct
|
||||
-- wave/flash at t=0 for "immediate" totems.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- ptype "tick" -> repeating pulse every `interval` seconds
|
||||
-- ptype "oneshot" -> single detonation `delay` seconds after placement
|
||||
-- anchor "selfgain" -> phase re-anchors on observed periodic self-gain
|
||||
-- combat messages (see core/pulseparse.lua + ui.lua)
|
||||
TotemBar.PULSE_DATA = {
|
||||
["Magma Totem"] = { ptype = "tick", interval = 2.0, firstTick = "delayed", source = "server", verified = false },
|
||||
-- 4.0s confirmed by server dump - corrects the earlier 3.0s book value.
|
||||
["Tremor Totem"] = { ptype = "tick", interval = 4.0, firstTick = "immediate", source = "server", verified = false },
|
||||
["Earthbind Totem"] = { ptype = "tick", interval = 3.0, firstTick = "immediate", source = "server", verified = false },
|
||||
["Poison Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
|
||||
["Disease Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
|
||||
["Healing Stream Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
|
||||
["Mana Spring Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
|
||||
-- server models it as a periodic 4s trigger like Magma, but with the 5s
|
||||
-- totem duration exactly one nova lands at t=4s, so the oneshot model
|
||||
-- is behaviorally equivalent.
|
||||
["Fire Nova Totem"] = { ptype = "oneshot", delay = 4.0, firstTick = "delayed", source = "server", verified = false },
|
||||
-- threat pulse every 2s.
|
||||
["Stoneclaw Totem"] = { ptype = "tick", interval = 2.0, firstTick = "immediate", source = "server", verified = false },
|
||||
}
|
||||
|
||||
-- Periodic gain lines name the BUFF ("You gain 10 Mana from Mana Spring."),
|
||||
-- not the totem spell - map those source names back to the totem. Exact
|
||||
-- wording gets pinned by pulsecal telemetry; extend as measured.
|
||||
TotemBar.PULSE_SOURCE_ALIASES = {
|
||||
["Mana Spring"] = "Mana Spring Totem",
|
||||
["Healing Stream"] = "Healing Stream Totem",
|
||||
}
|
||||
|
||||
function TotemBar.pulseInfo(totemName)
|
||||
if not totemName then
|
||||
return nil
|
||||
end
|
||||
return TotemBar.PULSE_DATA[totemName]
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
-- TotemBar - core/pulseparse.lua
|
||||
-- PURE parser: periodic combat-log line -> totem name (or nil). Used by
|
||||
-- ui.lua's CHAT_MSG_SPELL_PERIODIC_* handler to re-anchor pulse phase on
|
||||
-- REAL observed ticks. Lua 5.0: string.find with captures only (no
|
||||
-- string.match / no method-call syntax). Offline-tested via
|
||||
-- tools/luatests/test_pulseparse.lua; exact accepted wordings get refined
|
||||
-- from /tb pulsecal telemetry.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
-- Strips a trailing roman-numeral rank suffix (" IV", " VII", ...) - the
|
||||
-- totem-as-caster shape names the ranked unit ("Healing Stream Totem VII").
|
||||
local function stripRank(src)
|
||||
local _, _, base = string.find(src, "^(.-)%s+[IVXLC]+$")
|
||||
if base and base ~= "" then
|
||||
return base
|
||||
end
|
||||
return src
|
||||
end
|
||||
|
||||
-- Maps a raw source name to a totem name: alias table first (buff names
|
||||
-- like "Mana Spring"), then any source ending in " Totem".
|
||||
local function resolveSource(src)
|
||||
local alias = TotemBar.PULSE_SOURCE_ALIASES and TotemBar.PULSE_SOURCE_ALIASES[src]
|
||||
if alias then
|
||||
return alias
|
||||
end
|
||||
-- Every totem's name ends in " Totem" (rank suffixes already stripped),
|
||||
-- so anchor at the end - a plain substring test would false-positive on
|
||||
-- player names like "Totemguy".
|
||||
if string.find(src, "%sTotem$") then
|
||||
return src
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function TotemBar.parseSelfGain(msg)
|
||||
if not msg then
|
||||
return nil
|
||||
end
|
||||
local _, _, src = string.find(msg, "^You gain %d+ [Mm]ana from (.+)%.$")
|
||||
if not src then
|
||||
_, _, src = string.find(msg, "^You gain %d+ [Hh]ealth from (.+)%.$")
|
||||
end
|
||||
if not src then
|
||||
_, _, src = string.find(msg, "^(.-) heals you for %d+%.$")
|
||||
end
|
||||
if not src then
|
||||
return nil
|
||||
end
|
||||
return resolveSource(stripRank(src))
|
||||
end
|
||||
@@ -0,0 +1,185 @@
|
||||
-- TotemBar - core/spellindex.lua
|
||||
-- A single cached name -> spellbook-slot index, replacing the four
|
||||
-- separate linear GetSpellName(i, BOOKTYPE_SPELL) scans that used to be
|
||||
-- duplicated across ui.lua (FindSpellIndexByName), core/cast.lua
|
||||
-- (findSpellIndexByName), core/manacost.lua (findHighestRankSlot) and
|
||||
-- core/known.lua (scanSpellbook/highestKnownRank). Each of those walked
|
||||
-- the WHOLE spellbook on every call; SPELL_UPDATE_COOLDOWN/cast events
|
||||
-- fire several of them per totem drop, so a 4-totem-drop macro press
|
||||
-- could re-walk the book several times in one frame (perf review
|
||||
-- 2026-07-16, TotemBar finding #14: ui.lua:210 + cast.lua:175 +
|
||||
-- manacost.lua:116).
|
||||
--
|
||||
-- buildSpellIndex() below is PURE (no WoW API) and offline-tested: given
|
||||
-- a flat array of {name=, rank=} entries in spellbook slot order (array
|
||||
-- index N == what GetSpellName(N, BOOKTYPE_SPELL) returned for slot N),
|
||||
-- it returns a name -> record table where each record is:
|
||||
-- bookIndex - the FIRST (lowest) slot with this name. Mirrors the
|
||||
-- old FindSpellIndexByName/findSpellIndexByName
|
||||
-- first-match behavior (icon/cooldown/"is it known"
|
||||
-- lookups don't care which rank's slot they read --
|
||||
-- GetSpellTexture/GetSpellCooldown are identical across
|
||||
-- ranks of the same totem).
|
||||
-- maxRankIndex - the slot of the HIGHEST parsed rank. Mirrors the old
|
||||
-- manacost.lua findHighestRankSlot: this is the slot
|
||||
-- CastSpellByName actually casts and the one
|
||||
-- tooltip/mana-cost scans must read. Falls back to the
|
||||
-- LAST matching slot if no entry's rank string for this
|
||||
-- name ever parsed a number (matches the old
|
||||
-- fallback -- ranks are listed ascending, so the last
|
||||
-- slot is still the best guess).
|
||||
-- maxRank - the highest parsed rank NUMBER, or nil if none of
|
||||
-- this name's entries carried a parseable rank. Mirrors
|
||||
-- the old known.lua highestKnownRank() return value.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
function TotemBar.buildSpellIndex(entries)
|
||||
local idx = {}
|
||||
if not entries then
|
||||
return idx
|
||||
end
|
||||
for i = 1, table.getn(entries) do
|
||||
local e = entries[i]
|
||||
local name = e and e.name
|
||||
if name then
|
||||
local rec = idx[name]
|
||||
if not rec then
|
||||
rec = { bookIndex = i, maxRankIndex = i, maxRank = nil }
|
||||
idx[name] = rec
|
||||
end
|
||||
local _, _, numStr = string.find(e.rank or "", "(%d+)")
|
||||
local num = numStr and tonumber(numStr)
|
||||
if num and (not rec.maxRank or num > rec.maxRank) then
|
||||
rec.maxRank = num
|
||||
rec.maxRankIndex = i
|
||||
elseif not rec.maxRank then
|
||||
-- No rank number has parsed for this name in any entry
|
||||
-- seen so far; keep sliding the fallback to the LAST
|
||||
-- matching slot (mirrors the old findHighestRankSlot
|
||||
-- fallback for names whose rank string never parses).
|
||||
rec.maxRankIndex = i
|
||||
end
|
||||
end
|
||||
end
|
||||
return idx
|
||||
end
|
||||
|
||||
-- ===== WoW-API layer (not offline-executed) =====
|
||||
-- Guarded by CreateFrame's presence, same as manacost.lua's own event
|
||||
-- block, so dofile-ing this module under plain Lua (offline tests)
|
||||
-- never errors.
|
||||
|
||||
local cachedEntries = nil -- flat array {name=, rank=}, 1..n, book order
|
||||
local cachedIndex = nil -- name -> {bookIndex, maxRankIndex, maxRank}
|
||||
|
||||
local function scanLiveSpellbook()
|
||||
local entries = {}
|
||||
local i = 1
|
||||
while true do
|
||||
local name, rank = GetSpellName(i, BOOKTYPE_SPELL)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
entries[i] = { name = name, rank = rank }
|
||||
i = i + 1
|
||||
end
|
||||
return entries
|
||||
end
|
||||
|
||||
local function ensureBuilt()
|
||||
if cachedIndex then
|
||||
return
|
||||
end
|
||||
cachedEntries = scanLiveSpellbook()
|
||||
cachedIndex = TotemBar.buildSpellIndex(cachedEntries)
|
||||
end
|
||||
|
||||
-- Drops the cache; the next accessor call below rebuilds it from a
|
||||
-- fresh scan (lazy -- nothing re-scans until actually asked for). Wired
|
||||
-- to SPELLS_CHANGED below; also safe to call manually.
|
||||
function TotemBar.invalidateSpellIndex()
|
||||
cachedEntries = nil
|
||||
cachedIndex = nil
|
||||
end
|
||||
|
||||
-- Returns the cached name -> record index, building it lazily on first
|
||||
-- access. Lazy-build matters at login/reload: the spellbook
|
||||
-- (GetSpellName/GetSpellTexture) isn't reliably populated as early as
|
||||
-- ADDON_LOADED fires (see ui.lua's own SPELLS_CHANGED comment) -- an
|
||||
-- eager build at file-load time could cache an empty book.
|
||||
function TotemBar.getSpellIndex()
|
||||
ensureBuilt()
|
||||
return cachedIndex
|
||||
end
|
||||
|
||||
-- How many spellbook slots the cached scan holds (0 when the book was empty
|
||||
-- at scan time). Lets a caller tell "this spell is provably NOT known" apart
|
||||
-- from "we have no usable spellbook data yet" -- the book isn't reliably
|
||||
-- populated at login, and the cache is built lazily, so a nil findSpellIndex
|
||||
-- alone can't distinguish the two. Used by core/cast.lua's recordCast.
|
||||
function TotemBar.spellbookEntryCount()
|
||||
ensureBuilt()
|
||||
return table.getn(cachedEntries)
|
||||
end
|
||||
|
||||
-- Mirrors old FindSpellIndexByName (ui.lua) / findSpellIndexByName
|
||||
-- (cast.lua): first-match spellbook slot, or nil if unknown.
|
||||
function TotemBar.findSpellIndex(name)
|
||||
if not name then
|
||||
return nil
|
||||
end
|
||||
local rec = TotemBar.getSpellIndex()[name]
|
||||
return rec and rec.bookIndex
|
||||
end
|
||||
|
||||
-- Mirrors old manacost.lua findHighestRankSlot: the slot of the
|
||||
-- highest-known rank, or nil if unknown.
|
||||
function TotemBar.findHighestRankSlot(name)
|
||||
if not name then
|
||||
return nil
|
||||
end
|
||||
local rec = TotemBar.getSpellIndex()[name]
|
||||
return rec and rec.maxRankIndex
|
||||
end
|
||||
|
||||
-- Mirrors old known.lua highestKnownRank: the highest known rank
|
||||
-- NUMBER, or nil if unknown / no rank string ever parsed.
|
||||
function TotemBar.highestKnownRank(name)
|
||||
if not name then
|
||||
return nil
|
||||
end
|
||||
local rec = TotemBar.getSpellIndex()[name]
|
||||
return rec and rec.maxRank
|
||||
end
|
||||
|
||||
-- Mirrors old known.lua scanSpellbook: flat array of every spellbook
|
||||
-- entry's name, ONE PER SLOT (a multi-rank totem appears once per rank
|
||||
-- it has) -- ShowFlyout's knownTotems() filter and PrintScan's raw dump
|
||||
-- both rely on that shape. The by-name index above intentionally
|
||||
-- collapses ranks together, so this stays a separate view over the same
|
||||
-- cached scan rather than something derived from the index.
|
||||
function TotemBar.scanSpellbook()
|
||||
ensureBuilt()
|
||||
local names = {}
|
||||
for i = 1, table.getn(cachedEntries) do
|
||||
names[i] = cachedEntries[i].name
|
||||
end
|
||||
return names
|
||||
end
|
||||
|
||||
-- SPELLS_CHANGED is the event this codebase already relies on elsewhere
|
||||
-- (manacost.lua's manaCache, ui.lua's icon refresh) for "spellbook
|
||||
-- changed" -- fires on learning a new spell/rank and on talent changes.
|
||||
-- LEARNED_SPELL_IN_TAB was checked and does not exist as a 1.12 client
|
||||
-- event (not in this project's confirmed WoW 1.12 event list; it's a
|
||||
-- later-expansion API) -- SPELLS_CHANGED alone is the correct signal.
|
||||
if CreateFrame then
|
||||
local siEvents = CreateFrame("Frame", "TotemBarSpellIndexEventFrame", UIParent)
|
||||
siEvents:RegisterEvent("SPELLS_CHANGED")
|
||||
siEvents:SetScript("OnEvent", function()
|
||||
if event == "SPELLS_CHANGED" then
|
||||
TotemBar.invalidateSpellIndex()
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
-- TotemBar - core/totemdata.lua
|
||||
-- PURE data module (no WoW API calls) - offline-testable.
|
||||
--
|
||||
-- Static element -> totem name map for vanilla shaman totems.
|
||||
--
|
||||
-- NOTE: TurtleWoW may rename or add totems relative to vanilla 1.12.
|
||||
-- Use "/tb scan" in-game to print the player's actual known totem spell
|
||||
-- strings and cross-check them against this map before trusting it.
|
||||
|
||||
TotemBar = TotemBar or {}
|
||||
|
||||
TotemBar.TOTEM_ELEMENTS = { "Fire", "Earth", "Water", "Air" }
|
||||
|
||||
TotemBar.TOTEMS_BY_ELEMENT = {
|
||||
Fire = {
|
||||
"Searing Totem",
|
||||
"Fire Nova Totem",
|
||||
"Magma Totem",
|
||||
"Flametongue Totem",
|
||||
"Frost Resistance Totem",
|
||||
},
|
||||
Earth = {
|
||||
"Earthbind Totem",
|
||||
"Stoneclaw Totem",
|
||||
"Stoneskin Totem",
|
||||
"Strength of Earth Totem",
|
||||
"Tremor Totem",
|
||||
},
|
||||
Water = {
|
||||
"Fire Resistance Totem",
|
||||
"Healing Stream Totem",
|
||||
"Mana Spring Totem",
|
||||
"Poison Cleansing Totem",
|
||||
"Disease Cleansing Totem",
|
||||
"Mana Tide Totem",
|
||||
},
|
||||
Air = {
|
||||
"Grace of Air Totem",
|
||||
"Grounding Totem",
|
||||
"Nature Resistance Totem",
|
||||
"Sentry Totem",
|
||||
"Windfury Totem",
|
||||
"Windwall Totem",
|
||||
"Tranquil Air Totem",
|
||||
},
|
||||
}
|
||||
|
||||
-- Pure: the largest number of totems any single element offers. ui.lua sizes
|
||||
-- the hover flyout's icon pool from this. Computed from the data on purpose:
|
||||
-- the pool used to be a hard-coded 6, but Air has SEVEN totems, and with the
|
||||
-- Air slot empty the flyout lists ALL known totems of the element (not "all
|
||||
-- minus the chosen default") -- so the 7th, Tranquil Air Totem, was silently
|
||||
-- dropped and could not be cast or made default from the bar.
|
||||
function TotemBar.maxTotemsPerElement()
|
||||
local maxN = 0
|
||||
for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
|
||||
local list = TotemBar.TOTEMS_BY_ELEMENT[TotemBar.TOTEM_ELEMENTS[i]]
|
||||
local n = (list and table.getn(list)) or 0
|
||||
if n > maxN then
|
||||
maxN = n
|
||||
end
|
||||
end
|
||||
return maxN
|
||||
end
|
||||
|
||||
-- Build the reverse lookup (totem name -> element) once at load time.
|
||||
local elementByName = {}
|
||||
for elemIdx = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
|
||||
local element = TotemBar.TOTEM_ELEMENTS[elemIdx]
|
||||
local list = TotemBar.TOTEMS_BY_ELEMENT[element]
|
||||
for totemIdx = 1, table.getn(list) do
|
||||
elementByName[list[totemIdx]] = element
|
||||
end
|
||||
end
|
||||
|
||||
-- Returns the element ("Fire"/"Earth"/"Water"/"Air") a totem spell name
|
||||
-- belongs to, or nil if the name isn't in the static map.
|
||||
function TotemBar.elementOf(name)
|
||||
if not name then
|
||||
return nil
|
||||
end
|
||||
return elementByName[name]
|
||||
end
|
||||
|
||||
-- Totem lifetime durations (seconds), tuned to TurtleWoW server timers
|
||||
-- (mirrors pfUI libtotem's verified values). Keyed by totem spell name;
|
||||
-- any totem not listed here falls back to DEFAULT_TOTEM_DURATION.
|
||||
-- Searing Totem is rank-aware (see SEARING_TOTEM_DURATIONS below) and
|
||||
-- is intentionally NOT listed in this flat table.
|
||||
TotemBar.DEFAULT_TOTEM_DURATION = 120
|
||||
|
||||
-- Cross-checked 2026-07-16 against pfUI's own libtotem.lua duration table
|
||||
-- (Interface\AddOns\pfUI\libs\libtotem.lua, Spell_Fire_SearingTotem entry:
|
||||
-- {[-1]=55,[1]=30,[2]=35,[3]=40,[4]=45,[5]=50,[6]=55}) -- byte-identical to
|
||||
-- the vanilla values below for every rank. TurtleWoW uses vanilla Searing
|
||||
-- Totem timers, not a custom table; nothing was missing here.
|
||||
TotemBar.SEARING_TOTEM_DURATIONS = {
|
||||
[1] = 30,
|
||||
[2] = 35,
|
||||
[3] = 40,
|
||||
[4] = 45,
|
||||
[5] = 50,
|
||||
[6] = 55,
|
||||
}
|
||||
TotemBar.SEARING_TOTEM_MAX_RANK = 6
|
||||
TotemBar.SEARING_TOTEM_DEFAULT_DURATION = 55
|
||||
|
||||
TotemBar.TOTEM_DURATIONS = {
|
||||
-- Fire
|
||||
["Magma Totem"] = 20,
|
||||
["Fire Nova Totem"] = 5,
|
||||
["Flametongue Totem"] = 120,
|
||||
["Frost Resistance Totem"] = 120,
|
||||
-- Earth
|
||||
["Stoneclaw Totem"] = 15,
|
||||
["Earthbind Totem"] = 45,
|
||||
["Stoneskin Totem"] = 120,
|
||||
["Strength of Earth Totem"] = 120,
|
||||
["Tremor Totem"] = 120,
|
||||
-- Water
|
||||
["Mana Tide Totem"] = 12,
|
||||
["Healing Stream Totem"] = 60,
|
||||
["Mana Spring Totem"] = 60,
|
||||
["Fire Resistance Totem"] = 120,
|
||||
["Disease Cleansing Totem"] = 120,
|
||||
["Poison Cleansing Totem"] = 120,
|
||||
-- Air
|
||||
["Grounding Totem"] = 45,
|
||||
["Grace of Air Totem"] = 120,
|
||||
["Nature Resistance Totem"] = 120,
|
||||
-- 5 minutes (spell 6495, TurtleWoW 1.17.2 spell DB). Was missing here
|
||||
-- entirely and fell through to the 120s default -- but that fallback is
|
||||
-- meant as a safe OVERestimate, and for Sentry it cut the timer 180s
|
||||
-- short. pfUI's libtotem has no Sentry entry either, so GetTotemInfo
|
||||
-- could not correct it.
|
||||
["Sentry Totem"] = 300,
|
||||
["Tranquil Air Totem"] = 120,
|
||||
["Windfury Totem"] = 120,
|
||||
["Windwall Totem"] = 120,
|
||||
}
|
||||
|
||||
-- Pure: seconds a totem will remain active for, given its exact spell
|
||||
-- name and (for Searing Totem only) the player's highest known rank
|
||||
-- number. Unknown/unmapped totems fall back to DEFAULT_TOTEM_DURATION
|
||||
-- (a safe overestimate, rather than a timer that disappears too early).
|
||||
function TotemBar.totemDuration(name, highestRank)
|
||||
if not name then
|
||||
return TotemBar.DEFAULT_TOTEM_DURATION
|
||||
end
|
||||
if name == "Searing Totem" then
|
||||
if not highestRank or highestRank < 1 then
|
||||
return TotemBar.SEARING_TOTEM_DEFAULT_DURATION
|
||||
end
|
||||
local rank = highestRank
|
||||
if rank > TotemBar.SEARING_TOTEM_MAX_RANK then
|
||||
rank = TotemBar.SEARING_TOTEM_MAX_RANK
|
||||
end
|
||||
return TotemBar.SEARING_TOTEM_DURATIONS[rank] or TotemBar.SEARING_TOTEM_DEFAULT_DURATION
|
||||
end
|
||||
return TotemBar.TOTEM_DURATIONS[name] or TotemBar.DEFAULT_TOTEM_DURATION
|
||||
end
|
||||
Reference in New Issue
Block a user