UltimateGuildInvite-v2.21.1
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:mergeLayerreturned afalseno 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 bareSendChatMessagefallback. Every flavour tree on the reference drive hasC_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.luareloads 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 keysinviteMsgHeldNoneAvailable/announceMsgHeldNoneAvailablenaming the cause, instead of "limited to the designated inviter/announcer". Specs inTests/zz_invite_holds_spec.luaandTests/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 (AmIDesignatedis 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:Startnever askedOffDutyUnderPolicy, 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 inTests/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:ApplyPolicyread each leaf assrc and src[leaf] or nil, which turns afalsevalue into nil, so a policy that switched a setting off arrived on every peer as on. Fixed inModules/FGI_DeltaSync.lua; it applies to any policy field stored asfalse. - The three fall-open examples in
Tests/recruiter_spec.luanow 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
ApplyRemoteAssignnow ignores aGorPfrom 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:MayHoldalready 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
GetAutoCompleteRealmsreturns 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) andfn.welcomeGatenow 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_guildand 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 leavesinviternil. A genuine auto-decline still stamps us. fn:promotePendingToAntiSpam(name, how)takes"ours"(default),"shared"or"notOurs";fn:rememberPlayergainsnoInviter.
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 itstimeout(StaticPopup_OnUpdate -> CancelAndHideDialog "timeout" -> OnCancel DeclineGuild,timeout = 60in the classic_era tree), so an entry older than that plusfn.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).
recordAcceptpromotes 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:IsRunningFGIwould 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 nowalready_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_specdid not acceptSLASH_FGIDELTASYNC2(the /fgids alias) as order-dependent like its sibling;zz_settings_panel_specandzz_blacklist_form_specandzz_chat_tooltip_specread 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 inTests/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:isPlayerInMyGuildreports 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.
iInvitednow reads the entry'sinviterstamp, whichfn:rememberPlayerwrites first-writer-wins and DeltaSync carries (alreadyEntryToObj,Bridge:ApplyEntries). A joiner counts as this character's only when the stamp names this character (compared throughfn:fullPlayerName, so a bare and a realm-qualified spelling agree), or when the invite is still in this character's session-onlypendingInvites. 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:rememberPlayergained anownInviteargument, passed only byfn: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). TherememberPlayerheader 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.luadrives 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
/fgiin 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
!fgiofficer-chat command (other clients still send and answer it), and theFGI Debugchat-tab name. - Missed until Peer Review G3 (thread b4050289): the DeltaSync manual catch-up line still said
/fgi debug; it says/ugi debugnow. WT:IsRecruitreads 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.noteWhisperSendis a secure post-hook onC_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.takeWhisperSendspends 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.lowerfolds 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()) makesfn.recruitPausedByProtectiontrue 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.pumpClickdrains 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.luafoldsfn.inCombatPauseintoPump'slockedand intoScheduleAuto's retry test, so the timer re-arms on its existing lock retry and the welcome goes out after combat. fn.protectedPauseNoticesays "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:Sendreturns "combat" before gathering anything, so nothing is stamped and the first press after combat posts; an explicit press printsannounceMsgHoldCombat. Tests/zz_invite_holds_spec.luapins the pause in every mode, the resume and the pump hold;Tests/announce_channels_spec.luathe 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:isPlayerInMyGuildtreated afalsefromC_GuildInfo.MemberExistsByNameas final and only consulted LibGuildRoster on a client without that API. It now asks LibGuildRoster'sIsInGuildwhenever the client call says no; that function scans the live roster itself before the library is ready, so the oldIsReadygate is dropped.fn:addNewPlayerjudged 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/wowapipinned fromaf16344to11f9ff2(WoWAPITesting thread 20221b98, which FGI raised). The harness'sFCF_Closenow clearsinUseandisRegisteredon a temporary window, as the client does (Classic EraFloatingChatFrame.lua:1449-1453).- The
zz_functions_spec.luasetup that closes leftover whisper windows now selects them byinUse, the client's own marker, instead ofisDocked, which stood in for it while the harness kept a closed window in use.openWhisperWindowForinfunctions.luakeeps itsisDockedcheck: it is still correct, becauseFCF_Closeundocks. - 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:
## Titlenow 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 newDISPLAY_NAMEis the name under ESC > Options > AddOns and atop the panel;CATEGORYstays 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 fiveorfallbacks beside it match.FGI_Core.lua: the minimap button uses the newimg/minimap/UGI_MMB_Iconart 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 staysFGI, 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_PREFIXand 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 theUGI: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. TheFGI Debugchat-tab name is kept:functions.luauses it to find a tab the client has already saved. Tests/zz_chat_prefix_spec.luanow also fails if any shipped file still carries the old<|r|cff16ABB5FGIprefix; 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
compactToggleTooltipdescribed 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) readUGI,UGI - Guild Invite,UGI - Blacklist/Black List,UGI - Unblacklist;/fgi menuloglines 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 Debugchat-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: everyInterface\\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 LatinFGIorFastGuildInvite, outside the keptFGI Debugtab 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 throughLocale\Locale.xmland 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 andzz_locale_orphans_spec.luacaught the five stale lookups the first pass left (compactFrame.luax4,FGI_Core.luatemplate-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/wowapipinned from8391c1atoaf16344. The Adoption entries in between all read "ADOPT: nothing"; this repo passes no--timeout 0and sets nowow.errorObserver(tool: git grep, 0 matches).- New
.writ-suites.jsonat 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 forTests/wowapi/run.lua(no flag) outranked the declared ones, so a bare desktestcall still ran unflagged (Writ thread 41bffce5, citing itsinternal/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-errorson its command line (tool: background agents' reports of those run files). From.writ-suites.jsonitself, default order: 4558/0/11, argvlua 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 withthe 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.refreshWhisperHideDeadlineadds one topendingper chunk FGI sends (it is called once per chunk) and stampssentAt; a record older thanWHISPER_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_WINDOWreplacesFGI_WHISPER_HIDE_WINDOW,FGI_WHISPERED_WINDOWandWHISPER_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 bylineID(arg11; fallback text plusGetTime()) 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.hideWhisperand 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). ThelastLinerepeat check means a line both filters see is spent once. - The other player on a
CHAT_MSG_WHISPER_INFORMline 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 byfn.hideWhisper, the tray filter and the tray listener).fn.hideWhisperread 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 beforehandleNotFound: 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 byfn: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 clearwhispersgoes throughfn.clearEchoRecordsand 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.suppressPopoutForSendnow says whether it counted), and gives back exactly those when it goes (not found, expired, cleared) through one local,forgetEchoRecord.fn.consumeEchoreleases 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.forceRestoreWhisperModezeroes every record'scountedwith the count. - With "Let FGI manage whispers" off, a recruit's reply drops their
_recruitAlertPendingentry, 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 throughfn.managesWhispers()too. fn.hideAwayResponse(AFK/DND) hides only inside the 60 s window after FGI's last chunk.- The classic-family
FCF_OpenTemporaryWindowhook 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_outgoingWhisperson 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 throughfn.clearEchoRecords()(the Settings setter, beforeupdateWhisperEchoFilter), 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.removeMsgListandaddon._whisperSentRecently: written, never read.fn:resetSettingsno 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 clearwhispersclears 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_WINDOWearly or late. - KNOWN COST: an echo still in flight after
/fgi clearwhisperscan open a popout tab on Retail, because the clear gives the hold back at once. - Specs: the echo-hiding blocks of
zz_functions_spec, pluszz_manage_whispers_spec,zz_whisper_popout_spec,zz_forever_names_spec(lineIDs now passed),zz_whisper_alert_specandzz_reset_settings_spec. The popout-hold examples (counted chunks on drop and on consume, expiry release on lookup and on prune,countedreset 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 clearwhispersexample (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.lua3538/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 withfn.dropEchoRecordremoved (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_recruitAlertPendingwhile "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.luanow registers a guard as the harness'saround_each_fileafter-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 inECHO_FIELDSwith 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 intoFGI.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_alertandzz_functions. The popout spec's ownbefore_eachclear, which only hid the leak, is removed. - New
fn.releaseAllWhisperHolds(functions.lua): releases every inline holder, then puts the player's whisper mode back. ThePLAYER_LOGOUThandler and the test guard both call it, so the two cannot drift apart;zz_whisper_tabs_specchecks logout goes through it. Tests/zz_echo_leak_guard_spec.luapins the guard, 12 examples. Every one was seen red with what it covers switched off inTests/support/addon.luaor the spec, then green after the revert: hook registration, the inline-hold release andclearEchoStateraising (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 thepcallswapped for a bare call (20261019-114929.287-2438421790); a function missing from the load's snapshot, through a newM._fieldSnapshotseam, 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). Withzz_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 theECHO_FIELDSchange; 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
SetCVarstubbed 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 putsSetCVarback between the file and the guard. A file that ran before any spec requiredsupport.addonis not checked. Many specs setwow.timebackwards, 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 byDB.global.manageWhispers(account-wide likewhisperTabs, defaulttrue, 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_DNDfilters are removed. A new localhidesEchoes()(master ANDsendMSG) replaces the baresendMSGtest inrefreshWhisperHideDeadline,suppressPopoutForSend(the retail whisperMode flip),updateWhisperEchoFilterand the classic-familyFCF_OpenTemporaryWindowclose hook; WT:Enabled()answers false, so the compact tray's whisper tabs, their capture filter and their whisperMode hold all stand down;fn.hideNotFoundlets the server's "No player named X" line through for FGI's own whispers.
- the echo-hide state is not written, and the
- Switching it off takes effect at once: the setter calls
updateWhisperEchoFilter(filters out, whisperMode handed back) andWT: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'sCHAT_MSG_WHISPERhandler returns before anything else, so no sound, screen flash or tab flash, and no "Replied" mark (fn.recruits:onReplied);fn:sendWhisperno longer arms_recruitAlertPending, so switching back on does not alert on a reply to a whisper sent while it was off;Modules/Scan.lua'sCHAT_MSG_AFK/CHAT_MSG_DNDhandler 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_OpenTemporaryWindowhook, whichhooksecurefunccannot remove, and thehideNotFoundfilter). 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.luaonly; 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 assertswhisperModecomes back topopout, and a Retail example drives the hand-over inforceRestoreWhisperMode: FGI's send flippedwhisperModefirst, the tray hold took it second, and switching off must givepopoutback 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), andupdateWhisperEchoFilter'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:sweepStalePendingfiles 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 callfn.managesWhispers()instead of repeatingDB.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 asCHAT_MSG_SYSTEM, makefn:isInvitablerefuse them withnotfound_retry. This pins the claim that the not-found bookkeeping still bounds the retry loop with the switch off. Seen red withfn.recordNotFoundgated 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 = trueinFGI_Core.lua), so a settings template exports "on" instead of omitting it, and both are inSP.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 storetruefor on instead ofnil, 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:blackListAutoKicknow returns at once when the setting is off, after unregistering theOnRosterReadycallback 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 specTests/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
resumeis a FUNCTION, not a flag. WhenDialog:Showreplaces 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.ClickandEscapewait 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
Closeof it re-open it at once (Peer Review NEW-1, A1). NewDialog: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.showNextKickPromptdrops queue heads that are no longer on the blacklist (Remove all, Remove) before it asks; the Kick button re-checks the blacklist beforeGuildUninvite; and an answer removes the queue head only while it is still the name that prompt asked about. guildKickre-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:Deferand 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 bareClosere-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-Closeone 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 inModules/FGI_Dialog.luadisabled (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 sharedstubGuildUninvite()for the popup and displaced specs, which had four diverging local stubs;stubBlacklistBroadcasts()captures the OFFICER lines and the DeltaSync name push thatblacklistOfficer(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:ForgetDutyEdgeis a labelled test hook that resets the duty edge, used byTests/zz_baton_e2e_spec.luaso a simulated second client starts with no remembered duty, and itsafter_eachcloses 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
resumehandling 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'snot activecheck 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 inTests/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 underpcall. 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 inTests/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).geterrorhandlerjoined.luarc.json's globals. CoverageModules/FGI_Dialog.lua347/347 andModules/FGI_RecruitGoals.lua130/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 underxpcallwith the client's error handler as the handler, instead of calling the handler afterpcallreturned. 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 inTests/dialog_spec.lua, "reports a waiter's error with that waiter still on the stack": red underpcall(desk run 20261020-130352.076-1050322805, 64/1, the captured trace began at the dialog's report call), green underxpcall. Not tested in game. - FIXED (
Modules/Wingman.lua): the duty prompt had noresume, 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 inTests/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 withModules/FGI_Dialog.lua345/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 inTests/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): newDialog:ShowOrDefer(spec), the one "show me, or wait behind a dialog with a different id" rule the kick prompt (functions.luashowNextKickPrompt), the Wingman duty prompt and the recruitment-goal pop-up each wrote by hand (self-audit 65ac8eaa F3). It refuses a spec with noresume, the shape that let the duty prompt lose its question (F1). All three call it now. Four examples inTests/dialog_spec.luaunder "ShowOrDefer"; the stub inzz_recruit_goals_spec's personal-goal example counts both entry points. A fifth example pins thatShowOrDeferreplacing its own earlier copy keeps what waited behind that copy (Peer Review 7c731e70's follow-up): red withDialog: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'sresumeremoved (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 showedModules/Wingman.lua730/732). Two examples, the second a control with no such announcement. The file does not loadModules/Announce.lua, soFGI.announcethere is a stand-in answering onlyHasPerLayerAnnouncement, whose real answer is pinned inTests/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): everyMainWindow:OpenhookedOnSizeChangedon 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 liveMainWindow.framewhen 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 inTests/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 offalseinFGI_Core.luaso the template type gate can refuse a hand-edited non-boolean (it failed open with no default). The reader tests truthiness, sofalsebehaves asnildid.global.tabPrefs(main-window tab order and hidden tabs) is now catalogued (interface group), with anSP.LABELSentry because the per-tab controls are not named after the key.char.neverOnDutygoes inSP.NOT_A_SETTING:Recruiter:SetNeverOnDutysteps 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.DBis not seen by the sweep. The sweep now fails if any getter raises, since a raising getter's later reads would go unseen. SP:Applynow runs what the Settings setters run after writing:fn.updateWhisperEchoFilter(),fn.updateSystemMsgFilter(),WhisperTabs:Refresh()andMainWindow: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 inzz_manage_whispers_specandzz_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:Applynow 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()forwindowOpacity,UI.RefreshAllWindowLayers()forwindowLayer,fn.updateEscFrames()forkeepOpen,fn.setTabHidden("raceclassmatrix", ...)forshowRaceClassMatrix, andLibLocaleOverride:SetOverrideforuiLanguageOverride. Three more run only when the value CHANGED:fn.rememberWindowOpenforpersistMainOpen/persistCompactOpen, andfn:blackListAutoKick()forautoKickBlacklist.ApplyAtLoginruns before any window exists, so an unconditional re-stamp would write "was closed" over a character's real "was open", andOnEnablealready 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 onFGI.functionsand 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 toSP: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), andSP:Applydid 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, andSP:Applycalls 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_specpins that). Its order against the filter refresh has no effect: switched off, that refresh zeroes the echo count and every record'scountedanyway. 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'sClose, which runs no callback and so never advances the queue, leaving Mercyshock pending for the next file. Bothzzzz_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_eachruns even when an example fails), through one helper,clearKickQueue()inTests/support/addon.lua: it empties the queue'spendingandaskedlists 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. Theirafter_eachalso restores the blacklist they started with and callsfn:blackListAutoKick()so the roster callback is unregistered when the setting is back off. - A per-file guard in
Tests/support/addon.luanow 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 inModules/FGI_Dialog.luathat outlives the file: a kick prompt left up behind an empty queue failed the next file's "no dialog is up" assertion.askedcounts as well aspending: a leftoveraskedname 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'sclearKickQueue()removed (tool: desk test run 20261019-192543.584-906878796); four examples pin it inTests/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 itsbefore_each, so Bob is not a guildmate there, and empties the queue throughclearKickQueue()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.rosterrepublishes every cast change with that flag, so a file ending onsetReady(false)handed the next file a "still building" roster, and threezz_whisper_tabsexamples 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 inTests/zz_echo_leak_guard_spec.luapin 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 byroster.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 afterroster.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.lua3562/3562 andModules/FGI_Dialog.lua343/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, withModules/FGI_Dialog.lua359/359,Modules/Wingman.lua728/728,Modules/FGI_RecruitGoals.lua127/127 andfunctions.lua3559/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
uiLanguageOverridecode this client does not offer is handed toLibLocaleOverride:SetOverrideunchecked. 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-timefirstEvaluationwas 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, failingzz_templates_page_specwhen the two ran alone together. The example now restores the policy, and the drain's storm error inTests/support/addon.luanow 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, whichfn.refreshWhisperHideDeadlinewrites only whenDB.realm.sendMSGis on (doc: functions.lua, thesendMSGreturn 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._recruitAlertPendingis the gate now.fn:sendWhisperwrites it on every recruitment whisper, whateversendMSGsays.fn:resetSettingsclears 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:onRepliedno longer overwrites ACCEPTED or JOINED. REPLIED is a strong status, sosetStatuslet a reply after joining turn a member back into "the lead worth chasing". The reply time is still kept.- The spec's
sentTohelper switchedsendMSGon, which is why the suite never saw this; it now sends through the realfn: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:IsRecruitanswers 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:CapturedKeysasks it too, so a recruit who joins mid-conversation loses their tab and stops holdingwhisperMode, 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:Refreshcloses 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:Sendcould 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, andWT.chatFilterpasses 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
whisperModehold inWT:Refreshis 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_OpenTemporaryWindowhook (fn.installConversationTabSuppressionHook) asksWT: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.luafor all of it, the guild ones also through the real LibGuildRoster check; the hook's inTests/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.refreshresizes the tray withSetHeight, which only keeps the top edge still under aTOPLEFTanchor. It now re-pins the anchor throughsaveTopLeftAnchorwhenevercf:GetPoint(1)is anything else, before the resize. The resize grip'sOnMouseUpalso re-pins afterStopMovingOrSizing, 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'sSimpleFrameAPIDocumentation.lua; Blizzard clears the flag after its own drags,FloatingChatFrame.xml:678). Peer Review C1 named this as a candidate Retail cause. cf.refreshskips the re-pin while a drag or resize is live (cf.moving, set by the shareddragStart/dragStoppair the three drag handles now use, and by the grip): a scan can refresh mid-drag, and re-anchoring underStartMovingalso saved a mid-drag position (Peer Review C2). The tray'sOnHideclearscf.movingtoo, since a tray closed mid-drag may never run its stop handler, and callsStopMovingOrSizingfirst 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/GetLeftare 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.luapin it: a centre-anchored tray keeps its top edge as rows are removed, a width resize ends on aTOPLEFTanchor (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:
IsShownis the frame's own flag andIsVisiblethe parent chain (warcraft.wiki.ggAPI_ScriptRegion_IsShown), the same test Blizzard's chat frames make in theirOnHide. 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
OnHidecalledMainWindow:Close(); the window frame's ownOnHidehook 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 firesOnClosefromOnHide(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
UIParentkeeps 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 currentUIParent, 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, asCloseSpecialWindowsdoes, instead of calling its handler with the proxy still up. warcraft.wiki.gg'sUIHANDLER_OnHidesaysOnHidealso 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
OnHidefor a frame its parent already hid (Peer Review C1, threads a29fa8b7, b80a5e77 and cf2ded7f; warcraft.wiki.ggUIHANDLER_OnHidesays 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 showonly opens, the minimap button hides with the UI (LibDBIcon parents it toMinimap, underUIParent), and ESC brings the UI back first. Only a third-party launcher outsideUIParent(a data-broker display) or another addon callingMainWindow:Togglecould, and neither was checked.MainWindow:Close()firesOnCloseitself when the frame is still set after itsHide(). The tray'sOnHideteardown is one idempotent function, and a newcf.close()runs it aftercf: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.luanow drops its_Gsnapshot atunwatch()(WoWAPITesting thread 85e5323e). That snapshot held a pooled AceGUI button's global name, and with it the discarded matrix tab, which turnedTests/zz_tab_render_collectable_spec.luared 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

