commit 8dbc411e8032a4e566504309324ec8dff7098d4a
Author: ShempError <689+shemperror@noreply.octowow.st>
Date: Wed Aug 5 15:54:45 2026 +0200
TotemBar v0.2.5
diff --git a/Bindings.xml b/Bindings.xml
new file mode 100644
index 0000000..90dda88
--- /dev/null
+++ b/Bindings.xml
@@ -0,0 +1,34 @@
+
+ TotemBar.dropSetKey(keystate);
+ TotemBar.CastRecall();
+ TotemBar.ToggleBar();
+ TotemBar.ToggleOptions();
+ TotemBar.ToggleBindMode();
+ TotemBar.CastElement("Fire");
+ TotemBar.CastElement("Earth");
+ TotemBar.CastElement("Water");
+ TotemBar.CastElement("Air");
+ TotemBar.CastTotem("Searing Totem");
+ TotemBar.CastTotem("Fire Nova Totem");
+ TotemBar.CastTotem("Magma Totem");
+ TotemBar.CastTotem("Flametongue Totem");
+ TotemBar.CastTotem("Frost Resistance Totem");
+ TotemBar.CastTotem("Earthbind Totem");
+ TotemBar.CastTotem("Stoneclaw Totem");
+ TotemBar.CastTotem("Stoneskin Totem");
+ TotemBar.CastTotem("Strength of Earth Totem");
+ TotemBar.CastTotem("Tremor Totem");
+ TotemBar.CastTotem("Fire Resistance Totem");
+ TotemBar.CastTotem("Healing Stream Totem");
+ TotemBar.CastTotem("Mana Spring Totem");
+ TotemBar.CastTotem("Poison Cleansing Totem");
+ TotemBar.CastTotem("Disease Cleansing Totem");
+ TotemBar.CastTotem("Mana Tide Totem");
+ TotemBar.CastTotem("Grace of Air Totem");
+ TotemBar.CastTotem("Grounding Totem");
+ TotemBar.CastTotem("Nature Resistance Totem");
+ TotemBar.CastTotem("Sentry Totem");
+ TotemBar.CastTotem("Windfury Totem");
+ TotemBar.CastTotem("Windwall Totem");
+ TotemBar.CastTotem("Tranquil Air Totem");
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9b2c568
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,195 @@
+# Changelog
+
+All notable changes to TotemBar are documented here.
+
+## v0.2.5 — 2026-07-25
+
+### Added
+- **New option: "Show drop-all button".** The drop-all-totems button can now be
+ hidden from the bar, for anyone who drives it purely from a keybind or a macro
+ and would rather not spend the slot on it. On by default, so nothing changes
+ unless you turn it off.
+ - **The keybind keeps working while the button is hidden.** It is bound to the
+ cast function, not to the frame, and the entry stays listed in the Esc key
+ bindings menu — otherwise you could never assign it again.
+ - **The bar resizes instead of leaving a gap.** In the 1×6 layout it shrinks by
+ one slot. In 2×3 and 3×2 the frame keeps its size on purpose: Recall still
+ needs the last column, and shrinking there would break up the 2×2 block of
+ the four element totems. What remains is one empty grid cell in the trailing
+ corner — invisible, and it simply adds drag area.
+
+## v0.2.4 — 2026-07-14
+
+### Fixed
+- **Totemic Recall no longer refuses to fire after a /reload.** The "no totems
+ out — not recalling" guard could block a legitimate recall: TotemBar tracks
+ which totems you've dropped in memory only, and a `/reload` wipes that memory.
+ On 1.12 there is no `GetTotemInfo` / totem unit-id to re-detect a totem that
+ was placed before the reload, so the addon wrongly concluded nothing was out
+ and refused to recall. The guard now blocks **only** when it is certain nothing
+ is out (it tracked totems this session and they've all expired); after a
+ /reload the state is unknown, so it fails open and lets the recall through.
+ Worst case is one needless recall right after a reload — far better than a
+ blocked one.
+
+## v0.2.3 — 2026-07-13
+
+### Fixed
+- **Totemic Recall no longer sweeps away a freshly-placed set (nampower).**
+ With nampower installed, dropping your set while the global cooldown was
+ active could make the new totems appear and then instantly vanish: nampower
+ queued Totemic Recall behind the GCD, so it fired *after* the (off-GCD)
+ totems were placed and pulled them right back down. TotemBar now casts
+ Recall via `CastSpellByNameNoQueue` when nampower provides it, so a
+ GCD-blocked Recall is skipped for that press instead of firing late. No
+ effect for setups without nampower.
+
+### Changed
+- **The drop-set keybind now recalls on key-down and places on key-release.**
+ This keeps the recall and the totem placements as separate hardware events;
+ for a normal tap the difference is imperceptible.
+
+## v0.2.2 — 2026-07-10
+
+### Added
+- **"Button spacing" slider** in the options panel (10–30 px) — adjusts the
+ gap between bar buttons live, including the flyout and assignment panel.
+
+### Fixed
+- **Keybind casts now start TotemBar's own duration/pulse timers.**
+ Per-totem and element key bindings call the same tracking as click-casts.
+ Previously, setups without pfUI got no timer/ring/pulse feedback for
+ keybind-cast totems, and even with pfUI the spawn ripple and out-of-range
+ tint were missing for them.
+
+### Removed
+- **Dead `pulseGlow` setting** — the anticipation glow is part of "Show
+ pulse waves"; stale saved values are cleaned up automatically.
+
+## v0.2.1 — 2026-07-10
+
+### Fixed
+- **Tooltips showed rank 1 instead of your highest rank.** Both totem
+ tooltips (element buttons and the flyout) resolved the spell by its first
+ spellbook match — which is rank 1 — so mana cost, damage and duration read
+ far too low. Display-only: the actual cast is name-based and always used
+ your highest rank. Tooltips now resolve to the highest known rank **and
+ show the rank** next to the spell name (vanilla's tooltip omits it).
+
+## v0.2.0 — 2026-07-10
+
+**The Pulse UI release** — a complete visual rework of the bar around one idea:
+you should see at a glance *how long* each totem still stands and *when* it
+pulses next.
+
+### Added
+- **Round floating slots.** Buttons are free-floating round icons in a subtle
+ shadow-rim frame — the square boxes and backdrops are gone. Flyout, Recall
+ and drop-set share the round look (round hover/pressed states included).
+- **Duration ring (time traffic light).** The outer ring drains with the
+ totem's remaining lifetime and blends green → yellow → orange → red as time
+ runs out.
+- **Pulse ring.** A thin inner arc in the totem's element color fills toward
+ the next pulse and resets when it fires — animated per frame, no stutter.
+- **Server-accurate pulse timing.** Intervals and first-tick behavior come
+ from TurtleWoW server data, not guesswork: e.g. Tremor pulses every **4s**
+ (not the commonly cited 3s) and ticks **immediately** on placement, while
+ Magma/Healing Stream/Mana Spring first pulse one full interval after
+ placement. Water totems additionally re-anchor on the real observed
+ mana/heal ticks.
+- **Pulse waves & anticipation glow.** An expanding ripple in the element
+ color marks each pulse; a soft halo builds around the ring during the last
+ stretch before it. A spawn ripple confirms every placement.
+- **Bar layouts.** Cycle between 1×6, 2×3 (elements grouped as a 2×2 block)
+ and 3×2 in the options panel.
+- **Custom art set.** Element glyphs for empty slots (no more question
+ marks), a four-element drop-set icon, a minimap glyph, a custom options
+ panel skin with rounded corners, and grounding shadow plates under the
+ slots. All art is generated by scripts in `tools/` — reproducible, no
+ binary-only assets.
+- **New options:** Show duration ring · Show pulse ring · Show pulse waves ·
+ Show countdown text · Layout.
+- **`/tb pulsecal` (dev aid):** captures pulse-timing telemetry to
+ `imports\totembar_pulsecal.txt` (SuperWoW) for verifying timings in-game.
+
+### Changed
+- Button spacing widened (4 → 10 px) to give the ring frames air; multi-row
+ layouts use the same tight pitch vertically.
+- Options panel restyled with the custom skin and section dividers.
+- The remaining-time countdown text is now optional (on by default).
+
+## v0.1.3 — 2026-07-09
+
+### Fixed
+- **Totemic Recall no longer wastes its 6-second cooldown.** Recall — via the
+ button, its keybinding, or the "Totems" macro's auto-recall — now only fires
+ when you actually have totems out. Casting it with nothing down put Recall on
+ its 6s cooldown, so a set placed right after couldn't be recalled for 6
+ seconds. With nothing out, TotemBar now skips the cast (with a brief note).
+
+## v0.1.2 — 2026-07-09
+
+### Added
+- **Mana cost on the drop-set tooltip** — hovering the drop-set button (and the
+ "Totems" macro action) shows the total mana to drop your four chosen totems.
+- **Refund on the Totemic Recall tooltip** — shows the mana you'll get back,
+ computed live from the totems currently out (expired ones are excluded) and a
+ refund percentage that is learned from your real recall mana-gain (defaults to
+ 25% until learned).
+- **Totemic Mastery duration** — when the talent is skilled, helpful totems'
+ own-tracked timers get the +20% duration so they no longer read short.
+- `/tb manadump` dev aid — writes the raw mana-cost scan (resolved rank, tooltip
+ lines, parsed cost) to `imports\totembar_manadump.txt`.
+
+### Fixed
+- Totem mana costs were read from **rank 1** instead of the highest rank you
+ actually cast, so the displayed mana was far too low. The scan now resolves
+ the highest known rank.
+
+## v0.1.1 — 2026-07-09
+
+### Added
+- **Drop-set button** on the bar (right of the Totemic Recall button): one
+ left-click casts all four chosen totems (the same action as the "Totems"
+ macro, with the 2-second double-press guard).
+- **Key bindings** — a **TotemBar** section in the Esc → Key Bindings menu:
+ Drop all totems, Totemic Recall, Toggle bar, Toggle options, Toggle key-bind
+ mode, Cast *element* totem (chosen, one per element), and — under per-element
+ sub-sections — a binding to cast **every individual totem** (Cast Searing
+ Totem, Cast Windfury Totem, …).
+- **Hover-bind mode** (`/tb bind`, or the options panel's "Key bind mode"
+ button): hover any bar button or a flyout totem and press a key to bind it
+ (a button binds its action; a flyout totem binds that specific totem). ESC
+ over a button clears its key; ESC over empty space (or the options button)
+ exits. A "Key-Bind Mode ACTIVE" box is shown while active. The
+ currently-bound key is displayed on each button at all times (abbreviated,
+ e.g. `N7` for Num Pad 7). Bindings are saved by the game and persist.
+
+### Notes
+- Totemic Recall cast **deliberately** (the Recall button, or its keybinding)
+ is never guarded — only the Totems macro's built-in recall respects the 2s
+ double-press guard.
+
+## v0.1.0 — 2026-07-09
+
+Initial public release.
+
+### Added
+- Totem bar: four element buttons + a Totemic Recall button. Left-click an
+ element casts its chosen totem; right-click clears the slot. Hover an element
+ for a flyout of the element's other known totems (left-click to cast once,
+ right-click to set as the new default).
+- One-press "Totems" macro (`TotemBar.recallAndCastAll()`) with a 2-second
+ double-press recall guard (a rapid second press won't recall the totems you
+ just dropped).
+- Native cooldown swipes + OmniCC-style duration timers; out-of-range red tint
+ (buff-presence based); the Recall button pulses when a totem is out of range.
+- Real in-game spell tooltips on the element buttons and the flyout.
+- Minimap button (orbit-draggable; left-click = options, right-click = toggle
+ bar); pfUI-safe, with `/tb options` as a fallback.
+- Options panel: lock bar, auto-recall, show bar, recall guard, cycle-reset
+ gap, UI size (scales the bar, anchored top-left), reset position, create
+ "Totems" macro, and per-setting tooltips. Adopts pfUI's look when pfUI is
+ installed, a clean Blizzard look otherwise.
+- Assignment-receiver API (`TotemBar.ReceiveAssignment`) for a future raid
+ totem-assignment addon — see [`docs/API.md`](docs/API.md).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e1bc95c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 ShempError
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9c25184
--- /dev/null
+++ b/README.md
@@ -0,0 +1,205 @@
+# TotemBar
+
+A lightweight shaman totem bar for **TurtleWoW / WoW 1.12.1 (Vanilla)**.
+
+Pick one totem per element, cast it with a click, cycle alternatives from a
+hover flyout, or drop all four with a single press. Every slot shows **how
+long the totem still stands** (outer ring, green → red) and **when it pulses
+next** (inner ring in the element's color) — with timings taken from actual
+server data, not guesswork. No dependencies; adopts the
+[pfUI](https://github.com/shagu/pfUI) look automatically when present.
+
+
+
+## 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
+-- \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
diff --git a/core/config.lua b/core/config.lua
new file mode 100644
index 0000000..1dfff24
--- /dev/null
+++ b/core/config.lua
@@ -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
diff --git a/core/known.lua b/core/known.lua
new file mode 100644
index 0000000..6062118
--- /dev/null
+++ b/core/known.lua
@@ -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.
diff --git a/core/manacost.lua b/core/manacost.lua
new file mode 100644
index 0000000..c36c767
--- /dev/null
+++ b/core/manacost.lua
@@ -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 \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
diff --git a/core/optionslogic.lua b/core/optionslogic.lua
new file mode 100644
index 0000000..24e8457
--- /dev/null
+++ b/core/optionslogic.lua
@@ -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
diff --git a/core/pulse.lua b/core/pulse.lua
new file mode 100644
index 0000000..835e3ce
--- /dev/null
+++ b/core/pulse.lua
@@ -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
diff --git a/core/pulsecal.lua b/core/pulsecal.lua
new file mode 100644
index 0000000..dddf2ee
--- /dev/null
+++ b/core/pulsecal.lua
@@ -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
diff --git a/core/pulsedata.lua b/core/pulsedata.lua
new file mode 100644
index 0000000..60462b6
--- /dev/null
+++ b/core/pulsedata.lua
@@ -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
diff --git a/core/pulseparse.lua b/core/pulseparse.lua
new file mode 100644
index 0000000..904046a
--- /dev/null
+++ b/core/pulseparse.lua
@@ -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
diff --git a/core/spellindex.lua b/core/spellindex.lua
new file mode 100644
index 0000000..0eda29e
--- /dev/null
+++ b/core/spellindex.lua
@@ -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
diff --git a/core/totemdata.lua b/core/totemdata.lua
new file mode 100644
index 0000000..d55bf5c
--- /dev/null
+++ b/core/totemdata.lua
@@ -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
diff --git a/minimap.lua b/minimap.lua
new file mode 100644
index 0000000..c5de5b0
--- /dev/null
+++ b/minimap.lua
@@ -0,0 +1,123 @@
+-- TotemBar - minimap.lua
+-- Hand-rolled minimap button (no LibDBIcon on 1.12). Orbits the minimap at a
+-- saved angle, drag-repositions (angle persisted), left-click opens the
+-- options panel, right-click toggles the bar. pfUI-safe: parent = Minimap,
+-- name contains "Minimap", FrameStrata HIGH + level 9 (MEDIUM hides under
+-- pfUI). /tb options is the guaranteed access path if pfUI collects/hides
+-- the button. WoW-API file (parse-checked only).
+
+TotemBar = TotemBar or {}
+
+local button = nil
+
+-- Custom icon sheet (tools/gen_icons.js, same texture ui.lua's
+-- ICONS_TEXTURE points at): 4x4 grid of 128px cells, cell 7 = minimap
+-- totem glyph (transparent backing). core/pulse.lua loads before this
+-- file (see the .toc), so buildRingTexCoords is already available; built
+-- once here at load, not per-tick.
+local MINIMAP_ICON_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
+local MINIMAP_ICON_CELL = 7
+local minimapIconCoords = TotemBar.buildRingTexCoords(16, 4)
+
+-- Repositions the button on its orbit for the given angle (degrees).
+local function PlaceButton(angleDeg)
+ if not button then
+ return
+ end
+ local radius = (Minimap:GetWidth() / 2) + 5
+ local x, y = TotemBar.angleToOffset(angleDeg, radius)
+ button:ClearAllPoints()
+ button:SetPoint("CENTER", Minimap, "CENTER", x, y)
+end
+
+local function BuildMinimapButton()
+ if button then
+ return
+ end
+
+ local btn = CreateFrame("Button", "TotemBarMinimapButton", Minimap)
+ btn:SetWidth(33)
+ btn:SetHeight(33)
+ btn:SetFrameStrata("HIGH")
+ btn:SetFrameLevel(9)
+
+ local icon = btn:CreateTexture(nil, "ARTWORK")
+ icon:SetTexture(MINIMAP_ICON_TEXTURE)
+ local ic = minimapIconCoords[MINIMAP_ICON_CELL]
+ icon:SetTexCoord(ic.l, ic.r, ic.t, ic.b)
+ icon:SetWidth(20)
+ icon:SetHeight(20)
+ icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
+
+ local border = btn:CreateTexture(nil, "OVERLAY")
+ border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
+ -- Vanilla default-UI recipe (MiniMapTrackingButton in FrameXML): a 54px
+ -- border anchored TOPLEFT(0,0) on a 33px button. The ring is deliberately
+ -- off-centre WITHIN the texture, so it lands concentric with the icon at
+ -- this exact anchor/size - do NOT "centre" it (that pushes the ring off).
+ border:SetWidth(54)
+ border:SetHeight(54)
+ border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
+
+ btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
+
+ btn.angle = TotemBarDB.minimapAngle or 225
+
+ -- Drag (repositions around the orbit). Drag needs the movement threshold,
+ -- so a plain click still fires OnClick; the two coexist (verified 1.12
+ -- pattern).
+ btn:RegisterForDrag("LeftButton")
+ btn.isDragging = false
+ btn:SetScript("OnDragStart", function() this.isDragging = true end)
+ btn:SetScript("OnDragStop", function()
+ this.isDragging = false
+ TotemBarDB.minimapAngle = this.angle
+ end)
+ btn:SetScript("OnUpdate", function()
+ if not this.isDragging then
+ return
+ end
+ local mx, my = GetCursorPosition()
+ local scale = Minimap:GetEffectiveScale()
+ mx = mx / scale
+ my = my / scale
+ local cx, cy = Minimap:GetCenter()
+ this.angle = math.deg(math.atan2(my - cy, mx - cx))
+ PlaceButton(this.angle)
+ end)
+
+ -- Clicks: left = options, right = toggle bar.
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ btn:SetScript("OnClick", function()
+ if arg1 == "RightButton" then
+ if TotemBar.ToggleBar then TotemBar.ToggleBar() end
+ else
+ if TotemBar.ToggleOptions then TotemBar.ToggleOptions() end
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_LEFT")
+ GameTooltip:SetText("TotemBar")
+ GameTooltip:AddLine("Left-click: Options", 0.9, 0.9, 0.9)
+ GameTooltip:AddLine("Right-click: Toggle bar", 0.9, 0.9, 0.9)
+ GameTooltip:AddLine("Drag: reposition", 0.9, 0.9, 0.9)
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
+
+ button = btn
+ PlaceButton(btn.angle)
+end
+
+-- Build on ADDON_LOADED (SavedVariables are populated by then, same as the
+-- bar). Guarded; never on PLAYER_ENTERING_WORLD (would re-run and leak
+-- textures).
+local ev = CreateFrame("Frame", "TotemBarMinimapEventFrame", UIParent)
+ev:RegisterEvent("ADDON_LOADED")
+ev:SetScript("OnEvent", function()
+ if event == "ADDON_LOADED" and arg1 == "TotemBar" then
+ BuildMinimapButton()
+ ev:UnregisterEvent("ADDON_LOADED")
+ end
+end)
diff --git a/options.lua b/options.lua
new file mode 100644
index 0000000..dc11a94
--- /dev/null
+++ b/options.lua
@@ -0,0 +1,456 @@
+-- TotemBar - options.lua
+-- Standalone options panel (no Blizzard InterfaceOptions integration). Built
+-- once (lazy) on first ToggleOptions; repopulated from TotemBarDB on show.
+-- Opened by the minimap button's left-click and by /tb options. WoW-API
+-- file (parse-checked only). Widget wiring uses 1.12 templates:
+-- UICheckButtonTemplate (GetChecked() -> 1/nil) and OptionsSliderTemplate
+-- (SetMinMaxValues before SetValue; $parentLow/$parentHigh/$parentText).
+
+TotemBar = TotemBar or {}
+
+local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+local optionsFrame = nil
+
+-- Applies pfUI's font to a FontString (the main "matches pfUI" tweak) at the
+-- given size, or falls back to a small game font when pfUI is absent.
+local function ApplyFont(fontString, size)
+ if not fontString then
+ return
+ end
+ if pfUI and pfUI.font_default then
+ local sz = size or (pfUI_config and pfUI_config.global and pfUI_config.global.font_size) or 12
+ fontString:SetFont(pfUI.font_default, sz)
+ else
+ fontString:SetFontObject(GameFontNormalSmall)
+ end
+end
+
+-- Adds a wrapped explanatory GameTooltip on hover. Chains any existing
+-- OnEnter/OnLeave (e.g. pfUI's SkinButton hover-highlight) instead of
+-- replacing it, so skinning still works. No HookScript on 1.12.
+local function AddTooltip(widget, text)
+ if not widget or not text then
+ return
+ end
+ widget.tbTip = text
+ local oldEnter = widget:GetScript("OnEnter")
+ local oldLeave = widget:GetScript("OnLeave")
+ widget:SetScript("OnEnter", function()
+ if oldEnter then oldEnter() end
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ GameTooltip:SetText(this.tbTip, 1, 1, 1, 1, 1)
+ GameTooltip:Show()
+ end)
+ widget:SetScript("OnLeave", function()
+ if oldLeave then oldLeave() end
+ GameTooltip:Hide()
+ end)
+end
+
+-- Unique widget-name counters (template child labels resolve via getglobal,
+-- so every widget needs a non-nil, unique frame name).
+local cbIndex = 0
+local slIndex = 0
+
+-- Factory: a labeled checkbox wired to getter/setter. getter()->boolean,
+-- setter(boolean). Returns the CheckButton.
+local function CreateCheckbox(parent, label, getter, setter)
+ cbIndex = cbIndex + 1
+ local name = "TotemBarOptCheck" .. cbIndex
+ local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
+ cb:SetWidth(24)
+ cb:SetHeight(24)
+ local lbl = getglobal(name .. "Text")
+ if lbl then
+ lbl:SetText(label)
+ ApplyFont(lbl)
+ end
+ cb.tbGet = getter
+ cb.tbSet = setter
+ cb:SetScript("OnClick", function()
+ this.tbSet(this:GetChecked() == 1)
+ end)
+ if pfUI and pfUI.api and pfUI.api.SkinCheckbox then
+ pfUI.api.SkinCheckbox(cb)
+ end
+ return cb
+end
+
+-- Factory: a labeled slider wired to getter/setter over [minVal,maxVal] in
+-- `step` increments. `fmt` is a printf pattern for the live value text
+-- (e.g. "UI size: %.2f"). Returns the Slider.
+local function CreateSlider(parent, label, minVal, maxVal, step, fmt, getter, setter)
+ slIndex = slIndex + 1
+ local name = "TotemBarOptSlider" .. slIndex
+ local sl = CreateFrame("Slider", name, parent, "OptionsSliderTemplate")
+ sl:SetWidth(200)
+ sl:SetHeight(16)
+ sl:SetMinMaxValues(minVal, maxVal) -- BEFORE SetValue, else clamps to 0
+ sl:SetValueStep(step)
+ local low, high, txt = getglobal(name .. "Low"), getglobal(name .. "High"), getglobal(name .. "Text")
+ if low then low:SetText(tostring(minVal)); ApplyFont(low) end
+ if high then high:SetText(tostring(maxVal)); ApplyFont(high) end
+ if txt then
+ -- Left-align the value label above the slider (the template centers it),
+ -- so it lines up with the left-aligned checkbox labels. pfUI font.
+ txt:ClearAllPoints()
+ txt:SetPoint("BOTTOMLEFT", sl, "TOPLEFT", 0, 2)
+ txt:SetJustifyH("LEFT")
+ ApplyFont(txt)
+ end
+ sl.tbLabel = label
+ sl.tbFmt = fmt
+ sl.tbText = txt
+ sl.tbSet = setter
+ sl.tbGet = getter
+ sl:SetScript("OnValueChanged", function()
+ local v = this:GetValue()
+ if this.tbText then
+ this.tbText:SetText(string.format(this.tbFmt, v))
+ end
+ this.tbSet(v)
+ end)
+ if pfUI and pfUI.api and pfUI.api.SkinSlider then
+ pfUI.api.SkinSlider(sl)
+ end
+ return sl
+end
+
+-- Label text for the bar-layout cycle button, reflecting the current choice.
+local function BarLayoutLabel()
+ return "Layout: " .. (TotemBarDB.barLayout or "1x6")
+end
+
+-- Factory: a labeled push button wired to an onClick. Returns the Button.
+-- Named (project convention: name all frames) so the pfDebug profiler can attribute it.
+local function CreateButton(parent, name, label, onClick)
+ local btn = CreateFrame("Button", name, parent, "UIPanelButtonTemplate")
+ btn:SetWidth(140)
+ btn:SetHeight(22)
+ btn:SetText(label)
+ btn:SetScript("OnClick", onClick)
+ if pfUI and pfUI.api and pfUI.api.SkinButton then
+ pfUI.api.SkinButton(btn)
+ end
+ return btn
+end
+
+-- Factory: a static hairline divider spanning the panel's content width at
+-- cursor position y (Rev 4 UI chrome, section-break decoration). Anonymous
+-- texture - it's a static decoration, not an interactive/named widget.
+local function CreateDivider(parent, y)
+ local d = parent:CreateTexture(nil, "ARTWORK")
+ d:SetTexture(TotemBar.UI_TEXTURE)
+ local c = TotemBar.uiCoords[TotemBar.UI_DIVIDER_CELL]
+ d:SetTexCoord(c.l, c.r, c.t, c.b)
+ d:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, y)
+ d:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -14, y)
+ d:SetHeight(7)
+ return d
+end
+
+-- All widgets, stored so OnShow can repopulate them from TotemBarDB.
+local widgets = {}
+
+local function BuildOptionsFrame()
+ if optionsFrame then
+ return optionsFrame
+ end
+
+ local f = CreateFrame("Frame", "TotemBarOptionsFrame", UIParent)
+ f:SetWidth(280)
+ -- 472 + 44 + 28 + 28 + 44 + 28: room for the header emblem (+16, taller
+ -- than the old title-only header), two section dividers (+14 each), the
+ -- "Show pulse waves" checkbox row (+28, the pulse ring's countdown
+ -- readout sits alongside the ripple), the bar-layout cycle button row
+ -- (+28), the "Button spacing" slider row (+44, same slider-row
+ -- height as guard/gap/scale above it), and the "Show drop-all button"
+ -- checkbox row (+28) - see the layout-cursor deltas below (same
+ -- mechanism as the Task-5 height bump). Not cosmetic: without the last
+ -- +28 the "Key bind mode" button would land on the version footer.
+ f:SetHeight(628)
+ f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+ f:SetFrameStrata("DIALOG")
+ -- Rev 4 UI chrome: shared bespoke panel skin (see ui.lua's PANEL_BACKDROP)
+ -- instead of the Blizzard dialog backdrop or pfUI's generic skin, so the
+ -- options panel matches the bar's own art (emblem/dividers below pair
+ -- with this border/bg specifically).
+ f:SetBackdrop(TotemBar.PANEL_BACKDROP)
+ f:SetBackdropColor(1, 1, 1, 0.97)
+ f:SetMovable(true)
+ f:EnableMouse(true)
+ f:RegisterForDrag("LeftButton")
+ f:SetScript("OnDragStart", function() this:StartMoving() end)
+ f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
+ f:SetClampedToScreen(true)
+
+ -- Plain gold title, no emblem (the Rev-4 totem glyph was removed on
+ -- user request - the silhouette read, well, unfortunate).
+ local title = f:CreateFontString("TotemBarOptionsTitle", "OVERLAY", "GameFontNormal")
+ title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16)
+ title:SetText("TotemBar Options")
+ ApplyFont(title, 14)
+ title:SetTextColor(0.85, 0.66, 0.31)
+
+ local close = CreateFrame("Button", "TotemBarOptionsClose", f, "UIPanelCloseButton")
+ close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
+ if pfUI and pfUI.api and pfUI.api.SkinCloseButton then
+ pfUI.api.SkinCloseButton(close, f)
+ end
+
+ -- Layout cursor (back to the title-only header spacing).
+ local x = 24
+ local y = -44
+ local function place(w, dy)
+ w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
+ y = y + dy
+ end
+
+ -- Checkboxes.
+ widgets.lock = CreateCheckbox(f, "Lock bar",
+ function() return TotemBarDB.locked end,
+ function(v) TotemBarDB.locked = v end)
+ place(widgets.lock, -28)
+ AddTooltip(widgets.lock, "Locks the bar in place so you can't drag it by accident. Uncheck to reposition it.")
+
+ widgets.autoRecall = CreateCheckbox(f, "Auto-recall before setting",
+ function() return TotemBarDB.autoRecall end,
+ function(v)
+ TotemBarDB.autoRecall = v
+ if TotemBar.RefreshRecallIndicator then TotemBar.RefreshRecallIndicator() end
+ end)
+ place(widgets.autoRecall, -28)
+ AddTooltip(widgets.autoRecall, "When on, the Totems macro casts Totemic Recall before re-dropping your totems (refunds some mana when relocating).")
+
+ widgets.show = CreateCheckbox(f, "Show bar",
+ function() return not TotemBarDB.hidden end,
+ function(v)
+ if v then
+ TotemBarDB.hidden = false
+ if TotemBarFrame then TotemBarFrame:Show() end
+ else
+ TotemBarDB.hidden = true
+ if TotemBarFrame then TotemBarFrame:Hide() end
+ end
+ end)
+ place(widgets.show, -28)
+ AddTooltip(widgets.show, "Shows or hides the totem bar. The choice is remembered between sessions.")
+
+ -- Same kind of switch as "Show bar" above (visibility only, not a
+ -- feature toggle), hence the same group. ApplyBarLayout hides/shows the
+ -- button AND re-lays out the grid, so the bar resizes immediately -
+ -- no /reload needed.
+ widgets.dropSet = CreateCheckbox(f, "Show drop-all button",
+ function() return TotemBarDB.showDropSetButton end,
+ function(v)
+ TotemBarDB.showDropSetButton = v
+ if TotemBar.ApplyBarLayout then TotemBar.ApplyBarLayout() end
+ end)
+ place(widgets.dropSet, -40)
+ AddTooltip(widgets.dropSet, "Shows or hides the 'Drop all totems' button on the bar. Its key binding keeps working while it is hidden - rebind it under Key Bindings in the game menu.")
+
+ widgets.ring = CreateCheckbox(f, "Show duration ring",
+ function() return TotemBarDB.showDurationRing end,
+ function(v)
+ TotemBarDB.showDurationRing = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.ring, -28)
+ AddTooltip(widgets.ring, "Shows a colored arc on each button's ring that empties as the totem's remaining time runs out. The ring frame itself always stays visible.")
+
+ widgets.pulse = CreateCheckbox(f, "Show pulse ring",
+ function() return TotemBarDB.showPulseBars end,
+ function(v)
+ TotemBarDB.showPulseBars = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.pulse, -28)
+ AddTooltip(widgets.pulse, "Shows a thin inner ring around the icon that fills up toward the totem's next pulse, then resets.")
+
+ widgets.pulseWaves = CreateCheckbox(f, "Show pulse waves",
+ function() return TotemBarDB.showPulseWaves end,
+ function(v)
+ TotemBarDB.showPulseWaves = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.pulseWaves, -28)
+ AddTooltip(widgets.pulseWaves, "Shows an expanding ripple in the totem's color each time a pulsing totem fires.")
+
+ widgets.timerText = CreateCheckbox(f, "Show countdown text",
+ function() return TotemBarDB.showTimerText end,
+ function(v)
+ TotemBarDB.showTimerText = v
+ if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
+ end)
+ place(widgets.timerText, -28)
+ AddTooltip(widgets.timerText, "Shows the remaining-seconds text under each element button (the ring shows the same information graphically).")
+
+ -- Bar-layout cycle button: click steps through TotemBar.BAR_LAYOUTS
+ -- (wrapping), saves the choice, and re-lays out the live bar.
+ widgets.layout = CreateButton(f, "TotemBarOptLayoutButton", BarLayoutLabel(), function()
+ local layouts = TotemBar.BAR_LAYOUTS
+ local n = table.getn(layouts)
+ local current = TotemBarDB.barLayout or "1x6"
+ local currentIdx = 1
+ for i = 1, n do
+ if layouts[i] == current then
+ currentIdx = i
+ end
+ end
+ local nextIdx = math.mod(currentIdx, n) + 1
+ TotemBarDB.barLayout = layouts[nextIdx]
+ this:SetText(BarLayoutLabel())
+ if TotemBar.ApplyBarLayout then
+ TotemBar.ApplyBarLayout()
+ end
+ end)
+ place(widgets.layout, -28)
+ AddTooltip(widgets.layout, "Cycles the bar arrangement: one row of six, two rows of three, or three rows of two.")
+
+ -- Section divider (Rev 4 UI chrome): checkboxes above, sliders below.
+ CreateDivider(f, y)
+ y = y - 14
+
+ -- Sliders (leave headroom below each for its low/high/value text).
+ widgets.guard = CreateSlider(f, "Recall guard (sec)", 0, 5, 0.5, "Recall guard: %.1fs",
+ function() return TotemBarDB.recallGuardSeconds end,
+ function(v) TotemBarDB.recallGuardSeconds = v end)
+ place(widgets.guard, -44)
+ AddTooltip(widgets.guard, "A second press of the Totems macro within this many seconds skips Totemic Recall, so a double-press won't pull the totems you just dropped. 0 = no guard.")
+
+ widgets.gap = CreateSlider(f, "Cycle reset gap (sec)", 0.5, 5, 0.5, "Cycle gap: %.1fs",
+ function() return TotemBarDB.gapSeconds end,
+ function(v) TotemBarDB.gapSeconds = v end)
+ place(widgets.gap, -44)
+ AddTooltip(widgets.gap, "How long a pause (in seconds) restarts the one-per-press cast cycle back at the first totem.")
+
+ widgets.scale = CreateSlider(f, "UI size", 0.5, 2.0, 0.05, "UI size: %.2f",
+ function() return TotemBarDB.scale end,
+ function(v)
+ if TotemBar.SetBarScale then
+ TotemBar.SetBarScale(v)
+ elseif TotemBarFrame then
+ TotemBarDB.scale = v
+ TotemBarFrame:SetScale(v)
+ end
+ end)
+ place(widgets.scale, -44)
+ AddTooltip(widgets.scale, "Scales the whole bar. It stays anchored at its top-left corner, and the flyout scales with it.")
+
+ widgets.buttonGap = CreateSlider(f, "Button spacing", 10, 30, 1, "Spacing: %.0fpx",
+ function() return TotemBarDB.buttonGap end,
+ function(v)
+ if TotemBar.SetButtonGap then
+ TotemBar.SetButtonGap(v)
+ else
+ TotemBarDB.buttonGap = v
+ end
+ end)
+ place(widgets.buttonGap, -44)
+ AddTooltip(widgets.buttonGap, "Distance between the bar buttons. Applies immediately.")
+
+ -- Section divider (Rev 4 UI chrome): sliders above, action buttons below.
+ CreateDivider(f, y)
+ y = y - 14
+
+ optionsFrame = f
+ -- Buttons (Reset position, Create macro) are added in Task 4.
+ if TotemBar.BuildOptionsButtons then
+ TotemBar.BuildOptionsButtons(f, x, y)
+ end
+
+ -- Repopulate every widget from TotemBarDB whenever the panel shows.
+ f:SetScript("OnShow", function()
+ widgets.lock:SetChecked(TotemBarDB.locked and 1 or nil)
+ widgets.autoRecall:SetChecked(TotemBarDB.autoRecall and 1 or nil)
+ widgets.show:SetChecked((not TotemBarDB.hidden) and 1 or nil)
+ widgets.dropSet:SetChecked(TotemBarDB.showDropSetButton and 1 or nil)
+ widgets.ring:SetChecked(TotemBarDB.showDurationRing and 1 or nil)
+ widgets.pulse:SetChecked(TotemBarDB.showPulseBars and 1 or nil)
+ widgets.pulseWaves:SetChecked(TotemBarDB.showPulseWaves and 1 or nil)
+ widgets.timerText:SetChecked(TotemBarDB.showTimerText and 1 or nil)
+ widgets.layout:SetText(BarLayoutLabel())
+ widgets.guard:SetValue(TotemBar.clampValue(TotemBarDB.recallGuardSeconds, 0, 5))
+ widgets.gap:SetValue(TotemBar.clampValue(TotemBarDB.gapSeconds, 0.5, 5))
+ widgets.scale:SetValue(TotemBar.clampValue(TotemBarDB.scale, 0.5, 2.0))
+ widgets.buttonGap:SetValue(TotemBar.clampValue(TotemBarDB.buttonGap, 10, 30))
+ end)
+
+ -- Version footer (bottom-centre), read from the .toc so it always matches
+ -- the real addon version instead of a hardcoded duplicate.
+ local ver = (GetAddOnMetadata and GetAddOnMetadata("TotemBar", "Version")) or "0.1.0"
+ local verFS = f:CreateFontString("TotemBarOptionsVersion", "OVERLAY")
+ ApplyFont(verFS, 10)
+ verFS:SetPoint("BOTTOM", f, "BOTTOM", 0, 6)
+ verFS:SetText("v" .. ver)
+ verFS:SetTextColor(0.6, 0.6, 0.6)
+
+ -- ESC closes it.
+ tinsert(UISpecialFrames, "TotemBarOptionsFrame")
+
+ f:Hide()
+ return f
+end
+
+-- Creates or updates the "Totems" convenience macro from TotemBar.macroSpec.
+-- General (account) macro, capped at 18 in 1.12; if the cap is full and no
+-- "Totems" macro exists yet, reports and does nothing. Never auto-places it
+-- on the action bar (no 1.12 API for that - the player drags it).
+local function ApplyTotemsMacro()
+ local name, body, icon = TotemBar.macroSpec()
+ local existing = GetMacroIndexByName(name)
+ if existing and existing > 0 then
+ EditMacro(existing, name, icon, body, 1, nil)
+ ChatOut:AddMessage("TotemBar: '" .. name .. "' macro updated - drag it to your action bar.")
+ return
+ end
+ local numGlobal = GetNumMacros() -- returns global, perChar in 1.12
+ if numGlobal and numGlobal >= 18 then
+ ChatOut:AddMessage("TotemBar: macro slots full (18) - free one and retry.")
+ return
+ end
+ CreateMacro(name, icon, body, nil)
+ ChatOut:AddMessage("TotemBar: '" .. name .. "' macro created - drag it to your action bar.")
+end
+
+-- Adds the Reset-position and Create-macro buttons to the options frame.
+-- Called by BuildOptionsFrame (Task 3) after the sliders, at layout cursor
+-- (x, yStart). Kept separate so the panel body and the action buttons are
+-- two focused units.
+function TotemBar.BuildOptionsButtons(f, x, yStart)
+ -- Full content width (frame width minus the left/right margin x), so the
+ -- buttons read as a balanced block instead of a narrow left-biased pair.
+ local w = f:GetWidth() - (x * 2)
+
+ local reset = CreateButton(f, "TotemBarOptResetButton", "Reset position", function()
+ if TotemBar.ResetPosition then TotemBar.ResetPosition() end
+ end)
+ reset:SetWidth(w)
+ reset:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart)
+ AddTooltip(reset, "Moves the bar back to the center of the screen.")
+
+ local macro = CreateButton(f, "TotemBarOptMacroButton", "Create 'Totems' macro", function()
+ ApplyTotemsMacro()
+ end)
+ macro:SetWidth(w)
+ macro:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 28)
+ AddTooltip(macro, "Creates or updates a 'Totems' macro that drops all four chosen totems in one press. Drag it from the macro window to your action bar.")
+
+ local bind = CreateButton(f, "TotemBarOptBindButton", "Key bind mode", function()
+ if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
+ end)
+ bind:SetWidth(w)
+ bind:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 56)
+ AddTooltip(bind, "Toggle bind mode, then hover any bar button or a flyout totem and press a key to bind it. ESC clears. Bindings are saved automatically.")
+end
+
+-- Show/hide the options panel (builds it lazily). Left-click on the minimap
+-- button and /tb options both call this.
+function TotemBar.ToggleOptions()
+ local f = BuildOptionsFrame()
+ if f:IsShown() then
+ f:Hide()
+ else
+ f:Show()
+ end
+end
diff --git a/screenshots/bar.png b/screenshots/bar.png
new file mode 100644
index 0000000..697eaeb
Binary files /dev/null and b/screenshots/bar.png differ
diff --git a/screenshots/bind-mode.png b/screenshots/bind-mode.png
new file mode 100644
index 0000000..6823b4a
Binary files /dev/null and b/screenshots/bind-mode.png differ
diff --git a/screenshots/flyout.png b/screenshots/flyout.png
new file mode 100644
index 0000000..0d1082b
Binary files /dev/null and b/screenshots/flyout.png differ
diff --git a/screenshots/keybindings.png b/screenshots/keybindings.png
new file mode 100644
index 0000000..8fbc259
Binary files /dev/null and b/screenshots/keybindings.png differ
diff --git a/screenshots/options.png b/screenshots/options.png
new file mode 100644
index 0000000..1abf87a
Binary files /dev/null and b/screenshots/options.png differ
diff --git a/screenshots/pulse-ui.png b/screenshots/pulse-ui.png
new file mode 100644
index 0000000..28b5692
Binary files /dev/null and b/screenshots/pulse-ui.png differ
diff --git a/textures/icons.tga b/textures/icons.tga
new file mode 100644
index 0000000..21da641
Binary files /dev/null and b/textures/icons.tga differ
diff --git a/textures/panel_bg.tga b/textures/panel_bg.tga
new file mode 100644
index 0000000..4ad5562
Binary files /dev/null and b/textures/panel_bg.tga differ
diff --git a/textures/panel_border.tga b/textures/panel_border.tga
new file mode 100644
index 0000000..0b292de
Binary files /dev/null and b/textures/panel_border.tga differ
diff --git a/textures/ring_fx.tga b/textures/ring_fx.tga
new file mode 100644
index 0000000..5fe74e1
Binary files /dev/null and b/textures/ring_fx.tga differ
diff --git a/textures/ring_pulsearc.tga b/textures/ring_pulsearc.tga
new file mode 100644
index 0000000..f295547
Binary files /dev/null and b/textures/ring_pulsearc.tga differ
diff --git a/textures/ring_round.tga b/textures/ring_round.tga
new file mode 100644
index 0000000..a183c48
Binary files /dev/null and b/textures/ring_round.tga differ
diff --git a/textures/ui.tga b/textures/ui.tga
new file mode 100644
index 0000000..14d1d63
Binary files /dev/null and b/textures/ui.tga differ
diff --git a/ui.lua b/ui.lua
new file mode 100644
index 0000000..d92ba80
--- /dev/null
+++ b/ui.lua
@@ -0,0 +1,2541 @@
+-- TotemBar - ui.lua
+-- The totem bar frame: 4 element buttons plus a Totemic Recall button.
+-- Left-click an element button to cast its chosen totem; right-click
+-- clears the slot. Hovering an element button pops an upward flyout of
+-- the element's other known totems: left-click one to cast it once,
+-- right-click one to set it as the slot's new default. Each element
+-- button also carries an OmniCC-style remaining-duration timer text (hybrid
+-- source: pfUI libtotem's GetTotemInfo when present, else TotemBar's
+-- own cast-tracking - see core/cast.lua). No per-button text labels;
+-- hover the button for a tooltip naming the element/totem. Each element
+-- button also carries a native action-button-style radial cooldown
+-- swipe (CooldownFrameTemplate) reflecting the chosen totem's spellbook
+-- cooldown, IN ADDITION to the duration timer text. The Recall button's
+-- icon pulses whenever any element totem is currently out of range.
+-- WoW-API-only file; not offline-tested, only syntax-checked (see
+-- tools/luatests notes in the repo).
+
+TotemBar = TotemBar or {}
+
+-- Defensive fallback in case DEFAULT_CHAT_FRAME is ever unset this early
+-- in the loading sequence (guards against a later/leaner client build).
+local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
+
+local BUTTON_SIZE = 36
+-- 10 (was 4): the 48px round ring frames overhang their 36px buttons by
+-- 6px per side; adjacent opaque ring bands (screen radius ~22.9px) need
+-- button centers >= ~45.8px apart to not overlap. 36+10 = 46 clears it,
+-- for the bar row and the flyout column alike. Now a live-adjustable
+-- UPVALUE (not a constant): the options panel's "Button spacing" slider
+-- (range 10-30px, TotemBarDB.buttonGap) reassigns this in place via
+-- TotemBar.SetButtonGap below - every closure that reads BUTTON_GAP
+-- (element/flyout/assign-panel positioning, ApplyBarLayout) sees the new
+-- value on its next read. 10px is still the floor: below that, adjacent
+-- ring bands start to overlap per the geometry above.
+local BUTTON_GAP = TotemBar.DEFAULT_BUTTON_GAP or 10
+
+-- Bar-layout grid (feature: selectable bar arrangement, TotemBarDB.barLayout,
+-- see ApplyBarLayout below). Maps each saved layout string to its column
+-- count; row count then falls out of math.ceil(buttonCount / cols). Cycle
+-- order for the options panel's cycle button lives in TotemBar.BAR_LAYOUTS.
+local BAR_LAYOUT_COLS = { ["1x6"] = 6, ["2x3"] = 3, ["3x2"] = 2 }
+TotemBar.BAR_LAYOUTS = { "1x6", "2x3", "3x2" }
+
+-- Rows use the SAME pitch as columns (user request: tight grid). The timer
+-- text below a row slightly overlaps the next row's ring overhang; it draws
+-- on the OVERLAY layer above it and stays readable.
+
+-- Timer-text OnUpdate throttle: refresh at most this often (seconds),
+-- not every frame, to keep the shared/guild-addon perf budget sane.
+-- 0.1s (was 0.2): the same tick also refreshes the ripple wave INPUTS (the
+-- wave animation itself runs every frame in UpdateWaves, driven off these).
+-- Still allocation-free per tick.
+local TIMER_UPDATE_INTERVAL = 0.1
+local timerElapsed = 0
+
+-- Fallback icon for an unresolved/unknown totem name (flyout icons,
+-- ResolveTotemIcon, the pending-assignment panel). Element buttons' own
+-- empty-slot state uses the custom sheet's per-element glyph instead
+-- (see SetElementIcon/ELEMENT_EMPTY_CELL above), not this constant.
+local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
+
+-- Totemic Recall: exact icon is verified at button-creation time via a
+-- spellbook scan (GetSpellTexture); this is only the last-resort
+-- fallback if that scan fails to resolve a texture.
+local RECALL_SPELL_NAME = "Totemic Recall"
+local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal"
+
+-- Pulse UI Rev 2 - floating round icons + ripple pulse (spec
+-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md, section "Rev 2").
+-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc
+-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 =
+-- the decorative frame band - always shown, hides the icon's square
+-- corners so it reads as a floating round icon. Texture generated by
+-- tools/gen_ring_textures.js; path WITHOUT extension (client resolves
+-- .blp/.tga).
+local RING_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_round"
+local RING_FRAMES = 62
+local RING_GRID_COLS = 8
+-- Cell 64 = the permanent decorative frame band (now baked-in shadow look,
+-- see the art contract in the Rev 6 texture generator) - always shown,
+-- hides the icon's square corners so it reads as a floating round icon.
+-- (Rev 5's user-selectable ring-skin picker, which swapped this cell for a
+-- separate ring_tracks.tga, was reverted per user feedback - see
+-- ApplyRoundFrame below.)
+local RING_WAVE_CELL = 63
+local RING_TRACK_CELL = 64
+-- Built once at load (core/pulse.lua loads before ui.lua, see .toc); the
+-- per-tick path only indexes this table.
+local allRingCoords = TotemBar.buildRingTexCoords(64, RING_GRID_COLS)
+
+-- Screen px the ring/wave textures are drawn at, centered on the 36px
+-- button - the ring extends past the button's own edges so the decorative
+-- frame band's opaque interior fully covers the icon's square corners (see
+-- the texture generator's geometry rationale comment).
+local RING_TEX_SIZE = 48
+local WAVE_DURATION = 0.8 -- ripple lifetime, seconds
+local WAVE_MAX_SCALE = 1.9 -- wave grows from RING_TEX_SIZE to *WAVE_MAX_SCALE
+local WAVE_BASE_ALPHA = 0.85 -- wave alpha at spawn, fades to 0 by WAVE_DURATION
+
+-- Circular pulse countdown (Rev 6): the linear pulse bar (Rev 1) was
+-- replaced with a thin white inner arc drawn ON the band itself, since a
+-- round readout fits the round slots better than a straight bar (user
+-- feedback). Same flipbook mechanics as the duration ring (ringFill) above,
+-- just a separate all-white texture so it reads distinctly from the
+-- element-colored duration arc. Independently toggleable
+-- (TotemBarDB.showPulseBars, key name kept from the old linear bar - no
+-- migration needed) from the ripple wave (TotemBarDB.showPulseWaves).
+local PULSEARC_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_pulsearc"
+
+-- Pulse UI Rev 3: anticipation glow + spawn ripple + round hover/pushed
+-- click states, all drawn from one FX sheet (tools/gen_ring_textures.js):
+-- 2x2 grid of 128px cells. Cell 1 = annular anticipation halo, cell 2 =
+-- round hover ring, cell 3 = pushed press disc, cell 4 = spawn flash
+-- (reserved). Path WITHOUT extension, same convention as RING_TEXTURE.
+local FX_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ring_fx"
+local fxCoords = TotemBar.buildRingTexCoords(4, 2)
+local FX_GLOW_CELL = 1 -- annular anticipation halo (drawn at 64px)
+local FX_HOVER_CELL = 2 -- round hover ring (drawn at RING_TEX_SIZE)
+local FX_PUSHED_CELL = 3 -- press disc (drawn at RING_TEX_SIZE)
+local FX_FLASH_CELL = 4 -- reserved (spawn flash disc)
+local GLOW_TEX_SIZE = 64
+local GLOW_RAMP_START = 0.78 -- glow builds over the last 22% before a pulse
+local GLOW_MAX_ALPHA = 0.7
+local SPAWN_WAVE_DURATION = 0.6
+
+-- Custom icon sheet (tools/gen_icons.js): 4x4 grid of 128px cells.
+local ICONS_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
+local iconCoords = TotemBar.buildRingTexCoords(16, 4)
+local ELEMENT_EMPTY_CELL = { Fire = 1, Earth = 2, Water = 3, Air = 4 }
+-- cell 5 reserved (custom recall icon, reverted per user feedback)
+local ICON_DROPSET_CELL = 6
+local ICON_CROP = 0.08 -- standard action-button crop for real spell icons
+
+-- UI chrome sheet + panel skin (tools/gen_ui_textures.js).
+local UI_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\ui"
+local uiCoords = TotemBar.buildRingTexCoords(4, 2)
+local UI_SHADOW_CELL = 1
+local UI_EMBLEM_CELL = 2
+local UI_DIVIDER_CELL = 3
+local PANEL_BACKDROP = {
+ bgFile = "Interface\\AddOns\\TotemBar\\textures\\panel_bg",
+ edgeFile = "Interface\\AddOns\\TotemBar\\textures\\panel_border",
+ tile = true, tileSize = 128, edgeSize = 16,
+ insets = { left = 5, right = 5, top = 5, bottom = 5 },
+}
+TotemBar.PANEL_BACKDROP = PANEL_BACKDROP -- shared with options.lua / bind.lua
+TotemBar.UI_TEXTURE = UI_TEXTURE
+TotemBar.uiCoords = uiCoords
+TotemBar.UI_EMBLEM_CELL = UI_EMBLEM_CELL
+TotemBar.UI_DIVIDER_CELL = UI_DIVIDER_CELL
+
+local ELEMENT_COLORS = {
+ Fire = { 1.00, 0.45, 0.10 },
+ Earth = { 0.40, 0.85, 0.30 },
+ Water = { 0.25, 0.60, 1.00 },
+ Air = { 0.70, 0.55, 1.00 },
+}
+
+local elementButtons = {} -- element -> button frame
+local recallButton = nil -- Totemic Recall button frame (for the auto-recall indicator refresh)
+local dropSetButton = nil -- DropSet button frame (for RefreshPulseUI's ring-skin re-apply)
+
+-- True when ANY element's totem is currently out-of-range (the
+-- buff-presence red tint, see UpdateTimerDisplays). Recomputed once per
+-- UpdateTimerDisplays pass; drives the Recall button's icon pulse
+-- (OnRecallUpdate below) as a "go recall + redeploy" visual prompt.
+local anyOutOfRange = false
+
+-- Hover flyout: hovering an element button pops a column of icon
+-- buttons UPWARD for the OTHER known totems of that element (all known
+-- for the element minus the currently-chosen default). Left-click one to
+-- cast it ONCE without changing the slot's default; right-click one to
+-- make it the slot's new default. Shares one frame + a pool of icon
+-- buttons.
+local MAX_FLYOUT_ICONS = 6 -- most totems any single element has
+local FLYOUT_PAD = 4 -- inner padding inside the flyout frame
+local FLYOUT_GAP = 2 -- gap between element button top and flyout bottom
+local FLYOUT_HIDE_INTERVAL = 0.1 -- throttle for the mouse-leave hide check
+
+local flyoutIcons = {} -- pooled flyout icon buttons (created once)
+local flyoutFrame = nil -- lazily created shared flyout frame
+local flyoutElement = nil -- element currently shown in the flyout
+local flyoutOwnerButton = nil -- element button the flyout is anchored to
+local flyoutElapsed = 0 -- throttle accumulator for the hide check
+
+-- Forward declarations so functions defined later can be referenced by
+-- closures created earlier in the file (standard Lua forward-decl idiom:
+-- `local foo` then later `foo = function() ... end` / `function foo()`).
+local RefreshButton
+local RefreshCooldown
+local EnsureFlyoutFrame
+local ShowFlyout
+local RefreshFlyoutCooldowns
+local HideFlyout
+local OnFlyoutUpdate
+local CreateElementButton
+local CreateRecallButton
+local CreateDropSetButton
+local RefreshRecallIndicator
+local OnRecallUpdate
+local UpdateTimerDisplays
+local UpdateWaves
+local OnBarUpdate
+local OnDragStart
+local OnDragStop
+local EnsureAssignFrame
+local assignFrame -- lazily created pending-suggestion panel
+
+-- Finds the spellbook index of a known spell by exact name, or nil.
+-- Cache-backed accessor from core/spellindex.lua (loaded earlier in the
+-- TOC) -- used to be its own fresh linear GetSpellName() scan here.
+local FindSpellIndexByName = TotemBar.findSpellIndex
+
+-- Fills a tooltip with a totem spell, resolved to the HIGHEST known rank.
+-- A first-name-match slot (FindSpellIndexByName) is RANK 1, so SetSpell on
+-- it showed rank-1 mana/damage/duration - misleading, since the name-based
+-- CAST always uses the highest rank. Also appends the rank to the name
+-- line, which 1.12's SetSpell omits entirely.
+local function SetSpellTooltip(tip, name)
+ local idx = (TotemBar.findHighestRankSlot and TotemBar.findHighestRankSlot(name))
+ or FindSpellIndexByName(name)
+ if not idx then
+ tip:SetText(name)
+ return
+ end
+ tip:SetSpell(idx, BOOKTYPE_SPELL)
+ local _, rank = GetSpellName(idx, BOOKTYPE_SPELL)
+ if rank and rank ~= "" then
+ tip:AppendText(" |cffa8a8a8(" .. rank .. ")|r")
+ end
+end
+
+-- Resolves the icon texture path for whatever totem is currently chosen
+-- for `element`, or nil when the slot is empty/unresolved. Callers route
+-- the result through SetElementIcon, which draws the sheet's per-element
+-- glyph for nil instead of a spellbook texture.
+local function GetElementIcon(element)
+ local db = TotemBarDB
+ local name = db and db.chosen and db.chosen[element]
+ if not name then
+ return nil
+ end
+ local idx = FindSpellIndexByName(name)
+ if not idx then
+ return nil
+ end
+ return GetSpellTexture(idx, BOOKTYPE_SPELL)
+end
+
+-- Resolves (iconTexture, known) for an arbitrary totem name: the spellbook
+-- texture when the player knows it, else the empty/question-mark icon and
+-- known=false (the pending panel greys unknown totems).
+local function ResolveTotemIcon(name)
+ if not name then
+ return EMPTY_ICON, false
+ end
+ local idx = FindSpellIndexByName(name)
+ if not idx then
+ return EMPTY_ICON, false
+ end
+ local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
+ return texture or EMPTY_ICON, true
+end
+
+-- Resolves the Totemic Recall icon by scanning the spellbook for the
+-- real spell (so it matches whatever texture TWoW actually ships),
+-- falling back to a hardcoded texture if the scan can't find it.
+local function GetRecallIcon()
+ local idx = FindSpellIndexByName(RECALL_SPELL_NAME)
+ if idx then
+ local texture = GetSpellTexture(idx, BOOKTYPE_SPELL)
+ if texture then
+ return texture
+ end
+ end
+ return RECALL_ICON_FALLBACK
+end
+
+-- Sets an element button's icon: real spell icon (standard crop) when a
+-- totem is chosen/resolved, element glyph from the sheet when empty.
+-- SetTexCoord persists across SetTexture, so both branches must set it.
+local function SetElementIcon(btn, texturePath)
+ if texturePath then
+ btn.icon:SetTexture(texturePath)
+ btn.icon:SetTexCoord(ICON_CROP, 1 - ICON_CROP, ICON_CROP, 1 - ICON_CROP)
+ else
+ local c = iconCoords[ELEMENT_EMPTY_CELL[btn.element] or 1]
+ btn.icon:SetTexture(ICONS_TEXTURE)
+ btn.icon:SetTexCoord(c.l, c.r, c.t, c.b)
+ end
+end
+
+RefreshButton = function(element)
+ local btn = elementButtons[element]
+ if not btn then
+ return
+ end
+ SetElementIcon(btn, GetElementIcon(element))
+ RefreshCooldown(element)
+end
+
+-- Refreshes element `element`'s native cooldown swipe (the same radial
+-- CooldownFrameTemplate widget standard action buttons use) to match its
+-- currently-chosen totem's spellbook cooldown. Clears the swipe (start=0)
+-- when the slot is empty or unresolved. Called from RefreshButton (i.e.
+-- on selection changes and once at bar-build time) and from the
+-- SPELL_UPDATE_COOLDOWN event handler below - NEVER from the per-tick
+-- timer OnUpdate, since re-calling CooldownFrame_SetTimer every tick
+-- would restart the swipe's animation instead of letting it play.
+RefreshCooldown = function(element)
+ local btn = elementButtons[element]
+ if not btn then
+ return
+ end
+ local db = TotemBarDB
+ local totemName = db and db.chosen and db.chosen[element]
+ local idx = totemName and FindSpellIndexByName(totemName)
+ if not idx then
+ CooldownFrame_SetTimer(btn.cd, 0, 0, 0)
+ return
+ end
+ local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ CooldownFrame_SetTimer(btn.cd, start, duration, enable)
+end
+
+-- Glue for core/assign.lua's pending-assignment logic: it stays WoW-API-
+-- light and reaches the spellbook / bar refresh through these slots.
+-- isTotemKnown resolves a totem name against the live spellbook; RefreshAll
+-- re-skins every element button (used after a pending assignment is applied).
+TotemBar.isTotemKnown = function(name)
+ return FindSpellIndexByName(name) ~= nil
+end
+
+TotemBar.RefreshAll = function()
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ RefreshButton(elements[i])
+ end
+end
+
+-- Bind-mode key overlays: a small top-right FontString on each bindable
+-- button/flyout icon, ALWAYS shown (action-bar-hotkey style) whenever a key
+-- is bound, hidden only when nothing is bound. Independent of bind mode.
+-- Declared here, BEFORE EnsureFlyoutFrame/CreateElementButton/
+-- CreateRecallButton/CreateDropSetButton (all of which call
+-- registerBindOverlay from their function bodies): Lua resolves a `local`
+-- as an upvalue only for code appearing lexically after its declaration, so
+-- this block must sit above every factory that references it, not just
+-- above CreateElementButton (EnsureFlyoutFrame is defined earlier in the
+-- file than the element/recall/dropset button factories).
+local bindOverlayTargets = {} -- { frame = button, action = fn()->command }
+
+local function ensureBindOverlay(frame)
+ if frame.bindKeyText then
+ return frame.bindKeyText
+ end
+ local fs = frame:CreateFontString(nil, "OVERLAY")
+ fs:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
+ fs:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1)
+ fs:SetTextColor(1, 0.9, 0.2)
+ fs:Hide()
+ frame.bindKeyText = fs
+ return fs
+end
+
+-- action is a function returning the binding command for this frame right now
+-- (flyout icons change totem), or nil.
+local function registerBindOverlay(frame, actionFn)
+ ensureBindOverlay(frame)
+ tinsert(bindOverlayTargets, { frame = frame, action = actionFn })
+end
+
+TotemBar.refreshBindOverlays = function()
+ for i = 1, table.getn(bindOverlayTargets) do
+ local t = bindOverlayTargets[i]
+ local fs = t.frame.bindKeyText
+ local cmd = t.action()
+ local key = cmd and GetBindingKey(cmd) or nil
+ if key then
+ fs:SetText(TotemBar.shortenKey(key))
+ fs:Show()
+ else
+ fs:Hide()
+ end
+ end
+end
+
+-- Replaces the square Blizzard hover/pushed art with round FX-sheet states,
+-- sized to the ring frame so they hug the round button. Declared here,
+-- BEFORE EnsureFlyoutFrame (same ordering constraint as registerBindOverlay
+-- above): the pooled flyout icons' creation loop lives inside
+-- EnsureFlyoutFrame and calls this, so it must already be a visible upvalue
+-- at that point in the file, not just before CreateElementButton/
+-- CreateRecallButton/CreateDropSetButton further down.
+local function ApplyRoundClickStates(btn, size)
+ btn:SetHighlightTexture(FX_TEXTURE, "ADD")
+ local h = btn:GetHighlightTexture()
+ if h then
+ local c = fxCoords[FX_HOVER_CELL]
+ h:SetTexCoord(c.l, c.r, c.t, c.b)
+ h:ClearAllPoints()
+ h:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ h:SetWidth(size)
+ h:SetHeight(size)
+ end
+ btn:SetPushedTexture(FX_TEXTURE)
+ local p = btn:GetPushedTexture()
+ if p then
+ local c = fxCoords[FX_PUSHED_CELL]
+ p:SetTexCoord(c.l, c.r, c.t, c.b)
+ p:ClearAllPoints()
+ p:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ p:SetWidth(size)
+ p:SetHeight(size)
+ end
+end
+
+-- Repositions every pooled flyout icon per the current BUTTON_GAP (mirrors
+-- the bottom-up stacking math used when the icons are first built, below).
+-- The icon pool is built lazily ONCE and its anchors are baked in at that
+-- point - they don't reflow themselves - so this must be called again
+-- whenever BUTTON_GAP changes live (TotemBar.SetButtonGap), not just at
+-- build time. `frame` lets the build-time call pass the not-yet-assigned
+-- local `f` directly; later calls omit it and fall back to the flyoutFrame
+-- upvalue (guarded: a no-op if the flyout has never been built yet).
+local function layoutFlyoutIcons(frame)
+ local f = frame or flyoutFrame
+ if not f then
+ return
+ end
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = flyoutIcons[i]
+ if ico then
+ ico:ClearAllPoints()
+ ico:SetPoint("BOTTOM", f, "BOTTOM", 0, FLYOUT_PAD + (i - 1) * (BUTTON_SIZE + BUTTON_GAP))
+ end
+ end
+end
+
+-- Lazily builds the single shared flyout frame plus its pool of icon
+-- buttons (TotemBarFlyoutIcon1..MAX_FLYOUT_ICONS), stacked bottom-up so
+-- ShowFlyout can just Show the first N. DIALOG strata so it draws above
+-- the bar backdrop. Same anti-bevel icon treatment as the element
+-- buttons (opaque black backdrop under an inset, cropped ARTWORK icon).
+EnsureFlyoutFrame = function()
+ if flyoutFrame then
+ return flyoutFrame
+ end
+
+ -- Parent to the bar (not UIParent) so the flyout inherits the bar's
+ -- scale (UI-size slider) and hides with it. Still DIALOG strata below,
+ -- so it draws above the bar backdrop regardless of parent.
+ local f = CreateFrame("Frame", "TotemBarFlyout", TotemBarFrame)
+ f:SetFrameStrata("DIALOG")
+ f:SetWidth(BUTTON_SIZE + FLYOUT_PAD * 2)
+ f:SetHeight(BUTTON_SIZE + FLYOUT_PAD * 2)
+ f:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 16,
+ insets = { left = 4, right = 4, top = 4, bottom = 4 },
+ })
+ -- Floating icons: fully transparent flyout backdrop/border (the flyout
+ -- frame itself is now just an invisible hit-test/layout area).
+ f:SetBackdropColor(0, 0, 0, 0)
+ f:SetBackdropBorderColor(0, 0, 0, 0)
+ f:EnableMouse(true) -- so gaps/padding still count as "over the flyout"
+ f:SetClampedToScreen(true)
+ f:Hide()
+
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = CreateFrame("Button", "TotemBarFlyoutIcon" .. i, f)
+ ico:SetWidth(BUTTON_SIZE)
+ ico:SetHeight(BUTTON_SIZE)
+ -- Bottom-up: icon 1 just inside the bottom padding, each next
+ -- one a full button+gap higher (so nearest the element button
+ -- is first). Anchor applied below via layoutFlyoutIcons (once the
+ -- whole pool exists), not here - the same helper re-applies it
+ -- live when BUTTON_GAP changes.
+
+ ico:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+ ico:SetBackdropColor(0, 0, 0, 0)
+ ico:SetBackdropBorderColor(0, 0, 0, 0)
+
+ local icon = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Icon", "ARTWORK")
+ icon:SetPoint("TOPLEFT", ico, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", ico, "BOTTOMRIGHT", -3, 3)
+ icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ ico.icon = icon
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome), same as the element
+ -- buttons' - BACKGROUND layer, no glow on flyout icons so ordering
+ -- relative to other regions doesn't matter here.
+ local shadow = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", ico, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ shadow:SetAlpha(0.7) -- softened, see the element buttons' plate note
+ ico.shadow = shadow
+
+ -- Floating round frame, same decorative band as the element
+ -- buttons (no fill/tint - just the permanent frame). Flyout icons
+ -- are BUTTON_SIZE (36px) too, so the proportional ring size
+ -- S*(48/36) is just RING_TEX_SIZE (48px) again.
+ local ringTrack = ico:CreateTexture("TotemBarFlyoutIcon" .. i .. "RingTrack", "OVERLAY")
+ ringTrack:SetTexture(RING_TEXTURE)
+ local rtc = allRingCoords[RING_TRACK_CELL]
+ ringTrack:SetTexCoord(rtc.l, rtc.r, rtc.t, rtc.b)
+ ringTrack:SetPoint("CENTER", ico, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ ico.ringTrack = ringTrack
+
+ -- Native cooldown swipe over the flyout icon, so the player can
+ -- see which alternative totems are on cooldown while choosing.
+ -- Same "Model" (NOT "Cooldown") frame-type caveat as the element
+ -- buttons: vanilla 1.12 has no dedicated Cooldown widget type -
+ -- CooldownFrameTemplate is a Model template here (see the element
+ -- button's cd note above). Driven once at flyout-open by
+ -- ShowFlyout (the widget animates itself from start+duration), so
+ -- no per-frame refresh for this transient popup.
+ local cd = CreateFrame("Model", "TotemBarFlyoutIcon" .. i .. "Cooldown", ico, "CooldownFrameTemplate")
+ cd:SetAllPoints(ico.icon)
+ cd:SetFrameLevel(ico:GetFrameLevel())
+ ico.cd = cd
+
+ -- Round hover/pushed states (Rev 3), same FX sheet as the bar
+ -- buttons; flyout icons are BUTTON_SIZE, sized like the bar's ring.
+ ApplyRoundClickStates(ico, RING_TEX_SIZE)
+ ico:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+
+ -- Left-click: cast once for the element the flyout is currently
+ -- showing. recordCast() drives THAT element's timer to the
+ -- actually-cast totem, without touching the slot's chosen
+ -- default. Right-click: make this totem the slot's new chosen
+ -- default and re-populate the flyout in place (so the
+ -- newly-chosen totem drops out of the "others" list and the
+ -- previously-chosen one appears), keeping the flyout open.
+ ico:SetScript("OnClick", function()
+ if not (this.totemName and flyoutElement) then
+ return
+ end
+ if arg1 == "RightButton" then
+ local element = flyoutElement
+ local owner = flyoutOwnerButton
+ TotemBarDB.chosen[element] = this.totemName
+ RefreshButton(element)
+ if owner then
+ ShowFlyout(owner, element)
+ end
+ else
+ CastSpellByName(this.totemName)
+ TotemBar.recordCast(flyoutElement, this.totemName)
+ -- Immediate cooldown feedback: don't wait for
+ -- SPELL_UPDATE_COOLDOWN. Refreshes the bar button's swipe
+ -- (in case the cast totem is also this element's chosen
+ -- default) and the flyout icon's own swipe.
+ RefreshCooldown(flyoutElement)
+ RefreshFlyoutCooldowns()
+ end
+ end)
+
+ ico:SetScript("OnEnter", function()
+ if this.totemName then
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ SetSpellTooltip(GameTooltip, this.totemName)
+ GameTooltip:AddLine("Left-click: cast / Right-click: set default", 1, 1, 1)
+ GameTooltip:Show()
+ end
+ end)
+ ico:SetScript("OnLeave", function()
+ GameTooltip:Hide()
+ end)
+
+ ico:Hide()
+ registerBindOverlay(ico, function()
+ if ico.totemName then
+ return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(ico.totemName)
+ end
+ return nil
+ end)
+ flyoutIcons[i] = ico
+ end
+
+ -- Anchors every pooled icon per BUTTON_GAP now that the whole pool
+ -- exists (flyoutFrame isn't assigned yet at this point in the function,
+ -- hence passing `f` explicitly - see layoutFlyoutIcons's own comment).
+ layoutFlyoutIcons(f)
+
+ -- OnUpdate only fires while the frame is shown, so this hide-check
+ -- naturally stops running once the flyout is hidden.
+ f:SetScript("OnUpdate", OnFlyoutUpdate)
+
+ flyoutFrame = f
+ return f
+end
+
+-- Populates and shows the flyout above `button` for `element`, listing
+-- every known totem of that element EXCEPT the currently-chosen default.
+-- Shows nothing if there are no "others".
+ShowFlyout = function(button, element)
+ local f = EnsureFlyoutFrame()
+
+ local db = TotemBarDB
+ local spellNames = TotemBar.scanSpellbook()
+ local known = TotemBar.knownTotems(spellNames, element)
+ local chosen = db and db.chosen and db.chosen[element]
+
+ local count = 0
+ for i = 1, table.getn(known) do
+ local totemName = known[i]
+ if totemName ~= chosen and count < MAX_FLYOUT_ICONS then
+ count = count + 1
+ local ico = flyoutIcons[count]
+ ico.totemName = totemName
+ local idx = FindSpellIndexByName(totemName)
+ local texture = idx and GetSpellTexture(idx, BOOKTYPE_SPELL)
+ ico.icon:SetTexture(texture or EMPTY_ICON)
+ -- Drive the swipe once here at flyout-open so the player can
+ -- see which alternative totems are on cooldown; the widget
+ -- animates itself from start+duration, no per-tick refresh.
+ if idx then
+ local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ CooldownFrame_SetTimer(ico.cd, start, duration, enable)
+ else
+ CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
+ end
+ ico:Show()
+ end
+ end
+
+ for j = count + 1, MAX_FLYOUT_ICONS do
+ flyoutIcons[j]:Hide()
+ flyoutIcons[j].totemName = nil
+ -- Clear the swipe on unused pool icons so a stale cooldown from a
+ -- previous open doesn't linger if this slot is reused later.
+ CooldownFrame_SetTimer(flyoutIcons[j].cd, 0, 0, 0)
+ end
+
+ if count == 0 then
+ -- No other known totems for this element; don't show an empty box.
+ flyoutElement = nil
+ flyoutOwnerButton = nil
+ f:Hide()
+ return
+ end
+
+ flyoutElement = element
+ flyoutOwnerButton = button
+ flyoutElapsed = 0
+
+ f:SetHeight(count * (BUTTON_SIZE + BUTTON_GAP) - BUTTON_GAP + FLYOUT_PAD * 2)
+ f:ClearAllPoints()
+ f:SetPoint("BOTTOM", button, "TOP", 0, FLYOUT_GAP)
+ f:Show()
+ -- Freshly-populated flyout icons need their overlays re-driven right
+ -- away (rather than waiting for the next UPDATE_BINDINGS/toggle) so
+ -- keys show immediately while bind mode is already on.
+ if TotemBar.refreshBindOverlays then
+ TotemBar.refreshBindOverlays()
+ end
+end
+
+-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout
+-- icon, re-resolving each one's spellbook index fresh (so a just-cast
+-- totem's swipe reflects the cooldown that cast just started). No-op if
+-- the flyout isn't open. Doesn't touch icon texture/totemName/layout -
+-- only the swipe - so it's safe to call from anywhere without disturbing
+-- ShowFlyout's own population pass (which sets the initial swipe state
+-- itself, including clearing unused pool icons; left as-is here).
+RefreshFlyoutCooldowns = function()
+ if not (flyoutFrame and flyoutFrame:IsShown()) then
+ return
+ end
+ for i = 1, MAX_FLYOUT_ICONS do
+ local ico = flyoutIcons[i]
+ if ico:IsShown() and ico.totemName then
+ local idx = FindSpellIndexByName(ico.totemName)
+ if idx then
+ local start, duration, enable = GetSpellCooldown(idx, BOOKTYPE_SPELL)
+ CooldownFrame_SetTimer(ico.cd, start, duration, enable)
+ else
+ CooldownFrame_SetTimer(ico.cd, 0, 0, 0)
+ end
+ end
+ end
+end
+
+HideFlyout = function()
+ if flyoutFrame then
+ flyoutFrame:Hide()
+ end
+ flyoutElement = nil
+ flyoutOwnerButton = nil
+end
+
+-- Throttled mouse-leave check: hide the flyout once the cursor is over
+-- NEITHER the owning element button NOR the flyout itself. This keeps
+-- it open while the mouse travels from the button up onto the flyout.
+-- No per-frame allocation - just the accumulator and two MouseIsOver
+-- geometry checks, gated to run ~every FLYOUT_HIDE_INTERVAL seconds.
+OnFlyoutUpdate = function()
+ flyoutElapsed = flyoutElapsed + arg1
+ if flyoutElapsed < FLYOUT_HIDE_INTERVAL then
+ return
+ end
+ flyoutElapsed = 0
+ if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then
+ return
+ end
+ HideFlyout()
+end
+
+-- Applies the floating round-icon frame to one button: permanent
+-- decorative-frame texture (ringTrack) + duration-arc texture/tint
+-- (ringFill, where present) + wave tint (pulseWave, where present), and
+-- blanks the button's own square backdrop/border - the frame ring now IS
+-- the visual frame, always (no ring-option gate: even with the duration
+-- arc hidden, the permanent ring frame stays visible and the button must
+-- never show a black box behind it). Used for element buttons (which have
+-- ringFill/pulseWave) and the Recall/DropSet buttons (which don't, guarded
+-- below) alike.
+local function ApplyRoundFrame(btn)
+ btn.ringTrack:SetTexture(RING_TEXTURE)
+ local trc = allRingCoords[RING_TRACK_CELL]
+ btn.ringTrack:SetTexCoord(trc.l, trc.r, trc.t, trc.b)
+
+ if btn.ringFill then
+ btn.ringFill:SetTexture(RING_TEXTURE)
+ -- No element-color assignment here anymore: the duration ring is
+ -- now a time-remaining traffic light (green->yellow->orange->red),
+ -- recolored every tick in UpdateTimerDisplays via TotemBar.timeColor.
+ btn.ringLastIdx = nil -- force a fill-frame re-apply on the next tick
+ end
+
+ if btn.pulseArc then
+ local c = ELEMENT_COLORS[btn.element]
+ if c then
+ btn.pulseArc:SetVertexColor(c[1], c[2], c[3], 0.95)
+ end
+ end
+
+ if btn.pulseWave then
+ local c = ELEMENT_COLORS[btn.element]
+ if c then
+ btn.pulseWave:SetVertexColor(c[1], c[2], c[3])
+ end
+ end
+
+ if btn.glow then
+ local c = ELEMENT_COLORS[btn.element]
+ if c then
+ btn.glow:SetVertexColor(c[1], c[2], c[3])
+ end
+ end
+
+ btn:SetBackdropColor(0, 0, 0, 0)
+ btn:SetBackdropBorderColor(0, 0, 0, 0)
+end
+
+-- The timer text hangs below the button (the pulse arc and ripple wave are
+-- both centered ON the button, so neither competes for this anchor).
+local function ApplyTimerAnchor(btn)
+ btn.timerText:ClearAllPoints()
+ btn.timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
+end
+
+-- Deterministic-stacking child frame for regions that must render above
+-- ALL of a button's own regions regardless of draw layer (2026-07-16,
+-- HIGHLIGHT-hover regression fix). Background: f3644c5 moved
+-- ringFill/pulseWave/pulseArc (and the Recall button's autoIndicator) to
+-- the HIGHLIGHT layer to beat ringTrack's OVERLAY layer - but 1.12 only
+-- renders a Button's HIGHLIGHT draw layer while the mouse is hovering it
+-- (in-game confirmed: every fill ring/indicator went invisible except
+-- under the cursor). Layers were never going to work for "always-on-top,
+-- always visible" content on a Button. The fix used here instead (the same
+-- pattern used elsewhere for row-highlight panel frames): WoW's cross-frame
+-- draw order is primarily (strata,
+-- frame level) - a CHILD FRAME at a higher FrameLevel than its parent
+-- draws every one of its own regions above ALL of the parent's regions,
+-- no matter which of the 5 draw layers either side uses. So a small,
+-- mouse-transparent child frame pinned over the button's own bounds,
+-- one level higher, gives deterministic top-of-stack rendering without
+-- spending any of the parent's own 5-layer budget.
+local function CreateRingOverlay(btn, name)
+ local overlay = CreateFrame("Frame", name, btn)
+ overlay:EnableMouse(false) -- pure visual layer, must not steal btn's hover/click
+ overlay:SetAllPoints(btn) -- deckungsgleich with btn; CENTER anchors below resolve identically
+ overlay:SetFrameLevel(btn:GetFrameLevel() + 1)
+ return overlay
+end
+
+CreateElementButton = function(element, index)
+ local name = "TotemBarButton" .. element
+ local btn = CreateFrame("Button", name, TotemBarFrame)
+ btn:SetWidth(BUTTON_SIZE)
+ btn:SetHeight(BUTTON_SIZE)
+ btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
+
+ -- Backdrop template kept (needed for SetBackdropColor/BorderColor
+ -- below) but floating: ApplyRoundFrame (called later in this function)
+ -- blanks both to fully transparent - the decorative ring frame is the
+ -- only visible frame around the icon now.
+ btn:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+
+ local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
+ -- Inset a few px from the button's own edges (the floating decorative
+ -- ring frame, drawn OVERLAY on top, covers this margin and the icon's
+ -- square corners - see ApplyRoundFrame/the texture generator's
+ -- geometry rationale).
+ icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
+ btn.icon = icon
+ -- Set ahead of SetElementIcon below (which reads btn.element to pick
+ -- the empty-slot glyph cell); reassigned harmlessly again further
+ -- down alongside the rest of the button's setup, same repeat-write
+ -- pattern as the element-tint lookup ahead of ApplyRoundFrame below.
+ btn.element = element
+ -- SetElementIcon sets both texture and texcoord (SetTexCoord persists
+ -- across SetTexture, so both the real-icon crop and the sheet-glyph
+ -- coords always get (re)applied together - see its own comment).
+ SetElementIcon(btn, GetElementIcon(element))
+
+ -- Native action-button-style radial cooldown swipe (the same
+ -- CooldownFrameTemplate widget the default action bars use),
+ -- covering the icon area. This is IN ADDITION to the OmniCC-style
+ -- duration timer text below; RefreshCooldown() drives it from
+ -- events, see there for why it's not driven off the per-tick timer.
+ --
+ -- Frame type is "Model", NOT "Cooldown": vanilla 1.12 has no
+ -- dedicated Cooldown widget type (that was added in TBC/2.0) -
+ -- CooldownFrameTemplate is a Model-type template in 1.12 FrameXML
+ -- that renders Interface\Cooldown\UI-Cooldown-Indicator.mdx (same
+ -- approach pfUI's own libtotem/action bars use). CreateFrame with
+ -- type "Cooldown" would error on this client.
+ local cd = CreateFrame("Model", name .. "Cooldown", btn, "CooldownFrameTemplate")
+ cd:SetAllPoints(icon)
+ -- Match the button's own frame level instead of the default child
+ -- bump (parent level + 1): WoW's cross-frame draw order is primarily
+ -- (strata, level) - at the default +1 level the ENTIRE swipe frame
+ -- would draw above ALL of btn's own regions regardless of draw
+ -- layer, including the OVERLAY timer text below. At the SAME level,
+ -- ordering falls back to per-region draw layer, letting the OVERLAY
+ -- timer text render on top of the swipe as intended. Still flagged
+ -- for an in-game visual check (see file header note).
+ cd:SetFrameLevel(btn:GetFrameLevel())
+ btn.cd = cd
+
+ -- OmniCC-style remaining-duration text, anchored BELOW the button
+ -- (centered under the icon) so it doesn't overlap the native
+ -- cooldown swipe (btn.cd) rendered on top of the icon. Parented to
+ -- btn (a Button, like the element buttons themselves), not to the
+ -- bar's backdrop Frame - plain 1.12 Frames don't clip child regions
+ -- (no SetClipsChildren in this client), so text hanging below the
+ -- bar's backdrop still renders fully; flagged for an in-game visual
+ -- check regardless (see file header note). Hidden by default;
+ -- UpdateTimerDisplays() shows/updates it.
+ --
+ -- Left parented to btn (NOT moved to ringOverlay), 2026-07-16
+ -- HIGHLIGHT-hover regression fix: geometry check (btn height 36,
+ -- ring RING_TEX_SIZE 48 centered on btn -> ring overhangs 6px past
+ -- every edge) puts ring:GetBottom() at btn:GetBottom()-6, while this
+ -- FontString's SetPoint("TOP", btn, "BOTTOM", 0, -1) puts its OWN top
+ -- at only btn:GetBottom()-1 - i.e. a ~5px vertical band where the two
+ -- regions' bounding boxes technically overlap, directly under the
+ -- button's center. In practice that band is the ring's transparent
+ -- annulus hole (the band's opaque pixels hug its outer 48px edge, not
+ -- its center) plus the font's own ascender padding above the glyphs,
+ -- so no visible ink collision - stays on btn/OVERLAY, unchanged.
+ local timerText = btn:CreateFontString(name .. "Timer", "OVERLAY")
+ timerText:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
+ timerText:SetPoint("TOP", btn, "BOTTOM", 0, -1)
+ timerText:SetJustifyH("CENTER")
+ timerText:SetText("")
+ timerText:Hide()
+ btn.timerText = timerText
+ btn.timerVisible = false -- cached shown-state, avoid redundant Show/Hide
+ btn.timerLastText = nil -- cached last string, avoid redundant SetText
+ btn.timerLastLow = nil -- cached last <=5s tint state, avoid redundant SetTextColor
+ btn.tintRed = false -- cached out-of-range tint state, avoid redundant SetVertexColor
+
+ -- Set here (ahead of the later btn.element = element below) because
+ -- ApplyRoundFrame, called at the end of this block, needs it for the
+ -- element-tint color lookup; the later assignment is then a harmless
+ -- no-op repeat of the same value.
+ btn.element = element
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome). BACKGROUND layer - the
+ -- anticipation glow below used to also be BACKGROUND on the (wrong)
+ -- assumption that same-layer draw order follows creation order in this
+ -- client; the 2026-07-16 ring-occlusion investigation live-confirmed
+ -- that's false (ringTrack was randomly hiding ringFill despite being
+ -- created first) - 1.12 discards SetDrawLayer's sublevel argument, so
+ -- same-MAIN-layer siblings really do have undefined relative order.
+ -- glow now sits on its own BORDER layer instead (below), which is the
+ -- real fix: no assumption needed when the two don't share a layer.
+ local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ -- Softened after preview review: full-strength plate read boxy on light
+ -- ground; flatter + more transparent grounds without drawing attention.
+ shadow:SetAlpha(0.7)
+ btn.shadow = shadow
+
+ -- Pulse UI Rev 2: floating round frame (permanent decorative band +
+ -- duration-arc fill) + ripple wave. Sized to RING_TEX_SIZE (48px)
+ -- centered on the 36px button, NOT SetAllPoints - the ring extends
+ -- past the button's own edges (see the geometry rationale in the
+ -- texture generator).
+ --
+ -- Frame-level stacking (2026-07-16, HIGHLIGHT-hover regression fix -
+ -- see CreateRingOverlay's own comment for the full story): ringTrack,
+ -- ringFill, pulseWave and pulseArc are all centered on the same point
+ -- at the same RING_TEX_SIZE, i.e. all four spatially overlap. A prior
+ -- fix moved the three dynamic ones to HIGHLIGHT to beat ringTrack's
+ -- OVERLAY - but 1.12 only paints a Button's HIGHLIGHT layer on hover,
+ -- so that made them invisible almost always. ringTrack is the one
+ -- PERMANENT member of this quartet (always shown, see ringTrack:Show()
+ -- below) so it stays right where it was: parented to btn, OVERLAY
+ -- layer. ringFill/pulseWave/pulseArc move to a dedicated child frame
+ -- (ringOverlay) one FrameLevel above btn, which puts all three above
+ -- ringTrack unconditionally, regardless of draw layer. Within that
+ -- child frame the three still need to not collide with EACH OTHER
+ -- (1.12 discards SetDrawLayer's sublevel argument, so same-main-layer
+ -- siblings have undefined relative order) - given 3 items and a full
+ -- 5-layer budget to itself now, each gets its own layer:
+ -- ringFill = ARTWORK (base: the colored duration-remaining ring)
+ -- pulseWave = OVERLAY (the brief per-pulse ripple must read above
+ -- the fill it pulses over)
+ -- pulseArc = HIGHLIGHT (the thin white countdown-to-next-pulse
+ -- readout; per its own file comment it must
+ -- "read distinctly" from ringFill, so it gets
+ -- the topmost layer to stay legible even
+ -- while a wave is mid-flash)
+ -- All three are still spatially anchored to the SAME center point as
+ -- before (now via ringOverlay, which is deckungsgleich with btn -
+ -- SetAllPoints - so a CENTER anchor resolves to the identical pixel
+ -- as anchoring to btn directly).
+ local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
+ ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ ringTrack:Show() -- the frame is permanent, unlike ringFill/pulseWave below
+ btn.ringTrack = ringTrack
+
+ local ringOverlay = CreateRingOverlay(btn, name .. "RingOverlay")
+ btn.ringOverlay = ringOverlay
+
+ local ringFill = ringOverlay:CreateTexture(name .. "RingFill", "ARTWORK")
+ ringFill:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
+ ringFill:SetWidth(RING_TEX_SIZE)
+ ringFill:SetHeight(RING_TEX_SIZE)
+ ringFill:Hide()
+ btn.ringFill = ringFill
+ btn.ringLastIdx = nil -- cached flipbook frame, avoid redundant SetTexCoord
+ btn.ringVisible = false -- cached shown-state, avoid redundant Show/Hide
+
+ -- Ripple wave: expanding, fading ring in the element color, one wave
+ -- per pulse (replaces the old pulse progress bar). Animated every
+ -- frame by UpdateWaves below (allocation-free), inputs refreshed by
+ -- the 0.1s timer tick in UpdateTimerDisplays.
+ local pulseWave = ringOverlay:CreateTexture(name .. "PulseWave", "OVERLAY")
+ pulseWave:SetTexture(RING_TEXTURE)
+ local wc = allRingCoords[RING_WAVE_CELL]
+ pulseWave:SetTexCoord(wc.l, wc.r, wc.t, wc.b)
+ pulseWave:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
+ pulseWave:SetWidth(RING_TEX_SIZE)
+ pulseWave:SetHeight(RING_TEX_SIZE)
+ pulseWave:Hide()
+ btn.pulseWave = pulseWave
+ btn.waveVisible = false
+ -- Per-frame wave animation inputs, refreshed by the 0.1s tick; nil
+ -- interval means "no wave for this slot right now".
+ btn.waveInterval = nil
+ btn.waveOrigin = nil -- phase origin (pulseAnchor or start)
+ btn.waveOneshotAt = nil -- detonation time for oneshot waves
+
+ -- Circular pulse countdown: element-colored inner arc on the band,
+ -- fills clockwise toward the next pulse and resets (user feedback: the
+ -- wave alone doesn't show WHEN; a circular bar fits the round slots).
+ -- Colored below by ApplyRoundFrame (called at the end of this function)
+ -- via ELEMENT_COLORS - no fixed color set here.
+ local pulseArc = ringOverlay:CreateTexture(name .. "PulseArc", "HIGHLIGHT")
+ pulseArc:SetTexture(PULSEARC_TEXTURE)
+ pulseArc:SetPoint("CENTER", ringOverlay, "CENTER", 0, 0)
+ pulseArc:SetWidth(RING_TEX_SIZE)
+ pulseArc:SetHeight(RING_TEX_SIZE)
+ pulseArc:Hide()
+ btn.pulseArc = pulseArc
+ btn.pulseArcLastIdx = nil
+ btn.pulseVisible = false
+
+ -- Anticipation glow: annular halo just outside the ring band, fading in
+ -- over the last stretch before a pulse. BORDER layer (moved from
+ -- BACKGROUND, 2026-07-16 - see the shadow comment above): stays below
+ -- ARTWORK (icon) as intended, but no longer shares a layer with shadow.
+ local glow = btn:CreateTexture(name .. "Glow", "BORDER")
+ glow:SetTexture(FX_TEXTURE)
+ local gc = fxCoords[FX_GLOW_CELL]
+ glow:SetTexCoord(gc.l, gc.r, gc.t, gc.b)
+ glow:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ glow:SetWidth(GLOW_TEX_SIZE)
+ glow:SetHeight(GLOW_TEX_SIZE)
+ glow:Hide()
+ btn.glow = glow
+ btn.glowVisible = false
+ btn.spawnAt = nil -- one-shot spawn ripple origin (set by the tick)
+ btn.lastStartSeen = nil -- cast-change detector
+ btn.waveOneshotDelay = nil -- arming window length for oneshot glow
+
+ ApplyRoundFrame(btn)
+ ApplyTimerAnchor(btn)
+
+ -- No normal texture: UI-Quickslot2's bevel bleeds through
+ -- transparent icon art (see backdrop above). Round hover/pushed FX
+ -- states (Rev 3) only appear on click/hover, so they don't bleed.
+ ApplyRoundClickStates(btn, RING_TEX_SIZE)
+
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ btn.element = element
+
+ -- Left-click casts the slot's chosen totem. Right-click clears the
+ -- slot (sets no default); picking a new default happens via the
+ -- hover flyout's right-click instead (see EnsureFlyoutFrame above).
+ btn:SetScript("OnClick", function()
+ local clickedElement = this.element
+ if arg1 == "RightButton" then
+ TotemBarDB.chosen[clickedElement] = nil
+ RefreshButton(clickedElement)
+ else
+ local db = TotemBarDB
+ local totemName = db and db.chosen and db.chosen[clickedElement]
+ if totemName then
+ CastSpellByName(totemName)
+ TotemBar.recordCast(clickedElement, totemName)
+ else
+ ChatOut:AddMessage("TotemBar: no totem chosen for " .. clickedElement .. " (hover for known totems, right-click one to set it as default)")
+ end
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ local db = TotemBarDB
+ local totemName = db and db.chosen and db.chosen[this.element]
+ GameTooltip:SetOwner(this, "ANCHOR_TOP")
+ if totemName then
+ SetSpellTooltip(GameTooltip, totemName)
+ GameTooltip:AddLine("Left-click: cast / Right-click: clear", 1, 1, 1)
+ else
+ GameTooltip:SetText(this.element .. " (empty)")
+ GameTooltip:AddLine("Hover for known totems, right-click one to set default", 1, 1, 1)
+ end
+ GameTooltip:Show()
+ -- Pop the "cast one of the others" flyout above this button.
+ -- The flyout hides itself via its own throttled mouse-leave
+ -- check (OnFlyoutUpdate), so no OnLeave handling is needed here.
+ ShowFlyout(this, this.element)
+ end)
+ btn:SetScript("OnLeave", function()
+ GameTooltip:Hide()
+ end)
+
+ elementButtons[element] = btn
+ registerBindOverlay(btn, function() return "TOTEMBAR_CAST_" .. string.upper(element) end)
+ return btn
+end
+
+-- Refreshes the Recall button's small "A" auto-recall indicator to
+-- match TotemBarDB.autoRecall: shown (greenish) when on, hidden when
+-- off. Called once at button creation and again after every right-click
+-- toggle (see CreateRecallButton below).
+RefreshRecallIndicator = function()
+ if not recallButton or not recallButton.autoIndicator then
+ return
+ end
+ if TotemBarDB and TotemBarDB.autoRecall then
+ recallButton.autoIndicator:SetTextColor(0.3, 1, 0.3)
+ recallButton.autoIndicator:Show()
+ else
+ recallButton.autoIndicator:Hide()
+ end
+end
+
+-- Public alias so options.lua can refresh the "A" auto-recall indicator
+-- after the auto-recall checkbox is toggled.
+TotemBar.RefreshRecallIndicator = RefreshRecallIndicator
+
+-- Pulses the Recall button's icon alpha while anyOutOfRange is true (set
+-- by UpdateTimerDisplays) - a "go recall + redeploy" visual prompt.
+-- Time-based (GetTime()), so the pulse stays smooth regardless of frame
+-- rate; no table/string allocation, just float math + SetAlpha. Once
+-- anyOutOfRange goes false, resets to full alpha exactly once (cached
+-- via iconPulsing) instead of calling SetAlpha every single frame.
+OnRecallUpdate = function()
+ if anyOutOfRange then
+ this.icon:SetAlpha(0.35 + 0.65 * math.abs(math.sin(GetTime() * 3)))
+ this.iconPulsing = true
+ elseif this.iconPulsing then
+ this.icon:SetAlpha(1)
+ this.iconPulsing = false
+ end
+end
+
+-- Totemic Recall: left-click casts it immediately (no dropdown, no
+-- per-element timer - recall has no duration of its own, it just clears
+-- totems). Right-click instead toggles TotemBarDB.autoRecall, the flag
+-- that decides whether TotemBar.recallAndCastAll() (the Totems macro's
+-- entry point, core/cast.lua) prepends a recall before redeploying. A
+-- small "A" FontString overlay reflects the current flag state.
+CreateRecallButton = function(index)
+ local name = "TotemBarButtonRecall"
+ local btn = CreateFrame("Button", name, TotemBarFrame)
+ btn:SetWidth(BUTTON_SIZE)
+ btn:SetHeight(BUTTON_SIZE)
+ btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
+
+ -- Backdrop template kept (needed for SetBackdropColor/BorderColor
+ -- below) but floating: ApplyRoundFrame blanks both to fully transparent
+ -- - the decorative ring frame (below) is the only visible frame now.
+ btn:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+
+ local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
+ icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
+ icon:SetTexture(GetRecallIcon())
+ icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ btn.icon = icon
+ btn.iconPulsing = false -- cached: whether the icon's alpha is currently != 1 (avoids redundant SetAlpha calls once the pulse stops)
+
+ -- Auto-recall indicator: small "A" in the icon's top-left corner. Sits
+ -- inside the area ringTrack (below) is documented to cover ("covers
+ -- the icon's square corners" - see the element button's icon comment)
+ -- - i.e. a REAL, code-confirmed spatial collision with ringTrack, not
+ -- a borderline one. A same-layer HIGHLIGHT promotion (2026-07-16
+ -- ring-occlusion follow-up) tried to win that collision the same way
+ -- ringFill's did, and broke exactly the same way once confirmed
+ -- in-game: 1.12 only paints a Button's HIGHLIGHT layer on hover, so
+ -- the "A" went invisible except under the cursor. Fixed the same way
+ -- as the element buttons' ring stack (see CreateRingOverlay): its own
+ -- small child frame one FrameLevel above btn, so it renders above
+ -- ringTrack deterministically regardless of layer. Doesn't share
+ -- CreateElementButton's ringOverlay (different button, different
+ -- frame tree) - a second, button-local overlay, OVERLAY layer is
+ -- fine since it's the only region on it (no sibling to collide with).
+ -- RefreshRecallIndicator() (called below, and after every toggle)
+ -- shows/hides and colors it based on TotemBarDB.autoRecall.
+ local autoOverlay = CreateRingOverlay(btn, name .. "AutoOverlay")
+ btn.autoOverlay = autoOverlay
+ local autoIndicator = autoOverlay:CreateFontString(name .. "Auto", "OVERLAY")
+ autoIndicator:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
+ autoIndicator:SetPoint("TOPLEFT", icon, "TOPLEFT", 1, -1)
+ autoIndicator:SetJustifyH("LEFT")
+ autoIndicator:SetText("A")
+ autoIndicator:Hide()
+ btn.autoIndicator = autoIndicator
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
+ -- so creation order relative to other regions doesn't matter here.
+ local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ -- Softened after preview review: full-strength plate read boxy on light
+ -- ground; flatter + more transparent grounds without drawing attention.
+ shadow:SetAlpha(0.7)
+ btn.shadow = shadow
+
+ -- Same floating round frame as the element buttons (permanent
+ -- decorative band, no fill/tint - no ringFill/pulseWave on this
+ -- button, guarded inside ApplyRoundFrame).
+ local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
+ ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ btn.ringTrack = ringTrack
+ ApplyRoundFrame(btn)
+
+ -- No normal texture (UI-Quickslot2 bevel bleeds through transparent
+ -- icon art). Round hover/pushed FX states (Rev 3) only show on
+ -- click/hover, no bleed.
+ ApplyRoundClickStates(btn, RING_TEX_SIZE)
+
+ btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+
+ -- "Go recall + redeploy" visual prompt: pulses the icon's alpha
+ -- while anyOutOfRange is true (set by UpdateTimerDisplays). Only
+ -- touches btn.icon's alpha, never btn.autoIndicator, so the "A"
+ -- auto-recall flag text stays steady/visible throughout.
+ btn:SetScript("OnUpdate", OnRecallUpdate)
+
+ btn:SetScript("OnClick", function()
+ if arg1 == "RightButton" then
+ TotemBarDB.autoRecall = not TotemBarDB.autoRecall
+ if TotemBarDB.autoRecall then
+ ChatOut:AddMessage("TotemBar: auto-recall before setting ON")
+ else
+ ChatOut:AddMessage("TotemBar: auto-recall before setting OFF")
+ end
+ RefreshRecallIndicator()
+ elseif TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then
+ -- Confidently nothing out (tracked this session, all expired): don't waste
+ -- Totemic Recall's 6s cooldown on a no-op cast. After a /reload the tracking is
+ -- empty and 1.12 can't re-detect a pre-reload totem, so confidentNoneOut fails
+ -- open there and the recall goes through the else branch below (see core/cast.lua).
+ ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).")
+ else
+ -- Snapshot for the refund learner runs AFTER the cast: activeTotems
+ -- is still populated here (clearActiveTotems runs last).
+ CastSpellByName(RECALL_SPELL_NAME)
+ if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
+ -- Totemic Recall drops every active totem at once; clear our
+ -- own-tracking timers so the icons' countdowns disappear too
+ -- (GetTotemInfo, if present, will also reflect this).
+ TotemBar.clearActiveTotems()
+ end
+ end)
+
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_TOP")
+ GameTooltip:SetText(RECALL_SPELL_NAME)
+ GameTooltip:AddLine("Left-click: recall now", 1, 1, 1)
+ local state = "OFF"
+ if TotemBarDB and TotemBarDB.autoRecall then
+ state = "ON"
+ end
+ GameTooltip:AddLine("Auto Recall Toggle (right-click): " .. state, 1, 1, 1)
+ local activeCost = TotemBar.sumActiveCost(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(), TotemBar.getTotemManaCost, TotemBar.remaining)
+ local pct = (TotemBarDB and TotemBarDB.recallRefundPct) or 0.25
+ local refund = TotemBar.refundAmount(pct, activeCost)
+ if refund > 0 then
+ GameTooltip:AddLine("Refund: ~" .. refund .. " mana", 0.6, 0.6, 1)
+ end
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function()
+ GameTooltip:Hide()
+ end)
+
+ recallButton = btn
+ RefreshRecallIndicator()
+ registerBindOverlay(btn, function() return "TOTEMBAR_RECALL" end)
+
+ return btn
+end
+
+-- The "drop set" button: one click casts all four chosen totems
+-- (TotemBar.recallAndCastAll, with its 2s double-press guard). Placed after
+-- the Recall button.
+CreateDropSetButton = function(index)
+ local name = "TotemBarButtonDropSet"
+ local btn = CreateFrame("Button", name, TotemBarFrame)
+ btn:SetWidth(BUTTON_SIZE)
+ btn:SetHeight(BUTTON_SIZE)
+ btn:SetPoint("LEFT", TotemBarFrame, "LEFT", (index - 1) * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP, 0)
+ btn:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 12,
+ insets = { left = 3, right = 3, top = 3, bottom = 3 },
+ })
+
+ local icon = btn:CreateTexture(name .. "Icon", "ARTWORK")
+ icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3)
+ icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3)
+ icon:SetTexture(ICONS_TEXTURE)
+ local dc = iconCoords[ICON_DROPSET_CELL]
+ icon:SetTexCoord(dc.l, dc.r, dc.t, dc.b)
+ btn.icon = icon
+
+ -- Grounding shadow ellipse (Rev 4 UI chrome). No glow on this button,
+ -- so creation order relative to other regions doesn't matter here.
+ local shadow = btn:CreateTexture(name .. "Shadow", "BACKGROUND")
+ shadow:SetTexture(UI_TEXTURE)
+ local sc = uiCoords[UI_SHADOW_CELL]
+ shadow:SetTexCoord(sc.l, sc.r, sc.t, sc.b)
+ shadow:SetPoint("CENTER", btn, "CENTER", 0, -15)
+ shadow:SetWidth(58)
+ shadow:SetHeight(20)
+ -- Softened after preview review: full-strength plate read boxy on light
+ -- ground; flatter + more transparent grounds without drawing attention.
+ shadow:SetAlpha(0.7)
+ btn.shadow = shadow
+
+ -- Same floating round frame as the element buttons (permanent
+ -- decorative band, no fill/tint - no ringFill/pulseWave on this
+ -- button, guarded inside ApplyRoundFrame).
+ local ringTrack = btn:CreateTexture(name .. "RingTrack", "OVERLAY")
+ ringTrack:SetPoint("CENTER", btn, "CENTER", 0, 0)
+ ringTrack:SetWidth(RING_TEX_SIZE)
+ ringTrack:SetHeight(RING_TEX_SIZE)
+ btn.ringTrack = ringTrack
+ ApplyRoundFrame(btn)
+
+ -- Round hover/pushed FX states (Rev 3), same sheet as the other
+ -- bar buttons.
+ ApplyRoundClickStates(btn, RING_TEX_SIZE)
+ btn:RegisterForClicks("LeftButtonUp")
+ btn:SetScript("OnClick", function()
+ TotemBar.recallAndCastAll()
+ end)
+ btn:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_TOP")
+ GameTooltip:SetText("Drop all totems")
+ GameTooltip:AddLine("Left-click: cast your whole set", 1, 1, 1)
+ local cost = TotemBar.sumChosenCost(TotemBarDB.chosen, TotemBar.TOTEM_ELEMENTS, TotemBar.getTotemManaCost)
+ if cost and cost > 0 then
+ GameTooltip:AddLine("Mana: " .. cost, 0.6, 0.6, 1)
+ end
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
+
+ registerBindOverlay(btn, function() return "TOTEMBAR_DROPSET" end)
+ dropSetButton = btn
+ return btn
+end
+
+-- Refreshes every element button's remaining-duration timer text.
+-- HYBRID source: prefers pfUI libtotem's GetTotemInfo(slot) when
+-- present and reporting the slot active (also catches totems cast
+-- outside TotemBar); otherwise falls back to TotemBar's own
+-- cast-tracking (TotemBar.activeTotems). Called at most ~5x/sec by
+-- OnBarUpdate below, never per-frame.
+UpdateTimerDisplays = function()
+ local now = GetTime()
+ local hasGTI = (type(GetTotemInfo) == "function")
+ local elements = TotemBar.TOTEM_ELEMENTS
+ local activeTotems = TotemBar.activeTotems
+ local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end
+
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local btn = elementButtons[element]
+ if btn then
+ -- Own-tracking: compute remaining, evicting the record the
+ -- moment it expires (per-element table, no growth).
+ local ownRecord = activeTotems[element]
+ local ownRemaining = nil
+ if ownRecord then
+ ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
+ if not ownRemaining or ownRemaining <= 0 then
+ activeTotems[element] = nil
+ ownRemaining = nil
+ ownRecord = nil -- expired: not "active" for the range-tint check below either
+ end
+ end
+
+ -- Spawn ripple: a NEW own cast (start changed) fires one ripple.
+ -- The freshness check keeps restored/old records from replaying.
+ -- Gated on showPulseWaves (the ripple's own toggle), not
+ -- showPulseBars.
+ if TotemBarDB.showPulseWaves then
+ local startNow = ownRecord and ownRecord.start
+ if startNow and startNow ~= btn.lastStartSeen then
+ btn.lastStartSeen = startNow
+ if now - startNow < 1.5 then
+ btn.spawnAt = startNow
+ end
+ elseif not ownRecord then
+ btn.lastStartSeen = nil
+ end
+ end
+
+ -- pfUI libtotem, when present: Fire=1, Earth=2, Water=3,
+ -- Air=4 - i.e. exactly TotemBar.TOTEM_ELEMENTS' own order.
+ local gtiActive, gtiRemaining, gtiName, gtiStart, gtiDuration
+ if hasGTI then
+ local active, tname, start, duration = GetTotemInfo(i)
+ gtiActive = active
+ gtiName = tname
+ gtiStart = start
+ gtiDuration = duration
+ if start and duration then
+ gtiRemaining = TotemBar.remaining(start, duration, now)
+ end
+ end
+
+ local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining)
+
+ if remainingVal and TotemBarDB.showTimerText then
+ if not btn.timerVisible then
+ btn.timerText:Show()
+ btn.timerVisible = true
+ end
+ local text = TotemBar.formatRemaining(remainingVal)
+ if text ~= btn.timerLastText then
+ btn.timerText:SetText(text)
+ btn.timerLastText = text
+ end
+ local isLow = remainingVal <= 5
+ if isLow ~= btn.timerLastLow then
+ if isLow then
+ btn.timerText:SetTextColor(1, 0.15, 0.15, 1)
+ else
+ btn.timerText:SetTextColor(1, 1, 1, 1)
+ end
+ btn.timerLastLow = isLow
+ end
+ elseif btn.timerVisible then
+ btn.timerText:Hide()
+ btn.timerVisible = false
+ btn.timerLastText = nil
+ btn.timerLastLow = nil
+ end
+
+ -- Duration ring: flipbook frame from remaining/total, tinted as
+ -- a time-remaining traffic light (green->yellow->orange->red -
+ -- user feedback: element color didn't convey urgency). Gated on
+ -- showDurationRing for the ARC only (ringFill) - ringTrack (the
+ -- permanent decorative frame band) is never hidden here.
+ if TotemBarDB.showDurationRing and remainingVal then
+ local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
+ ownRemaining, ownRecord and ownRecord.duration)
+ local idx = TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES)
+ if idx ~= btn.ringLastIdx then
+ local rc = allRingCoords[idx + 1]
+ btn.ringFill:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
+ btn.ringLastIdx = idx
+ end
+ local tr, tg, tb
+ if totalDur and totalDur > 0 then
+ tr, tg, tb = TotemBar.timeColor(remainingVal / totalDur)
+ else
+ tr, tg, tb = 1, 1, 1
+ end
+ btn.ringFill:SetVertexColor(tr, tg, tb)
+ if not btn.ringVisible then
+ btn.ringFill:Show()
+ btn.ringVisible = true
+ end
+ elseif btn.ringVisible then
+ btn.ringFill:Hide()
+ btn.ringVisible = false
+ btn.ringLastIdx = nil
+ end
+
+ -- Ripple wave + pulse arc inputs: only for totems with
+ -- PULSE_DATA. Phase origin: own record start (anchored by real
+ -- gain events where available) or libtotem's start for casts
+ -- made outside TotemBar. Both the wave's animation AND the
+ -- pulse arc's flipbook frame are driven every FRAME in
+ -- UpdateWaves below (moved off this 0.1s tick - a 10Hz flipbook
+ -- update read as choppy, see rev7 report) - this block just
+ -- refreshes what they should animate toward. showPulseWaves and
+ -- showPulseBars are independent display toggles, but the arc
+ -- reads the SAME waveInterval/waveOrigin/waveOneshotAt/
+ -- waveOneshotDelay fields as the wave, so they're populated
+ -- whenever EITHER toggle is on.
+ local waveSet = false
+ if remainingVal then
+ local totemName = (ownRecord and ownRecord.totemName) or gtiName
+ local pd = totemName and TotemBar.PULSE_DATA[totemName]
+ if pd then
+ local startAt = (ownRecord and ownRecord.start) or gtiStart
+ if startAt then
+ if TotemBarDB.showPulseWaves or TotemBarDB.showPulseBars then
+ if pd.ptype == "oneshot" then
+ btn.waveInterval = nil
+ btn.waveOneshotAt = startAt + pd.delay
+ btn.waveOrigin = nil
+ btn.waveOneshotDelay = pd.delay
+ else
+ btn.waveInterval = pd.interval
+ btn.waveOrigin = (ownRecord and ownRecord.pulseAnchor) or startAt
+ btn.waveOneshotAt = nil
+ btn.waveOneshotDelay = nil
+ end
+ waveSet = true
+ end
+ end
+ end
+ end
+ if not waveSet then
+ btn.waveInterval = nil
+ btn.waveOneshotAt = nil
+ btn.waveOrigin = nil
+ btn.waveOneshotDelay = nil
+ if btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+ end
+ -- Pulse arc hide-on-expiry: UpdateWaves (every frame) recomputes
+ -- arcRatio from the fields above and will already hide it within
+ -- the next frame once waveInterval/waveOneshotAt go nil, but that
+ -- can be up to one frame stale right after this tick - not worth
+ -- a duplicate hide-path here (see rev7 report).
+
+ -- Out-of-range tint: buff-presence based. A totem's party
+ -- buff uses the SAME icon texture as the totem spell itself
+ -- (verified in-game), so "am I in range?" == "do I have a
+ -- buff whose texture matches this totem's icon?".
+ --
+ -- ACTIVE requires an own-tracking record (ownRecord, already
+ -- nil'd above once its stored duration expires) AND, when
+ -- pfUI's libtotem is present, GetTotemInfo(i) agreeing the
+ -- slot is still active. That second check is what keeps a
+ -- totem someone/something DESTROYED (burned, killed) before
+ -- its timer ran out from flashing red - once GTI says the
+ -- slot is gone, we just go back to normal, not red.
+ local rangeActive = (ownRecord ~= nil)
+ if rangeActive and hasGTI and not gtiActive then
+ rangeActive = false
+ end
+
+ if not rangeActive then
+ if btn.tintRed then
+ btn.icon:SetVertexColor(1, 1, 1)
+ btn.tintRed = false
+ end
+ else
+ local hasBuff = TotemBar.hasBuffWithIcon(ownRecord.icon)
+ if hasBuff then
+ -- In range: remember it (self-learning - marks this
+ -- as a buff totem so a later drop-off can be told
+ -- apart from a totem that simply never grants one).
+ ownRecord.everHadBuff = true
+ if btn.tintRed then
+ btn.icon:SetVertexColor(1, 1, 1)
+ btn.tintRed = false
+ end
+ elseif ownRecord.everHadBuff then
+ -- Had the buff earlier from this cast, don't have it
+ -- now: wandered out of the totem's range.
+ if not btn.tintRed then
+ btn.icon:SetVertexColor(1, 0.35, 0.35)
+ btn.tintRed = true
+ end
+ else
+ -- Either a non-buff totem (Searing/Magma/Grounding/
+ -- etc. never grant a matching buff, so everHadBuff
+ -- stays false forever -> never red) or we simply
+ -- haven't been in range yet since this cast.
+ if btn.tintRed then
+ btn.icon:SetVertexColor(1, 1, 1)
+ btn.tintRed = false
+ end
+ end
+ end
+
+ if btn.tintRed then
+ outOfRangeFound = true
+ end
+ end
+ end
+
+ anyOutOfRange = outOfRangeFound
+end
+
+-- Re-applies every Pulse-UI setting to the live buttons (options panel
+-- setters call this; cheap, not on any hot path).
+function TotemBar.RefreshPulseUI()
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local btn = elementButtons[elements[i]]
+ if btn then
+ ApplyRoundFrame(btn)
+ ApplyTimerAnchor(btn)
+ if not TotemBarDB.showDurationRing and btn.ringVisible then
+ btn.ringFill:Hide()
+ btn.ringVisible = false
+ btn.ringLastIdx = nil
+ end
+ if not TotemBarDB.showPulseWaves then
+ btn.waveInterval = nil
+ btn.waveOneshotAt = nil
+ btn.waveOrigin = nil
+ btn.waveOneshotDelay = nil
+ btn.spawnAt = nil
+ if btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+ if btn.glow then
+ btn.glow:Hide()
+ btn.glowVisible = false
+ end
+ end
+ if not TotemBarDB.showPulseBars and btn.pulseVisible then
+ btn.pulseArc:Hide()
+ btn.pulseVisible = false
+ btn.pulseArcLastIdx = nil
+ end
+ if not TotemBarDB.showTimerText and btn.timerVisible then
+ btn.timerText:Hide()
+ btn.timerVisible = false
+ btn.timerLastText = nil
+ btn.timerLastLow = nil
+ end
+ end
+ end
+
+ -- Recall/DropSet share ApplyRoundFrame with the element buttons
+ -- (harmless re-apply of their static ring-frame texture; kept for
+ -- symmetry with the element-button refresh above). Flyout icons don't
+ -- go through ApplyRoundFrame (see EnsureFlyoutFrame) but their ring
+ -- frame texture is likewise static now, set once at creation - no
+ -- per-refresh work needed for them.
+ if recallButton then
+ ApplyRoundFrame(recallButton)
+ end
+ if dropSetButton then
+ ApplyRoundFrame(dropSetButton)
+ end
+end
+
+-- Dev aid, one line of live WoW-object state for a single ring texture
+-- (ringFill, ringTrack, the icon, or timerText), called by
+-- TotemBar.DumpRingRenderState below. Every getter is pcall-wrapped
+-- individually ("?" on failure/absence) - this must never itself error,
+-- not even on a stale/nil texture object.
+-- GetTexCoord's return arity is UNCONFIRMED on this 1.12 client (4-value
+-- left/right/top/bottom vs the 8-value corner form retail uses) - its
+-- result is captured via a table constructor (`{ pcall(...) }`) instead
+-- of assuming a fixed number of return values, so the dump itself is the
+-- in-game measurement that settles the question.
+-- rect=[...]/layer= added alongside (2026-07-16, occlusion follow-up): the
+-- screen-space GetLeft/Right/Top/Bottom quad and GetDrawLayer, so a stale
+-- anchor (ringFill not actually over the button) or a same-layer sibling
+-- collision (1.12 discards SetDrawLayer sublevels - same main layer means
+-- undefined draw order between siblings) show up directly in the dump
+-- instead of needing to be inferred from shown/alpha/texCoord alone.
+local function DumpRingTextureLine(element, label, tex)
+ if not tex then
+ return " " .. element .. " " .. label .. ": (nil texture object)\n"
+ end
+ local shownOk, shown = pcall(tex.IsShown, tex)
+ local texOk, texPath = pcall(tex.GetTexture, tex)
+ local alphaOk, alpha = pcall(tex.GetAlpha, tex)
+ local wOk, w = pcall(tex.GetWidth, tex)
+ local hOk, h = pcall(tex.GetHeight, tex)
+ local leftOk, left = pcall(tex.GetLeft, tex)
+ local rightOk, right = pcall(tex.GetRight, tex)
+ local topOk, top = pcall(tex.GetTop, tex)
+ local bottomOk, bottom = pcall(tex.GetBottom, tex)
+ local layerOk, layer = pcall(tex.GetDrawLayer, tex)
+
+ local parentShown, parentAlpha = "?", "?"
+ local parentOk, parent = pcall(tex.GetParent, tex)
+ if parentOk and parent then
+ local pShownOk, pShown = pcall(parent.IsShown, parent)
+ local pAlphaOk, pAlpha = pcall(parent.GetAlpha, parent)
+ if pShownOk then parentShown = tostring(pShown) end
+ if pAlphaOk then parentAlpha = tostring(pAlpha) end
+ end
+
+ local coordParts = { pcall(tex.GetTexCoord, tex) }
+ local coordStr = "?"
+ if coordParts[1] then
+ local n = table.getn(coordParts)
+ local s = ""
+ for ci = 2, n do
+ if s ~= "" then s = s .. "," end
+ s = s .. tostring(coordParts[ci])
+ end
+ coordStr = "[" .. s .. "]"
+ end
+
+ return " " .. element .. " " .. label .. ": shown=" .. (shownOk and tostring(shown) or "?")
+ .. " texture='" .. (texOk and tostring(texPath) or "?") .. "'"
+ .. " alpha=" .. (alphaOk and tostring(alpha) or "?")
+ .. " texCoord=" .. coordStr
+ .. " w=" .. (wOk and tostring(w) or "?")
+ .. " h=" .. (hOk and tostring(h) or "?")
+ .. " parentShown=" .. parentShown
+ .. " parentAlpha=" .. parentAlpha
+ .. " rect=[" .. (leftOk and tostring(left) or "?") .. "," .. (topOk and tostring(top) or "?")
+ .. "," .. (rightOk and tostring(right) or "?") .. "," .. (bottomOk and tostring(bottom) or "?") .. "]"
+ .. " layer=" .. (layerOk and tostring(layer) or "?") .. "\n"
+end
+
+-- Dev aid, one line per region returned by a button's own :GetRegions()
+-- (2026-07-16, occlusion follow-up). GetRegions() is Blizzard's own
+-- z-order-relevant enumeration of the regions directly parented to a
+-- frame - within a shared main draw layer (see the sublevel-discard note
+-- above) it's the best available proxy for actual sibling paint order, so
+-- walking it WITH the index is how we tell "ringTrack drawn 2nd, after
+-- ringFill" apart from "ringTrack drawn 1st, before ringFill" from outside
+-- the client. Deliberately only ever called on a single small element
+-- button (~8 regions), never on a big container frame - a wide frame can
+-- carry hundreds of regions and walking all of them was the cause of an
+-- earlier crash, hence the hard cap below regardless of how many the
+-- client reports.
+local REGION_WALK_CAP = 24
+local function DumpRegionLine(idx, region)
+ if not region then
+ return " [" .. idx .. "] (nil region)\n"
+ end
+ local typeOk, objType = pcall(region.GetObjectType, region)
+ local nameOk, name = pcall(region.GetName, region)
+ local layerOk, layer = pcall(region.GetDrawLayer, region)
+ local shownOk, shown = pcall(region.IsShown, region)
+ local leftOk, left = pcall(region.GetLeft, region)
+ local topOk, top = pcall(region.GetTop, region)
+
+ -- Texture and FontString expose different "what is this" getters
+ -- (GetTexture vs GetText); try both, pcall-guarded, and use whichever
+ -- succeeds - a region of the other kind simply fails the wrong one.
+ local content = "?"
+ local texOk, texPath = pcall(region.GetTexture, region)
+ if texOk and texPath ~= nil then
+ content = "texture='" .. tostring(texPath) .. "'"
+ else
+ local textOk, text = pcall(region.GetText, region)
+ if textOk and text ~= nil then
+ content = "text='" .. tostring(text) .. "'"
+ end
+ end
+
+ return " [" .. idx .. "] name=" .. (nameOk and tostring(name) or "?")
+ .. " type=" .. (typeOk and tostring(objType) or "?")
+ .. " layer=" .. (layerOk and tostring(layer) or "?")
+ .. " shown=" .. (shownOk and tostring(shown) or "?")
+ .. " " .. content
+ .. " left=" .. (leftOk and tostring(left) or "?")
+ .. " top=" .. (topOk and tostring(top) or "?") .. "\n"
+end
+
+-- Dev aid, called from TotemBar.DumpTimerState (core/cast.lua)'s /tb tdump
+-- so the export stays a single file/single command. Purpose: the pure
+-- ring math (resolveRemaining/resolveDuration/ringFrameIndex) was already
+-- proven correct offline for the Searing-ring report (text shows, no
+-- ring), so this section captures what DumpTimerState's own-tracking/GTI
+-- dump can't: the RENDER side. Per element button: (a) which button
+-- instance is bound to the element, (b) the cached ring state this same
+-- render path reads/writes (btn.ringVisible/btn.ringLastIdx - the exact
+-- fields UpdateTimerDisplays above uses, not a guess), (c) a same-tick
+-- recompute of resolveDuration/ringFrameIndex/timeColor with the SAME
+-- inputs the render path would use right now, (d) the countdown
+-- FontString's own live state as a control group (it's the one the player
+-- actually sees), and (e) the LIVE WoW state of both ring textures (ringFill, the
+-- gated arc in question, and ringTrack, the always-on decorative band -
+-- if ringTrack is ALSO dark then the whole assembly, not just the fill,
+-- is hidden/mispositioned upstream of this addon's own Show/Hide calls).
+function TotemBar.DumpRingRenderState()
+ local now = GetTime()
+ local hasGTI = (type(GetTotemInfo) == "function")
+ local elements = TotemBar.TOTEM_ELEMENTS
+ local activeTotems = TotemBar.activeTotems
+ local out = "\n-- render state (per-element ring + text, live WoW objects) --\n"
+
+ for i = 1, table.getn(elements) do
+ local element = elements[i]
+ local btn = elementButtons[element]
+ if not btn then
+ out = out .. element .. ": (no button)\n"
+ else
+ local btnName = "?"
+ local okName, nameVal = pcall(btn.GetName, btn)
+ if okName and nameVal then
+ btnName = nameVal
+ end
+
+ -- Same recompute inputs/precedence as UpdateTimerDisplays
+ -- above, same tick (i.e. what the render path would compute
+ -- if it ran again right now).
+ local ownRecord = activeTotems[element]
+ local ownRemaining = nil
+ if ownRecord then
+ ownRemaining = TotemBar.remaining(ownRecord.start, ownRecord.duration, now)
+ end
+ local gtiActive, gtiRemaining, gtiDuration
+ if hasGTI then
+ local ok, active, tname, start, duration = pcall(GetTotemInfo, i)
+ if ok then
+ gtiActive, gtiDuration = active, duration
+ if start and duration then
+ gtiRemaining = TotemBar.remaining(start, duration, now)
+ end
+ end
+ end
+ local remainingVal = TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining)
+ local totalDur = TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration,
+ ownRemaining, ownRecord and ownRecord.duration)
+ local idxStr = "n/a"
+ if remainingVal then
+ idxStr = tostring(TotemBar.ringFrameIndex(remainingVal, totalDur, RING_FRAMES))
+ end
+ local colorStr = "n/a"
+ if totalDur and totalDur > 0 and remainingVal then
+ local okC, cr, cg, cb = pcall(TotemBar.timeColor, remainingVal / totalDur)
+ if okC then
+ colorStr = "(" .. tostring(cr) .. "," .. tostring(cg) .. "," .. tostring(cb) .. ")"
+ end
+ end
+
+ out = out .. element .. ": btn=" .. btnName
+ .. " ringVisible=" .. tostring(btn.ringVisible)
+ .. " ringLastIdx=" .. tostring(btn.ringLastIdx)
+ .. " | recompute: remainingVal=" .. tostring(remainingVal)
+ .. " totalDur=" .. tostring(totalDur)
+ .. " ringIdx=" .. idxStr
+ .. " timeColor=" .. colorStr .. "\n"
+
+ -- Control group: the countdown text the player actually sees, so a
+ -- mismatch (text shown, ring both cached+live hidden) pins the
+ -- bug to the ring specifically, not a shared render gate.
+ -- Guarded on btn.timerText itself (not just pcall on its
+ -- methods): indexing .IsShown/.GetText on a nil timerText
+ -- would error while pcall's OWN arguments are being evaluated,
+ -- i.e. outside pcall's protection - shouldn't happen (created
+ -- unconditionally in CreateElementButton) but this dump must
+ -- never be the thing that errors.
+ local tShown, tText = "?", "?"
+ local tLeft, tTop, tRight, tBottom, tLayer = "?", "?", "?", "?", "?"
+ if btn.timerText then
+ local tShownOk, tShownVal = pcall(btn.timerText.IsShown, btn.timerText)
+ local tTextOk, tTextVal = pcall(btn.timerText.GetText, btn.timerText)
+ local tLeftOk, tLeftVal = pcall(btn.timerText.GetLeft, btn.timerText)
+ local tRightOk, tRightVal = pcall(btn.timerText.GetRight, btn.timerText)
+ local tTopOk, tTopVal = pcall(btn.timerText.GetTop, btn.timerText)
+ local tBottomOk, tBottomVal = pcall(btn.timerText.GetBottom, btn.timerText)
+ local tLayerOk, tLayerVal = pcall(btn.timerText.GetDrawLayer, btn.timerText)
+ if tShownOk then tShown = tostring(tShownVal) end
+ if tTextOk then tText = tostring(tTextVal) end
+ if tLeftOk then tLeft = tostring(tLeftVal) end
+ if tRightOk then tRight = tostring(tRightVal) end
+ if tTopOk then tTop = tostring(tTopVal) end
+ if tBottomOk then tBottom = tostring(tBottomVal) end
+ if tLayerOk then tLayer = tostring(tLayerVal) end
+ end
+ out = out .. " " .. element .. " timerText: shown=" .. tShown
+ .. " text='" .. tText .. "'"
+ .. " rect=[" .. tLeft .. "," .. tTop .. "," .. tRight .. "," .. tBottom .. "]"
+ .. " layer=" .. tLayer .. "\n"
+
+ out = out .. DumpRingTextureLine(element, "ringFill", btn.ringFill)
+ out = out .. DumpRingTextureLine(element, "ringTrack", btn.ringTrack)
+ out = out .. DumpRingTextureLine(element, "icon", btn.icon)
+
+ -- Region walk (2026-07-16, occlusion follow-up): btn is a
+ -- small element button (icon/shadow/ringTrack/glow/timerText,
+ -- ~5 regions now that ringFill/pulseWave/pulseArc moved off
+ -- btn - see below) - safe to enumerate in full, unlike a big
+ -- container frame. Index order is GetRegions()'s own return
+ -- order, our best proxy for actual same-layer sibling paint
+ -- order (see DumpRegionLine above).
+ local regionParts = { pcall(btn.GetRegions, btn) }
+ if regionParts[1] then
+ local total = table.getn(regionParts) - 1
+ local capped = total
+ if capped > REGION_WALK_CAP then capped = REGION_WALK_CAP end
+ out = out .. " " .. element .. " regions (count=" .. total .. ", showing " .. capped .. "):\n"
+ for ri = 1, capped do
+ out = out .. DumpRegionLine(ri, regionParts[ri + 1])
+ end
+ else
+ out = out .. " " .. element .. " regions: (GetRegions failed)\n"
+ end
+
+ -- Second region walk (2026-07-16, ring-overlay fix): ringFill/
+ -- pulseWave/pulseArc now live on btn.ringOverlay, a separate
+ -- higher-FrameLevel child frame (see CreateRingOverlay) - they
+ -- no longer show up in btn:GetRegions() above at all, so the
+ -- dump would silently go blind on exactly the textures this
+ -- tool exists to inspect without this second walk. Same small
+ -- frame (~3 regions), same cap.
+ if btn.ringOverlay then
+ local overlayParts = { pcall(btn.ringOverlay.GetRegions, btn.ringOverlay) }
+ if overlayParts[1] then
+ local oTotal = table.getn(overlayParts) - 1
+ local oCapped = oTotal
+ if oCapped > REGION_WALK_CAP then oCapped = REGION_WALK_CAP end
+ out = out .. " " .. element .. " ringOverlay regions (count=" .. oTotal .. ", showing " .. oCapped .. "):\n"
+ for ri = 1, oCapped do
+ out = out .. DumpRegionLine(ri, overlayParts[ri + 1])
+ end
+ else
+ out = out .. " " .. element .. " ringOverlay regions: (GetRegions failed)\n"
+ end
+ else
+ out = out .. " " .. element .. " ringOverlay: (nil)\n"
+ end
+ end
+ end
+
+ return out
+end
+
+-- Ripple animation + pulse-arc flipbook: expanding/fading ring in the
+-- element color (one wave per pulse) and the circular countdown arc, both
+-- driven every FRAME (pure float math + Set calls, no allocation, same
+-- class as OnRecallUpdate - the arc joined the wave here in rev7, was a
+-- 0.1s-tick flipbook update before and read as choppy). Inputs are
+-- refreshed by the 0.1s tick in UpdateTimerDisplays above. Wave/glow are
+-- gated on showPulseWaves, the arc on showPulseBars - independent toggles
+-- that happen to share the same waveInterval/waveOrigin/waveOneshotAt/
+-- waveOneshotDelay fields (see the tick's comment).
+UpdateWaves = function()
+ local now = GetTime()
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ local btn = elementButtons[elements[i]]
+ if btn then
+ if TotemBarDB.showPulseWaves then
+ local frac = nil
+ if btn.spawnAt then
+ frac = TotemBar.oneshotWaveFrac(btn.spawnAt, 0, now, SPAWN_WAVE_DURATION)
+ if not frac then
+ btn.spawnAt = nil
+ end
+ end
+ if not frac then
+ if btn.waveInterval then
+ local ratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
+ frac = TotemBar.waveFrac(ratio, btn.waveInterval, WAVE_DURATION)
+ elseif btn.waveOneshotAt then
+ frac = TotemBar.oneshotWaveFrac(btn.waveOneshotAt, 0, now, WAVE_DURATION)
+ end
+ end
+ if frac then
+ local size = RING_TEX_SIZE * (1 + (WAVE_MAX_SCALE - 1) * frac)
+ btn.pulseWave:SetWidth(size)
+ btn.pulseWave:SetHeight(size)
+ btn.pulseWave:SetAlpha(WAVE_BASE_ALPHA * (1 - frac))
+ if not btn.waveVisible then
+ btn.pulseWave:Show()
+ btn.waveVisible = true
+ end
+ elseif btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+
+ -- Anticipation glow: ramps up as the next pulse (or the
+ -- oneshot detonation) approaches; hidden otherwise. Tied to
+ -- the wave's own toggle (showPulseWaves), unchanged.
+ local ga = nil
+ if btn.waveInterval then
+ local gratio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
+ ga = TotemBar.glowRamp(gratio, GLOW_RAMP_START)
+ elseif btn.waveOneshotAt and btn.waveOneshotDelay and now < btn.waveOneshotAt then
+ ga = TotemBar.glowRamp(1 - ((btn.waveOneshotAt - now) / btn.waveOneshotDelay), GLOW_RAMP_START)
+ end
+ if ga then
+ btn.glow:SetAlpha(GLOW_MAX_ALPHA * ga)
+ if not btn.glowVisible then
+ btn.glow:Show()
+ btn.glowVisible = true
+ end
+ elseif btn.glowVisible then
+ btn.glow:Hide()
+ btn.glowVisible = false
+ end
+ elseif btn.waveVisible or btn.glowVisible then
+ -- Toggled off (RefreshPulseUI normally already clears these
+ -- immediately; this is the per-frame safety net).
+ if btn.waveVisible then
+ btn.pulseWave:Hide()
+ btn.waveVisible = false
+ end
+ if btn.glowVisible then
+ btn.glow:Hide()
+ btn.glowVisible = false
+ end
+ end
+
+ -- Pulse arc: continuous countdown readout toward the next
+ -- pulse/detonation (own toggle: showPulseBars, independent of
+ -- the wave/glow above). A duplicate pulseRatio()/oneshotRatio()
+ -- call vs. the wave block above is cheap and keeps this block
+ -- self-contained (see rev7 report).
+ local arcRatio = nil
+ if TotemBarDB.showPulseBars then
+ if btn.waveInterval then
+ arcRatio = TotemBar.pulseRatio(btn.waveOrigin, nil, btn.waveInterval, now)
+ elseif btn.waveOneshotAt and btn.waveOneshotDelay then
+ arcRatio = TotemBar.oneshotRatio(btn.waveOneshotAt - btn.waveOneshotDelay, btn.waveOneshotDelay, now)
+ if arcRatio and arcRatio >= 1 then arcRatio = nil end
+ end
+ end
+ if arcRatio then
+ local idx = math.floor(arcRatio * (RING_FRAMES - 1) + 0.5)
+ if idx ~= btn.pulseArcLastIdx then
+ local rc = allRingCoords[idx + 1]
+ btn.pulseArc:SetTexCoord(rc.l, rc.r, rc.t, rc.b)
+ btn.pulseArcLastIdx = idx
+ end
+ if not btn.pulseVisible then
+ btn.pulseArc:Show()
+ btn.pulseVisible = true
+ end
+ elseif btn.pulseVisible then
+ btn.pulseArc:Hide()
+ btn.pulseVisible = false
+ btn.pulseArcLastIdx = nil
+ end
+ end
+ end
+end
+
+OnBarUpdate = function()
+ UpdateWaves()
+ timerElapsed = timerElapsed + arg1
+ if timerElapsed < TIMER_UPDATE_INTERVAL then
+ return
+ end
+ timerElapsed = 0
+ UpdateTimerDisplays()
+end
+
+OnDragStart = function()
+ if not TotemBarDB.locked then
+ this:StartMoving()
+ end
+end
+
+OnDragStop = function()
+ this:StopMovingOrSizing()
+ local point, _, relPoint, x, y = this:GetPoint()
+ TotemBarDB.point = point
+ TotemBarDB.relPoint = relPoint
+ TotemBarDB.x = x
+ TotemBarDB.y = y
+end
+
+-- (Re)applies the current BUTTON_GAP to the pending-assignment panel: frame
+-- width (4 icons + gaps + the fixed accept-button margin) and each icon's
+-- horizontal anchor - both baked in once when the panel is first built, so
+-- (like layoutFlyoutIcons above) this must be called again from
+-- TotemBar.SetButtonGap when the spacing slider changes live, not just at
+-- build time. `frame` lets the build-time call pass the not-yet-assigned
+-- local `f` directly; later calls omit it and fall back to the assignFrame
+-- upvalue (guarded: a no-op if the panel has never been built yet).
+local function layoutAssignIcons(frame)
+ local f = frame or assignFrame
+ if not f then
+ return
+ end
+ f:SetWidth(4 * (BUTTON_SIZE + BUTTON_GAP) + BUTTON_GAP + 40)
+ for i = 1, 4 do
+ local ico = f.icons and f.icons[i]
+ if ico then
+ ico:ClearAllPoints()
+ ico:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT",
+ BUTTON_GAP + (i - 1) * (BUTTON_SIZE + BUTTON_GAP), 6)
+ end
+ end
+end
+
+-- Lazily builds the pending-suggestion panel: a heading label, a row of up
+-- to 4 element-ordered totem icons, an Accept button, and a close "X".
+-- Built once, reused; event-driven show/hide (no OnUpdate, no per-frame
+-- allocation). Anchored above the bar.
+EnsureAssignFrame = function()
+ if assignFrame then
+ return assignFrame
+ end
+
+ -- Parent to the bar so the pending panel inherits the bar's scale
+ -- (UI-size slider), like the flyout. Still DIALOG strata below. Width
+ -- is set below by layoutAssignIcons (once f.icons exists), not here.
+ local f = CreateFrame("Frame", "TotemBarAssignFrame", TotemBarFrame)
+ f:SetFrameStrata("DIALOG")
+ f:SetHeight(BUTTON_SIZE + 34)
+ -- Rev 4 UI chrome: shared panel skin (bespoke border/bg) instead of the
+ -- generic tooltip textures. The bg texture carries the color now, so
+ -- the tint just needs to be near-opaque white.
+ f:SetBackdrop(PANEL_BACKDROP)
+ f:SetBackdropColor(1, 1, 1, 0.97)
+ f:SetClampedToScreen(true)
+ f:ClearAllPoints()
+ f:SetPoint("BOTTOM", TotemBarFrame, "TOP", 0, 6)
+
+ local heading = f:CreateFontString("TotemBarAssignHeading", "OVERLAY")
+ heading:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE")
+ heading:SetPoint("TOP", f, "TOP", 0, -6)
+ heading:SetText("Assigned set")
+ f.heading = heading
+
+ f.icons = {}
+ for i = 1, 4 do
+ local ico = f:CreateTexture("TotemBarAssignIcon" .. i, "ARTWORK")
+ ico:SetWidth(BUTTON_SIZE)
+ ico:SetHeight(BUTTON_SIZE)
+ -- Anchor applied below via layoutAssignIcons (once the whole row
+ -- exists), not here - the same helper re-applies it live when
+ -- BUTTON_GAP changes.
+ ico:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ f.icons[i] = ico
+ end
+
+ -- Sets the frame width and every icon's anchor per BUTTON_GAP now that
+ -- the whole row exists (assignFrame isn't assigned yet at this point in
+ -- the function, hence passing `f` explicitly - see layoutAssignIcons's
+ -- own comment).
+ layoutAssignIcons(f)
+
+ local accept = CreateFrame("Button", "TotemBarAssignAccept", f, "UIPanelButtonTemplate")
+ accept:SetWidth(28)
+ accept:SetHeight(BUTTON_SIZE)
+ accept:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -6, 6)
+ accept:SetText("OK")
+ accept:SetScript("OnClick", function()
+ TotemBar.ApplyPending()
+ end)
+ accept:SetScript("OnEnter", function()
+ GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
+ GameTooltip:SetText("Apply assigned set")
+ GameTooltip:AddLine("Sets these as your chosen totems (no cast).", 1, 1, 1)
+ GameTooltip:Show()
+ end)
+ accept:SetScript("OnLeave", function() GameTooltip:Hide() end)
+ f.accept = accept
+
+ local close = CreateFrame("Button", "TotemBarAssignClose", f, "UIPanelCloseButton")
+ close:SetWidth(22)
+ close:SetHeight(22)
+ close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -2, -2)
+ close:SetScript("OnClick", function()
+ TotemBar.ClearAssignment()
+ end)
+ f.close = close
+
+ f:Hide()
+ assignFrame = f
+ return f
+end
+
+-- Populates and shows the panel from TotemBar.pending. Unknown totems are
+-- greyed (desaturated + dimmed). Called by ReceiveAssignment via the hook.
+TotemBar.ShowAssignPanel = function()
+ local p = TotemBar.pending
+ if not p then
+ return
+ end
+ local f = EnsureAssignFrame()
+ f.heading:SetText(p.label or "Assigned set")
+
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, 4 do
+ local element = elements[i]
+ local name = element and p.set[element]
+ local ico = f.icons[i]
+ if name then
+ local tex, known = ResolveTotemIcon(name)
+ ico:SetTexture(tex)
+ if known then
+ ico:SetVertexColor(1, 1, 1)
+ ico:SetAlpha(1)
+ else
+ ico:SetVertexColor(0.5, 0.5, 0.5)
+ ico:SetAlpha(0.5)
+ end
+ ico:Show()
+ else
+ ico:Hide()
+ end
+ end
+ f:Show()
+end
+
+-- Hides the panel (called by ClearAssignment / ApplyPending via the hook).
+TotemBar.HideAssignPanel = function()
+ if assignFrame then
+ assignFrame:Hide()
+ end
+end
+
+-- Repositions the bar buttons (up to six) into the grid configured by
+-- TotemBarDB.barLayout and resizes the bar frame to match. Buttons are
+-- ordered: the four element buttons (TOTEM_ELEMENTS order), then Recall,
+-- then DropSet. The DropSet button is optional (TotemBarDB.showDropSetButton,
+-- options panel "Show drop-all button"): when off it is left out of the grid
+-- AND hidden, which drops the count to 5 - it is always the LAST slot in
+-- every layout table, so slots 1..5 stay exactly where they were and the
+-- 2x2 element block never breaks. Called once at the end of BuildUI (after
+-- every button exists), from options.lua's layout cycle button, and from
+-- that checkbox's setter. No-op before BuildUI has run (TotemBarFrame
+-- doesn't exist yet).
+TotemBar.ApplyBarLayout = function()
+ if not TotemBarFrame then
+ return
+ end
+
+ -- nil-safe read: an old SavedVariables file (or a call before
+ -- ensureDefaults) means "show", matching the default.
+ local showDrop = TotemBarDB.showDropSetButton
+ if showDrop == nil then
+ showDrop = true
+ end
+
+ local buttons = {}
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ tinsert(buttons, elementButtons[elements[i]])
+ end
+ tinsert(buttons, recallButton)
+ if showDrop then
+ tinsert(buttons, dropSetButton)
+ end
+
+ -- The button frame itself always exists (BuildUI creates it
+ -- unconditionally, so the toggle needs no UI rebuild and the bind
+ -- overlay stays registered) - only its visibility follows the option.
+ -- The TOTEMBAR_DROPSET keybinding keeps working either way: it is bound
+ -- to the cast function, not to this frame.
+ if dropSetButton then
+ if showDrop then
+ dropSetButton:Show()
+ else
+ dropSetButton:Hide()
+ end
+ end
+
+ local count = table.getn(buttons)
+ local layout = TotemBarDB.barLayout
+ -- Explicit per-button {col, row} slots (core/optionslogic.lua): the 2x3
+ -- layout keeps the four elements as a 2x2 block in the first two columns
+ -- with Recall/DropSet as the third column, which no row-major fill order
+ -- produces - hence a positions table per layout instead of index math.
+ local positions = TotemBar.BAR_LAYOUT_POSITIONS[layout] or TotemBar.BAR_LAYOUT_POSITIONS["1x6"]
+ local cols = BAR_LAYOUT_COLS[layout] or 6
+ local extra = 0
+
+ for i = 1, count do
+ local btn = buttons[i]
+ local pos = positions[i]
+ if btn and pos then
+ btn:ClearAllPoints()
+ btn:SetPoint("TOPLEFT", TotemBarFrame, "TOPLEFT",
+ BUTTON_GAP + pos[1] * (BUTTON_SIZE + BUTTON_GAP),
+ -(BUTTON_GAP + pos[2] * (BUTTON_SIZE + BUTTON_GAP + extra)))
+ end
+ end
+
+ local width, height = TotemBar.barDimensions(count, cols, BUTTON_SIZE, BUTTON_GAP, extra)
+ TotemBarFrame:SetWidth(width)
+ TotemBarFrame:SetHeight(height)
+end
+
+function TotemBar.BuildUI()
+ if TotemBarFrame then
+ return
+ end
+
+ local numElements = table.getn(TotemBar.TOTEM_ELEMENTS)
+
+ -- Placeholder size: TotemBar.ApplyBarLayout() (called below, once every
+ -- button exists) positions each button per the configured grid and
+ -- resizes the frame for real - this initial size is never seen on screen.
+ local frame = CreateFrame("Frame", "TotemBarFrame", UIParent)
+ frame:SetWidth(BUTTON_SIZE + BUTTON_GAP * 2)
+ frame:SetHeight(BUTTON_SIZE + BUTTON_GAP * 2)
+ frame:SetBackdrop({
+ bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
+ edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
+ tile = true, tileSize = 16, edgeSize = 16,
+ insets = { left = 4, right = 4, top = 4, bottom = 4 },
+ })
+ -- Floating icons: the bar's own backdrop is fully transparent (an
+ -- invisible drag/hit-test area only - size/anchors unchanged).
+ frame:SetBackdropColor(0, 0, 0, 0)
+ frame:SetBackdropBorderColor(0, 0, 0, 0)
+ frame:SetScale(TotemBarDB.scale or 1.0)
+
+ frame:SetMovable(true)
+ frame:EnableMouse(true)
+ frame:RegisterForDrag("LeftButton")
+ frame:SetScript("OnDragStart", OnDragStart)
+ frame:SetScript("OnDragStop", OnDragStop)
+
+ frame:ClearAllPoints()
+ frame:SetPoint(TotemBarDB.point, UIParent, TotemBarDB.relPoint, TotemBarDB.x, TotemBarDB.y)
+
+ for i = 1, numElements do
+ local element = TotemBar.TOTEM_ELEMENTS[i]
+ CreateElementButton(element, i)
+ RefreshCooldown(element) -- initial swipe state for whatever's already chosen
+ end
+ CreateRecallButton(numElements + 1)
+ CreateDropSetButton(numElements + 2)
+
+ -- Positions every button per TotemBarDB.barLayout and gives the frame
+ -- its real size (see ApplyBarLayout above and the placeholder-size
+ -- note on the CreateFrame call).
+ TotemBar.ApplyBarLayout()
+
+ frame:SetScript("OnUpdate", OnBarUpdate)
+
+ frame:Show()
+ if TotemBarDB.hidden then
+ frame:Hide()
+ end
+
+ -- Show any already-bound key labels right after login (overlays are
+ -- always visible now, not gated on bind mode - see refreshBindOverlays).
+ TotemBar.refreshBindOverlays()
+end
+
+-- Scales the bar while keeping its TOP-LEFT corner visually fixed, so the
+-- bar grows/shrinks toward the bottom-right instead of drifting away from
+-- where the player put it. SetScale scales the anchor offset too, so a naive
+-- SetScale moves the frame; here we capture the top-left's ABSOLUTE screen
+-- position (local coord * effective scale = pixels), apply the new scale,
+-- then re-anchor TOPLEFT so those pixels are preserved. Persists the new
+-- scale + anchor so the next login reproduces it.
+function TotemBar.SetBarScale(newScale)
+ local f = TotemBarFrame
+ if not f then
+ return
+ end
+ if not newScale or newScale <= 0 then
+ newScale = 1
+ end
+ local before = f:GetEffectiveScale()
+ local left = f:GetLeft()
+ local top = f:GetTop()
+ f:SetScale(newScale)
+ if left and top and before then
+ local leftPx = left * before
+ local topPx = top * before
+ local after = f:GetEffectiveScale()
+ f:ClearAllPoints()
+ f:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", leftPx / after, topPx / after)
+ end
+ if TotemBarDB then
+ TotemBarDB.scale = newScale
+ local p, _, rp, x, y = f:GetPoint()
+ if p then
+ TotemBarDB.point = p
+ TotemBarDB.relPoint = rp
+ TotemBarDB.x = x
+ TotemBarDB.y = y
+ end
+ end
+end
+
+-- Live-applies a new button gap (px, range 10-30): reassigns the shared
+-- BUTTON_GAP upvalue (every closure that reads it - element/flyout/assign-
+-- panel positioning, ApplyBarLayout - sees the new value on its next read,
+-- no re-declaration needed) and persists it. Two icon pools bake their
+-- BUTTON_GAP-dependent anchors in once at build time instead of recomputing
+-- them on every read (unlike ApplyBarLayout, which already re-reads
+-- BUTTON_GAP fresh every call and just needs re-running): the flyout icon
+-- pool and the pending-assignment panel, both re-laid-out here via their
+-- own helpers (layoutFlyoutIcons/layoutAssignIcons above), each a no-op if
+-- that particular frame was never built yet. Driven by the options panel's
+-- "Button spacing" slider.
+function TotemBar.SetButtonGap(newGap)
+ if not newGap then
+ newGap = TotemBar.DEFAULT_BUTTON_GAP or 10
+ end
+ newGap = TotemBar.clampValue(newGap, 10, 30)
+ BUTTON_GAP = newGap
+ if TotemBarDB then
+ TotemBarDB.buttonGap = newGap
+ end
+ layoutFlyoutIcons()
+ layoutAssignIcons()
+ if TotemBar.ApplyBarLayout then
+ TotemBar.ApplyBarLayout()
+ end
+end
+
+-- Shows/hides the bar and persists the choice (TotemBarDB.hidden). Driven
+-- by the options panel's "Show bar" checkbox, the minimap right-click, and
+-- the bare /tb command.
+function TotemBar.ToggleBar()
+ if not TotemBarFrame then
+ return
+ end
+ if TotemBarFrame:IsShown() then
+ TotemBarFrame:Hide()
+ TotemBarDB.hidden = true
+ else
+ TotemBarFrame:Show()
+ TotemBarDB.hidden = false
+ end
+end
+
+-- Resets the bar's saved anchor to centered defaults and re-anchors it.
+-- Does NOT change scale. Driven by the options panel's "Reset position".
+function TotemBar.ResetPosition()
+ TotemBarDB.point = "CENTER"
+ TotemBarDB.relPoint = "CENTER"
+ TotemBarDB.x = 0
+ TotemBarDB.y = 0
+ if TotemBarFrame then
+ TotemBarFrame:ClearAllPoints()
+ TotemBarFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+ end
+end
+
+-- "/tb scan" dev-aid: one-shot print of every spellbook entry that looks
+-- like a totem, so the static element map in core/totemdata.lua can be
+-- checked against the live TurtleWoW client. Also writes the list to
+-- \imports\totembar_scan.txt via SuperWoW's ExportFile (when
+-- present) so it can be read off-client without pasting from chat.
+function TotemBar.PrintScan()
+ local names = TotemBar.scanSpellbook()
+ local count = 0
+ local dump = ""
+ ChatOut:AddMessage("TotemBar: scanning spellbook for totems...")
+ for i = 1, table.getn(names) do
+ if string.find(names[i], "Totem", 1, true) then
+ ChatOut:AddMessage(" " .. names[i])
+ dump = dump .. names[i] .. "\n"
+ count = count + 1
+ end
+ end
+ ChatOut:AddMessage("TotemBar: " .. count .. " totem spell(s) found.")
+ -- ExportFile appends .txt itself; pass the name WITHOUT extension.
+ if ExportFile then
+ ExportFile("totembar_scan", "totems=" .. count .. "\n" .. dump)
+ end
+end
+
+local function HandleSlashCommand(msg)
+ local cmd = string.lower(msg or "")
+ if cmd == "" then
+ TotemBar.ToggleBar()
+ elseif cmd == "lock" then
+ TotemBarDB.locked = not TotemBarDB.locked
+ if TotemBarDB.locked then
+ ChatOut:AddMessage("TotemBar: bar locked.")
+ else
+ ChatOut:AddMessage("TotemBar: bar unlocked (drag to move).")
+ end
+ elseif cmd == "scan" then
+ TotemBar.PrintScan()
+ elseif cmd == "assign" then
+ -- Dev aid: inject a sample assignment to exercise the pending panel
+ -- + accept/decline flow end-to-end without any transport.
+ local sample = {
+ Fire = "Searing Totem",
+ Earth = "Strength of Earth Totem",
+ Water = "Mana Spring Totem",
+ Air = "Grace of Air Totem",
+ }
+ local ok, reason = TotemBar.ReceiveAssignment(sample, "TEST assignment")
+ if ok then
+ ChatOut:AddMessage("TotemBar: injected TEST assignment (click OK on the panel to apply).")
+ else
+ ChatOut:AddMessage("TotemBar: assign failed - " .. tostring(reason))
+ end
+ elseif cmd == "options" or cmd == "opt" then
+ TotemBar.ToggleOptions()
+ elseif cmd == "bind" then
+ if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
+ elseif cmd == "manadump" then
+ if TotemBar.dumpManaScan then TotemBar.dumpManaScan() end
+ elseif cmd == "tdump" then
+ if TotemBar.DumpTimerState then TotemBar.DumpTimerState() end
+ elseif string.find(cmd, "^pulsecal") then
+ local _, _, sub = string.find(cmd, "^pulsecal%s*(%a*)")
+ if TotemBar.PulseCal then TotemBar.PulseCal(sub or "") end
+ else
+ ChatOut:AddMessage("TotemBar: unknown command '" .. msg .. "'. Usage: /tb, /tb lock, /tb scan, /tb assign, /tb options, /tb bind, /tb manadump, /tb tdump, /tb pulsecal")
+ end
+end
+
+-- SPELL_UPDATE_COOLDOWN fires whenever any spell's cooldown starts or
+-- ends (the standard signal the default action bars key off of) - this
+-- is the event-driven trigger for RefreshCooldown, kept separate from
+-- the throttled per-tick timer OnUpdate (see RefreshCooldown's comment
+-- for why: SetTimer-on-every-tick would restart the swipe animation).
+local eventFrame = CreateFrame("Frame", "TotemBarEventFrame", UIParent)
+eventFrame:RegisterEvent("ADDON_LOADED")
+eventFrame:RegisterEvent("SPELL_UPDATE_COOLDOWN")
+eventFrame:RegisterEvent("SPELLS_CHANGED")
+eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
+eventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS")
+eventFrame:SetScript("OnEvent", function()
+ if event == "ADDON_LOADED" and arg1 == "TotemBar" then
+ TotemBar.ensureDefaults()
+ -- BUTTON_GAP was initialized above (file scope, load time) to the
+ -- file-load default, before TotemBarDB was even readable - it must
+ -- be overwritten by the SAVED value now, BEFORE BuildUI runs, or
+ -- the very first layout bakes in the default instead of whatever
+ -- the player set via the options panel's "Button spacing" slider.
+ BUTTON_GAP = TotemBar.clampValue(TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP or 10, 10, 30)
+ TotemBar.BuildUI()
+ eventFrame:UnregisterEvent("ADDON_LOADED")
+ elseif event == "SPELLS_CHANGED" then
+ -- BuildUI resolves each slot's icon off the live spellbook at
+ -- ADDON_LOADED time, but the spellbook (GetSpellName/
+ -- GetSpellTexture) isn't reliably populated that early after a
+ -- login/reload - so a SAVED totem's icon lookup falls through to
+ -- the sheet's empty-slot glyph even though its name is stored
+ -- fine. SPELLS_CHANGED fires once the spellbook is ready (and
+ -- whenever it later changes), so re-resolve every icon here.
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ RefreshButton(elements[i])
+ end
+ if recallButton and recallButton.icon then
+ -- Re-set BOTH texture and crop texcoord: SetTexCoord persists
+ -- across SetTexture, so a stale non-crop coord state must not
+ -- survive a texture re-resolve.
+ recallButton.icon:SetTexture(GetRecallIcon())
+ recallButton.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
+ end
+ elseif event == "SPELL_UPDATE_COOLDOWN" then
+ local elements = TotemBar.TOTEM_ELEMENTS
+ for i = 1, table.getn(elements) do
+ RefreshCooldown(elements[i])
+ end
+ RefreshFlyoutCooldowns()
+ elseif event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
+ or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS" then
+ -- Re-anchor pulse phase on REAL observed periodic gains (spec 5.5).
+ local totemName = TotemBar.parseSelfGain(arg1)
+ if totemName then
+ local element = TotemBar.elementOf(totemName)
+ local rec = element and TotemBar.activeTotems[element]
+ if rec and rec.totemName == totemName then
+ rec.pulseAnchor = GetTime()
+ end
+ end
+ end
+end)
+
+SLASH_TOTEMBAR1 = "/tb"
+SlashCmdList = SlashCmdList or {}
+SlashCmdList["TOTEMBAR"] = HandleSlashCommand