TotemBar v0.2.5

This commit is contained in:
2026-08-05 15:54:45 +02:00
commit 8dbc411e80
35 changed files with 5934 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
<Bindings>
<Binding name="TOTEMBAR_DROPSET" runOnUp="true" header="TOTEMBAR">TotemBar.dropSetKey(keystate);</Binding>
<Binding name="TOTEMBAR_RECALL">TotemBar.CastRecall();</Binding>
<Binding name="TOTEMBAR_TOGGLEBAR">TotemBar.ToggleBar();</Binding>
<Binding name="TOTEMBAR_TOGGLEOPTIONS">TotemBar.ToggleOptions();</Binding>
<Binding name="TOTEMBAR_TOGGLEBIND">TotemBar.ToggleBindMode();</Binding>
<Binding name="TOTEMBAR_CAST_FIRE">TotemBar.CastElement("Fire");</Binding>
<Binding name="TOTEMBAR_CAST_EARTH">TotemBar.CastElement("Earth");</Binding>
<Binding name="TOTEMBAR_CAST_WATER">TotemBar.CastElement("Water");</Binding>
<Binding name="TOTEMBAR_CAST_AIR">TotemBar.CastElement("Air");</Binding>
<Binding name="TOTEMBAR_TOTEM_SEARING_TOTEM" header="TOTEMBAR_FIRE">TotemBar.CastTotem("Searing Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FIRE_NOVA_TOTEM">TotemBar.CastTotem("Fire Nova Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_MAGMA_TOTEM">TotemBar.CastTotem("Magma Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FLAMETONGUE_TOTEM">TotemBar.CastTotem("Flametongue Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FROST_RESISTANCE_TOTEM">TotemBar.CastTotem("Frost Resistance Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_EARTHBIND_TOTEM" header="TOTEMBAR_EARTH">TotemBar.CastTotem("Earthbind Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_STONECLAW_TOTEM">TotemBar.CastTotem("Stoneclaw Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_STONESKIN_TOTEM">TotemBar.CastTotem("Stoneskin Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_STRENGTH_OF_EARTH_TOTEM">TotemBar.CastTotem("Strength of Earth Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_TREMOR_TOTEM">TotemBar.CastTotem("Tremor Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_FIRE_RESISTANCE_TOTEM" header="TOTEMBAR_WATER">TotemBar.CastTotem("Fire Resistance Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_HEALING_STREAM_TOTEM">TotemBar.CastTotem("Healing Stream Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_MANA_SPRING_TOTEM">TotemBar.CastTotem("Mana Spring Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_POISON_CLEANSING_TOTEM">TotemBar.CastTotem("Poison Cleansing Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_DISEASE_CLEANSING_TOTEM">TotemBar.CastTotem("Disease Cleansing Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_MANA_TIDE_TOTEM">TotemBar.CastTotem("Mana Tide Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_GRACE_OF_AIR_TOTEM" header="TOTEMBAR_AIR">TotemBar.CastTotem("Grace of Air Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_GROUNDING_TOTEM">TotemBar.CastTotem("Grounding Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_NATURE_RESISTANCE_TOTEM">TotemBar.CastTotem("Nature Resistance Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_SENTRY_TOTEM">TotemBar.CastTotem("Sentry Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_WINDFURY_TOTEM">TotemBar.CastTotem("Windfury Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_WINDWALL_TOTEM">TotemBar.CastTotem("Windwall Totem");</Binding>
<Binding name="TOTEMBAR_TOTEM_TRANQUIL_AIR_TOTEM">TotemBar.CastTotem("Tranquil Air Totem");</Binding>
</Bindings>
+195
View File
@@ -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 (1030 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).
+21
View File
@@ -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.
+205
View File
@@ -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.
<img src="screenshots/pulse-ui.png" alt="TotemBar in action: round slots with duration and pulse rings, totems placed in the world" width="100%">
## 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 |
| :---: | :---: |
| <img src="screenshots/bar.png" alt="The totem bar with Recall tooltip" width="100%"><br><img src="screenshots/flyout.png" alt="Hover flyout with spell tooltip" width="100%"> | <img src="screenshots/options.png" alt="Options panel" width="70%"> |
| Key bindings | Hover-bind mode |
| :---: | :---: |
| <img src="screenshots/keybindings.png" alt="Esc menu key bindings section" width="100%"> | <img src="screenshots/bind-mode.png" alt="Hover-bind mode with key labels" width="100%"> |
*(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).
+25
View File
@@ -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
+231
View File
@@ -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)
+147
View File
@@ -0,0 +1,147 @@
-- TotemBar - core/assign.lua
-- Assignment receiver seam: the contract + pure logic by which an external
-- assigner (future "ShamiPower") hands TotemBar a proposed totem set.
-- WoW-API-light so the logic below is offline-testable under real Lua 5.0.
-- The pending-suggestion UI lives in ui.lua and is reached through the
-- optional hook slots (ShowAssignPanel/HideAssignPanel/isTotemKnown/
-- RefreshAll) that ui.lua fills in at load time.
--
-- An "assignment set" is a table keyed by element -> totem spell name:
-- { Fire = "Searing Totem", Air = "Windfury Totem", ... }
-- Any subset of elements; a missing element means "nothing for that slot".
TotemBar = TotemBar or {}
-- True if `key` is one of the four totem elements.
function TotemBar.isElement(key)
if not key then
return false
end
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
if elements[i] == key then
return true
end
end
return false
end
-- Validates an assignment set. Returns true, or false plus a reason string.
function TotemBar.validateAssignment(set)
if type(set) ~= "table" then
return false, "set must be a table"
end
local count = 0
for k, v in pairs(set) do
if not TotemBar.isElement(k) then
return false, "unknown element key: " .. tostring(k)
end
if type(v) ~= "string" or v == "" then
return false, "totem name for " .. tostring(k) .. " must be a non-empty string"
end
count = count + 1
end
if count == 0 then
return false, "set is empty"
end
return true
end
-- Shallow copy of a set, keeping only valid element keys.
function TotemBar.copySet(set)
local out = {}
if type(set) == "table" then
for k, v in pairs(set) do
if TotemBar.isElement(k) then
out[k] = v
end
end
end
return out
end
-- Fresh copy (element -> name) of the currently chosen totems.
function TotemBar.GetChosenSet()
local out = {}
local chosen = TotemBarDB and TotemBarDB.chosen
if chosen then
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
local e = elements[i]
if chosen[e] then
out[e] = chosen[e]
end
end
end
return out
end
-- Splits a set into applied (isKnown(name) true) and skipped (false).
function TotemBar.filterKnown(set, isKnown)
local applied, skipped = {}, {}
for k, v in pairs(set) do
if isKnown(v) then
applied[k] = v
else
skipped[k] = v
end
end
return applied, skipped
end
-- Current pending assignment: { set = {element=name,...}, label = string } or nil.
-- In-memory only (not a SavedVariable) - an assignment is ephemeral coordination.
TotemBar.pending = nil
-- Optional hook, filled by an external assigner: called with the applied
-- set table after the player accepts. Nil by default.
TotemBar.onAssignmentApplied = nil
-- THE SEAM. An external assigner calls this to propose a set. Validates,
-- stores it as the pending suggestion (replacing any prior), and asks the
-- UI to show the panel. Does NOT apply. Returns true, or false + reason.
function TotemBar.ReceiveAssignment(set, label)
local ok, reason = TotemBar.validateAssignment(set)
if not ok then
return false, reason
end
TotemBar.pending = { set = TotemBar.copySet(set), label = label }
if TotemBar.ShowAssignPanel then
TotemBar.ShowAssignPanel()
end
return true
end
-- Drops any pending suggestion and hides the panel (decline / post-apply).
function TotemBar.ClearAssignment()
TotemBar.pending = nil
if TotemBar.HideAssignPanel then
TotemBar.HideAssignPanel()
end
end
-- Applies the pending suggestion: sets each KNOWN totem as the chosen
-- default for its element (unknown totems are skipped), refreshes the bar,
-- clears pending, hides the panel, and fires onAssignmentApplied. No cast.
function TotemBar.ApplyPending()
local p = TotemBar.pending
if not p then
return
end
local isKnown = TotemBar.isTotemKnown or function() return true end
local applied = TotemBar.filterKnown(p.set, isKnown)
TotemBarDB.chosen = TotemBarDB.chosen or {}
for element, name in pairs(applied) do
TotemBarDB.chosen[element] = name
end
if TotemBar.RefreshAll then
TotemBar.RefreshAll()
end
TotemBar.pending = nil
if TotemBar.HideAssignPanel then
TotemBar.HideAssignPanel()
end
if TotemBar.onAssignmentApplied then
TotemBar.onAssignmentApplied(applied)
end
end
+73
View File
@@ -0,0 +1,73 @@
-- TotemBar - core/bindlogic.lua
-- PURE helpers for keybindings + hover-bind mode (no WoW API), offline-testable.
TotemBar = TotemBar or {}
-- Binding-name suffix for a totem: uppercase, non-alphanumeric runs -> "_",
-- trimmed. "Grace of Air Totem" -> "GRACE_OF_AIR_TOTEM". MUST match the
-- Bindings.xml generation and the BINDING_NAME_ globals.
function TotemBar.bindingSuffix(name)
if not name then
return ""
end
local up = string.upper(name)
up = string.gsub(up, "[^A-Z0-9]+", "_")
up = string.gsub(up, "^_+", "")
up = string.gsub(up, "_+$", "")
return up
end
-- Modifier prefix for a key string, order ALT-CTRL-SHIFT (each arg is 1/nil
-- as IsAltKeyDown()/IsControlKeyDown()/IsShiftKeyDown() return on 1.12).
function TotemBar.modifierPrefix(isAlt, isCtrl, isShift)
local p = ""
if isAlt then p = p .. "ALT-" end
if isCtrl then p = p .. "CTRL-" end
if isShift then p = p .. "SHIFT-" end
return p
end
-- Binding COMMAND for a hovered thing, or nil. A flyout icon (totemName
-- given) -> the named per-totem binding (casts that specific totem, same
-- action as the Esc menu). A bar button (by global frame name) -> the
-- matching NAMED binding that already exists in Bindings.xml (NOT a
-- "CLICK ..." binding - those are unreliable on 1.12, see bind.lua).
function TotemBar.actionForButton(frameName, totemName)
if totemName and totemName ~= "" then
return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totemName)
end
if not frameName then
return nil
end
if frameName == "TotemBarButtonRecall" then
return "TOTEMBAR_RECALL"
end
if frameName == "TotemBarButtonDropSet" then
return "TOTEMBAR_DROPSET"
end
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
if frameName == "TotemBarButton" .. elements[i] then
return "TOTEMBAR_CAST_" .. string.upper(elements[i])
end
end
return nil
end
-- Compact a binding key string for a small button overlay:
-- "SHIFT-NUMPAD7" -> "sN7", "BUTTON4" -> "M4", "MOUSEWHEELUP" -> "MwU".
function TotemBar.shortenKey(key)
if not key or key == "" then
return ""
end
local s = key
s = string.gsub(s, "ALT%-", "a")
s = string.gsub(s, "CTRL%-", "c")
s = string.gsub(s, "SHIFT%-", "s")
s = string.gsub(s, "MOUSEWHEELUP", "MwU")
s = string.gsub(s, "MOUSEWHEELDOWN", "MwD")
s = string.gsub(s, "NUMPAD", "N")
s = string.gsub(s, "BUTTON", "M")
s = string.gsub(s, "SPACE", "Sp")
return s
end
+647
View File
@@ -0,0 +1,647 @@
-- TotemBar - core/cast.lua
-- Cast-cycle logic. The decision-making is split into two PURE,
-- offline-tested functions (nextIndex, findFilledSlot); castNext() is
-- the thin wrapper that touches CastSpellByName / GetTime / TotemBarDB.
TotemBar = TotemBar or {}
TotemBar.DEFAULT_GAP_SECONDS = 2
-- Anti double-press guard for recallAndCastAll: if a deploy happened within
-- this many seconds, a rapid second press SKIPS the recall (so it doesn't
-- pull the just-placed totems, which are still on their ~1.5s element
-- cooldown and couldn't be re-placed).
TotemBar.DEFAULT_RECALL_GUARD = 2
-- Default gap (px) between bar buttons. Matches ui.lua's file-scope
-- BUTTON_GAP default; the options panel's "Button spacing" slider (range
-- 10-30px) live-applies changes via TotemBar.SetButtonGap (ui.lua) and
-- persists them to TotemBarDB.buttonGap (core/config.lua).
TotemBar.DEFAULT_BUTTON_GAP = 10
-- Cycle state: which slot was cast last, and when.
TotemBar.castState = TotemBar.castState or {
index = 0, -- 0 = no cast yet (or state was reset)
lastTime = 0,
lastDeployTime = 0,
}
-- Own-tracking table for the OmniCC-style remaining-duration display in
-- ui.lua: fallback source for when pfUI's libtotem (GetTotemInfo) isn't
-- present, or reports a given slot inactive. element -> {start=,
-- duration=}, both in TotemBar.recordCast() below.
TotemBar.activeTotems = TotemBar.activeTotems or {}
-- Pure: given the previously cast slot index, the time of that previous
-- cast, the current time, the allowed gap (seconds) and the number of
-- slots, returns the next slot index (1-based) to advance to.
--
-- - prevIndex <= 0 (never cast yet) -> 1
-- - now - lastTime > gapSeconds -> 1 (fresh spam, start over)
-- - otherwise -> prevIndex + 1, wrapping from
-- numSlots back to 1
function TotemBar.nextIndex(prevIndex, lastTime, now, gapSeconds, numSlots)
if not prevIndex or prevIndex <= 0 then
return 1
end
if not lastTime or (now - lastTime) > gapSeconds then
return 1
end
local nxt = prevIndex + 1
if nxt > numSlots then
nxt = 1
end
return nxt
end
-- Pure: starting at startIndex, walk forward through `elements`
-- (wrapping past the end back to 1) and return the index of the first
-- element for which chosen[element] is truthy (a totem name). Returns
-- nil if none of the slots are filled.
function TotemBar.findFilledSlot(chosen, elements, startIndex)
local numSlots = table.getn(elements)
if numSlots == 0 or not startIndex then
return nil
end
for tries = 0, numSlots - 1 do
local slot = startIndex + tries
if slot > numSlots then
slot = slot - numSlots
end
if chosen[elements[slot]] then
return slot
end
end
return nil
end
-- Pure: seconds remaining given a start time, a duration and the
-- current time. May return <= 0 (already expired); callers decide how
-- to treat that. Returns nil if either start or duration is missing.
function TotemBar.remaining(start, duration, now)
if not start or not duration then
return nil
end
return start + duration - now
end
-- Pure: is any element's own-tracked totem still out (remaining > 0)?
function TotemBar.anyActiveTracked(activeTotems, elements, now, remainingFn)
if not activeTotems then
return false
end
for i = 1, table.getn(elements) do
local rec = activeTotems[elements[i]]
if rec then
local rem = remainingFn(rec.start, rec.duration, now)
if rem and rem > 0 then
return true
end
end
end
return false
end
-- Pure: decides which of two already-computed remaining-seconds values
-- to show for one element's timer text. GetTotemInfo (pfUI's
-- libtotem), when it reports the slot active, is authoritative;
-- otherwise (absent, or reporting the slot inactive) falls back to
-- TotemBar's own cast-tracking. Returns nil when neither source has
-- time left.
function TotemBar.resolveRemaining(gtiActive, gtiRemaining, ownRemaining)
-- FIX 2026-07-15: trust GTI (pfUI libtotem) ONLY when it reports the slot active
-- AND with positive time left. Previously a stale-active GTI slot (active but
-- gtiRemaining<=0 -- libtotem evicts lazily on read) hit the bare `return nil` and
-- BLOCKED the timer even when own-tracking had a valid time. That produced the
-- Fire/Earth-dead, Air-flickering, Water-ok pattern (pfUI's single non-slot-indexed
-- cast queue loses the race for the slots cast later in a multi-drop).
if gtiActive and gtiRemaining and gtiRemaining > 0 then
return gtiRemaining
end
if ownRemaining and ownRemaining > 0 then
return ownRemaining
end
return nil
end
-- Pure: OmniCC-style text for an already-known-positive remaining
-- seconds value: whole minutes rounded up from 60s on, plain rounded-up
-- integer seconds below that.
function TotemBar.formatRemaining(remaining)
if remaining >= 60 then
return string.format("%dm", math.ceil(remaining / 60))
end
return string.format("%d", math.ceil(remaining))
end
-- Pure: strips a trailing rank suffix from a spell name as CastSpellByName
-- would receive it -- both "Searing Totem(Rank 4)" (no space, what the
-- client itself produces) and "Searing Totem (Rank 4)" (a space, common in
-- hand-typed macros) are accepted forms. Returns the input unchanged if it
-- carries no such suffix, or nil if name is nil.
function TotemBar.stripRankSuffix(name)
if not name then
return nil
end
local _, _, base = string.find(name, "^(.-)%s*%(.-%)%s*$")
if base and base ~= "" then
return base
end
return name
end
-- Pure: given a spell name as CastSpellByName/GetSpellName would produce it
-- (optionally rank-suffixed, see stripRankSuffix above), returns the
-- element it belongs to and its rank-stripped base name -- or nil, nil if
-- rawName is nil or isn't one of TotemBar's known totems (core/totemdata.lua).
-- Used by the universal CastSpellByName/CastSpell hooks below to decide
-- whether a cast caught outside TotemBar's own paths is a totem at all.
function TotemBar.elementFromCastName(rawName)
if not rawName then
return nil, nil
end
local baseName = TotemBar.stripRankSuffix(rawName)
local element = TotemBar.elementOf(baseName)
if not element then
return nil, nil
end
return element, baseName
end
-- Finds the spellbook index of a known spell by exact name, or nil.
-- Used to duplicate ui.lua's own file-local FindSpellIndexByName as its
-- own fresh linear scan (no common "api" module to hang a single copy
-- off). Both now route through core/spellindex.lua's cached
-- TotemBar.findSpellIndex (loaded earlier in the TOC) instead.
-- Records that `totemName` was just cast into `element`'s slot, into
-- TotemBar's own tracking table (see activeTotems above). Touches
-- GetTime(), a spellbook index/texture scan and (for Searing Totem) a
-- rank scan, so it isn't pure; called from both the bar's left-click
-- path (ui.lua) and castNext()/castAll() below.
--
-- Also stashes the totem spell's icon texture (rec.icon) and a
-- self-learning "did I ever see this totem's buff" flag
-- (rec.everHadBuff, starts false). ui.lua's out-of-range red-tint
-- feature is buff-presence based: a totem's party buff uses the SAME
-- icon texture as the totem spell itself (verified in-game), so
-- TotemBar.hasBuffWithIcon(rec.icon) tells whether the player is
-- currently benefiting from THIS cast totem.
function TotemBar.recordCast(element, totemName)
if not element or not totemName then
return
end
local highestRank = nil
if totemName == "Searing Totem" and TotemBar.highestKnownRank then
highestRank = TotemBar.highestKnownRank(totemName)
end
local icon = nil
local idx = TotemBar.findSpellIndex(totemName)
if idx then
icon = GetSpellTexture(idx, BOOKTYPE_SPELL)
end
local rec = {
start = GetTime(),
duration = TotemBar.durationWithMastery(
TotemBar.totemDuration(totemName, highestRank),
TotemBar.isHelpfulTotem(totemName),
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery()),
totemName = totemName,
icon = icon,
everHadBuff = false,
}
TotemBar.activeTotems[element] = rec
end
-- Records a totem cast caught by the universal CastSpellByName/CastSpell
-- hooks below, UNLESS TotemBar's own code already recorded this EXACT cast
-- this same GetTime() tick. Every TotemBar-exposed cast entry point
-- (bind.lua's CastTotem/CastElement, castNext/castAll/dropSetKey/
-- recallAndCastAll above, ui.lua's bar/flyout click handlers) already calls
-- TotemBar.recordCast() itself right after casting -- verified by a full
-- inventory of every Bindings.xml binding, the "Totems" macro, and every
-- click handler (2026-07-16): none of them skip it. So a hook firing for a
-- cast that ALSO went through one of those paths would otherwise
-- double-record in the same frame (harmless -- recordCast just overwrites
-- with a near-identical timestamp -- but trivially avoidable with this one
-- check, so it is).
function TotemBar.recordCastFromHook(element, totemName)
local existing = TotemBar.activeTotems[element]
if existing and existing.totemName == totemName and existing.start == GetTime() then
return
end
TotemBar.recordCast(element, totemName)
end
-- ===== Universal cast hooks: catch totem casts from ANY path =====
-- Defense-in-depth for totems cast WITHOUT going through any TotemBar
-- function at all -- e.g. a hand-written macro's own `/cast Searing Totem`,
-- or another addon. Every path TotemBar itself exposes already calls
-- recordCast() (see recordCastFromHook's comment above) so this is a
-- safety net, not the primary fix for a "no countdown" report.
--
-- Fixed arity on purpose (project rule: no vararg closures) -- both globals
-- have a stable, known 1.12 signature: CastSpellByName(name, onSelf),
-- CastSpell(spellId, bookType).
--
-- Plain global reassignment, not hooksecurefunc -- pfUI provides
-- hooksecurefunc as its own polyfill (compat/vanilla.lua; native 1.12 has
-- no such function), and TotemBar must not hard-depend on pfUI being
-- loaded. Saving whatever function is CURRENTLY bound to the global and
-- calling it first (before doing our own extra work) composes correctly
-- with pfUI's own libtotem hook on CastSpellByName/CastSpell regardless of
-- addon load order: whichever wraps last just adds a layer on top of
-- whatever was already there.
--
-- UseAction is intentionally NOT hooked. 1.12 has no GetActionInfo /
-- GetActionSpellId to read a spell off an action-bar slot directly; pfUI's
-- own UseAction coverage (libtotem.lua) only works by scanning a hidden
-- GameTooltip:SetAction(slot) via its private libtipscan library, wired up
-- through hooksecurefunc -- both the tooltip-scan trick and hooksecurefunc
-- are pfUI-internal, not something TotemBar can reimplement without either
-- a hard pfUI dependency or a whole new action-slot tooltip scanner (this
-- file already has a spellbook-index tooltip scanner for mana costs, see
-- core/manacost.lua, but nothing that scans by action slot). Given every
-- TotemBar-native path already records, and pfUI's GetTotemInfo already
-- covers action-bar-dragged totem casts as the resolveRemaining/
-- resolveDuration fallback source when pfUI is present, this gap was
-- judged not worth the added dependency/complexity.
if type(CastSpellByName) == "function" then
local origCastSpellByName = CastSpellByName
CastSpellByName = function(name, onSelf)
origCastSpellByName(name, onSelf)
local element, baseName = TotemBar.elementFromCastName(name)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
end
end
if type(CastSpell) == "function" then
local origCastSpell = CastSpell
CastSpell = function(spellId, bookType)
origCastSpell(spellId, bookType)
if type(GetSpellName) == "function" then
local rawName = GetSpellName(spellId, bookType)
local element, baseName = TotemBar.elementFromCastName(rawName)
if element then
TotemBar.recordCastFromHook(element, baseName)
end
end
end
end
-- Module-scratch table for the buff-texture scan below, reused every
-- call (hasBuffWithIcon runs ~5x/sec, from ui.lua's throttled timer
-- tick) so it doesn't allocate a new table each time. buffScratchLen
-- tracks how far the previous scan filled it, so leftover entries past
-- the new scan's length get nilled out - keeping it a clean, hole-free
-- 1..n array (table.getn needs that to be reliable in Lua 5.0).
local buffScratch = {}
local buffScratchLen = 0
-- Pure: given a flat array of buff texture path strings (some entries
-- may be nil) and a totem spell's icon texture path, returns true if
-- any buff texture matches iconPath via a case-insensitive literal
-- substring search (tolerates path/casing differences between
-- GetSpellTexture's and UnitBuff's returned strings). Returns false if
-- iconPath or buffTexList is nil, or nothing matches.
function TotemBar.buffTexturesMatch(buffTexList, iconPath)
if not iconPath or not buffTexList then
return false
end
local needle = string.lower(iconPath)
for i = 1, table.getn(buffTexList) do
local tex = buffTexList[i]
if tex and string.find(string.lower(tex), needle, 1, true) then
return true
end
end
return false
end
-- Thin WoW-API wrapper: scans the player's current buffs (UnitBuff
-- "player" 1..32, stopping at the first nil slot) into the reusable
-- buffScratch table, then hands it to the pure
-- TotemBar.buffTexturesMatch() above. This is the "am I in this totem's
-- range?" signal for ui.lua's red-tint feature: a totem's party buff
-- shares its spell's icon texture (verified in-game), so having a
-- matching buff means the totem is currently affecting the player.
function TotemBar.hasBuffWithIcon(iconPath)
if not iconPath then
return false
end
local n = 0
for i = 1, 32 do
local tex = UnitBuff("player", i)
if not tex then
break
end
n = n + 1
buffScratch[n] = tex
end
for i = n + 1, buffScratchLen do
buffScratch[i] = nil
end
buffScratchLen = n
return TotemBar.buffTexturesMatch(buffScratch, iconPath)
end
-- Clears every own-tracked totem timer at once (e.g. after Totemic
-- Recall, which drops all active totems simultaneously).
function TotemBar.clearActiveTotems()
for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
TotemBar.activeTotems[TotemBar.TOTEM_ELEMENTS[i]] = nil
end
end
-- Is at least one totem currently out? Used to avoid wasting Totemic Recall's
-- own 6-second cooldown on a no-op cast: recalling with nothing out still puts
-- Recall on cooldown, so a fresh set placed right after can't be recalled for
-- 6s. Permissive on purpose - returns true if EITHER our own cast-tracking OR
-- GetTotemInfo (pfUI libtotem / SuperWoW, when present) reports a totem out, so
-- a legitimate recall is never wrongly blocked; only when both agree nothing is
-- out do we suppress the cast.
function TotemBar.anyTotemOut()
if TotemBar.anyActiveTracked(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS,
GetTime(), TotemBar.remaining) then
return true
end
if GetTotemInfo then
for slot = 1, 4 do
if GetTotemInfo(slot) then
return true
end
end
end
return false
end
-- pure: does the addon currently hold ANY tracked totem record (regardless of expiry)?
-- TotemBar.activeTotems is an in-memory table cleared to {} on load (see top of this file,
-- NOT persisted to SavedVariables), so a non-empty table proves we have cast at least one
-- totem THIS session and can reason about whether it is still out.
function TotemBar.hasTrackedTotems(activeTotems, elements)
if not activeTotems then return false end
for i = 1, table.getn(elements) do
if activeTotems[elements[i]] then return true end
end
return false
end
-- Should the manual-recall gate BLOCK the recall? Only when we are CONFIDENT that nothing
-- is out: we have tracked at least one totem THIS session AND all tracked totems have
-- expired. When tracking is empty (fresh load, or after a /reload -- activeTotems is
-- in-memory only and reset on load) the state is UNKNOWN, not "none out": on 1.12 there is
-- no GetTotemInfo / no "totemN" UnitID to re-detect a totem that was cast BEFORE the reload
-- (KG-confirmed 2026-07-14 -- no reload-proof detection exists for all totem types). So in
-- the unknown case FAIL OPEN -- let the recall through rather than falsely reporting "no
-- totems out" and blocking a legitimate recall of a still-standing totem (the bug this fixes:
-- our reloads wiped the tracking while the totems stayed physically out). Worst case of
-- fail-open is one needless recall right after a reload -- far cheaper than a blocked one.
function TotemBar.confidentNoneOut()
if not TotemBar.hasTrackedTotems(TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS) then
return false
end
return not TotemBar.anyTotemOut()
end
-- Casts exactly ONE totem per call: the next slot in Fire -> Earth ->
-- Water -> Air order, skipping empty (unassigned) slots. If more than
-- gapSeconds has passed since the previous call, the cycle restarts at
-- the first filled slot (so a fresh spam always begins with totem 1).
--
-- Intended for a macro: `/script TotemBar.castNext()`
function TotemBar.castNext()
local db = TotemBarDB
local chosen = (db and db.chosen) or {}
local gap = (db and db.gapSeconds) or TotemBar.DEFAULT_GAP_SECONDS
local elements = TotemBar.TOTEM_ELEMENTS
local numSlots = table.getn(elements)
local now = GetTime()
local state = TotemBar.castState
local startIdx = TotemBar.nextIndex(state.index, state.lastTime, now, gap, numSlots)
local slot = TotemBar.findFilledSlot(chosen, elements, startIdx)
if not slot then
-- Nothing assigned to any element; nothing to cast.
state.index = 0
state.lastTime = now
return nil, nil
end
local element = elements[slot]
local totemName = chosen[element]
CastSpellByName(totemName)
TotemBar.recordCast(element, totemName)
state.index = slot
state.lastTime = now
return totemName, element
end
-- Casts ALL filled slots in a single call (Fire -> Earth -> Water ->
-- Air). On TurtleWoW each totem element has its own cooldown, so this
-- MAY drop all four from one keypress. Whether 4 CastSpellByName calls
-- in one Lua frame all land (vs only the last "winning") is unverified
-- on this client -- offered as a one-press alternative to castNext() to
-- test in-game.
--
-- Intended for a macro: `/script TotemBar.castAll()`
function TotemBar.castAll()
local db = TotemBarDB
local chosen = (db and db.chosen) or {}
local elements = TotemBar.TOTEM_ELEMENTS
for i = 1, table.getn(elements) do
local element = elements[i]
local totemName = chosen[element]
if totemName then
CastSpellByName(totemName)
TotemBar.recordCast(element, totemName)
end
end
end
-- Pure: should recallAndCastAll fire Totemic Recall this call?
-- autoRecall off -> false (never recall)
-- never deployed (nil / <=0) -> true (nothing fresh to protect)
-- last deploy > guard ago -> true
-- last deploy within guard -> false (protect just-placed totems from a
-- rapid accidental second press)
function TotemBar.shouldRecall(autoRecall, lastDeployTime, now, guardSeconds)
if not autoRecall then
return false
end
if not lastDeployTime or lastDeployTime <= 0 then
return true
end
if not guardSeconds then
return true
end
if (now - lastDeployTime) > guardSeconds then
return true
end
return false
end
-- Casts Totemic Recall bypassing nampower's spell queue when that API is
-- present. Root cause of the "place a set, it vanishes a moment later" bug:
-- under nampower, a plain CastSpellByName of Totemic Recall (a GCD-tied
-- instant) issued while a GCD is active is QUEUED, not cast, and pops at
-- GCD-end -- by which time the totems are already down (TWoW gives each totem
-- element its own non-GCD recovery category, so the 4 totems fire immediately
-- with queue priority). The late Recall then sweeps the fresh set. Bypassing
-- the queue means a GCD-blocked Recall simply fails that press instead of
-- being deferred, so it can never fire late and tear the totems down. Falls
-- back to a plain cast when nampower isn't installed (the function is nil).
-- (KG: NP_QueueInstantSpells default 1 is the deferral; CastSpellByNameNoQueue
-- is nampower's queue-bypass cast.)
local function castRecallNoQueue()
if type(CastSpellByNameNoQueue) == "function" then
CastSpellByNameNoQueue("Totemic Recall")
else
CastSpellByName("Totemic Recall")
end
end
-- Recall-then-deploy: when TotemBarDB.autoRecall is on (the default -
-- toggleable via the Recall button's right-click, see ui.lua), casts
-- Totemic Recall FIRST (drops existing totems and refunds some mana)
-- and clears own-tracking, then always places all filled slots via
-- castAll(). One keypress = recall + redeploy (or just redeploy, with
-- the flag off). Like castAll, relies on TurtleWoW allowing several
-- CastSpellByName calls in one Lua frame -- verify in-game.
--
-- Guarded against rapid double-presses: shouldRecall() only fires Recall
-- if the last deploy was more than DEFAULT_RECALL_GUARD seconds ago. A
-- fast accidental second press then just re-attempts placement (a no-op,
-- since the totems are still up and each element is on its own ~1.5s
-- cooldown) instead of recalling the totems that were just placed.
--
-- Intended for a macro: `/script TotemBar.recallAndCastAll()`
function TotemBar.recallAndCastAll()
local now = GetTime()
local autoRecall = TotemBarDB and TotemBarDB.autoRecall
local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.anyTotemOut() then
castRecallNoQueue()
TotemBar.clearActiveTotems()
end
TotemBar.castAll()
TotemBar.castState.lastDeployTime = now
end
-- Key-down/key-up split for the DropSet keybind. Bound in Bindings.xml with
-- runOnUp="true", so this runs on BOTH flanks with the global `keystate` set
-- to "down" / "up" beforehand. Casting Totemic Recall on the DOWN stroke and
-- placing the set on the RELEASE. The real fix for the teardown is
-- castRecallNoQueue() (see above) -- bypassing nampower's queue so a
-- GCD-blocked Recall never fires late and sweeps the fresh set. The down/up
-- split adds belt-and-suspenders temporal separation: the Recall on the down
-- stroke has definitively resolved-or-failed (it is never queued) by the time
-- the release places the set. Both flanks are real hardware events, so both
-- may cast (Blizzard's own ActionButtonUp casts on release too), unlike a
-- timer-deferred placement which this client blocks as a non-hardware cast.
--
-- Still guarded by shouldRecall()'s 2s window (a rapid re-press within the
-- guard skips the Recall so it can't pull the just-placed set). keystate is
-- nil only if the binding ran without runOnUp (misconfig / older client);
-- treat nil like "up" so a single fire at least still PLACES rather than
-- silently doing nothing.
function TotemBar.dropSetKey(keystate)
if keystate == "down" then
local now = GetTime()
local autoRecall = TotemBarDB and TotemBarDB.autoRecall
local guard = (TotemBarDB and TotemBarDB.recallGuardSeconds) or TotemBar.DEFAULT_RECALL_GUARD
if TotemBar.shouldRecall(autoRecall, TotemBar.castState.lastDeployTime, now, guard)
and TotemBar.anyTotemOut() then
castRecallNoQueue()
TotemBar.clearActiveTotems()
end
else
-- Release (or nil fallback): place the set now, in this hardware frame.
TotemBar.castAll()
TotemBar.castState.lastDeployTime = GetTime()
end
end
-- Dev aid (/tb tdump): dumps TotemBar's own per-element cast-tracking
-- (TotemBar.activeTotems, the resolveRemaining/resolveDuration own-tracking
-- fallback source, see this file's header comment) side by side with the
-- RAW GetTotemInfo(1..4) (pfUI libtotem, when present -- the OTHER,
-- normally-authoritative source), so a "countdown missing" report can be
-- diagnosed off-client without an in-game screenshot: did TotemBar ever
-- record this cast at all, and what does GTI say about the same slot right
-- now? Also appends ui.lua's TotemBar.DumpRingRenderState() output (2026-07-
-- 16, Searing-ring bug: countdown TEXT shows but no pulse RING) -- the pure
-- ring math was already proven correct offline, so this section captures
-- the WoW-side render state instead: cached ring flags plus the LIVE
-- ringFill/ringTrack texture objects (shown/texture/alpha/texCoord/parent),
-- see that function's own comment for the full field list. Written to
-- <WoW>\imports\tb_tdump.txt via SuperWoW's ExportFile (name WITHOUT
-- the .txt extension -- ExportFile appends it itself); chat fallback when
-- SuperWoW isn't present. GetTotemInfo is pcall-wrapped since it's
-- third-party (pfUI) code this dump must never itself error on.
function TotemBar.DumpTimerState()
local now = GetTime()
local els = TotemBar.TOTEM_ELEMENTS
local activeTotems = TotemBar.activeTotems
local out = "TotemBar timer-state dump (now=" .. tostring(now) .. ")\n"
out = out .. "\n-- own tracking (TotemBar.activeTotems) --\n"
for i = 1, table.getn(els) do
local element = els[i]
local rec = activeTotems[element]
if rec then
local rem = TotemBar.remaining(rec.start, rec.duration, now)
out = out .. element .. ": spell='" .. tostring(rec.totemName) .. "'"
.. " start=" .. tostring(rec.start)
.. " duration=" .. tostring(rec.duration)
.. " remaining=" .. tostring(rem) .. "\n"
else
out = out .. element .. ": (no record)\n"
end
end
out = out .. "\n-- raw GetTotemInfo(1..4) (pfUI libtotem, when present) --\n"
if type(GetTotemInfo) == "function" then
for slot = 1, 4 do
local ok, active, name, start, duration = pcall(GetTotemInfo, slot)
if ok then
out = out .. "slot " .. slot .. ": active=" .. tostring(active)
.. " name='" .. tostring(name) .. "'"
.. " start=" .. tostring(start)
.. " duration=" .. tostring(duration) .. "\n"
else
out = out .. "slot " .. slot .. ": pcall error: " .. tostring(active) .. "\n"
end
end
else
out = out .. "(GetTotemInfo not present -- no pfUI libtotem loaded)\n"
end
-- Render-state section (ui.lua): the pure timer/duration/ring math
-- above was already proven correct offline for the Searing-ring
-- report (text shows, no ring) -- this is the other half, the
-- WoW-side render state (cached ring flags + LIVE texture objects)
-- that this dump previously never captured. pcall-wrapped: ui.lua
-- loads after this file (see TotemBar.toc), but by the time a player
-- runs /tb tdump everything is loaded; the guard just keeps this dump
-- from erroring if that ever isn't true (e.g. a future load-order
-- change) or if TotemBar.DumpRingRenderState itself hits something
-- unexpected.
local okRender, renderOut = pcall(TotemBar.DumpRingRenderState)
if okRender and renderOut then
out = out .. renderOut
else
out = out .. "\n-- render state: unavailable (" .. tostring(renderOut) .. ") --\n"
end
if ExportFile then
ExportFile("tb_tdump", out)
end
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage("TotemBar: tdump exported.")
end
end
+84
View File
@@ -0,0 +1,84 @@
-- TotemBar - core/config.lua
-- SavedVariables model. TotemBarDB is declared in the .toc as a
-- SavedVariable; ensureDefaults() fills in anything missing (first run,
-- or a saved file from an older version that lacks newer fields).
TotemBar = TotemBar or {}
TotemBarDB = TotemBarDB or {}
-- TotemBarDB shape:
-- chosen[element] = totemName ("Fire" -> "Searing Totem", ...)
-- gapSeconds = spam-cycle reset gap (seconds)
-- locked = boolean, whether the bar can be dragged
-- autoRecall = boolean, whether recallAndCastAll() prepends
-- Totemic Recall (toggled via the Recall button's
-- right-click, see ui.lua); default ON
-- point/relPoint/x/y = saved frame anchor (see ui.lua)
-- showDurationRing/ringStyle/showPulseBars/showPulseWaves/
-- showTimerText = Pulse UI (see spec). showPulseBars = the countdown
-- bar (primary "when's the next pulse" readout);
-- showPulseWaves = the ripple (event feedback only);
-- independently toggleable.
-- barLayout = bar arrangement: "1x6"|"2x3"|"3x2" (rows x cols),
-- cycled via the options panel (see ui.lua's
-- ApplyBarLayout / options.lua's layout button).
-- showDropSetButton = boolean, whether the "Drop all totems" (DropSet)
-- button is shown on the bar; default ON, so existing
-- users keep it. Visibility only: the button is always
-- created, and its keybinding (BINDING_NAME_TOTEMBAR_
-- DROPSET) plus the "Totems" macro keep working while
-- it is hidden - same idea as the "Show bar" toggle.
-- buttonGap = px gap between bar buttons, range 10-30, default
-- TotemBar.DEFAULT_BUTTON_GAP (see core/cast.lua);
-- live-applied via ui.lua's TotemBar.SetButtonGap,
-- set from the options panel's "Button spacing" slider.
function TotemBar.ensureDefaults()
TotemBarDB.chosen = TotemBarDB.chosen or {}
TotemBarDB.gapSeconds = TotemBarDB.gapSeconds or TotemBar.DEFAULT_GAP_SECONDS
if TotemBarDB.locked == nil then
TotemBarDB.locked = false
end
if TotemBarDB.autoRecall == nil then
TotemBarDB.autoRecall = true
end
TotemBarDB.point = TotemBarDB.point or "CENTER"
TotemBarDB.relPoint = TotemBarDB.relPoint or "CENTER"
TotemBarDB.x = TotemBarDB.x or 0
TotemBarDB.y = TotemBarDB.y or 0
TotemBarDB.scale = TotemBarDB.scale or 1.0
TotemBarDB.minimapAngle = TotemBarDB.minimapAngle or 225
if TotemBarDB.hidden == nil then
TotemBarDB.hidden = false
end
TotemBarDB.recallGuardSeconds = TotemBarDB.recallGuardSeconds or TotemBar.DEFAULT_RECALL_GUARD
TotemBarDB.recallRefundPct = TotemBarDB.recallRefundPct or 0.25
TotemBarDB.buttonGap = TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP
-- Pulse UI (spec docs/superpowers/specs/2026-07-09-pulse-ui-design.md):
-- duration ring + pulse bars, all on by default; ringStyle "round" vs
-- "square" is the in-game comparison toggle.
if TotemBarDB.showDurationRing == nil then
TotemBarDB.showDurationRing = true
end
TotemBarDB.ringStyle = TotemBarDB.ringStyle or "round"
if TotemBarDB.showPulseBars == nil then
TotemBarDB.showPulseBars = true
end
if TotemBarDB.showPulseWaves == nil then
TotemBarDB.showPulseWaves = true
end
-- dead setting removed in v0.2.2, clear persisted dust
TotemBarDB.pulseGlow = nil
if TotemBarDB.showTimerText == nil then
TotemBarDB.showTimerText = true
end
-- Bar contents: the DropSet button is optional, shown by default so an
-- upgrade never makes it disappear (visibility only, see the shape note).
if TotemBarDB.showDropSetButton == nil then
TotemBarDB.showDropSetButton = true
end
TotemBarDB.barLayout = TotemBarDB.barLayout or "1x6"
end
+51
View File
@@ -0,0 +1,51 @@
-- TotemBar - core/known.lua
-- knownTotems() is PURE (no WoW API) and offline-testable.
-- The WoW-API spellbook scan it consumes (scanSpellbook) now lives in
-- core/spellindex.lua, alongside the other spellbook-index accessors
-- (findSpellIndex/findHighestRankSlot/highestKnownRank) -- kept minimal
-- and separate so the pure filtering logic below never has to touch the
-- real API to be tested.
TotemBar = TotemBar or {}
-- Pure: given a flat list of spell names (strings) known to the player
-- and an element name ("Fire"/"Earth"/"Water"/"Air"), returns an array
-- of the subset of those names that are totems belonging to that
-- element, in the static map's order.
--
-- - Names not present in the static map (non-totem spells) are ignored.
-- - Totems in the static map that aren't in spellNames (not known) are
-- omitted from the result.
-- - An unknown/unmapped element returns an empty array.
function TotemBar.knownTotems(spellNames, element)
local result = {}
local candidates = TotemBar.TOTEMS_BY_ELEMENT[element]
if not candidates then
return result
end
local known = {}
if spellNames then
for i = 1, table.getn(spellNames) do
known[spellNames[i]] = true
end
end
local n = 0
for i = 1, table.getn(candidates) do
local name = candidates[i]
if known[name] then
n = n + 1
result[n] = name
end
end
return result
end
-- TotemBar.scanSpellbook() and TotemBar.highestKnownRank() used to be
-- defined here as thin WoW-API wrappers, each doing its own fresh linear
-- GetSpellName() scan. They're now cache-backed views provided by
-- core/spellindex.lua (loaded earlier in the TOC) instead, so a totem
-- drop no longer re-walks the spellbook once per caller. Same names,
-- same signatures, same return values -- callers here are unchanged.
+277
View File
@@ -0,0 +1,277 @@
-- TotemBar - core/manacost.lua
-- Mana-cost reading + refund/duration helpers. The PURE functions here are
-- offline-tested; the WoW-API pieces (hidden-tooltip scan, talent scan, refund
-- learner) are appended in a later task and are guarded so this file loads
-- fine under plain Lua for the tests.
TotemBar = TotemBar or {}
-- Pure: mana cost from a tooltip line like "155 Mana" -> 155, else nil.
function TotemBar.parseManaCost(text)
if not text then
return nil
end
-- Anchor to the "Mana" keyword so a numeric line like "30 sec cooldown"
-- can't be mistaken for the cost (don't rely on tooltip line ordering).
local _, _, n = string.find(text, "^(%d+) [Mm]ana")
if n then
return tonumber(n)
end
return nil
end
-- Pure: sum costFn(chosen[element]) over the elements that have a chosen totem.
function TotemBar.sumChosenCost(chosen, elements, costFn)
local total = 0
if not chosen then
return 0
end
for i = 1, table.getn(elements) do
local name = chosen[elements[i]]
if name then
local c = costFn(name)
if c then
total = total + c
end
end
end
return total
end
-- Pure: sum cost of active totems that are still out (remaining > 0).
function TotemBar.sumActiveCost(activeTotems, elements, now, costFn, remainingFn)
local total = 0
if not activeTotems then
return 0
end
for i = 1, table.getn(elements) do
local rec = activeTotems[elements[i]]
if rec then
local rem = remainingFn(rec.start, rec.duration, now)
if rem and rem > 0 then
local c = costFn(rec.totemName)
if c then
total = total + c
end
end
end
end
return total
end
-- Pure: floored refund.
function TotemBar.refundAmount(pct, activeCost)
if not pct or not activeCost then
return 0
end
return math.floor(pct * activeCost)
end
-- Pure: learn refund pct from an observed mana gain; nil if out of sane range.
function TotemBar.learnRefundPct(manaGained, activeCost)
if not manaGained or not activeCost or activeCost <= 0 then
return nil
end
local pct = manaGained / activeCost
if pct < 0.05 or pct > 1.0 then
return nil
end
return pct
end
-- Pure: helpful totems get Totemic Mastery's +20% duration.
function TotemBar.durationWithMastery(baseDuration, isHelpful, hasMastery)
if hasMastery and isHelpful then
return baseDuration * 1.2
end
return baseDuration
end
-- The pure fire-DAMAGE totems do NOT get the +20% helpful-totem duration.
-- Everything else is treated as helpful. VERIFY in-game and adjust.
TotemBar.NON_HELPFUL_TOTEMS = {
["Searing Totem"] = true,
["Magma Totem"] = true,
["Fire Nova Totem"] = true,
}
function TotemBar.isHelpfulTotem(name)
if not name then
return false
end
if TotemBar.NON_HELPFUL_TOTEMS[name] then
return false
end
return true
end
-- ===== WoW-API layer (not offline-executed) =====
-- TotemBar.findHighestRankSlot(name) used to be defined here as its own
-- fresh linear GetSpellName() scan per call. It's now a cache-backed
-- accessor provided by core/spellindex.lua (loaded earlier in the TOC) --
-- same name, same signature, same resolved slot (the HIGHEST known rank,
-- which is the one CastSpellByName actually casts; a first-match scan
-- would return rank 1 -> cost far too low, the bug behind "the mana
-- values are wrong"). Shared: ui.lua's tooltips resolve through this too -
-- SetSpell with a first-name-match slot shows the RANK 1 tooltip (wrong
-- mana/duration).
local scanTip = nil
local manaCache = {} -- name -> cost (only positive results cached)
-- Reads a totem's mana cost via a hidden tooltip scan (no GetSpellManaCost on
-- 1.12). Reflects cost talents (Tidal Focus, Restorative Totems) automatically.
-- Cached by name; cache cleared on SPELLS_CHANGED (talent/rank changes).
function TotemBar.getTotemManaCost(name)
if not name then return nil end
if manaCache[name] then return manaCache[name] end
local idx = TotemBar.findHighestRankSlot(name)
if not idx then return nil end
if not scanTip then
scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
end
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
scanTip:ClearLines()
scanTip:SetSpell(idx, BOOKTYPE_SPELL)
local cost = nil
local lines = scanTip:NumLines() or 0
for i = 1, lines do
local fs = getglobal("TotemBarScanTooltipTextLeft" .. i)
local text = fs and fs:GetText()
local c = TotemBar.parseManaCost(text)
if c then
cost = c
break
end
end
if cost then manaCache[name] = cost end
return cost
end
-- Dev aid (/tb manadump): dump the RAW mana-cost scan for the chosen totems +
-- Totemic Recall to <WoW>\imports\totembar_manadump.txt, so the resolved
-- slot/rank, every tooltip line, and the parsed cost can be verified
-- off-client (ground truth for the mana-cost feature). Chat fallback if no
-- SuperWoW ExportFile.
function TotemBar.dumpManaScan()
local els = TotemBar.TOTEM_ELEMENTS
local chosen = (TotemBarDB and TotemBarDB.chosen) or {}
local list = {}
for i = 1, table.getn(els) do
local nm = chosen[els[i]]
if nm then list[table.getn(list) + 1] = nm end
end
list[table.getn(list) + 1] = "Totemic Recall"
if not scanTip then
scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
end
local out = "TotemBar mana scan dump\n"
local total = 0
for i = 1, table.getn(list) do
local nm = list[i]
local idx = TotemBar.findHighestRankSlot(nm)
out = out .. "\n[" .. nm .. "] slot=" .. tostring(idx)
if idx then
local rn, rr = GetSpellName(idx, BOOKTYPE_SPELL)
out = out .. " resolved='" .. tostring(rn) .. "' rank='" .. tostring(rr) .. "'\n"
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
scanTip:ClearLines()
scanTip:SetSpell(idx, BOOKTYPE_SPELL)
local lines = scanTip:NumLines() or 0
local cost = nil
for L = 1, lines do
local fs = getglobal("TotemBarScanTooltipTextLeft" .. L)
local text = fs and fs:GetText()
out = out .. " L" .. L .. ": " .. tostring(text) .. "\n"
if not cost then
local c = TotemBar.parseManaCost(text)
if c then cost = c end
end
end
out = out .. " parsedCost=" .. tostring(cost) .. "\n"
if cost and nm ~= "Totemic Recall" then total = total + cost end
else
out = out .. " (not found in spellbook)\n"
end
end
out = out .. "\nsumChosenCost(live)=" .. tostring(
TotemBar.sumChosenCost(chosen, els, TotemBar.getTotemManaCost)) .. "\n"
out = out .. "dumpTotal(chosen)=" .. total .. "\n"
if ExportFile then
ExportFile("totembar_manadump", out)
end
if DEFAULT_CHAT_FRAME then
DEFAULT_CHAT_FRAME:AddMessage(
"TotemBar: mana dump written (imports\\totembar_manadump.txt). chosen sum=" .. total)
end
end
-- Totemic Mastery (TWoW: +20% helpful-totem duration): cached scan of the
-- talent trees by name.
local masteryCached = false
local function scanMastery()
masteryCached = false
local tabs = (GetNumTalentTabs and GetNumTalentTabs()) or 0
for tab = 1, tabs do
local num = GetNumTalents(tab)
for i = 1, num do
local tname, _, _, _, rank = GetTalentInfo(tab, i)
if tname == "Totemic Mastery" and rank and rank > 0 then
masteryCached = true
end
end
end
end
function TotemBar.hasTotemicMastery()
return masteryCached
end
-- Recall-refund auto-learn. Snapshot the summed cost of the totems currently
-- out just before a DELIBERATE Totemic Recall; when the mana-gain message
-- arrives shortly after, learn the real refund %.
TotemBar.recallPendingCost = 0
local recallExpectUntil = 0
function TotemBar.snapshotRecallCost()
TotemBar.recallPendingCost = TotemBar.sumActiveCost(
TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(),
TotemBar.getTotemManaCost, TotemBar.remaining)
recallExpectUntil = GetTime() + 2
end
-- Events: refresh mastery, clear the cost cache on spell changes, and learn
-- the refund % from the recall mana-gain message.
-- Guarded: CreateFrame is nil under plain offline Lua, so this whole block is
-- skipped there (loadfile/dofile must not error for the test suite).
if CreateFrame then
local mcEvents = CreateFrame("Frame", "TotemBarManaCostEventFrame", UIParent)
mcEvents:RegisterEvent("PLAYER_ENTERING_WORLD")
mcEvents:RegisterEvent("CHARACTER_POINTS_CHANGED")
mcEvents:RegisterEvent("SPELLS_CHANGED")
mcEvents:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
mcEvents:SetScript("OnEvent", function()
if event == "PLAYER_ENTERING_WORLD" or event == "CHARACTER_POINTS_CHANGED" then
scanMastery()
elseif event == "SPELLS_CHANGED" then
manaCache = {}
scanMastery()
elseif event == "CHAT_MSG_SPELL_SELF_BUFF" then
if GetTime() > recallExpectUntil then return end
local msg = arg1
if not msg then return end
-- Learn only from a Totemic Recall mana-gain within the window.
-- NOTE: exact message text is locale/format dependent - VERIFY in-game
-- (adjust the "Totemic Recall" + number pattern if needed).
if string.find(msg, "Totemic Recall") then
local _, _, num = string.find(msg, "(%d+)")
local gained = tonumber(num)
local pct = TotemBar.learnRefundPct(gained, TotemBar.recallPendingCost)
if pct and TotemBarDB then
TotemBarDB.recallRefundPct = pct
end
end
end
end)
end
+73
View File
@@ -0,0 +1,73 @@
-- TotemBar - core/optionslogic.lua
-- PURE helpers for the options panel + minimap button (no WoW API), so the
-- fiddly bits (orbit math, slider clamping, the macro contract) are
-- offline-testable under real Lua 5.0. The UI (minimap.lua / options.lua)
-- calls these.
TotemBar = TotemBar or {}
-- Orbit offset (x, y) for a minimap button at angleDeg on a circle of the
-- given radius. 0deg -> (radius,0); 90 -> (0,radius); 180 -> (-radius,0).
function TotemBar.angleToOffset(angleDeg, radius)
local rad = math.rad(angleDeg)
return math.cos(rad) * radius, math.sin(rad) * radius
end
-- Clamp v into [minVal, maxVal].
function TotemBar.clampValue(v, minVal, maxVal)
if v < minVal then
return minVal
end
if v > maxVal then
return maxVal
end
return v
end
-- The fixed spec for the "Totems" convenience macro: name, body, and a BARE
-- icon file name (no Interface\Icons\ prefix, no extension - TurtleWoW's
-- CreateMacro/EditMacro prepend the path themselves).
function TotemBar.macroSpec()
return "Totems", "/script TotemBar.recallAndCastAll()", "Spell_Nature_TremorTotem"
end
-- Bar-layout grid data + math (feature: selectable 1x6 / 2x3 / 3x2 bar
-- arrangement, see ui.lua's ApplyBarLayout). Pure so the button positions
-- and pixel-size math can be pinned offline instead of eyeballed in-game.
-- Explicit per-layout button positions {col, row} (0-based), button order:
-- Fire, Earth, Water, Air, Recall, DropSet. "2x3" groups the four element
-- totems as a 2x2 block in the first two columns (user request); "3x2"
-- groups them as the top 2x2 block with the utility row below.
-- DropSet is deliberately the LAST slot in every layout: when it is hidden
-- (TotemBarDB.showDropSetButton = false) the remaining five buttons keep
-- their exact slots - the element 2x2 block and Recall never move, and no
-- second positions table is needed. The multi-row layouts then leave the
-- trailing grid cell empty, which is invisible (transparent backdrop) and
-- still usable as drag surface.
TotemBar.BAR_LAYOUT_POSITIONS = {
["1x6"] = { {0,0},{1,0},{2,0},{3,0},{4,0},{5,0} },
["2x3"] = { {0,0},{1,0},{0,1},{1,1},{2,0},{2,1} },
["3x2"] = { {0,0},{1,0},{0,1},{1,1},{0,2},{1,2} },
}
-- Pixel dimensions of the bar frame for a grid of `count` buttons arranged
-- in at most `cols` columns of `size`x`size` buttons with `gap` spacing.
-- `rowPitchExtra` widens the vertical pitch between rows (multi-row layouts:
-- room for the timer text hanging below each row plus the next row's ring
-- overhang - see ui.lua's BUTTON_GAP comment); pass 0 for a single row.
-- height = top gap + rows*size + (rows-1)*(gap+extra) + bottom gap.
-- `cols` is capped at `count` because a grid can never be wider than it has
-- buttons: with the DropSet button hidden, "1x6" is a row of FIVE and the
-- frame must shrink with it instead of trailing dead space. The multi-row
-- layouts still need all of their columns at five buttons, so their size is
-- unchanged (they just leave the last grid cell empty).
function TotemBar.barDimensions(count, cols, size, gap, rowPitchExtra)
if cols > count then
cols = count
end
local rows = math.ceil(count / cols)
local width = cols * (size + gap) + gap
local height = gap + rows * size + (rows - 1) * (gap + rowPitchExtra) + gap
return width, height, rows
end
+171
View File
@@ -0,0 +1,171 @@
-- TotemBar - core/pulse.lua
-- PURE pulse/ring math for the Pulse UI (no WoW API; offline-tested via
-- tools/luatests/test_pulse.lua). ui.lua calls these from its throttled
-- timer tick - keep everything allocation-free except buildRingTexCoords
-- (called once at load).
TotemBar = TotemBar or {}
-- 0..1 phase toward the next pulse. Phase origin is anchorAt (the last
-- OBSERVED pulse, stamped by ui.lua's combat-message handler) when set,
-- else placedAt (dead reckoning). Lua 5.0: math.mod, not the % operator.
function TotemBar.pulseRatio(placedAt, anchorAt, interval, now)
if not interval or interval <= 0 then
return nil
end
local origin = anchorAt or placedAt
if not origin or not now then
return nil
end
local elapsed = now - origin
if elapsed < 0 then
return 0
end
return math.mod(elapsed, interval) / interval
end
-- 0..1 single fill from placement to detonation (Fire Nova). Clamped at
-- both ends; callers treat >=1 as "detonated" and hide the bar.
function TotemBar.oneshotRatio(placedAt, delay, now)
if not placedAt or not delay or delay <= 0 or not now then
return nil
end
local r = (now - placedAt) / delay
if r < 0 then
return 0
end
if r > 1 then
return 1
end
return r
end
-- Blueprint state D ("pulse imminent"): glow from 85% fill on.
TotemBar.PULSE_IMMINENT_THRESHOLD = 0.85
function TotemBar.pulseImminent(ratio)
if ratio and ratio >= TotemBar.PULSE_IMMINENT_THRESHOLD then
return true
end
return false
end
-- Flipbook frame index for the duration ring: 0 = empty ... frameCount-1 =
-- full, rounded to nearest so the ring reads full right after placement.
-- Generic over frameCount; callers pass the arc's actual frame count (Rev 2:
-- 62, cells 0..61 of the ring_round.tga flipbook).
function TotemBar.ringFrameIndex(remaining, duration, frameCount)
if not remaining or not duration or duration <= 0 or not frameCount then
return 0
end
local ratio = remaining / duration
if ratio < 0 then
ratio = 0
end
if ratio > 1 then
ratio = 1
end
return math.floor(ratio * (frameCount - 1) + 0.5)
end
-- Precomputed SetTexCoord rectangles for a square cols x cols grid texture,
-- row-major, 1-based array indexed by frame+1. Built ONCE at load; the
-- per-tick path just indexes it (zero allocation, zero math).
function TotemBar.buildRingTexCoords(frames, cols)
local coords = {}
local cell = 1 / cols
for i = 0, frames - 1 do
local col = math.mod(i, cols)
local row = math.floor(i / cols)
coords[i + 1] = {
l = col * cell,
r = (col + 1) * cell,
t = row * cell,
b = (row + 1) * cell,
}
end
return coords
end
-- Duration counterpart of core/cast.lua's resolveRemaining, SAME precedence:
-- GTI (pfUI libtotem) is authoritative while it reports the slot active;
-- otherwise fall back to own tracking. Returns the TOTAL duration whose
-- remaining time resolveRemaining would have returned, or nil.
function TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, ownRemaining, ownDuration)
-- FIX 2026-07-15: mirror resolveRemaining -- trust GTI only while it reports the
-- slot active WITH positive time left; otherwise fall back to own tracking (a
-- stale-active GTI slot must not block the timer, same Fire/Earth bug).
if gtiActive and gtiRemaining and gtiRemaining > 0 then
return gtiDuration
end
if ownRemaining and ownRemaining > 0 then
return ownDuration
end
return nil
end
-- Ripple wave phase: seconds since the last pulse mapped to 0..1 over the
-- wave's lifetime, or nil when no wave is showing. For tick totems the wave
-- starts AT each pulse (phase wraps via pulseRatio); waveDur is clamped to
-- half the interval so the wave always dies before the next one spawns.
function TotemBar.waveFrac(ratio, interval, waveDur)
if not ratio or not interval or interval <= 0 or not waveDur or waveDur <= 0 then
return nil
end
local dur = waveDur
if dur > interval * 0.5 then
dur = interval * 0.5
end
local age = ratio * interval
if age > dur then
return nil
end
return age / dur
end
-- One-shot (Fire Nova): wave for waveDur seconds AFTER detonation.
function TotemBar.oneshotWaveFrac(placedAt, delay, now, waveDur)
if not placedAt or not delay or not now or not waveDur or waveDur <= 0 then
return nil
end
local age = (now - placedAt) - delay
if age < 0 or age > waveDur then
return nil
end
return age / waveDur
end
-- Anticipation glow: eased 0..1 ramp over the last (1 - rampStart) fraction
-- of the pulse phase; nil below rampStart (no glow) or on bad inputs.
function TotemBar.glowRamp(ratio, rampStart)
if not ratio or not rampStart or rampStart >= 1 or ratio < rampStart then
return nil
end
local t = (ratio - rampStart) / (1 - rampStart)
if t > 1 then
t = 1
end
return t * t
end
-- Traffic-light color for a remaining-time fraction (1 = full, 0 = empty):
-- green above 0.5, blending green->yellow->orange->red as time runs out.
-- Returns r, g, b as three numbers (no table - called per tick).
function TotemBar.timeColor(frac)
if not frac then
return 1, 1, 1
end
if frac < 0 then frac = 0 end
if frac > 1 then frac = 1 end
if frac >= 0.5 then
-- green (0.25,0.8,0.25) -> yellow (0.95,0.85,0.2) over 1.0..0.5
local t = (1 - frac) * 2
return 0.25 + 0.70 * t, 0.80 + 0.05 * t, 0.25 - 0.05 * t
elseif frac >= 0.25 then
-- yellow -> orange (1.0,0.55,0.1) over 0.5..0.25
local t = (0.5 - frac) * 4
return 0.95 + 0.05 * t, 0.85 - 0.30 * t, 0.20 - 0.10 * t
end
-- orange -> red (0.90,0.15,0.10) over 0.25..0
local t = (0.25 - frac) * 4
return 1.00 - 0.10 * t, 0.55 - 0.40 * t, 0.10
end
+155
View File
@@ -0,0 +1,155 @@
-- TotemBar - core/pulsecal.lua
-- Dev-only pulse-calibration telemetry (/tb pulsecal): captures raw
-- totem-related combat lines + our own placements into a fixed ring buffer
-- and exports them via SuperWoW ExportFile, so the dev environment can
-- measure REAL TurtleWoW pulse intervals offline and bake them back into
-- core/pulsedata.lua (verified=true).
-- Pure buffer/serializer on top (offline-tested); WoW-gated capture below.
TotemBar = TotemBar or {}
TotemBar.PULSECAL_CAP = 2000
-- Pure: push one record into the ring buffer. state = { n = total pushed,
-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no
-- allocation growth while capturing).
function TotemBar.pulsecalPush(buf, cap, state, t, ev, msg)
local slot = state.idx
local rec = buf[slot]
if not rec then
rec = {}
buf[slot] = rec
end
rec.t = t
rec.e = ev
rec.m = msg
state.n = state.n + 1
state.idx = slot + 1
if state.idx > cap then
state.idx = 1
end
end
-- Pure: serialize surviving records in chronological order, one
-- "t;event;msg" line each (t with millisecond precision). Allocates - dump
-- time only, never per capture.
function TotemBar.pulsecalFormat(buf, cap, state)
local count = state.n
if count > cap then
count = cap
end
if count == 0 then
return ""
end
local start = state.idx - count
if start < 1 then
start = start + cap
end
local lines = {}
for i = 0, count - 1 do
local slot = start + i
if slot > cap then
slot = slot - cap
end
local rec = buf[slot]
lines[i + 1] = string.format("%.3f;%s;%s", rec.t, rec.e, rec.m)
end
return table.concat(lines, "\n")
end
-- ---------------------------------------------------------------------------
-- WoW-gated capture (skipped entirely by the offline test runner).
if CreateFrame then
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
local capturing = false
local buf = {}
local state = { n = 0, idx = 1 }
-- Everything that could plausibly carry a totem pulse on 1.12 (no
-- COMBAT_LOG_EVENT_UNFILTERED here - all localized text in arg1).
local CAPTURE_EVENTS = {
"CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS",
"CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
"CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
"CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE",
"CHAT_MSG_SPELL_SELF_DAMAGE",
"CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
"CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
"CHAT_MSG_SPELL_AURA_GONE_SELF",
"CHAT_MSG_SPELL_AURA_GONE_PARTY",
}
local calFrame = CreateFrame("Frame", "TotemBarPulseCalFrame", UIParent)
calFrame:SetScript("OnEvent", function()
if not capturing then
return
end
if event == "UNIT_CASTEVENT" then
-- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
-- Only record casts whose spell name mentions Totem (SpellInfo
-- is a SuperWoW API; guarded because the event only exists there
-- anyway).
local sname = SpellInfo and SpellInfo(arg4)
if sname and string.find(sname, "Totem", 1, true) then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event,
tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname))
end
return
end
-- Plain substring guard keeps the hot path cheap; "Totem"-less buff
-- names (Mana Spring / Healing Stream gain lines) are the exception,
-- so include the "You gain" prefix too.
local msg = arg1
if msg and (string.find(msg, "Totem", 1, true) or string.find(msg, "You gain", 1, true)) then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, msg)
end
end)
-- Record our own placements as reference marks (t0 for interval math).
-- Wrapping recordCast is safe here: core/pulsecal.lua loads AFTER
-- core/cast.lua (see .toc) and ui.lua resolves TotemBar.recordCast at
-- call time.
local origRecordCast = TotemBar.recordCast
TotemBar.recordCast = function(element, totemName)
origRecordCast(element, totemName)
if capturing then
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(),
"TB_PLACED", tostring(element) .. ";" .. tostring(totemName))
end
end
function TotemBar.PulseCal(sub)
if sub == "start" then
if not capturing then
capturing = true
for i = 1, table.getn(CAPTURE_EVENTS) do
calFrame:RegisterEvent(CAPTURE_EVENTS[i])
end
-- SuperWoW-only event; pcall-guarded in case a client build
-- rejects unknown event names.
pcall(function() calFrame:RegisterEvent("UNIT_CASTEVENT") end)
end
ChatOut:AddMessage("TotemBar: pulsecal capture STARTED (drop totems, then /tb pulsecal dump).")
elseif sub == "stop" then
capturing = false
calFrame:UnregisterAllEvents()
ChatOut:AddMessage("TotemBar: pulsecal capture stopped (" .. state.n .. " records kept).")
elseif sub == "dump" then
if not ExportFile then
ChatOut:AddMessage("TotemBar: pulsecal dump needs SuperWoW (ExportFile missing).")
return
end
-- Filename WITHOUT extension - the client appends .txt itself.
ExportFile("totembar_pulsecal", TotemBar.pulsecalFormat(buf, TotemBar.PULSECAL_CAP, state))
ChatOut:AddMessage("TotemBar: pulsecal dump written (imports\\totembar_pulsecal.txt, "
.. state.n .. " records).")
else
local on = capturing and "ON" or "OFF"
ChatOut:AddMessage("TotemBar: pulsecal " .. on .. ", " .. state.n
.. " records. Usage: /tb pulsecal start|stop|dump")
end
end
end
+61
View File
@@ -0,0 +1,61 @@
-- TotemBar - core/pulsedata.lua
-- Pulse metadata per totem name (PURE data, no WoW API). Drives the pulse
-- progress bar in ui.lua. Totems absent from PULSE_DATA get NO pulse bar
-- (better no info than invented info) - notably Searing Totem (irregular
-- attack cadence, not a fixed pulse), Grounding (event-consumed) and every
-- aura totem (continuous aura, no verified pulse mechanic).
--
-- Values are server-dump-derived (TWoW server dump, snapshot 2024-07,
-- patch_1172 branch; tw_world.sql + SpellAuras.cpp:8647-8714), not book
-- values - source="server" records that provenance. verified=false stays
-- reserved for IN-GAME confirmation: /tb pulsecal (core/pulsecal.lua)
-- captures the real timings; once measured in-game, update the value AND
-- flip verified=true. Spec:
-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md section 3.
--
-- firstTick: "immediate" means the aura's periodic tick fires at t=0 (the
-- totem is on SpellAuras.cpp's CalculatePeriodic exception list);
-- "delayed" means the first tick lands only after one full amplitude has
-- elapsed. NOTE: the pulseRatio math is identical either way - the bar
-- reaches 1.0 at every tick instant, including the t=0 wrap for
-- "immediate" totems - so firstTick is currently documentative only (no
-- consumer branches on it yet). Kept for future use, e.g. a distinct
-- wave/flash at t=0 for "immediate" totems.
TotemBar = TotemBar or {}
-- ptype "tick" -> repeating pulse every `interval` seconds
-- ptype "oneshot" -> single detonation `delay` seconds after placement
-- anchor "selfgain" -> phase re-anchors on observed periodic self-gain
-- combat messages (see core/pulseparse.lua + ui.lua)
TotemBar.PULSE_DATA = {
["Magma Totem"] = { ptype = "tick", interval = 2.0, firstTick = "delayed", source = "server", verified = false },
-- 4.0s confirmed by server dump - corrects the earlier 3.0s book value.
["Tremor Totem"] = { ptype = "tick", interval = 4.0, firstTick = "immediate", source = "server", verified = false },
["Earthbind Totem"] = { ptype = "tick", interval = 3.0, firstTick = "immediate", source = "server", verified = false },
["Poison Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
["Disease Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
["Healing Stream Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
["Mana Spring Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
-- server models it as a periodic 4s trigger like Magma, but with the 5s
-- totem duration exactly one nova lands at t=4s, so the oneshot model
-- is behaviorally equivalent.
["Fire Nova Totem"] = { ptype = "oneshot", delay = 4.0, firstTick = "delayed", source = "server", verified = false },
-- threat pulse every 2s.
["Stoneclaw Totem"] = { ptype = "tick", interval = 2.0, firstTick = "immediate", source = "server", verified = false },
}
-- Periodic gain lines name the BUFF ("You gain 10 Mana from Mana Spring."),
-- not the totem spell - map those source names back to the totem. Exact
-- wording gets pinned by pulsecal telemetry; extend as measured.
TotemBar.PULSE_SOURCE_ALIASES = {
["Mana Spring"] = "Mana Spring Totem",
["Healing Stream"] = "Healing Stream Totem",
}
function TotemBar.pulseInfo(totemName)
if not totemName then
return nil
end
return TotemBar.PULSE_DATA[totemName]
end
+52
View File
@@ -0,0 +1,52 @@
-- TotemBar - core/pulseparse.lua
-- PURE parser: periodic combat-log line -> totem name (or nil). Used by
-- ui.lua's CHAT_MSG_SPELL_PERIODIC_* handler to re-anchor pulse phase on
-- REAL observed ticks. Lua 5.0: string.find with captures only (no
-- string.match / no method-call syntax). Offline-tested via
-- tools/luatests/test_pulseparse.lua; exact accepted wordings get refined
-- from /tb pulsecal telemetry.
TotemBar = TotemBar or {}
-- Strips a trailing roman-numeral rank suffix (" IV", " VII", ...) - the
-- totem-as-caster shape names the ranked unit ("Healing Stream Totem VII").
local function stripRank(src)
local _, _, base = string.find(src, "^(.-)%s+[IVXLC]+$")
if base and base ~= "" then
return base
end
return src
end
-- Maps a raw source name to a totem name: alias table first (buff names
-- like "Mana Spring"), then any source ending in " Totem".
local function resolveSource(src)
local alias = TotemBar.PULSE_SOURCE_ALIASES and TotemBar.PULSE_SOURCE_ALIASES[src]
if alias then
return alias
end
-- Every totem's name ends in " Totem" (rank suffixes already stripped),
-- so anchor at the end - a plain substring test would false-positive on
-- player names like "Totemguy".
if string.find(src, "%sTotem$") then
return src
end
return nil
end
function TotemBar.parseSelfGain(msg)
if not msg then
return nil
end
local _, _, src = string.find(msg, "^You gain %d+ [Mm]ana from (.+)%.$")
if not src then
_, _, src = string.find(msg, "^You gain %d+ [Hh]ealth from (.+)%.$")
end
if not src then
_, _, src = string.find(msg, "^(.-) heals you for %d+%.$")
end
if not src then
return nil
end
return resolveSource(stripRank(src))
end
+175
View File
@@ -0,0 +1,175 @@
-- TotemBar - core/spellindex.lua
-- A single cached name -> spellbook-slot index, replacing the four
-- separate linear GetSpellName(i, BOOKTYPE_SPELL) scans that used to be
-- duplicated across ui.lua (FindSpellIndexByName), core/cast.lua
-- (findSpellIndexByName), core/manacost.lua (findHighestRankSlot) and
-- core/known.lua (scanSpellbook/highestKnownRank). Each of those walked
-- the WHOLE spellbook on every call; SPELL_UPDATE_COOLDOWN/cast events
-- fire several of them per totem drop, so a 4-totem-drop macro press
-- could re-walk the book several times in one frame (perf review
-- 2026-07-16, TotemBar finding #14: ui.lua:210 + cast.lua:175 +
-- manacost.lua:116).
--
-- buildSpellIndex() below is PURE (no WoW API) and offline-tested: given
-- a flat array of {name=, rank=} entries in spellbook slot order (array
-- index N == what GetSpellName(N, BOOKTYPE_SPELL) returned for slot N),
-- it returns a name -> record table where each record is:
-- bookIndex - the FIRST (lowest) slot with this name. Mirrors the
-- old FindSpellIndexByName/findSpellIndexByName
-- first-match behavior (icon/cooldown/"is it known"
-- lookups don't care which rank's slot they read --
-- GetSpellTexture/GetSpellCooldown are identical across
-- ranks of the same totem).
-- maxRankIndex - the slot of the HIGHEST parsed rank. Mirrors the old
-- manacost.lua findHighestRankSlot: this is the slot
-- CastSpellByName actually casts and the one
-- tooltip/mana-cost scans must read. Falls back to the
-- LAST matching slot if no entry's rank string for this
-- name ever parsed a number (matches the old
-- fallback -- ranks are listed ascending, so the last
-- slot is still the best guess).
-- maxRank - the highest parsed rank NUMBER, or nil if none of
-- this name's entries carried a parseable rank. Mirrors
-- the old known.lua highestKnownRank() return value.
TotemBar = TotemBar or {}
function TotemBar.buildSpellIndex(entries)
local idx = {}
if not entries then
return idx
end
for i = 1, table.getn(entries) do
local e = entries[i]
local name = e and e.name
if name then
local rec = idx[name]
if not rec then
rec = { bookIndex = i, maxRankIndex = i, maxRank = nil }
idx[name] = rec
end
local _, _, numStr = string.find(e.rank or "", "(%d+)")
local num = numStr and tonumber(numStr)
if num and (not rec.maxRank or num > rec.maxRank) then
rec.maxRank = num
rec.maxRankIndex = i
elseif not rec.maxRank then
-- No rank number has parsed for this name in any entry
-- seen so far; keep sliding the fallback to the LAST
-- matching slot (mirrors the old findHighestRankSlot
-- fallback for names whose rank string never parses).
rec.maxRankIndex = i
end
end
end
return idx
end
-- ===== WoW-API layer (not offline-executed) =====
-- Guarded by CreateFrame's presence, same as manacost.lua's own event
-- block, so dofile-ing this module under plain Lua (offline tests)
-- never errors.
local cachedEntries = nil -- flat array {name=, rank=}, 1..n, book order
local cachedIndex = nil -- name -> {bookIndex, maxRankIndex, maxRank}
local function scanLiveSpellbook()
local entries = {}
local i = 1
while true do
local name, rank = GetSpellName(i, BOOKTYPE_SPELL)
if not name then
break
end
entries[i] = { name = name, rank = rank }
i = i + 1
end
return entries
end
local function ensureBuilt()
if cachedIndex then
return
end
cachedEntries = scanLiveSpellbook()
cachedIndex = TotemBar.buildSpellIndex(cachedEntries)
end
-- Drops the cache; the next accessor call below rebuilds it from a
-- fresh scan (lazy -- nothing re-scans until actually asked for). Wired
-- to SPELLS_CHANGED below; also safe to call manually.
function TotemBar.invalidateSpellIndex()
cachedEntries = nil
cachedIndex = nil
end
-- Returns the cached name -> record index, building it lazily on first
-- access. Lazy-build matters at login/reload: the spellbook
-- (GetSpellName/GetSpellTexture) isn't reliably populated as early as
-- ADDON_LOADED fires (see ui.lua's own SPELLS_CHANGED comment) -- an
-- eager build at file-load time could cache an empty book.
function TotemBar.getSpellIndex()
ensureBuilt()
return cachedIndex
end
-- Mirrors old FindSpellIndexByName (ui.lua) / findSpellIndexByName
-- (cast.lua): first-match spellbook slot, or nil if unknown.
function TotemBar.findSpellIndex(name)
if not name then
return nil
end
local rec = TotemBar.getSpellIndex()[name]
return rec and rec.bookIndex
end
-- Mirrors old manacost.lua findHighestRankSlot: the slot of the
-- highest-known rank, or nil if unknown.
function TotemBar.findHighestRankSlot(name)
if not name then
return nil
end
local rec = TotemBar.getSpellIndex()[name]
return rec and rec.maxRankIndex
end
-- Mirrors old known.lua highestKnownRank: the highest known rank
-- NUMBER, or nil if unknown / no rank string ever parsed.
function TotemBar.highestKnownRank(name)
if not name then
return nil
end
local rec = TotemBar.getSpellIndex()[name]
return rec and rec.maxRank
end
-- Mirrors old known.lua scanSpellbook: flat array of every spellbook
-- entry's name, ONE PER SLOT (a multi-rank totem appears once per rank
-- it has) -- ShowFlyout's knownTotems() filter and PrintScan's raw dump
-- both rely on that shape. The by-name index above intentionally
-- collapses ranks together, so this stays a separate view over the same
-- cached scan rather than something derived from the index.
function TotemBar.scanSpellbook()
ensureBuilt()
local names = {}
for i = 1, table.getn(cachedEntries) do
names[i] = cachedEntries[i].name
end
return names
end
-- SPELLS_CHANGED is the event this codebase already relies on elsewhere
-- (manacost.lua's manaCache, ui.lua's icon refresh) for "spellbook
-- changed" -- fires on learning a new spell/rank and on talent changes.
-- LEARNED_SPELL_IN_TAB was checked and does not exist as a 1.12 client
-- event (not in this project's confirmed WoW 1.12 event list; it's a
-- later-expansion API) -- SPELLS_CHANGED alone is the correct signal.
if CreateFrame then
local siEvents = CreateFrame("Frame", "TotemBarSpellIndexEventFrame", UIParent)
siEvents:RegisterEvent("SPELLS_CHANGED")
siEvents:SetScript("OnEvent", function()
if event == "SPELLS_CHANGED" then
TotemBar.invalidateSpellIndex()
end
end)
end
+137
View File
@@ -0,0 +1,137 @@
-- TotemBar - core/totemdata.lua
-- PURE data module (no WoW API calls) - offline-testable.
--
-- Static element -> totem name map for vanilla shaman totems.
--
-- NOTE: TurtleWoW may rename or add totems relative to vanilla 1.12.
-- Use "/tb scan" in-game to print the player's actual known totem spell
-- strings and cross-check them against this map before trusting it.
TotemBar = TotemBar or {}
TotemBar.TOTEM_ELEMENTS = { "Fire", "Earth", "Water", "Air" }
TotemBar.TOTEMS_BY_ELEMENT = {
Fire = {
"Searing Totem",
"Fire Nova Totem",
"Magma Totem",
"Flametongue Totem",
"Frost Resistance Totem",
},
Earth = {
"Earthbind Totem",
"Stoneclaw Totem",
"Stoneskin Totem",
"Strength of Earth Totem",
"Tremor Totem",
},
Water = {
"Fire Resistance Totem",
"Healing Stream Totem",
"Mana Spring Totem",
"Poison Cleansing Totem",
"Disease Cleansing Totem",
"Mana Tide Totem",
},
Air = {
"Grace of Air Totem",
"Grounding Totem",
"Nature Resistance Totem",
"Sentry Totem",
"Windfury Totem",
"Windwall Totem",
"Tranquil Air Totem",
},
}
-- Build the reverse lookup (totem name -> element) once at load time.
local elementByName = {}
for elemIdx = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
local element = TotemBar.TOTEM_ELEMENTS[elemIdx]
local list = TotemBar.TOTEMS_BY_ELEMENT[element]
for totemIdx = 1, table.getn(list) do
elementByName[list[totemIdx]] = element
end
end
-- Returns the element ("Fire"/"Earth"/"Water"/"Air") a totem spell name
-- belongs to, or nil if the name isn't in the static map.
function TotemBar.elementOf(name)
if not name then
return nil
end
return elementByName[name]
end
-- Totem lifetime durations (seconds), tuned to TurtleWoW server timers
-- (mirrors pfUI libtotem's verified values). Keyed by totem spell name;
-- any totem not listed here falls back to DEFAULT_TOTEM_DURATION.
-- Searing Totem is rank-aware (see SEARING_TOTEM_DURATIONS below) and
-- is intentionally NOT listed in this flat table.
TotemBar.DEFAULT_TOTEM_DURATION = 120
-- Cross-checked 2026-07-16 against pfUI's own libtotem.lua duration table
-- (Interface\AddOns\pfUI\libs\libtotem.lua, Spell_Fire_SearingTotem entry:
-- {[-1]=55,[1]=30,[2]=35,[3]=40,[4]=45,[5]=50,[6]=55}) -- byte-identical to
-- the vanilla values below for every rank. TurtleWoW uses vanilla Searing
-- Totem timers, not a custom table; nothing was missing here.
TotemBar.SEARING_TOTEM_DURATIONS = {
[1] = 30,
[2] = 35,
[3] = 40,
[4] = 45,
[5] = 50,
[6] = 55,
}
TotemBar.SEARING_TOTEM_MAX_RANK = 6
TotemBar.SEARING_TOTEM_DEFAULT_DURATION = 55
TotemBar.TOTEM_DURATIONS = {
-- Fire
["Magma Totem"] = 20,
["Fire Nova Totem"] = 5,
["Flametongue Totem"] = 120,
["Frost Resistance Totem"] = 120,
-- Earth
["Stoneclaw Totem"] = 15,
["Earthbind Totem"] = 45,
["Stoneskin Totem"] = 120,
["Strength of Earth Totem"] = 120,
["Tremor Totem"] = 120,
-- Water
["Mana Tide Totem"] = 12,
["Healing Stream Totem"] = 60,
["Mana Spring Totem"] = 60,
["Fire Resistance Totem"] = 120,
["Disease Cleansing Totem"] = 120,
["Poison Cleansing Totem"] = 120,
-- Air
["Grounding Totem"] = 45,
["Grace of Air Totem"] = 120,
["Nature Resistance Totem"] = 120,
["Tranquil Air Totem"] = 120,
["Windfury Totem"] = 120,
["Windwall Totem"] = 120,
}
-- Pure: seconds a totem will remain active for, given its exact spell
-- name and (for Searing Totem only) the player's highest known rank
-- number. Unknown/unmapped totems fall back to DEFAULT_TOTEM_DURATION
-- (a safe overestimate, rather than a timer that disappears too early).
function TotemBar.totemDuration(name, highestRank)
if not name then
return TotemBar.DEFAULT_TOTEM_DURATION
end
if name == "Searing Totem" then
if not highestRank or highestRank < 1 then
return TotemBar.SEARING_TOTEM_DEFAULT_DURATION
end
local rank = highestRank
if rank > TotemBar.SEARING_TOTEM_MAX_RANK then
rank = TotemBar.SEARING_TOTEM_MAX_RANK
end
return TotemBar.SEARING_TOTEM_DURATIONS[rank] or TotemBar.SEARING_TOTEM_DEFAULT_DURATION
end
return TotemBar.TOTEM_DURATIONS[name] or TotemBar.DEFAULT_TOTEM_DURATION
end
+123
View File
@@ -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)
+456
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+2541
View File
File diff suppressed because it is too large Load Diff