TotemBar v0.2.5

This commit is contained in:
2026-08-05 15:54:45 +02:00
commit 8dbc411e80
35 changed files with 5934 additions and 0 deletions
+147
View File
@@ -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
+73
View File
@@ -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
+647
View File
@@ -0,0 +1,647 @@
-- TotemBar - core/cast.lua
-- Cast-cycle logic. The decision-making is split into two PURE,
-- offline-tested functions (nextIndex, findFilledSlot); castNext() is
-- the thin wrapper that touches CastSpellByName / GetTime / TotemBarDB.
TotemBar = TotemBar or {}
TotemBar.DEFAULT_GAP_SECONDS = 2
-- Anti double-press guard for recallAndCastAll: if a deploy happened within
-- this many seconds, a rapid second press SKIPS the recall (so it doesn't
-- pull the just-placed totems, which are still on their ~1.5s element
-- cooldown and couldn't be re-placed).
TotemBar.DEFAULT_RECALL_GUARD = 2
-- Default gap (px) between bar buttons. Matches ui.lua's file-scope
-- BUTTON_GAP default; the options panel's "Button spacing" slider (range
-- 10-30px) live-applies changes via TotemBar.SetButtonGap (ui.lua) and
-- persists them to TotemBarDB.buttonGap (core/config.lua).
TotemBar.DEFAULT_BUTTON_GAP = 10
-- Cycle state: which slot was cast last, and when.
TotemBar.castState = TotemBar.castState or {
index = 0, -- 0 = no cast yet (or state was reset)
lastTime = 0,
lastDeployTime = 0,
}
-- Own-tracking table for the OmniCC-style remaining-duration display in
-- ui.lua: fallback source for when pfUI's libtotem (GetTotemInfo) isn't
-- present, or reports a given slot inactive. element -> {start=,
-- duration=}, both in TotemBar.recordCast() below.
TotemBar.activeTotems = TotemBar.activeTotems or {}
-- Pure: given the previously cast slot index, the time of that previous
-- cast, the current time, the allowed gap (seconds) and the number of
-- slots, returns the next slot index (1-based) to advance to.
--
-- - prevIndex <= 0 (never cast yet) -> 1
-- - now - lastTime > gapSeconds -> 1 (fresh spam, start over)
-- - otherwise -> prevIndex + 1, wrapping from
-- numSlots back to 1
function TotemBar.nextIndex(prevIndex, lastTime, now, gapSeconds, numSlots)
if not prevIndex or prevIndex <= 0 then
return 1
end
if not lastTime or (now - lastTime) > gapSeconds then
return 1
end
local nxt = prevIndex + 1
if nxt > numSlots then
nxt = 1
end
return nxt
end
-- Pure: starting at startIndex, walk forward through `elements`
-- (wrapping past the end back to 1) and return the index of the first
-- element for which chosen[element] is truthy (a totem name). Returns
-- nil if none of the slots are filled.
function TotemBar.findFilledSlot(chosen, elements, startIndex)
local numSlots = table.getn(elements)
if numSlots == 0 or not startIndex then
return nil
end
for tries = 0, numSlots - 1 do
local slot = startIndex + tries
if slot > numSlots then
slot = slot - numSlots
end
if chosen[elements[slot]] then
return slot
end
end
return nil
end
-- Pure: seconds remaining given a start time, a duration and the
-- current time. May return <= 0 (already expired); callers decide how
-- to treat that. Returns nil if either start or duration is missing.
function TotemBar.remaining(start, duration, now)
if not start or not duration then
return nil
end
return start + duration - now
end
-- Pure: is any element's own-tracked totem still out (remaining > 0)?
function TotemBar.anyActiveTracked(activeTotems, elements, now, remainingFn)
if not activeTotems then
return false
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
return true
end
end
end
return false
end
-- Pure: decides which of two already-computed remaining-seconds values
-- to show for one element's timer text. GetTotemInfo (pfUI's
-- libtotem), when it reports the slot active, is authoritative;
-- otherwise (absent, or reporting the slot inactive) falls back to
-- TotemBar's own cast-tracking. Returns nil when neither source has
-- time left.
function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining)
-- FIX 2026-07-15: trust GTI (pfUI libtotem) ONLY when it reports the slot active
-- AND with positive time left. Previously a stale-active GTI slot (active but
-- gtiRemaining<=0 -- libtotem evicts lazily on read) hit the bare `return nil` and
-- BLOCKED the timer even when own-tracking had a valid time. That produced the
-- Fire/Earth-dead, Air-flickering, Water-ok pattern (pfUI's single non-slot-indexed
-- cast queue loses the race for the slots cast later in a multi-drop).
if gtiActive and gtiRemaining and gtiRemaining > 0 then
return gtiRemaining
end
if ownRemaining and ownRemaining > 0 then
return ownRemaining
end
return nil
end
-- Pure: OmniCC-style text for an already-known-positive remaining
-- seconds value: whole minutes rounded up from 60s on, plain rounded-up
-- integer seconds below that.
function TotemBar.formatRemaining(remaining)
if remaining >= 60 then
return string.format("%dm", math.ceil(remaining / 60))
end
return string.format("%d", math.ceil(remaining))
end
-- Pure: strips a trailing rank suffix from a spell name as CastSpellByName
-- would receive it -- both "Searing Totem(Rank 4)" (no space, what the
-- client itself produces) and "Searing Totem (Rank 4)" (a space, common in
-- hand-typed macros) are accepted forms. Returns the input unchanged if it
-- carries no such suffix, or nil if name is nil.
function TotemBar.stripRankSuffix(name)
if not name then
return nil
end
local _, _, base = string.find(name, "^(.-)%s*%(.-%)%s*$")
if base and base ~= "" then
return base
end
return name
end
-- Pure: given a spell name as CastSpellByName/GetSpellName would produce it
-- (optionally rank-suffixed, see stripRankSuffix above), returns the
-- element it belongs to and its rank-stripped base name -- or nil, nil if
-- rawName is nil or isn't one of TotemBar's known totems (core/totemdata.lua).
-- Used by the universal CastSpellByName/CastSpell hooks below to decide
-- whether a cast caught outside TotemBar's own paths is a totem at all.
function TotemBar.elementFromCastName(rawName)
if not rawName then
return nil, nil
end
local baseName = TotemBar.stripRankSuffix(rawName)
local element = TotemBar.elementOf(baseName)
if not element then
return nil, nil
end
return element, baseName
end
-- Finds the spellbook index of a known spell by exact name, or nil.
-- Used to duplicate ui.lua's own file-local FindSpellIndexByName as its
-- own fresh linear scan (no common "api" module to hang a single copy
-- off). Both now route through core/spellindex.lua's cached
-- TotemBar.findSpellIndex (loaded earlier in the TOC) instead.
-- Records that `totemName` was just cast into `element`'s slot, into
-- TotemBar's own tracking table (see activeTotems above). Touches
-- GetTime(), a spellbook index/texture scan and (for Searing Totem) a
-- rank scan, so it isn't pure; called from both the bar's left-click
-- path (ui.lua) and castNext()/castAll() below.
--
-- Also stashes the totem spell's icon texture (rec.icon) and a
-- self-learning "did I ever see this totem's buff" flag
-- (rec.everHadBuff, starts false). ui.lua's out-of-range red-tint
-- feature is buff-presence based: a totem's party buff uses the SAME
-- icon texture as the totem spell itself (verified in-game), so
-- TotemBar.hasBuffWithIcon(rec.icon) tells whether the player is
-- currently benefiting from THIS cast totem.
function TotemBar.recordCast(element, totemName)
if not element or not totemName then
return
end
local highestRank = nil
if totemName == "Searing Totem" and TotemBar.highestKnownRank then
highestRank = TotemBar.highestKnownRank(totemName)
end
local icon = nil
local idx = TotemBar.findSpellIndex(totemName)
if idx then
icon = GetSpellTexture(idx, BOOKTYPE_SPELL)
end
local rec = {
start = GetTime(),
duration = TotemBar.durationWithMastery(
TotemBar.totemDuration(totemName, highestRank),
TotemBar.isHelpfulTotem(totemName),
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery()),
totemName = totemName,
icon = icon,
everHadBuff = false,
}
TotemBar.activeTotems[element] = rec
end
-- Records a totem cast caught by the universal CastSpellByName/CastSpell
-- hooks below, UNLESS TotemBar's own code already recorded this EXACT cast
-- this same GetTime() tick. Every TotemBar-exposed cast entry point
-- (bind.lua's CastTotem/CastElement, castNext/castAll/dropSetKey/
-- recallAndCastAll above, ui.lua's bar/flyout click handlers) already calls
-- TotemBar.recordCast() itself right after casting -- verified by a full
-- inventory of every Bindings.xml binding, the "Totems" macro, and every
-- click handler (2026-07-16): none of them skip it. So a hook firing for a
-- cast that ALSO went through one of those paths would otherwise
-- double-record in the same frame (harmless -- recordCast just overwrites
-- with a near-identical timestamp -- but trivially avoidable with this one
-- check, so it is).
function TotemBar.recordCastFromHook(element, totemName)
local existing = TotemBar.activeTotems[element]
if existing and existing.totemName == totemName and existing.start == GetTime() then
return
end
TotemBar.recordCast(element, totemName)
end
-- ===== Universal cast hooks: catch totem casts from ANY path =====
-- Defense-in-depth for totems cast WITHOUT going through any TotemBar
-- function at all -- e.g. a hand-written macro's own `/cast Searing Totem`,
-- or another addon. Every path TotemBar itself exposes already calls
-- recordCast() (see recordCastFromHook's comment above) so this is a
-- safety net, not the primary fix for a "no countdown" report.
--
-- Fixed arity on purpose (project rule: no vararg closures) -- both globals
-- have a stable, known 1.12 signature: CastSpellByName(name, onSelf),
-- CastSpell(spellId, bookType).
--
-- Plain global reassignment, not hooksecurefunc -- pfUI provides
-- hooksecurefunc as its own polyfill (compat/vanilla.lua; native 1.12 has
-- no such function), and TotemBar must not hard-depend on pfUI being
-- loaded. Saving whatever function is CURRENTLY bound to the global and
-- calling it first (before doing our own extra work) composes correctly
-- with pfUI's own libtotem hook on CastSpellByName/CastSpell regardless of
-- addon load order: whichever wraps last just adds a layer on top of
-- whatever was already there.
--
-- UseAction is intentionally NOT hooked. 1.12 has no GetActionInfo /
-- GetActionSpellId to read a spell off an action-bar slot directly; pfUI's
-- own UseAction coverage (libtotem.lua) only works by scanning a hidden
-- GameTooltip:SetAction(slot) via its private libtipscan library, wired up
-- through hooksecurefunc -- both the tooltip-scan trick and hooksecurefunc
-- are pfUI-internal, not something TotemBar can reimplement without either
-- a hard pfUI dependency or a whole new action-slot tooltip scanner (this
-- file already has a spellbook-index tooltip scanner for mana costs, see
-- core/manacost.lua, but nothing that scans by action slot). Given every
-- TotemBar-native path already records, and pfUI's GetTotemInfo already
-- covers action-bar-dragged totem casts as the resolveRemaining/
-- resolveDuration fallback source when pfUI is present, this gap was
-- judged not worth the added dependency/complexity.
if type(CastSpellByName) == "function" then
local origCastSpellByName = CastSpellByName
CastSpellByName = function(name, onSelf)
origCastSpellByName(name, onSelf)
local element, baseName = TotemBar.elementFromCastName(name)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
end
end
if type(CastSpell) == "function" then
local origCastSpell = CastSpell
CastSpell = function(spellId, bookType)
origCastSpell(spellId, bookType)
if type(GetSpellName) == "function" then
local rawName = GetSpellName(spellId, bookType)
local element, baseName = TotemBar.elementFromCastName(rawName)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
end
end
end
-- Module-scratch table for the buff-texture scan below, reused every
-- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer
-- tick) so it doesn't allocate a new table each time. buffScratchLen
-- tracks how far the previous scan filled it, so leftover entries past
-- the new scan's length get nilled out - keeping it a clean, hole-free
-- 1..n array (table.getn needs that to be reliable in Lua 5.0).
local buffScratch = {}
local buffScratchLen = 0
-- Pure: given a flat array of buff texture path strings (some entries
-- may be nil) and a totem spell's icon texture path, returns true if
-- any buff texture matches iconPath via a case-insensitive literal
-- substring search (tolerates path/casing differences between
-- GetSpellTexture's and UnitBuff's returned strings). Returns false if
-- iconPath or buffTexList is nil, or nothing matches.
function TotemBar.buffTexturesMatch(buffTexList, iconPath)
if not iconPath or not buffTexList then
return false
end
local needle = string.lower(iconPath)
for i = 1, table.getn(buffTexList) do
local tex = buffTexList[i]
if tex and string.find(string.lower(tex), needle, 1, true) then
return true
end
end
return false
end
-- Thin WoW-API wrapper: scans the player's current buffs (UnitBuff
-- "player" 1..32, stopping at the first nil slot) into the reusable
-- buffScratch table, then hands it to the pure
-- TotemBar.buffTexturesMatch() above. This is the "am I in this totem's
-- range?" signal for ui.lua's red-tint feature: a totem's party buff
-- shares its spell's icon texture (verified in-game), so having a
-- matching buff means the totem is currently affecting the player.
function TotemBar.hasBuffWithIcon(iconPath)
if not iconPath then
return false
end
local n = 0
for i = 1, 32 do
local tex = UnitBuff("player", i)
if not tex then
break
end
n = n + 1
buffScratch[n] = tex
end
for i = n + 1, buffScratchLen do
buffScratch[i] = nil
end
buffScratchLen = n
return TotemBar.buffTexturesMatch(buffScratch, iconPath)
end
-- Clears every own-tracked totem timer at once (e.g. after Totemic
-- Recall, which drops all active totems simultaneously).
function TotemBar.clearActiveTotems()
for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
TotemBar.activeTotems[TotemBar.TOTEM_ELEMENTS[i]] = nil
end
end
-- Is at least one totem currently out? Used to avoid wasting Totemic Recall's
-- own 6-second cooldown on a no-op cast: recalling with nothing out still puts
-- Recall on cooldown, so a fresh set placed right after can't be recalled for
-- 6s. Permissive on purpose - returns true if EITHER our own cast-tracking OR
-- GetTotemInfo (pfUI libtotem / SuperWoW, when present) reports a totem out, so
-- a legitimate recall is never wrongly blocked; only when both agree nothing is
-- out do we suppress the cast.
function TotemBar.anyTotemOut()
if TotemBar.anyActiveTracked(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS,
GetTime(), TotemBar.remaining) then
return true
end
if GetTotemInfo then
for slot = 1, 4 do
if GetTotemInfo(slot) then
return true
end
end
end
return false
end
-- pure: does the addon currently hold ANY tracked totem record (regardless of expiry)?
-- TotemBar.activeTotems is an in-memory table cleared to {} on load (see top of this file,
-- NOT persisted to SavedVariables), so a non-empty table proves we have cast at least one
-- totem THIS session and can reason about whether it is still out.
function TotemBar.hasTrackedTotems(activeTotems, elements)
if not activeTotems then return false end
for i = 1, table.getn(elements) do
if activeTotems[elements[i]] then return true end
end
return false
end
-- Should the manual-recall gate BLOCK the recall? Only when we are CONFIDENT that nothing
-- is out: we have tracked at least one totem THIS session AND all tracked totems have
-- expired. When tracking is empty (fresh load, or after a /reload -- activeTotems is
-- in-memory only and reset on load) the state is UNKNOWN, not "none out": on 1.12 there is
-- no GetTotemInfo / no "totemN" UnitID to re-detect a totem that was cast BEFORE the reload
-- (KG-confirmed 2026-07-14 -- no reload-proof detection exists for all totem types). So in
-- the unknown case FAIL OPEN -- let the recall through rather than falsely reporting "no
-- totems out" and blocking a legitimate recall of a still-standing totem (the bug this fixes:
-- our reloads wiped the tracking while the totems stayed physically out). Worst case of
-- fail-open is one needless recall right after a reload -- far cheaper than a blocked one.
function TotemBar.confidentNoneOut()
if not TotemBar.hasTrackedTotems(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS) then
return false
end
return not TotemBar.anyTotemOut()
end
-- Casts exactly ONE totem per call: the next slot in Fire -> Earth ->
-- Water -> Air order, skipping empty (unassigned) slots. If more than
-- gapSeconds has passed since the previous call, the cycle restarts at
-- the first filled slot (so a fresh spam always begins with totem 1).
--
-- Intended for a macro: `/script TotemBar.castNext()`
function TotemBar.castNext()
local db = TotemBarDB
local chosen = (db and db.chosen) or {}
local gap = (db and db.gapSeconds) or TotemBar.DEFAULT_GAP_SECONDS
local elements = TotemBar.TOTEM_ELEMENTS
local numSlots = table.getn(elements)
local now = GetTime()
local state = TotemBar.castState
local startIdx = TotemBar.nextIndex(state.index, state.lastTime, now, gap, numSlots)
local slot = TotemBar.findFilledSlot(chosen, elements, startIdx)
if not slot then
-- Nothing assigned to any element; nothing to cast.
state.index = 0
state.lastTime = now
return nil, nil
end
local element = elements[slot]
local totemName = chosen[element]
CastSpellByName(totemName)
TotemBar.recordCast(element, totemName)
state.index = slot
state.lastTime = now
return totemName, element
end
-- Casts ALL filled slots in a single call (Fire -> Earth -> Water ->
-- Air). On TurtleWoW each totem element has its own cooldown, so this
-- MAY drop all four from one keypress. Whether 4 CastSpellByName calls
-- in one Lua frame all land (vs only the last "winning") is unverified
-- on this client -- offered as a one-press alternative to castNext() to
-- test in-game.
--
-- Intended for a macro: `/script TotemBar.castAll()`
function TotemBar.castAll()
local db = TotemBarDB
local chosen = (db and db.chosen) or {}
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
local element = elements[i]
local totemName = chosen[element]
if totemName then
CastSpellByName(totemName)
TotemBar.recordCast(element, totemName)
end
end
end
-- Pure: should recallAndCastAll fire Totemic Recall this call?
-- autoRecall off -> false (never recall)
-- never deployed (nil / <=0) -> true (nothing fresh to protect)
-- last deploy > guard ago -> true
-- last deploy within guard -> false (protect just-placed totems from a
-- rapid accidental second press)
function TotemBar.shouldRecall(autoRecall, lastDeployTime, now, guardSeconds)
if not autoRecall then
return false
end
if not lastDeployTime or lastDeployTime <= 0 then
return true
end
if not guardSeconds then
return true
end
if (now - lastDeployTime) > guardSeconds then
return true
end
return false
end
-- Casts Totemic Recall bypassing nampower's spell queue when that API is
-- present. Root cause of the "place a set, it vanishes a moment later" bug:
-- under nampower, a plain CastSpellByName of Totemic Recall (a GCD-tied
-- instant) issued while a GCD is active is QUEUED, not cast, and pops at
-- GCD-end -- by which time the totems are already down (TWoW gives each totem
-- element its own non-GCD recovery category, so the 4 totems fire immediately
-- with queue priority). The late Recall then sweeps the fresh set. Bypassing
-- the queue means a GCD-blocked Recall simply fails that press instead of
-- being deferred, so it can never fire late and tear the totems down. Falls
-- back to a plain cast when nampower isn't installed (the function is nil).
-- (KG: NP_QueueInstantSpells default 1 is the deferral; CastSpellByNameNoQueue
-- is nampower's queue-bypass cast.)
local function castRecallNoQueue()
if type(CastSpellByNameNoQueue) == "function" then
CastSpellByNameNoQueue("Totemic Recall")
else
CastSpellByName("Totemic Recall")
end
end
-- Recall-then-deploy: when TotemBarDB.autoRecall is on (the default -
-- toggleable via the Recall button's right-click, see ui.lua), casts
-- Totemic Recall FIRST (drops existing totems and refunds some mana)
-- and clears own-tracking, then always places all filled slots via
-- castAll(). One keypress = recall + redeploy (or just redeploy, with
-- the flag off). Like castAll, relies on TurtleWoW allowing several
-- CastSpellByName calls in one Lua frame -- verify in-game.
--
-- Guarded against rapid double-presses: shouldRecall() only fires Recall
-- if the last deploy was more than DEFAULT_RECALL_GUARD seconds ago. A
-- fast accidental second press then just re-attempts placement (a no-op,
-- since the totems are still up and each element is on its own ~1.5s
-- cooldown) instead of recalling the totems that were just placed.
--
-- Intended for a macro: `/script TotemBar.recallAndCastAll()`
function TotemBar.recallAndCastAll()
local now = GetTime()
local autoRecall = TotemBarDB and TotemBarDB.autoRecall
local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.anyTotemOut() then
castRecallNoQueue()
TotemBar.clearActiveTotems()
end
TotemBar.castAll()
TotemBar.castState.lastDeployTime = now
end
-- Key-down/key-up split for the DropSet keybind. Bound in Bindings.xml with
-- runOnUp="true", so this runs on BOTH flanks with the global `keystate` set
-- to "down" / "up" beforehand. Casting Totemic Recall on the DOWN stroke and
-- placing the set on the RELEASE. The real fix for the teardown is
-- castRecallNoQueue() (see above) -- bypassing nampower's queue so a
-- GCD-blocked Recall never fires late and sweeps the fresh set. The down/up
-- split adds belt-and-suspenders temporal separation: the Recall on the down
-- stroke has definitively resolved-or-failed (it is never queued) by the time
-- the release places the set. Both flanks are real hardware events, so both
-- may cast (Blizzard's own ActionButtonUp casts on release too), unlike a
-- timer-deferred placement which this client blocks as a non-hardware cast.
--
-- Still guarded by shouldRecall()'s 2s window (a rapid re-press within the
-- guard skips the Recall so it can't pull the just-placed set). keystate is
-- nil only if the binding ran without runOnUp (misconfig / older client);
-- treat nil like "up" so a single fire at least still PLACES rather than
-- silently doing nothing.
function TotemBar.dropSetKey(keystate)
if keystate == "down" then
local now = GetTime()
local autoRecall = TotemBarDB and TotemBarDB.autoRecall
local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.anyTotemOut() then
castRecallNoQueue()
TotemBar.clearActiveTotems()
end
else
-- Release (or nil fallback): place the set now, in this hardware frame.
TotemBar.castAll()
TotemBar.castState.lastDeployTime = GetTime()
end
end
-- Dev aid (/tb tdump): dumps TotemBar's own per-element cast-tracking
-- (TotemBar.activeTotems, the resolveRemaining/resolveDuration own-tracking
-- fallback source, see this file's header comment) side by side with the
-- RAW GetTotemInfo(1..4) (pfUI libtotem, when present -- the OTHER,
-- normally-authoritative source), so a "countdown missing" report can be
-- diagnosed off-client without an in-game screenshot: did TotemBar ever
-- record this cast at all, and what does GTI say about the same slot right
-- now? Also appends ui.lua's TotemBar.DumpRingRenderState() output (2026-07-
-- 16, Searing-ring bug: countdown TEXT shows but no pulse RING) -- the pure
-- ring math was already proven correct offline, so this section captures
-- the WoW-side render state instead: cached ring flags plus the LIVE
-- ringFill/ringTrack texture objects (shown/texture/alpha/texCoord/parent),
-- see that function's own comment for the full field list. Written to
-- <WoW>\imports\tb_tdump.txt via SuperWoW's ExportFile (name WITHOUT
-- the .txt extension -- ExportFile appends it itself); chat fallback when
-- SuperWoW isn't present. GetTotemInfo is pcall-wrapped since it's
-- third-party (pfUI) code this dump must never itself error on.
function TotemBar.DumpTimerState()
local now = GetTime()
local els = TotemBar.TOTEM_ELEMENTS
local activeTotems = TotemBar.activeTotems
local out = "TotemBar timer-state dump (now=" .. tostring(now) .. ")\n"
out = out .. "\n-- own tracking (TotemBar.activeTotems) --\n"
for i = 1, table.getn(els) do
local element = els[i]
local rec = activeTotems[element]
if rec then
local rem = TotemBar.remaining(rec.start, rec.duration, now)
out = out .. element .. ": spell='" .. tostring(rec.totemName) .. "'"
.. " start=" .. tostring(rec.start)
.. " duration=" .. tostring(rec.duration)
.. " remaining=" .. tostring(rem) .. "\n"
else
out = out .. element .. ": (no record)\n"
end
end
out = out .. "\n-- raw GetTotemInfo(1..4) (pfUI libtotem, when present) --\n"
if type(GetTotemInfo) == "function" then
for slot = 1, 4 do
local ok, active, name, start, duration = pcall(GetTotemInfo, slot)
if ok then
out = out .. "slot " .. slot .. ": active=" .. tostring(active)
.. " name='" .. tostring(name) .. "'"
.. " start=" .. tostring(start)
.. " duration=" .. tostring(duration) .. "\n"
else
out = out .. "slot " .. slot .. ": pcall error: " .. tostring(active) .. "\n"
end
end
else
out = out .. "(GetTotemInfo not present -- no pfUI libtotem loaded)\n"
end
-- Render-state section (ui.lua): the pure timer/duration/ring math
-- above was already proven correct offline for the Searing-ring
-- report (text shows, no ring) -- this is the other half, the
-- WoW-side render state (cached ring flags + LIVE texture objects)
-- that this dump previously never captured. pcall-wrapped: ui.lua
-- loads after this file (see TotemBar.toc), but by the time a player
-- runs /tb tdump everything is loaded; the guard just keeps this dump
-- from erroring if that ever isn't true (e.g. a future load-order
-- change) or if TotemBar.DumpRingRenderState itself hits something
-- unexpected.
local okRender, renderOut = pcall(TotemBar.DumpRingRenderState)
if okRender and renderOut then
out = out .. renderOut
else
out = out .. "\n-- render state: unavailable (" .. tostring(renderOut) .. ") --\n"
end
if ExportFile then
ExportFile("tb_tdump", out)
end
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage("TotemBar: tdump exported.")
end
end
+84
View File
@@ -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 (spec docs/superpowers/specs/2026-07-09-pulse-ui-design.md):
-- 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
+51
View File
@@ -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.
+277
View File
@@ -0,0 +1,277 @@
-- 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: 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
-- ===== 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
+73
View File
@@ -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
+171
View File
@@ -0,0 +1,171 @@
-- 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
-- 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
+155
View File
@@ -0,0 +1,155 @@
-- 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
-- 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
-- ---------------------------------------------------------------------------
-- 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).
local sname = SpellInfo 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
end
+61
View File
@@ -0,0 +1,61 @@
-- 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. Spec:
-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md section 3.
--
-- 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
+52
View File
@@ -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
+175
View File
@@ -0,0 +1,175 @@
-- 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
-- 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
+137
View File
@@ -0,0 +1,137 @@
-- 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",
},
}
-- 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,
["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