Fast Guild Invite - Revived

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

FastGuildInvite-v2.19.0

File nameFastGuildInvite-FastGuildInvite-v2.19.0.zip
Uploader
PmptastyPmptasty
Uploaded
Oct 21, 2026
Downloads
556
Size
7.2 MB
Flavors
RetailMoP ClassicClassic TBCClassicForever
File ID
9272768
Type
R
Release
Supported game versions
  • 12.1.0
  • 12.0.7
  • 12.0.5
  • 5.5.4
  • 4.4.2
  • 3.4.5
  • 2.5.6
  • 1.60.1
  • 1.15.9

What's new

<FGI> FastGuildInvite

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[v2.18.0] (2026-10-18) — push the guild policy from the main window, and "Only my invitees" welcomes your invitees again, and the settings page scrolls past its name boxes

New — push the guild policy from the main window (GUI/MainWindow.lua, GUI/SettingsPanel.lua)

Vishiswaz on Discord, 2026-10-17: "FGI really needs a "Push guild policy to members" button on the main window for any tab that can change something that syncs with the guild, visible only to people with authorization to edit guild policy. It is quite annoying to have to always go back into settings for this"; the operator: "i have no issue doing it".

  • A new bottom-row icon (INV_Scroll_03, left of the horn) shows on the Announce, Filters and Eligibility tabs (MainWindow.POLICY_TABS) when addon.CanEditGuildPolicy() is true, and is hidden everywhere else. MainWindow:RefreshPushIcon runs on every tab switch and on PLAYER_GUILD_UPDATE / GUILD_ROSTER_UPDATE, because the guild rank the permission check reads is nil until the guild data arrives after login, and a GM can grant or revoke rights while the window sits on one tab (Peer Review F1, thread 4c25605c). It does nothing for a detached icon left by a release that skipped OnClose. Its tooltip warns of unpushed changes from addon.IsPolicyDirty, read at hover time.
  • The icon has no fixed slot: RefreshPushIcon moves the status bar's right edge to -295 (STATUS_RIGHT_PUSH) while it shows and back to -272 (STATUS_RIGHT) while it is hidden, and the horn is now anchored to the window where the -272 edge put it, so the rest of the row never moves (Peer Review G2). It is detached on close and on pool release like the other bottom-row icons.
  • A refused click is no longer silent (Peer Review G1): when addon.PushGuildPolicy returns false the icon re-checks its visibility, and if the player has lost edit rights it hides and prints "You can no longer edit the guild policy, so it was not pushed." in the client locale. The two enforce refusals already show their own dialog, so they print nothing more.
  • The help tooltip lists the icon under its own heading after the every-tab icons, so "visible on every tab" stays true.
  • The Settings button's whole push body moved into pushGuildPolicy (exported as addon.PushGuildPolicy), and both buttons call it, so the enforce-with-nothing-marked guards, StampPolicySave and the post-click redraw cannot drift between them. It now also refuses on its own when the player may not edit the policy, since the icon reaches it without the Settings button's disabled gate, and returns whether it pushed.
  • Not verified in game: the icon texture's look, and that the guild events fire at the moments described.
  • Specs: nine in Tests/zz_mainwindow_handlers_spec.lua ("the push-policy icon") and two in Tests/zz_settings_panel_spec.lua ("pushing the policy"); none run red first. The reopen-after-reload spec's frame finder now skips the roster-listening frame, which also lives in MainWindow.lua. Full suite 4409 passed, 0 failed (tool: test run 20261018-075311.874-2749380805).
  • RefreshPushIcon clears the status bar's points and sets both edges again rather than repeating SetPoint on BOTTOMRIGHT alone, since the client's handling of a repeated point is not documented (Peer Review, thread 4c25605c); the spec also asserts the left edge survives each refresh.

Fixed — the settings page scrolls past the multi-line boxes (GUI/SettingsPanel.lua, GUI/TemplatesPage.lua)

Vishiswaz on Discord, 2026-10-17: the Guild Policy name lists caught the mouse wheel, so the page stopped scrolling under the cursor. The operator: "they should not eat the mouse whell, we should be able to continue scrolling past them." AceGUI's MultiLineEditBox sits in a UIPanelScrollFrameTemplate scroll frame, which takes every wheel event over it. A new FGI widget, FGI_WheelPassMultiLineEditBox, is the stock box built by its own constructor and renamed, so AceGUI pools it apart from every other addon's boxes. Its scroll frame hands the wheel to the nearest AceGUI-owned ancestor (one carrying .obj) with an OnMouseWheel script, the AceConfigDialog page, unless the box has focus, in which case it scrolls its own text as before. Frames without .obj are stepped past rather than stopped at, since an InlineGroup's border frame has none, but are never called, so no Blizzard options frame's wheel handler runs from addon code (Peer Review W1, thread 38321dd4). Every multi-line input uses it: the forced whisper, the three priority lists and the Templates import box (the last found by the new spec, which walks the whole options tree). Six specs in Tests/zz_settings_panel_spec.lua ("the multi-line boxes"). Shown red: the non-AceGUI-frame spec failed with the .obj check removed (run 20261018-085423.767-2734542983), and all five wheel/type specs failed against the stock box (run 20261018-085532.958-2761012305), though four of those by an error, since the harness's scroll template installs no wheel script; that and the missing IsMouseWheelEnabled went to WoWAPITesting, which delivered both (harness pin moved 3d668f6 -> b34264c). On that pin the focused-box spec also asserts the box scrolls its own text. Full suite 4415 passed, 0 failed (tool: test run 20261018-091240.343-1552938486). Not verified in game.

Fixed — "Only my invitees" welcomed nobody off retail (FGI_Core.lua)

Pockit on Discord, 2026-10-17, TBC Anniversary: "Newest release broke Welcome Whispers if my Invitees only is on ... no whisper goes out and nothing in the /fgi welcome queue. Turn it off and works beautifully again." The join handler (LibGuildRoster's OnMemberJoined) is handed Name-Realm on every client (NormalizeName appends the realm), while off retail fn:normalizePlayerName leaves a name as it is, so the anti-spam list holds the bare spelling an invite from /who went out under. The gate read DB.realm.alreadySended[key] raw and never matched, so with either "only my invitees" option on, no joiner was ever this character's invitee. It now reads through fn.playerKeyed, the both-spellings lookup every name-keyed read is meant to use, and also counts a still-pending invite (fn.pendingKeyFor), since the library and FGI's own CHAT_MSG_SYSTEM handler both react to the same join line and the pending -> anti-spam promotion is not guaranteed to run first (not verified which runs first in game). A welcomer policy still bypasses the option, as before. Why Pockit saw it start with the newest release is not established: the v2.16.0 revert restored this raw index from v2.14.14, and the v2.15.0 code it replaced (removed helpers fn.playerKeyIn, fn.stampKeyFor) may have looked the name up more tolerantly (not verified). Three examples in Tests/zzzz_core_spec.lua ("with only my invitees on"); not run red against the old code first. Full suite 4398 passed, 0 failed (tool: test run 20261017-182505.036-944809604). Not verified in game.

[v2.17.0] (2026-10-17) — a guildmate's post holds your announcement in that channel, and clearer announcement readouts

New — a guildmate's post in a place holds you too (Modules/FGI_AnnounceSync.lua, Modules/Announce.lua)

Vishiswaz, 2026-10-17: "if I'm standing in Westfall and post the announcement to General and then Person B is standing in Elwynn ... They can then walk into Westfall, and just so happen to be on the same layer as me, and since they didn't see the announcement, their's goes off again way too soon", then "I would rather err on the side of caution, where the announcement cooldowns are just based on the last time anyone in the guild spammed that zone"; the operator: "i'm ok with doing it how vish wants".

  • When an announcer's own ad echoes back in a public channel (noteLayerSighting, sender is fn.isCommSelf), it sends L <id> <place> <epoch> <member> to the guild. The echo is the proof it went out, so a send that never echoes and is rolled back is never shared. A receiver merges it through Announce:MergeGuildLayerSighting exactly as a sighting (MAX wins; a rotation member sets that place's cursor). The login catch-up reply (R) now also whispers C with every place still inside its cooldown (Announce:GuildLayerSightings). A client without this change (v2.16.0 included) ignores both kinds, so it neither shares its posts nor is held by anyone else's.
  • L and C carry server time (Peer Review thread defcaba5). With each sender's time() on the wire, a guildmate whose PC clock ran ahead held a place for the skew on top of the cooldown, and one running behind held it for less, which let through the early re-post this hold exists to stop. The sync module now converts at the wire only (toWire / fromWire, offset fn.getTime() - time()), so Announce keeps comparing in time(), and a stamp still ahead of now after conversion is taken as now. S and T are shipped and keep the sender's clock. Four examples, each run red against the old code first.
  • KNOWN COSTS (replacing v2.16.0's last one, "a login or zone-in always takes a turn"): a zone-in or login on a DIFFERENT layer from the last guild post in that place sits out that post's cooldown, because the guild channel cannot say which layer it came from (the operator's chosen trade, above); a guildmate whose client is in another language names the channel differently, so their posts are not shared with you. A C reply that arrives before this client has the guild's policy is dropped (no profile to key it under), so a member's very first login learns of a place only from the next L there. Clock skew between two clients still shifts the guild-wide S / T cooldowns by the difference.
  • A spec pins that noteLayerSighting hands a Forever-shaped sender (Vishi Swaz-SomeRealm) to fn.isCommSelf unaltered and shares the echo; the real fold of that shape is pinned in zz_forever_names_spec. Whether Forever's CHAT_MSG_CHANNEL reports our own name in a shape that fold accepts is NOT verified in game.
  • Specs: Tests/announce_per_layer_spec.lua goes from 45 to 62 examples. Eleven pin a guildmate's post holding the place: shared only on our own echo, held in this zone and not another, released after the cooldown, never rewound by an older post, other guilds' ids and empty places ignored, and the login catch-up both sent and honoured.
  • NOT VERIFIED in game: two announcers on WoW Forever, same layer and then different layers.

Fixed — announcement readouts on channels you are not in (Modules/Announce.lua, GUI/Tabs/Announce.lua)

  • Peer Review S2, thread 7ec778de. v2.16.0's fallback to the saved record when no place resolves still read Ready for an announcer whose rotation is public-only and who is in none of its channels (Trade, out of a city), while Send had nothing to post. RotationStatus now returns not-eligible with a 6th value, notJoined, which GetStatus passes on and the Announce tab's rotation row reads, so the readouts show "not in that channel" instead of a zero countdown. The designated recruiter (no announcer rank) and rotations with a Guild / Officer channel keep the saved-record path. GetNextReadyIn still reads 0 for such a row, as for a plain unjoined announcement; Wingman consumes the click only when Send posts.
  • Peer Review T1, thread 43c9652a. The Announce tab's row for a plain announcement reads ProfileReadyIn, which had no notJoined, so out of Trade a Trade announcement whose cooldown had run still read "in 0S" there after both horn tooltips stopped showing it. ProfileReadyIn now returns a 3rd value, notJoined, by GetStatus's rule (and passes a rotation's on), and the tab shows the red text for it. Announce:NotJoinedText() (new) is the one spelling of that readout, used by StatusReadout and both of the tab's paths.
  • Peer Review F3, thread aea56400. A profile ticked for a joined Trade on cooldown and an unjoined General read "not in that channel" on the tab and the horn, hiding Trade's real wait, and GetNextReadyIn answered 0 so Wingman kept trying an announce that could not post. The minimum is now taken over the joined channels first; notJoined is set only when every waiting channel is one the player is not in. The aggregation was written twice (ProfileReadyIn and GetStatus's plain branch); both now call one local, plainReadiness, so the fix landed once.
  • Peer Review, same thread (LOW). An enabled announcement with an empty message (every channel IsEligible "empty") read "in 0S" on the tab and the horn, and GetNextReadyIn offered Wingman an announce Send had nothing for. GetStatus now gives it no row, the rule HasActiveProfiles already applied, and the tab shows "(no message)" (new enUS key; other locales fall back).

Fixed — a re-rendered tab kept its first copy alive (GUI/Tabs/Announce.lua, GUI/Tabs/GuildRoster.lua, GUI/Tabs/Eligibility.lua)

In game a tab is built again after a live language switch (MainWindow:RefreshLocale) or when a setting hides and re-shows it (RefreshTabStrip), and the old copy is only hidden and unparented. So before this, a rebuilt Guild Roster or Eligibility tab stopped updating on roster changes (the callbacks still repainted the first copy) and the old Announce copy kept its one-second ticker running (Peer Review thread 6b113780, N1; not reproduced in game). The test suite renders one fresh tab per example and its heap climbed to ~825 MB. A new spec, Tests/zz_tab_render_collectable_spec.lua, renders each of the 13 main-window tabs twice, discards the first copy, collects, and names the path from a GC root to whatever still holds it. It found three holders of mine:

  • Announce: every Render started a one-second ticker and never stopped the previous one, so each old copy stayed alive and kept ticking. Render now cancels the previous ticker (AnnounceTab._ticker). Shown red without the cancel.
  • Guild Roster: the roster callbacks and the rank event frame are registered once per session and captured the first render's scheduleRefresh, so a second copy never repainted on a roster change while the first stayed alive. They now call through GuildRosterTab._scheduleRefresh, which each Render repoints. Shown red first.
  • Eligibility: the same shape and the same fix. Not shown red before fixing.

The spec pins one holder outside FGI as known, by breaking that edge and requiring the copy to die: LibGraph-2.0's RegisteredGraphLine (Statistics), which LibGraph declined to weaken because the client never collects a frame (inbox 3d3b1cb7). The matrix tab's AceGUI widgets are pinned the same way by their global frame names. LibLocaleOverride's ddFontAddon held six tabs until it went weak-keyed at LibLocaleOverride MINOR 18 (inbox 0e40213c); the spec went red on that and the six rows were removed. MINOR 18 is in LibLocaleOverride's working tree, not a release, and the suite loads that tree.

The heap walk now runs only to name a holder after the collector has shown one exists, records edges without building strings, and gives up after 10 s with a message: the first version walked the whole ~800 MB heap twice per known-holder example and the file timed out at 60 s in a full run. The budget was 30 s. Two later bare runs (60 s per file) timed this file out with no example failing, so the walk never ran: the passing examples' own collections take ~69 s there (--times). The budget went to 10 s so that a real failure can report inside the limit; it does not make the file fit under 60 s.

Every form spec's re-render now goes through one helper, Tests/support/addon.lua's freshRoot(old), which hides the old root AND detaches it from UIParent. Nine of the ten form specs only hid it, so every example's copy of a tab stayed a child of UIParent for the rest of the suite (Peer Review thread f31ebddf, F3); the collectable spec now detaches its second copy as well. announce_channels_spec cancelled the earlier files' timers by setting cancelled by hand, which skips the harness's own cancel, so each kept its callback; it now drops fn too. End-of-run heap: 711 MB -> 467 MB (tool: test run 20261017-154448.989-4093511682, --times, 4394 passed, 0 failed). The 711 MB run predates the pooled-widget .parent clear below as well, so the drop is not this change's alone; how it splits was not measured. The collectable spec itself takes 69 s of CPU there, inside the declared suites' 300 s per file but over the bare runner's 60 s default.

Fifteen more specs built a tab per example and never let it go (Peer Review thread f31ebddf, F4). A second helper, releaseHost(host, ...), hides each raw frame, clears its points, detaches it, then releases the AceGUI host; the scan strip, scan refresh gate, Eligibility and Guild Roster refresh, roster sections, Filters save, dialog call-site, class-filter, rotation-reorder, row-list and Scan press specs now call it in after_each (or finally). The Settings Tabs page and Templates page specs called ACD:Close, which is a no-op for a container passed to ACD:Open (AceConfigDialog-3.0.lua:1818-1824, :1891-1911); they release the host themselves. Three refresh specs now Show() their host, because the next example gets the pooled host back hidden. Clearing the points matters: without it each dead copy stayed in the harness's anchor index for the reused host, and the scan strip spec went from 1.3 s to 52 s of CPU. End-of-run heap 467 MB -> 274 MB; the collectable spec 69 s -> 17 s (tool: test run 20261017-163814.228-668256687, --times, 4395 passed, 0 failed). The exemption list now carries every frame-name family Ace3 gives, including the EditBox's AceGUI-3.0EditBox and the TabGroup's tabs, with an exemption-test row for each.

The record-and-release loop those specs wrote out five times is one helper now, renderTracker(): :host() builds the SimpleGroup and content frame a tab renders into, :root() a fresh root, and :releaseAll() releases everything recorded (Peer Review thread 492e7d96, F3). The every-tab render spec kept all 47 tab copies it builds on the claim that a released container would be handed back out under the tabs' own references; it releases them through the tracker now, like every other render spec. The Eligibility form spec hid its root and then forgot it, so the next render had nothing to detach and every example's copy stayed under UIParent; it detaches it (full suite 4395 passed, 0 failed, tool: test run 20261017-171557.399-3050463009). A further teardown pass over more specs took the end-of-run heap from 274 MB to about 180 MB (tool: test run 20261017-174702.219-931498835, --times, 4395 passed, 0 failed); which specs it touched was not reviewed when this entry was written.

Harness pin 41b150e -> 3d668f6 (79e230c plus its Adoption log entry): a spent C_Timer entry now drops its callback (inbox 357b7d2e, which FGI requested). Tests/support/addon.lua's stand-in that did that is gone; releaseDropdownLog() keeps the other half, emptying UIDROPDOWNMENU_ADDED at each file boundary, which the harness documents as the consumer's job. End-of-run heap: 825 MB -> 711 MB (tool: test run 20261017-141638.455-324097916, 4394 passed, 0 failed).

The Race/Class example failed once in a full run and could not be repeated; --order shuffle:42 reproduced it. Two mistakes of mine in the spec, both about AceGUI's widget pool. The known-holder rule dropped only global names the example had added, but in a shuffled order the tab is handed a pooled button an earlier spec created, so AceGUI30Button26 was already in _G; it now drops any AceGUI frame whose frame or widget parent chain reaches the discarded copy. And AceGUI:Release never clears a widget's .parent (AceGUI-3.0.lua:172-207), so a pooled edit box still pointed into a container the tab took from the pool; the spec now clears .parent on every pooled widget before it collects (the next AddChild sets it anyway). Green in all five orders: default, reverse, shuffle:12345, shuffle:42, shuffle:1, 4394 passed, 0 failed each (tool: test runs 20261017-143442.902-2100798764, 20261017-143732.959-1280462900, 20261017-143622.216-3175633870, 20261017-143152.493-2759731327, 20261017-143309.818-548482989). No 300 s hang in any of them.

Changed — LibGuildRoster's chat lockdown guard is now tested, not taken on a reading (Tests/zz_chat_registration_spec.lua)

Self-audit S77. The lockdown-guard walk exempts LibGuildRoster10Frame because the library guards its own chat handlers, and that exemption cited line numbers that no longer exist and an IS_RETAIL gate the library has since replaced with a payload test (issecretvalue, read at call time). Six new examples drive the frame's installed OnEvent with wow.secretValue payloads. The CHAT_MSG_SYSTEM half is proven red-first: a control blinds issecretvalue and the same secret then raises. The CHAT_MSG_GUILD/_OFFICER half is not: the harness's secret answers type() with "table", so NormalizeName's string check drops it before the guard matters, and those examples are a no-error pin only. A secret that answers "string" was asked of WoWAPITesting (inbox 0996b65e).

Harness 41b150e delivered it (wow.secretValue(label, { type = "string" })), and the pin moved there. Every secret in the describe is now that kind, and the guild and officer examples gained the same blinded-issecretvalue control as the system one: both raise without the guard, so all three halves are now proven red-first.

Changed — the guild-leave spec reads the harness's guild event log (Tests/guild_events_spec.lua)

The same pin crossed harness be92d12, which models the guild event log (QueryGuildEventLog, GetNumGuildEvents, GetGuildEventInfo) as globals the env owns and reinstalls on guild.reset(). This spec kept its own stand-ins for all three, and rosterOf() resets the guild two lines after the stand-in went in, so 24 examples read a query counter nothing incremented. The stand-ins are gone: the spec writes rows with guild.setEventLog, asserts on guild.eventLogQueries, and sets eventLogAutoDeliver = false because it delivers each reply itself. rosterOf() now carries the log, the counter and that switch across its mid-example reset. One assertion changed: an entry read back from the log now carries age = 0, because the harness returns the four "since" numbers the client always returns, where the stand-in returned none. The no-ages example keeps its own stand-in, since it models a client shape the harness does not express. Peer Review (thread 9ae8e8df) found the move to real ages left the time-seed check's no-age branch with no example (the remaining no-ages example uses a legacy true seed that returns before the age is read); a new example in "seeded by time" pins it, and the retail-API example now restores C_GuildInfo and the bare query itself instead of relying on the next example's guild reset.

Fixed — a test logout left the database without its defaults (Tests/zz_whisper_tabs_spec.lua, Tests/support/addon.lua)

AceDB's own PLAYER_LOGOUT pass calls RegisterDefaults(nil) on every database (AceDB-3.0.lua:359). zz_whisper_tabs_spec's logout example fired it and never registered FGI's defaults again, so under --order shuffle:12345 every later file ran with FGI.DB.defaults nil, and zz_reset_settings_spec read inviteType back as nil after ResetDB (red: test run 20261017-141853.664-871220860). That example now registers them again, and app.initDB() restores them whenever they are missing; with none set, RegisterDefaults strips nothing and only fills (AceDB-3.0.lua:422-440). Green at that seed with no hang (test run 20261017-142130.547-848805548, 4394 passed, 0 failed), which is the order todo t9e8105a80aa07275's 300 s hang was seen in.

[v2.16.0] (2026-10-17) — every announcer posts the guild's announcements on their own layer, and Ultimate Guild Roster ban import

New — import Ultimate Guild Roster's bans, and one warning per banned guildmate (functions.lua, GUI/Tabs/Blacklist.lua, GUI/SettingsPanel.lua, FGI_Core.lua, Modules/FGI_SettingsProfile.lua)

Vishiswaz on Discord, 2026-10-14: "Can we please get support for regularly importing the blacklist from ultimate guild roster, just like we have for GRM and GIL?" and "Also make sure that only one addon warns when a member of both blacklists is found to be in the guild".

  • fn:importUGRBlacklist(quiet) reads Ultimate Guild Roster's active bans through public methods on its AceAddon object (GetActiveBans, IsBanned, WarnsAboutBans) and never from UGR_DB: UGR keeps its ban table on disk below the ban rank and reads nothing from it there, so reading the saved variable would import a demoted officer's stale bans. Those methods do not exist in UGR yet -- they are asked for on UGR's inbox (contract a552781e). Every method is feature-detected, so until UGR ships them the import reports that UGR is not available and the warning rule below never fires. Reason <UGR> or <UGR> reason; same realm, tombstone and refresh rules as GRM/GIL.
  • Regularly: a new ugrAutoSync toggle (off by default, like the GRM and GIL ones) imports once on LibGuildRoster's OnRosterReady and quietly (no chat line, no banner) on every UGR_BANS_CHANGED. Both are events, no timer. Registered on fn, because addon already holds the blacklist sweep's OnRosterReady handler and CallbackHandler keys by target. UGR was also asked to send UGR_BANS_CHANGED once when its bans first become readable, in case its ranks load after the roster.
  • One warning: the blacklisted-member-in-guild prompt is not opened for a player when UGR says it warns about that player itself (WarnsAboutBans() and IsBanned(name)). UGR is asked with the roster's Name-Realm, not the bare queue key: UGR completes a bare name with the asker's realm, so a connected-realm member asked bare would miss their ban. KNOWN COST: for those players FGI's Kick and Remove buttons are not offered; UGR was told it can ask for the opposite (FGI's prompt stays, UGR goes quiet) on the same thread.
  • An Import UGR button on the Blacklist tab, shown only when UGR is loaded, and an Import UGR bans now button under the toggle in Settings.
  • The three importers' shared work (the connected-realm set, tombstones, the refresh rule, the summary line) was three copies of one block; it is now fn.newBlacklistImport, used by all three.
  • Peer Review ba6c0204, all four findings fixed:
    • P1, privacy. UGR shows bans to officers only, and guild:blackList reaches every guild member of any rank, so my first version would have handed officer data to the whole guild. fn.isUGRReason names a <UGR> entry, and Modules/FGI_DeltaSync.lua's GetItemMap(BLACKLIST) now answers a copy without them, so they are in no hash, baseline, diff or live push. ApplyEntries lets a guild entry for the same player replace a local <UGR> one, or the two hashes would differ forever and the peer would re-offer it every exchange. Removing a <UGR> entry by hand writes a local DB.realm.ugrRemoved mark, not a synced tombstone, which would have published the name and deleted a guildmate's own entry.
    • P2. fn.registerUGRAutoImport now runs the import inline when LibGuildRoster is already ready, as the kick sweep does; OnRosterReady fires once per guild, so after a /reload the login import never ran.
    • P3, unbans. Each import drops the <UGR> entries UGR no longer lists, with no tombstone, so a re-ban imports again; a typed reason is never touched. Without it, an unban in UGR made UGR go quiet and FGI start warning, Kick included, from the stale entry. The summary gains a removed count.
    • pcall. GetActiveBans, WarnsAboutBans and IsBanned are called through pcall. A UGR error now reads as "UGR does not warn", so FGI warns (one warning too many, never none) and the kick sweep carries on for the remaining names. A failed GetActiveBans prunes nothing.
  • Peer Review fc07ba20, the second round:
    • N1. A nil from GetActiveBans now reads as "UGR not available" and prunes nothing; my first fix took nil as success, so it would have dropped every <UGR> entry. {} (UGR's answer below its ban rank, or after a demotion) still prunes, which is meant.
    • F1. A guild tombstone no longer touches a <UGR> entry: the UGR import ignores blackListRemoved (new third argument to fn.newBlacklistImport) and checks only DB.realm.ugrRemoved, and ApplyEntries keeps a local <UGR> entry when a tombstone arrives. With either half alone, a guildmate removing their own entry for a player would have stopped an officer's UGR ban for that player reaching FGI, or made each catch-up delete it and the next import add it back.
    • F2. fn:blacklistRemove resolves the stored key before deciding it is a <UGR> entry, so a case-variant caller no longer publishes a synced tombstone for an officer-only name.
    • fn:blackList clears the ugrRemoved mark for a player re-blacklisted by hand, and Bridge:PushName reads the one entry instead of copying the filtered list, and returns for a <UGR> one.
    • All three importers' tryAdd now looks the name up with IsInBlackList(name, "exact"), so a hand-typed entry stored in another case is recognised instead of getting a second imported row beside it (Peer Review 66664a2e).
    • UGR was asked to answer nil rather than {} while its ranks are still loading, so FGI's loud login import cannot report bans as removed and then quietly re-add them.
    • Ultimate Guild Roster has built the requested API (Modules/Bans.lua, read-only block at the end; thread a552781e) and ships it in its next release. GetActiveBans answers {} below the ban rank, and UGR_BANS_CHANGED with no id fires whenever the list becomes readable or stops being.
  • Specs: Tests/zz_ugr_import_spec.lua (new) and four examples in Tests/zzzz_blacklist_kick_popup_spec.lua. The UGR stand-in is registered in AceAddon's own table, so fn.ugrAddon runs as in game. Full suite 4,379 passed, 0 failed. Not run in a game client; it cannot be until UGR ships the methods.
  • Player notes: the UGR import and the one-warning rule are in docs/Curseforge_Description.html (v2.16.0 and the Blacklist feature list) and Modules/intro.lua.

New — every announcer posts the guild's announcements, layer by layer (Modules/Announce.lua, Modules/FGI_Recruiter.lua, GUI/Tabs/Announce.lua, Modules/Wingman.lua)

Vishiswaz on Discord, 2026-10-13: on WoW Forever the public channels only reach players on your own layer, so one designated announcer per guild only ever advertises on one layer. They named General, Trade and LocalDefense; the operator, 2026-10-17: "The conversatoin around these channels were examples, not wants" and "what the user DID want is the ability to have ALL the FGI announcers do announcements if they are in different layers, for the designated channels."

The design is the operator's, 2026-10-13: "all the announcers that are on, can post, but if you are in a zone, and see oen of the other announcers announcing, you skip that rotation because you're in the same layer", and "so we have a stacked ranked list already, use it. announcer one goes, if announcer 2 doesn't see it, it goes, then 3 then so on and so on."

  • Every enforced guild announcement and rotation, every public channel it is set to. No switch and no list of channels: every announcer posts it, not only the designated one. Guild and Officer and personal announcements keep the normal rules. My first build of this put it behind a per-announcement Layer checkbox and only for General, LocalDefense and Trade, recognised by ChatChannels ID; I read the reporter's examples as the scope. The checkbox, its two locale strings, the perLayer snapshot field and the ID allow-list are removed.
  • Seeing the ad is the layer test. The CHAT_MSG_CHANNEL handler records a session-local sighting whenever a line in a public channel is the rendered text of a guild announcement, from anyone, our own echo included. A sighting is keyed by announcement and the joined channel's full server name (GetChannelName on the event's channelIndex, channelBaseName when the slot has none; both NeverSecret in the Forever docs), which the send side reads the same way. So "General - Durotar" and "General - The Barrens" are two places, "Trade - City" is one channel in every city, a localised name works because both sides read one client, and a channel that is not split by layer needs no special case: the first announcer's post reaches every other one and they all skip. At the click the place is read again, so a zone change between gathering and posting uses the new zone. Our own successful send stamps the same record. Sightings are never saved and never synced.
  • A guild rotation takes the same turns (Peer Review P1, thread a0e6585b: I had left rotations out on my own call). Its sighting is kept under the rotation's id, whichever member posted, and every member's text counts. A per-layer post never touches the rotation's saved cursor or guild-wide interval, and nothing is broadcast, because those still time the rotation's Guild and Officer posts and every layer moving them would starve them. Each place keeps its own session-local cursor (layerCursors) instead: a per-layer post advances it, an unechoed one puts it back, and seeing a member's text sets it to that member, so announcers on one layer keep one order. A place with no cursor yet starts after the member this session last posted or saw anywhere (lastLayerMember), so a zone change carries the order on; with neither, after the saved cursor.
  • Peer Review R1/R2 on the same thread. R1: the place's cursor used to be the saved rotation cursor, which the guild sync re-seats on every Guild-channel post from any layer (mergeRotation), so a layer could skip or repeat a member; hence the separate per-place cursor. R2: the per-place rotation gate no longer also asks memberChannelReady, whose 60 s floor reads the member's channel record, which a per-layer post never writes; the place's own turn is the gate.
  • Peer Review S1/A1, thread 7ec778de. S1: RotationStatus read only the saved rotation record, which a rotation set only to public channels never moves any more, so once its interval had run the row read Ready at the same member forever and GetNextReadyIn told Wingman there was announce work when Send had none. For an announcer it now takes the soonest place's turn (layerDueIn) and that place's next member; the saved record still answers for the rotation's Guild / Officer channels. A1: a per-layer post now also stamps the sighting of every other guild announcement or rotation whose text is the same (as its echo would), so a second click before the echo returns does not post the line again under the other id; a post that never echoed puts those back too.
  • Peer Review P2/P3 on the same thread: the sighting's place is built only after the line is known to be the guild's ad (every public channel line used to pay for a GetChannelName), and both the slot's name and channelBaseName go through isSafe before :lower(), as the v2.2.3 guard does, since the event is SecretInChatMessagingLockdown even though both args are documented NeverSecret.
  • Announcers take turns. Recruiter:AnnouncerRank() (new) is my place in the election's order: an assigned holder, then the priority list with each entry's alts, through the same eligibility gate. With no list, or nobody on it online, it is everyone online known to run FGI, sorted by name. A per-layer channel is eligible once seen + cooldown + rank × 8 s has passed (or, with nothing seen this session, 8 s per place after the first look). Chat may only be sent from a click, so the wait makes a click ineligible rather than scheduling anything. A member who is not an announcer right now (rank nil) keeps the normal rules.
  • A per-layer post never stamps or pushes the guild-wide cooldown record: the guild sync crosses layers, which is the bug being fixed.
  • A round keeps its items across clicks, so each per-layer item is checked again at the click and dropped, unstamped, if the ad has been seen in that channel since.
  • A soundboard press by a member who is not the designated announcer still waits for their turn.
  • Wingman stays on announce duty for an announcer when the guild has an enforced announcement on a public channel (HasPerLayerAnnouncement, and Recruiter:AnnouncerRank() not nil -- a member who is not an announcer could never post it, so it no longer keeps them on duty).
  • The match is on a folded form, not the raw bytes (Peer Review F1, thread ca4216cb). GUILDLINK is the guild-finder link on a client with the listing cached and <Guild Name> on one without (fn:msgModBroadcast), and a link's payload and colour are the client's own, so two guildmates' renderings of one ad differ. Both sides of the comparison now turn every hyperlink and every <Guild Name> into one marker and drop colour codes, so either client's line matches the other's.
  • The poster of a sighted ad is noted as running FGI (Recruiter:NotePresence). With no announcer list the order is built from who each client has heard from, and that is only stamped by inbound sync traffic, which need not flow both ways: A can know B while B knows nothing of A, and then both rank themselves first and post together (Peer Review F3). After one such round each has seen the other's ad and the two orders agree. The player's own echo is never noted (fn.isCommSelf): the Templates send list reads the same set, and noting ourselves offered our own name there (Peer Review G1).
  • The fold also trims the ends, so an ad saved with a stray trailing space still matches. The <Guild Name> spelling now comes from one helper, fn.guildNameTag (Modules/FGI_Placeholders.lua), used by both fn:msgModBroadcast and the fold, so the two cannot drift apart.
  • The echo check accepts the folded text too (confirmDelivery). Exact text is tried first; failing that, an echo whose link payload or colour came back rewritten still confirms the post, instead of reporting "did NOT go out" and rolling back the stamps of an ad the sighting already saw. This covers every announcement, not only per-layer ones. Whether the server ever rewrites an echo is not verified.
  • KNOWN COSTS: two announcers who click inside one second of their turns can both post; a group join that moves you to another layer keeps the old layer's sighting until its cooldown ends, so you sit out one round there; with no list, two clients can disagree about the order until each has seen the other's ad once; and a login or zone-in always takes a turn, even on a layer served a minute ago, because chat from before you arrived cannot be seen (Peer Review F2).
  • NOT VERIFIED in game: the two-announcer, one-layer test, and the guild-link folding against a real guild-finder link.
  • Replaces the first build of this feature, which worked the layer out from creature GUIDs (Modules/FGI_Layer.lua, deleted with its TOC lines) and synced zone-and-layer records through the guild. The "layer not known yet" readout and its chat notice went with it.
  • Specs: Tests/announce_per_layer_spec.lua rewritten (45 examples: ten for a guild rotation; LookingForGroup, a GuildRecruitment channel and a custom channel all post; a sighting in a channel not split by layer holds the next announcer; the event's channel name is used when the slot has none; both directions of the guild-link rendering, the trailing space, a rewritten echo still confirming, presence from a sighting but not from our own echo, and a post that never echoed leaving nothing seen -- Peer Review F4); Tests/recruiter_spec.lua pins the line-up (6 examples, one for the F3 disagreement).

Changed — reverted: the v2.15.0 "Only my invitees" change (FGI_Core.lua, functions.lua, GUI/SettingsPanel.lua, all Locale/*.lua)

The operator, 2026-10-15: "you need to back out any changes you made due to pockit's comments, as we don't have enough information to change anything", and with a guild policy on, one welcomer is the intended behaviour. I made the v2.15.0 change from one Discord report without asking first. The welcome gate is back to its v2.14.14 form: iInvited is presence in DB.realm.alreadySended, and a welcomer policy (Recruiter.HasWelcomerPolicy, true for a welcomer list or, with none set, the announcer list it inherits; read by hasWelcomerPol in FGI_Core.lua and by fn.welcomeGate for /fgi welcome) bypasses the option. Removed: fn.iInvited, fn.welcomeBypassesInviteeGate, fn.inviteStampFresh, fn.clearInviteStamp, fn.playerKeyIn, fn.stampKeyFor, INVITE_ANSWER_WINDOW, ACCEPTED_MINE_WINDOW and addon.acceptedMine; clearPending, promotePendingToAntiSpam and sweepStalePending no longer clear the invitedForAccept stamp. The two "Only my invitees" tooltips are back to their v2.14.14 text in every locale. The specs that pinned the reverted rule are gone from Tests/zz_welcome_gate_spec.lua, Tests/zzzz_core_spec.lua and Tests/zz_scan_system_handler_spec.lua.

Kept, deliberately: fn:recordAccept still reads its pending entry through fn.pendingKeyFor (Peer Review 47699b43, C1). It fixes accept counting, not the welcome gate: a join spelled bare for a realm-qualified invite left the entry in flight, and the hourly sweep then logged a player who had joined as Unresolved. pendingKeyFor predates v2.15.0 (v2.14.13). Pinned by Tests/zz_scan_system_handler_spec.lua ("promotes a realm-qualified invite when the join is spelled bare"), which fails with the raw lookup put back.

Fixed — a refusal or a "not found" was blamed on the wrong recruit when several invites were out (Modules/Scan.lua)

Peer Review ab006fa8, P1 and P2. Both handlers took next(addon.pendingInvites) on a "single-in-flight invariant" that no send path enforces: fn:invitePlayer writes a pending entry on every send in every mode and the chat menu does the same, and an unanswered entry lives an hour.

  • P2, not found. handleNotFound had the parsed name and discarded it, so B logging off logged A as unreached, cleared A's invite and counted towards suppressing A from scans, and B stayed in flight. The user's own /w to someone offline was also filed against a recruit whenever anything was in flight. It now resolves fn.pendingKeyFor(name) and bails when that is nil.
  • P1, nameless refusal (trial, veteran, opposite faction). The message names nobody, so it is matched by time: the one non-Type-3 pending entry sent within GUILD_REJECT_WINDOW (2 s) of now, since the server refuses at once and Type 3 sent no invite. 2 s rather than the 10 s first written (Peer Review bb2a76b2, L1): two invite clicks inside the window leave a refusal unattributed, and the decline branch already treats an answer under 1.0 s as the server's own. Two such entries, or none, and it is logged and left rather than guessed. Before, an older recruit was anti-spammed for somebody else's refusal and the refused player was offered again.
  • Six new examples, red first (three failed; the other three passed only because next() happened to return the right entry): two pending with a not_found naming the second, a not_found for a name not in flight, and a refusal with an older entry, two fresh entries, a Type 3 entry, and one old entry. The spec's inFlight helper now writes Type 1 instead of reading DB.global.inviteType. The leak that exposed it: Tests/zz_reset_settings_spec.lua's last example set inviteType = 3 and never put it back, so every later file in a full run inherited message-only mode. It now restores it through the file's onCleanup.
  • Release-prep doc pass: the version became v2.16.0 (two New features), Modules/intro.lua gained the missing "Only my invitees" Changed bullet, and README.md gained the Ultimate Guild Roster import.

Fixed — a test file left a stub copy of Announce listening to chat (Tests/broadcast_tokens_spec.lua)

It loaded Modules/Announce.lua into a stub addon table without the frame teardown every announce_* spec takes, so in reverse order its listener answered announce_channels_spec's chat events and failed 8 examples on a missing isCommSelf. It now takes teardown(require("support.isolate").frames()).

Fixed — the dev sync copied files the release excludes (wow-version-replication.ps1)

Reported by Ultimate Guild Roster (inbox c75cf61f). A .pkgmeta glob's * compiled to [^\\]*, so "*.txt" matched only root-level files, while the packager's shell case match lets * cross folders. Libs\LibDeflate\LICENSE.txt was replicated into every other flavour though the zip leaves it out. * and ? now cross folders and the **\ special case is gone; the dry run now prunes that file.

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

This mod has no additional files