+
+## What's new — v0.2.0 · *The Pulse UI release* · 2026-07-10
+
+- **Round floating slots** — the square boxes are gone; icons float in a
+ subtle shadow-rim frame, with round hover/pressed states everywhere.
+- **Time traffic light** — the outer ring drains with the totem's remaining
+ lifetime and shifts green → yellow → orange → red.
+- **Pulse ring** — a thin element-colored inner arc fills toward the next
+ pulse and resets when it fires, animated per frame.
+- **Server-accurate pulse timing** — intervals and first-tick behavior from
+ TurtleWoW server data (Tremor pulses every **4s** and immediately on
+ placement; Magma and the water totems first pulse one interval in). Water
+ totems re-anchor on the real observed mana/heal ticks.
+- **Pulse waves & glow** — a ripple in the element color marks each pulse; a
+ halo builds just before it.
+- **Bar layouts** — 1×6, 2×3 (elements as a 2×2 block) or 3×2.
+- **Custom art set** — element glyphs for empty slots, drop-set icon, minimap
+ glyph, skinned options panel. All generated by scripts in `tools/`.
+
+**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
+
+- **v0.2.5** (2026-07-25) — new option **"Show drop-all button"**: hide the
+ drop-all-totems button if you fire it from a keybind or macro and would rather
+ not spend a slot on it. The keybind keeps working while it's hidden, and in the
+ 1×6 layout the bar shrinks instead of leaving dead space.
+- **v0.2.4** (2026-07-14) — Totemic Recall no longer refuses to fire after a
+ `/reload`: the "no totems out" guard relied on in-memory tracking that a reload
+ wipes, and 1.12 offers no way to re-detect totems placed beforehand. Also a
+ shared spellbook index, killing a multi-millisecond hitch when dropping four
+ totems from one macro press.
+- **v0.2.3** (2026-07-13) — fixes a bug where dropping your set could make the
+ new totems vanish an instant later: with nampower, Totemic Recall was queued
+ behind the global cooldown and fired *after* the (off-GCD) totems were
+ placed, sweeping them. Recall now bypasses the spell queue so it can't pull a
+ fresh set.
+- **v0.2.2** (2026-07-10) — "Button spacing" slider in the options panel;
+ keybind casts now feed TotemBar's own duration/pulse timers (previously
+ only click-casts and pfUI did); removed the dead `pulseGlow` setting.
+- **v0.2.1** (2026-07-10) — tooltips now show your highest known rank (they
+ read rank-1 values before; the cast itself was always highest-rank) and
+ display the rank next to the name.
+- **v0.1.3** (2026-07-09) — Totemic Recall no longer wastes its 6s cooldown
+ when no totems are out.
+- **v0.1.2** (2026-07-09) — mana cost on the drop-set tooltip, live refund
+ estimate on the Recall tooltip, Totemic Mastery duration bonus, rank-aware
+ mana scan.
+- **v0.1.1** (2026-07-09) — key bindings (Esc menu section for every action
+ and every single totem), drop-set bar button, hover-bind mode with
+ per-button key labels.
+- **v0.1.0** (2026-07-09) — first public release: element buttons, hover
+ flyout, "Totems" macro with recall guard, minimap button, options panel,
+ duration timers, out-of-range tint.
+
+## Screenshots
+
+| The bar & flyout | Options panel |
+| :---: | :---: |
+| 
|
|
+
+| Key bindings | Hover-bind mode |
+| :---: | :---: |
+|
|
|
+
+*(Click any image for full size.)*
+
+## Features
+
+**Casting**
+- One button per element (Fire / Earth / Water / Air): left-click casts the
+ chosen totem, right-click clears the slot.
+- Hover flyout with your other known totems per element — left-click casts
+ once, right-click makes it the new default.
+- **Drop-set button** and a one-press **"Totems" macro** cast all four chosen
+ totems at once, guarded against accidental double presses (so a fast second
+ press won't recall the set you just placed).
+- Dedicated **Totemic Recall** button that only fires when totems are
+ actually out — never wastes Recall's 6s cooldown. Right-click toggles
+ auto-recall-before-redeploy.
+
+**At-a-glance timing**
+- **Duration ring:** the outer ring drains with the totem's lifetime,
+ colored green → yellow → orange → red by remaining time.
+- **Pulse ring:** the inner arc (element color) fills toward the next pulse —
+ intervals and first-tick behavior derived from TurtleWoW server data, water
+ totems locked onto their real observed ticks.
+- **Pulse waves:** an expanding ripple marks each pulse, a soft halo
+ announces it just before, and a spawn ripple confirms each placement.
+- Optional countdown text under each slot; totems tint red when you leave
+ their range (buff-presence based), and the Recall button pulses while
+ anything is out of range.
+- Native cooldown swipes and real in-game spell tooltips everywhere.
+
+**Control**
+- **Key bindings:** an Esc → Key Bindings section for every action — including
+ a binding for every single totem you know — plus a fast **hover-bind mode**
+ (hover a button, press a key). Bound keys show on the buttons.
+- **Layouts:** one row (1×6), two rows (2×3, elements grouped 2×2) or three
+ rows (3×2), switchable live.
+- **Minimap button** (drag to reposition, left-click options, right-click
+ toggles the bar) and a skinned **options panel** for everything else.
+- **Assignment seam** for future raid tools: an external addon can *suggest*
+ a totem set via `TotemBar.ReceiveAssignment` — shown as a one-click apply
+ panel, never auto-cast. See [`docs/API.md`](docs/API.md).
+
+## Install
+
+**Option A — download (simplest):**
+
+1. Grab **`TotemBar-vX.Y.Z.zip`** from
+ [**Releases**](https://github.com/ShempError/TotemBar/releases).
+ *(Use the Release zip, not "Code → Download ZIP" — that names the folder
+ `TotemBar-master`, which WoW won't load.)*
+2. Extract the **`TotemBar`** folder into `Interface\AddOns\`.
+3. Restart the client.
+
+**Option B — git (auto-updatable):**
+
+```
+cd Interface/AddOns
+git clone https://github.com/ShempError/TotemBar.git TotemBar
+```
+
+`master` is the stable release channel — git-based managers
+(GitAddonsManager, OctoWoW) stay current with a `git pull`. `dev` is
+work-in-progress.
+
+## Usage
+
+| Action | Effect |
+| --- | --- |
+| Left-click element button | Cast that element's chosen totem |
+| Right-click element button | Clear that element's slot |
+| Hover element button | Flyout with the element's other known totems |
+| Left-click flyout totem | Cast it once (default unchanged) |
+| Right-click flyout totem | Set it as the slot's new default |
+| Left-click Recall | Cast Totemic Recall (only when totems are out) |
+| Right-click Recall | Toggle auto-recall |
+| Left-click drop-set button | Cast all four chosen totems |
+| Minimap button | Left: options · Right: toggle bar · Drag: reposition |
+
+**Slash commands:** `/tb` (toggle bar) · `/tb options` · `/tb lock` ·
+`/tb bind` (hover-bind mode) · `/tb scan`, `/tb assign`, `/tb manadump`,
+`/tb pulsecal` (dev aids).
+
+**The "Totems" macro:** options panel → **Create 'Totems' macro**, then drag
+the `Totems` macro to your action bar. One press recalls (if auto-recall is
+on) and redeploys all four chosen totems.
+
+## Options
+
+Open with the minimap button or `/tb options`.
+
+| Option | Does |
+| --- | --- |
+| Lock bar | Disable dragging |
+| Auto-recall before setting | Recall first when the macro/drop-set fires |
+| Show bar | Show/hide the whole bar |
+| Show drop-all button | Show/hide the drop-all-totems button; its keybind keeps working either way |
+| Show duration ring | Outer remaining-time ring (traffic-light colors) |
+| Show pulse ring | Inner next-pulse arc (element color) |
+| Show pulse waves | Ripple + glow effects on pulses |
+| Show countdown text | Remaining-seconds text under each slot |
+| Layout | Cycle 1×6 / 2×3 / 3×2 |
+| Recall guard (sec) | Double-press protection window |
+| Cycle reset gap (sec) | When macro cycling restarts at totem 1 |
+| UI size | Scales bar + flyout from the top-left corner |
+| Reset position / Create macro / Key bind mode | One-click actions |
+
+## Requirements
+
+- A WoW 1.12.1 client (TurtleWoW). **No addon dependencies.**
+
+Optional, auto-detected: **pfUI** (skinned widgets; its `libtotem` feeds the
+timers as an extra source) and **SuperWoW** (only used by the `/tb scan` /
+`/tb pulsecal` dev aids for file exports).
+
+## For developers
+
+- Public API for companion addons: [`docs/API.md`](docs/API.md).
+- Pure-logic modules under `core/` are unit-tested offline against a real
+ Lua 5.0.3 interpreter (`tools/luatests/`, 14 suites). WoW-API files are
+ syntax-checked; in-game behavior is verified on TurtleWoW.
+- All textures are **generated**: `tools/gen_*.js` (zero-dependency Node)
+ render the ring/FX/icon/panel sheets as 1.12-safe TGAs, and
+ `tools/preview_*.js` composite them into PNG previews for review without
+ launching the client.
+- `/reload` covers changes to existing `.lua` files; a client restart is
+ needed for `.toc` changes, new files, or changed textures (the client
+ caches them).
+
+## License
+
+MIT (see LICENSE).
diff --git a/TotemBar.toc b/TotemBar.toc
new file mode 100644
index 0000000..dc92068
--- /dev/null
+++ b/TotemBar.toc
@@ -0,0 +1,25 @@
+## Interface: 11200
+## Title: TotemBar
+## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro.
+## Author: ShempError
+## Version: 0.2.5
+## SavedVariables: TotemBarDB
+
+core\totemdata.lua
+core\spellindex.lua
+core\known.lua
+core\cast.lua
+core\config.lua
+core\assign.lua
+core\optionslogic.lua
+core\bindlogic.lua
+core\manacost.lua
+core\pulsedata.lua
+core\pulse.lua
+core\pulseparse.lua
+core\pulsecal.lua
+ui.lua
+options.lua
+minimap.lua
+Bindings.xml
+bind.lua
diff --git a/bind.lua b/bind.lua
new file mode 100644
index 0000000..b923c91
--- /dev/null
+++ b/bind.lua
@@ -0,0 +1,231 @@
+-- TotemBar - bind.lua
+-- Key-binding labels (for the Esc > Key Bindings menu) + the cast entry
+-- points the Bindings.xml bodies call. Hover-bind mode is added below in a
+-- later step. Loaded AFTER Bindings.xml (see the .toc).
+
+TotemBar = TotemBar or {}
+
+local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+
+-- Section + fixed-action labels.
+BINDING_HEADER_TOTEMBAR = "TotemBar"
+BINDING_NAME_TOTEMBAR_DROPSET = "Drop all totems"
+BINDING_NAME_TOTEMBAR_RECALL = "Totemic Recall"
+BINDING_NAME_TOTEMBAR_TOGGLEBAR = "Toggle bar"
+BINDING_NAME_TOTEMBAR_TOGGLEOPTIONS = "Toggle options"
+BINDING_NAME_TOTEMBAR_TOGGLEBIND = "Toggle key-bind mode"
+
+-- Per-element "cast the chosen totem" labels + per-element sub-headers.
+do
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local e = elements[i]
+ setglobal("BINDING_NAME_TOTEMBAR_CAST_" .. string.upper(e), "Cast " .. e .. " totem (chosen)")
+ setglobal("BINDING_HEADER_TOTEMBAR_" .. string.upper(e), "TotemBar: " .. e .. " Totems")
+ end
+ -- Per-totem labels (must match Bindings.xml names via bindingSuffix).
+ for i = 1, table.getn(elements) do
+ local e = elements[i]
+ local list = TotemBar.TOTEMS_BY_ELEMENT[e]
+ for j = 1, table.getn(list) do
+ local totem = list[j]
+ setglobal("BINDING_NAME_TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totem), "Cast " .. totem)
+ end
+ end
+end
+
+-- Cast a specific totem by name (no-op in-game if not known).
+function TotemBar.CastTotem(name)
+ if name then
+ CastSpellByName(name)
+ -- keybind casts must feed the own-tracking timers exactly like the
+ -- flyout click handler does (ui.lua) - without this, vanilla-only
+ -- setups get no duration ring/pulse for keybind-cast totems.
+ local element = TotemBar.elementOf(name)
+ if element then
+ TotemBar.recordCast(element, name)
+ end
+ end
+end
+
+-- Cast the currently-chosen totem for an element.
+function TotemBar.CastElement(element)
+ local n = TotemBarDB and TotemBarDB.chosen and TotemBarDB.chosen[element]
+ if n then
+ CastSpellByName(n)
+ -- keybind casts must feed the own-tracking timers exactly like the
+ -- flyout click handler does (ui.lua) - without this, vanilla-only
+ -- setups get no duration ring/pulse for keybind-cast totems.
+ TotemBar.recordCast(element, n)
+ else
+ ChatOut:AddMessage("TotemBar: no totem chosen for " .. tostring(element) .. ".")
+ end
+end
+
+-- Cast Totemic Recall and clear own-tracking (mirrors the Recall button).
+function TotemBar.CastRecall()
+ -- Don't waste Totemic Recall's 6s cooldown when nothing is out -- but only block when we
+ -- are CONFIDENT of that (tracked totems this session, all expired). After a /reload the
+ -- tracking is empty and 1.12 has no way to re-detect a pre-reload totem, so confidentNoneOut
+ -- fails open there and lets the recall through (see core/cast.lua).
+ if TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then
+ ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).")
+ return
+ end
+ CastSpellByName("Totemic Recall")
+ if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
+ if TotemBar.clearActiveTotems then
+ TotemBar.clearActiveTotems()
+ end
+end
+
+-- Stub so the TOTEMBAR_TOGGLEBIND binding never calls nil before Task 4
+-- fills it in. Replaced (same name) by the real implementation below.
+if not TotemBar.ToggleBindMode then
+ function TotemBar.ToggleBindMode()
+ ChatOut:AddMessage("TotemBar: key-bind mode not available.")
+ end
+end
+
+-- ===== Hover-bind mode =====
+-- Toggle a mode where hovering a bar button / flyout totem and pressing a
+-- key binds that key to the thing's action (buttons -> CLICK binding, flyout
+-- totems -> the named per-totem binding). ESC clears the hovered thing's
+-- binding. Bindings persist via the client (SaveBindings), no addon SV.
+
+local bindMode = false
+local captureFrame = nil
+local infoBox = nil
+
+-- Base keys that are pure modifiers - never bind these alone.
+local BARE_MODIFIERS = {
+ LSHIFT = true, RSHIFT = true, LCTRL = true, RCTRL = true,
+ LALT = true, RALT = true, UNKNOWN = true,
+}
+
+function TotemBar.isBindMode()
+ return bindMode
+end
+
+-- Resolves the mouse-focused frame to a binding action. Uses the pure
+-- TotemBar.actionForButton with the frame's global name and, for a flyout
+-- icon, its current .totemName.
+local function actionForFocus(focus)
+ if not focus then
+ return nil
+ end
+ local totemName = focus.totemName -- set on flyout icon buttons when shown
+ local fname = focus.GetName and focus:GetName() or nil
+ return TotemBar.actionForButton(fname, totemName)
+end
+
+local function ensureCaptureFrame()
+ if captureFrame then
+ return captureFrame
+ end
+ local f = CreateFrame("Frame", "TotemBarBindCapture", UIParent)
+ f:SetAllPoints(UIParent)
+ f:SetFrameStrata("FULLSCREEN_DIALOG")
+ f:EnableKeyboard(false)
+ f:Hide()
+ f:SetScript("OnKeyUp", function()
+ local key = arg1 -- NOTE: OnKeyUp arg1 = key name string (matches pfUI's hoverbind capture)
+ if not key or BARE_MODIFIERS[key] then
+ return
+ end
+ local focus = GetMouseFocus()
+ local action = actionForFocus(focus)
+ if key == "ESCAPE" and not action then
+ -- Keyboard capture swallows all keys while bind mode is on, so
+ -- ESC over nothing is the keyboard exit (the options button also
+ -- toggles it off). ESC over a button clears that button below.
+ TotemBar.ToggleBindMode()
+ return
+ end
+ if not action then
+ return
+ end
+ if key == "ESCAPE" then
+ local k1, k2 = GetBindingKey(action)
+ if k1 then SetBinding(k1) end
+ if k2 then SetBinding(k2) end
+ else
+ local full = TotemBar.modifierPrefix(IsAltKeyDown(), IsControlKeyDown(), IsShiftKeyDown()) .. key
+ SetBinding(full) -- clear whatever `full` was bound to
+ SetBinding(full, action) -- bind it to this action
+ end
+ SaveBindings(GetCurrentBindingSet())
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+ end)
+ captureFrame = f
+ return f
+end
+
+-- Lazily builds a visible on-screen info box shown while bind mode is
+-- active, so the mode (which otherwise only shows via a chat message and
+-- stays on until ESC) is hard to miss. Rev 4 UI chrome: shared bespoke
+-- panel skin (ui.lua's PANEL_BACKDROP), matching the rest of the addon's
+-- chrome, regardless of pfUI presence.
+local function ensureBindInfoBox()
+ if infoBox then
+ return infoBox
+ end
+ local f = CreateFrame("Frame", "TotemBarBindInfo", UIParent)
+ f:SetWidth(380)
+ f:SetHeight(80)
+ f:SetPoint("TOP", UIParent, "TOP", 0, -160)
+ f:SetFrameStrata("FULLSCREEN_DIALOG")
+ -- Cheap guard: bind.lua loads LAST per the .toc (after ui.lua), so
+ -- TotemBar.PANEL_BACKDROP is always set by the time this runs - the
+ -- fallback only protects against a future load-order change.
+ local backdrop = TotemBar.PANEL_BACKDROP or {
+ bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
+ edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
+ tile = true, tileSize = 32, edgeSize = 32,
+ insets = { left = 11, right = 12, top = 12, bottom = 11 },
+ }
+ f:SetBackdrop(backdrop)
+ f:SetBackdropColor(1, 1, 1, 0.97)
+ local t = f:CreateFontString("TotemBarBindInfoText", "OVERLAY", "GameFontNormal")
+ t:SetPoint("CENTER", f, "CENTER", 0, 0)
+ t:SetJustifyH("CENTER")
+ t:SetText("|cff33ffccKey-Bind Mode ACTIVE|r\nHover a bar button or flyout totem and press a key to bind it.\nESC over a button clears its key. ESC over empty space (or the options button) exits.")
+ if pfUI and pfUI.font_default then
+ t:SetFont(pfUI.font_default, 12)
+ end
+ f:Hide()
+ infoBox = f
+ return f
+end
+
+function TotemBar.ToggleBindMode()
+ bindMode = not bindMode
+ local f = ensureCaptureFrame()
+ if bindMode then
+ f:EnableKeyboard(true)
+ f:Show()
+ ensureBindInfoBox():Show()
+ ChatOut:AddMessage("TotemBar: key-bind mode ON - hover a button or flyout totem and press a key to bind. ESC over a button clears its key; ESC over nothing (or the options button) exits.")
+ else
+ f:EnableKeyboard(false)
+ f:Hide()
+ if infoBox then
+ infoBox:Hide()
+ end
+ ChatOut:AddMessage("TotemBar: key-bind mode OFF.")
+ end
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+end
+
+-- Refresh overlays whenever the client's bindings change.
+local bindEvents = CreateFrame("Frame", "TotemBarBindEventFrame", UIParent)
+bindEvents:RegisterEvent("UPDATE_BINDINGS")
+bindEvents:SetScript("OnEvent", function()
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+end)
diff --git a/core/assign.lua b/core/assign.lua
new file mode 100644
index 0000000..96a71a7
--- /dev/null
+++ b/core/assign.lua
@@ -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
diff --git a/core/bindlogic.lua b/core/bindlogic.lua
new file mode 100644
index 0000000..ddaf54f
--- /dev/null
+++ b/core/bindlogic.lua
@@ -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
diff --git a/core/cast.lua b/core/cast.lua
new file mode 100644
index 0000000..f991a1b
--- /dev/null
+++ b/core/cast.lua
@@ -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
+-- x37&QpbckXSrY^1zvcp-yPrzkL>KnGGh4rsz&a_sA5*O>tq2!HNFY4@eC|vA(S8kj
zTpqZfx~X|(M)b-E6p>9qEOeztc?9&IVYuBCd{c>k=tnB4{WIAm@(sg@8M%_;r^|
wfbe1y46w|3zR&6)6=KnZGa$8vB
zTH!n&|INA(BH$eUol{crh5bgXW#iE9IxCtz^QN2cdUhzZPMqurD*Dr@U|M-vr{yB%
z&~$nx)1B$*hDQElJ!et+AwjLn$7t=w33?b$WOs#_h}cVe`xQ@}`WO|wJA2p4!XyNw
z{1xiS@$RJ57|aR;I(TDIx4I1zwc5b1Gni}~m|^dggG1`k+8u*5THAGq$YZV>8S_H%
zil$}*k37CQU9`wLiub4mu(pae^mg;3i_7^}P|uo8EK6qfWfCv75I-Vo-JD{TFaU
zdiAU3L1QGi@=fQA`oz1T4IQu+RJM%?39S~?Fm>Vi_D%R!u5?_dElR~_XBU16r_aeu
z?4QbW(nO8?yZx!r7UPjGYiB=)*aqdhqiU`yIlraMRL|H6J+n0Srhex+)Z<>P7xb*K
zu+R#}b63|OmC8BHi|pyfKz_;N=?eJAwplk8m>BSlc}3Xn$)2>V#gMx-T?k?8hsq;6
zwtRtA9`aDaTg*ZPc!Y3@DfIC#9LZ35wp3h65%MkhiJoJzNtlHSSOw!x4i=FH#@}1C
z#!*wB3W_gY2{(^?R7rVWqJn?7aL)K0_ZJBDO{5x_@q7OJLir1N)=!Icy~OgzVjka+
zmV;j6YZHS}%v_^rc5bfZnPj0QAXZC?>sH&-9($cmi)imn42kUcxB=r`ihTCUMxANr
z#lcduS9Z>JrJae76Rn6wXz17M@-l8Y8EUbXy4IQ8n^$GDE}!p;-U#XA157vUnmn~}
zyg)wW4zXjRe(mmsdBOoX=FUwP&;0Y64Xn3hsXphD|G7d`*ssa?Angb3
zYCl9Emh_fuoUwjbKpozFv3-Aib_xQ2zPvsU@*G)5kYa+F2W~QBAP~Ay^>+0i=5)KL
zw*DVH5qI?AoJV;rSP@}iNo6&$5Pm7`Ur!+X>0e5Q)f0U($KMlH*OYpiLhVJjA9#LH
z_;K+sRMaJ=98X%5qot4coUkd>PCMH&wAuS*vtNt?K#C@FSubep^RPneJ5|Ldc%J;C
z2g3&!bldR>^uLcx7v1xO!xfR|fO>3Oc8c1;<)P@ww
LzSOJZ{vX_>tk0AFkHgf+nao!YHalfGkPh?atISk3NzYF
zJe%!*5f5@n@y8_y1~!ynll;)gdQo8FZ)E|SA7o>%T#Y5{Rb+nYOY}wO=Dr!|Rn%b4
z6m<-&p|{@3Ang(bhWIQm?P{TD?|A^kaL3Fu-kDb;t%%1|+^h69o0trkIA~fj#q0EL
ztU4!%@~fpbj+Fe?oWRQiJxzi}+gBE1cOF47`oUb~l&B2Y3ww!;6F+lwk