promotional bannermobile promotional banner

Fast Guild Invite - Revived

A WoW Classic AddOn to help recruit new members into your guild.
Back to Files

FastGuildInvite-v2.12.2

File nameFastGuildInvite-FastGuildInvite-v2.12.2.zip
Uploader
PmptastyPmptasty
Uploaded
Aug 27, 2026
Downloads
2.4K
Size
7.5 MB
Flavors
RetailMoP ClassicClassic TBCClassic
File ID
8744268
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 11.2.7
  • 5.5.4
  • 4.4.2
  • 3.4.5
  • 2.5.6
  • 1.15.9

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, deliberatelyMicro 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 DB upvalue is gone. It was assigned in dump.lua's own PLAYER_LOGIN handler, so every button in the file raised on a nil DB if the window was opened before that fired — again, in the one window reached when something is already wrong. Each site reads addon.DB now.
  • 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 registered events.OnClick directly 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 ChatFrame1EditBoxChatFrame10EditBox 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:IsOnline reports 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:Stop clears wasActive, 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:

  • normalizePlayerName returns early on anything but retail, so on Classic Era it cannot fabricate. fullPlayerName appends on every flavour by design — that is its entire purpose, telling bob-RealmA from bob-RealmB on 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.0 added to ## Dependencies: in all six TOCs, and the Libs\LibGraph-2.0\... load line removed from each.
  • libgraph-2-0-revived added to .pkgmeta required-dependencies:, so CurseForge installs it alongside FGI exactly as it already does for GuildRoster, DeltaSync, AceCommQueue and LibLocaleOverride.
  • README.md moves 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.FGI so 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 its NormalizeName appends 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 is nil when 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's GetNameAndServerNameFromGUID reads them (Blizzard_SharedXML\UnitUtil.lua:21-24). The GUID is argument 12 on every CHAT_MSG_* event, so anyone who types one word resolves themselves for free;
  • LibGuildRoster-1.0's GetAllMembers(), 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 queueSendWho 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, and applyAutoKick calls it every time the setting is switched on — so off/on/off/on left four live CHAT_MSG_SYSTEM handlers all reacting to the same joiner. One frame per session now, kept on kickQueue rather than a new file-scope local (functions.lua is 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 clearDBtimes is 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:antiSpamExpiryDays owns the migration, the clamp and the policy floor. fn:acceptRetentionSeconds and fn.expireAntiSpam each 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.
  • antiSpamMin stays 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