promotional bannermobile promotional banner

Ultimate Guild Invite

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

UltimateGuildInvite-v2.21.1

File nameFastGuildInvite-UltimateGuildInvite-v2.21.1.zip
Uploader
PmptastyPmptasty
Uploaded
Nov 1, 2026
Downloads
225
Size
7.2 MB
Flavors
RetailMoP ClassicClassic TBCClassicForever
File ID
9391828
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 5.5.4
  • 4.4.2
  • 3.4.5
  • 2.5.6
  • 1.60.1
  • 1.15.9

What's new

<UGI> Ultimate Guild Invite (formerly FastGuildInvite)

[v2.21.1] (2026-10-31) — coverage catch-up, and a legacy guild set that was never adopted

Fixed — a guild never got back legacy data whose verdict had to wait for the roster

Found by the coverage catch-up. When a character joins a guild while the live set belongs to another guild, and the only parked set is an unstamped legacy one, the verdict on whether it is this guild's rests on the policy author, and that waits for a built roster. GuildScope:Resolve in Modules/FGI_GuildScope.lua still swapped the other guild's set aside and made this guild the owner, so on the next resolve the owner was in scope and the legacy set was never looked at again: a guild whose author it was never got its legacy policy and lists. A waiting verdict now sets guildScopeLegacyWait, and the next resolve judges the parked set again; when it is this guild's, it is loaded with what this guild wrote meanwhile merged in (the new loadOver, now shared with the sister-guild case, which also keeps a value set meanwhile where the loaded set has none). Only the shared lists are merged key by key: a policy is one authored record, so the loaded policy wins whole rather than being mixed with one saved while the roster built (found writing the self-audit; the first cut of loadOver unioned every table). What this guild wrote while waiting is kept wherever the loaded set has nothing for that key -- read off the parked set itself, since swap fills every key it is not given with an empty default and a test of the live value dropped the guild's own messages (Peer Review 262c69d8, a). A wait whose parked set has gone clears instead of re-resolving for ever. Three new examples in Tests/zz_guild_scope_spec.lua; the file is at 169/169. Not verified in a client.

Changed — README and CurseForge page: Ace3 is a manual install on WoW Forever

Discord, Danne (2026-10-31, WoW Forever): UGI and every library except LibLocaleOverride reported a missing dependency after a clean CurseForge reinstall. LibLocaleOverride is the only one with no ## Dependencies: Ace3 line, and Ace3 has no Forever release yet (operator, on Discord: "Ace3 doesn't have a Release version in Forever yet. You have to manually install it from CF"). Installing Ace3 into the Forever folder fixed it. Both player docs said CurseForge installs every dependency for you; they now name Ace3 on Forever as the exception, and why: CurseForge installs only release versions by default, and Ace3 publishes only alpha builds for Forever so far (operator, 2026-10-31: "it has to be installed by hand UNTIL they issue a release version ... CF only installs release versions by default, and Ace3 is only issuing -alpha builds right now"). Drop the note once Ace3 ships a Forever release.

Changed — every file of FGI's own code at 100% line coverage

Measured in six chunks through the harness's coverage.lua: every root file, all 38 Modules/, all 19 GUI/ files (the twelve under GUI/Tabs/ had never been measured), FGI's own Libs/LibWho and Locale/summary.lua. Not measured: the vendored libraries (LibCompress, LibDeflate, LibDataBroker, LibDBIcon) and the translation tables. What the gaps were, and what closed them:

  • FGI_AnnounceSync.lua: mergeLayer returned a false no caller reads; removed.
  • FGI_GuildScope.lua: the sister merge guarded two states its own swap rules out, folded into one condition; examples for an unprefixed owner stamp and a parked non-sister set; and the legacy-wait defect above.
  • FGI_Recruiter.lua: the alt taking its main's turn in the announcer line-up.
  • GUI/SettingsPanel.lua: v2.21.0's three stop toggles, the inviters-only sync setting and the three "nobody available" status lines had no example.
  • GUI/Tabs/Blacklist.lua: Import UGR, shown only with Ultimate Guild Roster loaded.
  • GUI/Tabs/Scan.lua: the held count on the scan line.
  • functions.lua: the bare SendChatMessage fallback. Every flavour tree on the reference drive has C_ChatInfo.SendChatMessage, but the Wrath and Cataclysm Classic clients are older builds than any of them, so it was kept and specced rather than deleted.
  • Locale/summary.lua (56%): each flavour's class and race tables are built at load, so only Classic Era's were reached. Tests/zz_summary_flavours_spec.lua reloads it as every flavour. It pins which race key sits on which side under which id; that those ids are a real client's is not checked.

Suite 4675 passed in default and reverse order.

Then the shuffled orders, which nobody had run on this work: seeds 12345 and 42 and the reverse order passed, seed 1 failed two examples, both test isolation and neither a product defect. Tests/wingman_prompt_spec.lua replaced C_Timer with a stub that had no NewTimer, and a DeltaSync session an earlier file left open called it from inside this file's roster publishes; the stub now has one. Tests/zzzz_blacklist_kick_popup_spec.lua seeded its blacklist before publishing its roster, and when an earlier file had left the live set stamped to another guild that publish's guild-scope resolve parked the seeded blacklist; the spec now puts it back once the guild is settled. After both fixes, the default order, reverse order and seeds 1, 12345 and 42 all pass at 4676.

[v2.21.0] (2026-10-31) — guild data stays with its guild, duty per realm group on retail, and a stop setting for each duty list

Discord, Pockit (thread ad7357d9, 2026-10-30, Retail): about 50 blacklist entries none of the guild's recruiters wrote, with no date and no blacklister, arrived by guild sync. The sync trust gate (Bridge:IsTrustedSender) accepted any guild member, the Sync tooltip promised "other officers", and the guild runs Guild Roster Manager with its import on. A code audit of the sync settings found the master switch did not do what it says.

Changed — a duty list with nobody on it online now stops that duty, by guild-policy setting

When a guild policy's announcer, inviter or welcomer list was set but nobody on it was online, amDesignateFor in Modules/FGI_Recruiter.lua answered true for everyone: every member running UGI could announce, invite and send recruiting whispers, or welcome. I had built that in on my own ("so recruiting doesn't die when your recruiters are offline"); it left a hole in the policy. The operator, 2026-10-31: "we need to make a setting for all 3 in guild policy (on/off, and on by default) that if no one on the list is online, then that things stops ... we should let the users decide what happens in that case ... we need to do it seperately for each of the 3".

Three new policy fields, recruiterHoldWhenNoneOnline, inviterHoldWhenNoneOnline and welcomerHoldWhenNoneOnline (Recruiter.HOLD_KEYS), each a toggle under its own list in Settings > Guild Policy ("Stop announcing / inviting / welcoming when nobody on the list is online"). On is stored as an absent field and off as false, so a policy saved before this reads as on and keeps its fingerprint; the fields sync like every other policy leaf. With the setting on (the default), nobody acts while no listed member is online -- on retail, while none is online in this client's realm group. AnnouncerRank returns nothing in that case instead of falling back to every member running UGI, and the three status lines now say the duty is stopped rather than open to everyone.

  • Behaviour change for every guild with a list set: after updating, with nobody on a list online, that duty stops until a listed member logs on or a policy editor switches the setting off and pushes.
  • Not held: the officer-channel leaver notice (CanPostAutoNotice), which is guild housekeeping rather than a recruiting announcement; holding it would drop the line whenever every announcer is off.
  • "Nobody available", not "nobody online" (Peer Review fcfdf356, H1): the hold fires when the election finds nobody eligible, so a listed member who stepped aside, set Never on duty, or (on retail) plays in another realm group counts as unavailable. The strings say "available" and name those cases. Consequences, each the policy working as set: on retail a realm group with no listed member stops by default; when the last available listed member steps aside, the duty stops rather than passing (not verified in a client). The welcomer setting decides even while the welcome follows the announcer list, and its tooltip says so.
  • The held messages say why (Peer Review fcfdf356, Q3): with the setting on and nobody available, the invite notice (fn.inviterHeldNotice) and Announce's skip line print new keys inviteMsgHeldNoneAvailable / announceMsgHeldNoneAvailable naming the cause, instead of "limited to the designated inviter/announcer". Specs in Tests/zz_invite_holds_spec.lua and Tests/announce_channels_spec.lua.
  • Wingman under the hold (Peer Review fcfdf356, Q1), read in Modules/Wingman.lua: with both lists set and nobody on either available, a member on neither list is off duty (OffDutyUnderPolicy), so a running session stops and the duty prompt is never offered (AmIDesignated is false with no designate). The stop line said "no longer the designated announcer/inviter", which reads as someone else taking over; when neither election names anyone it now says nobody on the lists is available and the policy holds recruiting (offDutyReason). With only the announcer list set, inviting stays open and Wingman keeps running for it, as before. Also a defect of mine found by the same read: Wingman:Start never asked OffDutyUnderPolicy, so a press while barred from both roles started a scan-only session that ran until the next roster event stopped it. Start now refuses with the same line. The existing auto-stop example started its session while already barred, so it now starts first and changes the policy under it. Specs in Tests/wingman_prompt_spec.lua; suite 4657 passed. Not verified in a client.
  • KNOWN COST (Peer Review fcfdf356, H2): only updated clients read the setting. A member on an older UGI still falls open when the whole list is unavailable. The tooltips say so.
  • A policy-sync example checks the setting switched off and then back on reaches a peer (Tests/policy_sync_spec.lua); switching on is a leaf removal and travels as a stamped tombstone. It found a defect of mine in the per-leaf policy merge: ApplyPolicy read each leaf as src and src[leaf] or nil, which turns a false value into nil, so a policy that switched a setting off arrived on every peer as on. Fixed in Modules/FGI_DeltaSync.lua; it applies to any policy field stored as false.
  • The three fall-open examples in Tests/recruiter_spec.lua now check both settings; five new examples cover the default, each setting separately, a listed member online, the leaver notice, and retail's per-realm-group case. Suite 4652 passed. Not verified in a client.

New — on retail, one inviter, announcer and welcomer per connected-realm group

Discord, Pockit (thread 8ffa22e6, 2026-10-31, Retail): one guild recruiting on Tichondrius, Proudmoore and Area 52, both factions, got ONE inviter for the whole guild, so every other realm group's recruiters sat idle. The operator, 2026-10-31: "retail has 10s of realm clusters and all of those realm clusters are connected into one global realm. the ask is for retail". eligible in Modules/FGI_Recruiter.lua now also requires, on retail, that the candidate is in this client's connected-realm group (LibGuildRoster's IsSameRealmGroup, contract 6a87c6ab, built on C_AutoComplete.GetAutoCompleteRealms). Every role reads eligible -- the elections, AnnouncerRank's per-layer order, and the baton -- so each group elects its own holders and clients in one group agree with no extra message. Gated gv.isRetail and not gv.isForever: Classic, Anniversary and MoP Classic guilds hold one realm group, so one per guild already is one per group there, and WoW Forever has no realms ("forever doesn't have realms, they have character first/last names and layers"). Three examples in Tests/recruiter_spec.lua.

  • KNOWN COST: the split is by realm group, not faction; a member's faction is not on the roster.
  • The baton is still one name per role, so on retail ApplyRemoteAssign now ignores a G or P from a sender outside this client's realm group; without it another group's assign or release wiped this group's holder (Peer Review R1, thread dbb4a7a7). No wire change: MayHold already confines an assignee to the assigner's group. A spec pins it.
  • The realm group is read once per election pass (myRealmGroup) rather than per candidate, since the library caches nothing and each answer is a client call and a sort (Peer Review R2).
  • Needs the LibGuildRoster release that carries IsSameRealmGroup. Against an older library the gate is off and retail keeps one election per guild.
  • Not verified in a client: what GetAutoCompleteRealms returns on a realm with no connections, and whether it answers at login.

Fixed — an alt in another guild used, and synced, this guild's policy and lists

Discord, Pockit (thread e3988a25, 2026-10-23, Proudmoore): a recruiter's alt in a DIFFERENT guild on the same realm and faction sent Pockit's guild-policy welcome whisper to that other guild's joiner. The policy (gmPolicy), the synced lists (blackList, blackListRemoved, alreadySended, leave) and the recruit roll-up lived in AceDB's factionrealm/realm scopes with no guild identity, so every character on the realm read one copy whatever its guild, and its sync handed it to that guild. The operator, 2026-10-31: "there should be no interaction what so ever. we should block sycn, and everything else to a guild that is not your guild or a sister guild" -- with the guild facts from LibGuildRoster.

New Modules/FGI_GuildScope.lua (all seven TOCs, before the bridge). Each scope is stamped with its owning guild (guildScopeOwner, LibGuildRoster's GetHomeGuildKey). A character whose guild is neither the owner nor one of its sister guilds (IsSisterGuildKey) puts the owner's set aside under guildStores[<owner>] and loads its own guild's set, or an empty one; nothing is deleted, and returning swaps it back. Sister guilds share the realm lists only (below). One set is live at a time, so the ~450 readers are unchanged. Resolved on LibGuildRoster's roster-ready signal, at OnEnable, before every sync exchange (OnRosterReady, RunCatchUp, OnDataRequest, OnDataReceived) and before the join handler reads the welcome policy; memoised on the home key, so the repeat calls are a string compare.

Data saved before the stamp is judged by factionrealm.guild, the guild FGI_Core has always recorded at login, captured before OnEnable overwrites it (CaptureLegacyStamp): naming another guild that is not a sister, the set goes aside under ?legacy and a later login to that guild adopts it. With no stamp, the policy author counts only when LibGuildRoster places them in another guild; an author in no roster (left, renamed, a full WoW Forever name) is no evidence and the set is claimed.

Peer Review (thread b1f74853) found three defects in the first cut, all fixed: G1, an author in no roster was read as "another guild", which would have parked a guild's own data on upgrade; G2, a sister guild's first login before the sister list arrived split the sisters for good -- a guild that started empty now rejoins a parked sister's set when the list names it, merging its own entries in (a union by key, the sister's entry winning a clash, the sister's policy standing), and OnSisterConfigChanged re-resolves; G3, the blacklist kick sweep and GuildScope both ran off OnRosterReady in no fixed order -- GuildScope now also resolves on every GUILD_ROSTER_UPDATE and PLAYER_GUILD_UPDATE, and fn:blacklistKick, its join-kick handler and fn:importUGRBlacklist resolve first.

What a guild RECRUITS WITH stays its own, even between sister guilds: the factionrealm scope (the policy, gmPolicyReceived, the recruit roll-up, memberStats, and now also messageList, curMessage and announce) never counts a sister as "here", while the realm lists stay shared with sisters. The operator, 2026-10-31: "recruiting in one sister guild should have messages for that guild, and we should have messages for our guild. that goes for inviting as well." The born-empty rejoin therefore runs only for the realm scope, and a sister's policy can no longer replace this guild's.

KNOWN COST (Peer Review 2c77612e, S2): with Settings' "Messages scope: global" on, fn.getMessageStore() serves DB.global.messageList/curMessage, which are account-wide, so those users keep one set of whisper messages across every guild. That is the setting's stated purpose and an explicit choice, so it is left as it is. The welcome texts (S1) are already per guild: since v2.11.1 the sender reads them as rows of messageList, and the old DB.global.welcomeWhisperMessage field is only the source of a one-time import whose flag sits outside the swapped keys, so a new guild's empty set does not re-import it.

Guilds are compared by NAME, not by the whole key: LibGuildRoster builds the key from the character's own faction, and a retail guild can hold both factions (Pockit, Discord 8ffa22e6: one guild recruiting on both factions across three realms), so a Horde alt and an Alliance main of one guild would have split the realm-shared lists in two. KNOWN COST: two different guilds of opposite factions with the same name on one realm would share the realm lists (whether a realm allows that is not verified).

KNOWN COST: a guildless character keeps the last loaded set (GetHomeGuildKey is nil both when guildless and while guild data loads, and swapping on the second would stash a guild's data under no owner every login). It cannot invite or sync, and the forced announcements now answer nothing while IsInGuild() is false (Announce.lua getPolicyProfiles), so it no longer posts that guild's message. A pre-stamp realm store (shared by both factions) is judged by the stamp of whichever faction resolves it first. Specs: Tests/zz_guild_scope_spec.lua. Not verified in a client.

Fixed — "Disable all sync" still received and answered

fn.syncDisabled was checked only on our own sends (live pushes, the catch-up). Bridge:OnDataReceived merged a guildmate's live push and Bridge:OnDataRequest answered a guildmate's query with our lists while the switch was on. Both now return first when it is on, as its tooltip always said.

New — guild policy: accept shared lists only from members who can invite

An Enforced setting (gmPolicy.syncInvitersOnly, Settings > Guild Policy, policy editors only, pushed with the policy). While the policy is active and it is on, Bridge:SenderMayShareLists drops the sent invites, blacklist, removals, leavers, recruit history and officer totals from any sender whose rank cannot guild-invite, as LibGuildRoster's CanInvite answers it (contract 332fcbb6, LibStub MINOR 38, not released yet). The policy itself and alt groups are not governed. An unknown answer (nil, or a LibGuildRoster without CanInvite) is let through, so the guild's sync does not stop until the library reaches every client. Not verified in a client.

Fixed — imported blacklist entries carried no blacklister

fn.newBlacklistImport (the GRM and /ignore importers) wrote { reason, time }, so an import synced to the guild with nobody's name on it. New imports record the importer; a refresh keeps the first one, as fn:blackList does. Entries imported before this keep their blank.

Changed — the auto-import tooltips say imports are shared

"Auto-sync GRM blacklist on login" and "Auto-sync GIL list on login" never said the imported names reach every guildmate, so a member could not make that choice knowingly. Both now end with a separate line (IMPORT_SHARED_WITH_GUILD), kept apart from the first sentence so its 31 translations survive.

Fixed — the "Only my invitees" tooltips described the old gate (welcome audit W5)

Both said a joiner counts as yours when "their name is in your Anti-Spam list"; the gate has been the inviter stamp since v2.20.3, and the elected welcomer under a guild welcomer list skips it (fn.welcomeGate). Both now say so, under new keys (WELCOME_ONLY_MINE_DESC, WELCOME_WHISPER_ONLY_MINE_DESC). The old keys and their 62 translations, which described the old behaviour, are removed; those languages show the English text until translated.

Changed — the recruit-history sharing tooltip

It said history is shared "with other officers"; it is shared with every guild member running UGI that has sharing on. The text now says "guild members".

[v2.20.3] (2026-10-28) — one welcome per joiner with several recruiters

Discord, Pockit (thread 49cf2c55, 2026-10-27, Retail and TBC Anniversary): two recruiters, no Designated welcomer list, guild policy enforcing the welcome, "Only my invitees" relied on -- and every joiner got a welcome whisper from each of them. A code audit of the whole welcome path (Peer Review threads 20f2e2d6, 55b730ea and b067ac12) found several ways one client counted a joiner as its own invitee, or skipped "Only my invitees", when it should not have. We think one of them is this guild's issue; which one is NOT established, as each depends on a setup fact not yet known. The changes below are in and pass the suite. Not verified in a client: the operator cannot run two recruiters, so this release is the test and the reporting guild is asked to help.

Fixed — "Only my invitees" was skipped when nobody on the welcomer list was online (audit W1)

With a guild policy holding an announcer list and no welcomer list, the welcome borrows the announcer list (Q4), and any welcomer policy made the join handler skip both "only my invitees" gates. When nobody listed is online the election falls open -- Recruiter:CanWelcome is true for everyone and nobody is the designate -- so every member running UGI welcomed every joiner whatever their boxes said.

  • FGI_Core.lua (OnMemberJoined) and fn.welcomeGate now skip the gate only for the member this client elected (Recruiter:AmIDesignatedWelcomer), never on a fall-open.

Fixed — a refused invite recorded us as the joiner's inviter (audit W2)

"X has already been invited to a guild" was classified as auto_decline and promoted with ownInvite, so a recruiter whose invite was REFUSED because another recruiter's was live was stamped as X's inviter, and both welcomed X. "X is already in a guild" did the same.

  • New outcome already_invited (Modules/Scan.lua, constant and English fallback). It is counted and anti-spammed as before; it, in_guild and the nameless refusals (handleGuildReject: trial, veteran, opposite faction -- Peer Review b067ac12 F2) promote with the new "notOurs" mode, which keeps a peer's stamp and otherwise leaves inviter nil. A genuine auto-decline still stamps us.
  • fn:promotePendingToAntiSpam(name, how) takes "ours" (default), "shared" or "notOurs"; fn:rememberPlayer gains noInviter.

Fixed — pressing Decline on a queue row recorded us as the inviter (audit W3)

invitePlayer(true) called rememberPlayer with no inviter, which defaulted to us; the stamp synced first-writer-wins. It now passes noInviter.

Fixed — an old unanswered invite claimed someone else's recruit (audit W8)

The join handler counted any pending invite this client held, and recordAccept promoted it with ownInvite. A join line names the joiner, not the inviter, so an invite sent long ago proved only that we once asked.

  • fn.pendingInviteLive(key): the recruit's GUILD_INVITE pop-up declines itself after its timeout (StaticPopup_OnUpdate -> CancelAndHideDialog "timeout" -> OnCancel DeclineGuild, timeout = 60 in the classic_era tree), so an entry older than that plus fn.INVITE_LIFETIME_SLACK (15 s, an allowance for the two network legs, not measured) cannot be the accepted invite. Read from the client's own dialog table; the live retail tree defines no such pop-up, so there it answers nil.
  • The join handler counts a pending invite only when it is live, or -- its age unknown -- when no stamp names someone else (Peer Review b067ac12 F1: the first cut still counted an unknown-age invite over a peer's stamp, so on retail the welcome itself was not fixed). recordAccept promotes a live one as "ours" and anything else as "shared" (a peer's stamp wins).
  • KNOWN COST: on live retail the lifetime is unknown, so a recruiter whose stale invite is promoted before the inviter's stamp reaches them can still stamp themselves there and welcome.

Fixed — a sister guild's join in the login window could be welcomed (audit W9)

GuildEvents:IsHomeGuildEvent answered true while the home guild key was unresolved, and LibGuildRoster's sister-feed refusal compares against that same nil. It now refuses a key LibGuildRoster:IsSisterGuildKey names.

Changed — a login template names the welcome settings it changed (audit W7)

A character following a login template re-applies it at every login, so a template saved with "Only my invitees" off un-ticked it on every login, for the whole account, with only a settings count in chat. SP:Apply now returns the changed welcome entries, ApplyAtLogin passes them on, and addon.ReportLoginTemplate prints one line naming them.

Fixed — "Only my invitees" could not be ticked under a guild-enforced welcome

The Settings panel greyed both gates out on the member's own welcome toggle, though a forced welcome sends with that toggle off and still honours the gate. They are live while the guild enforces the welcome. This was first written up as the cause of the report; it is not established as that -- in the earlier report (thread e3988a25) the poster said both boxes were ticked, and a greyed box keeps its value.

Not changed, on purpose

  • W4 (the welcomer election never checks that the elected member runs UGI): gating the election on Recruiter:IsRunningFGI would make clients disagree, because presence is only noted when a peer's sync message happens to reach us -- two clients electing two different designates is exactly a double welcome. Left as is.
  • W5 (the "Only my invitees" tooltips still describe the anti-spam list): the tooltip text is the locale key in all 31 locale files, so rewording needs a re-key across all of them; deferred.

Tests

  • New Tests/zzzz_welcome_audit_spec.lua, 32 examples, including the two-recruiter runs Peer Review asked for (each client's own name and copy of the shared list): only the inviter's client welcomes; a joiner invited by neither is welcomed by nobody; the elected welcomer greets everyone; B's expired invite does not make B welcome A's recruit.
  • Re-baselined with the reason: zz_scan_outcomes_spec (three labels now already_invited), zz_welcome_gate_spec (the bypass is for the elected welcomer; a fall-open now reads "gated").
  • Found while running every order: zzzz_created_globals_spec did not accept SLASH_FGIDELTASYNC2 (the /fgids alias) as order-dependent like its sibling; zz_settings_panel_spec and zz_blacklist_form_spec and zz_chat_tooltip_spec read state an earlier file left. Each fixed.
  • Seen red against the old code (Peer Review b067ac12): with the W1, W2 and W8 conditions put back one at a time, the two-recruiter W1 examples, the W2 'already invited' examples and the W8 join examples failed; restored, all pass.
  • Whole suite 4621 passed, 0 failed, 11 pending in default and reverse order (4617 in all five orders before the F1/F2 follow-up).

[v2.20.2] (2026-10-27) — welcomes wait for your click on retail

Changed — welcomes always wait for a click on live retail, as on WoW Forever

Discord, DADDY (thread 13a0d741, 2026-10-27): on live retail the client blocked a timer-sent welcome, and the v2.14.9 safety net printed "the game blocked a welcome sent automatically". The automatic send (welcomeAuto, default on since v2.14.6) had only ever been switched off for WoW Forever, on the belief that live retail did not block it. Live and Forever document C_ChatInfo.SendChatMessage identically (HasRestrictions, RestrictedForMacroChatMessages, SecretArguments = "AllowedWhenUntainted", both trees pulled 2026-10-18), and both now have a field block, so the guard moves from isForever to isRetail, which covers both.

  • Welcome:AutoSend() is always false on the retail client; every welcome waits for the next click. Classic-family clients keep the setting.
  • The Settings box "Send welcomes automatically" is shown unticked and disabled on retail as well, and its tooltip says so.
  • What changed to surface this: before v2.14.6 welcomes always waited for a click, so no timer send existed to be blocked. Nothing in the welcome send path changed in v2.20.0/v2.20.1 that would cause it (only the "UGI" tag, and a combat hold that makes the timer more cautious). Why the retail client blocks a GUILD or WHISPER line from a timer, when the wiki's chat rule says it should not, is still not established.
  • Cost: retail players who wanted welcomes to go out without a click (DADDY asked for that on 2026-09-26) lose it there.
  • Spec in Tests/welcome_spec.lua ("never auto-sends on live retail"), and a Retail check on the Settings box in Tests/zz_forever_names_spec.lua. Whole suite 4587 passed, 0 failed, 11 pending. Not verified in a client.

[v2.20.1] (2026-10-26) — /ugi commands, recruiting pauses in combat, and whisper and welcome fixes

Fixed — the recruit-reply alert sounded for a player already in the guild

Discord, Bronzefinger (thread a7dce058): the alert played, but no tab opened and the whisper could not be found in a busy main chat. The alert (Modules/WhisperAlert.lua) fired on the first reply from anyone FGI had whispered, with no guild check, while the compact tray opens no tab for a guildmate (WT:IsRecruit, Modules/FGI_WhisperTabs.lua). A recruit who accepted and then whispered "ty" therefore sounded the alert with nothing to show.

  • The alert now follows the tray's rule: a reply from a player fn:isPlayerInMyGuild reports in our guild plays no sound, flashes neither the screen nor a tab. The reply is still recorded on the recruit and the wait still ends, so their next whisper does not alert either.
  • Cost (operator approved, 2026-10-26, "go ahead"): a recruit who joins mid-conversation stops alerting from that point, as the tabs already do.
  • Spec in Tests/zz_whisper_alert_spec.lua. Whole suite 4583 passed, 0 failed, 11 pending. Not verified in a client.

Fixed — "Only my invitees" welcomed other recruiters' invitees too

Discord, Pockit (thread e3988a25): two recruiters in one guild, both with the welcome whisper's "Only my invitees" on, and every joiner got both welcome whispers. The gate in FGI_Core.lua asked only whether the joiner was in DB.realm.alreadySended, which DeltaSync shares across the guild's recruiters, so each recruiter held the other's invitee and welcomed it. The guild-chat line was de-duplicated by the peer suppression in Modules/FGI_Welcome.lua; whispers have no equivalent.

  • iInvited now reads the entry's inviter stamp, which fn:rememberPlayer writes first-writer-wins and DeltaSync carries (alreadyEntryToObj, Bridge:ApplyEntries). A joiner counts as this character's only when the stamp names this character (compared through fn:fullPlayerName, so a bare and a realm-qualified spelling agree), or when the invite is still in this character's session-only pendingInvites. The same gate serves the guild-chat welcome's "Only my invitees".
  • An entry with no stamp (a legacy bare timestamp, or one from a peer too old to send it) no longer counts as ours. The old comment calling the shared reading intended is replaced.
  • Unchanged: a guild welcomer policy still bypasses the gate for the designated greeter.
  • Only the character that sent the invite counts. An invite sent on an alt does not make the main a welcomer for that joiner (operator, 2026-10-26: "it's a super edge case that i don't think we need to solve").
  • Peer Review F1 (thread c85834e3): fn:rememberPlayer gained an ownInvite argument, passed only by fn:promotePendingToAntiSpam. Our own pending invite is proof of who invited, so it overwrites a stamp a peer left earlier (they invited first, the player declined, we re-invited and they accepted). Sync stays first-writer-wins, so the peer's copy keeps its own stamp and in that rare case both clients now welcome (before, only the peer did). The rememberPlayer header no longer says the wire is a bare timestamp: that is only the legacy broadcast; the DeltaSync bridge carries the inviter.
  • Peer Review F5: fn.welcomeGate's comment claimed it called the sender's own functions; it restates the join handler's gate order. The comment says so now, and a spec pins the two together over every toggle, only-mine and text combination for both welcomes.
  • Specs in Tests/zzzz_core_spec.lua: a peer's invitee is not welcomed, an unstamped entry is not ours, either spelling of our own name matches, a peer's entry applied through the DeltaSync bridge is not ours, and our re-invite of a peer-stamped player is ours when promotion runs first. Whole suite 4586 passed, 0 failed, 11 pending. Not verified in a client.

Fixed — with several level filters, a full scan was re-run instead of split

Discord, SiliconSerenade, 2026-10-24 (retail v2.20.0): filters at 10-80, 81-89 and 90, every subdivision axis on, and the chat showed "Query: 10-90. Returned players: 50" followed by the same 10-90 query again. isQueryFiltered skipped a query as soon as ANY whitelist (schema 2) filter rejected it, while whitelist filters combine with OR. With the 50 rows piled at the level cap the cut is 10-89 / 90-90; each half missed some filter's range, both were dropped, the queue emptied and the next press rebuilt the pass from 10-90, so nothing below the cap was ever narrowed.

  • A query is now skipped only when EVERY active whitelist filter rejects it. Deny-list (pre-schema-2) filters keep their rule: any one excluding the query skips it. The progress denominator (queryWorstCaseCost) asks the same function, so it follows.
  • Tests/zz_split_filters_repro_spec.lua drives the reporter's three filters through the real callback, with rows spread and with rows piled at the cap, ascending and descending.

Changed — the slash commands are /ugi, /ugibl, /ugidebug and /ugids

Operator, 2026-10-21: "did you not update the /fgi commands to /ugi?". FGI_Core.lua registers ugi, UltimateGuildInvite, ugibl and ugidebug; Modules/FGI_DeltaSync.lua makes /ugids its first spelling. The fgi, FastGuildInvite, fgibl, fgidebug and /fgids spellings stay registered as aliases so macros keep working; nothing player-facing names them any more.

  • Every /fgi in shipped text became /ugi: help printers and chat lines, settings descriptions, the locale keys and their values in all 31 locale files together (so each lookup still matches), README and the CurseForge page. The v2.20.0 release note on that page keeps /fgi, which was true when it shipped.
  • Kept: the !fgi officer-chat command (other clients still send and answer it), and the FGI Debug chat-tab name.
  • Missed until Peer Review G3 (thread b4050289): the DeltaSync manual catch-up line still said /fgi debug; it says /ugi debug now.
  • WT:IsRecruit reads the recruit record before asking whether the player is in our guild (Peer Review G4), so a whisper from somebody FGI never recruited no longer walks the roster.

Fixed — a recruit's first reply opened an empty whisper window beside the tray tab (Classic family)

Operator, 2026-10-23, with whispers set to open their own windows: "it's going to compact, but also opening an blank whisper tab ... it shouldn't be opening both". The game's chat manager opens the window for an incoming whisper before UGI's listener records the line, so the window hook in functions.lua could not tell which side the window was for and treated every one as an outgoing line. An unanswered recruit's outgoing line is deliberately not captured, so the window opened for the recruit's FIRST REPLY was never closed: the reply went to the tray tab, the window stayed empty.

  • fn.noteWhisperSend is a secure post-hook on C_ChatInfo.SendChatMessage (and on the bare global when it is a separate function) that counts one owed echo per whisper sent to a player. fn.takeWhisperSend spends one when the game opens a window for that player: that window is the outgoing side. A reply finds nothing owed and its window is closed. An unspent send lapses after the 60-second echo window, and a second ask in the same frame gets the same answer, so a stacked hook cannot spend two.
  • Tests/zz_functions_spec.lua: the "recruit who has not answered yet" case now sends first, as the client does; new cases pin the first-reply close, per-player counting and the lapse. The spec re-installs the window hook over the unhooked function rather than stacking one per example.
  • Peer Review (thread b4050289) on the above: only ONE send function is hooked now, the namespaced one where it exists (G1). Where both exist the global is the deprecation wrapper that calls it (Era Deprecated_ChatInfo.lua:8-10), so hooking both counted a send through the global twice and a reply's window read as outgoing. The same-window cache is keyed on the window the game just opened rather than on the time (G2), so a second echo's window in the same frame spends its own send, and on the window AND the frame (G2b), because the client reuses a closed temporary frame for the next window, so a recruit's reply can arrive in the very frame our whisper used. The per-player key also folds accented Latin and Cyrillic capitals and drops spaces from a typed realm (G5): string.lower folds ASCII only, so a whisper typed to one spelling and the echo's other spelling made two keys and the send was never spent. New spec drives a send through a client-shaped global wrapper; the per-window spec now asks about three distinct windows.
  • Not changed on Retail, which has no window hook (closing a game window from addon code there taints the chat router). Retail still shows the known empty window for a first reply in popout mode. Not yet verified in a client.

Changed — recruiting pauses in combat, every mode (functions.lua)

Operator, 2026-10-23: "when i'm in combat, and a whisper goes out, it's one case where i get the blank whisper tab", then "why wouldn't we just make it so we 'pause' during combat? that's a protected state, and we probably shouldn't be doing anything during it?"

  • fn.inCombatPause (InCombatLockdown()) makes fn.recruitPausedByProtection true in EVERY invite mode, Invite Only included, so scans (fn:nextSearch), invites (fn:invitePlayer, Wingman's drain) hold and the candidate stays queued. The chat-messaging-lockdown half is unchanged: there Invite Only still works.
  • fn.pumpClick drains nothing in combat, so a queued welcome or decline whisper waits for the first click after combat ends. Nothing is scheduled; the resume is that click.
  • The welcome's auto-send timer is held in combat too (Peer Review G9, thread b4050289): Modules/FGI_Welcome.lua folds fn.inCombatPause into Pump's locked and into ScheduleAuto's retry test, so the timer re-arms on its existing lock retry and the welcome goes out after combat.
  • fn.protectedPauseNotice says "you're in combat" in combat, behind the same "show hold warnings" setting (off by default) as before.
  • Announcements pause in combat too (operator, 2026-10-23: "yes", to "Should guild announcements also pause in combat?"): Announce:Send returns "combat" before gathering anything, so nothing is stamped and the first press after combat posts; an explicit press prints announceMsgHoldCombat.
  • Tests/zz_invite_holds_spec.lua pins the pause in every mode, the resume and the pump hold; Tests/announce_channels_spec.lua the announcement hold. Not yet verified in a client.

Fixed — the "is this a guildmate" check trusted one source

Field report (operator, 2026-10-23): Icekandrian-Myzrael was contacted a second time 12 seconds after joining the guild off our first invite (the server answered "already in a guild"). The saved history shows the join was real, so what sent the second contact is still open on the todo list. Reading the path turned up two gates that trusted a single source, and both were mine:

  • fn:isPlayerInMyGuild treated a false from C_GuildInfo.MemberExistsByName as final and only consulted LibGuildRoster on a client without that API. It now asks LibGuildRoster's IsInGuild whenever the client call says no; that function scans the live roster itself before the library is ready, so the old IsReady gate is dropped.
  • fn:addNewPlayer judged a /who row by its Guild field alone. It now also drops (and evicts from the queue) a row whose name is on our roster, so a guildmate the /who answer shows guildless never reaches the queue.

Debug logging was off, so neither row's /who Guild field was saved. A second report the same day (Fionnbar-Myzrael, in another guild) is the any-guild case and is not covered by these two changes. Specs in Tests/zz_invite_spec.lua pin both gates. Not yet verified in a client.

Changed — harness pin: FCF_Close frees a temporary window (Tests/wowapi)

  • Tests/wowapi pinned from af16344 to 11f9ff2 (WoWAPITesting thread 20221b98, which FGI raised). The harness's FCF_Close now clears inUse and isRegistered on a temporary window, as the client does (Classic Era FloatingChatFrame.lua:1449-1453).
  • The zz_functions_spec.lua setup that closes leftover whisper windows now selects them by inUse, the client's own marker, instead of isDocked, which stood in for it while the harness kept a closed window in use. openWhisperWindowFor in functions.lua keeps its isDocked check: it is still correct, because FCF_Close undocks.
  • Whole suite 4579 passed, 0 failed, 11 pending (run 20261026-092448.993-1581351258).

[v2.20.0] (2026-10-21) — renamed Ultimate Guild Invite, a new minimap icon, and UGI chat headers

Changed — the addon is now called Ultimate Guild Invite in game (TOCs, window titles, settings, minimap)

The CurseForge project was renamed Ultimate Guild Invite (same project id, new slug). The in-game name surfaces follow it; the folder, TOC file names, SavedVariables, slash commands and every registration name stay FastGuildInvite / FGI until writ can migrate the folder (operator, 2026-10-21).

  • All seven TOCs: ## Title now reads Ultimate Guild Invite (the AddOns list and the addon compartment).
  • Window titles: main window (GUI/MainWindow.lua), the legacy window, the dump window and the what's-new popup.
  • GUI/SettingsPanel.lua: a new DISPLAY_NAME is the name under ESC > Options > AddOns and atop the panel; CATEGORY stays the AceConfig app name. Opening goes by the opaque category ID, so it is unaffected.
  • L["FastGuildInvite"] (dialog titles) now reads Ultimate Guild Invite, and the five or fallbacks beside it match.
  • FGI_Core.lua: the minimap button uses the new img/minimap/UGI_MMB_Icon art with TOGProfessionMaster's crop for the same disc-with-margin shape (iconCoords = { 0.135, 0.825, 0.105, 0.795 }), and its LibDataBroker label reads Ultimate Guild Invite. The object name stays FGI, the key the saved minimap position hangs off.
  • Every chat header now reads UGI (operator, 2026-10-21: "we do need to switch these, and any debug prints, channel prints and anything else that had the FGI header"): FGI_CHAT_PREFIX and its literal copies (|cff16ABB5UGI|r), the orange <UGI> announce/invite/contact-log tags, the plain <UGI> officer-channel and import lines, the [UGI] debug and version-detection prints, and the UGI: unit tooltip line. The locale keys that carry a tag were changed in all 31 locale files together so every lookup still matches; bnIN's Bengali-script tag became <UGI> too. The FGI Debug chat-tab name is kept: functions.lua uses it to find a tab the client has already saved.
  • Tests/zz_chat_prefix_spec.lua now also fails if any shipped file still carries the old <|r|cff16ABB5FGI prefix; the specs that pinned output text were moved to UGI, each with a new check that the old tag is gone.

Changed — the rest of the in-game text names the addon UGI (locales, menus, tray, slash output)

The operator, 2026-10-21, on the CurseForge page and README still saying FGI: "we need to update all of this to UGI, we're rebarnding", then "just edit the code". So every player-visible "FGI" and "FastGuildInvite" in prose became UGI / Ultimate Guild Invite, not only the headers.

  • All 31 locale files: values and keys together, so every lookup still matches. The transliterated names went too: ruRU ("Фги", "Фастгуилдинвите", and "Еск" for ESC, 46 lines), bnIN, hiIN, mrIN, taIN, and the letter-by-letter paIN, teIN and thTH files (their long name and their short FGI). ruRU's compactToggleTooltip described an older button and is retranslated from enUS.
  • Right-click menus: the chat submenu (Modules/FGI_ChatMenu.lua) and the legacy dropdown (FGI_Core.lua) read UGI, UGI - Guild Invite, UGI - Blacklist / Black List, UGI - Unblacklist; /fgi menulog lines say "UGI added nothing" / "UGI submenu added".
  • The compact tray's tag, help and gear tooltips (Modules/compactFrame.lua); the chat-name tooltip footer and its two fallbacks (Modules/FGI_ChatTooltip.lua); the debug window title (Modules/debug.lua); the dialog title fallbacks (Modules/FGI_Dialog.lua, Modules/Wingman.lua); /fgi perf, /fgi debug, /fgi vcheck, /fgi escstate, /fgi kbstate, /fgi clearwhispers, the unknown-command line, the race backfill summary, the template send failure, the protected-content pause notice and the held-queue notice.
  • The bracketed debug tags: [UGI scan-debug], [UGI cd-debug], [UGI decline-debug] and LibWho's [UGI libwho-debug] (Libs/LibWho, FGI's own modified copy).
  • Kept on purpose: the FGI Debug chat-tab name (an existing saved tab is found by it), /fgi, frame and global names, and every registration name.

Fixed — locale font paths pointed at a folder that does not exist (Locale/enUS.lua, ruRU, zhCN, zhTW)

The first rename pass replaced "FastGuildInvite" inside the Font paths too, giving Interface\\AddOns\\Ultimate Guild Invite\\fonts\\.... The folder is still FastGuildInvite, so those fonts would not have loaded. Unreleased, never in a client; restored. Found by Tests/zz_locale_escape_values_spec.lua by accident, so two guards were added:

  • New Tests/zz_addon_paths_spec.lua: every Interface\\AddOns\\<folder>\\... path in shipped code must name a file that exists in this addon's own folder.
  • New Tests/zz_locale_brand_spec.lua: wherever the enUS value names the addon, each translation of that key must name it as UGI or Ultimate Guild Invite. This is what found the paIN, teIN and thTH spellings that a search for the old spellings missed (144 rows on its first run). A second example fails on any locale value still carrying the Latin FGI or FastGuildInvite, outside the kept FGI Debug tab name and file paths. Both new guards were seen red on a deliberately broken value before being trusted.
  • Tests/zz_chat_prefix_spec.lua (and the new path spec) now read the files the TOC loads through XML. Before, the locale files arrive through Locale\Locale.xml and were never swept, so the "no old <FGI> prefix left" check added in the first pass covered the code but not the 31 translations.
  • Peer Review's G3 (thread 92a2ff62, a spec that every literal lookup is an enUS key) already existed as Tests/zz_locale_semantic_keys_spec.lua; it and zz_locale_orphans_spec.lua caught the five stale lookups the first pass left (compactFrame.lua x4, FGI_Core.lua template-too-new).
  • Whole suite: 4561 passed, 0 failed, 11 pending (tool: desk test run 20261021-211036.348-1063778939).

Fixed — v2.19.0's notes overstated the main-window fix (docs/Curseforge_Description.html)

v2.19.0's player notes (CurseForge page, Discord post) said opening and closing the main window many times "no longer slows its resizing down". The fix stopped MainWindow:Open from hooking the frame's OnSizeChanged again on every open; a slowdown was never measured in a client (see the v2.19.0 GUI/MainWindow.lua entry). The CurseForge line now says what the fix did. Carry one Fixed bullet into the next release's player notes correcting it (Peer Review a84fc227, N2).

Changed — the suite fails an example whose callback error was only recorded (Tests/wowapi, writ suites)

Since v2.19.0, Modules/FGI_Dialog.lua runs button, Escape and waiter callbacks through xpcall(fn, geterrorhandler()), so a raising callback no longer unwinds the spec; the harness's default handler only records it, and run.lua never read that record. A spec whose dialog callback raised could therefore pass. Asked of WoWAPITesting on inbox 75ad335a; delivered as --fail-on-errors.

  • Tests/wowapi pinned from 8391c1a to af16344. The Adoption entries in between all read "ADOPT: nothing"; this repo passes no --timeout 0 and sets no wow.errorObserver (tool: git grep, 0 matches).
  • New .writ-suites.json at the repo root declares the five gate suites (default, reverse, shuffle 12345, 42 and 1), each passing --fail-on-errors, plus the two NOT-A-GATE suites (--times, Elune taint) and a suppression of .busted. Until now these lived only in writ's bank, which does not travel with the repo (Peer Review cb5801d0, N2); the bank copies are removed so the file is the one source. Writ's built-in suite for Tests/wowapi/run.lua (no flag) outranked the declared ones, so a bare desk test call still ran unflagged (Writ thread 41bffce5, citing its internal/checks/suites.go:179-234). The file now suppresses that built-in marker, so the bare call runs the first declared suite: a bare {"tool":"test"} ran the flagged default order, 4558/0/11 (run 20261020-202701.352-2801855080).
  • Whole suite with the flag, default order: 4558 passed, 0 failed, 11 pending (tool: desk test run 20261020-200351.995-744097465). Nothing turned red, so no spec needed fixing. The declared gate suites, run after the red check was restored: reverse 4558/0/11 (run 20261020-200721.504-3542436441), shuffle 12345 4558/0/11 (run 20261020-200903.480-2448746896), default 4558/0/11 (run 20261020-201014.135-465105204), shuffle 42 4558/0/11 (run 20261020-201436.857-1185629852), shuffle 1 4558/0/11 (run 20261020-201613.937-1016130607), each with --fail-on-errors on its command line (tool: background agents' reports of those run files). From .writ-suites.json itself, default order: 4558/0/11, argv lua Tests/wowapi/run.lua --timeout 300 --fail-on-errors (run 20261020-201949.266-3888893522).
  • Red check: with the handler swap removed from Tests/dialog_spec.lua's "keeps the rest waiting behind a dialog a failing waiter opened before it raised", that example failed with the default error handler recorded 1 error(s) ... dialog_spec.lua:853: broke after opening (tool: desk test run 20261020-200518.239-2068100860); the line is restored.

[v2.19.0] (2026-10-20) — "Let FGI manage whispers", whisper and alert fixes, and prompts that wait behind an open window

Fixed — "Hide outgoing whisper echoes" hides only FGI's own whispers (functions.lua, Modules/WhisperAlert.lua, Modules/FGI_WhisperTabs.lua, Modules/Scan.lua, GUI/SettingsPanel.lua, FGI_Core.lua)

Pockit on Discord, 2026-10-18, with the setting on: whispers Pockit typed by hand to someone FGI had whispered showed nowhere (Pockit's claim, thread e85a35f1). The operator's answer, same day: "only hide FGI's whispers". Before, addon._outgoingWhispers[key] was a presence flag: once FGI whispered someone, every whisper to them was hidden, hand-typed or not, until they replied, /fgi clearwhispers, or /reload.

  • The record is now { pending, counted, sentAt, lastLine, consumedAt }. fn.refreshWhisperHideDeadline adds one to pending per chunk FGI sends (it is called once per chunk) and stamps sentAt; a record older than WHISPER_ECHO_WINDOW (60 s) starts over, so echoes owed by chunks that never echoed do not carry.
  • One window for every "FGI just whispered X" record: WHISPER_ECHO_WINDOW replaces FGI_WHISPER_HIDE_WINDOW, FGI_WHISPERED_WINDOW and WHISPER_MODE_FALLBACK, all three 60 s, so the echo-hide record, _fgiWhispered (fn.hideNotFound) and the Retail whisperMode hold cannot disagree about when FGI's whisper is over (Peer Review F5, thread 6f5656de).
  • Every write prunes expired rows of both tables (pruneWhisperRecords). Before, an expired record went only when the same name was looked up again, so the tables kept every name whispered in the session (Peer Review P2).
  • New fn.consumeEcho(name, msg, lineID) does the work: it hides one echo line per owed chunk, then lets lines through. The filter runs once per chat frame for the same line, so lines are told apart by lineID (arg11; fallback text plus GetTime()) and a repeat of the line just consumed is hidden again without counting. The per-frame over-count is what broke the v2.1-v2.3 counter (doc: CHANGELOG_ARCHIVE.md:16162). Echo text is not matched, because retail normalises it.
  • Both filters that can see an outgoing whisper call fn.consumeEcho: fn.hideWhisper and the compact tray's capture filter (WT.chatFilter). Blizzard stops the filter chain at the first filter that returns true, so before this a tray conversation swallowed FGI's echo without spending it, and the player's next typed line was hidden in its place, with the tray no longer capturing it either (Peer Review P1; derived by reading the filter order, not reproduced against the old code). The lastLine repeat check means a line both filters see is spent once.
  • The other player on a CHAT_MSG_WHISPER_INFORM line is now read from arg2, as Blizzard's window manager reads it (chatTarget = tostring(select(2, ...)), FloatingChatFrame.lua:2351 in the classic_era, classic and classic_anniversary trees, :2515 live, :2861 forever), falling back to arg5 when arg2 is empty (fn.whisperInformTarget, used by fn.hideWhisper, the tray filter and the tray listener). fn.hideWhisper read arg5 alone until now (Peer Review P4, 01d60a5b). Which field the live server fills is not checked with /etrace.
  • fn.dropEchoRecord, called from Scan.lua's not-found branch before handleNotFound: when the server says the player is not online, their record goes, so the echoes it still owed cannot hide the player's own next line to them (Peer Review F1). A bare not-found name is keyed by fn:fullPlayerName, so a cross-realm player whose realm this client has not learned is not matched and their record runs out after the window instead.
  • /fgi clearwhispers goes through fn.clearEchoRecords and counts only records still hiding anything (inside the window with an echo owed), and its message no longer names an internal table.
  • Retail popout hold: each record remembers how many of its chunks were counted onto _pendingEchoCount (rec.counted; fn.suppressPopoutForSend now says whether it counted), and gives back exactly those when it goes (not found, expired, cleared) through one local, forgetEchoRecord. fn.consumeEcho releases one only for a counted echo. fn.notifyEchoSuppressed(n) is the one place the count comes down. Before, a record's uncounted chunks (sent while whisperMode was inline) drained the count, so the restore came early and another player's echo opened a popout tab (Peer Review a8bc9c5a F1, F3). fn.forceRestoreWhisperMode zeroes every record's counted with the count.
  • With "Let FGI manage whispers" off, a recruit's reply drops their _recruitAlertPending entry, so switching back on does not alert on a second reply to a whisper sent before (Peer Review P3). The switch's own getter now goes through fn.managesWhispers() too.
  • fn.hideAwayResponse (AFK/DND) hides only inside the 60 s window after FGI's last chunk.
  • The classic-family FCF_OpenTemporaryWindow hook closes the game's whisper window only while an echo is owed, or when the filter consumed one in the same frame (their order for one line is not fixed). A whisper the player types after FGI's echoes arrived keeps its window.
  • A reply no longer clears the record (Modules/WhisperAlert.lua); the record runs out on its own. Retail echoes can arrive after the reply. Peer Review N1 (thread 8b25d14f): nothing clears _outgoingWhispers on a reply any more, so a reply to the welcome cannot drop an echo it still owes. Switching "Let FGI manage whispers" off now drops every record through fn.clearEchoRecords() (the Settings setter, before updateWhisperEchoFilter), because no filter spends them while it is off, and an owed echo would otherwise hide the player's own next line if the switch went back on inside the window. Spec: zz_manage_whispers_spec, red 20261019-130759.357-2282622507, green 20261019-131616.734-89411853.
  • Deleted addon.removeMsgList and addon._whisperSentRecently: written, never read. fn:resetSettings no longer re-creates them.
  • KNOWN COST: a line the player types to someone within 60 s of FGI's last chunk to them, while one of FGI's echoes is still owed (for example the recipient went offline, so it never echoed), is hidden in its place. /fgi clearwhispers clears it. Inside the same window an AFK/DND reply is hidden even to a hand-typed whisper, and the Classic window hook can close the window a recruit's reply opened.
  • KNOWN COST: the popout hold assumes a record's uncounted chunks echo before its counted ones, which holds for the order they are sent in. If Retail delivers echoes out of order, one release lands on the wrong echo; the hold ends at most WHISPER_ECHO_WINDOW early or late.
  • KNOWN COST: an echo still in flight after /fgi clearwhispers can open a popout tab on Retail, because the clear gives the hold back at once.
  • Specs: the echo-hiding blocks of zz_functions_spec, plus zz_manage_whispers_spec, zz_whisper_popout_spec, zz_forever_names_spec (lineIDs now passed), zz_whisper_alert_spec and zz_reset_settings_spec. The popout-hold examples (counted chunks on drop and on consume, expiry release on lookup and on prune, counted reset across a second flip) were each seen red by temporarily disabling the code they pin in the source, then reverted (desk test runs 20261019-103040.141-3396492779, 20261019-103910.975-1127492149, 20261019-103922.309-3703406280, as reported on Peer Review threads e2a3e5bd and 00a5db4e); green after the revert, 70 passed with the settings-profile spec (20261019-104432.703-1708110303). The first full-suite run then failed the /fgi clearwhispers example (3 cleared where 2 were made): a record an earlier spec file left inside the window was counted. That block now clears the records before each example. Full suite 4480 passed, 0 failed in default order (tool: desk test run 20261019-105145.126-1977077133) and in shuffled order 12345 (20261019-105321.350-3495020016); functions.lua 3538/3538 lines covered (20261019-105715.067-1880496984). Not tested in game.
  • NOT SEEN RED (Peer Review R1): these examples pass, but none was ever run against code with its behaviour taken out, so they are not proven to catch its loss. In zz_functions_spec: "drops other players' expired records whenever FGI whispers anyone" (P2), "forgets the echoes owed to a player the server says is not online" (F1), and that example's other-player premise with fn.dropEchoRecord removed (P7). The P1 tray-filter fix was derived by reading and never reproduced against the old code. From Peer Review thread a26f2642, the items it named "one-spelling" and P3 (the reply that drops _recruitAlertPending while "Let FGI manage whispers" is off) were not seen red either.
  • Test isolation: that clearwhispers failure was echo state leaking between spec files, because the test loader puts the real addon's tables back by reference. Tests/support/addon.lua now registers a guard as the harness's around_each_file after-hook, so it runs at the END of every spec file, after that file's own teardowns. It checks eight fields (_outgoingWhispers, _fgiWhispered, _pendingEchoCount, _whisperModeRestoreAt, _whisperModeRestoreDue, _whisperModeSaved, _inlineHolders, _inlineHoldSaved), listed once in ECHO_FIELDS with a kind that drives both the check and the wipe (Peer Review c95bc968), wipes what it finds and raises, which the runner reports as a FAIL labelled with that file (Peer Review threads a63deb18, 2f5d36a5, 475f88a7, 67743bf6). The blame is exact, the last file in any order is checked, and no innocent file goes red. The wipe first puts the load's original cleanup functions back into FGI.functions, so a stub the finished file left behind cannot stop the player's saved whisper mode being restored (Peer Review NEW-D). The cleanup reports a cleanup function that raises, and one that is missing, instead of skipping it. One cleanup, clearEchoState(), serves the guard and the specs' after_each. Nine spec files leaked and now clean up after themselves: zz_invite, zz_protected_decline_whisper, zz_wingman_welcome_priority, zz_chat_lockdown, zz_forever_names, zz_protected_welcome, zz_welcome_cancel, zz_whisper_alert and zz_functions. The popout spec's own before_each clear, which only hid the leak, is removed.
  • New fn.releaseAllWhisperHolds (functions.lua): releases every inline holder, then puts the player's whisper mode back. The PLAYER_LOGOUT handler and the test guard both call it, so the two cannot drift apart; zz_whisper_tabs_spec checks logout goes through it.
  • Tests/zz_echo_leak_guard_spec.lua pins the guard, 12 examples. Every one was seen red with what it covers switched off in Tests/support/addon.lua or the spec, then green after the revert: hook registration, the inline-hold release and clearEchoState raising (desk test run 20261019-112848.150-3210433207); the original-functions wipe (NEW-D, red before the fix, 20261019-112933.799-3767375627); a missing cleanup function (20261019-113743.731-1970418719); a misspelt name in the guard's function list (20261019-113938.514-1985851924); naming every field, and the seed-every-ECHO_FIELDS-entry example, with one field left out of the report (20261019-114853.094-1114316536); clearing every field, with one left out of the wipe (20261019-114906.542-1823623415); silence on a clean file, with the early return removed (20261019-114921.455-1007050736); a cleanup function that raises, with the pcall swapped for a bare call (20261019-114929.287-2438421790); a function missing from the load's snapshot, through a new M._fieldSnapshot seam, with its error line removed (20261019-114938.290-2554009016). Green after the last revert: 20261019-114945.636-3031327151. The logout example went red with the handler's call removed (20261019-113726.221-652629272). With zz_invite_spec's cleanup removed, the guard failed that file by name and the next file stayed green (20261019-113142.278-2444759334). Full suite 4491 passed, 0 failed in default order (20261019-114158.742-1766977513) and shuffled 42 (20261019-114352.730-1557379416), before the ECHO_FIELDS change; after it, 4502 passed, 0 failed, 11 pending in default order (20261019-122953.642-1120039574) and shuffled 12345 (20261019-123716.801-1563873346).
  • KNOWN COST: a file that leaves SetCVar stubbed with a no-op has the guard's restore written into the stub, and the saved whisper mode is then dropped unreported (a stub that raises is reported); nothing puts SetCVar back between the file and the guard. A file that ran before any spec required support.addon is not checked. Many specs set wow.time backwards, against the harness's rule that the test clock only moves forward; that is why a leaked row could look fresh to the next file, and it is not changed here.

New — "Let FGI manage whispers", one switch for everything FGI does to whispers (functions.lua, Modules/FGI_WhisperTabs.lua, GUI/SettingsPanel.lua)

Operator, 2026-10-18, after crimsonmane (a WIM user) asked on Discord for every FGI whisper feature to be switchable off: "FGI either does 'it's windows' or the main windows. other addons can take over from the main window if they want it to. we need a master 'stop fucking with whispers' setting and it needs to turn all our whisper manipulation off for folks. this need to be on to allow FGI to manipulate whispers by default. someone can turn it off if they want." So there is no setting for choosing which chat window FGI prints to.

  • New fn.managesWhispers(), backed by DB.global.manageWhispers (account-wide like whisperTabs, default true, readers test == false). New Settings toggle "Let FGI manage whispers", placed just above "Hide outgoing whisper echoes".
  • Switched off, FGI changes nothing about how a whisper shows:
    • the echo-hide state is not written, and the CHAT_MSG_WHISPER_INFORM / CHAT_MSG_AFK / CHAT_MSG_DND filters are removed. A new local hidesEchoes() (master AND sendMSG) replaces the bare sendMSG test in refreshWhisperHideDeadline, suppressPopoutForSend (the retail whisperMode flip), updateWhisperEchoFilter and the classic-family FCF_OpenTemporaryWindow close hook;
    • WT:Enabled() answers false, so the compact tray's whisper tabs, their capture filter and their whisperMode hold all stand down;
    • fn.hideNotFound lets the server's "No player named X" line through for FGI's own whispers.
  • Switching it off takes effect at once: the setter calls updateWhisperEchoFilter (filters out, whisperMode handed back) and WT:Refresh (strip hidden, hold released). "Hide outgoing whisper echoes" and "Whisper tabs on the compact tray" are greyed out while it is off; their own values are kept.
  • The recruit-reply alert is off too. Operator, 2026-10-18: "it should stop the flash too, everything we do with whispers should be turned off when that switch is flipped". With the switch off:
    • Modules/WhisperAlert.lua's CHAT_MSG_WHISPER handler returns before anything else, so no sound, screen flash or tab flash, and no "Replied" mark (fn.recruits:onReplied);
    • fn:sendWhisper no longer arms _recruitAlertPending, so switching back on does not alert on a reply to a whisper sent while it was off;
    • Modules/Scan.lua's CHAT_MSG_AFK / CHAT_MSG_DND handler no longer reads an away or busy auto-reply as an invite outcome; that invite resolves through the pending sweep instead;
    • the three recruit-alert toggles are greyed out in Settings, and the switch's description says alerts stop too.
  • Still on with the switch off, by design: FGI's recruiting, welcome and decline whispers go out; Scan's "player not found" bookkeeping keeps running, because it tracks whether FGI's own send was delivered and is what bounds the not-found retry loop. KNOWN COST: several handlers stay registered with their bodies gated (the tray's capture filter and listener, the classic FCF_OpenTemporaryWindow hook, which hooksecurefunc cannot remove, and the hideNotFound filter). The realm resolver still reads the GUID from whisper events to learn realms; it changes nothing about any whisper.
  • KNOWN COST: crimsonmane's other report (whisper sounds with nothing showing in WIM, with whisper tabs off) is not explained by this. With the tabs off, the tab capture filter already returned false (doc: Modules/FGI_WhisperTabs.lua, WT:Captures). Their answer to the follow-up is still pending.
  • Locale: the two new strings are in Locale/enUS.lua only; other locales fall back to them.
  • Spec Tests/zz_manage_whispers_spec.lua (8 examples), every example asserting the ON state first. The tray example now also asserts whisperMode comes back to popout, and a Retail example drives the hand-over in forceRestoreWhisperMode: FGI's send flipped whisperMode first, the tray hold took it second, and switching off must give popout back through both. Each gate seen red with it removed in turn: hidesEchoes() (tool: desk test run 20261018-164549.414-990337522), WT:Enabled's master check (20261018-171324.591-3057339806, 2 failed), hideNotFound's (20261018-171339.748-3815648363, 1 failed), and updateWhisperEchoFilter's restore (20261018-171358.987-1834697870, 1 failed). Four more examples pin the alert half (alert toggles greyed out; no arming; no alert or reply mark; no AFK/DND outcome); the three code gates, removed together, failed 3 (20261018-183341.090-4015290121). Full suite 4454 passed, 0 failed (20261018-184508.443-4262046970).
  • KNOWN COST (Peer Review F1 and F3, thread 8b25d14f): with the switch off, an away or busy recruit's in-flight invite is no longer settled by the auto-reply. It stays pending, so that player sits out of scans for up to an hour, until fn:sweepStalePending files it Unresolved rather than AFK/DND. A recruit's reply is not marked Replied either. Both follow the operator's "everything" literally.
  • The five Settings disabled() closures (echo hiding, whisper tabs, the three alert toggles) now call fn.managesWhispers() instead of repeating DB.global.manageWhispers == false, so one definition decides (Peer Review, thread 8b25d14f).
  • New example in zz_manage_whispers_spec: with the switch off, two "player not found" answers for one recruit, fired as CHAT_MSG_SYSTEM, make fn:isInvitable refuse them with notfound_retry. This pins the claim that the not-found bookkeeping still bounds the retry loop with the switch off. Seen red with fn.recordNotFound gated on the switch inside the spec (desk test run 20261019-130759.357-2282622507), green after the revert (20261019-131616.734-89411853).
  • Both switches now have defaults (manageWhispers = true, whisperTabs = true in FGI_Core.lua), so a settings template exports "on" instead of omitting it, and both are in SP.CATALOGUE (notify group). Neither had been in the catalogue, so neither could go into a shared template (Peer Review, thread e860f6ea, P1). Readers still test == false, so a stored nil reads as on, as before. Both Settings setters now store true for on instead of nil, so "on" has one spelling (Peer Review, thread d1b702b4).

Fixed — switching "Check the guild for blacklisted members" off swept the guild anyway (functions.lua, GUI/SettingsPanel.lua, Modules/FGI_SettingsProfile.lua)

Found by the settings-template sweep's agent and confirmed by Peer Review (thread b710eca5, reply 40b9cddc). The Settings toggle calls fn:blackListAutoKick() on every set, off included, and a template's SP:Apply calls it on any change. The function never read DB.global.autoKickBlacklist, so switching the check off ran a full roster sweep and opened a Kick / Remove / Skip prompt for every blacklisted member. It also re-registered the OnRosterReady callback, which had no setting check of its own.

  • fn:blackListAutoKick now returns at once when the setting is off, after unregistering the OnRosterReady callback a switch-on or the login left armed. The callback itself checks the setting too, for a roster-ready that fires after the value was written without the function being called again.
  • The comment on applyAutoKick (GUI/SettingsPanel.lua) said "off, unregister them" while nothing was unregistered; it now says what happens.
  • Five examples in Tests/zzzz_blacklist_autokick_spec.lua: called with the setting off; switching off drops a sweep waiting for the roster; a roster-ready after the setting went off; and switching off from the Settings toggle and from a template, each through the real sweep with a blacklisted member on a built roster. All five seen red with both guards removed (tool: desk test run 20261019-120109.347-450296351), green after the revert in the full suite: default order 4502 passed, 0 failed (tool: desk test run 20261019-122953.642-1120039574) and shuffled seed 12345 the same (tool: desk test run 20261019-123716.801-1563873346). The 11 pending in both are the opt-in Elune spec Tests/zz_elune_taint_forever_spec.lua, which this work did not touch.

Tests/zz_echo_leak_guard_spec.lua's seed-all example now requires each table field to come out of the guard as an EMPTY TABLE, not nil-or-empty (Peer Review b710eca5, F3). Seen red with the wipe changed to nil the table (tool: desk test run 20261019-120206.226-179818806).

Fixed — a blacklist kick prompt was lost when another FGI dialog opened over it (Modules/FGI_Dialog.lua, functions.lua)

Peer Review N1 (threads 4132f275 and c5eef925). FGI shows one dialog at a time, and Dialog:Show replaced whatever was up. When the kick prompt was the one replaced, nothing re-showed it. Its name stayed at the head of the kick queue, guildKick saw it already queued and returned early, and no blacklisted member was asked about again until /reload.

  • A dialog spec's resume is a FUNCTION, not a flag. When Dialog:Show replaces a dialog whose spec carries one, the function is carried onto the dialog that replaced it, and passed along again if that one is replaced too. When the last dialog closes, the function is called. If the closing dialog's own callback opened another dialog, the function moves onto that one. Click and Escape wait until their callback has run before they call it. The function re-derives the question rather than replaying the old spec, because the old spec's name or reason may be stale by then (Peer Review NEW-2).
  • A dialog whose own spec carries the same function is not handed it again: it IS the re-derived question, and carrying the call as well would ask twice, or make a bare Close of it re-open it at once (Peer Review NEW-1, A1). New Dialog:Defer(fn) hands a function to the dialog on screen and answers false when nothing is up.
  • The kick prompt's spec sets resume = showNextKickPrompt. The displaced name is never marked as asked. showNextKickPrompt drops queue heads that are no longer on the blacklist (Remove all, Remove) before it asks; the Kick button re-checks the blacklist before GuildUninvite; and an answer removes the queue head only while it is still the name that prompt asked about.
  • guildKick re-shows the head of the queue whenever no dialog is up, including when the name was already queued. This covers a prompt closed with no answer and nothing left to bring it back.
  • CHANGED: a kick prompt no longer replaces another FGI dialog. With a different FGI dialog on screen (a half-typed Blacklist edit, the Wingman duty prompt), the prompt waits through Dialog:Defer and is asked when that dialog closes (Peer Review NEW-4). A kick prompt over a kick prompt still replaces it.
  • KNOWN COST: a kick prompt deferred behind a dialog that stays open (the Wingman duty prompt left up) is not asked until that dialog closes. Nothing times it out, on purpose: the defer is close-driven only.
  • Tests/zzzz_blacklist_kick_displaced_spec.lua, ten examples. The first four: displaced then closed, re-asked when a second name is queued; brought back when the displacing dialog is answered; a bare Close re-asked by the next sweep; brought back after a chain of two displacing dialogs. Three of those were seen red before the fix (tool: desk test run 20261019-185328.090-2823186374, 0 passed, 3 failed); the bare-Close one was added after that run and was never seen red (not verified).
  • Six more drive the REAL dialogs rather than stand-ins: the Blacklist tab's own edit dialog (FGI_V2_BLACKLIST_EDIT) saved over the prompt asks once and kicks once (a); the prompt re-opened by that save stays closed when closed with no answer; Cancel or Escape on the edit brings the prompt back once and sends nothing; the tab's own "Remove all" confirmed over the prompt asks nothing and kicks nobody (b); a dialog already up when the sweep runs is kept and the prompt asked once it closes; and a prompt re-derived over a prompt stays closed when closed (d). (d) was seen red with the same-resumer skip in Modules/FGI_Dialog.lua disabled (tool: desk test run 20261020-092904.209-28419206); the red runs for (a), (b) and the rest are on Peer Review threads c903f8ba and 0b0e1c79 and were not re-read for this entry (not verified).
  • Spec support (Tests/support/addon.lua): one shared stubGuildUninvite() for the popup and displaced specs, which had four diverging local stubs; stubBlacklistBroadcasts() captures the OFFICER lines and the DeltaSync name push that blacklistOfficer (on by default) sends, and the examples assert them; clearKickQueue() closes ANY open FGI dialog, which its comment and the guard's message now say. Wingman:ForgetDutyEdge is a labelled test hook that resets the duty edge, used by Tests/zz_baton_e2e_spec.lua so a simulated second client starts with no remembered duty, and its after_each closes the duty prompt a genuine rising edge opens. A new example there asserts Take opens the duty prompt on the clicking client; with Take's click handler backed out it goes red with the other three Take examples (tool: desk test run 20261020-122159.506-4224190131, 8 passed, 4 failed).
  • The resume handling is shared by every FGI dialog. Not tested in game.
  • CHANGED: the Wingman duty prompt and the recruitment-goal pop-up wait behind another FGI dialog the same way (Wingman:ShowDutyPrompt, Goals:Prompt), and each is derived afresh when that dialog closes. Both used to replace it, closing a half-typed Blacklist edit with no callback (Peer Review NF-2, NF-2b). The goal pop-up is reached from Wingman's invite step on any click, which is how it could land on an edit.
  • FIXED: duty flapping on, off and on behind an open dialog asked the duty question twice, and pressing Later on the first prompt opened the second (Peer Review NF-2a). Each deferral passed a new closure, which the waiter list's identity check cannot merge. Both modules now defer one file-scope resumer.
  • Tests/wingman_prompt_spec.lua: a duty flap behind a dialog asks once and Later asks no more (red with a closure per call, desk run 20261020-110144.840-268204786); Wingman started while the prompt waited asks nothing (red with the resumer's not active check removed, run 20261020-110608.015-59527898). Tests/zz_recruit_goals_spec.lua, three examples: the pop-up waits and then shows; reached twice it shows once; a policy moved to the chat line meanwhile shows nothing. The first two were red with the wait removed (run 20261020-111953.996-2893876977), the third red with the resumer's policy check removed (run 20261020-112807.892-3733024944). Green: run 20261020-113230.818-1654306234, 266 passed, 0 failed. Not tested in game.
  • FIXED: a recruitment goal reached while another FGI dialog was up could ask the goal question twice. When that dialog's own button reached the goal again (the duty prompt's Enable starts Wingman, which says the goal), the pop-up it raised was handed the copy already waiting, and Okay opened it (Peer Review NF-C). The goal pop-up now names its resumer as its own resume, so the dialog never hands it back to itself. Example in Tests/zz_recruit_goals_spec.lua, "raises it once when the waiting dialog's own button reaches the goal again": red before the fix (desk run 20261020-124258.744-4245488117, 81 passed, 1 failed), green after (run 20261020-124318.400-3218994564, 82/0). The button there stands in for Enable and drives the same dialog path; the real Enable is not driven by an example (not verified).
  • FIXED (Modules/FGI_Dialog.lua): the prompts waiting behind a dialog are each run under pcall. The list is detached before it runs, so one that raised used to lose every one behind it for the session (Peer Review NF-B). The error still goes to the client's error handler. New example in Tests/dialog_spec.lua: red with the call made bare again (run 20261020-124409.342-3817673497, 63/1), green with the guard (run 20261020-124403.057-2445923754, 64/0). geterrorhandler joined .luarc.json's globals. Coverage Modules/FGI_Dialog.lua 347/347 and Modules/FGI_RecruitGoals.lua 130/130, default-order suite 4542/0/11 in the same run (desk coverage run 20261020-124722.897-2868687334, a headerless record: no verdict or target line). Not tested in game.
  • CHANGED (Modules/FGI_Dialog.lua): the waiters now run under xpcall with the client's error handler as the handler, instead of calling the handler after pcall returned. The handler now runs while the failing waiter is still on the stack, so BugSack shows where it broke rather than a stack starting in the dialog module (self-audit 65ac8eaa F2). Example in Tests/dialog_spec.lua, "reports a waiter's error with that waiter still on the stack": red under pcall (desk run 20261020-130352.076-1050322805, 64/1, the captured trace began at the dialog's report call), green under xpcall. Not tested in game.
  • FIXED (Modules/Wingman.lua): the duty prompt had no resume, so a dialog the player opened over it (a Blacklist edit) replaced it with nothing to bring it back; the question was gone until duty flapped (self-audit 65ac8eaa F1). It now names the same file-scope resumer it defers with, so it comes back, re-derived, when that dialog closes. Two examples in Tests/wingman_prompt_spec.lua: it comes back (red before the fix, desk run 20261020-130017.478-444704317, 98/1), and it does not come back when duty left while covered. Five dialog-related spec files green together (run 20261020-130645.374-2471034696, 271/0); default-order suite 4545/0/11 under coverage with Modules/FGI_Dialog.lua 345/345 (desk coverage run 20261020-131152.452-2641771127, a headerless record). Not tested in game.
  • FIXED (Modules/FGI_Dialog.lua): a button or Escape callback that raised skipped the prompts waiting behind that dialog, leaving them on a closed handle for the session (Peer Review NF-D, NF-B one layer up: a kick prompt waiting behind the duty prompt was lost if Enable raised). Click and Escape now run their callback through the same guarded call as the waiters, so the error still reaches BugSack and the waiters still run. Two examples in Tests/dialog_spec.lua, red before the fix (desk run 20261020-131427.858-877691747, 66/2) and green after (run 20261020-131448.509-142822526, 68/0). A third pins NF-E (a waiter that opens a dialog and then raises leaves the rest waiting behind that dialog); it was already correct and passed before the fix. Not tested in game.
  • CHANGED (Modules/FGI_Dialog.lua): new Dialog:ShowOrDefer(spec), the one "show me, or wait behind a dialog with a different id" rule the kick prompt (functions.lua showNextKickPrompt), the Wingman duty prompt and the recruitment-goal pop-up each wrote by hand (self-audit 65ac8eaa F3). It refuses a spec with no resume, the shape that let the duty prompt lose its question (F1). All three call it now. Four examples in Tests/dialog_spec.lua under "ShowOrDefer"; the stub in zz_recruit_goals_spec's personal-goal example counts both entry points. A fifth example pins that ShowOrDefer replacing its own earlier copy keeps what waited behind that copy (Peer Review 7c731e70's follow-up): red with Dialog:Show's waiter carry disabled (desk run 20261020-134736.109-2492629524, 71/2), green restored (run 20261020-134950.714-1982181594, 73/0). Seven dialog-related spec files green together (desk run 20261020-131818.519-3522339319, 309/0). Not tested in game.
  • CHANGED: a recruitment-goal pop-up covered by another FGI dialog now comes back, re-derived, when that dialog closes, as the kick prompt already did (self-audit 65ac8eaa F4). Before NF-C it was simply lost. Two examples in Tests/zz_recruit_goals_spec.lua: it comes back, and it does not when the policy moved to the chat line meanwhile. The first went red with the pop-up's resume removed (desk run 20261020-131618.475-582388331, 83/2, with the NF-C example). A third drives two different prompts waiting behind one dialog and asserts one of each, in order; the first prompt there is a stand-in shaped like the duty prompt, not the real Wingman prompt. Not tested in game.
  • Tests/wingman_prompt_spec.lua: Wingman:OffDutyUnderPolicy's per-layer branch had no example (an announcer who is not the designated one stays on duty while the guild has an announcement on a public channel; coverage showed Modules/Wingman.lua 730/732). Two examples, the second a control with no such announcement. The file does not load Modules/Announce.lua, so FGI.announce there is a stand-in answering only HasPerLayerAnnouncement, whose real answer is pinned in Tests/announce_per_layer_spec.lua; the rank is the real Recruiter's. Red with the branch disabled (desk run 20261020-132903.635-2347747487, 100/1, the control still green), green restored (run 20261020-132732.120-1893472703, 101/0).
  • FIXED (GUI/MainWindow.lua): every MainWindow:Open hooked OnSizeChanged on the window's frame again. AceGUI hands the same pooled Frame back on the next Open and a hook cannot be removed, so each open/close cycle stacked one more handler for the rest of the session. It is now hooked once per frame (flag _fgiRelayoutHooked, the Esc-proxy hook's pattern) and reads the live MainWindow.frame when it fires, doing nothing while that frame belongs to someone else. Found by a reverse-order suite run that timed out inside a 12-deep chain of these hooks (desk run 20261020-115827.840-4142880531); that the stack is the cause of the timeout is not verified beyond reading it. New example in Tests/zz_mainwindow_spec.lua, "hooks the window frame's resize once, however often it is reopened": red with the guard removed (run 20261020-120417.275-2618982291), green with it (run 20261020-120322.764-252230705). Not tested in game.

Fixed — settings a template could not carry (Modules/FGI_SettingsProfile.lua)

The catalogue's anti-drift sweep walked only FGI.defaultSettings, so a setting with no default was invisible to it. A second sweep in Tests/zz_settings_profile_spec.lua now runs every Settings control's get against a DB that records each top-level scope.key it reads, and fails on any that is neither catalogued nor in SP.NOT_A_SETTING; it also fails if a getter arms a timer. Its first run found three more beyond the two whisper switches:

  • global.latinNamesOnly ("Skip names not written in the Latin alphabet") is now catalogued (scan group), and has a default of false in FGI_Core.lua so the template type gate can refuse a hand-edited non-boolean (it failed open with no default). The reader tests truthiness, so false behaves as nil did.
  • global.tabPrefs (main-window tab order and hidden tabs) is now catalogued (interface group), with an SP.LABELS entry because the per-tab controls are not named after the key.
  • char.neverOnDuty goes in SP.NOT_A_SETTING: Recruiter:SetNeverOnDuty steps the character aside or back in and tells the guild at once, and a template's raw write does neither. The login re-assert (FGI_Baton.lua, 8 s after login) only ever steps aside, so it would not cover a template switching it off.
  • RESIDUAL: a getter that reads through a captured local instead of addon.DB is not seen by the sweep. The sweep now fails if any getter raises, since a raising getter's later reads would go unseen.
  • SP:Apply now runs what the Settings setters run after writing: fn.updateWhisperEchoFilter(), fn.updateSystemMsgFilter(), WhisperTabs:Refresh() and MainWindow:RefreshTabStrip(). Before, a template that switched whisper handling off left the echo filters registered until the next toggle; one that switched "Hide system messages" off left every system line hidden until /reload (and switching it on did nothing until then); and one carrying a tab order left an open window showing the old strip. Pinned in zz_manage_whispers_spec and zz_settings_profile_spec, each seen red without its call (tool: desk test runs 20261018-172054.011-4216524381, 20261018-172838.414-3333893527, 20261018-172954.093-2098953295).
  • SP:Apply now also runs the rest of the Settings setters' live hooks, each only when the template carried that key (Modules/FGI_SettingsProfile.lua, the block after the filter refresh): fn:applyWindowOpacity() for windowOpacity, UI.RefreshAllWindowLayers() for windowLayer, fn.updateEscFrames() for keepOpen, fn.setTabHidden("raceclassmatrix", ...) for showRaceClassMatrix, and LibLocaleOverride:SetOverride for uiLanguageOverride. Three more run only when the value CHANGED: fn.rememberWindowOpen for persistMainOpen / persistCompactOpen, and fn:blackListAutoKick() for autoKickBlacklist. ApplyAtLogin runs before any window exists, so an unconditional re-stamp would write "was closed" over a character's real "was open", and OnEnable already runs the blacklist sweep at login. Before this, a template carrying any of them changed the stored value but none of those effects ran until the setting was next toggled or /reload.
  • The side-effect sweep Peer Review asked for (thread f6f796e6, reply 13fff472) is in Tests/zz_settings_profile_spec.lua: every function on FGI.functions and on each capitalised module table is wrapped to record calls; for each catalogued key with a toggle, range or select control, the Settings setter runs, the value it wrote is restored and handed to SP:Apply, and every module function the setter called must also be called by Apply. The two exemptions are keyed per setting ("global.scanInterval -> fn.getGiveUpMin"), the same string a miss is reported as, so an exemption cannot excuse the same function called by another setting. It fails unless more than 30 settings are driven. RESIDUALS: an effect reached through a file-local or captured upvalue is not seen, and input and multiselect controls are not driven.
  • The sweep then caught the "Let FGI manage whispers" switch: its Settings setter drops every hidden-echo record when it goes off (fn.clearEchoRecords), and SP:Apply did not, so a template switching it off left the records to hide the player's own next line if the switch came back on inside the window (red 20261019-134415.649-333213484, green 20261019-134801.928-2278711982). Both writers now call one function, fn.applyManageWhispers() (functions.lua): it drops the records when the switch is off, refreshes the filters, then redraws the tray. The Settings setter calls it, and SP:Apply calls it when a template carries the switch; a template that does not carry it only refreshes the filters and the tray, on purpose (zz_manage_whispers_spec pins that). Its order against the filter refresh has no effect: switched off, that refresh zeroes the echo count and every record's counted anyway. Each caller was seen red with its call removed (template side 20261019-160316.646-1110215908; the setter side's red run, 20261019-153927.304-713010545, is from an earlier session's account and was not seen by the session that cited it).
  • Driving "Check the guild for blacklisted members" runs the real roster sweep, so the sweep example asserts it leaves no kick prompt queued and no dialog open for the next file (Peer Review 3e9ecc59, reply 2c385dcd). Seen red by seeding a blacklisted guildmate (20261019-151351.036-2664516288). The example runs the sweep against an empty blacklist, and starts from an empty kick queue with no dialog open. It failed whenever an order put the two zzzz_blacklist_* files first (20261019-170807.526-928117944): most kick-popup examples end with the prompt's Close, which runs no callback and so never advances the queue, leaving Mercyshock pending for the next file. Both zzzz_blacklist_* files now clean up on the way in as well as on the way out (on the way in against whatever an earlier file left; after_each runs even when an example fails), through one helper, clearKickQueue() in Tests/support/addon.lua: it empties the queue's pending and asked lists in place FIRST, then closes FGI dialogs until none is current (at most ten closes, then it raises), then empties the lists again. The order matters: closing a dialog that displaced the kick prompt calls the prompt's resumer, which would re-open it from a queue still holding names. Their after_each also restores the blacklist they started with and calls fn:blackListAutoKick() so the roster callback is unregistered when the setting is back off.
  • A per-file guard in Tests/support/addon.lua now fails any spec file that ends with names in either kick-queue list, or with any FGI dialog still current, naming the file, the names and the dialog's id, then clears both so the next file is not blamed. The dialog is checked because the current dialog is module state in Modules/FGI_Dialog.lua that outlives the file: a kick prompt left up behind an empty queue failed the next file's "no dialog is up" assertion. asked counts as well as pending: a leftover asked name means a later file's prompt for it never opens, and a "no dialog is up" assertion there would pass for the wrong reason. Seen red with the popup spec's clearKickQueue() removed (tool: desk test run 20261019-192543.584-906878796); four examples pin it in Tests/zz_echo_leak_guard_spec.lua. Its first whole-suite run named the file that left Bob queued: Tests/zz_scan_batch_spec.lua, whose batch blacklist queued a kick prompt for Bob (tool: desk test run 20261019-194249.018-1550335383). Bob is not on the support roster; an earlier file's cast put him in the test guild and nothing reset it (which file was not pinned). That file now resets the roster in its before_each, so Bob is not a guildmate there, and empties the queue through clearKickQueue() on the way in and out.
  • The same guard now fails a spec file that ends with the test roster's ready flag off (Peer Review R2): support.roster republishes every cast change with that flag, so a file ending on setReady(false) handed the next file a "still building" roster, and three zz_whisper_tabs examples failed that way under shuffle 12345. Which file did it was never pinned. With the check in place, default order and shuffle 12345 both named no file (tool: desk test runs 20261019-203518.046-1401811776 and 20261019-204412.352-74904389, each 4514 passed, 0 failed), so none does in those two orders. Three examples in Tests/zz_echo_leak_guard_spec.lua pin it; with the raise and reset taken out, two went red (tool: desk test run 20261019-204900.168-3042689005), and "silent when ready" cannot go red that way.
  • KNOWN COST: the roster check resets the module's flag only; it does not rebuild the library, which stays "still building" until the next setCast / setOnline / setRank. A next file that asks the library before any of those still sees a building roster, with no file blamed. It also cannot see a library left out of the guild by roster.leaveGuild(), which leaves the flag alone; three files call that (announce_guild_ready_spec, guild_events_spec, zz_deltasync_bridge_spec). A library left building is the normal state between files after roster.reset(), so the guard cannot tell a leak from a clean file.
  • Whole suite on the 2026-10-20 tree, after every change in this section and the main-window fix above, but BEFORE the NF-C and NF-B fixes and everything after them (those runs ended 12:08-12:29; the NF-C and NF-B fix runs are 12:43-12:44, Peer Review C1): reverse order 4540 passed, 0 failed, 11 pending (tool: desk test run 20261020-120831.282-229887076); default order the same (tool: desk test run 20261020-121125.222-2612018241); shuffled seed 12345 the same (tool: desk test run 20261020-122942.266-998176576). Coverage of functions.lua 3562/3562 and Modules/FGI_Dialog.lua 343/343 lines (tool: desk coverage run 20261020-121917.486-125400591, a headerless record: its first line is a FILE path, with no verdict or target line). The 11 pending are the opt-in Elune spec. The runs that cover the later dialog fixes are cited in their own entries.
  • Whole suite after every dialog fix above (F1-F4, NF-B to NF-E, ShowOrDefer) and the Wingman per-layer examples: reverse order 4557 passed, 0 failed, 11 pending (tool: desk test run 20261020-134121.276-3392909975); shuffled seed 12345 the same (tool: desk test run 20261020-134244.511-3830185458); default order the same under coverage, with Modules/FGI_Dialog.lua 359/359, Modules/Wingman.lua 728/728, Modules/FGI_RecruitGoals.lua 127/127 and functions.lua 3559/3559 (tool: desk coverage run 20261020-133822.427-2654476105, a headerless record that printed no RUN ID line; the id is its file name).
  • KNOWN COST: the four filter and redraw calls (updateWhisperEchoFilter, updateSystemMsgFilter, WhisperTabs:Refresh, MainWindow:RefreshTabStrip) run on every apply that changed anything, login included, not per key. Harmless today, but the sweep cannot catch one of those going missing for one key.
  • KNOWN COST: a template carrying a uiLanguageOverride code this client does not offer is handed to LibLocaleOverride:SetOverride unchecked. The Settings dropdown offers only the codes it lists, and its getter shows a saved code FGI has no locale for as Auto (GUI/SettingsPanel.lua:1661).
  • KNOWN COST: now that both whisper switches have defaults, every exported template carries them, so applying one that includes the notify group turns a recipient's "off" back on. That is the same behaviour as every other defaulted setting the author leaves selected.
  • Test isolation: zz_settings_profile_spec's "never carries the guild's policy" example left a live guild policy in the DB. Wingman's load-time firstEvaluation was still pending, so the next file's load-time timer drain ran it under that policy and it re-armed through the Recruiter's 30 s warmup, failing zz_templates_page_spec when the two ran alone together. The example now restores the policy, and the drain's storm error in Tests/support/addon.lua now names where each re-arming callback is defined.
  • Full suite 4450 passed, 0 failed (tool: desk test run 20261018-173432.929-2723765602).

Fixed — recruit-reply alerts fire with "Hide outgoing whisper echoes" off (Modules/WhisperAlert.lua)

Found while tracing Pockit's Discord report of 2026-10-18 (Retail and Classic: "the only way to get the Alert sound on recruit whisper response is to turn on the screen flash as well"). Pockit later said the setting was off on their client, and after switching it on: "Sound worked, whispers worked, all worked beautifully in Classic TBC with the hide outgoing whisper echoes enabled!" (Pockit's claim, Discord, 2026-10-18). So this was the cause of Pockit's report, on the released build; the fix itself is not yet tested in game.

  • The alert's gate read addon._outgoingWhispers, the echo-hiding table, which fn.refreshWhisperHideDeadline writes only when DB.realm.sendMSG is on (doc: functions.lua, the sendMSG return in that function). With "Hide outgoing whisper echoes" off, none of the sound, screen flash or tab flash ever fired, and the reply was never recorded for the History tab. None of the three alert settings mentions that dependency.
  • New addon._recruitAlertPending is the gate now. fn:sendWhisper writes it on every recruitment whisper, whatever sendMSG says. fn:resetSettings clears it with the other session tables.
  • The welcome whisper does NOT arm it (Peer Review W1, thread a53e4cfa). The welcome stamps the echo-hide state through the same refreshWhisperHideDeadline, so writing the gate there would have made a new member's "ty" to the welcome ding, and record them as a "replied" recruit. With echo hiding on that was already happening under the old gate. A reply no longer touches the echo-hide state at all (see the "hides only FGI's own whispers" entry above).
  • Recruits:onReplied no longer overwrites ACCEPTED or JOINED. REPLIED is a strong status, so setStatus let a reply after joining turn a member back into "the lead worth chasing". The reply time is still kept.
  • The spec's sentTo helper switched sendMSG on, which is why the suite never saw this; it now sends through the real fn:sendWhisper. The echo-off example was seen red against the old gate (tool: desk test run 20261018-161023.045-1879416806, 19/1); the welcome-reply and joined-reply examples were seen red with the gate armed from the shared helper and the JOINED guard removed (run 20261018-162209.331-973499219, 20/2). Full suite 4437 passed, 0 failed (run 20261018-162437.092-719041292). Not tested in game.

Fixed — the whisper tabs no longer swallow ordinary whispers (Modules/FGI_WhisperTabs.lua)

Pockit on Discord, 2026-10-18 (Classic Anniversary): a /tell or a WIM whisper to someone "nothing to do with recruiting" showed in no chat window and the answer came into FGI's window; and after a right-click invite the welcome whisper "DOES go out but, it doesn't show it in any chat window anywhere". The operator's decisions, same day: "Stop once in guild" and "Show in normal chat".

  • WT:IsRecruit answers false for a player in our guild now (fn:isPlayerInMyGuild). The recruit record it reads lives as long as the history retention (30 days by default), so a guildmate FGI recruited last week was still captured. WT:CapturedKeys asks it too, so a recruit who joins mid-conversation loses their tab and stops holding whisperMode, rather than leaving a frozen tab on the strip while the talk carries on in the chat window (Peer Review W1, thread c6ce46ae). KNOWN COST: from the moment they join, the rest of that conversation is in the chat window.
  • WT:Refresh closes the open conversation (WT.active) when its tab has gone, whether the player joined the guild or the recruit record lapsed. The pane used to stay on screen with no tab under it, WT:Send could still whisper from it, and with the last tab gone it would have reopened by itself if the player ever counted as a recruit again (Peer Review F1, thread ab4614fa). The same clear runs when "Whisper tabs on the compact tray" is switched off, or a recruit record briefly lapses: the open conversation closes, and switching the setting back on does not reopen it (Peer Review A1, thread e3ae49f2). Hiding the tray does not clear it.
  • WT:Captures(name, outgoing) no longer captures a line the player sends before the other player has replied, and WT.chatFilter passes the direction from the event. The tab only opens on their reply (WT:OpenKeys), so hiding that line left it shown nowhere. It is still recorded, so the tab shows it once it opens. Bloodpox on Discord the same day, "When in "compact mode" it does not send Whispers when "Whispers Only" mode is selected", was this, as designed then: the whispers went out, and unticking "Whisper tabs on the compact tray" showed both them and the replies (Bloodpox, same thread).
  • The inline whisperMode hold in WT:Refresh is taken only while a conversation has a TAB (OpenKeys), not for every recruit whispered this session. Held for every unanswered recruit, it kept every whisper from anyone inline for the whole session (Peer Review W2); the reason it covered them, their outgoing lines being hidden, is gone with the change above. KNOWN COST: in popout mode, a first reply from a recruit whose whisper window the player already closed opens a new window that stays empty, the reply being in the tab.
  • The Classic-family FCF_OpenTemporaryWindow hook (fn.installConversationTabSuppressionHook) asks WT:Captures(chatTarget, true), so it closes the game's window only for a conversation with a tab. It cannot tell which side opened the window, and closing the one opened for our first whisper would have shown that line nowhere again (Peer Review W3).
  • Specs in Tests/zz_whisper_tabs_spec.lua for all of it, the guild ones also through the real LibGuildRoster check; the hook's in Tests/zz_functions_spec.lua, whose tray examples now give the recruit a real answered conversation. The whisper-tab file answers "not in the guild" by default, because the shared test guild has members named Alice and Bob and every other example is about a recruit who has not joined.

Fixed — the compact tray shrinks from the bottom on Retail too (Modules/compactFrame.lua)

Pockit on Discord, 2026-10-18: on Classic the tray shrinks from the bottom up as queue rows go, so the action buttons stay under the mouse; on Retail it "kinda re-centres itself so the action buttons move".

  • cf.refresh resizes the tray with SetHeight, which only keeps the top edge still under a TOPLEFT anchor. It now re-pins the anchor through saveTopLeftAnchor whenever cf:GetPoint(1) is anything else, before the resize. The resize grip's OnMouseUp also re-pins after StopMovingOrSizing, as the three drag handlers already did.
  • The tray and the soundboard are NAMED movable frames, so a drag made them "user placed" and the client saved and restored their points itself, outside FGI's saved layout. Both now call SetDontSavePosition(true) at creation (documented in every flavour's SimpleFrameAPIDocumentation.lua; Blizzard clears the flag after its own drags, FloatingChatFrame.xml:678). Peer Review C1 named this as a candidate Retail cause.
  • cf.refresh skips the re-pin while a drag or resize is live (cf.moving, set by the shared dragStart/dragStop pair the three drag handles now use, and by the grip): a scan can refresh mid-drag, and re-anchoring under StartMoving also saved a mid-drag position (Peer Review C2). The tray's OnHide clears cf.moving too, since a tray closed mid-drag may never run its stop handler, and calls StopMovingOrSizing first when a drag was live, as Blizzard stops a move before hiding a dragged frame (Peer Review A2, thread e3ae49f2). It then saves the anchor and width the stop handlers would have saved, so a resize cut short by closing the tray is not lost on the next /reload.
  • Which of these left the Retail tray on another anchor is not verified: the client's own saved position, the login restore's pin returning early when GetTop/GetLeft are not known yet, or the grip never re-pinning. The invariant is enforced at the resize itself rather than at one guessed cause. Needs an in-game check on Retail.
  • Three specs in Tests/zz_compact_frame_spec.lua pin it: a centre-anchored tray keeps its top edge as rows are removed, a width resize ends on a TOPLEFT anchor (both measured red with the re-pins removed), a refresh mid-drag leaves the anchor and the saved position alone until the drop, and a tray closed mid-drag re-pins on its next refresh. Those last two were not seen red.

Fixed — hiding the UI (Alt-Z) closed the compact tray and the main window for good (Modules/compactFrame.lua, GUI/MainWindow.lua)

Found by Peer Review B2 (thread a29fa8b7) and traced in the suite. The ESC proxy is a child of UIParent, so hiding the UI fired its OnHide, which hides the tray as if ESC had been pressed; the tray stayed shut when the UI came back. The tray's own OnHide then also hid the proxy (so ESC stopped closing it) and recorded the tray as closed for the next login.

  • Both handlers now act only when their own frame was hidden: IsShown is the frame's own flag and IsVisible the parent chain (warcraft.wiki.gg API_ScriptRegion_IsShown), the same test Blizzard's chat frames make in their OnHide. A hidden parent leaves the tray, the proxy and the reopen flag alone.
  • The main window closed the same way, through three paths, and all three now make the same test (Peer Review M1, thread a29fa8b7): its ESC proxy's OnHide called MainWindow:Close(); the window frame's own OnHide hook hid the proxy, whose handler then closed the window (this one was found by the new spec, not by the review); and AceGUI's Frame fires OnClose from OnHide (AceGUIContainer-Frame.lua:28-29), where FGI recorded the window closed and released the widget. MainWindow:Close() hides the frame itself, so every real close (our Close, the X, ESC) still passes the test.
  • Specs: hiding UIParent keeps the tray shown, remembered as open and closable by ESC, and the same for the main window (hiding the window's and the proxy's actual parents: in a full run the window's parent was measured not to be the current UIParent, most likely because AceGUI pools the Frame across spec files -- that cause is not verified). The ESC proxy specs of both now hide the proxy, as CloseSpecialWindows does, instead of calling its handler with the proxy still up. warcraft.wiki.gg's UIHANDLER_OnHide says OnHide also fires when any parent is hidden (Peer Review's fetch); not tested in game.
  • A safeguard, not a player-visible fix: closing either window while the UI is hidden now tears it down even if the client fires no OnHide for a frame its parent already hid (Peer Review C1, threads a29fa8b7, b80a5e77 and cf2ded7f; warcraft.wiki.gg UIHANDLER_OnHide says a hide of an already-hidden frame fires none, and whether that covers this case is not verified in game). FGI's own controls cannot reach it: no key binding toggles either window (Bindings.xml), /fgi show only opens, the minimap button hides with the UI (LibDBIcon parents it to Minimap, under UIParent), and ESC brings the UI back first. Only a third-party launcher outside UIParent (a data-broker display) or another addon calling MainWindow:Toggle could, and neither was checked. MainWindow:Close() fires OnClose itself when the frame is still set after its Hide(). The tray's OnHide teardown is one idempotent function, and a new cf.close() runs it after cf:Hide(); the X, the expand button, the ESC proxy and the minimap launcher (FGI_Core.lua) all close through it. Without this the window stayed half-open (the next toggle only closed it again) and the tray kept its ESC proxy up and its reopen-after-reload flag set. Specs for both, seen red with the fallbacks removed, as was the existing "handler with the proxy still shown" example with its guard removed.
  • Test harness pinned to 8391c1a: env/provenance.lua now drops its _G snapshot at unwatch() (WoWAPITesting thread 85e5323e). That snapshot held a pooled AceGUI button's global name, and with it the discarded matrix tab, which turned Tests/zz_tab_render_collectable_spec.lua red alongside the Alt-Z fix (that the fix's change of pool order caused it is inferred, not verified). The spec's temporary workaround, which treated the snapshot as a known holder, is removed.

Older versions (129 to v2.18.0) are in CHANGELOG_ARCHIVE.md.

This mod has no additional files