FastGuildInvite-v2.18.0
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) whenaddon.CanEditGuildPolicy()is true, and is hidden everywhere else.MainWindow:RefreshPushIconruns on every tab switch and onPLAYER_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 skippedOnClose. Its tooltip warns of unpushed changes fromaddon.IsPolicyDirty, read at hover time. - The icon has no fixed slot:
RefreshPushIconmoves 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.PushGuildPolicyreturns 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 asaddon.PushGuildPolicy), and both buttons call it, so the enforce-with-nothing-marked guards,StampPolicySaveand 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'sdisabledgate, 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 inTests/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 inMainWindow.lua. Full suite 4409 passed, 0 failed (tool: test run 20261018-075311.874-2749380805). RefreshPushIconclears the status bar's points and sets both edges again rather than repeatingSetPointonBOTTOMRIGHTalone, 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 isfn.isCommSelf), it sendsL <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 throughAnnounce:MergeGuildLayerSightingexactly as a sighting (MAX wins; a rotation member sets that place's cursor). The login catch-up reply (R) now also whispersCwith 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. LandCcarry server time (Peer Review thread defcaba5). With each sender'stime()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, offsetfn.getTime() - time()), soAnnouncekeeps comparing intime(), and a stamp still ahead of now after conversion is taken as now.SandTare 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
Creply 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 nextLthere. Clock skew between two clients still shifts the guild-wideS/Tcooldowns by the difference. - A spec pins that
noteLayerSightinghands a Forever-shaped sender (Vishi Swaz-SomeRealm) tofn.isCommSelfunaltered and shares the echo; the real fold of that shape is pinned inzz_forever_names_spec. Whether Forever'sCHAT_MSG_CHANNELreports our own name in a shape that fold accepts is NOT verified in game. - Specs:
Tests/announce_per_layer_spec.luagoes 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
Sendhad nothing to post.RotationStatusnow returns not-eligible with a 6th value,notJoined, whichGetStatuspasses 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.GetNextReadyInstill reads 0 for such a row, as for a plain unjoined announcement; Wingman consumes the click only whenSendposts. - Peer Review T1, thread 43c9652a. The Announce tab's row for a plain announcement reads
ProfileReadyIn, which had nonotJoined, so out of Trade a Trade announcement whose cooldown had run still read "in 0S" there after both horn tooltips stopped showing it.ProfileReadyInnow returns a 3rd value,notJoined, byGetStatus'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 byStatusReadoutand 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
GetNextReadyInanswered 0 so Wingman kept trying an announce that could not post. The minimum is now taken over the joined channels first;notJoinedis set only when every waiting channel is one the player is not in. The aggregation was written twice (ProfileReadyInandGetStatus'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, andGetNextReadyInoffered Wingman an announceSendhad nothing for.GetStatusnow gives it no row, the ruleHasActiveProfilesalready 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
Renderstarted a one-second ticker and never stopped the previous one, so each old copy stayed alive and kept ticking.Rendernow 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 throughGuildRosterTab._scheduleRefresh, which eachRenderrepoints. 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 fromUGR_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
ugrAutoSynctoggle (off by default, like the GRM and GIL ones) imports once on LibGuildRoster'sOnRosterReadyand quietly (no chat line, no banner) on everyUGR_BANS_CHANGED. Both are events, no timer. Registered onfn, becauseaddonalready holds the blacklist sweep'sOnRosterReadyhandler and CallbackHandler keys by target. UGR was also asked to sendUGR_BANS_CHANGEDonce 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()andIsBanned(name)). UGR is asked with the roster'sName-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:blackListreaches every guild member of any rank, so my first version would have handed officer data to the whole guild.fn.isUGRReasonnames a<UGR>entry, andModules/FGI_DeltaSync.lua'sGetItemMap(BLACKLIST)now answers a copy without them, so they are in no hash, baseline, diff or live push.ApplyEntrieslets 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 localDB.realm.ugrRemovedmark, not a synced tombstone, which would have published the name and deleted a guildmate's own entry. - P2.
fn.registerUGRAutoImportnow runs the import inline when LibGuildRoster is already ready, as the kick sweep does;OnRosterReadyfires once per guild, so after a/reloadthe 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,WarnsAboutBansandIsBannedare called throughpcall. 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 failedGetActiveBansprunes nothing.
- P1, privacy. UGR shows bans to officers only, and
- Peer Review fc07ba20, the second round:
- N1. A
nilfromGetActiveBansnow reads as "UGR not available" and prunes nothing; my first fix tooknilas 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 ignoresblackListRemoved(new third argument tofn.newBlacklistImport) and checks onlyDB.realm.ugrRemoved, andApplyEntrieskeeps 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:blacklistRemoveresolves 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:blackListclears theugrRemovedmark for a player re-blacklisted by hand, andBridge:PushNamereads the one entry instead of copying the filtered list, and returns for a<UGR>one.- All three importers'
tryAddnow looks the name up withIsInBlackList(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
nilrather 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.GetActiveBansanswers{}below the ban rank, andUGR_BANS_CHANGEDwith no id fires whenever the list becomes readable or stops being.
- N1. A
- Specs:
Tests/zz_ugr_import_spec.lua(new) and four examples inTests/zzzz_blacklist_kick_popup_spec.lua. The UGR stand-in is registered in AceAddon's own table, sofn.ugrAddonruns 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) andModules/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
perLayersnapshot field and the ID allow-list are removed. - Seeing the ad is the layer test. The
CHAT_MSG_CHANNELhandler 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 (GetChannelNameon the event'schannelIndex,channelBaseNamewhen the slot has none; bothNeverSecretin 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 asksmemberChannelReady, 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:
RotationStatusread 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 andGetNextReadyIntold Wingman there was announce work whenSendhad 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 andchannelBaseNamego throughisSafebefore:lower(), as the v2.2.3 guard does, since the event isSecretInChatMessagingLockdowneven though both args are documentedNeverSecret. - 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 onceseen + cooldown + rank × 8 shas 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, andRecruiter: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 bothfn:msgModBroadcastand 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.luarewritten (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.luapins 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.
handleNotFoundhad 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/wto someone offline was also filed against a recruit whenever anything was in flight. It now resolvesfn.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'sinFlighthelper now writes Type 1 instead of readingDB.global.inviteType. The leak that exposed it:Tests/zz_reset_settings_spec.lua's last example setinviteType = 3and never put it back, so every later file in a full run inherited message-only mode. It now restores it through the file'sonCleanup. - Release-prep doc pass: the version became v2.16.0 (two New features),
Modules/intro.luagained the missing "Only my invitees" Changed bullet, andREADME.mdgained 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:nextSearchrefuses 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.
goalStopsSessionnow 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
wingmanMsgGoalOfffallback 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 itsheld=line removed, exactly that example fails, 76 passed, 1 failed (run 20261012-145544.781). - Player notes: a Changed bullet in
docs/Curseforge_Description.htmlandModules/intro.lua. - Testing mode in
fn:invitePlayernow 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 inzz_recruit_goals_spec; not shown red. The stale "a goal does NOT hold the scan" comment infn:nextSearchis 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 otherdb.sv.factionrealmentries 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-upsApplyPolicyruns, 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
gmPolicyfor that reason. The record keeps its own datestamp, canon and leaf canons, soIsPolicyDirtyshows 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 throughIsPolicyDirty, because a received record blended from two authors carries one author'scontentHashover both authors' content and would never relay.OnDataRequestsends nothing while it is true. Known limit: the check recomputes each received leaf's digest locally, and DeltaSync once changedComputeHashV2'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. PolicyVersionhashes only leaves with a stored canon when the record has aleavestable, so a draft no longer changes what this client advertises. Legacy records without one are unchanged.IsPolicyDirtynow 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.iInvitedreads only records that never leave this client: the pending invite, theinvitedForAcceptsend stamp (now read throughfn.inviteStampFresh, shared withfn:recordAccept), and a session markeraddon.acceptedMinethatrecordAcceptleaves 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'sinviterfield 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.
invitedForAcceptis 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). Newfn.clearInviteStamp, called frompromotePendingToAntiSpam(every refusal: decline, auto-decline, in a guild, the nameless server refusal), fromclearPendingon 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.acceptedMinenow 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.inviteStampFreshtakes 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-OurRealmleft a bare stamp standing (Peer Review 809b1458). Newfn.playerKeyIn(tbl, name)is the one resolver:fn.pendingKeyForand the newfn.stampKeyForare both it,fn.clearInviteStampclears exactly the one key it resolves (or the caller's pending key), andfn.iInvitedandrecordAcceptread the stamp through it.clearPendingpasses only a resolved pending key, so a not-found after a/reloadreaches the resolver.recordAcceptreads its pending entry throughfn.pendingKeyFortoo: 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 stampBob-Faerlinaand none forBob, 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
CanWelcomefalls open for everyone -- so every member skipped their gate together.fn.welcomeBypassesInviteeGatenow bypasses only for the elected welcomer (AmIDesignatedWelcomer). The/fgi welcomediagnostic 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 callsonDone; the window'sonDonethen didtable.remove(list, idx)with the index captured at the click, which by then held the next player. The row now callsUI.FastBlacklist(entry)/UI.ShowBlacklistConfirm(entry)with no callback, as the compact tray does, and the fast path's hand copy ofapplyBlacklistis 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_LOGINhandler, and only for a player with the legacy UI as default.LMW.Openonly built the frame. The layout is nowlayoutFrame(), run byLMW.Openon every open, so the window also shows settings changed while it was hidden. Keep open now takes it offUISpecialFrames(it never did), and theINTERFACEOPTIONS_ADDONCATEGORIESgate 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 legacyand the main window's [-] button, never at login. Not tested in a client. - The saved mode is selected by setting the dropdown's
selectedIDdirectly rather than throughUIDropDownMenu_SetSelectedID, whoseUIDropDownMenu_Refreshwould redraw any Blizzard menu open when/fgi legacyis typed (Peer Review P1). - The invite-mode menu could show two ticks. The client's
UIDropDownMenu_AddButtonticks the selected id as well asinfo.checked(ClassicUIDropDownMenu.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 itsOnShowhook redraws the selection and the label. New examples inTests/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.SavedInviteModeandUI.SelectSavedInviteMode(GUI/UI.lua) replace the Scan tab'sINVITE_MODEStable 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-modetipstrings went with the old table. - A mode picked in one window left the other naming the old one (Peer Review L2).
/fgi legacydoes not close the main window, so both can be on screen, and each redrew only its own text.UI.SetInviteMode(mode)now saves and callsUI.RefreshInviteModeDropdowns(), which reselects and rewrites every dropdown listed throughUI.RegisterInviteModeDropdown(weak keys). Both menus' picks go through it, and the legacy pick no longer callsUIDropDownMenu_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 inTests/zzzz_legacy_window_behaviour_spec.luabuild the Scan tab beside the legacy window, pick in one and read the other; run red with the redraw removed fromSetInviteMode. - Removed:
LMW.Toggle, which had no caller (Peer Review P2), and theSetMinResize/SetMaxResizefallback, since every flavour hasSetResizeBounds. The older-LibAceGUIWidgets width lock moved intoLMW.lockWidthso it can be tested. - Spec:
Tests/zzzz_legacy_window_behaviour_spec.lua, 49 examples.GUI/UI.lua,GUI/Tabs/Scan.lua,GUI/LegacyMainWindow.luaandModules/FGI_SettingsProfile.luatogether are at 100% (2490/2490). Harness pin moved to22e21ed:705f522keeps a dropdown's selection on the frame's ownselectedIDas the client does (WoWAPITesting, 86167921), so the examples assert throughUIDropDownMenu_GetSelectedIDagain, and22e21edmakesUIDropDownMenu_AddButtontick 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 haveIsReady, 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.myNameon Forever with no surname is the first name (Tests/zz_forever_names_spec.lua);fn.foreignWhoOwedreads GuildRoster's /who queue on a library with noIsWhoOwed(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.
All Relations
- All Relations
- Embedded Library
- Optional Dependency
- Required Dependency
- Tool
- Incompatible
- Include

