- Lua 63.8%
- Python 23.9%
- Rust 10.8%
- Shell 1.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| Bars | ||
| Core | ||
| hooks | ||
| Layouts | ||
| tools | ||
| .gitignore | ||
| .luacheckrc | ||
| ALTS.md | ||
| check.sh | ||
| GEAR.md | ||
| icub3d.toc | ||
| install.sh | ||
| README.md | ||
| TALENTS.md | ||
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.luaandMeter,TextWindowand the wholeLayoutsfolder quietly aren't there.check.shparses every file with luajit, which shares Lua 5.1's grammar with the client — a 5.4luacacceptsgotoand bitwise operators that WoW would reject, so it is only the fallback. - A file missing from
icub3d.tocnever 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.lualoads 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 fromGetUnitSpeed— while gliding, the game moves you by a different mechanism andGetUnitSpeedkeeps 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 wheneverisGlidingis true. - Bags is counted on
BAG_UPDATE_DELAYEDand 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) fromC_Map.GetPlayerMapPosition./ic hud worldswitches toUnitPosition, 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 hidesays 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:
bar1isMainMenuBar, 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 —
- What's on the bars right now —
GetActionInfoover every mapped slot, with macros unwrapped viaGetMacroSpelland flyouts expanded. Ground truth for "has a key", including anything placed by hand. - The spellbook — every active (non-passive) ability you currently know.
- 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
rankseeds the list from the gear profile's own slots as well, and marks thoseequipped. - 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
--ilvlis 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 taggedlegacyand 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:
advancedCombatLoggingmust 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 freshWTF/, and/ic logsays 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.
wheredefaults 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_DAMAGEcarries a trailing tag thatSWING_DAMAGEdoes not, so counting backwards reads melee damage off by one and returns the attacker's level.clogcounts 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
clogscans/proc/*/fdand refuses, with or without--apply. - One written in the last five minutes, as a backstop in case
/proccannot 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" },
},
})
nilmeans leave this button alone, exactly like anilin 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-Mouse5are 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 onShift-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 —
bar1and 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 dumpemitsSkip()for them with a comment rather than dropping them silently. dumpcan only see what's on the bar now, so it emits single spells. TurningSpell("Cenarion Ward")intoSpell("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 stricttoggles it back off.- "Empty" is the
im_emptymacro when that macro exists on the character: every path that empties a slot — an explicitClear()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 deleteim_emptyand 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 autoto hook into, it just rebinds. See Checking and fixing keys. /soundsets master volume to0rather than togglingSound_EnableAllSound, so there's one knob: any nonzero value brings sound straight back.- Merchant automation fires on
MERCHANT_SHOWand 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.
UnitPositionandC_Map.GetPlayerMapPositionboth 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_Spelllooks 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 checkor with/ic bars debugon. 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 hidereparents 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 oldGameTooltip: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, onF1. /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.luaalready 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 helplists only what works. It gates on the same thing — a capability, not a flavor:Core/Talents.lua—C_ClassTalents and C_TraitsCore/Spec.lua—GetNumSpecializations, in either spellingCore/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 itCore/Roster.lua— retail, by judgement:C_MountJournalis right there and this would run fine, it would just be measuring a one-character roster against a plan written for thirteenLayouts/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.