Skip to main content

Spell Sequence Debugger

Overview

This open-source example implements a complete TBC Affliction Warlock rotation using the IZI SDK's spell system. But the rotation itself isn't the main event — this plugin is a showcase for a powerful development workflow that combines callback-based spell priorities with Nova's SpellDebugger, enabling an AI-assisted feedback loop that makes building and debugging rotations dramatically faster.

The idea is simple: structure your rotation as a table of named callbacks, wire them into the SpellDebugger with a single line, and you get both a real-time in-game debug UI and an exportable report that you can hand to an AI agent. The agent reads the report, sees exactly which spells passed or failed and why, and fixes the rotation for you. You test again, export, send back — and the loop continues until the rotation is perfect.

Key Features:

  • Callback-Based Rotation — each spell is a named function that returns true/false, tried in priority order
  • SpellDebugger Integration — real-time in-game UI showing every decision the rotation makes
  • Exportable Debug Reports — structured Lua tables an AI agent can read and reason about
  • AI Feedback Loop — describe what you want in natural language, test, export, let the AI iterate
  • Auto Rank Detection — scans the spellbook to always use the highest rank of each spell
  • Reactive Procs — Nightfall (Shadow Trance) triggers instant Shadow Bolt at highest priority
  • Full Menu System — per-spell toggles, keybind toggle, control panel integration
The Real Power

The rotation code itself is straightforward. The breakthrough is the development workflow: you can vibe-code an entire rotation by describing it to an AI, test it in-game for a few seconds, export the SpellDebugger report, send it back to the AI, and have it diagnose and fix issues — all without the AI ever needing direct access to the game.


Requirements

  • FBR - The Node by Nova — provides the SpellDebugger system. The rotation works without it, but you lose the debug UI and export capability.

The AI Feedback Loop

This is the workflow that makes this approach special:

┌─────────────────┐     natural language      ┌──────────────┐
│ Developer │ ──────────────────────▶ | AI Agent │
│ (or vibe-coder) │ │ │
└────────┬────────┘ └──────┬───────┘
│ │
│ test in-game │ generates/fixes
│ for a few seconds │ rotation code
▼ ▼
┌─────────────────┐ ┌──────────────┐
│ SpellDebugger │ export report │ AI reads │
│ records every │ ──────────────────────▶ │ the report │
│ decision │ (Lua table) │ and fixes │
└─────────────────┘ └──────────────┘
▲ │
│ updated code │
└───────────────────────────────────────────┘
  1. Describe what you want: "Affliction warlock, keep DoTs up, Life Tap when low mana, Shadow Bolt on Nightfall procs, Drain Life as filler"
  2. The AI generates the callback-based rotation
  3. Test in-game for a few seconds against a training dummy
  4. Export the SpellDebugger report (press the export button in the debug UI)
  5. Send the report back to the AI — it sees every tick, every condition check, every cast
  6. The AI diagnoses issues and produces a fix
  7. Repeat until the rotation is clean

The export is a structured Lua table (shown in detail below), so the AI can reason about it precisely — no guesswork, no "try adding a print statement".


SpellDebugger UI

SpellDebugger UI

The SpellDebugger overlay shows the real-time decision flow: which spells were evaluated each tick, which conditions passed or failed, which spell won, and which were skipped despite being available. Press END in-game to toggle the UI.


Full Source Code

-- TBC Warlock Sequence Test
-- Affliction Rotation (IZI SDK)
-- SpellDebugger-integrated: callback-based priority loop + reactive Nightfall / Life Tap
-- Press END key in-game to toggle the SpellDebugger UI

local izi = require("common/izi_sdk")
local key_helper = require("common/utility/key_helper")
local control_panel_helper = require("common/utility/control_panel_helper")

-- FBR/SpellDebugger integration (optional - rotation works without it)
---@diagnostic disable-next-line: undefined-field
local FBR = _G.NODE_LIBRARY_INSTANCE

local PREFIX = "tbc_warlock_aff"
local function uid(k) return PREFIX .. "_" .. k end

-- Toggle: false = use cast() instead of cast_safe() for debugging
local use_safe_cast = true

local function try_cast(spell, target, label)
if use_safe_cast then
return spell:cast_safe(target, label)
else
return spell:cast(target, label)
end
end

--------------------------------------------------------------------------------
-- Menu
--------------------------------------------------------------------------------
local m = core.menu

local menu = {
MAIN_TREE = m.tree_node(),
ROTATION_TREE = m.tree_node(),

enabled = m.checkbox(true, uid("enabled")),
toggle = m.keybind(999, false, uid("toggle")),

shadow_bolt = m.checkbox(true, uid("shadowbolt")),
life_tap = m.checkbox(true, uid("lifetap")),
corruption = m.checkbox(true, uid("corruption")),
agony = m.checkbox(true, uid("agony")),
siphon = m.checkbox(true, uid("siphon")),
ua = m.checkbox(true, uid("ua")),
immolate = m.checkbox(true, uid("immolate")),
drain = m.checkbox(true, uid("drain")),
}

function menu:on()
return self.enabled:get_state() and self.toggle:get_toggle_state()
end

core.register_on_render_menu_callback(function()
menu.MAIN_TREE:render("TBC Warlock Sequence", function()
core.menu.header():render("Affliction Rotation", izi.color.yellow(200))
menu.enabled:render("Enabled", "Master toggle")
if not menu.enabled:get_state() then return end

menu.ROTATION_TREE:render("Spells", function()
menu.toggle:render("Rotation Toggle",
"Keybind to enable/disable rotation")
menu.shadow_bolt:render("Shadow Bolt (Nightfall)")
menu.life_tap:render("Life Tap")
menu.corruption:render("Corruption")
menu.agony:render("Curse of Agony")
menu.siphon:render("Siphon Life")
menu.ua:render("Unstable Affliction")
menu.immolate:render("Immolate")
menu.drain:render("Drain Life (filler)")
end)
end)
end)

core.register_on_render_control_panel_callback(function()
local el = {}
if not menu.enabled:get_state() then return el end
control_panel_helper:insert_toggle(el, {
name = string.format("[Warlock] Rotation (%s)",
key_helper:get_key_name(menu.toggle:get_key_code())),
keybind = menu.toggle
})
return el
end)

--------------------------------------------------------------------------------
-- Spells
--------------------------------------------------------------------------------
local SHADOW_TRANCE = 17941

local DEFS = {
CORRUPTION = 172,
AGONY = 980,
SIPHON = 18265,
UA = 30108,
IMMOLATE = 348,
BOLT = 686,
DRAIN = 689,
TAP = 1454,
}

local spells = {}
local spell_ids = {}
local all_ranks = {}

for k, id in pairs(DEFS) do
spells[k] = izi.spell(id)
spell_ids[k] = id
all_ranks[k] = { id }
end

-- SPELLS table for SpellDebugger (maps callback names to spell objects)
local SPELLS = {
LIFE_TAP = spells.TAP,
SHADOW_BOLT = spells.BOLT,
CORRUPTION = spells.CORRUPTION,
AGONY = spells.AGONY,
SIPHON = spells.SIPHON,
UA = spells.UA,
IMMOLATE = spells.IMMOLATE,
DRAIN_LIFE = spells.DRAIN,
DRAIN = spells.DRAIN,
}

--------------------------------------------------------------------------------
-- Spellbook scanner
--------------------------------------------------------------------------------
local resolved = {}
local name_cache = {}
local last_scan = -999

local function spell_name(id)
if name_cache[id] then return name_cache[id] end
local s = izi.spell(id)
if not s then return nil end
local n = s:name()
if n and n ~= "" then name_cache[id] = n; return n end
return nil
end

local function scan()
local now = core.time()
if now - last_scan < 2 then return end
last_scan = now

for k, id in pairs(DEFS) do
if not resolved[k] then
local n = spell_name(id)
if n then resolved[k] = n end
end
end

local known = core.spell_book.get_spells()
if not known then return end

local ids = {}
for k, v in pairs(known) do
if type(k) == "number" then ids[k] = true end
if type(v) == "number" then ids[v] = true end
end

local best, ranks = {}, {}
for sid in pairs(ids) do
local n = spell_name(sid)
if n then
for def_k in pairs(DEFS) do
if resolved[def_k] and n == resolved[def_k] then
ranks[def_k] = ranks[def_k] or {}
table.insert(ranks[def_k], sid)
if not best[def_k] or sid > best[def_k] then
best[def_k] = sid
end
end
end
end
end

for k, id in pairs(best) do
if spell_ids[k] ~= id then
spell_ids[k] = id
spells[k] = izi.spell(id)
end
if ranks[k] then all_ranks[k] = ranks[k] end
end

-- Refresh SPELLS so callbacks and SpellDebugger use current spell ranks
SPELLS.LIFE_TAP = spells.TAP
SPELLS.SHADOW_BOLT = spells.BOLT
SPELLS.CORRUPTION = spells.CORRUPTION
SPELLS.AGONY = spells.AGONY
SPELLS.SIPHON = spells.SIPHON
SPELLS.UA = spells.UA
SPELLS.IMMOLATE = spells.IMMOLATE
SPELLS.DRAIN_LIFE = spells.DRAIN
SPELLS.DRAIN = spells.DRAIN
end

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

local function debuff_missing(target, key)
local r = all_ranks[key]
if not r or #r == 0 then return true end
return not target:debuff_up(r)
end

--------------------------------------------------------------------------------
-- SPELL CALLBACKS (SpellDebugger-integrated)
-- Each callback returns true if it cast, false otherwise.
-- Callbacks are tried in priority order each frame.
--------------------------------------------------------------------------------

local spellCallbacks = {}

spellCallbacks.lifeTap = function()
local me = izi.me()
if not me then return false end
if not menu.life_tap:get_state() then return false end
if not SPELLS.LIFE_TAP
or not SPELLS.LIFE_TAP:is_learned()
or not SPELLS.LIFE_TAP:cooldown_up() then
return false
end
local cond = (me:mana_pct() < 25 and me:get_health_percentage() > 75)
or (me:mana_pct() < 50 and me:get_health_percentage() > 99)
if not cond then return false end
return try_cast(SPELLS.LIFE_TAP, me, "Life Tap")
end

spellCallbacks.shadowBolt = function(condition)
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.shadow_bolt:get_state() then return false end
if not SPELLS.SHADOW_BOLT
or not SPELLS.SHADOW_BOLT:is_learned() then
return false
end
if condition == "nightfall" or me:buff_up(SHADOW_TRANCE) then
return try_cast(SPELLS.SHADOW_BOLT, target,
"Shadow Bolt (Nightfall)")
end
return false
end

spellCallbacks.corruption = function()
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.corruption:get_state() then return false end
if not SPELLS.CORRUPTION
or not SPELLS.CORRUPTION:is_learned()
or not SPELLS.CORRUPTION:cooldown_up() then
return false
end
if not debuff_missing(target, "CORRUPTION") then return false end
return try_cast(SPELLS.CORRUPTION, target, "Corruption")
end

spellCallbacks.agony = function()
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.agony:get_state() then return false end
if not SPELLS.AGONY
or not SPELLS.AGONY:is_learned()
or not SPELLS.AGONY:cooldown_up() then
return false
end
if not debuff_missing(target, "AGONY") then return false end
return try_cast(SPELLS.AGONY, target, "Curse of Agony")
end

spellCallbacks.siphon = function()
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.siphon:get_state() then return false end
if not SPELLS.SIPHON
or not SPELLS.SIPHON:is_learned()
or not SPELLS.SIPHON:cooldown_up() then
return false
end
if not debuff_missing(target, "SIPHON") then return false end
return try_cast(SPELLS.SIPHON, target, "Siphon Life")
end

spellCallbacks.ua = function()
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.ua:get_state() then return false end
if not SPELLS.UA
or not SPELLS.UA:is_learned()
or not SPELLS.UA:cooldown_up() then
return false
end
if not debuff_missing(target, "UA") then return false end
return try_cast(SPELLS.UA, target, "Unstable Affliction")
end

spellCallbacks.immolate = function()
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.immolate:get_state() then return false end
if not SPELLS.IMMOLATE
or not SPELLS.IMMOLATE:is_learned()
or not SPELLS.IMMOLATE:cooldown_up() then
return false
end
if not debuff_missing(target, "IMMOLATE") then return false end
return try_cast(SPELLS.IMMOLATE, target, "Immolate")
end

spellCallbacks.drain = function()
local me = izi.me()
local target = me and me:get_target()
if not me or not target
or not target:is_valid()
or not target:is_valid_enemy() then
return false
end
if not menu.drain:get_state() then return false end
if not SPELLS.DRAIN_LIFE
or not SPELLS.DRAIN_LIFE:is_learned()
or not SPELLS.DRAIN_LIFE:cooldown_up() then
return false
end
return try_cast(SPELLS.DRAIN_LIFE, target, "Drain Life (filler)")
end

-- ONE LINE to enable the SpellDebugger
if FBR and FBR.SpellDebugger then
FBR.SpellDebugger.register_callbacks(spellCallbacks, SPELLS)
end

--------------------------------------------------------------------------------
-- Action list (priority order)
--------------------------------------------------------------------------------
local actionList = {}
actionList.core = function()
if spellCallbacks.lifeTap() then return true end
if spellCallbacks.corruption() then return true end
if spellCallbacks.agony() then return true end
if spellCallbacks.siphon() then return true end
if spellCallbacks.ua() then return true end
if spellCallbacks.immolate() then return true end
if spellCallbacks.drain() then return true end
return false
end

--------------------------------------------------------------------------------
-- Update
--------------------------------------------------------------------------------

core.register_on_update_callback(function()
scan()

if not menu:on() then return end

local me = izi.me()
if not me or not me:is_alive() then return end

local target = me:get_target()
if not target or not target:is_valid() then return end
if not target:is_valid_enemy() then return end
if not me:can_attack(target) then return end

local SD = FBR and FBR.SpellDebugger

-- Nightfall: force cast Shadow Bolt (highest priority)
if menu.shadow_bolt:get_state() and me:buff_up(SHADOW_TRANCE) then
if SD then SD.set_action_list("nightfall") end
spellCallbacks.shadowBolt("nightfall")
return
end

-- Core rotation (Life Tap, DoTs, Drain filler)
if SD then SD.set_action_list("core") end
actionList.core()
end)

--------------------------------------------------------------------------------
-- HUD
--------------------------------------------------------------------------------

core.register_on_render_callback(function()
if not menu.enabled:get_state() then return end

if FBR and FBR.SpellDebugger then
local txt = "[TBC Warlock Aff] SpellDebugger: Press END to toggle"
core.graphics.text_2d(
txt, izi.vec2(20, 50), 16, izi.color.yellow(200), false
)
end
end)

Code Walkthrough

The Callback Pattern

The core idea that makes everything work — both the rotation and the SpellDebugger — is structuring every spell as a named callback in a table:

local spellCallbacks = {}

spellCallbacks.lifeTap = function()
-- check conditions
-- return true if cast succeeded, false otherwise
end

spellCallbacks.corruption = function()
-- ...
end

Each callback follows the same contract: check all conditions, attempt the cast if everything passes, return true if the spell was cast and false otherwise. The name of the function (lifeTap, corruption, etc.) becomes the label in the debug UI and the exported report.

The action list then tries them in priority order:

actionList.core = function()
if spellCallbacks.lifeTap() then return true end
if spellCallbacks.corruption() then return true end
if spellCallbacks.agony() then return true end
if spellCallbacks.siphon() then return true end
if spellCallbacks.ua() then return true end
if spellCallbacks.immolate() then return true end
if spellCallbacks.drain() then return true end
return false
end

The first callback to return true wins, and the rest are skipped for that tick. This waterfall pattern is simple to read, easy to reorder, and — critically — easy for the SpellDebugger to instrument.


SpellDebugger Integration

The entire integration is one line:

if FBR and FBR.SpellDebugger then
FBR.SpellDebugger.register_callbacks(spellCallbacks, SPELLS)
end

That's it. You pass your callbacks table and your spells table, and the SpellDebugger hooks into every callback to record what happened. You also tag which action list is running so the debugger knows which priority group each tick belongs to:

local SD = FBR and FBR.SpellDebugger

if SD then SD.set_action_list("nightfall") end
spellCallbacks.shadowBolt("nightfall")

-- Or for the main rotation
if SD then SD.set_action_list("core") end
actionList.core()

The SpellDebugger then provides two outputs: a real-time in-game overlay (toggle with END key) and an exportable report you can save and share.


Reading the Export

When you export a debug session, you get a structured Lua table that captures everything the SpellDebugger recorded. Here's a real export from a 3.7-second session hitting a Beginner Training Dummy — 3 successful casts, 2 failed attempts, and 6 skipped-but-available entries. Let's walk through each section.

tick_snapshots — What happened each tick

tick_snapshots = {
{
time = 0.48,
winner = "corruption",
invocations = {
{ spell_id = 1454, result = false, name = "lifeTap" },
{ spell_id = 172, result = true, name = "corruption" },
}
},
{
time = 2.00,
winner = "agony",
invocations = {
{ spell_id = 1454, result = false, name = "lifeTap" },
{ spell_id = 172, result = false, name = "corruption" },
{ spell_id = 980, result = true, name = "agony" },
}
},
{
time = 3.52,
winner = "siphon",
invocations = {
{ spell_id = 1454, result = false, name = "lifeTap" },
{ spell_id = 172, result = false, name = "corruption" },
{ spell_id = 980, result = false, name = "agony" },
{ spell_id = 18265, result = true, name = "siphon" },
}
},
}

Each snapshot records the time, which callbacks were invoked, whether they returned true or false, and which one won. You can read this like a story:

  • Tick 1 (0.48s): lifeTap failed (mana was fine), corruption won (DoT was missing from target).
  • Tick 2 (2.00s): lifeTap failed, corruption failed (already applied), agony won (next DoT missing).
  • Tick 3 (3.52s): lifeTap, corruption, agony all failed (already up), siphon won.

An AI agent reading this immediately knows the rotation is correctly waterfall-applying DoTs in priority order. Notice how each tick evaluates more callbacks than the previous one — that's the waterfall growing as DoTs get applied and their callbacks start returning false.

cast_events — What actually happened in the game

cast_events = {
{
spell_id = 27216,
time = 0.67,
spell_name = "Corruption",
target_name = "Beginner Training Dummy",
buffs_active = {}
},
{
spell_id = 11712,
time = 2.19,
spell_name = "Curse of Agony",
target_name = "Beginner Training Dummy",
buffs_active = {}
},
{
spell_id = 30911,
time = 3.62,
spell_name = "Siphon Life",
target_name = "Beginner Training Dummy",
buffs_active = {}
},
}

These are the actual game-confirmed casts. Notice the spell IDs here (27216, 11712, 30911) are the highest-rank versions — not the base rank IDs from DEFS (172, 980, 18265). This confirms the spellbook scanner is resolving ranks correctly. The buffs_active field shows which player buffs were active at cast time — useful for verifying proc-based logic like Nightfall.

failed_casts — Why a spell didn't fire

failed_casts = {
{
spell_id = 172,
time = 0.48,
name = "corruption",
conditions = {
{ name = "player", passed = true },
{ name = "target", detail = "Beginner Training Dummy", passed = true },
{ name = "is_learned", passed = true },
{ name = "cooldown_up", passed = true },
{ name = "usable", passed = true },
{ name = "castable_to_target", passed = true },
{ name = "target:has_debuff(table: 0x022f...)", passed = false },
}
},
{
spell_id = 980,
time = 2.00,
name = "agony",
conditions = {
{ name = "player", passed = true },
{ name = "target", detail = "Beginner Training Dummy", passed = true },
{ name = "is_learned", passed = true },
{ name = "cooldown_up", passed = true },
{ name = "usable", passed = true },
{ name = "castable_to_target", passed = true },
{ name = "target:has_debuff(table: 0x022f...)", passed = false },
}
},
}

This is the most powerful section for debugging. Every condition that cast_safe checked is listed with its pass/fail status. In both cases above, six conditions passed but target:has_debuff failed — the DoT wasn't on the target yet because the previous tick's cast was still being processed. An AI agent can pinpoint the exact failing condition without guessing.

skipped_available — Spells that could have cast but lost priority

skipped_available = {
{
spell_id = 1454,
time = 0.48,
winner = "corruption",
action_list = "core",
name = "lifeTap",
conditions = {
{ name = "player", passed = true },
{ name = "target", passed = true, detail = "Beginner Training Dummy" },
{ name = "is_learned", passed = true },
{ name = "cooldown_up", passed = true },
{ name = "usable", passed = true },
{ name = "castable_to_target", passed = true },
}
},
-- ... (6 total entries across all 3 ticks)
}

These are spells where every cast_safe condition passed, but a higher-priority spell won the tick. In this export, Life Tap was castable every single tick (all base conditions passed) but consistently lost to DoTs — which is correct behavior because its custom mana/health condition returned false before cast_safe was even reached.

This section is critical for identifying priority ordering issues. If a spell is constantly getting skipped despite being "ready" and you want it to fire, it needs to be moved up in the action list.

registered_callbacks — What spells the rotation tracks

registered_callbacks = {
{ spell_id = 980, name = "agony" },
{ spell_id = 172, name = "corruption" },
{ spell_id = 689, name = "drain" },
{ spell_id = 348, name = "immolate" },
{ spell_id = 1454, name = "lifeTap" },
{ spell_id = 686, name = "shadowBolt" },
{ spell_id = 18265, name = "siphon" },
{ spell_id = 30108, name = "ua" },
}

This gives the AI agent a full inventory of what the rotation is set up to handle — 8 spell callbacks registered, each mapped to its base spell ID.

Session Metadata

The export header summarizes the session at a glance:

-- Session duration: 3.7s
-- Casts: 3 | Failed: 2 | Skipped: 6

session_start = 3675.20, -- game time when recording started
export_time = 3678.86, -- game time when exported
current_time = 3.66, -- session duration at export
resource_name = "Soul Shards", -- class resource tracked

Spellbook Scanner (Auto-Ranking)

TBC has multiple ranks of each spell. The scanner automatically finds the highest rank in your spellbook:

local function scan()
local now = core.time()
if now - last_scan < 2 then return end
last_scan = now

-- Resolve base spell names from DEFS
for k, id in pairs(DEFS) do
if not resolved[k] then
local n = spell_name(id)
if n then resolved[k] = n end
end
end

-- Scan spellbook for all known spells, find highest rank by name match
local known = core.spell_book.get_spells()
-- ...
for k, id in pairs(best) do
if spell_ids[k] ~= id then
spell_ids[k] = id
spells[k] = izi.spell(id)
end
end
end

The approach: define base-rank IDs in DEFS, resolve their names, then scan the spellbook for all spells with matching names and pick the highest ID (which corresponds to the highest rank in TBC). The all_ranks table collects every rank so debuff_missing can check if any rank of a DoT is active on the target.

You can see this working in the export: DEFS defines Corruption as 172 (Rank 1), but cast_events shows 27216 was actually cast — the highest rank the character knows.


Reactive Proc Handling

Nightfall (Shadow Trance) is handled as a high-priority interrupt that bypasses the normal action list:

if menu.shadow_bolt:get_state() and me:buff_up(SHADOW_TRANCE) then
if SD then SD.set_action_list("nightfall") end
spellCallbacks.shadowBolt("nightfall")
return
end

This check runs before actionList.core(), so Nightfall procs always take priority over DoT maintenance. The "nightfall" string is passed both to the SpellDebugger (as an action list tag) and to the callback itself (as a condition parameter):

spellCallbacks.shadowBolt = function(condition)
-- ...
if condition == "nightfall" or me:buff_up(SHADOW_TRANCE) then
return try_cast(SPELLS.SHADOW_BOLT, target,
"Shadow Bolt (Nightfall)")
end
return false
end

This pattern — check before the action list, tag the debugger, call the callback directly — is reusable for any reactive proc: Art of War, Missile Barrage, Clearcasting, etc.


cast vs cast_safe

local use_safe_cast = true

local function try_cast(spell, target, label)
if use_safe_cast then
return spell:cast_safe(target, label)
else
return spell:cast(target, label)
end
end

cast_safe includes additional checks (GCD, casting state, facing, range) before attempting the cast — this is what generates the detailed conditions list in the SpellDebugger export. cast skips these and sends the cast command directly. During development you might use cast to see raw behavior, then switch to cast_safe for production.


The menu uses per-spell checkboxes so you can toggle individual spells on/off during testing:

menu.ROTATION_TREE:render("Spells", function()
menu.toggle:render("Rotation Toggle",
"Keybind to enable/disable rotation")
menu.shadow_bolt:render("Shadow Bolt (Nightfall)")
menu.life_tap:render("Life Tap")
menu.corruption:render("Corruption")
-- ...
end)

The control panel integration adds a keybind toggle to the floating control panel:

core.register_on_render_control_panel_callback(function()
local el = {}
if not menu.enabled:get_state() then return el end
control_panel_helper:insert_toggle(el, {
name = string.format("[Warlock] Rotation (%s)",
key_helper:get_key_name(menu.toggle:get_key_code())),
keybind = menu.toggle
})
return el
end)

This lets users toggle the rotation from the floating control panel without opening the full menu — useful for quick enable/disable while testing.


Rotation Priority

PrioritySpellCondition
0 (reactive)Shadow BoltNightfall (Shadow Trance) proc active
1Life TapMana < 25% and HP > 75%, or Mana < 50% and HP > 99%
2CorruptionMissing from target
3Curse of AgonyMissing from target
4Siphon LifeMissing from target
5Unstable AfflictionMissing from target
6ImmolateMissing from target
7Drain LifeFiller (always available)

Key Patterns to Reuse

PatternWhere in CodeReuse For
Callback-based rotationspellCallbacks tableAny class rotation — structure spells as named functions
SpellDebugger integrationregister_callbacks one-linerDebugging any callback-based rotation
Action list tagsSD.set_action_list("core")Labeling different priority groups in the debugger
Auto rank detectionscan() spellbook scannerTBC/Classic rotations where spells have multiple ranks
Multi-rank debuff checkdebuff_missing with all_ranksChecking if any rank of a DoT is active
Reactive proc bypassNightfall check before action listArt of War, Clearcasting, Missile Barrage, etc.
cast vs cast_safe toggletry_cast wrapperSwitching between debug and production casting
Control panel integrationregister_on_render_control_panel_callbackAdding keybind toggles to the floating panel

Requirements

  • FBR - The Node by Nova — provides the SpellDebugger. The rotation runs without it, but you lose the debug UI and export capability.