FastGuildInvite-v2.12.2
What's new
<FGI> FastGuildInvite
[v2.12.2] (2026-08-26) — A quiet-zone list that stopped at vanilla; a race/class table we had been maintaining by hand and getting wrong; an announce button that posted without ever starting a timer; a graph library that stopped being ours to embed; and duty you can finally see
Fixed — the scan silenced 19 hand-typed zones and called that "instances"
Field report, with screenshots, from a TBC user: recruits were being scanned inside The Underbog, The Steamvault, Serpentshrine Cavern, The Shattered Halls, The Arcatraz, The Botanica and The Slave Pens — seven misses visible on a single screen.
Their guess was that vanilla dungeons had been listed and TBC ones forgotten. It was worse than
that. FGI_CONST.dungeonAreas held 12 area ids and FGI_CONST.raidAreas held 7. Vanilla
alone has more instances than 19, so the list was never complete for any flavour — it was written
once, never extended, and every instance added to the game afterwards has been scannable ever since.
The counter added last release made the rot measurable and then did nothing about it.
fn.getStaticAreaCount exists precisely so "is the instance list complete?" stops being a shrug,
and its own comment says it is "the guard that has to exist BEFORE enumerating the list is worth
anybody's time". The enumeration never happened. That is the actual failure here — the instrument
was built and the measurement was skipped.
Extending the ids by hand would have fixed TBC and left Wrath, Cata, MoP and retail exactly as
broken, until the next expansion broke it again. So the ids stop being the source of truth. New
fn.getInstanceMaps climbs to the top of the client's own map tree and takes every
Enum.UIMapType.Dungeon descendant, using the names the client itself reports — correct in every
flavour and every locale by construction, with no list to maintain. C_Map.GetMapChildrenInfo and
the enum are declared in all four flavour trees (verified in
Blizzard_APIDocumentationGenerated/, not assumed), so no version guard is needed; both are still
feature-tested so an absent API degrades to the id list rather than erroring.
The id list is kept and unioned, not replaced: it still carries the battlegrounds and arenas,
which are not Dungeon-typed maps. The user's own custom quiet list is unioned too, and has a spec
saying so — it is the escape hatch for anything the walk misses, and losing it in the same change
would have taken away the workaround at the moment it was most needed.
Dungeon only, deliberately — Micro is inn and building interiors and Orphan is unparented
art, both places a recruit legitimately stands. A missed instance leaves us where we already were; a
wrongly-silenced zone loses a real candidate for ever. The asymmetry decides it, and this fails
toward today's behaviour rather than past it.
And Dungeon alone turned out not to be enough — the first version of this change was wrong, and
the UiMap DB2 on wago.tools is what caught it. Enum.UIMapType.Dungeon means "drawn with
dungeon-style floor art", not "is an instance". Dalaran carries it (three separate copies: Wrath
501/502, Legion 626/628/629, later retail 2305-2307), and so do Oribos, the Deeprun Tram and
one copy of the Vale of Eternal Blossoms. UiMapDetails exposes only mapID, name, mapType
and parentMapID, so at runtime Dalaran and The Steamvault are genuinely indistinguishable — the
Flags column that separates them in the DB2 is 0 for both and is not surfaced by the API anyway.
Shipping the bare walk would have silenced the scan inside a capital city and a prime recruiting
hub, which by this feature's own asymmetry is a considerably worse bug than the one being fixed. New
FGI_CONST.nonInstanceMaps subtracts them by map id, not by name: UiMap ids are stable across
builds and identical in every locale, and FGI ships in about thirty of them, so a name list would
have protected Dalaran on English clients only. Instanced content that merely looks like an
exception — Proving Grounds, Brawl'gar Arena, The Secrets of Ragefire, Silvershard Mines — is
deliberately left in, because silencing the scan there is correct.
This list is now the part that must not fall behind, and that is a much better trade than the one it replaces: it is a handful of exceptions rather than a complete enumeration of every instance in the game.
A bug the new spec caught in its own guard. The API-absent branch first read
instanceMaps = {}; return instanceMaps — caching the empty answer, so a truthy empty table
short-circuited every later call and one early call before the map API was reachable would have
silently restored the old behaviour until /reload. That is the exact failure the empty-walk retry
guard was written to prevent, left open one branch above it. Both paths now return a bare table and
cache nothing.
The Dump window's Areas button now prints ids: N of M resolved | map walk: K instances above
the names, because the names alone cannot say which source produced them — and a map-walk count of
0 beside a healthy id count is the signature of the walk having failed on that client.
Tests/zz_instance_maps_spec.lua, 13 examples. Suite 1855 -> 1868 passed, 0 failed, 2 pending.
The walk's premise is verified against the game's own data, not assumed. Every instance in the
field report is Type = 4 in UiMap — The Underbog 262, The Steamvault 263/264, The Slave Pens
265, The Botanica 266, The Arcatraz 269/270/271, The Shattered Halls 246, Serpentshrine Cavern 332.
Each hangs off a Zone-type map (Zangarmarsh 102, Hellfire Peninsula 100, Netherstorm 109), which
in turn hangs off Outland 1467 -> Cosmic 946, whose ParentUiMapID is 0. So the climb terminates
where the code expects, and allDescendants is load-bearing rather than decorative: these maps are
three levels below the root and a direct-children-only walk would find none of them.
Verified in a TBC client, 2026-08-26. The Areas readout reported
ids: 26 of 45 resolved | map walk: 42 instances, and all seven instances from the field report
are in the resolved set — The Underbog, The Steamvault, Serpentshrine Cavern, The Shattered Halls,
The Arcatraz, The Botanica, The Slave Pens. The walk also covered the vanilla instances on that same
client (Deadmines, Gnomeregan, Stratholme, Molten Core, Blackwing Lair, Naxxramas, Ahn'Qiraj,
Zul'Gurub and the rest), so it is not a TBC-shaped patch over a vanilla list — it is the whole set.
Two things that readout settles beyond the headline. The 42 contain no false positives: no city, no tram, no outdoor zone, nothing a recruit stands in during ordinary play — which is the exclusion list doing its job on the client rather than only in a spec. And 19 of the 45 hand-maintained ids did not resolve, which is the expected and previously invisible half of the old design: those are ids for content this client does not have. Under the old code that silence was indistinguishable from a complete list.
The harness still models none of C_Map.GetMapChildrenInfo, GetMapInfo, GetBestMapForUnit or
Enum.UIMapType, so the specs drive a staged stand-in and the contract in
Tests/HARNESS_CONTRACT.md stands.
Fixed — Classic Era was scanning seven vanilla dungeons, including every low-level one
Measured, on the two clients, and the pair is what found this. The Areas readout reported
ids: 26 of 45 resolved | map walk: 42 instances on TBC and
ids: 22 of 45 resolved | map walk: 0 instances on Classic Era.
Classic Era files its dungeons as ordinary Zone maps and has no dungeon-typed map entries at all, so the map walk above finds nothing there and that flavour falls back entirely to the hand-maintained ids. Diffing the two lists showed what the ids had never covered: The Deadmines, Wailing Caverns, Gnomeregan, Blackfathom Deeps, The Stockade, Ragefire Chasm and Blackrock Spire — silenced on TBC by the walk, scannable on Classic Era since the addon was written.
That is the worst possible set to have missed. They are the low-level dungeons. A levelling player in Deadmines or Wailing Caverns is exactly the recruit this addon exists to find, so the scan was interrupting precisely the people it most wanted to talk to — and Classic Era is the flavour FastGuildInvite is developed on.
Their AreaTable ids are now in FGI_CONST.dungeonAreas, read from the Classic Era DB2 rather than
recalled. Confirmed as the right table by the same query returning 2557 Dire Maul, 2017 Stratholme,
2057 Scholomance and 1584 Blackrock Depths — four ids already in the list, matching exactly.
The seasonal instances on that client are NOT a gap, and this is worth writing down so nobody
"fixes" it later. Classic Era's Map table also carries a set of seasonal instances (The Searing
Basin, Shadow Hold, Nightmare Grove, Karazhan Crypts, The Scarab Dais and others, map ids 2720+).
None of them has an AreaTable row — that table's map ids stop around 451 — so C_Map.GetAreaInfo
cannot name them and no id could ever be added for them.
That looked like a hole until it was measured in the client rather than argued about:
GetRealZoneText() inside that content answers the parent outdoor zone ("Durotar"), which is the
string /who reports. So a player inside is indistinguishable from a player standing outdoors, in
the only data a scan ever sees. There is nothing to silence and nothing that could be silenced —
and a name list built from the Map table would have been actively wrong, silencing open-world
ground for everyone. Two of the candidates make that concrete: "Deadwind Pass" is a real outdoor
zone and "The Tainted Scar" a real outdoor sub-area. Adding those would have been the Dalaran
mistake in a second costume, and would additionally have been the only locale-fragile thing in an
otherwise locale-correct design.
Fixed — the Global Ignore List import kept the tag and threw away the reason
Field report with a screenshot (Vishiswaz): nineteen imported rows all reading a bare <GIL>, where
they used to read <GIL> bigot, kicked from guild.
The note was never failing to be read. It was read, written, and then overwritten one loop
later. The importer has two sources — the GlobalIgnoreList addon, which carries a free-text note per
entry, and WoW's own /ignore list, which has no note field. GIL mirrors /ignore, so nearly
every name is in both. Source 2 wrote <GIL> <note>; Source 1 then reached the same name with a
bare <GIL>; and tryAdd refreshes whenever the existing reason is importer-created and
different, without ever asking whether the replacement carried less. "<GIL> bigot..." begins with
"<GIL> ", so it qualified as refreshable and the note was destroyed on every import.
The function's own comment claimed the ordering handled this — "Source 2 runs before Source 1 so each player's GIL note (if any) wins over the bare /ignore mirror". Ordering cannot win when the later write clobbers, so that was stated intent that was never implemented.
Source 1 now skips any name Source 2 already spoke for in the same run.
Fixed at the call site rather than in tryAdd, and the rejected fix is worth recording. The
first idea was "never replace a longer reason with a shorter one". It is wrong: when a user
deletes a note in GIL, Source 2 legitimately writes a bare <GIL> and that update has to land.
The real rule is per-run and per-name — whichever source spoke first this run owns the row — which
is not something tryAdd can see. There is an example pinning exactly that case, and it fails under
the rejected fix.
Tests/zz_gil_import_spec.lua, 8 examples. Confirmed to catch the bug rather than assumed:
with the guard disabled, three go red and the headline one reports
expected "<GIL> bigot, kicked from guild", actual "<GIL>" — the reported symptom exactly.
Suite 1877 -> 1885 passed, 0 failed, 2 pending.
A harness gap surfaced on the way and is raised in Tests/HARNESS_CONTRACT.md: the env models the
Who/Social half of C_FriendList but not GetNumIgnores / GetIgnoreName, and models neither
message() nor UIErrorsFrame — so API.ShowMessage raises at the very END of the importer, after
all its real work, and an otherwise-correct spec fails with a message about nothing under test.
New — right-click the Wingman button to tick its steps on and off
Requested by Vishiswaz on Discord: "Right click wingman button to enable/disable via checkboxes Scan, Invite, Announce". A right-click on the Wingman toggle opens a small menu with those three as tick boxes.
They are the same three settings as the Wingman page, not a second copy. The user's instruction
was explicit — "this should do the same thing as the 3 checkboxes in wingman settings, and it
should check/uncheck them. these need to be tied together" — so both surfaces read and write
DB.global.wingman.doScan / .doInvite / .doAnnounce, and there is no second state to keep in
step. What the feature actually needed was the repaint: an open Settings panel does not re-read
the database on its own, so a menu change now calls AceConfigRegistry:NotifyChange, and the toggle
tooltips repaint through UpdateToggles. A menu carrying its own state would have passed a shallow
"three checkboxes exist" test and been exactly the wrong build.
Wired on both Wingman buttons — the main window's and the compact tray's. The tray is the
surface a Wingman user actually keeps on screen, so putting the gesture only on the main window
would have put it where they are not. The menu itself lives on the module (Wingman:ShowStepMenu)
so both call one implementation.
Two details worth recording. The tick boxes stay open between clicks (keepShownOnClick), because
turning two steps off in one gesture shouldn't need the menu reopened. And the item labels are keyed
by the DB field name (doScan / doInvite / doAnnounce) rather than by an English phrase, so the
menu iterates the same one table the drain reads — an example asserts the shipped locale carries a
real label for each, since without one the menu would render the raw field name.
Both toggle tooltips gained a line naming the gesture; it is invisible otherwise.
It closes when you click away, and that took a deliberate fix. The menu is a UIDropDownMenu —
a raw Blizzard frame, like every other picker and context menu in FGI, and the widget class
LibLocaleOverride's AttachDropDownFont exists to font. The cost of that choice is exactly this: an
AceGUI window closes itself when you click off it and the legacy dropdown does not, because nothing
in that system watches for an outside click. Rather than inventing a click-catcher frame, this does
what Blizzard's own menu manager does — listens for GLOBAL_MOUSE_DOWN and closes if the cursor is
not over the list, the rule copied from MenuManagerMixin:HandleGlobalMouseEvent
(Blizzard_Menu/Menu.lua:1905-1917 in the Classic Era tree, where the event was confirmed to
exist). Mouse down is what makes it safe to share: CloseDropDownMenus() closes whichever
dropdown is open rather than only ours, but another addon's menu opens on mouse up, after we have
closed and unregistered — there is no window in which we can shut somebody else's. Clicking a tick
still keeps the menu open, since the cursor is over the list.
Tests/zz_wingman_step_menu_spec.lua, 7 examples. The property under test is deliberately not
"the menu has three items" — it is that a change through either surface is visible through the
other and agrees with what the drain will do (DrivesScan / DrivesInvite / DrivesAnnounce),
tested in both directions. It also pins the asymmetric defaults: scan and invite are on unless
switched off, announce is opt-in, and a menu that assumed all three behaved alike would have quietly
switched announcing on for everybody. Suite 1921 -> 1928 passed, 0 failed, 2 pending.
Changed — the announce countdown reads 4M51S instead of in 291s
The horn tooltip and the compact tray printed a raw second count. The user's words: "just having
it in S makes it a huge unintelligible number" — and the Announce tab was already rendering the
identical value as 9M29S two panels away.
No new formatter. formatRemaining already existed as a file-local in the Announce tab and is
promoted to Announce:FormatRemaining, the single renderer for all three surfaces. The tab's local
is now a one-line delegate, so a third copy of those eight lines never happens.
Not the same as the tab's formatDuration, and the difference is deliberate. That one pads
every unit (0D0H5M0S) because it renders a configured cooldown, where the padding shows the
field's full grammar and matches what you type into it. A countdown gets the short form — dropping
leading zero units — because 0D0H4M51S is the unreadable number in a different costume.
The locale line changed shape with it: L["in %s"] takes the formatted string, where the old
L["in %ds"] took a number. It is a new key rather than a reworded one because ~30 locale files
carry %d in the old spelling, and string.format raises on %d given a string — that would
be a broken tooltip, not merely an odd-reading one. Locales without the new key fall back to enUS.
6 examples, including the zero, negative, nil and non-numeric cases — the tooltip repaints every frame, so a raise there is not a one-off. Suite 1915 -> 1921 passed, 0 failed, 2 pending.
Fixed — the announce button posted without starting a timer, then started all of them at once
Reported by Vishiswaz: three announcements Ready, click one posted announcement one, and every click after appeared to do nothing. With Wingman off.
The mechanism. A press posts one announcement. Until now nothing was stamped as it went out — each posted state was collected, and the whole set was stamped together when the last one in the round was sent. So with three ready: click one posted and started no timer, click two posted and started no timer, click three posted and started all three.
This was my rule, not a request, and it was wrong. The intent written into the code was "the refresh timer starts after you send the last one", so a set of announcements would come back Ready together instead of drifting apart. The user ended it with one question — what if you wait 20 minutes between announce pushes? Then announcement one's cooldown starts twenty minutes after it was actually sent. That is a false timestamp, and no amount of keeping a set in step justifies writing one. Each announcement's cooldown now starts on the press that sent it.
The second half of the report follows from the same defect. Between the presses those states were unstamped, so anything that re-gathered the eligible set saw messages it had already sent as still eligible — and Wingman drains through the very same function. Vishiswaz switched to Wingman while the timers were unstarted, which is where the rest of the odd behaviour came from.
Rotations move with it: a rotation's interval and its cursor now advance on the press that sent that
member's message, rather than at the end of the round. unconfirmed posts are still not stamped
(we posted on a permission we guessed, so starting a cooldown would suppress the retry too) — this
changed when a stamp happens, never whether.
And moving the rotation half introduced a defect that the user caught by asking whether rotations
had been touched at all — recorded because it is the more useful half of this entry. Rotations
were the part that was working in the field report, and nothing in the suite would have said
otherwise. A rotation targeting N channels gathers N queue items, every one carrying the same
rotation id and the same member: one message going to several places, not several turns. The
deferred version collected them into a map keyed by rotation id, so N channels collapsed to one
stamp for free. Stamping per item does not, and the first cut of this fix broadcast the rotation's
cursor to the guild once per channel. The cursor value was identical each time, so it was never
turn-skipping — it was redundant guild traffic that scales with the channel count, which is the kind
of thing nobody notices until a large guild is carrying it. The stamp is now guarded to fire once
per rotation per round, on the first channel, which is also the right timestamp: the turn's cooldown
starts when the member's message first went out. An example pins it, and going back to the
unguarded version turns it red at expected: 1, actual: 2.
Tests/announce_guild_ready_spec.lua grows by 6 examples. Confirmed to catch the bug rather than
assumed: with the deferred stamp restored, three go red, including the decisive one — clicks
20 minutes apart, asserting each announcement carries its own press's timestamp. That example is the
only one that separates the two schemes; the rest pass under either, because stamping everything
with the last press's clock looks identical until the clock moves between presses.
Suite 1909 -> 1915 passed, 0 failed, 2 pending.
Not yet explained, and not fixed here: Vishiswaz's screenshot shows only one of the three on a countdown, with the other two still reading Ready. Under the old round-end stamp all three should have stamped together, so on that client only one profile ever actually posted. The public-channel designated-announcer gate is the candidate — two of the three target public channels — but that is untraced, and if the horn's Ready display ignores that gate it is a separate bug.
New — an opt-in chat line for every contact, so the queue stops moving faster than you can read it
Requested by rocky on Discord, quoted because the reasoning is the design: "would it be possible to
get an option that makes a chat log <FGI> Invited Bobdole Level 69 Hunter - Stormwind City when
wingman invites someone? i had another person in instance (they didn't even complain but i happened
to /who them and saw they were in an instance, but also it's nice to know where the addon is at in
it's search) … even when i have the fgi mini window open, it often moves too fast for me to really
see what's going on".
Settings > Wingman > Log each contact to chat, off by default — their call too: "i don't think it woudl end up being a very popular option for people so obviously disabled by default".
It fires for manual invites as well as Wingman's, deliberately. The setting sits on the Wingman page because that is where it was asked for, but the stated reason — knowing where the scan has got to — is not Wingman-specific, and gating it would mean the same click logs or does not log depending on which surface drove it. That is worse than either behaviour on its own.
One call site, after the four invite-mode branches converge, not one print per branch. Those
four blocks are near-identical and a fifth copy of a print is exactly the thing a later edit forgets.
The testing-mode path is excluded because it returns earlier and prints its own [TEST] line;
without that, a tester would get two lines describing one non-event.
Mode 3 says Whispered, not Invited. Message Only sends a whisper and no invite at all, so
the requested wording would be a false statement in the one output whose entire purpose is reporting
what the addon did.
fn.chatLoc() and a new addon.ClassNameClient, never L or addon.ClassDisplay. Both of the
latter resolve through FGI's UI-language override, and this goes to Blizzard's shared chat frame,
which we cannot re-font — override-locale text lands there as boxes. ClassNameClient exists purely
to be the client-locale half of ClassName, which consults the override first; the comment on it
says so, because it otherwise reads as a pointless duplicate.
Off by default in the strict sense, and that is not incidental. Every other wingman sub-key in
FGI is read as ~= false (on by default). This one is a plain truthy test, and a copied-in
~= false would switch a per-invite chat line on for every recruiter in the fleet. Two examples pin
it: an absent wingman table, and a table that exists with the key missing.
The zone is a snapshot from the /who that found them, so it is left off the line entirely when the
game reported none — Blizzard omits it for a player on a non-zoned map slice mid-phase-change, and
rows queued before v2.12.0 carry no zone field at all. An example asserts the line never ends in a
dangling separator.
Tests/zz_contact_log_spec.lua, 16 examples, including the Settings toggle driven through the real
registered options table. Suite 1893 -> 1909 passed, 0 failed, 2 pending.
New — run either blacklist import on the spot, from the Settings page that schedules it
Settings > Advanced > Sync gains Import GRM blacklist now and Import GIL list now, each directly beneath the auto-sync toggle that runs the same import at login.
Before this, the only way to run one by hand was the Import button on the Blacklist tab. Someone who has just ticked Auto-sync GIL list on login is standing in Settings, and the toggle they just flipped does nothing until their next login — so the one place the control was missing was the place the user was.
Order values are half-steps (14.5, 15.5) so each button stays paired with its own toggle. AceConfig
sorts on order alone and falls back to table iteration when two controls share one, which is not
stable between sessions — a button that merely exists can otherwise render under the wrong toggle
and read as belonging to it. There is an example asserting each button's position relative to both
toggles, and another asserting all four orders are distinct.
A related defect found while wiring it up: an import never told an open Blacklist tab that the
rows had changed. FGI has a live list-refresh bus — data-change sites fire a topic and the tab
redraws at once if it is the one on screen — and both importers were writing DB.realm.blackList
without firing it. It was invisible because the Blacklist tab's own Import button called its private
refresh straight afterwards, which covered the gap for exactly as long as that button was the only
caller. It was not: the login auto-sync has been a silent second caller the whole time, and would
leave an open tab showing stale reasons. Both importers now fire fn.refreshList('blackList')
unconditionally at the end, which is free when the tab is not visible.
Tests/zz_gil_import_spec.lua grows to 16 examples — three on the refresh bus (visible, hidden, and
an import that changed nothing), five on the buttons, driven through the real registered options
table so they invoke the same func AceConfigDialog will. Suite 1885 -> 1893 passed, 0 failed,
2 pending.
What this does not do, stated because it was the reason the buttons were asked for. Re-importing
cannot recover notes that a previous import already overwrote. The note text lives only in
GlobalIgnoreList's own notes array; once an import has replaced <GIL> <note> with a bare <GIL>,
the only surviving copy is GIL's, and if GIL's entry has no note there is nothing to restore. On the
reporting account, GlobalIgnoreDB.notes is {"", "", "", ""} — every entry blank. The fix above
stops the destruction happening again; it is not a recovery path, and there is no recovery path.
Fixed — two settings on the General page were unreadable, clipped by a neighbour sharing their row
Field report with a screenshot, from a client running no UI-scaling addon: "Disable compact UI tooltips" rendered as "Disable compact UI to..." and "Use Legacy UI ([-] opens classic single-page window)" as "Use Legacy UI ([-] ope...". Two settings you could see but not read.
AceConfig gives a toggle half width by default, so two sit side by side and a long label is
clipped at the halfway mark — with no ellipsis from the layout itself and no warning. Three toggles
on that page already carried width = "full" and the rest did not, which is exactly why it bit some
rows and not others and why it had never been noticed.
Every toggle on the General page is now full width, one per row. The panel scrolls; a clipped label does not, and it cannot be recovered by resizing the window, because the half is a fraction of the panel rather than a pixel count. The comment on the first one says so, because "tidying" these back into pairs to save vertical space is the obvious future change and it would bring the bug straight back.
The Tabs page keeps its two-per-row grid deliberately: those labels are single tab names.
Fixed — impossible race/class pairs when class was ordered before race, and a race/class table that is now generated rather than believed
Two defects, one reported and one found while confirming the first.
The report (Spacedoc, Discord): "if you place class before race, that is happening that you get
all race-class combination even which are not atm possible, like druid-human". Exactly right, and it
was the whole value of the reordering feature leaking away — every impossible pair is a /who that
can only ever return nothing.
RACEsplit skipped impossible races behind if forcedClass then, and forcedClass is the class
narrowed by the user's filters. A class that arrives because class was split first lives in
qp.class, and that path skipped the check entirely. The variable that holds either one,
keptClass, already existed one line above and was already being used to build the query — only the
validity test read the wrong one. CLASSsplit had always done the mirror image correctly for
race-then-class, so two halves of one idea disagreed and the axis order decided which you got. Fixed
in the queue and in the progress denominator, which had the identical gate and would otherwise
have kept pricing queries the queue no longer issues.
A race with no table row is now UNKNOWN, not IMPOSSIBLE, and that guard is what makes the above
safe. L.race and RaceClassCombo are maintained separately and did disagree: Dracthyr was in the
locale table with no combo row at all. Treating a missing row as "cannot be this class" would have
stopped querying that race entirely — every Dracthyr recruit lost, silently. An absent row now falls
open, which also repairs a pre-existing silent skip on the filter path. A spurious query costs one
/who; a skipped race costs all of them.
Changed — the race/class table is generated from the game's own data
Prompted by the above, and it found more than it was looking for. fn.raceClassCombo was hand-typed
per flavour; the Mists block's own header admitted it was "written from knowledge of MoP's character
creation, NOT read from a file". Checked against CharBaseInfo.db2 — the character-creation
validity table — joined to ChrRaces and ChrClasses, per product branch:
| Flavour | Verdict |
|---|---|
| Classic Era | Exactly right. All 8 races, every combination. Unchanged. |
| TBC | Exactly right. All 10 races. Unchanged. |
| Mists | Dwarf missing Warlock, Undead missing Hunter (both Cataclysm additions) |
| Retail | Missing three entire races plus six combinations |
Retail was missing Earthen and Harronir from every table, and Dracthyr from the combo
table — so no /who was ever issued for them and no recruit of those races could appear. On top of
that: Night Elf Warlock, Draenei Rogue and Warlock, and Tauren Mage, Rogue and
Warlock. Earthen and Harronir are added to Locale/summary.lua as neutral races, since both
factions can play them.
Every error of this kind is silent. A missing row is a query that is never issued, so the
recruits simply never appear and nothing logs anything — which is precisely what
GUI/Tabs/RaceClassMatrix.lua warns about, and why that tab must stay display-only.
There is no in-game API for this, which is why a baked table is unavoidable rather than lazy:
character-creation validity is glue-screen only. Verified in the Classic Era documentation tree —
the only hits are a CharSectionCondition enum and a purchase-result code, neither of which answers
the question.
Two deliberate departures from the raw data, both recorded in the code so nobody "restores" them:
Adventurer is dropped (a real class id in the table, but it is Plunderstorm, and it would give
every retail race a phantom class no /who can match), and Scourge is renamed Undead to
match the key the locale table uses.
A method note, because it nearly shipped a bug. Reading the source table through a summarising web fetch reported "39 data rows" while silently omitting Dwarf-Rogue, then "41" when asked again; the file has 40. It was only caught because Dwarf Rogue is obviously valid. The tables are generated from the files on disk. A summariser cannot answer a completeness question, and completeness is the entire point of this table.
Tests/zz_who_subdivision_spec.lua +2 examples. Suite 1875 -> 1877 passed, 0 failed, 2 pending.
Fixed — the Dump window's General button raised on every click, and always had
Reported from the client:
dump.lua:101: bad argument #3 to 'format' (string expected, got nil)
addon.gversion is read from the TOC field X-Interface (init.lua:63) and no TOC has ever
declared one, so it has always been nil — and format("%s", nil) raises rather than printing
"nil". It now prints gv.versionString (FGI_Compatibility.lua:111), which is what that line was
reaching for and is built unconditionally at load.
A diagnostic that only breaks when it is needed. Nobody opens the Dump window until something else is already wrong, so a dead button there is invisible in ordinary use and then fails at the exact moment a player is being asked to paste its output.
Two more things came out of writing the spec for it, both real:
- The module's
DBupvalue is gone. It was assigned in dump.lua's ownPLAYER_LOGINhandler, so every button in the file raised on a nilDBif the window was opened before that fired — again, in the one window reached when something is already wrong. Each site readsaddon.DBnow. - AceGUI swallows callback errors (
AceGUI-3.0.lua:66), so a completely broken button is indistinguishable from a working one unless you are running BugSack. The new spec therefore calls the registeredevents.OnClickdirectly instead of going through:Fire, which is the same wiring without the error handler hiding the result.
Tests/zzzz_dump_buttons_spec.lua, 7 examples, one per button plus the two regressions. zzzz_
because an earlier draft fired PLAYER_LOGIN to satisfy the old upvalue and broke five examples in
two unrelated spec files; the fix removed the need for the event entirely, and the file records why
it must not come back.
Fixed — 22x "attempt to perform boolean test on a secret boolean value"
Reported from the chat frame, 22 occurrences, with the stack landing in our own hook:
FGI_Core.lua:1282: attempt to perform boolean test on a secret boolean value
(execution tainted by 'FastGuildInvite')
[C]: in function 'Hide'
[Blizzard_ChatFrameBase/Shared/ChatFrameEditBox.lua]:537: in function 'ClearChat'
The v2.2.4 stale-chat-focus defence hooks OnHide on ChatFrame1EditBox … ChatFrame10EditBox and
clears a keyboard-focus claim the edit box leaves behind (without it, F5/F6 silently stop dispatching
to our secure buttons after you type in chat). It read:
if self.HasFocus and self:HasFocus() and self.ClearFocus then
Once our execution is tainted, HasFocus() hands back a secret boolean, and and-ing a secret
boolean is the error. The locals in the report show it directly: (temporary)=<secret boolean>.
The predicate was never worth anything — ClearFocus() on an unfocused edit box is a no-op — so the
test is gone and the call is unconditional. HasFocus had exactly one call site in the addon.
The general shape, recorded because the next one will look different: never boolean-test the return of a frame query from tainted addon code. Prefer calling an idempotent action unconditionally over gating it on a queried predicate.
Fixed — the scan progress bar sized itself from another window's status bar
_setProgressBar took the bar's width from statusbg:GetWidth(). That number is not the bar's width.
AceGUI's status background is a Button sized entirely by two anchors (BOTTOMLEFT +15 /
BOTTOMRIGHT -272 on our window), and LibLocaleOverride's button auto-fit writes an explicit
SetWidth onto every button a font walk reaches — including that one, which has no label to fit.
Modules/FGI_Dialog.lua:168 walks a whole dialog frame, AceGUI pools the Frame, and the main
window then acquires a status background carrying a width measured for whatever dialog last held it.
GetWidth reports that number at every size the window is ever dragged to.
New MainWindow.PanelSpan measures the bar from its resolved edges (GetRight - GetLeft), with
GetWidth kept only as the fallback for an unplaced frame. Edges cannot be frozen by a stamp; and if
the client honours the stamp instead of the anchors, the edges equal it — so the measurement is right
either way.
This is the same stale-__lloFitFloor mechanism Modules/FGI_Dialog.lua:209-237 already documents
at length for dialog buttons; the status background is the same class of victim and was missed there.
The library defect itself is raised in the new docs/LIBRARY_CONTRACTS.md as LLO-001 — it is not
FGI's to fix, and any consumer that walks a pooled AceGUI Frame has it.
Found by the suite, not by a player. Tests/zz_progressbar_resize_spec.lua failed only in a
full-suite run — alone, no dialog had run first, so nothing had stamped the panel. Whether a player
ever saw the frozen bar depends on what the client paints for a region carrying both two horizontal
anchors and an explicit width, which is not verified and is flagged as such in the contract.
New — tell me in chat when the designated announcer / inviter / welcomer changes
Field request (Discord). New setting under Guild Policy, with the baton controls:
"Tell me in chat when duty changes", DB.global.announceDesignateChanges, default OFF.
Off by default because duty follows the guild roster: in a guild with a long priority list it fires on every relevant login and logout, which is noise for anyone who is not one of the designates.
It is a diff over samples, not a hook on a mutator, and it has to be. The current holder is never
stored anywhere — electFrom() derives it per call from the priority list and the live roster. So the
commonest handover of all, "the member above you logged off", is not a mutation of anything and no
mutator hook could ever see it. Recruiter:NotifyDesignationChanges() samples all three roles and
compares against the last sample, which catches every cause: a logout, a login, a local or remote
baton, and a policy edit arriving over the wire.
Two properties that would each have shipped as a bug looking like the feature working, both pinned by
spec in Tests/recruiter_spec.lua:
- It does not sample during warmup.
GR:IsOnlinereports everyone above you offline until LibGuildRoster has built the roster, so priming there would record a fiction and then announce a "change" back to the truth a few seconds later — on every single login. - It samples whether or not the setting is on. Switching the notice on reports the next change rather than replaying a burst of handovers the player already lived through.
Call sites, all edges where the answer can have changed: Wingman:EvaluateDesignateState (which
already receives the coalesced OnMemberOnline/OnMemberOffline pair, so no seventh per-member
roster callback was added), onBatonChange in Modules/FGI_Recruiter.lua, and Bridge:ApplyPolicy
in Modules/FGI_DeltaSync.lua — that last one is the handover a member cannot possibly see coming,
the GM editing the list from another character, and it was the one path with no local action and no
roster event behind it.
Each role reports independently. A single "duty changed" line would be wrong: the roles are deliberately held by different characters (an announcing main, an inviting alt), so one moving says nothing about the others.
Fixed — you could pass the baton, but taking it back never offered Wingman again
Field report (Discord, Vishiswaz), verbatim:
Officer A and Officer B are online. Officer A is higher than Officer B in the priority list for being the designated announcer. Officer A uses
/fgi baton. Officer B gets the popup. Good. Clicks Enable. Officer A is ready to take the baton again. Officer A uses/fgi baton. No popup happens
Two symptoms, one defect, and the second is caused by the first.
Wingman:EvaluateDesignateState's auto-stop fires on OffDutyUnderPolicy(), which means "I am
barred from announce and from invite". Both halves fall open for a list that is not
configured. So in the common shape — a guild that filled in the announcer priority list and left
the inviter list empty — CanInvite() is still true after Officer A steps aside, the auto-stop's
test is false, and A's Wingman was never stopped. That falling-open is deliberate for a roster
change (losing an election should not kill a session that can still invite) and wrong for a
declaration, which is what /fgi baton is.
The missing popup is then the same bug seen from the other end: the rising-edge offer is gated on
not active, so the still-running session silently swallowed the prompt when duty came back.
Recruiter:StepAside now stops Wingman itself, before the evaluation runs — so the hand-over is
recorded off duty with Wingman off, and stepping back in is an honest rising edge that prompts.
- Known cost, not engineered around:
Wingman:StopclearswasActive, so the Restore my session option will not re-arm at the next login after a baton pass. The login prompt is the designed way back on duty and it fires there. - Not changed: the member who loses the baton (Officer B, when A steps back) keeps Wingman running if invites are still open to them. B did not make a declaration; B lost an election, and the per-step gates already hold whichever action is not theirs.
Five examples in Tests/wingman_prompt_spec.lua. The load-bearing one asserts that
OffDutyUnderPolicy() is false in this exact scenario — so nobody removes the stop from
StepAside on the theory that the auto-stop already covers it.
New — the Guild Roster tab shows who is on recruiting duty right now
Field request from the same report: "FGI should have somewhere in the main window that shows who the designated announcer/welcomer/recruiter is."
Settings shows the three priority lists, which are the input to the election. Its output was not on screen anywhere, so a member could read the whole policy and still not know whether duty had landed on them. A second row on the Guild Roster tab's top strip now reads:
Recruiting duty: Announcer: Alice Inviter: You Welcomer: nobody online
It calls GetDesignated / GetDesignatedInviter / GetDesignatedWelcomer — the very predicates
the enforcement gates use — so the line and the addon's behaviour cannot disagree.
Three states per role, and collapsing any two makes the readout useless. GetDesignated*
answers nil for both "there is no list" and "the list is set but nobody on it is online", and
those mean opposite things to a reader: unpoliced versus a policy that has fallen open. Hence
not set versus nobody online, resolved through HasPolicy / HasInviterPolicy /
HasWelcomerPolicy. While the roster is still building it says so rather than painting a name from
an election that reports everyone offline; and if you are the one who stepped aside it says that
too, which is the only thing that explains your own absence from a list you know you are on.
Repainted from the roster callbacks the tab already had, plus onBatonChange — a baton fires no
roster event, so nothing else could ever have seen it. Eight examples in
Tests/zz_roster_duty_line_spec.lua.
Fixed — the blacklist key builder still fabricated a realm, on every flavour
fn:normalizePlayerName was wired to addon.RealmResolver in v2.12.0 so it asks what a character's
realm actually is before falling back to appending our own. fn:fullPlayerName was not, and it
sat that way for four days while its own docstring calls the blacklist "the table most likely to be
ambiguous on a cluster".
It is the wider of the two, which is why this is a fix rather than tidying:
normalizePlayerNamereturns early on anything but retail, so on Classic Era it cannot fabricate.fullPlayerNameappends on every flavour by design — that is its entire purpose, tellingbob-RealmAfrombob-RealmBon a cluster — so the guess was reachable on the flavour this tree targets and the user actually plays.- It keys the blacklist and the GRM importer. A fabricated realm there writes an entry nobody can ever match again: the blacklisted player keeps getting whispered, and the entry sits in the table forever looking correct.
Now asks RealmResolver.Resolve first (functions.lua:1050), exactly as normalizePlayerName does
100 lines above. The local-realm append survives on a miss and deleting it would be a regression,
not a fix — an unqualified key splits one player into two across events that qualify a name and
events that do not. The resolver returns nil rather than a guess, so a hit is a fact and the append
stays the documented last resort.
Six examples in Tests/zz_realm_resolver_spec.lua, negative-checked: disabling only the resolver
call turns exactly the two "prefers the real realm" examples red with the fabricated key
Heafstaag-Azuresong, while the fallback, already-qualified, trailing-hyphen and non-string examples
stay green — so they are pinning four different properties rather than one property four times.
Changed — the RaiderIO lookup key asks the resolver too, and is written once instead of four times
The third key builder, and the one the audit item explicitly declined to call a defect. fn:filtered
looked up m+ scores and raid progress through four copies of
player.Name .. '-' .. (player.Realm or GetNormalizedRealmName())
— the same fabrication the two builders above were wired off, at functions.lua:5822, :5840,
:5912 and :5941.
The blast radius is genuinely smaller and the entry says so rather than inflating it. Nothing is
stored and nothing is suppressed: a wrong key makes RaiderIO.GetProfile return nil, so the cost is
a filter with an m+ or raid rule quietly rejecting a candidate it could not price — not a bad row
that outlives the session. Retail-only in practice, since RaiderIO is nil elsewhere and the branch
never runs.
New fn:rioKey (functions.lua:5728) is the single spelling. It has a source of truth its two
siblings do not, so the order is different and deliberate: player.Realm off the /who row is a
fact and wins outright, the resolver answers only where we would otherwise be inventing one, and
the local append remains the documented last resort. One property changed as a side effect and is an
improvement: an empty realm string is now treated as absent, where the old or accepted it — ""
is truthy in Lua, and it built the key "Heafstaag-", which matches no profile RaiderIO has ever held.
Four examples in Tests/zz_realm_resolver_spec.lua covering all four orderings. Suite 1839 → 1843
passed, 0 failed.
Fixed — the Statistics pie chart threw on every frame, on retail 12.0 (AUDIT S36/S37)
Retail 12.0 removed the MouseIsOver global. Our vendored LibGraph-2.0 fork predates that and
captured it as an upvalue at file scope (LibGraph-2.0.lua:88), which is why the failure was total
rather than partial: the capture binds nil once, so PieChart_OnUpdate raised on every frame the
cursor was near the chart. A field report showed it 148 times in one session.
Migrated to the frame:IsMouseOver() method at the call site (:1076), and the dead upvalue is
gone. MouseIsOver is now absent from the repo, so the two dev-config declarations of it
(.vscode/settings.json, FastGuildInvite.code-workspace) went in the same change — a stale
read globals entry is a promise to the linter, never evidence a name is used.
The multi-version gate was checked before editing, not after. A retail-only spelling here would
trade a retail crash for a Classic one across six TOCs. self:IsMouseOver() appears throughout the
Classic Era client source — 108 occurrences across 63 files — so the method is neither new nor
retail-only.
Changed, NOT verified, and the suite cannot help. PieChart_OnUpdate has no spec driving it, and
our own harness contract records LibGraph as deliberately unasserted because the frame layer forbids
pixel assertions. The suite passing after this change says nothing about it; the confirmation is a
retail client with the cursor over a pie chart.
Superseded within the same release, and the account above is kept because it is why. The surgical fix above treated one symptom of a stale fork. The fork itself was the problem, so the embedded copy is now replaced wholesale by the shared merged library (see the next section) and the "not swept" caveat is answered by not carrying a private fork at all.
Changed — LibGraph-2.0 is no longer embedded at all; it is an external required dependency
FGI carried a private fork of LibGraph-2.0 frozen before retail 12.0. Every consumer of this
library embeds its own copy and LibStub hands out whichever registers the highest minor, so a
stale fork is not merely stale for the addon carrying it — it can win, and impose its bugs on
everything else installed.
That is exactly what was happening. Measured across this install:
| Copy | Minor | Carried |
|---|---|---|
| Details | 90000 + 62 = 90062 |
stock r62, the MouseIsOver crash |
| Recount | 90000 + 68 = 90068 |
stock r68, the MouseIsOver crash |
| FastGuildInvite | 90000 + 1000 + 68 = 91068 |
private fork, the crash and a flipped DrawHLine SetTexCoord |
FGI's fork had the highest minor, so on any install carrying all three, FGI's copy is the one
every addon got — and fastguildinvite sorts early in load order, which decided the ties.
Now replaced by the shared copy (github.com/Pimptasty/LibGraph), merged from the FastGuildInvite
and Recount forks and functionally tested in a client before adoption. It keeps both things this
addon's Statistics tab depends on — graph.LabelHook (the localisation seam for LibGraph's internal
axis labels) and graph.XLabelsEnabled — and carries the 12.0 IsMouseOver fix and the DrawHLine
SetTexCoord fix together. Its minor sits in a +2000 band: +0 stock, +1000 a private
per-addon fork, +2000 the shared copy, so it supersedes both rather than tying one. A tie would be
silent — LibStub refuses an equal minor and the loser bails at if not lib then return end.
The first attempt swapped the embedded file for the shared copy. That was still an embedded copy,
and the shared library now ships on its own, so FGI stopped carrying one. Libs/LibGraph-2.0/ is
deleted — the library file, the wrapper .toc, its LibStub.lua and the twelve .tga textures. In
its place:
LibGraph-2.0added to## Dependencies:in all six TOCs, and theLibs\LibGraph-2.0\...load line removed from each.libgraph-2-0-revivedadded to.pkgmetarequired-dependencies:, so CurseForge installs it alongside FGI exactly as it already does for GuildRoster, DeltaSync, AceCommQueue and LibLocaleOverride.README.mdmoves it out of the "built with" list and into the companion add-ons list, which is the list a hand-installer actually reads.
This is what actually fixes the shared-library problem, rather than winning it. The minor-band scheme described above works by out-ranking every other copy — it is still a copy competing with other copies, and the next addon to embed a newer fork takes the crown back. One installed library that every addon resolves through LibStub has no crown to win.
The suite now loads the installed library, not a vendored one. Tests/support/addon.lua drops
its FGI-LibGraph vendored entry for a plain LibGraph-2.0 registration with no root, which
means the harness's default root — the sibling AddOns folder. So the specs exercise the exact file
that ships to players, which is the same rule the harness already applies to Ace3, GuildRoster,
DeltaSync and LibLocaleOverride. Its LibStub.lua is deliberately not loaded: the harness vendors
the canonical LibStub so real embeddable libraries register unchanged.
Suite 1868 passed / 0 failed with the vendored copy deleted, and the Statistics tab still renders
in Tests/zz_tabs_render_spec.lua, which is what proves the library resolved from its new home.
Verified in a client (Classic, 2026-08-26), both halves. The addon loads against the external
library — which is the whole of the load-time risk, since a wrong dependency or folder name in
## Dependencies: does not degrade, it stops the addon loading outright. And the Statistics tab
draws: screenshot shows the line chart with its grid, both axes labelled and the series rendered,
with no errors. That last part confirms more than "the library resolved" — the X-axis day labels and
the Y-axis values are drawn through graph.LabelHook and graph.XLabelsEnabled, the two seams this
addon depends on, so the external copy keeps them.
Still unconfirmed, and it is one specific thing rather than a general caveat: the pie chart on
retail 12.0, which is the surface the IsMouseOver fix above was about. The pass here is a line
chart on Classic and cannot speak to it.
Changed — the harness pin moved to 1f8fe09
after_each now runs when an example fails. Ours were pre-assessed as safe (almost all are plain
restores that cannot raise), and the suite is unchanged at 1839 passed / 0 failed / 2 pending —
which is the number that mattered, since the fix's whole point is that one red example used to poison
every later spec file.
Changed — the suite now pins what FGI writes into _G
Test-only; nothing shipped changes. Tests/support/addon.lua wraps the harness's provenance ledger
(env/provenance.lua, adopted with pin fccefa3) around the addon-file loop and nothing else —
Ace3 and the eight shared libraries load above the watch, so what is recorded is what our own files
did. Tests/zzzz_created_globals_spec.lua asserts it.
The assertion that earns its keep is "overwrites nothing it does not own". FGI replaced
StaticPopupDialogs once; it did not error, and the symptom was ADDON_ACTION_FORBIDDEN on bag
clicks, three layers from the assignment. That class now fails offline.
Two things the ledger taught about itself, written into the spec so nobody re-derives them:
- Which side of the ledger a name lands on is a fact about LOAD ORDER, not about FGI. Several
specs stub
_G.FGIso they can load one module without the whole addon, so in a full-suite run FGI's own assignment is an overwrite; alone it is a create. The first draft pinned a list taken from a single-file run and went red in the suite. Every assertion now works on the union of both. - The ledger cannot see a write that changes nothing.
SLASH_FGIDELTASYNC1 = "/fgids"is unconditional at file scope, yet is invisible in a full-suite run: the create half only fires for an absent key and the overwrite half compares identity, so re-assigning the same constant is neither. Measured in both directions rather than reasoned about — requiring it is red alone, forbidding it is red in the suite — so it is accepted-if-present. Do not pin a global whose value is a constant scalar.
Suite 1835 → 1839, and green both in the full run and file-alone, which is the property that matters: a spec that only passes one way is a trap.
Changed — one test seam, labelled as one
Recruiter:ForgetDesignationSnapshot() exists only so the spec can start each example unprimed.
Nothing in the addon calls it. It is named and documented as a seam rather than dressed up as
production code, because the alternative was a spec reaching into the module's file-locals.
[v2.12.1] (2026-08-24) — Recheck stops spending scan queries and asks the server directly; keep the race the client was already handing us; read the guild roster through the library that already has it
A CLAIM MADE AND WITHDRAWN IN THE SAME DAY — read this before "re-fixing" the realm append
An earlier draft of this section announced a retail-only realm-fabrication bug in
fn:normalizePlayerName (functions.lua:909), on the grounds that appending
GetNormalizedRealmName() to a bare name is a guess that goes wrong on a connected-realm cluster.
The user rejected the premise and they were right. Recording it here so the same wrong fix is not
attempted a third time.
The client's behaviour is universal, not retail-specific: a name from a local client API is bare only when the character is on the viewer's own realm, so completing it with the local realm is a correct inference, not a fabrication. Two independent sources say so:
LibGuildRoster-1.0.lua:178-187, which is why itsNormalizeNameappends the local realm and why the library documents that as the right thing for roster rows, units and the local player.- Warcraft Wiki / Wowpedia on
UnitName: the realm return isnilwhen the unit is on the same realm or the same connected group, and the normalized realm only for a genuinely different one.
The real axis is the provenance of the name, which the library also documents: a name that
arrived over the wire lost the context that made bare mean "mine", because the receiver's realm is
not the sender's. CanonName exists for those and keeps them bare. FGI grew a wire-provenance name
source in v2.12.0 (guild-shared recruit history over DeltaSync), so that is the case worth caring
about — not the local-API one the withdrawn draft was aimed at.
One contradiction is left standing rather than smoothed over, because it decides whether a
connected-realm player is qualified or not and the two sources disagree. The wiki says a connected
realm returns nil. But the Dibs field case that started this had UnitFullName returning
"Myzrael" for a player whose master looter was on Azuresong, and those two realms are
connected. The wiki may be describing GetUnitName's display logic rather than UnitName's raw
return. Unresolved. Do not build anything that depends on the answer without measuring it in a
client first.
Changed — a name/realm resolver, for names whose origin is not known
New module Modules/FGI_RealmResolver.lua. A session-long name -> "Name-Realm" table fed by
three sources, none of which can invent anything:
UnitFullName(unit)for group members;GetPlayerInfoByGUID(guid)— the client's own name cache, sixth and seventh returns, exactly as Blizzard'sGetNameAndServerNameFromGUIDreads them (Blizzard_SharedXML\UnitUtil.lua:21-24). The GUID is argument 12 on everyCHAT_MSG_*event, so anyone who types one word resolves themselves for free;LibGuildRoster-1.0'sGetAllMembers(), an array of already-qualified roster keys.
The no-fabrication contract is the whole design. Note refuses a bare name, NoteGUID and
NoteUnit refuse an empty realm even though empty is a real answer meaning "my realm", and
Resolve returns nil rather than guessing. Refusing is what keeps a fact distinguishable from a
guess at the call site.
The local-realm append is UNCHANGED and is not a fallback for a broken thing. Per the withdrawal above it is a correct inference for a local-API name, and it is what keeps one player from splitting into two keys across events. The resolver sits in front of it only to supply a realm it has actually observed; when it has not, the existing behaviour runs exactly as before. A spec example pins that so nobody removes the append as dead code.
So this is a no-op for every name FGI handles today, and it is recorded as Changed rather than Fixed for that reason. Its value is the wire-provenance case: guild-shared recruit history arriving over DeltaSync from an officer on another realm, where bare does not mean "mine".
Changed — guild roster reads go through LibGuildRoster instead of a second scanner
The first version of the resolver hand-rolled a GetNumGuildMembers + GetGuildRosterInfo loop.
That was wrong twice over: LibGuildRoster-1.0 is embedded in this addon and already maintains a
scanned, event-driven, connected-realm-aware roster, so a second scanner is a second implementation
of one job that can drift — and it is the slower one, re-walking the client's roster on every call
where the library answers from a table it already keeps current. GetAllMembers() returns precisely
the array of real "Name-Realm" strings that was being rebuilt.
A correction carried in the source comment as well, because the previous draft was wrong:
NormalizeName is not dangerous in itself. The library's header (LibGuildRoster-1.0.lua:178-187)
draws the line by the provenance of the name — a name read from a local client API is bare only
when the character is on the viewer's realm, so NormalizeName is right for roster rows, units and
the local player, while CanonName (MINOR 11) is for names that arrived over the wire. The resolver
takes neither door only because it handles names of unknown provenance.
Also noted at the call site: every library method taking a name memoizes that string even when the
answer is nil (:189-197, nine such doors). GetAllMembers takes no name, so it has no such side
effect — another reason it is right for a resolver fed arbitrary strings from chat.
Changed — the race backfill fills in what it already knows before spending a query
GetPlayerInfoByGUID returns the race in the same call that answers the realm (third return,
localized). It was being discarded. It is now banked in the resolver, in a table kept separate from
the resolved realms on purpose: a same-realm player's realm comes back empty and is correctly
refused, but their race was perfectly good, and one combined table would throw away exactly the
majority case. It is banked before the realm guard can return, for the same reason.
fn.backfillRacesFromCache walks memberHistory and alreadySended filling races from that cache,
and fn.backfillOnlineRaces now runs it before its own gates — it spends no query, so it is
worth doing even on a click that is about to be refused because a scan is running. A refusal that
still filled in twenty races beats one that did nothing.
It reaches players the query structurally cannot. That module's own header records the two
limits of the /who path: it cannot see offline players at all, and it caps at 50 results. Neither
applies to a cached GUID — a guildmate who spoke and then logged off is still in the cache, and so is
the fifty-first.
Changed — removed a duplicate race-writing helper that never ran
applyRaceToStores in Modules/FGI_RaceBackfill.lua had no caller: onBackfillResults does the
same work inline. Two implementations of one rule with nothing asserting they agreed. Its note about
the legacy bare-number entry shape was kept, because every writer in that file depends on it.
Changed — Recheck asks the server who is online, instead of spending scan queries to find out
New module Modules/FGI_Presence.lua, and fn.refreshQueueRun now drives it instead of a /who
sweep. The mechanism is a silent addon whisper — C_ChatInfo.SendAddonMessage("FGIPRES", "x", "WHISPER", "<Name-Realm>"). The server answers ERR_CHAT_PLAYER_NOT_FOUND_S on CHAT_MSG_SYSTEM
when the target is offline and says nothing at all when they are online — so silence is the
positive signal, which inverts every other check in the suite and is why the spec drives the
timeout path as carefully as the message path. The target sees nothing and does not need
FastGuildInvite installed; addon messages never render, and a client with no handler for the prefix
discards them.
Three reasons it replaced the /who recheck, so nobody reverts it on a hunch. It costs
nothing — a /who recheck spent the scarce budget the scan needs, so a long queue traded directly
against finding new recruits. One press covers the queue — SendWho is hardware-event gated and
SendAddonMessage is not, so a timer paces the whole queue from one press (the first in-client run
answered 65 names for zero queries). And it answers the actual question: presence in a /who
result was only ever a proxy for "is this person still around".
Every cheaper presence API was ruled out first, by reading the Classic Era client docs — the
full list is the header of Modules/FGI_Presence.lua. They all key on a relationship (unit token,
GUID, friendship, guild membership) and a recruitment queue is made of strangers by construction.
KNOWN COST. A /who answer carries level, guild and zone; a probe carries none of that, so
Recheck no longer prunes rows whose owner has joined a guild and there is no replacement — ordinary
scanning still drops them when it next sees them. Seen does still advance: an ONLINE answer is
exactly the evidence a /who hit is.
The /who recheck machinery is DELETED, on the user's instruction once the sweep was confirmed
in a client: "i think you should delete it, it was causing issues with the normal scan, we don't
want n- in the normal scans." Gone: fn.refreshQueue, fn.refreshQueueStep, fn.queueRefreshPool,
fn.queueRefreshQueries, the refreshPool/refreshSeen/refreshTotal state, the pool filter in
searchWhoResultCallback, and fn:nextSearch's isRecheckStep parameter.
Removal beat fencing it, and it had been fenced twice already. The pool filtered the results
of every answer, scan-wide — subdivided children carry no per-query flag, so it could not be
per-query — so a pool set for any reason silently restricted the Scan button, Wingman and F6 to names
already queued. A guard in Wingman.lua, then an isRecheckStep exclusion, were both fences around
a shape that could return. The specs pinning those rules were inverted rather than deleted: they
plant the dead state by hand and assert nothing reads it.
Two design points that are easy to get wrong later, both commented in place: progress counts
answers, not sends (sends run ahead by up to the reply window), and search.presenceProgress is
a separate key from progressDone/progressTotal, which are denominated in queries a sweep
never spends.
Changed — Recheck comes off the scan cooldown, which it no longer has any reason to share
The user, after the switch above: "recheck is still tied to the 8s who scan timer for the scan button. we need to remove that now as it isn't using /who scans."
widgets.scanCooldown existed to stop a user firing two /who queries inside one interval by
alternating >> and Recheck. That premise died with the presence sweep — Recheck spends no
query, so no shared resource was left to protect and the gate had turned into pure cost in both
directions: a scan blocked a recheck, and a recheck blocked a scan.
Three places carried it and all three are gone: the click handler's if widgets.scanCooldown then return end (and the fn.startScanCooldown call after it), ScanTab.SetCooldown's two-button loop,
now owning >> alone, and the not widgets.scanCooldown guard on the label restore in
ScanTab.Refresh. The button shows no countdown at all now.
What stops a second press instead, since something must: Presence.Sweeping() refuses a second
sweep, and fn.refreshQueueRun turns that refusal into "already checking the queue" — better
feedback than a greyed-out button, because it says what is happening rather than only that you may
not.
Fixed — /fgi requeuestop did not stop a recheck, and reported "0 of 0" while it failed to
Found while rewriting the Recheck tooltip, which promises that command cancels — so the promise was false and would have shipped that way.
fn.refreshQueueStop cleared the /who refresh pool and never touched the sweep itself, returning
looking successful while the probes carried on. fn.refreshQueueStatus had the mirror blind spot: it
read refreshPool and answered 0, 0, so the command printed "stopped after 0 of 0" about a sweep
genuinely forty names in.
Both now answer for the sweep. A stop clears search.presenceProgress and repaints — the sweep
clears it only on its normal completion path, so the green bar froze at its last reading, and because
the status bar tests the presence branch before the scan branch that would have hidden every later
scan's readout with no way back. Presence.Reset clears it for the same reason. Stopping a recheck
also leaves whoQueryList alone, so it cannot wipe the pending queries of a scan running at the
same time — reachable now that the cooldown no longer keeps the two apart.
Changed — the Recheck sweep prints one line instead of one per name
The status bar fills green while the sweep runs (a third colour beside scan orange and sync teal, because presence and scan share a tab and mean different things), and exactly one line lands in chat when it finishes: how many were checked, and how many were online, offline and not asked.
The first build printed four kinds of line per name through print, ungated — the module's own
header called them instrumentation and said to replace them before shipping. A 65-name queue is
about 130 chat lines from one button press, which buries the recruit replies the addon exists to
surface. The per-name sent / ONLINE / offline / skipped lines now go through fn.debug.
They were routed rather than deleted: they are exactly what a future presence bug needs, and
debug mode is already where a user is told to go to collect it. Tests/zz_presence_spec.lua pins
both halves, asserting on the line count rather than the wording.
Changed — Removed: WagoAnalytics, which had never been connected under this maintainer
init.lua:4 had the real registration commented out and replaced with a stub whose six methods were
all empty, so LibStub("WagoAnalytics") was never resolved and a player who had the real library
was never touched by FastGuildInvite. All eighteen call sites are gone, along with the
## OptionalDeps: WagoAnalytics line from all six TOCs. docs/DEV_NOTES.md:1117 is struck through
in place with the reason rather than deleted.
The deciding fact, kept in init.lua so it survives the removal: kRNLQ46o is the original
author's Wago project id. Uncommenting that line would have sent every user's telemetry to somebody
else's project. Secondary, and the reason "just uncomment it" was never a one-line change: eight of
the sixteen setting sites passed raw DB values with no nil guard, which the empty stub had been
absorbing invisibly.
Re-adding is safe in itself if it is ever wanted — the real library writes only WagoAnalytics and
WagoAnalyticsSV and never touches a consumer's table, read from its own source on 2026-08-24 — but
it needs a new project id registered to this maintainer.
Also fixed in the same pass, and the drift is the real defect rather than this line: the six TOCs
disagreed, one saying ## OptionalDeps: and five ## OptionalDependencies:. Same class as the
_BCC suffix bug — one TOC differing silently.
Notes
The resolver only ever adds a realm it has observed, so no existing key changes on any flavour. New
spec Tests/zz_realm_resolver_spec.lua (17 examples) — its "prefers the REAL realm over the local
one" example asserts the resolver is consulted, not that the old behaviour was wrong.
Suite 1755 → 1786 passed, 0 failed, 2 pending — the count fell from 1800 because deleting the /who
recheck retired about twenty examples and replaced them with eight absence checks. The user confirmed
the button timer and the presence sweep in a client; the one-line summary and /fgi requeuestop
remain unverified there.
Two harness findings were raised in Tests/HARNESS_CONTRACT.md. One matters anywhere in this suite:
run.lua wraps a spec's befores, body and afters in one pcall, so a failing example never
runs its after_each — a spec substituting a global there leaks it into every later file. One real
failure produced 108, of which 107 were innocent. Substitute and restore inside the example.
New — a Remove button on the "blacklisted player is in your guild" prompt
Player request, relayed verbatim against a screenshot of that exact window: "please add a 'remove from
blacklist' button to this window". It is a restoration rather than a new idea — v1.x had an
Unblacklist button here and the v1 → v2 strip dropped it along with the dialog definition
(CHANGELOG_ARCHIVE, "popup now shows reason + has Unblacklist button"). The gap was real: this
window is the only place that tells you a blacklisted player is in your guild, and answering "they are
fine, actually" meant closing it, opening Settings and finding them on the Blacklist tab by hand.
Remove routes through fn:unblacklist, not a direct DB.realm.blackList[name] = nil — that is the
one path that also normalizes the spelling, redraws the Blacklist tab and posts the officer-chat note,
the same reason Modules/FGI_BlacklistAudit.lua routes its own removals through it.
The dialog had no coverage at all before this. addon.kickQueue has carried the comment "exposed
so the offline specs can drive the queue" since v2.1.8 and no spec ever did.
Tests/zzzz_blacklist_kick_popup_spec.lua now drives it. It is a zzzz_ file deliberately: written
inside zz_dialog_callsites_spec first, it broke a later file, and running last is this repo's own
answer for a spec that perturbs shared state.
Changed — "Auto-kick blacklist" is now "Check the guild for blacklisted members"
A player asked for "a blacklist check (search for blacklisted members in guild) automatically upon login/reload", and the user's reaction was "i thought we did this". They were right — it has shipped the whole time. The defect was discoverability, and both strings were factually false:
- The name "Auto-kick blacklist" and the description "Automatically remove a player from your guild if they appear in the FGI blacklist" promised something more aggressive than it does. It never kicks anyone by itself; it opens a prompt and waits. So a recruiter who did not want members removed behind their back would decline the very setting that does what they want — exactly backwards, and why somebody asked for a feature they already had.
- The description also claimed it "watches GUILD_ROSTER_UPDATE". Also false: it is a login roster
sweep plus
CHAT_MSG_SYSTEM. That event appears nowhere in the path.
The saved key autoKickBlacklist is unchanged on purpose — renaming it would silently reset the
setting for everyone who had turned it on. The default stays off; the user's call, verbatim: "yes,
leave it off. the rename should be enough." The two old locale keys are kept rather than deleted,
because ~30 locale files still carry translations of them and a key with no reader is harmless while a
missing key is not.
The general lesson, worth more than the fix: a setting nobody can identify from its own name is a setting nobody has. Two separate user requests in one session were for things that already existed. When a user asks for something that sounds already-built, check before building — and if it is there, suspect the label.
Fixed — the guild blacklist check ran before the guild roster existed, so it checked nobody
Field-reported, and my first answer to it was wrong. The user pressed Skip on the
blacklisted-member-in-guild prompt, reloaded, and was never asked again. I said kickQueue.asked is
session-only (true) but that nothing re-asks at login because the setting defaults off (also true,
and not their case) — they came back with a screenshot of the checkbox ticked.
fn:blacklistKick hand-rolled for i = 1, GetNumGuildMembers(), and fn:blackListAutoKick called it
from OnEnable (FGI_Core.lua:397). At that moment the client's guild roster cache is empty, so
the count is 0, the loop body never executes once, and the sweep returns having looked at nobody.
Nothing failed — it searched an empty list. That is why switching the setting on in Settings always
appeared to work (the roster is already loaded by then) and a /reload never did.
The roster is now read through LibGuildRoster, which is a hard TOC dependency and exists for this:
it waits out the login stream and only calls a roster stable once the member count has settled, which
is what IsReady() reports. The login sweep registers OnRosterReady instead of guessing at a delay,
and sweeps immediately when the library is already ready. The old loop survives as the fallback for a
client where the library did not resolve.
Two more defects in the same function, both found while in there:
- It called
CreateFrame("Frame")on every invocation, andapplyAutoKickcalls it every time the setting is switched on — so off/on/off/on left four liveCHAT_MSG_SYSTEMhandlers all reacting to the same joiner. One frame per session now, kept onkickQueuerather than a new file-scope local (functions.luais close enough to Lua 5.1's 200-local ceiling that adding one is not free). - That handler never checked the setting, so it kept prompting after the setting went off.
applyAutoKick's comment has claimed since v2.1.8 that it "unregisters the kick frame's events", and no code ever did. It asks now.
Tests/zzzz_blacklist_kick_popup_spec.lua had to change with it: it seeded the roster through
env.guild directly, which backs GetNumGuildMembers but not the library, so all six examples went
red with "the blacklist roster scan opened no prompt". Published through support.roster now, which
drives the real library through the client's own surface.
Changed — the anti-spam expiry is a days slider, matching the retention control beside it
User request: "can we update the anti-spam expiry to be a slider just like invite history retention?", against a screenshot of the two controls stacked — one a dropdown reading "1 week", one a slider reading 30. Two retention settings side by side that worked in different units.
DB.global.antiSpamExpiryDays (0 = never expire) replaces the five-choice index in clearDBtimes,
on the same 0–365 range as the history retention. Four things worth recording:
- The migration runs once. The old index maps 1 → never, 2 → 1, 3 → 7, 4 → 30, 5 → 180 days, and
clearDBtimesis deliberately not deleted — it is what a rollback to an older build reads. There is intentionally no AceDB default for the new key: a default would make it never nil, the migration would never fire, and every existing user would be silently re-timed to 7 days. - One resolver.
fn:antiSpamExpiryDaysowns the migration, the clamp and the policy floor.fn:acceptRetentionSecondsandfn.expireAntiSpameach decoded the index separately before and had already diverged — one repaired a bad value on disk, the other did not. - The guild-policy floor is enforced, not just displayed. It used to be applied only in the
settings control's
get, so the panel showed the longer retention while the runtime went on expiring at the member's own shorter value — which made the notice beside it ("You may use a longer retention, but not a shorter one") untrue of what the addon did. It can only ever lengthen retention, and the stored value is still not mutated, so clearing the policy gives the member their own choice back. antiSpamMinstays an index. It travels between clients in the policy payload, so its meaning on the wire cannot change without breaking peers on other builds; it is decoded to days at the comparison point only. The GM-side control is still the five-choice select.
Changed — pop-up windows fit their content instead of a fixed guess
Reported against the blacklist prompt: "a lot of dead text" — one line of text, one row of buttons,
and roughly two thirds of the window empty underneath. Not that dialog's fault. Dialog.Normalize has
always set the height from a constant (180 plain, 220/260 with an edit box or swatch) sized for the
largest thing a dialog of that shape might hold, so anything smaller wore the difference as dead space.
Dialog.FrameHeight fits the frame to what was actually built. The number comes from AceGUI's own Flow
layout, which reports the total it laid out via LayoutFinished — summing child:GetHeight() instead
would be wrong the moment two widgets share a row, which is exactly what a button row is. Chrome is
derived by subtraction rather than written as a constant, since it is AceGUI's number and moves with
the art.
The asymmetry with width is deliberate: width may only ever grow, because the body text was already
laid out against the requested width and narrowing re-wraps text that is on screen. Height may shrink —
but only when the height being overridden was ours. spec.autoHeight records whether the caller
named one, so the Add popups keep the size they asked for and do not collapse around an empty edit box
and jump on first use. A floor stops a near-zero measurement reducing a window to a title bar.
Changed — the blacklist prompt loses its Skip button; closing the window is the answer
"why don't we just get rid of the skip button and update the tooltip." Skip's entire body was
asked[name] = true; advance(), which is what dismissing the window already means — so it was a third
caption to read, a third string to translate, and (through Dialog.FrameWidth, which grows the frame to
fit the row) width the window did not need, all to duplicate a gesture the window already had.
It was still load-bearing in one way, which is why this needed a dialog change and not a delete. This
prompt is the head of a queue — a roster scan finding five blacklisted members asks about them one
at a time — and escape = false meant "closing runs nothing at all", which would strand the rest. So
Skip existed partly to give Escape a target that did something.
FGI_Dialog gained onEscape: a callback run when no button stands for Escape, closing first and
then running it, the same order as a click. The prompt is escape = false plus a queue-advancing
onEscape; pointing escape at either surviving button would be unsafe in both directions, since one
kicks and one unblacklists.
The help text now carries the third choice, and that is load-bearing rather than tidy: a gesture has no caption to hang a tooltip on, so a help string that stops mentioning what closing does makes that choice invisible, not merely undocumented. A spec example asserts it mentions closing.
Changed — the "i" help icon is back on the blacklist prompt
"it also needs the i mouseover info tooltip between the close button and status bar, just like
everywhere else." It was removed earlier the same day, arguing that per-button tooltips already answer
each question where it is asked. That argument was not wrong and was not the point: the "i" is a
consistent affordance, in the same place on the main window and the compact tray, so a window without
one reads as a window with nothing to explain. No dialog machinery needed changing — only the prompt's
help field had been dropped.
Fixed — the Skip tooltip promised a re-ask that never happened
It read "you are not asked about this player again until you next log in", which implies you will be asked next login. The suppression is session-only and a reload does clear it, but nothing re-runs the roster check at login unless Check the guild for blacklisted members is on, and that defaults off. Superseded entirely when Skip was removed above; the wording lives in the help text now.
Notes on the suite and the changelog archive
Suite 1786 → 1815 passed, 0 failed, 2 pending. zz_progressbar_resize_spec's four order-dependent
failures are unrelated and predate all of this — recorded rather than fixed, with three disproven
diagnoses against them.
CHANGELOG.md was archived at last. v2.12.0 (103,032 bytes, over half the file) moved to
CHANGELOG_ARCHIVE.md, taking the live file from 119,957 bytes to 16,925 against a 120,000 working
ceiling. Done as a byte-exact copy and verified two ways: the two files' sizes are conserved to the
byte, and a SHA-256 of the moved block taken before the write matches one taken after re-reading it out
of the archive. That second check is the one that matters — a size comparison catches a dropped line but
not a changed character.
Older releases (v2.12.0 and earlier) have been moved to CHANGELOG_ARCHIVE.md.
This mod has no additional files

