+
+## What's new — v0.4.1 · 2026-08-31
+
+- **A totem destroyed inside the recall attribution window stays dead.** A totem the enemy
+ destroyed in the ~0.4s window between a Totemic Recall wipe and its refusal being attributed
+ no longer has its countdown resurrected by the refused-recall restore.
+
+**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
+
+- **v0.4.1** (2026-08-31) — a totem destroyed by the enemy inside the recall attribution
+ window now stays dead instead of coming back when a refused Totemic Recall restores its
+ timers.
+- **v0.4.0** (2026-08-30) — a totem destroyed before its timer ran out is now
+ detected and cleared (countdown, ring, pulse and range tint together) instead
+ of counting down as if it still stood; a totem cast refused for lack of mana
+ no longer starts a countdown (the Clearcasting exemption never applied to
+ totems); a Totemic Recall the server refuses no longer wipes your timers.
+- **v0.3.0** (2026-08-15) — element buttons and the flyout now dim totems you
+ can't afford; a refused cast no longer starts a phantom countdown, and one
+ that already started can now be taken back after the fact (narrowly, and
+ never at the risk of clearing a real timer). Also fixes Totemic Recall
+ wiping or wasting itself around a press that couldn't go out or one queued
+ behind the global cooldown, Sentry Totem's timer expiring early, the
+ seventh Air totem being unreachable, Windwall's range tint sticking on
+ with Strength of Earth also up, the pulse ring jumping on another
+ shaman's tick, the out-of-range tint missing most of a set under pfUI, and
+ the duration ring double-counting with Totemic Mastery talented.
+- **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.
+- **Totems you cannot afford are dimmed** — buttons and flyout, from the real
+ cost of your highest known rank; the dim composes with the range tint instead
+ of overwriting it, and stands down while Clearcasting is up.
+- **Countdowns only start for casts that could go out** — a press refused for
+ mana or a real cooldown is not tracked, so no phantom timer and no wasted
+ Totemic Recall on an empty board. A second check catches what that cannot
+ predict (movement, a stun, a server-side refusal) and takes the countdown
+ back after the fact — narrowly filtered to the totem just pressed, so it
+ stays silent whenever it can't be sure rather than risk clearing a timer
+ for a totem that is actually still out.
+- 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/`, 19 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..c158cd5
--- /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.4.1
+## 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..3cc27d9
--- /dev/null
+++ b/bind.lua
@@ -0,0 +1,233 @@
+-- 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. Runs the exact same
+-- TotemBar.manualRecall (core/cast.lua) as the Recall button's left-click, so
+-- the keybind and the button can never diverge: it owns the "nothing is out"
+-- gate, the "is Recall even castable right now" check (a press that cannot go
+-- out must NOT wipe the timers of totems still standing), the queue-safe cast
+-- that keeps nampower from firing a late Recall into a fresh set, the refund
+-- snapshot and the own-tracking wipe. Only the chat feedback lives here.
+function TotemBar.CastRecall()
+ if not TotemBar.manualRecall then
+ return
+ end
+ local action = TotemBar.manualRecall()
+ if action == "none-out" then
+ ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
+ elseif action == "cooldown" then
+ ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
+ 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..ab3e992
--- /dev/null
+++ b/core/cast.lua
@@ -0,0 +1,2108 @@
+-- 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
+
+-- The spell the recall paths cast. One source for the name so the guard, the
+-- cooldown lookup and the cast itself can never disagree.
+local RECALL_SPELL_NAME = "Totemic Recall"
+
+-- How long a manual Recall press that the "nothing is out" gate BLOCKED stays
+-- remembered, so a deliberate re-press can override the gate (see
+-- TotemBar.manualRecallAction). The lower bound exists so an accidental
+-- double-click cannot burn the very 6s cooldown the gate is there to save; the
+-- upper bound expires the override again.
+TotemBar.RECALL_OVERRIDE_MIN = 0.5
+TotemBar.RECALL_OVERRIDE_MAX = 5
+
+-- 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
+
+-- Destroyed-totem GUID-liveness poll interval (seconds). Throttled
+-- independently of ui.lua's faster 0.1s display tick (see
+-- UpdateTimerDisplays) -- UnitExists/UnitHealth/UnitIsDeadOrGhost are cheap
+-- but there is no reason to call them 10x/sec once a GUID is latched. See
+-- "Destroyed-totem detection (GUID liveness)" below for the full design.
+TotemBar.GUID_LIVENESS_POLL_INTERVAL = 0.5
+
+-- 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 {}
+
+-- element -> the GetTime() a destroy-evicted record would naturally have
+-- expired at (its own start+duration). Set by ui.lua's liveness poll the
+-- moment it evicts a destroyed totem; read by resolveRemaining (via
+-- TotemBar.tombstoneActive) to stop trusting pfUI's libtotem for that
+-- element until then -- libtotem is ALSO just a blind duration timer (see
+-- "Destroyed-totem detection (GUID liveness)" below), so without this it
+-- keeps reporting the destroyed totem active and the countdown/ring/pulse
+-- reappear from the GTI branch even though activeTotems[element] was
+-- correctly cleared. Cleared the moment a real recordCast lands on the
+-- element again (see recordCast below) or once GetTime() passes the
+-- stored expiry (ui.lua sweeps it then) -- bounded to at most 4 keys
+-- either way, never a growing table.
+TotemBar.destroyedTombstone = TotemBar.destroyedTombstone 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, tombstoned)
+ -- 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).
+ --
+ -- FIX (destroyed-totem review, adversarial-review finding #1): `tombstoned`
+ -- (see TotemBar.tombstoneActive/TotemBar.destroyedTombstone) forces this to
+ -- skip the GTI branch entirely for a totem this addon just evicted as
+ -- destroyed. Without it, a destroy-eviction cleared ownRemaining/ownRecord
+ -- correctly but pfUI's libtotem -- itself just another blind duration timer,
+ -- see the header comment above TotemBar.shouldLatchGuidFromCastEvent below
+ -- -- kept reporting the SAME destroyed totem active until ITS OWN timer ran
+ -- out, so the countdown/ring/pulse reappeared from the GTI branch a moment
+ -- after this addon had just cleared them. tombstoned=nil/false (every
+ -- existing call site before this fix) reproduces the old behaviour exactly.
+ if not tombstoned and gtiActive and gtiRemaining and gtiRemaining > 0 then
+ return gtiRemaining
+ end
+ if ownRemaining and ownRemaining > 0 then
+ return ownRemaining
+ end
+ return nil
+end
+
+-- Pure: is element's destroyed-tombstone still in effect? tombstoneExpiry is
+-- TotemBar.destroyedTombstone[element] (nil if none), the GetTime() the
+-- destroy-evicted record would naturally have expired at. Strictly less-than:
+-- at the exact expiry moment GTI is trusted again (by then GTI's own blind
+-- timer would have run out too, so there is nothing left to falsely resurrect).
+function TotemBar.tombstoneActive(tombstoneExpiry, now)
+ if not tombstoneExpiry or not now then
+ return false
+ end
+ return now < tombstoneExpiry
+end
+
+-- Pure: should the out-of-range red tint treat this element as ACTIVE?
+--
+-- Own tracking is the base signal. When pfUI's libtotem is present its
+-- GetTotemInfo may VETO it, so a totem DESTROYED before its timer ran out
+-- stops flashing red -- but only for a slot libtotem demonstrably tracked
+-- (gtiTracked, latched in ui.lua when GTI reported the slot active under this
+-- record's totem name). "GTI has no record for this slot" is no information,
+-- not a contradiction: libtotem keeps a single non-slot-indexed cast queue
+-- committed on ONE SPELLCAST_STOP, so a four-totem drop leaves three slots
+-- with no GTI record at all. Vetoing on those silenced both the red tint and
+-- the Recall button's out-of-range pulse for most of the set -- the same
+-- missing-GTI race resolveRemaining above already fixed for the timer path.
+function TotemBar.rangeTintActive(hasOwnRecord, hasGTI, gtiTracked, gtiActive)
+ if not hasOwnRecord then
+ return false
+ end
+ if hasGTI and gtiTracked and not gtiActive then
+ return false
+ end
+ return true
+end
+
+-- ===== Destroyed-totem detection (GUID liveness) =====
+--
+-- Everything above (rangeTintActive included) can tell a totem is out of
+-- the PLAYER's own buff range, but nothing in this file -- nor pfUI's
+-- libtotem (checked against its source, pfUI/libs/libtotem.lua:
+-- GetTotemInfo's "active" flag is ALSO just a blind start+duration timer,
+-- cleared only on natural expiry or PLAYER_DEAD, never on the totem
+-- actually dying) -- can tell that a totem was DESTROYED (killed by an
+-- enemy, an AoE, a dispel) before its timer ran out. Fixing that needs the
+-- totem's own GUID, which recordCast cannot know at cast time (1.12 gives
+-- no return value naming the newly summoned unit); it is latched
+-- OPPORTUNISTICALLY after the fact by the event hooks further below, once a
+-- GUID becomes observable through the client's own SuperWoW telemetry:
+--
+-- 1. UNIT_CASTEVENT (SuperWoW): a totem that periodically CASTS its
+-- pulse (Fire Nova, Magma, Mana Spring, Healing Stream, Mana Tide, the
+-- Cleansing totems, ...) fires this with its own GUID as casterGUID
+-- the first time it pulses -- the same event core/pulsecal.lua already
+-- observes for calibration telemetry; this just also keeps the GUID.
+-- COVERAGE: pulsing totems only -- a totem that never casts anything
+-- never latches through this path. CONFIRMED (2026-08-20 event tap)
+-- that casterGUID (arg1) resolves via UnitName to the TOTEM's own name
+-- -- but the pulse SPELL's own name (arg4/SpellInfo) does NOT equal the
+-- totem's name (a Searing Totem's pulse is "Searing Bolt", Healing
+-- Stream Totem's is "Healing Stream") -- the original elementFromCastName
+-- exact-match-on-spell-name mapping was structurally unable to latch
+-- ANYTHING. Fixed: latch now maps through UnitName(arg1) (rank-tolerant,
+-- TotemBar.totemNameMatches), not the spell name at all. Also gated on
+-- the caster's resolved "owner" matching the player
+-- (TotemBar.shouldLatchGuidFromCastEvent) -- see that function's own
+-- comment for why (a stranger's same-named totem must never latch
+-- here); "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