WoW Addon
  • Lua 63.8%
  • Python 23.9%
  • Rust 10.8%
  • Shell 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Joshua Marsh (icub3d) 6555ae8685
feat(mana): a druid mana bar under the player frame in bear and cat form
WoW Forever only. Shifting turns the player frame's power bar into rage or
energy and takes mana off screen; this draws it as a thin bordered bar
underneath whenever the main bar is not showing mana. /ic mana [on|off].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 20:21:59 -06:00
Bars feat(druid): potion keys, plain aimed spells, emergency bear, burst macro 2026-09-23 19:12:01 -06:00
Core feat(mana): a druid mana bar under the player frame in bear and cat form 2026-09-30 20:21:59 -06:00
hooks build: syntax, TOC and lint checks outside the game 2026-08-29 15:35:47 -06:00
Layouts feat(classic): Healing Touch on 4 2026-09-25 09:46:38 -06:00
tools feat(sim): replay a Mythic+ key from the combat log with talent swaps 2026-09-25 10:10:58 -06:00
.gitignore feat(tools): query the client's own item and spell tables 2026-09-18 21:16:14 -06:00
.luacheckrc feat: combat logging, a gear record, and a local Warcraft Logs 2026-09-17 11:29:23 -06:00
ALTS.md feat: routes, and why speed enchants aren't worth it 2026-09-17 11:29:01 -06:00
check.sh feat(flavor): run on the WoW Forever beta beside retail 2026-09-18 21:16:25 -06:00
GEAR.md feat: combat logging, a gear record, and a local Warcraft Logs 2026-09-17 11:29:23 -06:00
icub3d.toc feat(mana): a druid mana bar under the player frame in bear and cat form 2026-09-30 20:21:59 -06:00
install.sh feat(flavor): run on the WoW Forever beta beside retail 2026-09-18 21:16:25 -06:00
README.md feat(mana): a druid mana bar under the player frame in bear and cat form 2026-09-30 20:21:59 -06:00
TALENTS.md feat(talents): spec trees for all four specs, and the full-rank rule 2026-09-25 09:46:37 -06:00

icub3d

Personal WoW addon. A command dispatcher and SavedVariables structured so adding a module is a new file plus one ns.RegisterModule call — declarative action bar layouts, keybinding checks, key chords, and a pile of odds and ends that were each easier to write than to keep as a WeakAura.

Built against retail 12.1.0 (Interface 120100) and the WoW Forever beta — flavor wow_classic_beta, build 1.60.1, Interface 16001. See Two games, one addon for what runs where.

Install

./install.sh                 every supported flavor installed
./install.sh _retail_        just one
WOW_ADDONS=… ./install.sh    an explicit AddOns directory

Symlinks this repo to …/World of Warcraft/<flavor>/Interface/AddOns/icub3d. The link name must match icub3d.toc or the client won't load it. Edits here are live after a /reload.

With no arguments it links into every flavor whose interface number the TOC declares, reading each client's own WTF/Config.wtf (engineSurveyPatch) rather than guessing from the directory name. A flavor that has never been run has nothing to read, so it is linked anyway — the TOC decides, and being out of date is a character-screen warning, not a broken install. Naming a flavor explicitly skips the check.

It also points core.hooksPath at hooks/, so the checks below run on every commit.

Checking

./check.sh

The three ways this addon breaks are all invisible from inside the game, which is why they're worth a script:

  • A syntax error stops the client loading that file, and every file after it in the TOC. One typo in Bars/Bindings.lua and Meter, TextWindow and the whole Layouts folder quietly aren't there. check.sh parses every file with luajit, which shares Lua 5.1's grammar with the client — a 5.4 luac accepts goto and bitwise operators that WoW would reject, so it is only the fallback.
  • A file missing from icub3d.toc never loads at all, and looks exactly like a feature that stopped working for no reason. The TOC is checked against the filesystem in both directions.
  • A layout page that isn't twelve entries long doesn't error either — it slides every button after the gap onto the wrong key, which reads as a spell that mysteriously moved rather than as a bug. tools/layoutcheck.lua loads all thirteen layouts outside the game and checks page lengths, class tokens, Talent(n) against the size of the pool, and keys declared for bars that have no page.

luacheck is used if it's installed (pacman -S luacheck) and skipped if it isn't — .luacheckrc leaves undefined reads alone, since those are the WoW API, and keeps undefined writes, which are the missing local that leaks a name into _G for every other addon to trip over.

Beyond the layout shapes, none of this runs addon code, so it says nothing about behaviour — and nothing offline can say whether a spell name resolves, since that is a question about a live spellbook. /reload, /ic bars check and /ic bars audit are still the test.

Druid reference

Two notes files that aren't addon code — they're the reasoning behind what the loadouts in Layouts/DruidTalents.lua and the gear on the character actually are. Both are for 12.1.

TALENTS.md — the class tree

34 points, 53 nodes, gates at 8 and 23. The structural finding is that Soothe|Cyclone → Stampeding Roar is the only path into the damage-multiplier block, so every spec in every format pays two points of pure utility before buying any class-tree damage. Three more chokepoints split the rest into wings. 23 of your 34 points are committed above the last gate, so the bottom three rows offer 19 ranks and let you buy 11.

tools/talents.py derives all of that from the live client data rather than from guides — traversal costs, chokepoints, the ~1.7 trillion legal loadouts, per-node inclusion rates, uniform sampling, and an exact (max, +) optimiser over hand-assigned scores. uv run tools/talents.py verify checks the graph assumptions and the DP against brute force.

GEAR.md — four specs, one gear set

The whole multi-spec problem is one leg enchant.

  • A full set of gems and enchants is worth roughly 7% throughput.
  • Sharing one set across all four specs costs about 1.2% — and 1.1% of that is legs alone, because armour kits give Agility and spellthreads give Intellect, so the wrong one is dead rather than merely suboptimal.
  • Gems and ring enchants cost ~0.15% to share. Midnight gems are dual-stat and all four secondaries are live for all four druid specs; the sim gap between right and wrong is ~0.01% per gem.
  • Chest (Mark of the Worldsoul) is adaptive primary stat, and helm, shoulders and boots are tertiary — identical picks for all four specs.

Do this: keep a second pair of legs, one kitted and one spellthreaded, and swap the item rather than the enchant. Armour primary stat adapts, so any leather legs works for every spec. That one duplicate recovers nearly the whole penalty; nothing else is worth duplicating. Trinkets are the exception and aren't costed there.

Consumables have no sharing cost — they're consumed per use, so carry both. One weapon oil (Thalassian Phoenix Oil), one augment rune (Void-Touched, and it doesn't survive death), one primary-stat feast, plus two flasks split Mastery (Feral, Balance) / Haste (Guardian, Restoration). Potion of Recklessness trades your lowest secondary for your highest, so it reads your own stats and adapts to whatever spec you're in.

Layout

icub3d.toc
check.sh             syntax, TOC and lint checks -- see Checking
Core/Util.lua        API shims (C_Spell, C_SpecializationInfo, C_ClassTalents)
Core/Command.lua     slash dispatcher + module registry + SavedVariables
Core/Spec.lua        /ic cs
Core/Talents.lua     /ic t -- switch and (re)create talent loadouts
Core/Sim.lua         /ic sim -- SimulationCraft profile export
Core/Sound.lua       /ic sound, /sound
Core/Quests.lua      /ic quest -- shift-held accept and turn-in
Core/Vendor.lua      /ic vendor -- repair and sell junk at merchants
Core/Prices.lua      /ic ah -- auction price collection
Core/PriceTooltip.lua     prices on item tooltips
Core/Counts.lua      /ic counts -- per-character inventory
Core/CountTooltip.lua     inventory counts on item tooltips
Core/Hud.lua         /ic hud -- speed / bags / coordinates readout
Core/Remind.lua      /ic remind -- buff check, encounter timeline probe
Core/Waypoint.lua    /way -- TomTom-format coordinates into a map pin
Core/Hide.lua        /ic hide -- hide action, bag and stance bars
Core/ManaBar.lua     /ic mana -- druid mana bar in bear/cat form (WoW Forever)
Core/Meter.lua       /ic dm -- show/hide the damage meter
Core/TextWindow.lua  shared copy-to-clipboard window
Bars/Slots.lua       bar -> action-slot mapping, bar -> binding command
Bars/Entries.lua     entry primitives and resolution
Bars/Apply.lua       event handling, the apply pass, commands
Bars/Dump.lua        /ic bars dump
Bars/Keys.lua        /ic keys show
Bars/Audit.lua       /ic bars audit
Bars/Bindings.lua    /ic keys -- check and fix keybindings
Layouts/Common.lua   the keyboard, the skyriding page, shared audit noise
Layouts/Druid.lua    your bar layouts
Layouts/Warrior.lua  ... and one file per class, all thirteen
Layouts/DruidTalents.lua  the four standard loadouts per spec, as strings
TALENTS.md           class-tree analysis -- see Druid reference
GEAR.md              gems, enchants and consumables across four specs
tools/talents.py     tree analysis: costs, chokepoints, counts, optimiser
tools/scores.py      hand-assigned talent scores -- the model, not the data
tools/loadout.py     read and write talent export strings
tools/simc.sh        fetch, build and drive SimulationCraft locally
tools/sim.py         sim talent choices against a saved gear snapshot
tools/mplus.py       keystone runs from the combat log, as simc routes
tools/layoutcheck.lua     page shapes, class tokens, Talent(n) range

Commands

Command Effect
/ic help
/ic bars apply the layout now
/ic bars check apply, then list every slot that resolved to nothing
/ic bars dump export current bars as a paste-ready layout block
/ic bars racial what Racial() resolves to here, and why it doesn't
/ic bars keys show a bar as the keys you press, not slot numbers
/ic bars macros list the macros the layout wants: Via() /startattack, consumable ladders, im_empty
/ic bars macros make write the missing ones (never overwrites, never deletes)
/ic bars audit every ability you have — or could talent into — with no key
/ic bars status class/spec/loadout, which pages matched, resolved talent pool
/ic bars on / off toggle auto-apply
/ic bars strict toggle clearing slots that resolve to nothing
/ic bars debug verbose logging
/ic keys check bindings against the layout, report what drifted
/ic keys fix rebind anything that doesn't match
/ic keys dump export current bindings as a paste-ready keys block
/ic keys show show a bar as the keys you press, not slot numbers
/ic keys auto toggle rebinding automatically on login / spec change
/ic keys check toggle the report on login / spec change
/ic keys strict toggle unbinding keys the layout doesn't declare
/ic probe which game APIs this client has — for porting across flavors
/ic cs list this class's specs, current one marked (retail only)
/ic cs <prefix> change spec by name prefix — /ic cs f for Feral
/ic cs <index> change spec by index (the same 1-4 BySpec() uses)
/ic t list this spec's saved talent loadouts, selected one marked
/ic t <prefix> switch loadout by name prefix or index — /ic t r for Raid
/ic t setup delete this spec's loadouts, import the standard set
/ic sim export this character as a SimulationCraft profile
/ic sim sets the same, plus this spec's saved loadouts as profilesets
/ic gear what the gear record holds, and how stale it is
/ic gear scan re-read bags, equipped, and the bank if it is open
/ic gear sim the whole record as a simc profile + owned-item list
/ic gear tier which set pieces you own, and where they are
/ic gear auto toggle rescanning on login, bank and equipment changes
/ic gear purge forget everything recorded
/ic log combat logging state: on/off, advanced, and whether here counts
/ic log on / off start / stop logging by hand
/ic log auto toggle starting and stopping by itself
/ic log advanced toggle the advancedCombatLogging CVar
/ic log where <raid|key|both> what auto considers worth logging
/ic quest show shift-held quest automation settings
/ic quest on / off toggle it
/ic quest accept / turnin toggle just one half
/ic quest grace <secs> how long a conversation stays armed (default 1.5)
/ic ah collected price count, plus collection/tooltip/history settings
/ic ah scan sweep the whole auction house once (AH must be open)
/ic ah on / off toggle price collection
/ic ah tooltip toggle the tooltip lines
/ic ah days <n> daily samples kept per item (default 14)
/ic ah purge delete this realm's collected prices
/ic vendor show merchant automation settings
/ic vendor on / off toggle it
/ic vendor repair / guild / junk toggle one piece
/ic counts list recorded characters and when each was last seen
/ic counts find <text> search every recorded character for an item by name
/ic counts scan re-read this character's bags (and bank, if open)
/ic counts on / off toggle tracking
/ic counts tooltip toggle the tooltip lines
/ic counts forget <name> drop a deleted or transferred alt
/ic counts purge delete all recorded inventories
/ic hud show readout settings
/ic hud on / off show / hide the readout
/ic hud speed / bags / coords toggle one segment
/ic hud world switch coords between map percentages and world yards
/ic hud lock toggle dragging
/ic hud size <n> font size (default 12)
/ic hud place <anchor> [x] [y] snap to bottom, top, center, topleft…
/ic hud reset put it back at the default position (bottom centre)
/ic fish toggle fishing mode — one key casts, the same key hooks
/ic fish status what the mode is doing, and what key it borrowed
/ic fish probe log what the cast bar and spellcast events actually do
/ic fish key <KEY> which key the mode borrows (default BACKSPACE)
/ic fish gear <name> equipment set to wear while fishing, or none
/ic fish bar / lock toggle the readout / toggle dragging it
/ic fish place <anchor> [x y] snap the readout to an anchor point
/ic fish reset put the readout back at the default position
/ic fish loot toggle auto-loot while the mode is on
/ic fish sound toggle muting music, ambience and dialog while fishing
/ic fish sfx <0-1> SFX volume while fishing, or keep to leave it alone
/ic fish size <n> readout font size (default 14)
/way <x> <y> [name] pin coordinates on the current map
/way #<mapID> <x> <y> [name] pin coordinates on a specific map
/way / /way list what's pinned now, plus saved waypoints
/way go <n> / rm <n> re-pin or forget a saved waypoint
/way clear remove the current pin
/way link print the pin as a shareable chat link
/way track toggle the on-screen arrow
/way purge forget every saved waypoint
/ic remind buff check now, plus reminder settings
/ic remind buffs check flask, food, rune, weapon and raid buffs
/ic remind auras list every buff on you, as the game names them
/ic remind skip <what> stop checking one: food flask rune weapon fort int shout motw
/ic remind auto / sound toggle the automatic check / the missing-buff sound
/ic remind probe toggle recording what the encounter timeline hands us
/ic remind snap record the timeline as it stands right now
/ic remind dump / clear show the capture, copyable / throw it away
/ic remind api what the encounter timeline API exposes on this client
/ic remind usage call every timeline function wrong, keep the usage strings
/ic hide show which default bars are hidden
/ic hide bags / stance toggle one of them
/ic hide bar1…bar8 toggle one action bar
/ic hide bars toggle all eight action bars at once
/ic hide names toggle the macro names printed on action buttons
/ic mana WoW Forever: what the form mana bar is attached to, and whether it is on
/ic mana on / off show / never show druid mana under the player frame in bear and cat form
/ic dm toggle the damage meter — Details! if loaded, else the built-in one
/ic dm on / off show / hide explicitly
/sound show every channel's volume
/sound <0-1> master volume — /sound 0 mutes
/sound <channel> <0-1> one channel: master sfx music ambience dialog

Applies happen automatically on login, spec change, and talent/loadout change. /ic bars keys still works as an alias for /ic keys show.

Quests, on shift

Hold shift while you talk to a quest giver and the conversation runs itself: the quest is picked out of the gossip list, accepted, and — when you come back with it done — handed in. Let go of shift and every window behaves exactly as it does without the addon.

The modifier is the whole design. Most automation addons invert it: always on, hold shift to stop them. That's fine right up until the one quest you wanted to read, or the one turn-in whose reward you cared about, goes past before you notice. Requiring the key means the default state is "the game does what the game does", and automation is something you ask for one interaction at a time.

Three things it deliberately never does:

  • Choose a reward. A turn-in offering a choice of items stops there with the window open and says so in chat. Picking for you is the one thing that can't be undone.
  • Click gossip options. Only entries in the quest lists are ever selected, so a flight master, a vendor line, or a "begin the scenario" option is never touched.
  • Anything at all while shift is up.

The grace window

Shift is sampled when an event arrives, not when you clicked — and a turn-in isn't one event. It's gossip, then progress, then complete, each a separate round trip to the server. Sampling all of them would mean holding the key through a conversation whose length you can't predict.

So the first shift-held event arms the module for grace seconds, and every step it handles re-arms it. Hold shift as you click and the rest of that conversation carries itself, including a chain of several quests at one NPC; stop interacting and the window lapses on its own. /ic quest grace 0 makes it strictly per-event if you'd rather hold the key the whole way.

Auction prices

Collects the minimum buyout per item and puts it on the tooltip, so you can tell what something is worth without walking to the auction house. Two lines, at most:

Auction                1,204g 50s
14 days ago              980g 12s

The second line is the point of keeping history at all. A bare current price can't tell you whether a thing is cheap right now or has always cost that, and the direction of travel is most of what you want when deciding to buy or to sit on something. It's green when the price has come down since, red when it's gone up — read from the buyer's side.

There is deliberately no buying, selling, or posting UI. Blizzard's auction house is already good at "buy the cheapest one" and "undercut by a copper", and every addon that reimplements that has to keep reimplementing it every patch. What the default UI can't do is follow you out of the building, which is the whole gap this fills.

Getting data in

How When
passive every search you run at the AH is harvested on the way past
sweep /ic ah scan walks the whole house once, a minute or two

Passive collection is free and unthrottled — nothing is requested that you didn't already ask for — and it covers exactly the items you care about, because you looked them up. The sweep fills in everything else.

/ic ah scan drives the same browse state the default UI is showing, so it replaces whatever results you had on screen. Run it when you walk up to the auction house, not in the middle of shopping.

Not used: C_AuctionHouse.ReplicateItems, despite its own API documentation recommending it for whole-house queries. Replicate returns every individual auction row; a browse query returns one already-reduced minimum per item, which is the only number a tooltip wants. Replicate is also the heavily throttled call — orders of magnitude more data, to throw nearly all of it away.

Storage, and why it's bounded

Prices live in their own icub3dPrices saved variable, not in icub3dDB. The two have nothing to do with each other and very different lifetimes: this one grows to megabytes and is disposable, and /ic ah purge should never be able to take your bar config with it.

History is a bounded ring of daily samples — one per item per day, 14 days by default (/ic ah days). Saved variables are deserialized on every login and rewritten on every logout, so size is a cost you pay constantly, not just at the auction house. Unbounded price history is exactly what makes the big auction addons' saved variables enormous.

Two details worth knowing:

  • Keys. Browse results are keyed by ItemKey = {itemID, itemLevel, itemSuffix, battlePetSpeciesID}, not by item ID. Commodities — ore, herbs, flasks, everything whose price you want at a glance — carry a zeroed key and collapse to a bare item ID. Equipment fragments across item levels, so those keep the level and the tooltip falls back to the qualified key.
  • Realm scoping. The database is per realm, which is the conservative choice. Some of the retail market is region-wide and some isn't; getting that wrong optimistically means quietly showing you another realm's prices, while getting it wrong this way just means collecting the same numbers twice under two keys.

A price older than three days keeps showing — it beats nothing, which is what you had before — but the label says how old it is, so it doesn't read as a quote.

Merchants

/ic vendor does the two chores you do at every vendor: repair, and sell the greys. Neither is clever. They're worth automating only because doing them by hand a dozen times an evening is the definition of a papercut.

Repairs prefer guild funds when you're allowed to use them, and the check is on GetGuildBankWithdrawMoney() — your withdrawal allowance, which is not the same as the guild's balance. -1 means unlimited. A bill above your allowance would silently fail and leave you standing there still broken, so that case falls back to your own money.

Junk is counted before it's sold rather than after, because afterwards the items are gone and the only alternative is watching your money go up — which also moves when the repair bill lands, in the same frame.

Unlike quest automation this isn't shift-gated. Quest automation makes irreversible choices on your behalf; repairing and dumping greys is what you were going to do anyway, and there's nothing to regret.

What this deliberately doesn't do is warn that a junk item is worth more on the auction house. Poor-quality items can't be listed at all, so the set of things SellAllJunkItems touches and the set of things /ic ah knows about don't overlap. That check could never fire, and a check that can never fire is worse than no check — it reads like protection.

What every character owns

/ic counts answers "do I already have one of these, somewhere?" on the tooltip, without logging around your alts:

Owned                        252
  Hulner                 12 bags
  Hulner                 40 bank
  Bankalt                    170
  Warband                     30

Character rows carry their class colour, which is the fastest way to find the alt you meant in a list of eight. The current character is broken out into bags and bank because that's the one place the distinction is actionable — you can go get it. For everyone else "somewhere on that character" is all you can act on anyway, so it collapses to one number.

Searching, when the item isn't in front of you

The tooltip needs the item under your cursor, which is no help when the whole problem is that it's on a character you're not playing. /ic counts find asks the same question by name:

/ic counts find healing potion
/ic counts find tome

Substring, case insensitive, sorted by how many you have, capped at 20 rows. Each hit shows the total and which piles it's in, plus the itemID.

Counts are stored by itemID, because that's what the containers report and a name is one localisation away from being wrong. A search has to have names though, and the client's item cache is cold for anything you haven't looked at this session — so uncached items are loaded first and the report waits, with a three-second timeout so a single item that never resolves can't hang it. If anything is still unnamed when it prints, it says how many rather than quietly omitting them: a search that silently skips half your bank is worse than one that admits it. Run it again and the cache is warm.

The one real limit

There is no way to read another character's bags remotely. The client simply doesn't have the data. So the only honest design is to snapshot each character while you're on it: log in to an alt once and it appears; never log in to it again and its numbers quietly age. That's inherent, not a shortcut, which is why /ic counts leads with when each character was last seen.

/ic counts forget <name> drops a character you deleted or transferred, since nothing else will ever clean it up.

Banks

Bank containers report zero slots when the bank window is closed, which is indistinguishable from an empty bank. So a bank snapshot is only ever written while the window is open, and stale bank data is kept rather than overwritten with a confident zero. Opening a bank rescans it; so does closing one, to catch a deposit made immediately before walking away.

Bag indices, for reference: 0–5 are carried bags (5 is the reagent bag), 6–11 are character bank tabs, 12–16 are the account-wide warband bank. Which of those tabs exist is a purchase rather than a constant, so the code asks C_Bank.FetchPurchasedBankTabIDs and only falls back to the static ranges.

The warband bank is stored at the top level rather than under a character, because it genuinely is shared. Storing it per character would multiply one pile of items by however many alts have opened a bank, and every one of those copies would be a different age.

The readout

/ic hud is the one-line thing everyone eventually builds a WeakAura for:

Speed 142%   Bags 42/120   Loc 1234, -567, 89

Labels in class colour, values in white. /ic hud lock unlocks it for dragging; unlocked also means it accepts mouse clicks, which is the whole reason locking exists — a locked HUD never eats a click meant for something behind it.

Dragging is fiddly if you want an exact edge, so /ic hud place <anchor> snaps: bottom, top, center, left, right, and the four corners. Optional x and y follow the anchor (/ic hud place bottom 0 40 lifts it above the action bars). With no offsets, edge anchors get a 12px nudge inward — pinned flush to the bottom of the screen, descenders get clipped.

It's one font string, not a frame per segment. That makes the layout problem string concatenation, which is the only layout engine that never needs debugging.

Three details:

  • Speed is a percentage of BASE_MOVEMENT_SPEED (7 yards/second), because every mount and movement buff in the game is expressed as a multiple of it. Skyriding doesn't come from GetUnitSpeed — while gliding, the game moves you by a different mechanism and GetUnitSpeed keeps reporting ground speed. C_PlayerInfo.GetGlidingInfo() is the one that knows; it's what Blizzard's own motion-sickness vignette reads to decide how fast the landscape is going past. The readout uses it whenever isGliding is true.
  • Bags is counted on BAG_UPDATE_DELAYED and cached, not recounted every frame. Walking six containers ten times a second to get an answer that only changes when the game says it changed is silly.
  • Coordinates default to map percentages (45.2, 67.8) from C_Map.GetPlayerMapPosition. /ic hud world switches to UnitPosition, which is a different coordinate system rather than a scaled version of the same one: big signed numbers in yards on a per-continent grid, with no divisor that turns them into percentages. Both go to -- where the game declines to give a position, which is the normal state of affairs inside instances. The segment shows -- rather than disappearing so the line doesn't change width as you zone.

There is no z

UnitPosition documents a third return as a height. It isn't one — it reads 0, and Blizzard's own code never touches it anywhere in the UI source. There's no other API that exposes player height to an addon, so the coordinate segment drops it rather than printing a permanent 0. The check is still in the code (if z ~= 0), so the line fixes itself if that ever changes.

Segments you turn off cost nothing — they're not built. /ic hud rate isn't a command; the refresh interval is 0.1s and there's no reason to change it.

Fishing

/ic fish. The problem with fishing was never the casting — it's the clicking. You cast, you find a bobber against water the same colour as the bobber, you click it, four hundred times.

Since 10.0 you don't have to. Soft targeting treats the bobber as an interactable object, so the interact key hooks it. The reason nobody noticed is that the default interact arc and range are tuned for talking to a quest giver standing in front of you, not for a cork thirty yards out to your left. Widen those two numbers and the bobber is always in range and always counts as "in front of you", and the mouse never moves at all.

So the mode is three things: widen the interact cone, put cast and interact on one key, and put everything back afterwards.

/ic fish          on. BACKSPACE casts.
                  press it again when the line lands -- that hooks.
/ic fish          off. key, CVars and gear all restored.

There is no automation here, deliberately. A key press per cast and a key press per catch is what keeps this a UI convenience rather than a bot.

One key, two kinds of binding

Casting is a protected action, so it has to come from a secure button. Interacting is a plain binding command, INTERACTTARGET. A key holds one override binding, so the key gets swapped between them: line goes in the water and the key becomes the hook, bobber comes out and it becomes the cast again. The readout says which of the two it currently is, because that's the one part of this design you can't see by looking at the screen.

The swap is driven by polling the cast bar, not by spellcast events. The first version keyed off UNIT_SPELLCAST_CHANNEL_START and simply didn't work — fishing doesn't announce itself as an ordinary channel, so the event never arrived and backspace recast forever. Whatever fishing is classified as internally, a bar is on screen for the whole time the bobber is in the water, and that's the observable fact the feature actually depends on, so that's what gets read (UnitChannelInfo, falling back to UnitCastingInfo).

Polling also means the end of a cast needs no handling at all — no interrupt event, no loot event, no timeout to get stuck on. The bar goes away and the key goes back to casting. /ic fish probe prints each transition, plus every spellcast event this client sends, which is how that wrong assumption should have been caught before it shipped.

Why override bindings

Bars/Bindings.lua rewrites the real binding set, because /ic keys is meant to durably change what your keyboard does. This is the opposite case: the key is on loan. Override bindings are the API for that — they stack on top of the real set, they belong to a frame, ClearOverrideBindings puts everything back in one call, and they don't survive a reload.

That last part is the safety property the rest of this rests on. If anything in here goes wrong while the mode is on, /reload restores your keyboard exactly. It's also why the mode itself isn't saved: the bindings and CVars it depends on are gone after a reload, so persisting "on" could only ever mean lying about the state.

/ic fish reports what it borrowed the key from, and gives it back on the way out. A 30-yard omnidirectional interact key is genuinely unpleasant to walk around a city with — you interact with things you didn't mean to — so the CVars are saved and restored rather than set and forgotten, including on logout.

Combat

The mode suspends itself when you're pulled into combat and comes back when it ends. Override bindings can't be changed in combat at all, so this has to happen on the way in rather than on demand. Suspending also puts your real gear back on, which is why resuming re-equips the fishing set — otherwise every fight would leave you fishing in a helmet.

Gear

/ic fish gear <name> wears an equipment set while the mode is on. Coming back is the interesting half: a set can be restored, but "whatever you happened to be wearing" can't, so if no set was equipped when the mode started there's nothing to return to. That's reported when you turn it on, not discovered later when you're already in a hat.

Sound

Once the mouse stops moving, the splash is the only cue the mode gives you — you're not watching the bobber any more, you're waiting to hear it. So the mode mutes everything that isn't the splash: music, ambience and dialog go to zero, and SFX (which is what the splash is) goes to 1.

Volume 0 rather than Sound_EnableAllSound, matching Core/Sound.lua — one knob, and any nonzero value brings sound straight back. These ride the same save/restore as the interact CVars, so /ic fish off, and even a logout, put your real volumes back.

/ic fish sfx keep leaves SFX wherever you had it. /ic fish sound turns the whole profile off.

Master volume is deliberately untouched. If you've muted the game outright that was a decision, and a fishing mode isn't the thing to override it — /ic fish says so instead, because otherwise a muted master looks exactly like the splash cue being broken.

One consequence worth knowing: /sound changes made while the mode is on are overwritten when it ends, because what gets restored is the volumes from before it started.

Why there's no bite alert

There is no event for a fish biting, and that isn't an oversight to work around. The bobber is a world object; the splash is an animation and a sound, and an addon can see neither. Every fishing addon ever written answers "when do I click?" with "listen for the splash" — which is why the sound profile above is the real feature here, not a nicety.

Soft targeting looked like it might have changed that. softinteract resolves to whatever the game considers interactable, documented as including world objects, so if the bobber only became interactable at the moment of the bite, that would have been the bite exactly. It was built, wired to a screen flash, and tested: the probe saw nothing — not at the bite, not when the bobber landed, not at any point in the cast.

The reason is that softinteract being set and UnitExists("softinteract") being true are different things. UnitExists returns false for anything that can't be targeted, and the bobber is a game object, not a unit. That's why the interact key can act on it while no unit-token API can see it — the same reason a mailbox or a herb node reads as nothing. The interact binding and the unit tokens are looking at two different systems that happen to share a name.

So the bobber is hookable and invisible at the same time, and that is consistent rather than contradictory.

So: listen for the splash. Don't spend another evening on this one.

Lure

The lure is a temporary weapon enchant, the same as a shaman's imbue, so the readout gets it from GetWeaponEnchantInfo. Read only — applying one is a protected item use, and would need a second secure button and a second borrowed key, which is more machinery than "the bar says the lure ran out" is worth.

The CVar names

They aren't documented anywhere official. One the client doesn't have is skipped and named in the output rather than treated as an error, so a rename in a future patch degrades to "the arc didn't widen" instead of a broken mode.

Reminders

/ic remind. Two halves in Core/Remind.lua that share a file because they will share an engine, and nothing else.

Buffs

/ic remind buffs answers "am I actually ready to pull" — flask or phial, Well Fed, augment rune, a weapon oil or stone, and the four raid buffs. It runs automatically on a ready check and a few seconds after you zone into a dungeon or raid, which are the two moments the answer still matters. Missing things print in one line and play the raid-warning sound.

/ic remind skip food turns off any single check and running it again turns it back on, because the checks you don't want are personal and a settings UI for eight booleans is not worth writing.

Matching is by aura name, not spell ID. "Flask" and "Phial" as substrings catch whatever this expansion decided to call its flasks, where a table of IDs needs editing every patch. The cost is that it's English-only and could collide with a similarly named toy. Both failures are loud rather than silent — you'll see the wrong answer the first time — and the CHECKS table at the top of the file takes a numeric spell ID in place of a string wherever a name misleads.

The weapon check is the exception: temporary enchants live on the weapon rather than on you, so that one goes through GetWeaponEnchantInfo().

Why this isn't a WeakAura

Midnight's secret values put live combat state in a box an addon can hold but not open, scoped to active raid encounters and M+ runs. That is what ended WeakAuras, and it draws a hard line through this module:

  • Anything that reads your buffs or cooldowns to make a decision is dead inside an encounter. So the buff check deliberately runs before the pull, where nothing is secret, and doesn't pretend to be a combat aura.
  • Anything that only reacts to the timeline still works, because the timeline is Blizzard's own data and they hand it over — DBM and BigWigs are built on it now.

Which means a reminder can say "big hit in 4 seconds, press something" but not "press Ironfur, which I can see is ready." The unconditional version is most of the value anyway: you know your own cooldowns, you just want to not be surprised by the incoming.

Every read of game state in this file goes through a Readable() guard. Touching a secret value as a number or a string throws, and a reminder that errors mid-pull is worse than one that says nothing.

The timeline probe

A capture tool, not a feature — it exists to be deleted.

The wiki documents C_EncounterTimeline.GetSortedEventList() and GetEventInfo() signatures but not the payload, and whether an event carries a spell ID is what decides whether timeline reminders can be written per-ability ("alert me on Crushing Blow") or only per-severity ("alert me on anything red"). That question is cheaper to answer from inside one dungeon than from any amount of reading.

/ic remind api        what C_EncounterTimeline actually exposes here
/ic remind probe      start recording
... run a key or a boss ...
/ic remind dump       the capture, in a copyable window

api is worth running first and costs nothing: it lists the real functions on your client and names any event the client rejected at load. Registering an unknown event name throws, so each registration is wrapped — which turns "the probe recorded nothing" from a mystery into a line of output.

snap records the timeline as it stands at that instant rather than waiting for the next event, which is the one to use if you're standing in a boss room wondering whether any of this is populated at all.

Recording state persists across a reload on purpose: you turn it on, run something, and read it afterwards, and a mid-run reload shouldn't silently stop the capture. The buffer caps at 600 lines and says so when it fills.

Hiding default bars

/ic hide bags and /ic hide stance get rid of the bag bar and the stance/form bar. /ic hide bar1 through bar8 do the same for the action bars, and /ic hide bars does all eight in one go. /ic hide names blanks the macro name under every action button, and unlike the bars it works in combat.

The bar keys are the ones Bars/Slots.lua uses, so /ic hide bar4 and a layout's bar4 mean the same bar.

These work by reparenting the frame into a permanently hidden frame, not by calling Hide(). They are Edit Mode systems that show themselves again whenever the game feels like it — the stance bar especially, since it's driven by a secure state driver that reasserts on every form change. A Hide() has to be re-applied forever and loses the race at least once. A frame whose parent is hidden simply never draws, and nothing has to fight anything.

Two consequences worth knowing:

  • They are protected frames, so reparenting them during combat isn't allowed. Anything asked for mid-fight is queued and applied when you drop combat, and /ic hide says so while it's pending.
  • Unhiding restores the frame, but whether it should be on screen right now is Blizzard's call and some of that only runs at load. If a bar doesn't come back, /reload. The command tells you this rather than leaving you staring at it.

A hidden bar also isn't selectable in Edit Mode, for the obvious reason.

Hiding an action bar does not unbind it

The point of hiding action bars is to keep the keys and lose the clutter, and that works: bindings like ACTIONBUTTON1 and MULTIACTIONBAR1BUTTON1 are handled by the game's action system rather than by routing a click through a visible frame, so a bar sitting in limbo still fires on its key. /ic bars is unaffected for the same reason — it writes action slots, not frames.

Two things to check the first time you try it, because they depend on frame internals rather than on anything this addon controls:

  • bar1 is MainMenuBar, which in current builds also parents the XP/reputation tracking bar. Hiding bar1 may take that with it. If you're levelling and want the XP bar, leave bar1 shown and hide the rest.
  • Form and stance paging lives on a secure state driver attached to the main bar. A hidden parent shouldn't stop it — that's the whole argument for reparenting over Hide(), and it's how the stance bar has always behaved here — but druid form swapping is the thing to watch on the first run.

Mana in bear and cat form

WoW Forever only. Shifting into bear or cat turns the player frame's power bar into rage or energy, and the mana that decides whether you can shift back out is no longer on screen. Core/ManaBar.lua draws it as a second, thinner bar directly under the first, for druids, whenever the main bar is showing something other than mana. Moonkin and travel form keep the main bar on mana, so nothing extra appears there.

It is on by default; /ic mana off turns it off, and /ic mana says which frame it attached itself to. If the client ever shows its own second bar for a shifted druid, this one stands down rather than drawing two.

There is no number on it. The bar is fed straight from UnitPower, and nothing does arithmetic on the value, so it keeps working if the client hands addons power as a secret value in combat.

Mounting

/ic mount — F1 summons a random favourite, and dismounts you if you are already up. C_MountJournal.SummonByID(0) does both, so one key covers both directions.

A shapeshifted druid can't mount at all — the summon fails rather than dropping the form for you — so the macro cancels the form first:

/cancelform [form]
/run C_MountJournal.SummonByID(0)

Conditional, so it's only a form cancel when there is a form: everyone else presses a line that does nothing, and so does a druid in caster form or already mounted, because the key still has to mean "mount" and "dismount". /cancelform is protected, which is the other half of the argument for the secure button below — as macrotext it's an ordinary macro line, where the same thing from a Lua binding handler would be refused.

/ic mount             what is bound, and how many favourites you have
/ic mount key F1      move it, or blank to unbind

Deliberately not a chord. Mounting is the most-pressed non-combat action there is, and putting it two keys deep to be tidy would be paying for a menu nobody needs to read. A chord is for choosing between things; this is the case where you do not want to choose. The specific mount is still at , m when it matters — the yak when you want a vendor, the brutosaur when you want the mail.

It goes through the same secure button and override binding the chord leaves use, rather than running the summon straight from a binding handler. Summoning wants a hardware event behind it, that rule has been tightened more than once, and this way it cannot be legislated out from under. F1 is Blizzard's self-target key; the override borrows it, never touches your real binding set, and a /reload puts it back.

Key chords

/ic chord — a tree of menus walked with the keyboard. Press the root key, then one key per level, and the last press does the thing. F1 t h is travel, hearthstone. Depth is unbounded; it goes until it hits a leaf.

The tree is declared in Layouts/Chords.lua, the same way bars are declared — no configuration UI, and a diff you can read a year later, which is the argument against keeping this in rings.

The root is , — a leader key, in the Vim sense. The right hand triggers and the left hand types the chord. It is unbound in a default WoW and safe to press in chat, since an override binding does not fire while a text field has focus. Not /, which opens the chat box before anything here would see it.

/ic chord             open the first root
/ic chord list        print every tree
/ic chord check       resolve every leaf against this character
/ic chord find <text> search worn, bags, toys and mounts for a name
/ic chord key main F1 move a root's key
/ic chord close       hand the keyboard back
/ic chord delay 0.15  hesitation before the popup appears
/ic chord unlock      drag the popup
/ic chord icons       toggle icons
/ic chord size 13     popup font size

The popup appears late on purpose

The model is which-key, not a menu. A menu is something you open, read and choose from; a chord is something you already know. So the level opens instantly, the keyboard is rebound instantly, and the popup is only scheduled — press the next key inside the delay and no frame is ever drawn. Hesitate and it appears, with a breadcrumb, one row per entry, and cooldown sweeps.

Once it is up, going deeper draws immediately. Waiting a second time would read as lag rather than as restraint.

Core/ChordFrame.lua is a display of state and nothing else: Core/Chord.lua has already rebound the keyboard by the time it is asked to draw, and the whole feature still works with the file deleted — the levels go to chat instead, which is the mode the chord machinery was built and tested in before there was anything to look at.

Nothing is ever hidden for being unusable. A hearthstone on cooldown stays where it is, greyed, with its sweep, because dropping it renumbers everything below and a chord whose second half depends on what happens to be off cooldown is a chord you cannot learn. Position is the contract; the greying is only there so you know why nothing happened.

Declaring by name, and checking it

Most of the tree is macro = "/use <name>". That one form resolves against bags, equipped items and the toy box at once, so a hearthstone, a toy, a ring and a cloak are one leaf type rather than four, and nothing has to be looked up in a database first. /cast <name> does the same for mounts.

Names are the fragile half of that trade — an item you no longer carry, a toy on another account, a rename — and a macro turns every one of those into silence rather than an error. /ic chord check is the answer: it walks the whole tree, resolves each leaf against this character, and prints the ID it found next to it. A stale name is a line of red instead of a key that quietly does nothing, and the printed IDs are what you would paste back if you ever want the declaration hardened from names to numbers.

/ic chord find is the other half of that. check tells you a name is wrong; find tells you what the right one is, searching what you are wearing, your bags, your toy box and your mount journal for a substring and printing the ID next to every hit. The names worth putting in a tree are exactly the long ones nobody recalls exactly, so half of one is the interface.

Items you have to wear first

/use on an equippable item in your bags does not equip it. It tries to fire the on-use and the game answers must have the proper item equipped. So equip = true arms the press for where the item actually is: /equip while it is in your bags, /use once it is on you.

That decision is made while the level is being built, out of combat, because a secure attribute cannot be changed during the press that changes it — and it is made again on PLAYER_EQUIPMENT_CHANGED, which is the moment the swap is both confirmed and safe to act on. The press that equips a ring cannot re-arm its own button; the event that follows can.

Between the two, the entry keeps the menu open and shows equip next to its name, so the second press is a single key rather than the whole chord again, and the first press does not look like it did nothing.

Why the leaf has to be a secure button

Casting, using an item and summoning a mount are protected: they have to come from a real key press landing on a SecureActionButton that already has the right attributes. You cannot walk a tree in Lua and then do the leaf at the end — by that point the hardware event is spent.

So the tree is walked between presses, never during one. Opening a level assigns every entry on it to a button, sets that button's attributes, and points the entry's key at it. The next press fires the secure action without the addon in the way; a PostClick afterwards decides whether that press was a branch (descend, rebind, wait) or a leaf (close). Branch entries carry no attributes, so their press is a no-op on the secure side and navigation on the Lua side.

Letters, not numbers

An entry with no key gets the first free letter of its own name, and only falls back to a digit when nothing in the name is free.

That order is the point. A digit is a position, so a list that grows — a mount added to your favourites, a profession learned — renumbers everything below it, and every chord you had learned past that point is now wrong. A letter is derived from the entry itself and survives its neighbours changing. , m f s keeps being the sea turtle no matter what else you favourite.

The same reasoning is why nothing is hidden for being on cooldown: an unavailable entry that vanishes renumbers everything below it, and then you are reading the menu instead of playing it.

Generated menus

A branch's menu can be a function instead of a list, called when the level opens rather than cached — out of combat, on the gap between two key presses, with nothing else happening. Walking every mount in the game to find the favourites is fine there, and beats a cache invalidated by events nobody remembers to register.

Core/ChordMenus.lua holds them: ns.chord.FavoriteMounts and ns.chord.Professions, the two lists that would be wrong the moment you favourite something or learn a trade.

Professions open the window, not the trade. Casting a profession by name mostly opens its window, but "mostly" is the problem — fishing casts a line instead — and a menu where one row does the thing and the rest open a panel will catch you out. Opening a panel is not protected, so these are plain Lua leaves; the cast stays as a fallback for a client without the API, chosen once while the level is built rather than inside the leaf. Fishing is in the list like everything else — it has a window too — and is only absent on the cast fallback, where selecting it would put a line in the water instead.

Markers

/wm and /tm are not slash commands in a stock client — they come from other addons — so the marker entries are Lua leaves calling SetRaidTarget, PlaceRaidMarker and ClearRaidMarker directly.

That also lets them say why nothing happened. World markers need a party or raid; placed solo they do nothing at all, silently, which from the other side of the screen is indistinguishable from a broken keybind. Target markers work solo and only need a target.

Leaving an instance group

, u l. A Lua leaf for the same reason the markers are: it can say why nothing happened.

The category is the whole point. You can be in two groups at once — the party you queued with, and the instance group the finder put you in — so this calls C_PartyInfo.LeaveParty with the instance category, which drops the dungeon or delve without disbanding the party you came with. That's the same thing the raid frame's "Leave Instance Group" does. Called with no category it would leave the home party instead, which is /leave and not what this key is for.

There's no confirmation step, deliberately — clicking through a menu is the thing the bind exists to avoid — which is why it sits on l under Utility rather than anywhere a finger lands on the way to something else. Press it with no instance group to leave and it says so rather than doing nothing.

Entries that only some characters have

A leaf may carry when, a predicate asked when the level opens:

{ key = "r", name = "Rootwalking", spell = "Rootwalking",
                                   when = ns.chord.IsRace("Harronir") },

The token is matched case-insensitively, and an optional second argument is a race ID that matches on its own — the same leniency Racial() has, for the same reason. A when that is wrong hides the row from the one character it was written for and says nothing about why, which looks exactly like an entry that doesn't apply.

False and the entry isn't there at all — no key, no row, and nothing for /ic chord check to call red. That distinction is the point: a Harronir racial on a dwarf is not a broken declaration, it's one that doesn't apply, and a leaf that merely failed to resolve would look identical to a name that had gone stale. Filtering can't shift a chord you've learned, because a declared key is a letter rather than a position.

Rootwalking is there because the Harronir have two racials and they belong in different places. Thorn Bloom is the combat one and goes on Shift-F through Racial() like every other race's; Rootwalking teleports you to the Cradle, so it sits under travel with the hearthstones.

Out of combat, on purpose

SetAttribute, SetOverrideBinding and ClearOverrideBindings are all refused in combat, and every level change calls all three. Nothing here is a rotation, so that is the right trade — but it has one honest failure mode: a menu open when something pulls cannot give its keys back until combat ends. It says so when that happens and clears on PLAYER_REGEN_ENABLED, the same deferral fishing uses. Keep the chord alphabet off your combat keys and it never comes up, and a /reload restores the keyboard exactly, because override bindings do not survive one.

The root keys and the level keys have separate owner frames for the same reason: override bindings are cleared per frame, all at once, and rebinding a level must not be able to take the root key with it.

Waypoints

/way takes the line Wowhead gives TomTom users and turns it into the game's own map pin, super-tracked so you get the on-screen arrow and distance counter:

/way #2393 47.6 51.0 Riftblade Maella
/way 47.6 51.0 Some Name
/way 47.6 51.0

It also eats a map-pin hyperlink pasted out of chat — C_Map does that parsing, so a format change is Blizzard's problem rather than a regex here.

One pin, and it has no name

This is the client's model, not a shortcut: the game allows exactly one user waypoint, and there is no API that attaches a label to it. Setting a second replaces the first.

So the name you type is kept on this side. /way lists everything you've pinned before with its name and zone, and /way go <n> re-pins one. That turns the one-pin limit from "your notes are gone" into "your notes are in a list", which is most of what a waypoint addon was doing for you anyway.

Pinning the same spot twice is one entry moved to the front, keeping the newer name. Coordinates round to a tenth, which is the precision everyone shares at.

TomTom

If TomTom is loaded, /way is left alone — both would claim it and the winner would be whoever registered last. /ic way does everything regardless. The check runs at PLAYER_LOGIN rather than at file load, because TomTom sorts after icub3d and isn't loaded yet when this file runs.

Coverage audit

/ic bars audit answers the question the layout can't answer about itself: what did it forget? It cross-references three sources —

  1. What's on the bars right now — GetActionInfo over every mapped slot, with macros unwrapped via GetMacroSpell and flyouts expanded. Ground truth for "has a key", including anything placed by hand.
  2. The spellbook — every active (non-passive) ability you currently know.
  3. The talent trees via C_Traits — every node in the class tree, spec tree, and hero trees, including choices you didn't take.

— and reports, in a copyable window:

  • Known, active, but no key. The real gaps. If the layout does mention the spell, the line says so — that means the layout is fine and the apply just hasn't run.
  • Active talents not taken, no slot in the layout. Retalent into one of these and it lands on no key. This is the "spells I feel like I'm missing" list: add each as an alternative in some Spell(...) entry (or the talent pool) so the layout is ready before you ever take it.
  • Active talents not taken, layout already has a slot. Informational — the first-known-wins machinery catches these on its own.

Everything is scoped to the current spec: macro conditionals, BySpec() arms, and the trait config all resolve against the spec you're standing in, and the other specs' trees aren't loaded into the active config at all. To audit all four specs, /ic cs <spec> and rerun — four runs, four honest answers, no guessed state.

Abilities that genuinely never need a key (Teleport: Moonglade, Dreamwalk, …) go in audit.ignore in Layouts/Druid.lua. Grow that list from audit output rather than guessing at it up front.

Changing spec

/ic cs          list
/ic cs f        Feral
/ic cs 2        by index

The spec list comes from the game, not from a table here, so the letters are whatever this class's spec names start with — druid gets b f g r. Any prefix works as long as it's unambiguous; /ic cs prints the shortest one that is, next to each spec's role and index. An ambiguous prefix says what it matched rather than guessing.

Combat is checked before the call so you get a message instead of silence. Nothing needs to fire afterwards: bars already listens for PLAYER_SPECIALIZATION_CHANGED, so the layout re-applies on its own.

Talent loadouts

/ic t           list this spec's saved loadouts, selected one marked
/ic t r         switch by name prefix (or index)
/ic t setup     delete this spec's loadouts, import the standard set

Every spec carries the same four names, so the muscle memory is the content type, not the spec — two specs add a fifth for a job the four don't cover:

Loadout For
Quest open world — quick kills, rounding up mobs
Dungeon M+ / dungeons
Raid raid bosses
Delve solo/duo with Brann — self-sufficiency first
Catweave Restoration only — a delve build that damages in Cat Form
Speed Feral only — legacy dungeons for transmog, built to move

The builds live in Layouts/DruidTalents.lua as talent-calculator export strings, each commented with its source guide (12.1 / Midnight Season 2). One is hand-made and says so: Restoration's Delve is a caster build — heal the companion, damage at range, never weave cat — with its recipe in TALENTS.md rather than a guide URL. Restoration's Catweave is a published build — Icy Veins' Delves one — but read off the string rather than off the page, because the page argues against what it publishes: all the damage is in Cat Form and there is not one astral talent in the tree (the read-off). Quest tracks that one rather than Delve, since questing has no companion to hold the pack.

Feral's Speed is hand-made too, and differently: nobody writes guides for running Karazhan in 2026, so it is generated rather than sourced. The class tree is uv run tools/talents.py optimise --spec feral --profile speed, the string is assembled by tools/loadout.py, and TALENTS.md has the four talents that do the work. The strings encode a hash of the trees, so after a patch that changes them the import fails loudly with "refresh it from the guide" instead of half-applying — update the string from the URL in the comment and rerun /ic t setup. Where guides publish no distinct Quest build, it duplicates the Delve string but stays a separate saved loadout, free to drift in-game.

Everything is current-spec only — the game refuses cross-spec imports and switches — so setup runs once per spec: /ic cs g, /ic t setup, repeat. setup deletes the spec's existing saved loadouts first (it says which); the talents you're standing in don't change until you /ic t <name>.

Switching goes through C_ClassTalents.SwitchToLoadoutByIndex, the same secure path as Blizzard's dropdown, which avoids the long-standing taint bug where addon-driven LoadConfig breaks action buttons mid-fight. Outside a rested area the switch is the usual interruptible "Changing Talents" cast; success and failure both get a chat line. Bars re-apply on their own afterwards — bars listens for TRAIT_CONFIG_UPDATED.

Shared action bars. A loadout created through the API defaults to carrying its own action bars, captured empty at creation — switch to one and every bar is wiped, the skyriding bar included. Every loadout setup creates is therefore flagged usesSharedActionBars immediately, and /ic t <name> heals the flag on any loadout still carrying private bars before switching to it. If a wipe already happened: /ic bars restores everything the layout declares, which now includes the skyriding page.

Simulating

Raidbots is a frontend and a pile of hardware wrapped around SimulationCraft, which is GPL and builds locally in well under a minute. tools/simc.sh is the wrapper:

./tools/simc.sh setup          clone (shallow) and build -- once
./tools/simc.sh update         pull and rebuild after a patch
./tools/simc.sh run FILE       simulate a profile
./tools/simc.sh version        engine build + the game build it targets

The clone, the build and the reports all live under tools/ and are gitignored. Reports land in tools/out/ as HTML and JSON.

The profile comes from /ic sim in game, which is this addon's version of the official SimulationCraft addon's /simc. It writes the same format into the usual copy window: character, spec, the active talents= export string, and one line per equipped item with its enchant, gems, bonus ids and crafted stats.

/ic sim sets additionally emits each of this spec's saved loadouts as a profileset."Name"=talents=... line, so one run compares every build /ic t can switch between:

Profilesets (median Damage per Second):
    134504.938 : Raid
    117286.664 : Dungeon
    117254.446 : Delve
    117088.567 : Quest

That took 1.5 seconds. target_error=0.2 is the default and is roughly Raidbots' Quick Sim quality; anything after the filename is passed to simc, so run me.simc target_error=0.05 fight_style=DungeonSlice works.

Asking what-if

tools/sim.py exists so that trying a talent does not mean going back into the game. Save /ic sim output once as tools/gear/<name>.simc — it only needs redoing when your gear changes — and the talents come from Layouts/*Talents.lua or from the command line:

uv run tools/sim.py loadouts
uv run tools/sim.py swap --loadout delve --out incapacitating_roar --in mighty_bash
uv run tools/sim.py swap --loadout delve --set "fluid_form:0" --name "no fluid form"

simming no fluid form...
  bash over roar            68,233   +0.04%
  baseline (Delve)          68,208
  no fluid form             67,346   -1.26%
  no heart of the wild      61,807   -9.38%

It never has to build an export string. simc parses talents=<hash> first and then layers class_talents=/spec_talents=/hero_talents= on top as name:rank pairs, rank 0 removing — so a swap is expressed against your real build. Names are checked against the tree data before simc runs, because an unknown one aborts the whole sim.

It runs one simc process per variant, and that is not paranoia. The obvious design — one invocation, every variant as a profileset — is quietly wrong: a profileset that removes a talent leaks into the others in the same run, including the baseline. Measured alone this character sims at 68,241; put a heart_of_the_wild:0 profileset next to it and the baseline reads 61,692, having inherited the removal, so every delta collapses to nothing. Separate processes cost about a second each and cannot contaminate each other.

Two things to watch. --error defaults to 0.05 because talent deltas are small and the 0.2 that simc.sh uses is bigger than most of them; the noise floor is then around 0.05%, so treat anything under ~0.1% as no result. And simc does not enforce the talent budget, so adding a talent without removing one compares your build against one you cannot play — sim.py says so when it spots that.

Two caveats. /ic sim is a reimplementation, and the official addon is the reference — it tracks item-string changes faster than this will, so if an export looks wrong, check it against /simc before believing it. And it exports only the sixteen items you are wearing — for everything else, see the next section.

Replaying a key

sim.py swap answers "what does this talent do to my DPS" against a dummy. For Mythic+ the better question is "what would it have done to that key", and the combat log has everything needed to ask it:

uv run tools/mplus.py runs                  your keystone runs, newest first
uv run tools/sim.py route murder --set "rampant_ferocity:0/coiled_to_spring:1" \
    --name "CtS for RF" --vs raid

Murder Row +10, 9/17/2026 13:54:01, 18:14 -- 9 pulls, Hulner did 20.1% of the damage
    0:11    80s   27 mobs    141.4M  trash  [lust]
    ...
   16:18   116s   36 mobs     66.1M  boss: Lithiel Cinderfury

                            route         change  your DPS  real key
  baseline (as exported)     719s
  CtS for RF                 745s   +25.9s ±1.1     -4.7%     +8.6s
  loadout Raid               996s  +276.4s ±1.4    -34.5%    +67.1s

The run is a number from runs or part of a dungeon name (the newest match), and defaults to your newest key. Variants take the same --set, --out/--in and --name as swap, plus --vs <loadout> for a whole other build. With more than one profile in tools/gear/ it picks the one for the spec the log says you played.

How the key becomes a sim. tools/mplus.py cuts the run into pulls: every boss encounter, and every burst of trash damage separated from the next by six quiet seconds. Each enemy gets the health the group actually took off it, times your share of the run's damage, and the time between pulls is kept. That goes to simc's DungeonRoute fight style, which runs until everything is dead, so what comes back is how long you would take to clear your part of that route.

How that becomes seconds. Not directly. The sim plays far better than a person -- it never moves, never waits on a mechanic, always has the pack stacked -- so its route is faster than the key really was, and the header line says by how much. What carries over is the ratio: your DPS change across the route, applied to the key's real fighting time through your share, since the other four players are unchanged. real key is that estimate; negative is faster. ± is the sim's standard error on the route (600 iterations per variant by default); anything inside about 2 seconds of route is no result.

What it doesn't know, and which way each one leans:

  • A boss's adds all spawn at the pull. The log says who was hit, not when they arrived, so an add-wave boss plays as one big AoE pull. That flatters AoE talents on those bosses.
  • Your share is assumed even across every mob. In the real key your teammates' AoE killed the small ones while you were on the big ones.
  • Cooldown timing moves between variants. A talent that shifts when Convoke comes up moves damage from one pull to the next, which is real, but it makes single pulls noisy. Trust the total, not one pull.
  • One key is one route. Run the same swap across a few keys before deciding, especially across dungeons: in these logs bosses were 29-46% of fighting time depending on the dungeon.

Ranking what you already own

tools/sim.py varies talents against fixed gear. tools/gear.py does the other half: fix the talents, vary one slot, and rank everything in your bags that could go in it.

uv run tools/gear.py list --slot trinket
uv run tools/gear.py rank --slot trinket --ilvl 311
uv run tools/gear.py rank --slot weapon  --ilvl 311 --spec feral

The item list is not scraped and does not come from /ic sim. Core/Counts.lua already records the bags and bank of every character you log in to, plus the warband bank, into icub3dCounts — but it stores item ids and counts and nothing else. So gear.py cross-references those ids against SimulationCraft's own item database, tools/simc/engine/dbc/generated/item_data.inc, which is already on disk because simc is already built and which matches the game build the sim will use. That recovers names, inventory slots and stat types with no network access and no second addon.

refresh keeps the profile current without the copy-paste. /ic gear sim is a paste, so the file on disk is only as fresh as the last time you remembered to do it -- but Core/Gear.lua already writes the same information to SavedVariables on every scan:

uv run tools/gear.py refresh

rebuilds the item lines from icub3dGear and keeps the existing file's header and talents= line, which is the one thing the addon does not record (it stores gear, not builds). Talents change far less often than gear, which is what makes that the right way round; paste a fresh export when they do.

Three limits, all of which come straight from that data source:

  • Counts never sees equipped items, only bags and bank. So rank seeds the list from the gear profile's own slots as well, and marks those equipped.
  • Counts stores no bonus ids, so nothing here knows what item level your copy actually is, or how far up its upgrade track it sits. That is why --ilvl is required rather than inferred: every candidate is simmed at the same level. That is the useful question anyway — if I spent the crests, where would this land — and it avoids pitting a 6/6 against a 1/6.
  • An item from a previous expansion scales on a different curve and cannot actually reach a current --ilvl. Those are still printed, because "the old one is somehow still ahead" is worth seeing, but they are tagged legacy and they do not set the baseline.

The method is the standard one for valuing a single item: empty the slot's partner so two trinkets cannot interact, and vary one. Absolute DPS is therefore low by design and only the ordering and the gaps mean anything — and a gap smaller than --error is a tie, not a win.

The gear record, and real swaps

rank has to be told an item level because Counts cannot know one. /ic gear removes that limitation. Core/Gear.lua keeps a second, much smaller record than Counts: the full simc item string — bonus ids, gems, enchant, crafted stats — for every equippable item in your bags, bank and on your body.

/ic gear scan        (open the bank first, for the bank half)
/ic gear sim         → paste into tools/gear/<name>.simc

That export is a runnable simc profile with your owned items appended as comment lines the sim ignores and the tool reads:

# owned hands 308 bags Enigmatic Dreamwatcher's Gauntlets
# ,id=271529,bonus_id=...

Which unlocks the two commands that actually answer gearing questions, because every candidate now sims at the item level your copy really is, against your real equipped set:

uv run tools/gear.py swap --slot trinket
uv run tools/gear.py tier

swap sims every owned item for a slot against your current gear. For the paired slots it tries both positions, because which ring or trinket a candidate should displace is the question, not an input to it:

Zul'jin's Guillotine Technique 311 -> trinket1     134,662   +6.55%
Zul'jin's Guillotine Technique 311 -> trinket2     128,872   +1.97%
equipped (as exported)                             126,380     --

tier is the same machinery pointed at set bonuses — is a lower item level tier piece worth it for the next set bonus? Nothing on an item says "this is tier"; what says so is membership of a set, and simc already ships that table in item_set_bonus.inc, keyed by class and spec. So the tool reports the piece count and simc applies the bonus itself:

+Enigmatic Dreamwatcher's Somnolent Stare 302 (…2pc)   128,740   +1.83%
equipped (as exported)                                 126,424     --

+…Somnolent Stare 302 (…2pc): displaces head (id 271875)

A row that crosses a 2pc or 4pc threshold already has that bonus in its number, so the item level it gave up is priced in.

Both commands run one simc process per variant, for the reason sim.py documents: a profileset that changes something leaks into its neighbours, baseline included, and every delta then collapses together.

Two things the record cannot do, both inherited from how the client works. There is no way to read another character's bags remotely, so every character records itself while you are on it. And the bank reports zero slots when its window is shut, which is indistinguishable from an empty bank — so a bank scan only happens with the window open, and a stale bank record is kept rather than replaced with a confident nothing.

Logging

Uploading to Warcraft Logs never needed an addon, and this one does not upload anything. The game writes Logs/WoWCombatLog.txt; /combatlog is a built-in toggle; the uploader is a separate desktop application that watches that file. What logging addons actually do is press the toggle at the right moment, which is what Core/Log.lua does:

/ic log auto                 start and stop by itself
/ic log where key            ...for keys only, rather than keys and raids

Two things it checks that are easy to get wrong by hand:

  • advancedCombatLogging must be on. Without it the log still parses, but every line loses actor health, resources, position and gear — most of what the site's analysis is built from. It is off by default, it is not sticky across a fresh WTF/, and /ic log says so in red rather than assuming.
  • Logging everything is a mistake. The file is plain text and grows fast, and the uploader has to read all of it. where defaults to raids and keys.

/combatlog is not sticky. It is a per-session toggle: it survives a /reload, and it is off again every time you log out and back in. There is no "turn it on once" setting — which is the entire reason this module exists, and the reason everyone else runs Loggerhead.

On Linux the uploader is a native AppImage — no Wine, no Lutris prefix:

https://github.com/RPGLogs/Uploaders-warcraftlogs/releases

Point it at the _retail_ folder inside the Proton prefix and it watches the log the same way it would on Windows.

Reading the log: tools/clog

A local Warcraft Logs, in Rust, with a ratatui front end. It parses WoWCombatLog.txt, splits it into runs, and totals up who did what. Nothing is uploaded and no account is involved.

cd tools/clog && cargo build --release
./tools/clog/target/release/clog            # the TUI
./tools/clog/target/release/clog runs       # what is in the log
./tools/clog/target/release/clog show 10 --healing --spells 2
./tools/clog/target/release/clog logs       # log files on disk
./tools/clog/target/release/clog compress   # shrink the old ones ~17x

cargo install --path tools/clog puts clog on your PATH if you would rather not type the target directory.

In the TUI: j/k move, tab switches focus between the run list and the actor table, h swaps damage for healing, z shows plain zone segments, q quits. Four overlays for the selected actor: s spells, d what killed them, t what is hitting them, u aura uptime. Each has a CLI equivalent (--spells N, --deaths, --taken N, --uptime N).

┌ runs ───────────────────────────┐┌ healing -- Nek'zali the Soulcoiler ──────┐
│09:43 Lady Naz'jar     0:29 kill ││             HPS     total  overheal absorb│
│10:09 Nek'zali…        4:43 kill ││Tekloa    ██ 102.7K  29.1M  45%     453.6K │
│10:19 The Twin Fangs   6:44 kill ││Nákur     ██  83.2K  23.5M  57%     194.9K │
│10:52 Appalling…       0:03 wipe ││Kalastraza██  79.4K  22.5M  44%     758.9K │
└─────────────────────────────────┘└───────────────────────────────────────────┘

Three things it gets right that are easy to get wrong, all of which were bugs first and were found by running against a real 200MB log rather than a synthetic one:

  • The suffix is not a fixed distance from the end of the line. SPELL_DAMAGE carries a trailing tag that SWING_DAMAGE does not, so counting backwards reads melee damage off by one and returns the attacker's level. clog counts forwards from header + prefix + advanced block, which also copes with advanced logging being off.
  • SPELL_ABSORBED's last-but-one field is the shield's size, not the hit. Reading it inflates absorbs about fivefold.
  • A pet's damage can arrive before its owner has done anything, which creates the owner's row under the pet's name -- a shaman showed up in the output as "Surging Totem". Names are learned from every event and applied at the end.

Two numbers per row, not one, because one of them always lies. DPS is over the whole segment and aDPS is over time actually spent fighting. For an encounter they agree; for a zone segment they do not, and the gap is the walking. A 13-minute delve read 285K by the clock and 625K while actually swinging. Healing has the same pair.

taken and shielded are separate columns because the damage event's amount is post-absorb, with the absorbed portion in its own field:

SWING_DAMAGE  amount=10341  base(unmitigated)=58668  absorbed=2582

so 12,923 arrived and 10,341 came off the health bar. Folding them together would hide mitigation; reporting only amount undercounts a shielded tank. Both are shown and incoming is their sum.

Absorbs count toward healing output. They used to sit in their own column outside the HPS everyone was ranked by, which understated shield-heavy healers badly -- on one LFR boss it moved a paladin from 44.6K to 66.2K and doubled a warlock's number. The absorb column still shows the shield portion on its own.

Damage aimed at a friendly target is not damage done. Retail has plenty of it -- abilities that cost health, friendly-fire mechanics, a paladin's Blessing of Dawn logging a real SPELL_DAMAGE from a player to himself -- and crediting it as throughput inflates DPS with damage that never touched an enemy. It still lands in the victim's taken column, because they really did take it. Over one 215MB log that was 30.1M of damage, 96.6% of it self-inflicted (Sacrifice, Refraction, Soul Link, Fel Armor), and it was worth 7% of one tank's damage done. Only friendly is excluded: a neutral mob you pulled is a real target.

The incoming side gets the same breakdown as the outgoing one. t (or --taken N) splits what hit an actor by ability and by what swung it, which is the tank question the damage-done table cannot answer:

Felzel -- incoming 24.0M  (19.9M taken, 4.1M shielded)
by ability
  Eternal Venom      6.9M   28.6%
  Toxic Fumes        5.3M   22.3%
by source
  Vexhul             8.1M   34.0%
  (unattributed)     6.1M   25.5%

Totals are incoming, not taken, because a hit does not stop being aimed at you because a shield ate it.

Aura uptime, defined as up on at least one unit. u (or --uptime N) is the metric that actually says whether you played well, and one rule covers both cases people care about: Ironfur uptime is one unit (yourself) and Rip uptime is any target bleeding. Per-target uptime is a worse number -- a Feral with Rip on four mobs is not at 400%. The flip side is that on a heavy multi-target fight, 100% Rip does not prove the boss was bled.

Rip                  ██████████ 100.0%   16x deb
Rake                 ██████████ 100.0%   35x deb
Predatory Swiftness  ██████      63.2%    9x

Permanent passives are filtered out. An aura never applied during the segment and up for all of it is a flask, a food buff or a profession passive: always 100%, never a decision, and there are enough of them to bury the rows that matter.

Two things this got wrong first, both worth knowing if the numbers ever look odd. Applications and time have to be banked separately -- adding the live interval on every application re-counts it once per unit, which put a raid-wide buff at 269 minutes of uptime inside a 6:44 fight. And because an encounter nests inside a keystone, an interval is attributed per segment as now - max(since, segment.start), with a closing segment paid off before it leaves the open set so it cannot be paid twice.

Deaths say what caused them. UNIT_DIED alone gives a count, which is the least useful thing about a death, so a 15-second ring buffer of incoming hits per player is kept and snapshotted when they die -- spell, source and health remaining, killing blow last. A source of nil becomes (unattributed), which is a DoT whose caster despawned or environmental damage, not a missing feature.

Summoned damage is folded in but also broken out. Every meter folds pets into their owner and so does this, but in a delve the "pet" is Brann or Valeera doing half the damage. A delve that read 285K DPS was 76% War Turtle and Valeera Sanguinar; the player's own share was about 68K. The spell breakdown says so explicitly rather than letting the headline number lie.

Keeping the logs

The client never cleans up after itself: one file per play session, appended and never rotated, at roughly 150 MB per hour of instanced content. With /ic log where all that is a gigabyte or two a week.

The answer is not to delete them. zstd gets about 17x on this data:

clog compress            what it would do (dry run)
clog compress --apply    do it
clog logs                sizes, and a nudge if it is getting silly

WoWCombatLog-091726_094034.txt ... 215.8 MB -> 12.8 MB (16.9x)

clog reads .txt.zst transparently, so a compressed log is not a different kind of thing -- runs, show and raw all work on it unchanged, and the decompression costs about 60ms on top of a 0.57s parse. A year of logging becomes a few gigabytes instead of seventy-five, with nothing discarded.

Three files are never compressed, and the first reason is the important one:

  • One some process holds open. On Linux, unlinking a file a process has open leaves that process writing to an inode nobody can see: the compressed copy would be a snapshot, the rest of the session would go nowhere, and the disk space would not come back until the game exits. Truncating in place is worse. So clog scans /proc/*/fd and refuses, with or without --apply.
  • One written in the last five minutes, as a backstop in case /proc cannot be read.
  • One already compressed.

You never need to touch the live file anyway. The name carries the time logging started, so the next play session writes a new one and today's becomes compressible on its own.

The compressed copy is verified before the original is removed. It is streamed back out and compared byte for byte, and a mismatch leaves the original alone -- this deletes the only copy of something irreplaceable, so the tenth of a second is worth it. Round-tripping a 215 MB log reproduces an identical MD5.

There is deliberately no timer. Compression is lossless, so automating that would be fine; automating deletion is how you lose the log from the night something interesting happened. clog logs prints a line when the uncompressed total passes 500 MB and leaves the decision to you.

clog screenshot renders one frame to an offscreen buffer and prints it as text, so the layout can be checked over a pipe, in CI, or into this README without a terminal. --kind takes a string of keys to press first.

There was a Python reference implementation, tools/clog.py, and it is worth knowing it existed: it is the version the field offsets above were discovered with, and the port was accepted only once the two produced byte-identical output over the same 200MB log across twelve run/mode combinations -- damage, healing, spell breakdowns, pets, overheal and absorbs. Rust does it in 0.46s against Python's 11.6s. It was deleted once that comparison passed, because two implementations of one parser drift apart and the second one stops being a reference the first time it lags a patch. git log -- tools/clog.py if you ever want it back to re-run the diff.

A run that this module started is a run it will stop; a run you started with /ic log on is left alone until you say /ic log off. That asymmetry is deliberate — zoning out of a raid should not silently end a session you started on purpose.

The talent export string is the same format Layouts/DruidTalents.lua stores and tools/talents.py manipulates, so the sim, the loadouts and the tree analysis all speak to each other. See TALENTS.md.

The core idea

An entry can list alternatives, and the first one you actually know wins:

Spell("Cenarion Ward", "Nourish")

A talent-granted spell stops being known the moment you untalent it. So this resolves to whichever of the two you currently have, with no conditionals anywhere in the layout. That is why one set of pages covers all four druid specs and every talent build: everything you don't have simply doesn't resolve.

Entry primitives

Entry Meaning
Spell("A", "B", …) first known spell wins; names or IDs
Macro("name") macro by name
Via("Spell", "macro") the spell, through your macro when it exists — see Aimed casts
Item(id) item
Skip() leave this slot exactly as it is
Clear() actively empty this slot
BySpec(a, b, c, d) pick by spec index 1–4, then resolve that
Any(...) first alternative of any type that resolves
Talent(n) the Nth known spell from the talent pool
Assist() the Single-Button Assistant, asked for from C_AssistedCombat
Racial() your race's on-use racial, looked up by race token or race ID

A bare string or number is shorthand for Spell(x).

Talent(n)

The pool is a list of optional class-tree talents you'll only ever have some of. Talent(1..7) fills seven buttons with whichever seven you took, packed with no gaps — retalent and the buttons reshuffle to match. If you know fewer than n, the slot is left alone.

/ic bars status prints the current pool resolution so you can see exactly what Talent(3) means right now.

Pool entries may be Via() as well as Spell(), which is how the aimed CC macros ride in the pool: the gate is still "do you know the spell", so an untaken talent takes no slot even though its macro exists.

Key roles

bar1 is the only bar that swaps with form, and it holds the cheapest keys. So it carries the rotation, and each key is assigned a role that holds across all four form pages — muscle memory transfers through the role even though the spell differs:

Key Button Role caster cat bear moonkin
1 1 main cooldown Nature's Swiftness Tiger's Fury Pulverize New Moon
2 2 2nd cooldown Cenarion Ward Primal Wrath Bristling Fur Stellar Flare
3 3 situational Grove Guardians Maim † Moonfire Solar Eclipse
4 4 situational, don't misfire Ironbark Feral Frenzy † Growl Astral Communion
Q 5 primary builder Rejuvenation Shred Mangle Wrath
E 6 2nd builder / dot Regrowth Rake Thrash Starfire
R 7 primary spender Swiftmend Rip Ironfur Starsurge
F 8 2nd spender Wild Growth Ferocious Bite Maul Starfall
Mouse4 9 AoE builder Moonfire ‡ Thrash Swipe Moonfire
Mouse5 10 AoE / defensive Sunfire ‡ Swipe Frenzied Regen Sunfire
Wheel↑ 11 spammable — — Wrath (Balance only) root macro
Wheel↓ 12 Single-Button Assistant assist assist assist assist

† Cat swaps its two situational slots. Feral Frenzy came up off Shift-3 onto 4 — it's pressed far more often than its cooldown suggests, and the shift row was too far for that. Maim moved to 3, and Adaptive Swarm went out to Shift-3 in the trade. R is still Rip, so the spender row reads straight across every form.

The cost is that cat's 4 no longer holds a don't-misfire button. A mistimed Feral Frenzy is cheap and a mistimed stun isn't, and 3 gives Maim no more protection than 4 did — if that starts biting, swap 3 and 4 back.

‡ Restoration's healing came off the thumb buttons — Lifebloom moved to Z and Efflorescence to Shift-Wheel↑, because a mouse button doesn't fire while the cursor is on a raid frame. See Mouse buttons die under the cursor. What went back is the caster damage, which is the one thing that fits the rule rather than fighting it: you press Moonfire and Sunfire with the cursor out in the world. Wheel↑ is Wrath for the same spec, on the wheel's usual misfire-harmless test. Dead weight in a raid; the whole damage kit in a delve. Sunfire is a class talent for Resto, so that key is empty until the build takes it.

The wheel deliberately holds no cooldowns: it is fast but imprecise, and a mistimed cooldown costs more than a mistimed filler. Where nothing in a form is genuinely forgiving, the wheel is left empty rather than padded. Wheel↓ is the exception that proves the rule — see Single-Button Assistant.

Form-independent utility stays on the static bars (bar3 = Z X C V B G T 5 plus Shift-Z/X/C/V) so it is literally the same key everywhere. That is the other half of the muscle-memory story, and why the two goals — "same place in every form" and "common spells on QER" — don't actually conflict: they live on different bars.

/ic keys show prints this grid live for whichever form you're in.

Alts

The role table is the whole reason there are now thirteen layout files rather than one. An alt is a new rotation on a keyboard you already know: Q builds, R spends, C interrupts and Shift-Wheel↓ is the oh-no button on every character you own, and the only thing that changes between them is which spell sits behind each role.

Read across a row and the point is obvious:

Role Druid (cat) Warrior (Arms) Paladin (Ret) Rogue (Sub) Mage (Frost)
Q primary builder Shred Mortal Strike Blade of Justice Gloomblade Frostbolt
E 2nd builder / dot Rake Overpower Judgment Shadowstrike Ice Lance
R primary spender Rip Slam Templar's Verdict Eviscerate Flurry
F burst / execute Ferocious Bite Execute Hammer of Wrath Rupture Glacial Spike
C interrupt Skull Bash Pummel Rebuke Kick Counterspell
Z emergency heal Regrowth Victory Rush Flash of Light Crimson Vial barrier
Shift-Wheel↓ Survival Instincts Shield Wall Divine Shield Cloak of Shadows Ice Block

Three things are worth knowing before you edit one of these files.

Empty is a decision. Every class got the same twelve roles and not every class has twelve things worth binding. Where nothing fits, the key is Clear() with a comment saying why, not a second copy of something that already has a key — a demon hunter's Z is empty because the class heals by eating soul fragments, and a death knight's wheel is empty because nothing a death knight casts is free. Padding a key is worse than leaving it open: it teaches your hand a lie.

Only two classes page the main bar. Druid forms and rogue stealth swap bar1 onto a bonus page, so those two files declare cat/bear/moonkin and stealth/shadowdance on top of the eight real bars. Slots.lua still carries battle/defensive/berserker and shadowform aliases from the expansions where warrior stances and Shadowform paged; they're historical and nothing declares them.

The spells are a starting point, the audit is the verification. A layout file is written against what a class is expected to know; only the client can say what it actually knows. On a fresh alt, run:

/ic bars check     what didn't resolve, and why
/ic bars audit     actives you know that have no key at all
/ic keys check     whether Q really is the key for bar1:5

check separates "your layout is wrong" from "you just don't have that yet", which on a level-20 alt is nearly everything. audit is the one that finds real gaps — an ability with no home — and its output is meant to be pasted into the class file, either onto a key or into audit.ignore.

Checking and fixing keys

The whole table above is a claim about bindings, and until now nothing checked it. bars guarantees the right spell is in slot 5; it says nothing about Q being the key that presses slot 5. A keybind reset, a fresh character, or Blizzard shipping a new default and the layout is still correct while your hands are wrong — silently, because every slot still reports as placed.

So a layout can declare its keys next to its bars, indexed by the same 1–12 button numbers and merged baseline ← spec ← loadout the same way:

B.Register("DRUID", {
    bars = { bar1 = normal, bar3 = bottomLeft, … },
    keys = {
        --       1    2    3    4    Q    E    R    F    M4         M5
        bar1 = { "1", "2", "3", "4", "Q", "E", "R", "F", "BUTTON4", "BUTTON5",
                 "MOUSEWHEELUP", "MOUSEWHEELDOWN" },
        bar3 = { "Z", "X", "C", "V", "B", "G", "T", "5" },
    },
})
  • nil means leave this button alone, exactly like a nil in a bar page. A short list is fine; only the buttons you name are ever touched.
  • A table gives a second key: { "Q", "NUMPAD7" }.
  • Case doesn't matter — "shift-q" is fine.

Keys go on bar1, never on cat/bear/moonkin. The form pages have no bindings of their own; they swap the main bar onto other slots, so one bar1 declaration covers all four forms. Declaring keys on a form page is a layout bug and gets reported as one rather than silently rebinding bar1.

/ic keys reports drift, naming what each key is actually on:

Keys: 21 correct, 2 wrong
  bar1:5  Q is on bar1:7
  bar1:6  E is unbound

/ic keys fix rebinds and saves. Because SetBinding steals a key from whatever held it, the fix reports anything it left with no key at all — that displacement is the one surprise worth surfacing.

Keys that aren't buttons

A reserved bindings sub-table declares commands with no action button behind them — the bindings the bars half of the addon cannot reach at all:

keys = {
    bar1     = { … },
    bindings = { FOCUSTARGET = "CTRL-F" },
},

Same row shape as a button, so the report, /ic keys fix and the displacement warning all work on it unchanged, and /ic keys dump emits the block for the handful of commands worth carrying in a layout (FOCUSTARGET, TARGETLASTTARGET, TARGETNEARESTENEMY, TARGETSELF, ASSISTTARGET, INTERACTTARGET) when they're bound.

Set focus is the reason this exists. It is the one key a layout genuinely needs and can't put on a bar — a focus macro on an action slot would work, but it would cost a button to say something that is already a binding.

Getting started

Layouts/Druid.lua ships the block emitted by /ic keys dump on my character. If your bindings differ, that's the loop: bind everything the way you want it in-game, then

/ic keys dump

and paste the emitted block into B.Register. Same arrange-then-dump-then- commit loop as /ic bars dump. Only the buttons named in the block are ever touched, so a short block is a safe block.

Defaults, and why

Setting Default
check on report drift on login and spec change; read-only
auto off rebind on login and spec change
strict off unbind keys the layout doesn't declare

auto is off because rewriting keybindings behind your back isn't something an addon should do until you've said out loud that you want it. strict here is off even though bars strict is now on: an extra key you bound by hand is cheap to keep and harmless, while a stale button actively misleads mid-fight — the two kinds of drift earn different defaults.

Worth knowing before turning auto on: WoW has no per-spec keybindings. SaveBindings writes to whichever set you're currently editing, so if you're on account-wide bindings a spec-change rebind changes every character. /ic keys auto prints which set it will write to when you enable it, and every fix says so afterwards. If you want per-spec key sets, switch to character-specific bindings first.

Binding changes are refused in combat, so a fix triggered in combat is queued and runs when you drop out — the same deferral the bar apply uses.

Racial()

Shift-F, as Any(Racial(), Macro("im_racial")) — the racial if it resolves, your old macro if it somehow doesn't.

The table lives in Bars/Entries.lua and is keyed by race token (NightElf, ZandalariTroll, …), the stable English identifier from UnitRace, not the localized display name.

Tokens are matched case-insensitively, and a few keys are numeric race IDs instead — a backstop rather than a second spelling. Both exist because a token miss is silent: Racial() resolves to nothing, Any() moves on to the fallback macro, and the key keeps working with the wrong ability on it, which is the worst failure this file has.

The token is also less stable than it looks. Earthen's is EarthenDwarf, not Earthen. The Harronir have two r's, and writing it the way everyone says it — Haranir — is how a Vulpera im_racial macro ended up on a Harronir druid's Shift-F for a week. A race whose token spelling hasn't been read off a live client gets its ChrRaces ID listed too; the lookup tries the token first, then the ID.

/ic bars racial prints your token, your race ID, and each listed name with whether it resolves and whether it's known — the fastest way to tell "the table is missing your race" from "the spell name is wrong".

Entries are spell names, not IDs, and that's the whole trick: several racials ship as a different spell ID per class — Blood Fury has three, Arcane Torrent has one for nearly every resource type — while keeping a single name. Going by name collapses that to one entry per race. Races with more than one on-use (Forsaken, Goblin) list them best-first and resolve first-known-wins, exactly like Spell().

An unlisted race is treated as a config error, so it gets reported on every apply rather than passing silently — the table being out of date is a bug, not game state. Add the row and it's fixed.

Only seven races can be druids — Night Elf, Tauren, Worgen, Troll, Highmountain Tauren, Zandalari Troll, Kul Tiran — so the rest of the table is never exercised here. It's complete anyway so the primitive survives being copied into another class's layout.

Single-Button Assistant

Wheel↓, on all four form pages, via the Assist() entry.

Blizzard's one-button rotation ability (spell 1229376, patch 11.1.7): it picks the next ability for you and casts it, at the cost of an extra 0.3s global cooldown on anything it casts.

Why the wheel and not 1. The wheel's stated role is "spammable, misfire-harmless," and the assistant is the only button in the layout that is purely that — it chooses the ability itself, so there is no wrong time to press it. Mashing is the intended input. Meanwhile 1 is the main-cooldown slot, and the assistant presses your cooldowns for you: putting it there would cost you Tiger's Fury / Pulverize / New Moon on the key your hands already know, in exchange for a slot you don't need to be precise with. The wheel was empty on every page; nothing is displaced.

It's on all four pages rather than a static bar because the wheel is far cheaper to reach than Z X C V B G T 5, and repeating one entry across four pages costs nothing — the key still means the same thing in every form, which is the point.

About that doubled Tiger's Fury. Cat's Wheel↑/Wheel↓ were Skip(), and Skip() never clears a slot — so a Tiger's Fury you dragged there by hand years ago stayed forever, invisible to the layout. That's the duplicate you noticed. Both wheel slots are owned by the layout now — Wheel↓ holds the assistant, Wheel↑ is Clear() where nothing earns it (bear's holds Wrath for Balance — see Fluid Form transitions) — so a stale copy gets swept on the next apply.

Assist() rather than Spell("Single-Button Assistant") — the spell is flagged hidden until overridden and displayed outside of spellbook, so the usual IsPlayerSpell test can't be trusted for it. Assist() asks C_AssistedCombat.GetActionSpell() instead, which is what Blizzard's own bar code uses, and falls back to the literal ID only on a client with the spell but not the API (Core/Util.lua). If the feature isn't available — below level 10, say — the entry resolves to nothing and the slot is left alone.

Shift row (bar2)

Shift-Q/E/R/F are nearly as cheap as Q/E/R/F, so they're premium space. They used to hold Wrath / Starfire / Sunfire, duplicating the moonkin page. They now hold the panic buttons, which had been on Ctrl-Z/X/C/V — reaching those means re-gripping the keyboard mid-fight:

Key Holds
Shift-1 Celestial Alignment / Berserk / Incarnation (by spec)
Shift-T Moonfire — Feral tags out of form, Guardian casts in bear
Shift-3 Adaptive Swarm (Feral) / Grove Protection (Resto)
Shift-Q Convoke the Spirits
Shift-E health potion macro
Shift-R healthstone macro
Shift-F racial — Racial(), resolved from your race
Shift-M4 top trinket — im_trinket1 (/use 13)
Shift-M5 bottom trinket — im_trinket2 (/use 14)
Shift-Wheel↑ minor offensive CD — Force of Nature / Warrior of Elune / Rage of the Sleeper
Shift-Wheel↓ the big oh-no button — Survival Instincts / Tranquility
Shift-G Nature's Swiftness — static, so it survives moonkin form paging

The duplicates were only there because the caster page was Restoration-only, so Balance had nothing on bar1 out of form and its kit had to live on a static bar. The caster page is spec-aware now, which is what freed the row.

Freed keys

Key Notes
Z (Restoration only) Regrowth — Resto already has it on E; the slot is Skip() for you to fill

Ctrl-Z took Thorns, and Ctrl-X is reserved for a combat-potion macro: create im_pot and the next apply places it; until then the key is actively cleared. Ctrl-C/Ctrl-V, freed when the panic macros moved to the shift row, are now the Fluid Form transition keys — see below.

Trinkets

The two thumb buttons on the shift row are the two trinket slots, on the same reserved-macro pattern as im_pot:

Key Macro Body
Shift-M4 im_trinket1 #showtooltip / /use 13 (top trinket)
Shift-M5 im_trinket2 #showtooltip / /use 14 (bottom trinket)

By equipment slot, not by name — Item("…") would go dark the moment you replace a trinket. Create the two macros and the next apply places them; until then both keys are actively cleared. The addon does not create them for you: macros are a limited per-character resource, so writing to your macro list is opt-in the same way keys.auto is.

These replace the old combined im_trinket that sat on Shift-F; that macro can be deleted. Shift-F now holds the racial, promoted off Shift-M5 — a keyboard-hand cooldown, where the trinkets want the thumb.

Barkskin left the shift row entirely: it is already on the forms bar at bar5:5 (Alt-Q), bound and in use, so the second copy was redundant.

The forms bar shows only its first six buttons — bar5 slots 7–12 are declared Clear() and must stay that way. Anything placed there is invisible and unpressable.

Fluid Form transitions

The Fluid Form class talent makes form entry part of the rotation: Shred, Rake, and Skull Bash shift you into cat, Mangle shifts you into bear, and Wrath/Starfire shift you into moonkin (at the end of the cast). The catch is reachability — Mangle normally lives only on the bear page, which you can't see until you're already in bear. Transition keys are form-independent by definition, so they live on the static bars:

Key Holds Means
Ctrl-C Mangle become bear, and start its rotation
Ctrl-V Rake become cat, and open its rotation
C Skull Bash the kick doubles as an emergency cat entry
Q (caster, Balance) Wrath back into moonkin from caster form
Wheel↑ (bear, Balance only) Wrath the way out of a defensive bear-weave

Mangle and Rake are baseline bear/cat abilities, so every spec resolves them. Without Fluid Form talented the keys still work as plain in-form attacks — they just stop crossing forms. Leaving cat/bear back to caster needs no key at all: casting any heal auto-unshifts.

The bear-page Wheel↑ entry is BySpec Balance-only and fits the wheel's misfire-harmless rule for that spec — spamming Wrath in bear is "get back to my rotation". Other specs keep the slot free.

The interrupt on C is spec-aware: im_beam (Solar Beam) on Balance, im_skull_bash on Feral/Guardian, reserved (empty) on Resto so C never means anything but "interrupt". Both macros aim themselves — see below.

Mouse buttons die under the cursor

Mouse4 and Mouse5 do nothing while the cursor is over any mouse-enabled frame. The frame under the cursor takes the click first, and a party or raid frame is mouse-enabled because it has to be — targeting, tooltips, the right-click menu. The binding never fires.

This is not the same thing as click-casting, and turning click-casting off does not fix it: the frame still eats the press, it just does nothing with it afterwards. Keyboard bindings are unaffected, because they don't route through whatever the cursor happens to be over.

So the rule is: a mouse button may only hold something you press with the cursor in open world. For a DPS or a tank that is nearly everything, and the thumb buttons keep their AoE role. For a healer it is nearly nothing — the cursor lives on the raid frames — so the healing specs hold Clear() on both thumbs and their two casts move to the keyboard:

Class Spec was M4 → was M5 →
Druid Restoration Lifebloom → Z Efflorescence → Shift-Wheel↑
Priest Discipline Power Word: Shield → Shift-4 Power Word: Radiance → Shift-Wheel↑
Priest Holy Renew → Shift-4 Circle of Healing → Shift-Wheel↑
Paladin Holy Holy Prism → Shift-4 Light of Dawn → Shift-Wheel↑
Monk Mistweaver Soothing Mist → Shift-4 Sheilun's Gift → Shift-Wheel↑
Shaman Restoration Chain Lightning → Shift-4 Chain Heal → Shift-Wheel↑
Evoker Preservation Emerald Blossom → Shift-4 Temporal Anomaly → Shift-Wheel↑

Shift-4 and Shift-Wheel↑ were Clear() on every one of these layouts, so nothing was displaced. The druid is the exception — both were already spoken for, and Resto's Z arm was Skip() (Regrowth is on E for that spec), which made it the one bare keyboard key the spec had free.

Two things this does not cover, both worth a look:

  • Shift-Mouse4/Shift-Mouse5 are the trinkets, on every layout. They die over a frame the same way. A healer pressing a trinket while hovering the raid frames gets nothing, and there is no free key to move them to.
  • The mousewheel is fine — tested on a party frame, and a bound cast fires normally. A frame only receives wheel input if it explicitly asks for it, and the compact frames don't. So the rule really is about buttons, and the spells that would have been worst hit stay where they are: Priest's and Paladin's Flash Heal / Flash of Light on Wheel↑, and Priest's Pain Suppression and Monk's Life Cocoon on Shift-Wheel↓ — externals you press precisely while hovering someone's frame.

Nameplates are mouse-enabled frames too, so the same rule applies to hovering one to aim. Every aimed spell in the layouts is already on a keyboard key — Shift-G for Intervene, Misdirection and Tricks of the Trade, X/V for the paladin blessings — which is lucky rather than planned, and is now the rule.

Aimed casts

The unit you want is often not the unit you have targeted — the caster to kick, the enraged mob, the add to root, the thing beating on the healer. WoW can only express that in a macro, so the layout routes those spells through one:

#showtooltip Skull Bash
/cast [@mouseover,harm,nodead][@focus,harm,nodead][] Skull Bash

Mouseover, then focus, then your target. Two lines, and no side effects at all — it never touches your target, your focus or your auto-attack. If the cursor is over a hostile unit (a nameplate, a model in the world) that's the cast; otherwise a hostile focus; otherwise exactly what a plain cast would do.

The trailing [] is a bare "and otherwise, normally", which matters when your target is the friendly you're healing: casting a harmful spell at a friendly assists them, so it lands on what they are fighting. Worth confirming in game rather than taking on faith — it depends on your auto-self-cast setting.

The one cost is that the cursor votes. Resting the mouse over a mob while you meant to cast at your target casts at the mob. That's the trade every mouseover-first player already makes with their raid frames, and it's why this is on CC and utility rather than on the rotation.

The macros

One per aimed spell, each the two lines above with the name swapped. Reserved, not created: macros are a limited per-character resource, so the addon never writes to your macro list.

Key Macro Spell
C im_beam Solar Beam (Balance)
C im_skull_bash Skull Bash (Feral, Guardian)
X im_roots Entangling Roots
B im_dispel Nature's Cure (Resto) / Remove Corruption
bear:4 im_growl Growl
Talent(n) im_bash Mighty Bash
Talent(n) im_cyclone Cyclone
Talent(n) im_soothe Soothe
Talent(n) im_hibernate Hibernate
Talent(n) im_mass_entangle Mass Entanglement

im_skull_bash already exists — add the /cast chain to it, and check whether its form-shift line is still earning its place, since Fluid Form makes Skull Bash shift you to cat on its own.

Two adjustments for particular spells. Soothe is a dispel, so its filter is the same harm (an enrage is on an enemy) — no change needed. The friendly dispel on B is the opposite and is the one macro here that wants help:

#showtooltip
/cast [@mouseover,help,nodead][@target,help,nodead][@player] Nature's Cure

im_dispel is the same macro name the other layouts use for their dispel, so one macro per character covers whichever class you are on — swap the spell name. Growl is the interesting one: a mouseover taunt is more deliberate than a plain one, since the cursor names the mob, which is the opposite of what a self-aiming taunt would be.

Via(), and why not Macro()

Via("Cyclone", "im_cyclone")

The spell, through that macro when the macro exists. Three states, and the third is the reason the primitive exists:

resolves to
spell known, macro exists the macro
spell known, no macro yet the plain spell
spell not known nothing

A bare Macro("im_cyclone") can only manage the first. A macro exists whether or not you took the talent, so it would leave a live-looking Cyclone button on a build without Cyclone — and inside the Talent(n) pool it would hold a slot against a talent you actually have. Via() gates on the spell first, which is what makes these safe to put in the pool.

It also means you can build the macros one at a time, or not at all. Every one of these keys works as a plain cast today and upgrades the moment the macro appears; delete a macro and the key falls back rather than going dark.

What isn't aimed

  • Typhoon, Ursol's Vortex — ground-targeted. There's no unit for a mouseover to name.
  • Incapacitating Roar — centred on you; it already hits what's near.
  • Maim — needs combo points on a target you're already fighting, which is the case where you have the right target anyway.
  • The rotation — bar1 and the form pages stay plain spells. Cursor position deciding your builder is a bug, not a feature.

The nearest-enemy variant, and why it's not here

An earlier draft carried Naowh's macro on Shift-C: no mouseover and no focus, so park your target in the focus slot, /cleartarget, /targetenemy, cast, restore the target, drop the focus. It works — slash-command targeting runs through the game's secure path, so it's legal in combat where an addon calling TargetUnit would be refused — and using the focus slot as a one-line variable is a genuinely clever trick.

It's not in the layout because the fallback chain covers nearly all of it without any of the costs: the dance clears a friendly focus (the harm check fails, so it falls through to borrow-and-clear), has no range or line-of-sight check so it will eat a cooldown on something 40 yards away, drops auto-attack (which its trailing /startattack then repairs), and can't restore a target that has become untargetable. For a mouseover-first player what's left is a narrow case — hands off the mouse, no focus, nothing hostile targeted — and Shift-C is worth more as a Talent(n) key.

Moonfire

Shift-T on bar4, a static bar — the same key in every form and spec. Two different uses want one key, which is exactly why it isn't on a form page:

  • Feral — cast out of form to tag an enemy.
  • Guardian — cast in bear form, part of the 12.1 bear rotation.

Written plainly as Spell("Moonfire"). Feral's Lunar Inspiration talent does grant a separate spell (155625) that shares the name and is castable in cat form, but by-name is still safe: the talent overrides the button, and SlotMatches compares base spell IDs (Bars/Apply.lua:85), so the slot keeps matching and the apply never fights the override. Nothing to pin by ID here.

One limit worth knowing rather than discovering: baseline Moonfire cast from bear form shifts you out for any spec without Guardian's bear-form Moonfire. That's a spec restriction, not something the layout can route around — it just doesn't bite either of the two uses above.

Bars and slots

Action slots 1–180 are 12-button pages:

Key Slots
bar1 1–12 main bar
page2 13–24 main bar page 2
bar4 25–36 MultiBarRight
bar5 37–48 MultiBarLeft
bar3 49–60 MultiBarBottomRight
bar2 61–72 MultiBarBottomLeft
cat prowl bear moonkin 73–120 bonus bars 1–4 (druid forms)
skyride 121–132 bonus bar 5
bar6 bar7 bar8 145–180 MultiBar5/6/7

Bonus bars are the subtlety. Shifting into a form doesn't change what bar1 holds — it swaps the main bar onto a different page of slots entirely. A druid layout that only fills bar1 is empty the moment you shift.

Bar starting slots are read off the live button frames at runtime, with the table above as fallback, so Blizzard renumbering the bars again won't silently scramble a layout.

Registering a layout

B.Register("DRUID", {
    talents = classTalents,          -- pool for Talent(n)
    bars    = { bar1 = normal, bear = bear, … },
    keys    = { bar1 = { "1", "2", … } },
    specs   = { [102] = { bars = { bar1 = { [6] = Spell("Starfall") } } } },
    loadouts= { ["Raid ST"] = { bars = { … } } },
})

Layers merge baseline ← spec ← loadout, and bar pages merge by button index, so an override only states the buttons it changes. keys merges through the identical machinery, so a spec or loadout can override a single binding the same way it overrides a single button.

Three members are the same on every class and come from Layouts/Common.lua rather than being pasted into thirteen files:

B.StandardKeys() the keyboard — see Key roles
B.SkyridePage() bonus bar 5, which every class has and the game only populates once
B.AuditIgnore{…} your class's never-bind list, plus the clutter every character has

Each returns a fresh table per call, so a careless write into one class's keys can't rebind the other twelve.

Reporting

Unresolved slots are split two ways, because they mean different things:

  • broken — a misspelled spell name, a macro that doesn't exist. Your layout is wrong. Always reported, on every apply.
  • n/a — wrong spec, untaken talent. Normal for a layout covering four specs. Counted in the summary; listed only by /ic bars check.

The legacy config carried 'Astral Commmunion' (three m's) which silently did nothing. That class of bug is now loud.

Notes and limits

  • Bars are protected in combat. An apply triggered in combat is queued and runs when you leave combat.
  • Mounts, flyouts, pets and equipment sets can't be placed yet. /ic bars dump emits Skip() for them with a comment rather than dropping them silently.
  • dump can only see what's on the bar now, so it emits single spells. Turning Spell("Cenarion Ward") into Spell("Cenarion Ward", "Nourish") is the hand-editing pass — that intent isn't something the game knows.
  • strict (clear-unresolved) is on by default: a declared slot whose entry resolves to nothing is actively emptied, so after any talent, spec, or loadout change the bars are the layout — no stale buttons. Skip() slots and undeclared bars (6–8, page2, the skyriding tail) are still never touched; /ic bars strict toggles it back off.
  • "Empty" is the im_empty macro when that macro exists on the character: every path that empties a slot — an explicit Clear() or a strict sweep — places it instead, so intentionally-empty buttons stay visible. Without the macro it falls back to a true clear; create or delete im_empty and the next apply switches style everywhere.
  • Keybindings are account-wide or character-wide, never per-spec — the game has no per-spec bindings for /ic keys auto to hook into, it just rebinds. See Checking and fixing keys.
  • /sound sets master volume to 0 rather than toggling Sound_EnableAllSound, so there's one knob: any nonzero value brings sound straight back.
  • Merchant automation fires on MERCHANT_SHOW and is not shift-gated, unlike quest automation. Repairing and selling greys is reversible in the sense that matters: you were going to do both anyway.
  • Inventory counts for other characters are only ever as fresh as the last time you logged in to them. Nothing can read another character's bags remotely.
  • The HUD's coordinate segment is blank inside most instances. UnitPosition and C_Map.GetPlayerMapPosition both decline to answer there; that's the game, not the addon.
  • There's no player height available to addons. See There is no z.
  • A spell name that doesn't resolve isn't necessarily a typo. C_Spell looks names up against your current spellbook only, so a correctly spelled spell belonging to another spec is indistinguishable from a misspelling. The game offers no way to tell them apart. Name misses are therefore counted as state, not as layout bugs; only a bad numeric spell ID is definitely wrong.
  • Config errors are summarised on an automatic apply and listed in full only on /ic bars check or with /ic bars debug on. Auto-apply fires on every spec and talent change, and a layout covering builds you aren't in shouldn't shout on each one.
  • /ic hide reparents rather than hiding, and reparenting a protected frame is illegal in combat. Toggles made mid-fight apply when you leave it.
  • Tooltip prices hook TooltipDataProcessor.AddTooltipPostCall. Since 10.0 tooltips are built from data providers and the old GameTooltip:HookScript("OnTooltipSetItem") never fires — nearly every tutorial still online shows the dead one.
  • Price lines are suppressed on gear comparison tooltips (ShoppingTooltip*), which already stack two or three deep. The one under the cursor is the one you're asking about.

Why there is no mount panel

There used to be one: a grid of click-only buttons for mounts, toys and equippables you press for what they do rather than to travel. It was replaced by OPie rings, and the rings have now been replaced by key chords — Layouts/Chords.lua is the third and, being a Lua file in this repo rather than an export string in someone's WTF directory, the first one that survives a wipe without being pasted back in from a README.

The panel's real problem was never the layout. It was that a grid costs screen space when idle and a mouse trip to the corner when used, and that everything in it had to be kept in sync by hand. A chord costs nothing when idle, is keys rather than aim, and the parts that go stale — favourite mounts, professions — are generated rather than typed.

Two things went with the panel and have since come back:

  • A random favourite on one key. That is /ic mount, on F1.
  • /ic mounts scan, which dumped exact mount and toy names as paste-ready config. That is /ic chord find, which searches worn items, bags, the toy box and the mount journal and prints the ID next to every hit — and /ic chord check, which tells you when a name in the declaration has gone stale.

Two games, one addon

The WoW Forever beta — Battle.net flavor wow_classic_beta, build 1.60.1, internally "Camelot" — runs from the same symlink as retail. Not a branch, not a copy: icub3d.toc declares both interface numbers,

## Interface: 120100, 16001

and the modules that cannot work there gate themselves.

The number is worth writing down because it is not guessable and the client never displays it. It comes from _classic_beta_/WTF/Config.wtf:

SET engineSurveyPatch "16001"

which also happens to be 1.60.1 read as 1 · 60 · 01, the same way retail's 120100 is 12 · 01 · 00. install.sh reads that line rather than keeping a table of flavor names.

What this beta actually is

It is the modern client engine running 1.60-era content, and mistaking it for old Classic is the trap. The giveaway is its WTF tree, which has edit-mode-cache-account.txt, click-bindings-cache.txt and saved variables for Blizzard_DamageMeter and Blizzard_Professions — none of which the old Classic Era codebase has ever had.

So the usual assumption is wrong in both directions, and the first probe run proved it more thoroughly than expected. The beta has C_ClassTalents and C_Traits in full — config IDs, GenerateImportString, GetTreeHash — it has a spec (1484), it has the whole mount journal and toy box, C_Bank, C_EquipmentSet, C_SuperTrack, and all eight action bars. What it lacks is mostly the old API: GetSpellInfo, GetItemInfo, GetContainerItemInfo, UnitAura/UnitBuff/UnitDebuff, LoadAddOn and CombatLogGetCurrentEventInfo are all gone, with only the C_* spelling left. It is a more modern API surface than retail in places, not a less modern one.

Which is which is not answerable from outside the game, so the addon does not guess:

/ic probe

walks everything the addon reaches for, prints present/absent for each, and drops it in a copyable window. It never calls anything — on a beta a bad call is as likely to disconnect you as to return nil — and it is deliberately not gated on flavor, because running it on retail is what produces the baseline the beta report is read against. It also reports which of the eight action bars the client has.

The last section is the bonus-bar map, which is the one thing no API will enumerate — there is no call that says "cat form is page 1". So the probe accumulates it: each run records the form you are standing in against the offset it sees, into icub3dDB, and prints everything recorded so far. Shift form, re-run, and the table fills in. That is built this way because a druid learns its forms over dozens of levels, so the four readings are not available in one sitting even in principle — and a fresh beta druid below level 10 has no forms at all, which the report says in those words rather than leaving you staring at an offset of 0 that will not move.

The two kinds of gate

  • A call gates on the API being present, never on the flavor. Every shim in Core/Util.lua already does this, and it is the honest test: a beta can gain an API in any build, and a flavor check would then be a bug that silently keeps a working feature switched off.
  • A module early-returns when it cannot work, so it never registers its command and /ic help lists only what works. It gates on the same thing — a capability, not a flavor:
    • Core/Talents.lua — C_ClassTalents and C_Traits
    • Core/Spec.lua — GetNumSpecializations, in either spelling
    • Core/Sim.lua, Core/Gear.lua — retail, because simc has no 1.60 profile, which is a fact about a tool outside the game rather than about an API inside it
    • Core/Roster.lua — retail, by judgement: C_MountJournal is right there and this would run fine, it would just be measuring a one-character roster against a plan written for thirteen
    • Layouts/DruidTalents.lua — retail, because the import strings encode a hash of the 12.1 trees and cannot even be parsed elsewhere

The first two of those started out as ns.IsRetail() checks, written on the assumption that a 1.60 build means 1.60 systems. /ic probe showed the beta has the entire trait system and a spec, so both would have switched off modules that run there perfectly well. That is precisely the bug the API-not-flavor rule exists to prevent, and it took about ten minutes to commit it anyway — which is the argument for running the probe before writing the gate, not after.

Detection is the interface number, not WOW_PROJECT_ID. A new flavor ships a new WOW_PROJECT_* constant that nothing here can know in advance, and guessing it wrong makes every gate in the addon wrong at once. Anything below 100000 is a classic-lineage build.

Keys travel; bars do not

This is the important half, and it is why the port was worth doing.

A layout's keys is a keyboard — Q is the builder, Shift-E is the health potion, Ctrl-F sets focus. A keyboard is the same keyboard in any version of the game, so B.StandardKeys() carries over whole and /ic keys works on the beta exactly as it does on retail.

A layout's bars is a list of 12.1 spells, and on a 1.60 client almost none of them exist. So B.Register keeps only keys off retail and drops bars, specs and loadouts on the floor.

That is not tidiness, it is a safety interlock. Auto-apply is on by default and so is strict, which means a declared slot resolving to nothing gets cleared. A retail druid layout let loose on the beta would not degrade into a partial bar — it would methodically empty the character's action bars on login and report it as a success. ResolveLayout already returns "no bars defined" for a def without them, so dropping them turns that into one honest line.

A layout says which game it is for, and the default is retail:

B.Register("DRUID", { ... })                       -- retail
B.Register("DRUID", { flavor = "classic", ... })   -- 1.60

Last registration for a class wins, so Layouts/DruidClassic.lua is listed after Layouts/Druid.lua in the TOC and replaces it on the beta — and only there, since it early-returns on retail before it ever registers.

DruidClassic.lua is the 1.60 druid, written to the same key roles: Q is still the filler, R is still the nuke, Alt-1 through Alt-E are the forms. It is a reconstruction, not a reading — the vanilla druid kit, cross-checked against the spell IDs that came back from /ic bars dump on a live beta character. Roles that 1.60 has no answer for in caster form (interrupt, gap closer) are Skip() rather than filled with something that nearly fits. A wrong spell name is inert: it resolves to nothing and the slot is left alone. /ic bars audit lists every ability you have with no key, which is how to finish it.

Turn strict off before the first apply on a levelling character. strict empties any declared slot that resolves to nothing, so at level 8 — where most of the declared kit is years away — it will clear those slots along with whatever food or racial is in them today. That is correct behaviour on a finished character and wrong on this one. /ic bars strict toggles it; with it off, known spells are still placed and nothing is deleted.

The form pages are deliberately absent. A form page is a bonus bar offset the client swaps the main bar onto, and the cat=bonus1, bear=bonus3 table in Bars/Slots.lua is retail's numbering, unconfirmed here. Declaring a cat page against a guessed offset would write cat abilities onto whichever page that offset really is, over whatever was there. /ic probe builds the real map as you shift form; add the pages once it has the rows.

Bars 6–8

As it turns out the beta has all eight, so this is defensive rather than load-bearing today — but it is the kind of thing that is much cheaper to have been careful about than to debug later.

Bars/Slots.lua asks whether a bar exists before offering it. That is not the same question as whether the player is showing it — a bar nobody has enabled has no frames, which is what FALLBACK is for, and it is still real and still bindable. A bar the client does not have is real neither way.

The test is BINDING_NAME_<command>, which the client defines for every binding it knows and for no binding it doesn't; frames are a fallback for the unlikely client that ships one without the other. Between the two, being generous is the safer error: a bar wrongly kept behaves exactly as it does today, while a bar wrongly dropped silently deletes a page from every layout that mentions it. On retail all eight pass, and on the beta all eight pass too, so nothing changes anywhere yet.

Asking the client what exists

Everything above that guesses at 1.60's spell and item names has an authoritative answer sitting on disk. WoW ships its whole item and spell database in CASC archives as DB2 tables — that is where Wowhead gets it from — and tools/wowdata.py reads the local copy:

uv run tools/wowdata.py build              extract this flavor's tables
uv run tools/wowdata.py item Bandage
uv run tools/wowdata.py spell "Bear Form"
uv run tools/wowdata.py layout Layouts/DruidClassic.lua

Extraction shells out to db2tool (go install github.com/wowsims/mop/tools/db2tool@latest) for the same reason tools/simc.sh shells out to SimulationCraft: a CASC reader — .build.info, root, encoding, .idx, BLTE, TACT decryption, WoWDBDefs column definitions — is a real project and this is not it. It applies the client's own DBCache.bin hotfixes on top, and the result is SQLite in tools/.cache/.

layout is the reason it exists. It pulls every Spell() and Via() name out of a layout and checks each against the client:

DruidClassic.lua: 43/43 names exist in wow_classic_beta

A name that does not exist is the quietest possible bug — it resolves to nothing, so the key just never fills, indistinguishable from a spell you have not learned. /ic bars audit finds those in game but only for spells you already have; this finds them at level one, for the whole file. check.sh runs it when the data has been extracted and skips it otherwise.

Worth recording what the first run settled, because it was all guesswork before: every one of the 43 druid names was right, including the ones flagged as uncertain (Bash, Revive, Soothe Animal, Challenging Roar, Aquatic Form), and so were all ten bandage tiers and all six healing potions. Windstone (item 255663) is real.

Note that wago.tools, which hosts pre-extracted DB2s per build and would be a plain HTTP fetch, does not carry wow_classic_beta — its build list is wow and wowxptr only. For this flavor, local extraction is the only route.

Moving the keybindings across

bindings-cache.wtf is account-wide and flat, and a new flavor starts with an empty one — every key a Blizzard default. That is a file-copy problem rather than an addon problem, so it lives in tools/:

uv run tools/bindings.py flavors
uv run tools/bindings.py show _retail_
uv run tools/bindings.py copy _retail_ _classic_beta_ [--dry-run]

It backs up the destination first and refuses to overwrite a non-empty one without --force.

Commands are copied through rather than filtered. A command the client does not recognise is dropped silently the next time it saves, and the client is a better judge of which of retail's bindings exist in a 1.60 build than any list kept here. The one case worth rewriting is an addon's binding — MDTTOGGLE, say — which is not a client command at all: bound through, it would land on a flavor where that addon does not exist and the key would fall back to a game default, so it becomes NONE instead, preserving what the binding meant in practice.

The client must be closed when you run it. WoW rewrites the whole WTF tree on exit and will happily throw the copy away.

Afterwards /ic keys is the check that closes the loop: the copied bindings and B.StandardKeys() are two independent statements of the same keyboard, so if they agree, both are right.