promotional bannermobile promotional banner

Fast Guild Invite - Revived

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

FastGuildInvite-v2.18.0

File nameFastGuildInvite-FastGuildInvite-v2.18.0.zip
Uploader
PmptastyPmptasty
Uploaded
Oct 18, 2026
Downloads
1.1K
Size
7.2 MB
Flavors
RetailMoP ClassicClassic TBCClassicForever
File ID
9242185
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.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.

[v2.15.0] (2026-10-12) — copy the guild policy from another character, goals hold scans, and one welcome whisper per joiner

Changed — a met recruitment goal now holds scans as well as invites (Modules/Wingman.lua, functions.lua)

Vishiswaz on Discord, 2026-10-12: with only invites held, "the list of potential recruits on a populated server keeps growing and growing and is irrelevant by the time invites are able to go out again", and "I just wanted scans and invites to stop. Not announcements." The operator, the same day: "the recruiting goals should stop invites too ... and it should stop scans". This widens the v2.14.14 rule (invites only); announcements are still never held.

  • fn:nextSearch refuses at a met goal before anything else, with the explicit goal notice, so the Scan button, the F6 keybind and the legacy window are all held. A /who already in flight is left to land.
  • Wingman's scan step checks the goal itself (the once-per-goal notice) and passes the click on, as its invite step does. goalStopsSession now stops a session when Scan or Invite is on and Announce is off, so the default Scan + Invite session switches off at the goal and back on when it clears. Ticking Announce on is now the only step change that resumes a held session at a met goal. The resume reason reads "Announce is on". The right-click step menu marks Scan as held too.
  • The wingmanMsgGoalOff fallback text says scanning and inviting are held, and so does its enUS row. The guild-policy goals description is rekeyed in enUS (see the goal-text section below). No other locale has a row for either key, so all 30 show the English.
  • Specs: Tests/zz_recruit_goals_spec.lua. Rewritten to the new rule: the scan press, the scan, the default drain and the step menu. New: "goes out again once the goal itself is raised" (scan), "with Announce on as well" (three examples), "switches off a scan-only session at the goal", and "stays off when doScan is ticked on while the goal is still met". Red with the three gates put back to the old rule: 7 failed (run 20261012-144030.316). The step-menu example has its own red run: with only its held= line removed, exactly that example fails, 76 passed, 1 failed (run 20261012-145544.781).
  • Player notes: a Changed bullet in docs/Curseforge_Description.html and Modules/intro.lua.
  • Testing mode in fn:invitePlayer now runs after the protection pause and the goal hold, as the manual invite paths already did. Before, a test click at a met goal printed "Would invite" and took the candidate off the queue (Peer Review 05d66762). New example in zz_recruit_goals_spec; not shown red. The stale "a goal does NOT hold the scan" comment in fn:nextSearch is gone.

Changed — the one-message-per-click rule is stated once (FGI_APICompat.lua, functions.lua, Modules/Scan.lua, Modules/Wingman.lua, Modules/FGI_Welcome.lua, FGI_Core.lua)

Peer Review (thread bcbd0fac, sideways medium) found the rule stated four contradictory ways. Some comments said every chat send needs a hardware event and that the client allows one chat message per hardware event; others said WHISPER and GUILD need none. The keybinds were read as breaking the rule by draining an owed message and then inviting in one press.

The rule is now written once, as THE CLICK RULE on API.SendChatMessage, which every FGI chat send goes through, and every other site points there. What the client gates, per chat type (warcraft.wiki.gg API_C_ChatInfo.SendChatMessage, and the harness's env/protected.lua "THE CHAT RULE"): CHANNEL everywhere, SAY and YELL outdoors, WHISPER/GUILD/OFFICER/PARTY/RAID never; on retail, also SendWho and guild invite. What FGI chooses: owed messages wait for a click under the operator's no-timers rule (the welcome's welcomeAuto is the exception), at most one per click. That cap is FGI's pacing; whether the client would refuse a second message in one hardware event has never been measured. So a click may drain one owed whisper or guild line and then do its own action, and the keybinds are correct as they are. Every owed message is a WHISPER or GUILD line: the two pumps are the welcome (GUILD, WHISPER) and the type-4 decline whisper. No behaviour changes. The wrong statements corrected: the pump's header in functions.lua, three in Modules/Scan.lua (including round 91's "SendChatMessage needs a hardware event", which a WHISPER does not), the Wingman overlay's "WoW allows ONE chat message per hardware event", the old note in Welcome:Pump, the F5 keybind comment, and the wrapper's own "there is NO timer-safe chat send on retail".

Fixed — a long whisper's pieces are sent in order (functions.lua)

fn:sendWhisper walked the pieces of a message split at 255 bytes with pairs, which does not promise array order in Lua 5.1, so in principle a two-part whisper could arrive second part first. It is ipairs now. No field report; found reading the split for the click rule above.

Changed — the goal text says a met goal holds scans and invites (Locale/enUS.lua, GUI/SettingsPanel.lua)

The Guild Policy "Recruitment goals" tooltip now says scanning and invitations both stop, from the buttons and from Wingman, and announcements carry on. It is two word-only keys; the \n\n between them and the gold 0 (passed in through %s) are added in code. wingmanMsgGoalOff reads "scanning and inviting are held, and Announce is off". No other locale carried either row.

Fixed — translatable strings keyed on their words, not their layout (GUI/SettingsPanel.lua, all Locale/*.lua)

Peer Review c984ab8f F4: a spacing change in English had silently orphaned every translation of seven rows. The leading \n\n and wrapping colour codes moved out of the policy, anti-spam lock, welcome-blank, byte-counter and credits keys into the calling code. Every locale was rekeyed, each value losing the same prefix and suffix as its key. The locked rows split into a label and a shared "(set by " key whose translations carry their own leading space, because the CJK rows open with a full-width bracket. The green author names stay inside the credits keys, because several locales transliterate them. Tests/zz_locale_escape_values_spec.lua now refuses any enUS key, or any translation file key, that opens or closes with a newline; it was not shown red against the old tree, only its detector self-test was.

Fixed — the +(N)sel help line no longer blames a game limit (GUI/MainWindow.lua, Locale/enUS.lua, Locale/hiIN.lua)

Peer Review 92aad9ec F2: the line said WoW's hardware-event gate allows one invite per click on every version. Under the click rule above that is FGI's pacing, not a client gate everywhere, so it now says FastGuildInvite invites one checked player per click by design. hiIN carries the matching Hindi; no other file had the key.

Tests — computed locale tables (Tests/zz_locale_semantic_keys_spec.lua)

Every SCAN_COUNTERS abbr/label/desc and every UI.INVITE_MODES key is pinned to an enUS row.

Fixed — Settings > Tabs: check boxes missing on first open (GUI/SettingsPanel.lua)

Reported v2.11.6: four of the twelve "Show these tabs" boxes were not drawn on first open, and tabbing away and back showed them all. Since then a double-deferred NotifyChange in the panel's OnShow hook re-fed the page two frames after every open, labelled as a symptom fix.

The boxes were AceConfig toggles in an inline group, placed by AceGUI's Flow layout, which chains each box to its predecessor and wraps by whatever width it measures at that instant. On a later resize, AceGUI's ContentResize re-lays the inline group by itself. It does so at the content frame's real width, which is one pixel narrower than InlineGroup:OnWidthSet computes (the border is inset 1px on the right, AceGUIContainer-InlineGroup.lua:79-88). The page around it is not re-laid to match. Which of these produced the report was never measured in the client, and a covered last row does not explain four scattered boxes. So the whole mechanism is removed rather than one part of it guessed at.

The twelve toggles are now one type = "multiselect" option drawn by a new widget, FGI_TabShowGrid. It places pooled AceGUI CheckBoxes itself in three fixed columns, alphabetical by label. Its height is 18 + ceil(n / 3) * 24, from the tab count alone, and a width change only re-positions boxes inside it. The setter calls NotifyChange so the Tab order list follows a tick, which AceConfigDialog runs on its next OnUpdate (AceConfigDialog-3.0.lua:1827-1830). The boxes go back to the pool through addon.ReleaseWidget. The OnShow re-feed is deleted.

Specs: Tests/zz_settings_tabs_page_spec.lua is rewritten around the grid: one shown box per catalog tab, alphabetical, ticks follow the preferences, an untick hides the tab, hover text, the height unchanged across three widths, the three-column positions, and every box released with the page. zz_settings_panel_spec drives the multiselect setter and asserts the panel no longer re-feeds on show. Agent a591e389's Tests/zz_settings_tabs_probe_spec.lua is folded in and removed (in writ's trash). Full suite 4303 passed, 0 failed (run 20261012-163603.746); GUI/SettingsPanel.lua 2822/2822. Verified in the client by the operator, 2026-10-12: all 13 boxes drawn in three columns on first open, with the re-feed gone.

New — copy the guild policy from another of your characters (Modules/FGI_DeltaSync.lua, GUI/SettingsPanel.lua)

Asked for by Pockit on Discord, 2026-10-11: a GM who switches to a character on another realm or faction had no guild policy there until a guildmate running FGI was online at login, because gmPolicy lives in DB.factionrealm. The saved variables are account-wide, so every other faction-realm's record is already on disk. Settings > Guild Policy now has Copy policy from another character (a picker labelled with the guild last seen on that character, the realm, the author and the date) and a Copy button, for policy editors only.

  • Bridge:OtherPolicySources() lists the other db.sv.factionrealm entries that hold a policy; Bridge:CopyPolicyFrom(key) replaces this record's content leaves with a deep copy of that one's and drops leaves the source lacks, so the result is the source's policy rather than a blend. It then runs the designation-notice and Wingman goal follow-ups ApplyPolicy runs, because this client reads the copied lists and goal straight away.
  • Explicit, never automatic, and local until Push. A guild exists on one faction-realm, so the source is usually a different guild; the settings templates exclude gmPolicy for that reason. The record keeps its own datestamp, canon and leaf canons, so IsPolicyDirty shows it as unpushed and nothing reaches the guild until the editor presses Push policy to guild, which stamps it as their save and turns each dropped leaf into a tombstone.
  • Specs: Tests/policy_sync_spec.lua, block "copied from another character". Not tested in a client.

Fixed — unpushed guild policy edits could reach guildmates without a Push (Modules/FGI_DeltaSync.lua)

Found by Peer Review (cffe0e79, G1) on the copy above, and wider than it: every policy edit is written into gmPolicy live, and OnDataRequest sent the stored record to any peer whose canon differed. A guildmate who queried while an editor had unpushed edits (or an unpushed copy) received them under the old canon and adopted them. A leaf the draft added also had no stored canon, so PolicyVersion synthesised one and the advertised version moved for a change nobody pushed.

  • New Bridge:PolicyHasDraft(): a leaf whose value no longer matches its own stored canon, a leaf with no canon, or a never-stamped record. Per leaf, not through IsPolicyDirty, because a received record blended from two authors carries one author's contentHash over both authors' content and would never relay. OnDataRequest sends nothing while it is true. Known limit: the check recomputes each received leaf's digest locally, and DeltaSync once changed ComputeHashV2's output for a value holding the same sub-table twice (its CHANGELOG, finding 12). A leaf stamped that way by an author on the older library reads as a draft here, so this client stops relaying it until the next push. It loses nothing; a value off the wire never holds a shared sub-table.
  • PolicyVersion hashes only leaves with a stored canon when the record has a leaves table, so a draft no longer changes what this client advertises. Legacy records without one are unchanged.
  • IsPolicyDirty now reports a record with content and no canon as unpushed (G2): a copy into a character that held no policy, or a policy saved before the stamp existed. It used to read clean, hiding the Push it needs. The spec that pinned the old answer is rewritten for the new one.

Fixed — "Only my invitees" let two recruiters both welcome the same joiner (FGI_Core.lua, functions.lua)

Reported by Pockit on Discord, 2026-10-11: two recruiters in one guild, welcome message and whisper set by the guild policy, "Only my invitees" ticked for the whisper, and new members "sporadically" got the whisper from both. Two holes in the gate, either one enough on its own, plus the text that described it:

  • The gate tested presence in the Anti-Spam list, and that list is synced guild-wide. A joiner the other recruiter invited reached this client over DeltaSync, so both clients counted them as their own invitee. Sporadic because it depended on the sync landing before the join. The new fn.iInvited reads only records that never leave this client: the pending invite, the invitedForAccept send stamp (now read through fn.inviteStampFresh, shared with fn:recordAccept), and a session marker addon.acceptedMine that recordAccept leaves when it consumes the other two -- so it holds whichever of the join system message and the roster's join callback runs first. The synced entry's inviter field is deliberately NOT used: it records whoever first listed the name for any reason (a scan Decline, a whisper-only contact, an "already invited to a guild" reply), so it could name the recruiter whose invite did not land (Peer Review 918fb07c).
  • The send stamp outlived a refusal. invitedForAccept is written when the invite is SENT and was cleared only on the accept and on the anti-spam expiry, so a recruiter whose invite was refused -- "already been invited to a guild" is exactly the reported race, where the other recruiter's invite got there first -- still read the joiner as their own for the whole anti-spam window (Peer Review c386c974). New fn.clearInviteStamp, called from promotePendingToAntiSpam (every refusal: decline, auto-decline, in a guild, the nameless server refusal), from clearPending on not-found / unresolved (NOT on AFK / DND, which proves the player is online and the invite may still land), and from the hourly stale sweep -- but not from the logout drain, since the stamp is what attributes an invite accepted after a /reload. addon.acceptedMine now holds a timestamp and counts for five minutes, enough for the two join paths of one join; it used to hold for the session, so a player who left and rejoined through another recruiter's invite was still ours.
  • An unanswered invite stayed "mine" for days. An invite that drew no server message at all, whose pending entry the logout drain then took, left only the stamp, so the gate trusted it for the whole anti-spam window -- and forever with the expiry at 0 days (Peer Review d68b75dc). The gate and the accept marker now trust the stamp for one hour, INVITE_ANSWER_WINDOW, the same constant the stale sweep uses to call an invite refused (fn.inviteStampFresh takes an optional max age). Accept counting keeps the long window: a late accept is still an accept.
  • The stamp lookup had its own copy of the name-spelling rules, and it was wrong both ways. It scanned on past an exact hit and could clear a same-named player's stamp from another realm, and it had no own-realm step, so a refusal spelled Name-OurRealm left a bare stamp standing (Peer Review 809b1458). New fn.playerKeyIn(tbl, name) is the one resolver: fn.pendingKeyFor and the new fn.stampKeyFor are both it, fn.clearInviteStamp clears exactly the one key it resolves (or the caller's pending key), and fn.iInvited and recordAccept read the stamp through it. clearPending passes only a resolved pending key, so a not-found after a /reload reaches the resolver. recordAccept reads its pending entry through fn.pendingKeyFor too: 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 (Peer Review 47699b43, C1). Known limit: a bare name cannot say which realm it means. With exactly one stamp Bob-Faerlina and none for Bob, an own-realm Bob who joins through someone else's invite reads as ours: whispered if the stamp is under an hour old, and counted as our accept within the anti-spam window. Two candidates are refused rather than guessed.
  • The two "Only my invitees" tooltips described the old rule ("their name is in your Anti-Spam list"). Reworded in every locale.
  • A welcomer policy skipped the gate even when its election fell open. The bypass was "a welcomer list exists", which also holds when nobody listed is online and CanWelcome falls open for everyone -- so every member skipped their gate together. fn.welcomeBypassesInviteeGate now bypasses only for the elected welcomer (AmIDesignatedWelcomer). The /fgi welcome diagnostic reads the same function, so it cannot disagree with the sender.

Specs: Tests/zzzz_core_spec.lua (examples through the real OnMemberJoined handler, including the reported two-recruiter case driven through the real recordAccept in both event orders, and a scan Decline that must not count), Tests/zz_welcome_gate_spec.lua, whose example pinning the old fall-open bypass now pins the corrected rule, and Tests/zz_scan_system_handler_spec.lua: every refusal fired through the real CHAT_MSG_SYSTEM handler, then the join driven through the real recordAccept and OnMemberJoined in both orders, asserting no whisper -- with the accepted invite as the positive control; a stamp older than the hour with the anti-spam expiry at 7 and 0 days; the realm-spelling cases (a bare refusal for a realm-qualified stamp, an own-realm refusal for a bare stamp, a same-named stamp on another realm left alone, two candidates left alone); and one spelling table asserted against playerKeyIn, pendingKeyFor and stampKeyFor together. The file now snapshots the tables its blocks replace and restores them when it ends, so a later spec file no longer inherits emptied ones.

Fixed — the legacy window: an extra player dropped on blacklist, and a broken mid-session open (GUI/LegacyMainWindow.lua)

Found while closing the file's coverage gap (66.99% before), and reviewed by Peer Review (f7ec5520).

  • Confirming a blacklist from a row also took the next player off the queue. The shared applyBlacklist (GUI/UI.lua) already removes the player by name and repaints, then calls onDone; the window's onDone then did table.remove(list, idx) with the index captured at the click, which by then held the next player. The row now calls UI.FastBlacklist(entry) / UI.ShowBlacklistConfirm(entry) with no callback, as the compact tray does, and the fast path's hand copy of applyBlacklist is gone. The spec drives the real confirm's OK and was run red against the old callback (P3 dropped as well) before going green.
  • Opening it mid-session showed nothing positioned, and Esc did not close it. Positions, the saved invite mode, filter switch and level range, and the Esc registration ran only from a 0.1 s timer in the PLAYER_LOGIN handler, and only for a player with the legacy UI as default. LMW.Open only built the frame. The layout is now layoutFrame(), run by LMW.Open on every open, so the window also shows settings changed while it was hidden. Keep open now takes it off UISpecialFrames (it never did), and the INTERFACEOPTIONS_ADDONCATEGORIES gate is gone (that global is in no flavour tree, so the gate was always open). Login builds the window hidden for a legacy-UI player and lays nothing out. Why the old layout was delayed 0.1 s is not known; the layout now runs only from /fgi legacy and the main window's [-] button, never at login. Not tested in a client.
  • The saved mode is selected by setting the dropdown's selectedID directly rather than through UIDropDownMenu_SetSelectedID, whose UIDropDownMenu_Refresh would redraw any Blizzard menu open when /fgi legacy is typed (Peer Review P1).
  • The invite-mode menu could show two ticks. The client's UIDropDownMenu_AddButton ticks the selected id as well as info.checked (Classic UIDropDownMenu.lua:568-582), and the selection was set only on open, so a mode saved while the window stayed open (the Scan tab's mode menu, GUI/Tabs/Scan.lua:901, or a settings reset) left the old mode ticked beside the new one. The menu's initialiser now reselects the saved mode each time it is built, and a saved mode the window does not know now selects mode 1 (it kept the unknown id, so nothing was ticked while the text read "Invite only"). Run red with the reselect removed (two examples showed two ticks).
  • The Scan tab's mode menu had the same two ticks, and its "Mode:" label could go stale (Peer Review M1). The tab is built once and cached, and its selection and label were set only at build time and by its own menu, so a mode saved in the legacy window or by a settings reset left the old mode ticked and named until /reload. Its initialiser now reselects the saved mode, and its OnShow hook redraws the selection and the label. New examples in Tests/zz_scan_strip_actions_spec.lua: "ticks only the mode saved since the tab was built" and "shows the mode saved while it was hidden when it is shown again", each run red first (modes 1 and 3 both ticked; the label still read "Invite only").
  • One list of invite modes. UI.INVITE_MODES, UI.InviteModeLabel, UI.SavedInviteMode and UI.SelectSavedInviteMode (GUI/UI.lua) replace the Scan tab's INVITE_MODES table and the legacy window's copy of the labels, so both menus read one list. The mode count comes from the list itself, not a separate 4 (Peer Review L1). The Scan menu now selects by id rather than by value; its unused per-mode tip strings went with the old table.
  • A mode picked in one window left the other naming the old one (Peer Review L2). /fgi legacy does not close the main window, so both can be on screen, and each redrew only its own text. UI.SetInviteMode(mode) now saves and calls UI.RefreshInviteModeDropdowns(), which reselects and rewrites every dropdown listed through UI.RegisterInviteModeDropdown (weak keys). Both menus' picks go through it, and the legacy pick no longer calls UIDropDownMenu_SetSelectedID. Applying a settings template also redraws them (SP:Apply), since a template can carry the mode. A settings reset is not routed: it reloads the UI (functions.lua:2653). Two examples in Tests/zzzz_legacy_window_behaviour_spec.lua build the Scan tab beside the legacy window, pick in one and read the other; run red with the redraw removed from SetInviteMode.
  • Removed: LMW.Toggle, which had no caller (Peer Review P2), and the SetMinResize / SetMaxResize fallback, since every flavour has SetResizeBounds. The older-LibAceGUIWidgets width lock moved into LMW.lockWidth so it can be tested.
  • Spec: Tests/zzzz_legacy_window_behaviour_spec.lua, 49 examples. GUI/UI.lua, GUI/Tabs/Scan.lua, GUI/LegacyMainWindow.lua and Modules/FGI_SettingsProfile.lua together are at 100% (2490/2490). Harness pin moved to 22e21ed: 705f522 keeps a dropdown's selection on the frame's own selectedID as the client does (WoWAPITesting, 86167921), so the examples assert through UIDropDownMenu_GetSelectedID again, and 22e21ed makes UIDropDownMenu_AddButton tick the selected entry as the client does, which is what lets the two-tick case be tested offline.

Tests — the login order of "Never put me on recruiting duty" is pinned (Tests/wingman_prompt_spec.lua)

At login the opt-out is re-asserted 8 s after PLAYER_LOGIN (Modules/FGI_Baton.lua), while Wingman's session restore and first duty evaluation run at 3 s, or as soon as the roster is ready, so either can come first. That was unstated. It is safe because the election skips an opted-out character directly (eligible in Modules/FGI_Recruiter.lua), with no step-aside entry needed, so the re-assert only tells the guild. A new example drives a fresh session with the opt-out saved and the re-assert not yet run, and asserts this character is not designated and the duty prompt does not appear. File: 93 passed, 0 failed. Not checked by removing the eligible line to watch it fail.

Tests — coverage catch-up, first pass (98.43% before)

The 2026-10-11 coverage run over every shipped file read 23781/24161 lines (98.43%). New examples for the code that had none:

  • Settings (Tests/zz_settings_panel_spec.lua): the personal goal sliders store per character, zero meaning off, and re-judge Wingman; the "Stops at" line names whose goal is in force; the exempt-rank picker lists every roster rank and drops on Nobody; the v2.15.0 policy copy is hidden without a source, edit rights or the bridge, lists each source, copies only once one is picked, and says so only on success.
  • Baton refusals (Tests/zz_roster_duty_line_spec.lua): the v2.14.13 chat line for a refused Take or Assign had no spec. Now driven through the real menu clicks: it names the role, names the target or "You" by which side refused, and says nothing when the assign goes through.
  • Template size cap: the "more than N KB" refusal is said on Save and on paste (Tests/zz_templates_page_spec.lua) and for a received template (Tests/zz_template_wire_spec.lua).
  • Defensive paths: a baton step-aside or step-back with an empty or missing name is ignored (Tests/baton_sender_guard_spec.lua); Announce's channel-name check still refuses a non-string without its helper (Tests/announce_channels_spec.lua); Wingman's roster callback evaluates at once with no timer and leaves its coalescer clear (Tests/zz_roster_refresh_spec.lua).

Full suite: 4221 passed, 0 failed. No defect found. Coverage after: 23855/24161 (98.73%).

Tests — coverage catch-up, second pass

  • Guild leavers (Tests/guild_events_spec.lua): on a LibGuildRoster too old to have IsReady, a logged departure is judged by the client's own member check: left alone while they are still a member, blacklisted once they are not.
  • Template inflater (Tests/zz_template_bounded_inflate_spec.lua): a bomb made of stored (uncompressed) blocks is stopped at the limit, and hand-built DEFLATE streams pin each refusal: a stored block longer than the stream, a literal code the table does not assign, and an empty code-length code. Each malformed stream has a well-formed control beside it where one exists.
  • Whisper tabs (Tests/zz_whisper_tabs_spec.lua): Enter in the reply box sends and clears, and keeps a reply that did not go; the tab tooltip names the conversation; the mouse wheel scrolls the thread.
  • whisperMode (Tests/zz_whisper_popout_spec.lua): when a send and the tray's inline hold overlap, whichever lets go last puts the player's own mode back, and neither saved value is left behind afterwards.
  • Queue-held notice (Tests/zz_invite_holds_spec.lua): silent with nothing held; counts the held rows, at most once per five seconds.
  • Small fallbacks: fn.myName on Forever with no surname is the first name (Tests/zz_forever_names_spec.lua); fn.foreignWhoOwed reads GuildRoster's /who queue on a library with no IsWhoOwed (Tests/zz_wingman_session_spec.lua).

Full suite: 4238 passed, 0 failed. No defect found. functions.lua, Modules/FGI_GuildEvents.lua, Modules/FGI_WhisperTabs.lua and Modules/FGI_SettingsProfile.lua are now at 100%. GUI/LegacyMainWindow.lua (66.99%) was closed in the third pass, the legacy-window entry above.


Older releases have been moved to CHANGELOG_ARCHIVE.md — v2.14.14 and earlier, in full. v2.13.0 went section by section while it was the only version in this file (ten on 2026-09-09, twelve on 2026-09-10, eight on 2026-09-11, four on 2026-09-13, four on 2026-09-14, ten on 2026-09-16), and its heading and last section followed on 2026-09-16. v2.13.1 moved whole on 2026-09-16, v2.13.2, v2.13.3 and v2.13.4 whole on 2026-09-20, v2.13.5 whole on 2026-09-25, v2.14.0 and v2.14.1 whole the same day, v2.14.2 whole on 2026-10-03, v2.14.3 whole on 2026-10-09, v2.14.4 whole on 2026-10-10, v2.14.5, v2.14.6, v2.14.7 and v2.14.8 whole on 2026-10-11, and v2.14.9 and v2.14.10 whole later the same day, and v2.14.11 whole on 2026-10-12, and v2.14.12 whole on 2026-10-13, and v2.14.13 whole later the same day, and v2.14.14 whole on 2026-10-15. The next candidate is v2.15.0, whole.

This mod has no additional files