0.7.0
What's new
WarbandMeDowns
0.7.0 (2026-08-24)
Full Changelog Previous Releases
- perf: stop settling the whole warband in the frame that opens the panel
Opening the settings page or running /wmd ranks went through
GetWarbandRanking -> GetProjectedItemLevelsForWarband -> SettleAllGroups, which
ran the entire warband assignment in one frame - the same global settlement the
tooltip does lazily, one slot group at a time. With Pawn installed and a scale
resolving for every character, that asks Pawn to evaluate every warbound item in
the warband, and Pawn's cache-miss path calls SetHyperlink on a hidden tooltip
and parses every line. Several hundred of those in one frame is a visible stall.
Only the Theoretical iLvl column ever needed it. The ordering, Level, iLvl and
Max iLvl already fall out of the scan Recompute performs anyway.
Settlement is now incremental: one group per timer tick, kicked off by the
existing debounced background recompute so it is usually finished before
anything asks. One group is the finest grain available - SettleGroup mutates
claims across the pool and has to complete atomically. A Recompute mid-run
cancels the outstanding timer and abandons the queue rather than continuing
against a pool that has been replaced.
Until it finishes, the theoretical number has no honest value, so it is reported
as pending rather than computed from half the claims: the panel shows an
ellipsis and /wmd ranks prints a note. No automatic reprint of the table - a
second unasked copy in chat is worse than one missing column.
Also memoizes item metadata per generation. Every fact the engine asks about an
item is a property of the link, which never changes, yet one refresh made ~8000
C_Item calls against ~1400 distinct links. Only resolved items are cached:
asking is what queues the client's async load, and "unknown" is a transient
state the engine depends on re-checking, so caching a nil would freeze an alt's
gear as permanently unreadable. Dropped per generation rather than per session
because heirloom item levels scale with the level of the character looking.
Measured in the harness at this account's real volumes (22 characters, 374
containers, 1126 items), for the frame that draws the screen: 8171 -> 2800
C_Item calls, and 476 -> 0 Pawn tooltip parses.
Note the contract change: theoreticalItemLevel is nil until the warband is
fully settled, so callers must check theoreticalPending.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01LMfxdSN9SBaVJKDJnrH3ea - fix(assignment): only claim candidates the character can actually wear
Two subclasses were unmapped and warned on load: Armor 5 is Cosmetic
(transmog-only gear) and Weapon 14 is Generic, the Miscellaneous auction
subcategory - mining picks, blacksmith hammers and other equippable oddities.
Both are genuine Armor/Weapon items with an equip location, so they reach the
eligibility check like anything else. They are now mapped explicitly to an
empty class list rather than left out of the tables: a missing subclass means
"not taught about this yet" and warns accordingly, which is a different
statement from "nobody wears this for stats".
Mapping them was not enough on its own. Data.SlotClassKey collapses all armor
into one key, so the candidate union for a plate chest also holds every cloth,
leather and cosmetic chest in the warband. SettleGroup vetted only the group's
representative subclass, and _ClaimBestCandidate never re-checked per
candidate - so a warrior sitting on a plate 620 upgrade and a cloth 700 had the
cloth claimed for them, left the plate unclaimed, and was told to vendor a real
upgrade. Cosmetics with a nominal item level did the same thing, which is how
these two subclasses turned a latent bug into a visible wrong answer.
Candidates and floor items are now narrowed to what each character can actually
equip before anything is scored or claimed, reusing Characters.CanCharacterEquipItem.
The entry tables are the same objects, so a claim still removes the entry from
every other group it could have satisfied.
The bug predates cosmetics - cloth versus plate triggers it with no cosmetic
involved - so this is a correctness fix, not just warning cleanup.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01Wj3G1QpAvXbSMybXRbb2f9 - fix(diagnostics): report the recompute once, and in packaged builds too
/wmd refresh printed two lines: the timing from Recompute and a bare
"recomputed." from the command handler. Dropping the redundant one would have
left packaged builds with no feedback at all, since the timing line sits in an
--@debug@ block that the packager strips from everything it builds - it is
visible only when running from a git checkout.
Recompute now takes a userRequested flag and decides in one line the packager
can rewrite:
local announce = userRequested
--@debug@
announce = true
--@end-debug@
so a recompute the player asked for always reports back, while the automatic
ones - which fire on every bag update and equipment change, and would be chat
spam - stay development-only.
The elapsed-time capture moves out of the debug block accordingly.
debugprofilestop is a normal retail API despite the name, present in every
build, so nothing there depends on a debug flag.
/wmd refresh calls Recompute(true) directly instead of MarkDirty +
EnsureFresh. That is how the flag gets through, and it is more honest about
what the command does: the old pairing set the dirty flag purely so EnsureFresh
would not no-op, and left a debounce timer to fire half a second later and find
nothing to do.
Verified by stripping --@debug@ blocks the way the packager does and running
both trees: /wmd refresh prints one line in each, and an automatic recompute
prints one line in the checkout and none in the packaged copy.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01Uz4YnTwQ4X4a8sXR7ZKZoB - refactor(tooltip): register cache-invalidation events with AceEvent
Tooltip.lua created its own CacheInvalidationFrame, wired an OnEvent script to
it, looped RegisterEvent over thirteen events, unregistered them by hand in
OnDisable, and guarded the frame's existence in two places. AceEvent does all
of that: self:RegisterEvent(name, "OnCacheInvalidated"), and AceAddon calls
AceEvent's OnEmbedDisable on disable, which unregisters everything.
The pcall around each registration stays. AceEvent routes to the same
frame:RegisterEvent underneath, so an event name a given client does not know
still raises, and one unsupported event must not take the rest of the list
with it.
Costs two embedded libraries rather than one: AceEvent-3.0 depends on
CallbackHandler-1.0. Both are copied from the same Ace3 distribution the
already-committed AceAddon-3.0 (13) and AceConsole-3.0 (7) came from, so
nothing is mixed. .pkgmeta already lists ace3, which covers them at package
time.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01H8H1EnZE9DKAWMWMHc31L7 - refactor(assignment): debounce recomputes with AceTimer instead of a token
MarkDirty kept an incrementing _debounceToken and TryBackgroundRecompute
compared against it, so that a C_Timer.After callback armed by an earlier
invalidation could recognise itself as superseded and bail. AceTimer expresses
the same thing directly: cancel the outstanding timer, schedule a new one.
AceTimer-3.0 has no dependencies of its own, and its OnEmbedDisable cancels
every timer when the addon is disabled, so Reset no longer has to think about
a callback still in flight.
The timer methods are called on the addon object rather than on Assignment,
which is a plain namespace table and not an AceAddon module - the one
non-obvious part, so it is commented at the call site.
Unchanged: the dirty flag is still set immediately on every invalidation, the
timer remains a warm-up only, EnsureFresh is still the guaranteed path, and a
background recompute is still skipped in combat.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01H8H1EnZE9DKAWMWMHc31L7 - refactor(diagnostics): parse /wmd arguments with AceConsole:GetArgs
HandleChatCommand split its input with a hand-rolled
^%s*(%S*)%s*(.-)%s*$, and ParseItemArgument trimmed what was left. That
survives a shift-clicked item link only because everything after the first word
is dumped into one string - a link is not one whitespace-delimited word, so any
command wanting a second argument after one would have broken.
AceConsole:GetArgs is already embedded and already mixed into the addon object.
It walks past |H...|h...|h hyperlinks and |T...|t textures explicitly, hands a
full coloured link back as a single argument, trims for us, and returns nil
rather than an empty string for an argument that is not there.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01H8H1EnZE9DKAWMWMHc31L7 - refactor: use Blizzard's wipe and tContains instead of WarbandMeDowns.Util
Util held exactly two functions, arrayContains and clearTable, which are
Blizzard's owntContainsandwipeglobals under different names. Both are
long-standing retail globals - 170 addons in a stock install call wipe
unguarded - so there is nothing to gain by carrying private copies.
tContains needs one bit of care. Blizzard's has historically answered 1/nil
rather than true/false. Two of the three call sites only branch on it, where
that is immaterial, but Data.CanItemBeSentToTwink returns the value and is
annotated as returning a boolean, so it coerces explicitly and is correct under
either contract rather than depending on which one the current client ships.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01H8H1EnZE9DKAWMWMHc31L7 - refactor: share one warband ranking between the engine, the panel and /wmd
Three places ranked the warband independently: Recompute sorted into
_sortedCharacters, and both Settings.RefreshTable and Diagnostics.PrintRanks
called GetSortedWarbandCharacters again for themselves. They agreed only
because the comparator is deterministic - nothing enforced it. The panel
re-sorted from live DataStore state, so a level-up between the recompute and
opening it would show an order the engine never settled under, and /wmd ranks,
whose whole job is explaining that order, was a third opinion. The two row
builders also disagreed about missing data: PrintRanks coerced an unknown item
level to 0, GetRowData rendered an em dash.
Assignment:GetRankedCharacters() is now the one order - the engine's own array,
after EnsureFresh. Characters.GetWarbandRanking() layers the numbers both
consumers display onto it, so the panel and the console format from one record.
GetSortedWarbandCharacters is left as the sort itself and is called only by
Recompute. /wmd ranks gains a theo column as a result, so both views show the
same four numbers for the same character in the same order.
GetDisplayName walked the whole warband on every call to look for a name
collision, which is a per-row cost in /wmd ranks and in ExplainItem's table -
O(n^2) for one printed table, and worse once GetWarbandCharacters started
sorting. The collision map is now built once, and the roster is memoized
alongside it, both dropped by Recompute/Reset with the other per-generation
memos.
Measured in the offline harness on a 22-character warband, one panel refresh
plus one /wmd ranks: warband sorts 3 -> 1, comparator calls 270 -> 90,
GetWarbandCharacters rebuilds 27 -> 5.
Also folds the settings table's four hand-synchronised column definitions (a
widths table, two creation chains, a styling list) into one ordered descriptor,
and extracts the collect-keys-and-sort idiom the three bucket walks in
Assignment each repeated into a SortedKeys helper - that idiom is what the
engine's determinism rests on and should exist once.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01FEeqXLQP4x1DcSbSPnBv8N - fix(itemlevel): stop counting the off hand as empty for two-handed wielders
Max iLvl read 316 on a character equipped at 295 whose real ceiling was ~298.
The off hand slot was scored as 0 because nothing is in it, so any weapon,
shield or holdable in the bags looked like it was filling a naked slot and was
awarded its full item level as gain - an entire extra item, worth about +19.
Blizzard does not score it that way: a two-hander counts twice, once for each
weapon slot. The stored snapshot forces that reading - an averageItemLvl of
295.1875 is an exact multiple of 1/16 and not of 1/15, so there really are
sixteen contributions, and if the off hand were one of them at zero then that
character's fifteen equipped items would have to average 315 while the
character sheet reads 295. 14 of the 22 characters in the account have a filled
main hand and an empty off hand, so this was most of the warband.
While a two-hander is equipped the off hand's baseline is now the main hand's
item level; a two-handed candidate is weighed against what both slots
contribute together and gains against both when it wins (a real two-hand
upgrade used to be under-counted by half); and nothing may be dropped into the
off hand on its own, since doing that really means swapping the main hand too,
which this model does not attempt - that errs toward under-counting, the safe
direction here.
Ranged locations are handled as two-handed as well. INVSLOT_RANGED is dead in
retail - a bow equips to the main hand, and slot 18 is empty on all 22
characters - but Data.EquipLocToSlotID still points INVTYPE_RANGED and friends
at it, so a bagged bow was invisible to the projection. Routed around locally
rather than changing a table the assignment engine also reads; the engine side
is noted in docs/DATA_SOURCES.md and left alone.
Adds /wmd ilvl [name], which prints the per-slot account behind the number
alongside Blizzard's overall item level (the second, previously unused return
of DataStore:GetAverageItemLevel) as a cross-check. The number was otherwise
unauditable, which is exactly how a phantom slot went unnoticed.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01UDdb9jSCzJ4iucdcstxSxs - feat(assignment): rank the warband by projected instead of equipped item level
CharacterPriorityComparator broke ties on DataStore:GetAverageItemLevel, which
under-rated exactly the character this addon exists to help: someone sitting on
a pile of upgrades they have not put on looks weak, ranks low, and is therefore
sent even more gear. The tiebreak moves to the Max iLvl projection - where they
would land having equipped everything usable already in their bags, bank and
mail.
Not the theoretical projection: that one is derived from the engine's claims,
which are decided by walking this very order, so it would be circular and
self-reinforcing - gear assigned to a character would raise their priority and
so assign them more gear.
Recompute now runs in two passes, because the order it needs is derived from
the scan. Pass 1 scans under a plain character-key order, which is safe because
ScanWarband's output does not depend on its walk order (_SortBuckets
re-normalizes every bucket and every entriesByLink list afterwards); pass 2
computes the real priority order, after the scan and before anything settles.
ItemLevel.GetMaxItemLevel reads the pool exactly as it stands and never calls
EnsureFresh or SettleAllGroups, so the comparator cannot recurse into
Recompute, and its per-generation cache is cleared alongside the other memos.
Two determinism fixes fall out of touching this: GetWarbandCharacters now
returns a key-sorted array rather thanpairsorder, and the comparator is a
total order down to the character key - table.sort is not stable, so without
both, characters equal on level and item level came out in an order the session
decided.
/wmd ranks prints max alongside ilvl, since ilvl is no longer the sort key.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session\_01M13qUXhBjhTdb3hgnFT7GX
This mod has no additional files

