File Details
FastGuildInvite-v2.5.7
- R
- Jun 2, 2026
- 5.55 MB
- 195
- 12.0.7+6
- Retail + 2
File Name
FastGuildInvite-FastGuildInvite-v2.5.7.zip
Supported Versions
- 12.0.7
- 12.0.5
- 11.2.7
- 4.4.0
- 3.4.3
- 2.5.5
- 1.15.8
<FGI> FastGuildInvite
[v2.5.7] (2026-06-02) — retail TOC version bump (TWW 11.2.7 + Midnight pre-patch) + packaging fixes (docs no longer shipped, changelog archived)
FastGuildInvite_Mainline.toc — Interface bump
## Interface:updated to110207, 120005, 120007— The War Within 11.2.7 (retail live) plus the Midnight 12.0.05 / 12.0.07 pre-patch builds. Clears the "Out of Date" flag on the current retail client. Compatibility metadata only — no code change; the packager still substitutes## Version:from the release tag.
Packaging — .pkgmeta ignore fix (docs/ was shipping)
- Directory ignore entries (
docs,.github,.vscode,.claude) carried trailing slashes. The BigWigs/CurseForge packager appends/*to directory entries so its glob matches the files inside them; a trailing slash produceddocs//*, which matches nothing — so the entiredocs/folder was being shipped in the release zip. Dropped the trailing slashes so the directory globs resolve correctly. - Collapsed redundant
**/*.ps1/**/*.batinto*.ps1/*.bat(the packager'scaseglob already crosses/), and addedCHANGELOG_ARCHIVE.mdto the ignore list so the history file doesn't bloat the player zip.
Changelog archive split (release-body size limit)
CHANGELOG.mdhad grown to ~509 KB. The packager publishes the entire changelog as the GitHub release body, and GitHub rejects any body over 125,000 characters with HTTP 422 — which would have failed the release workflow on this tag (the CurseForge upload itself still succeeds, masking the failure). Split at the v2.2.5 / v2.2.4 boundary: the liveCHANGELOG.mdnow keeps v2.5.7 → v2.2.5 (~100 KB) and everything from v2.2.4 down to the oldest entry moved verbatim to the newCHANGELOG_ARCHIVE.md(excluded from the package), with a pointer at the bottom of the live file.
[v2.5.6] (2026-05-31) — retail SetRaidTarget taint REGRESSION fixed (v2.3.1 reopened it) + cap-level /who subdivision doc
Modules/FGI_ChatMenu.lua — SetRaidTarget taint: retail tag-exclusion restored + combat gate
- Field-reported, live on 2.5.x:
ADDON_ACTION_FORBIDDEN: AddOn 'FastGuildInvite' tried to call the protected function 'SetRaidTarget()'— user in an M+ right- clicked their tank to place a raid marker, marker never placed. Stack:TargetFrame.lua:963 SetRaidTargetIcon→UnitPopupSharedButtonMixins.lua:2422. Confirmed FGI is the source (disabling FGI removes it 100%). - This is a regression of the v2.2.4 bug. v2.2.4 correctly fixed it by excluding
the unit-frame menu tags on retail. v2.3.1 reopened it: trusting the Blizzard
Menu implementation guide's claim that "addons should always be able to insert
elements … without imparting taint to any of the surrounding element handlers"
([11_0_0_MenuImplementationGuide.lua:416-418]), v2.3.1 re-added the full unit-frame
tag list and instead blamed a secret-value read in the generator (which it deferred
to OnClick). The deferred-read fix was real and is kept — but it was NOT sufficient:
the field proves that merely REGISTERING
Menu.ModifyMenuagainst a menu that hostsSetRaidTargettaints that protected click on retail, regardless of whether the generator reads anything. The Blizzard doc is aspirational, not a guarantee. - Fix (two layers, retail-only):
- Tag exclusion at registration —
MENU_TAGSis split intoMENU_TAGS_SAFE(chat roster / friend / guild member — none host a protected submenu) andMENU_TAGS_UNIT_FRAME(player / party / raid / raid-player / target / focus / enemy). On retail only the safe set is registered; classic-family keeps the full list (its legacyUnitPopupButtonspath doesn't cross-taint siblings). The in-generatorgateAllowed()check was proven insufficient, so the gate is moved to which tags we register against at all. - Combat gate in
gateAllowed()— idea borrowed from GRIP's UnitPopupInvite: on retail, insert nothing into ANY menu whileInCombatLockdown(). Raid markers matter most mid-pull; the safest guarantee FGI never tampers with a menu's secure subtree at that moment is to add no element while locked down. Belt-and-suspenders on top of the exclusion.
- Tag exclusion at registration —
- Retail trade-off (same as v2.2.4): no "FGI ›" submenu when right-clicking a target / focus / party / raid / generic-player / enemy unit frame on retail. FGI stays on chat right-click, friends list, and the guild panel. Raid markers work.
- Cross-checked against GRIP (a sibling recruitment addon the user pointed at):
GRIP avoids
TARGET/FOCUSentirely and combat-gates its insertion, but still registers againstPARTY/RAID_PLAYER— so it has a latent out-of-combat marker bug FGI now avoids by excluding those too. FGI's fix is strictly more conservative.
GUI/SettingsPanel.lua — subdivision-header tooltip clarifies the level-cap explosion
- Not a bug fix — a documentation fix. Field question: at the level cap
(e.g. 90 on retail) the scan appears to "walk every level-90 race and class"
(
90 r-Human,90 c-Mage, …) even when the plain90-90query "only found 3." - Why it's correct: subdivision is gated on the RAW /who return hitting the
50-cap ([functions.lua:4116] —
#results >= FGI_MAXWHORETURN), nothing else.resultsis every player the server returned (guilded + guildless); the guild/blacklist/anti-spam filtering happens later, one player at a time, inaddNewPlayer([functions.lua:4167-4170]). At the cap there are hundreds of max-level players online, so every single-level query genuinely caps at 50 and the Race → Class → Zone walk is the only way to reach the handful of guildless players hidden in the 50. The "3" the user saw is the post-filter guildless yield, which never feeds the subdivide decision — so the queue grows by a few while the query list expands. Expected, complete-coverage behaviour. - Change: appended a paragraph to the
subdivideHeadertooltip explaining the cap-level case (50 counts everyone, queue only grows by guildless players, many-queries-few-additions is the haystack not a bug) and pointing users at the Class / Zone tier toggles to trade completeness for fewer queries. No engine change — the>= 50gate and tier order are untouched.
[v2.5.5] (2026-05-31) — retail: taint-free outgoing-whisper popout-tab suppression (data-driven)
functions.lua — restore the tab-suppression half of sendMSG, without the taint
- Context: v2.5.4 removed the
FloatingChatFrameManagerOnEvent wrapper to kill the MONSTER_SAY taint cascade. That wrapper had been the thing that stopped FGI's own outgoing whispers from opening popout conversation tabs in "popout" / "popout_and_inline" whisper mode. With it gone,sendMSG("Hide outgoing whisper echoes") hid the echo text but the empty tab came back — exactly what the setting's own description still promised to suppress. - Approach (taint-free): feed Blizzard's own decision input instead of
hooking its router. The manager opens a popout only when
GetCVar("whisperMode")is a popout variant ([FloatingChatFrame.lua:2496]);whisperModeis a NON-SECURE CVar ([CVars.lua:1591] →secure=false), soSetCVaron it is legal in/out of combat and carries no Lua taint (engine-side). FGI flipswhisperModetoinlinearound its own sends so the manager never creates the tab; a recruit's real reply arrives after the flip-back and still pops out. - Data-driven, not timer-driven (the key correctness point): an outgoing
WHISPER_INFORMecho is a server round-trip and, under chat throttle during a bulk blast, can return seconds-to-tens-of-seconds after the send. A fixed grace timer (the first cut) restoredwhisperModeto popout before late echoes landed and leaked ~1/5 of tabs. Now a counter (_pendingEchoCount) increments on each chunk sent (fn.suppressPopoutForSend, called from the per-chunk chokepointfn.refreshWhisperHideDeadline) and decrements asfn.hideWhispersuppresses each matching echo (fn.notifyEchoSuppressed).whisperModeis restored only when the count drains to zero — so however late an echo lands, it's still processed while inline. The whole batch stays inline start→last-echo and flips back exactly once, after the last echo. - Restore is deferred one frame (
C_Timer.After(0, …)):fn.hideWhisper(a chat message filter) and the manager's OnEvent are independent, unordered subscribers to the sameWHISPER_INFORM. A synchronous restore on the final echo could let the manager run after us and tab that last echo; deferring keeps us inline until the current event fully drains, and re-checks the count in case a new send bumped it back up. - Safety restores so the user is never stranded inline: a 60 s fallback
timer pushed forward on every send (covers echoes that never arrive — failed /
offline sends return
ERR_CHAT_PLAYER_NOT_FOUND_S, not an echo), aPLAYER_LOGOUThandler (fires on logout and/reload, when the engine persists CVars), and an immediate restore whensendMSGis toggled off mid-window (fn.updateWhisperEchoFilter). - Retail-only: classic-family keeps its existing taint-free
FCF_OpenTemporaryWindowhooksecurefunc path, which never needed this.
GUI/SettingsPanel.lua / .luarc.json
sendMSGtooltip rewritten to describe the data-driven behaviour and its one honest trade-off (a whisper arriving while a batch is mid-send shows inline instead of popping out; replies after the batch pop as normal).- Added
SetCVar/GetCVarto the Lua language-server globals.
[v2.5.4] (2026-05-31) — retail chat-taint root-fix + locale overhaul (ES/IT/PT translated, enUS canonical) + Guild Roster header polish
Retail: HistoryKeeper taint cascade root-fixed (MONSTER_SAY secret-string crash)
- Symptom:
attempt to perform string conversion on a secret string value (execution tainted by 'fastguildinvite')atHistoryKeeper.lua:35(ChatHistory_GetToken), ~100x per fight during dungeons / raids whenever a monster said / yelled. - Root cause (confirmed against the Blizzard chat-frame source): FGI replaced
FloatingChatFrameManager's OnEvent viamgr:SetScript("OnEvent", wrapper)to suppress outgoing-whisper popout tabs. The wrapper pre-bailed safely for FGI's own whisper targets, but for every other whisper it fell through toorig(self, event, ...)— Blizzard's manager router — running under FGI's execution taint. In "popout" whisper modeorigcallsFCF_OpenTemporaryWindowand re-fires the event into the new frame ([FloatingChatFrame.lua:2501-2502]), whoseMessageEventHandlercallsChatHistory_GetAccessID, whosenextAccessID = nextAccessID + 1([HistoryKeeper.lua:14]) is a module-local upvalue. Incrementing it under taint poisons it permanently, so every laterChatHistory_GetTokenstrlower()on a secret MONSTER_SAY sender throws. - Why prior fixes missed it: v2.2.0 / v2.3.2 / v2.4.0 treated symptoms or
kept the manager wrapper believing the pre-bail made it safe. It's safe for FGI
targets only — the pass-through of everyone else's whispers was the surviving
taint source. Message-event filters were never the cause: modern retail
sandboxes those via
securecallfunction+canaccessvalue([ChatFrameFilters.lua:115,146]). - Fix: removed the
FloatingChatFrameManagerOnEvent wrapper and its PLAYER_ENTERING_WORLD re-install entirely — FGI no longer touches Blizzard's chat router.securecall(orig, ...)is not a reliable cure (same primitive failed forFCF_Close), and there is no taint-safe way to suppress the popout from inside the manager's own OnEvent. - Trade-off: retail users in "popout" / "popout_and_inline" whisper mode will
again get a conversation tab for FGI's outgoing whispers. The chat-line text
echo is still suppressed by the taint-safe message-event filters
(
fn.hideWhisper). Inline whisper mode (the default) is unaffected. Classic- family tab suppression (FCF_OpenTemporaryWindowhooksecurefunc) is unchanged.
Guild Roster: brand-coloured, sortable, striped breakdowns
- Section headers now route through the shared
addon.UI.MakeLabelhelper: brand colour +GameFontNormalSmall(matching the column headers on the other tabs) instead of the old oversizedGameFontNormalLargeraw FontString. - Each section (By Class / By Level / By Rank) gained three sortable column
headers — Name (the section title) /
#(count) /%— each with a hover tooltip and aMoreArrowsort-direction indicator on the active column (same affordance as the RowList tables). Name sorts bynameSort(alpha for class, numeric bucket index for level, hierarchy position for rank — so level sorts numerically, not as the string "100-104" vs "60-64");#/%sort by count. Clicking the active column flips direction; ties fall back to Name-ascending. - Breakdown rows shrunk to the small font variants (
GameFontNormalSmall/GameFontHighlightSmall) with a tighter 14 px row height so more of a long roster fits, and gained zebra striping (white @ 4 % alpha on alternate rows, the same tint the RowList tables use) so a single row is easy to follow across the gap between the label and the right-aligned count / percent columns.
Locale: Spanish (esES/esMX), Italian (itIT), Brazilian Portuguese (ptBR) populated
- These four files were stubs (header only, 0 keys). WoW listed the languages as supported, but every string fell through to English at runtime.
- Each now carries a full translation of the canonical key set (~222 keys): all UI
labels, slash-command help, button/row tooltips, the Announce sub-page, the
byte-counter / statistics / history-outcome strings, and the multiline
filter / level-range / sync blocks.
esMXmirrorsesES(Latin-American Spanish). - Format specifiers (
%s/%d), colour escapes (|cff..|r), texture escapes, and[=[ ]=]multiline blocks were verified per-key against enUS so a:format()arity mismatch is impossible.
enUS is now the canonical language (was ruRU)
- Why: keys were the original Russian UI text (
L["Обновить"]), with ruRU as the master in summary.lua'sGetL()rebuild — it iterated ruRU's keys (so any key not in ruRU was dropped at runtime, e.g. thebyteCount*strings) and used Russian as the final fallback. Maintaining in English meant reading Cyrillic keys. summary.lua:GetL()now iterates the union of enUS and ruRU keys — a strict superset of the old ruRU-only iteration, so nothing that worked before is dropped (notably ruRU's fullsettings.sizetable, which enUS only partially overrides). Value precedence is current-locale → enUS → ruRU; English is the scalar fallback, ruRU a last-ditch net. Side effect: enUS-only keys the old path silently dropped (byteCount*) now resolve.- Key rename: all 107 Russian text-keys → English text-keys across 9 code files
and 11 locale files (
L["Обновить"]→L["Refresh"]). Keys-only swap; values untouched, so ruRU keeps its Russian values. The 8 multiline keys stay bracket-formL[ [=[…]=] ]exactly as before. Zero collisions (no two Russian keys shared an English value; no clash with an existing identifier key), verified by full parse. GUI/LegacyMainWindow.lua: 14 keys were inlined as Cyrillic lookups with Englishor "…"fallbacks and were never in enUS or ruRU, so they always rendered English for everyone (including RU clients). Promoted to first-class enUS keys (English) and ruRU keys (the Cyrillic text was literally their Russian translation), restoring Russian for the Invitation Mode dropdown, Level Range, Enable filters, Lvl/Class, and the Clear/Settings/Statistics buttons.- enUS: 236 keys, ruRU: 230 keys. No Cyrillic remains as an
Lkey in code or as a locale left-hand-side key. - Version-agnostic: the locale tables and the
summary.luarebuild are shared across all five targets; no version-specific API surface was touched.
[v2.5.3] (2026-05-30) — bottom-row icons clickable + Guild Roster % clipping
functions.lua — new shared FGI.LiftAboveSizers(button[, offset]) helper
- Bug: the announce, copy-all, compact-mode, settings, and help icons parked on the main window's bottom status row only responded to clicks and mouseover in a tiny center sliver; the rest of each icon's hitbox was dead.
- Cause: AceGUI's Frame widget lays invisible, mouse-enabled resize strips
across the full bottom edge (
sizer_s) and the bottom-right corner (sizer_se). The frame sits at level 100, so the sizers land at level 101 — the same level as any button added as a child of that frame. Two mouse-enabled frames overlapping at the same frame level produce ambiguous hit-testing, so the sizer wins most of the area and the icon only responds where it happens to win the Z-fight. Nothing to do with the texture,SetSize, or hit-rect insets. - Fix: new
addon.LiftAboveSizers(button, offset)in functions.lua bumps a button toparent:GetFrameLevel() + offset(default 5), clearing both sizers. Defined on the addon table (callable anywhere asFGI.LiftAboveSizers) and nil-guarded so it's safe on any frame. GUI/MainWindow.lua now routes all five bottom-row icons (helpIcon,announceIcon,copyAllIcon,compactModeIcon,gearIcon) through it instead of repeating the frame-level math inline. Every future button parked on a window edge should call it too. - Version-agnostic: AceGUI's sizer layout is identical across all five targets, so the single helper is correct for Classic Era / TBC / Wrath / Cata / Retail.
GUI/Tabs/GuildRoster.lua — percent column clipped under the scrollbar on narrow screens
- Bug: the v2.5.1 percent-of-guild column rendered under each section's
scrollbar gutter on smaller resolutions, clipping the
%digits. - Cause: the row
countandpctFontStrings used fixed left x-offsets (label width 120, count at x=130, pct at x=175). Each section's scroll child is only ~150 px wide at the tab's minimum width, so the fixed pct x=175 overflowed it into the scrollbar gutter (the scroll frame is inset −22 for the bar), where it was clipped. - Fix: anchor
pctto the scroll child'sTOPRIGHTandcountto the left ofpct, both right-justified, with thelabelfilling the remaining space on the left (SetWordWrap(false)so a long rank name clips instead of colliding). The number columns now scale with the actual column width at any resolution and always sit inside the scrollbar gutter.
[v2.5.2] (2026-05-30) — race-backfill button for online guildies
Modules/FGI_RaceBackfill.lua — one-shot manual backfill via /who g-<guild>
v2.5.1 wired WhoInfo.raceStr into alreadySended.race and memberHistory.race so the chat-name hover tooltip shows race alongside level + class — but only for entries captured after v2.5.1. Existing entries (and any guildie FGI never /who'd via scan flow) stayed with race = nil and would only fill in by chance the next time the player appeared in a recruit scan.
The cheap-path backfill. A single /who g-"<YourGuildName>" returns up to 50 currently-online guildmates as WhoInfo structs, each carrying raceStr. For any normally-sized guild that's a single query covering the entire online roster.
Modules/FGI_RaceBackfill.lua — new module, ~150 lines, registers fn.backfillOnlineRaces() on addon.functions. Implementation outline:
- Gate checks. Refuse with a clear chat message if any of:
- No active DB (
addon.DB.globalnil) - Recruitment scan in progress (
#addon.search.whoQueryList > 0) - A
/whois currently in-flight (libWho.isAddon) - Player not in a guild (
GetGuildInfo("player")returns nil)
- No active DB (
- Swap the libWho callback. Save
libWho.callbackto a module-local, thenlibWho:SetCallback(onBackfillResults). The gates above guarantee no scan-engine query is in flight at this moment, so swapping is safe. - Send the query.
libWho:GetWho('g-"' .. guildName .. '"'). The quotes around the guild name handle multi-word guild names; without them,g-Achievement Loungewould be parsed asg-Achievement+Lounge(a separate name filter). - One-shot response handler. Walks the
WhoInfolist, normalizes each name viafn:normalizePlayerName, looks up the key inDB.factionrealm.memberHistoryandDB.realm.alreadySended. For each store: if entry exists and.raceis nil-or-empty, stamp it withinfo.Race. Tracks four counters — joined-record fills, anti-spam fills, "already had race" hits, and "not in any FGI store" misses — and prints a one-line summary. - Restore the scan-engine callback.
libWho:SetCallback(savedCallback)so subsequent recruitment-scan/whoresponses route back throughsearchWhoResultCallback. Failure to restore would silently break the next recruit scan. - 50-cap warning. If the response returned exactly 50 entries, print an additional note explaining the cap and recommending a re-run when fewer are online (since
/whotruncates at 50 results with no native pagination).
Idempotent. Running the backfill twice writes nothing the second time — the not race or race == "" check skips entries that already have race, whether populated by the first backfill or by a recruit-scan fn:rememberPlayer call.
Bare-number alreadySended entries left alone. A legacy alreadySended[k] = <timestamp> (pre-v2.2.0 / from older sync peers) is NOT upgraded to a table with race here. The next fn:rememberPlayer write on that name will rewrite the slot with the full table shape including race; backfilling bare-number → table from a different code path would risk diverging from the preserve-on-nil semantics in rememberPlayer.
Out of scope for the cheap path (deliberate, documented in the Settings tooltip):
- Offline guildies —
/whodoesn't see offline players. Their race fills in when they next log in and the backfill is re-run, or via natural recruit scans. - Cross-realm anti-spam entries —
/whois local-realm only on retail. Cross-realm names stay nil. - Per-name backfill for the wider anti-spam list — would need one
/who n-<name>per entry (rate-limited 2-8 s each). Deferred to a follow-up "expensive path" if anyone asks.
Settings → Guild → Member-history tooltips — new "Backfill races" button
GUI/SettingsPanel.lua — new backfillRaces execute entry at order 9.5 (immediately below the showChatTooltip toggle that the race data feeds into). Tooltip explicitly calls out the three out-of-scope cases above so users don't expect the button to fill in offline / cross-realm / non-guildie entries.
TOC registration
Modules\FGI_RaceBackfill.lua added to all five TOCs (FastGuildInvite.toc, _BCC, _Wrath, _Cata, _Mainline) immediately after Modules\FGI_MemberHistory.lua since the new module reads from the schema the history module defines.
No DB / sync / settings impact
- No new DB defaults — the backfill writes into existing
memberHistory[k].raceandalreadySended[k].raceslots that v2.5.1 already defined. - No sync wire-format change —
raceremains local-only enrichment; the bare-timestamp REMEMBER broadcast is unchanged. - No new settings DB keys — the button is a stateless execute.
- Multi-version safe —
g-<GuildName>is a documented/whofilter on every supported client;WhoInfo.raceStrships on every supported client (confirmed in v2.5.1 work).
[v2.5.1] (2026-05-30) — sync-failure message rewrite + race shown on chat-name hover + Guild Roster percent column
Guild Roster sections gain a greyed-out percent column
Field request via screenshot mockup: add a %-of-guild column to each Guild Roster breakdown so a glance at any row reads as "X% of the guild" rather than just a raw count. Implementation in GUI/Tabs/GuildRoster.lua buildSection — the per-row FontString pool gains a third entry (pct) at x=175 in dim grey (SetTextColor(0.55, 0.55, 0.55)), wired into the clearPool / acquireRow / setData paths so the new column reuses the same churn-free row recycling the existing label + count strings do.
-- per-row pool entry now holds three strings
pool[i] = { label = label, count = count, pct = pct }
-- setData computes the percent from ag.total (the guild-wide member
-- total), not the section sum, so the three sections share a single
-- denominator and a "5%" in By Class is directly comparable to a "5%"
-- in By Rank
if total > 0 then
row.pct:SetText(math.floor((r.count or 0) / total * 100) .. "%")
end
Denominator choice: ag.total (guild-wide member count), not the section sum. Three reasons: (a) the three sections each show every guild member counted once, so the section sum IS the guild total — using ag.total is the same number with cleaner semantics; (b) for the level section specifically, empty buckets are still elided so a "section sum" would equal ag.total regardless; (c) a glance-comparison across sections ("Hunters are 11% of the guild; rank Social is 43% of the guild") works only when the denominator is uniform.
Rounding: math.floor(count / total * 100). Tiny populations render as "0%" (e.g. a 1-member rank in a 582-member guild → floor(0.17) = 0%) rather than "<1%" so the column width stays predictable. Matches the visual pattern in the user's reference screenshot.
No behaviour change beyond the visual — same data, additional column. No DB / settings / sync impact.
Chat-name hover tooltip shows race (player-requested)
Players asked for race alongside level + class on the FGI chat-hover tooltip (Settings → Guild → Member-history tooltips → Show FGI tooltip on chat-name hover). The native guild roster API (GetGuildRosterInfo) does not return race — confirmed by walking the 15-return signature in Modules/FGI_ChatTooltip.lua:93 — so the race has to come from somewhere FGI already tracks it.
WhoInfo.raceStr (documented at F:\Blizzard API Docs\Blizzard_APIDocumentationGenerated\FriendListDocumentation.lua:626) carries race on every /who result, and the scan loop at functions.lua:3958 was already grabbing it as entry.race. The gap was downstream: fn:rememberPlayer (functions.lua:2551) wrote { time, class, level } to alreadySended but dropped race; MemberHistory:onJoin (Modules/FGI_MemberHistory.lua:53) wrote { joinedAt, lvlAtJoin } without race; the tooltip rendered "Level X Class" with no race segment.
Three small surgical changes to wire the existing scan-captured race through to the tooltip:
fn:rememberPlayersignature gains a 4thracearg. Same preserve-on-nil pattern asclass/level— sync REMEMBER receives + accept-handler fallbacks call withniland preserve any existing value rather than wiping. ThealreadySended[k]table shape adds aracefield; wire format stays bare timestamp so older peers continue to decode (race is local-only enrichment, mirrors howclassandlevelwere added in v2.2.0).-- v2.5.1 DB.realm.alreadySended[normalizedName] = { time = now, class = classToken, level = level, race = race, }Call-site threading. Every site that has a scan-result entry in scope now passes
entry.race(ore.race):- functions.lua
fn:invitePlayer—local race = entry.racecaptured next toclassTokenandlevel, then threaded through to all fourpendingInvitesentry shapes (Types 1 / 2 / 3 / 4) and bothrememberPlayercalls (Type 3 immediate + decline-noInv branch). 6 sites in one function. - functions.lua
fn:promotePendingToAntiSpam— picksentry.raceoff the pending entry and passes it through. - GUI/Tabs/Scan.lua — both call sites (batch-Decline iterator + per-row Skip handler).
- Modules/compactFrame.lua — compact-tray Skip handler.
- GUI/LegacyMainWindow.lua — legacy-window Skip handler.
- Sync REMEMBER receiver at functions.lua:5038 intentionally NOT updated — it has
msg.playerNameonly and rightly passes nil for class / level / race; the preserve-on-nil pass inrememberPlayerkeeps whatever local enrichment is already on the entry.
- functions.lua
MemberHistory:onJoincopies race forward fromalreadySended. When a new guildie is detected by LibGuildRoster, FGI looks them up inDB.realm.alreadySended(they were just /who'd seconds-to-minutes ago for the invite, so the entry is fresh) and stampsraceonto the newmemberHistory[key]entry alongsidejoinedAt/lvlAtJoin. Falls back to nil for hand-invited joiners that FGI never scanned.-- Modules/FGI_MemberHistory.lua local race if DB.realm and DB.realm.alreadySended then local sent = DB.realm.alreadySended[key] if type(sent) == "table" then race = sent.race end end DB.factionrealm.memberHistory[key] = { joinedAt = now, lvlAtJoin = nil, race = race, }FGI_ChatTooltipresolves race and inlines it. New private helperlookupRace(key, shortKey)in Modules/FGI_ChatTooltip.lua checksmemberHistoryfirst (longest-lived store, survives anti-spam expiry), then falls back toalreadySended, and returns nil when neither has a race string. The level + class line at line 158 conditionally splices race in:-- v2.5.1 if race then lvlClass = "Level " .. (roster.level or "?") .. " " .. race .. " " .. (roster.class or "") else lvlClass = "Level " .. (roster.level or "?") .. " " .. (roster.class or "") endfn/key/shortKeyhoisted from theif DB thenblock to the top ofpopulate()so both the race lookup and the existing blacklist / anti-spam / leave blocks reuse the same canonical keys (single normalization pass, no behaviour change to the existing blocks).
Coverage matrix for hover targets after this lands:
| Hover target | Race shown? | Source |
|---|---|---|
| Guildie who joined via FGI invite at any point post-v2.5.1 | yes | memberHistory.race |
| Guildie joined via FGI but pre-v2.5.1 | not yet | fills in after you /who them once more, then yes via alreadySended.race |
| Non-guildie recently in your scan queue (anti-spam list) | yes | alreadySended.race |
| Random chat name FGI has never /who'd | no | falls back to existing "Level X Class" line |
Multi-version safety. WhoInfo.raceStr ships on every FGI-supported client (Classic Era / TBC / Wrath / Cata / MoP / Anniversary / Retail) — confirmed at the WhoInfo definition site in the Blizzard docs. No version guards needed. Sync wire format unchanged → older peers receiving REMEMBER broadcasts continue to decode normally; race stays a local-only enrichment field.
No DB migration needed. race is nil on every existing entry; readers (tooltip, anti-spam tab, member-history tooltip) all treat nil as "no data, omit the segment." New scans / new joiners populate the field going forward.
fn.onSyncFailed parenthetical removed; main print gains accurate retry hint
Field-reported by a user who saw <FGI> Sync failed: Galdof-OldBlanchy timed out — Orgrimmar (may be in a raid, instance, or combat) while standing in a city. They were neither in a raid nor an instance nor combat; the parenthetical was pure speculation. Every other branch of onSyncFailed (functions.lua:5745) — offline / mobile / DND / AFK — is backed by actual GetGuildRosterInfo return values, but the fallback case (partner online, not mobile, not DND, not AFK) had no evidence behind its "may be in a raid, instance, or combat" claim.
The branch at line 5768 changed from:
elseif zone and zone ~= "" then
reason = " — |cffffff00" .. zone .. "|r (may be in a raid, instance, or combat)"
end
to:
elseif zone and zone ~= "" then
-- Partner was online, not mobile, not DND, not AFK.
-- We have NO evidence about why they didn't respond
-- (could be a load screen, network blip, addon-message
-- throttling, or the WoW client being momentarily
-- unresponsive). Show the zone as bare context and
-- do NOT speculate about cause — past wording
-- ("may be in a raid, instance, or combat") was a
-- guess that misled users sitting in a city.
reason = " — they were online in |cffffff00" .. zone .. "|r but didn't reply"
end
The main print at 5774 also gains an accurate recovery hint:
-- before
print(string.format("%s |cffff0000Sync failed: %s timed out|r%s", FGI_PREFIX, partner, reason))
-- after
print(string.format("%s |cffff0000Sync failed: %s timed out|r%s. Sync will reattempt automatically the next time any peer triggers a sync.", FGI_PREFIX, partner, reason))
"Sync will reattempt automatically the next time any peer triggers a sync" reflects the actual recovery mechanism. onSyncFailed only fires when wasEstablished was true (functions.lua:4854) — i.e., a real handshake completed with another FGI-running guildmate. That means the user is by definition in a multi-FGI-user guild, where any subsequent hash broadcast — PLAYER_LOGIN (functions.lua:5620), the manual Sync button, or the end-of-successful-sync chain (fn.startSync(true) at functions.lua:5533) from any peer — will re-detect the mismatch and re-attempt sync opportunistically. There is no fixed-interval auto-retry timer; the message wording was earlier "Click Sync to retry" but that understated the actual behaviour in any populated guild.
Message-text only. No DB schema change, no settings change, no behavioural change. All four target trees (_classic_era_, _classic_, _anniversary_, _retail_) propagated via wow-version-replication.ps1.
[v2.5.0] (2026-05-29) — rebranded from v2.5.0 mid-development because the scope (12+ feature areas, 2 new tabs, schema migrations, taint-cascade root-fix, OR-filter semantics flip) outgrew a patch designation. HistoryKeeper taint cascade fixed via canaccessvalue (per Blizzard docs); Messages tab moved to the main window mirroring Filters with a per-message enabled flag; per-filter Message bindings (fn.getMsgForName picks the bound template for matched filters); Scan tab gains Row 2 batch actions (Sel All / +(N)sel / Skip / Decline / Blacklist); welcome message + welcome whisper gating on "only my invitees"; Guild Roster duplicate-rank-name bug fixed (key by rankIndex, not rankName); GM-policy "Never expire" anti-spam option; canEditGuildPolicy hardened for Classic Hardcore (drop unreliable CanGuildPromote); copy-all icon in main-window status row pops a clipboard-ready name list; window-layer slider (0-100, default 50); scan-interval floor version-aware again (8 s Retail, 2 s Classic-family); filter combination flipped from AND to OR (multi-filter empty-queue bug); Guild Roster scrollbars auto-hide + max-level count in summary; scan-tab scroll preservation on checkbox + per-row + batch actions; showChatTooltip default-off
canaccessvalue taint-cascade fix (HistoryKeeper crash on MONSTER_YELL)
Field-reported 90× attempt to perform string conversion on a secret string value (execution tainted by 'FastGuildInvite') cascade from Blizzard_ChatFrameBase/Shared/HistoryKeeper.lua:35 during normal dungeon play. Root cause: three of our chat-event surfaces touched event args directly without first calling canaccessvalue. On retail, several chat events (CHAT_MSG_AFK, CHAT_MSG_DND, CHAT_MSG_WHISPER, CHAT_MSG_WHISPER_INFORM) can carry a sender / target arg as a Blizzard secret-string token; any string operation on that token (including tostring, ==, :find, :gsub) taints the addon's execution. Subsequent secure-code dispatch (HistoryKeeper's strlower(chanSender) on MONSTER_YELL) then crashes once per affected event for the rest of the session.
Memory feedback_consult_blizzard_api_docs.md requires consulting F:\Blizzard API Docs before fixing taint bugs; the docs at Blizzard_ChatFrameBase/Shared/ChatFrameFilters.lua:37,116 show the canonical defense pattern:
local function ApplyFilter(chatFrame, event, ...)
if canaccessvalue(...) then
return callback(chatFrame, event, ...);
end
end
canaccessvalue is the Lua-side gate documented in FrameScriptDocumentation.lua:65 (SecretArguments = "AllowedWhenUntainted"). v2.5.0 switches the three unprotected sites:
Modules/Scan.luaawayMsgFilter(NEW in v2.4.0 — the regression source) — was usingpcall(function() return sender == "" end)to "defend" against secret-string senders.pcallcatches the throw but doesn't strip the taint the addon acquired by touching the value. Replaced withif gv.isRetail and canaccessvalue and not canaccessvalue(sender) then return endBEFORE the equality /normalizePlayerNamecalls.Modules/WhisperAlert.luaCHAT_MSG_WHISPER handler — same pattern fix on thesenderarg beforefn.fullPlayerNameis called.functions.luaFCFMgr OnEvent hook (installFloatingChatFrameManagerHook) —targetarg gated throughcanaccessvaluebefore thepcall(fn.fullPlayerName, fn, target)call. v2.3.2 had used pcall-only here too; works in the no-secret-string case but doesn't strip taint when the field IS a secret string.
The two AceConfigDialog-registered chat filters (fn.hideWhisper, fn.hideAwayResponse) are already protected — ChatFrame_AddMessageEventFilter's registry auto-wraps every callback with canaccessvalue(...) per the doc-pattern above. The leak was purely on the raw CreateFrame:RegisterEvent handlers + the FCFMgr hook, which Blizzard's filter registry doesn't touch.
Messages tab — relocated from Settings to the main window, RowList parity with Filters
The v2.4.0 Messages section in Settings → Messages was an AceConfig select + multiline input pair: pick a template index from a dropdown, edit its body in the field below, add / delete buttons. Field request: surface multiple templates with checkbox-style enable flags so the random-pick pool can be curated, and use the same RowList UI Filters uses for visual consistency. AceConfig has no RowList equivalent and its select dropdown gets unusable with >5 entries, so the page moved out of Settings entirely.
- Schema migration.
messageList[i]flipped from a bare string to{ name, body, enabled }:-- v2.5.0 messageList = { [1] = { name = "Default", body = "Hi NAME! ...", enabled = true }, [2] = { name = "Variant A", body = "Hey NAME — ...", enabled = false }, }fn.migrateMessageStore(store)in functions.lua wraps legacy string entries in-place:name = "Message N",enabled = true. Idempotent; runs once per scope per login fromOnInitializeviafn.migrateAllMessageStores. Sync receivers tolerant:fn:getRndMsgaccepts both the new table form AND the legacy string form (treating strings as enabled) so peers on pre-v2.5.0 builds broadcasting a stringmessageListstill get rendered correctly. - New main-window tab GUI/Tabs/Messages.lua. Mirrors Filters' tab structure: top strip with
[Name input] [Save] [Add new]+ a multi-line scrollable Body editor + live byte counter (usesfn:estimateSentBytes(text, "split")for placeholder-substituted accuracy). RowList below with checkbox (On) + Name + Body preview + delete action. Click a row → loads into form for editing. - Scope toggle (
messageScopeGlobal— account-wide vs faction-realm storage) stays in Settings; it's a session-level preference that belongs there, not a list-editing concern. - Settings stub at GUI/SettingsPanel.lua replaces the per-template editor with a description + "Open Messages tab" execute button calling
addon.MainWindow:Open("messages"). - MainWindow wiring in GUI/MainWindow.lua: tab added between Filters and Blacklist in
TAB_DEFS, dispatcher case inbuildTabContent,TAB_MODULEentry for the resize-floor lookup, help-tooltip entry for the "i" icon. - TOC updates —
GUI\Tabs\Messages.luaadded to all 5 TOC files (FastGuildInvite.toc,_BCC,_Wrath,_Cata,_Mainline) afterFilters.lua. - Body editor click-to-focus fix —
scrollFrame:GetWidth()returned 0 at Render time before the parent strip had been laid out, feedingbodyInput:SetWidth(0 - 8) = -8and collapsing the EditBox click-target. Fixed by clamping tomath.max(200, scrollFrame:GetWidth() - 8)for the initial width, gating theOnSizeChangedresnap onw > 8, and addingscrollFrame:EnableMouse(true) + OnMouseDown → bodyInput:SetFocus()so a click on the empty body area focuses the editbox even before any text has been typed. - Name label moved above the input + tooltip relocated. Per field request: bare
attachTooltip(nameInput, ...)was too easy to miss; tooltip now lives on theNamelabel above the input, matching Filters'placeLabelAbovepattern.STRIP_H162 → 178 /MIN_HEIGHT460 → 476 to accommodate the new label gutter.
Per-filter Message bindings — route the whisper template by which filter accepted the candidate
Field request: tie specific recruitment templates to specific filters so a "Healers 30-40" filter sends one template and a "Hunters" filter sends a different one, instead of every recruit getting a random pick from the global pool.
- Filter schema (DB.realm.filtersList) gains
boundMessage— string (name of a Messages-tab entry) or nil. nil = no binding, falls back to the random enabled pool. - Scan-engine plumbing (functions.lua
fn:filtered) — iteration changed fromfor _, v in pairs(...)tofor name, v in pairs(...)so the outer name is available. When a whitelist (schemaVersion=2) filter ACCEPTS a player (all criteria pass), the filter's name is appended toplayer.matchedFilters. Legacy deny-list filters aren't tracked — "didn't reject" isn't the same semantic as "wants this recruit", and the legacy schema can't carry a binding anyway. - inviteList carry —
fn:addNewPlayerwritesmatchedFilters = p.matchedFiltersonto the new entry so the field survives from scan time to invite time. - Resolver
fn.getMsgForName(name). Precedence: GM policy override → per-filter binding (alpha-sorted matched filters; first valid binding wins for stable tiebreak) →fn:getRndMsgrandom pool. Disabled-in-pool messages still send via binding by user spec — the binding is explicit user intent and overrides the enabled flag. Stale bindings (deleted message) are silently skipped here. - Send site (
fn:sendWhisper) — single-line change fromfn:getRndMsg()tofn.getMsgForName(name) or fn:getRndMsg(). Defensiveorcovers file-load order edge cases. - Filters tab UI (GUI/Tabs/Filters.lua) — new
msgDDUIDropDownMenuTemplate on Row 1, anchored to the right of Max Lvl. Values:(none — use random pool)+ each Messages-tab entry by name. Stale bindings render as|cffff6666(deleted: <name>)|rand are disabled.loadIntoForm/doSavethreadform.boundMessage↔filter.boundMessage. NewMSG_DD_W = 140constant.
Scan tab — Row 2 batch actions
Strip rebuilt as two rows (STRIP_H 32 → 64, MIN_HEIGHT 400 → 432) — single-target controls on Row 1, batch / selection controls on Row 2. Per user spec, Sel All belongs with its batch siblings.
- Row 1:
[>>] [+(N)] [Clear] [Mode ▾] counters Lvl X-Y(unchanged controls, re-anchored toROW1_Y = -5). - Row 2 (
ROW2_Y = -33):[Sel All] [+(N)sel] [Skip (N)] [Decline (N)] [Blacklist (N)]—Sel Allmoved from end-of-row-1 chain to TOPLEFT at Row 2;+(N)selfollowed; three new batch buttons appended. - Skip (N) — pure
table.removeover selected entries (iterated tail-forward for stable indices), honoursDB.global.rememberSkipped. No protected API. Single refresh at end withpreserveScroll = true. - Decline (N) — iterates selected,
fn:invitePlayer(true, i)per entry — thenoInv = truebranch records the decline (history + searchInfo + promote-to-anti-spam) without callingC_GuildInfo.Invite. No protected API. - Blacklist (N) — snapshots the candidates BEFORE the popup so the user can't desync the list while the StaticPopup is up; "Blacklist N selected player(s)?" confirm dialog; on accept iterates
UI.FastBlacklist(entry, nil)per entry (same path the per-row Blacklist icon uses). One refresh after the loop. - Live count labels — the existing
selCountrefresh inScanTab.Refreshnow pushes the same count onto Skip / Decline / Blacklist labels so all four batch buttons stay in lockstep. - lvl container / counterTip resized from
STRIP_HtoBTN_Hso their hover zones don't poach Row 2 clicks;modeDDrechained frominvSelBtn(now Row 2) toclearBtn(Row 1).
Welcome message + welcome whisper "only my invitees" gating
Field request: stop firing the FGI welcome at hand-invites and at recruits invited by other officers — only my FGI invitees.
- DB defaults (FGI_Core.lua):
welcomeOnlyMyInvitees = false,welcomeWhisperOnlyMyInvitees = false. Defaults off — preserves the pre-v2.5.0 "welcome every joiner" behaviour for existing users. - Gate in
LibGuildRoster.OnMemberJoinedcomputesiInvitedfromDB.realm.alreadySended[normalizedName]. On retail, the accept-counter block above promotespendingInvites → alreadySendedbefore this gate runs; on classic, theCHAT_MSG_SYSTEMaccept path does the same ahead ofLibGuildRosterfiring. Each welcome path gates onnot toggle or iInvited. - Two new toggles in
Settings → Guild → Welcome on join. Iterated three times before landing on stacked full-width: half-width pairing truncated the labels because AceConfig's checkbox widget hardcodes the label-area cap regardless of widget width. Final: each gate on its own row with(gate the auto-welcome above)/(gate the auto-whisper above)parenthetical in the label so the relationship is explicit without the visual proximity cue. - Cross-officer caveat documented in the gate's comment block:
alreadySendedis "anyone in your FGI fleet (you + peers receiving REMEMBER syncs) invited them", not "I personally invited them". Per-character attribution would need a separate stamp on the entry — out of scope for v2.5.0.
Guild Roster — duplicate rank name bug
Field report from a guild with two distinct ranks both literally named "Guild Master" (one actual GM at rank 0 with 1 member, plus a separate rank N also labeled "Guild Master" with 60 members). FGI's "By Rank" breakdown showed Guild Master 61 / Guild Master 61 — same combined count rendered twice — while the comparison addon correctly showed Guild Master 1 / Guild Master 60.
- Root cause:
result.byRankwas keyed byrankName, so both rank-0 and rank-N members landed in the same bucket (61).result.rankOrderwas keyed byrankIndex(correctly distinguished the two ranks), but the renderer looked up the count viabyRank[name]→ got the merged total → showed it twice. - Fix (GUI/Tabs/GuildRoster.lua):
byRanknow keyed byrankIndex;rankOrdercarries{ idx, name }records so the renderer readsbyRank[rec.idx]for the count andrec.namefor the display label. Duplicates in name now render as separate rows with each rank's own member count.
GM-policy "Never expire" anti-spam option
The Settings → Main → Anti-spam expiry dropdown for end users had a [1] = "Never expire" choice; the matching GM-policy dropdown didn't. Field request: let the GM force every guild member's retention to permanent.
gmPolicyAntiSpamdropdown (GUI/SettingsPanel.lua) gains[1] = "Never expire"at the end of the sort{ 0, 2, 3, 4, 5, 1 }so the reading order goes shortest → longest retention.- User-side enforcement in
clearDBtimes.getextended: whenantiSpamMin == 1, forcev = 1(every member pinned to Never) regardless of their saved choice. Index 2..5 keeps the pre-v2.5.0 "bump to floor" logic. - Locked-by-policy description branches on the antiSpamMin value:
"Locked to Never expire by guild policy"when pinned, the original"Minimum locked by guild policy"otherwise.
canEditGuildPolicy rewritten — Classic Hardcore safety
Field report from Classic Hardcore (Anniversary realms): the four Settings → Guild → Guild Policy controls weren't greying out for non-GM, non-officer characters. Anyone in the guild could push policy.
Root cause: the v2.4.0 predicate IsGuildLeader() or CanGuildPromote() relied on CanGuildPromote, which is field-confirmed unreliable on Classic Era / Anniversary — returns true for non-officer members when the guild's lower ranks have the promote permission toggled on. v2.5.0 (GUI/SettingsPanel.lua) replaces it with a rank-index gate: IsGuildLeader() OR rankIndex in {0, 1} (GM or conventional officer tier).
- Trade-off documented in the predicate's comment block: guilds with non-default rank structures where the actual officer rank is index 2 or 3 will need their officers promoted to rank 1 to push policy. The alternative — everyone editing policy on Hardcore — is worse.
gmPolicyMessagefield's bespoker ~= 0gate (GM only, excluded officers) also rewritten to use the sharedcanEditGuildPolicyso all four policy widgets gate identically.
showChatTooltip default flipped OFF
User preference: the chat-name hover tooltip is opt-in, not opt-out. FGI_Core.lua DB default showChatTooltip flipped from true to false. GUI/SettingsPanel.lua get callback rewritten from ~= false (nil-defaults-true pattern) to truthy check so AceDB's stripped-defaults behaviour reads as off for fresh installs and for upgraders whose stored value matched the old default.
Scan tab scroll preservation on in-place mutations
Field-reported: checking a box on row 30 of the scan queue snaps the list back to row 1. Same for per-row Invite / Skip / Decline / Blacklist clicks. RowList's SetData(data) unconditionally reset listOffset = 0 because "scroll to top when data changes" was the right default for filter changes and new scans — but wrong for in-place edits.
- GUI/RowList.lua
RowList:SetData(data, preserveScroll)— new optional flag. When true, clampslistOffsetto the new data length so a row removed from the end doesn't leave an empty view; otherwise preserves the offset. Default behaviour unchanged. - GUI/Tabs/Scan.lua
ScanTab.Refresh(preserveScroll)threads the flag through toSetData. 8 call sites updated to passtrue: checkbox toggle, per-row Blacklist / Decline / Skip / Invite, strip+(N)invite-next, stripSel All, strip+(N)sel. Left at default (reset-to-top): the>>scan button (new dataset), level-range mousewheel adjust (filter shape change), initial render.
Copy-all names from the current tab
Field request: bulk-export names from any list tab (Anti-Spam, Blacklist, History, scan queue) for spreadsheets / Discord posts / external tools. WoW's Lua sandbox has no OS-clipboard access, so the standard workaround is a modal popup with a pre-selected EditBox — the ChatCopyPaste idiom.
UI.ShowCopyAllPopup(title, text)(GUI/UI.lua) — singleton dialog (one shared frame reused across calls to avoid leaking Blizzard frame handles). Title + "Press Ctrl+C to copy, Esc to close" hint + scrollable multi-line EditBox pre-filled with the bulk text +HighlightText()called on show so Ctrl+C works immediately. Draggable by title bar; closable via Esc or the corner X button.UI.GetCopyTextForTab(tabKey)(GUI/UI.lua) — per-tab resolver. Anti-Spam / Blacklist dump alpha-sorted name lists from their backing tables. History dedupes and dumps in chronological order. Scan queue dumps in queue order. Non-list tabs (Statistics, Filters, Messages, Guild Roster, Custom Scan, Quiet Zones, Announce) return nil so the icon no-ops with an explanatory tooltip.- New
copyAllIconon the main window's bottom-right strip (GUI/MainWindow.lua). 20×20,Interface\Buttons\UI-GuildButton-PublicNote-Uptexture (matches the button-style icons on the row better than the icon-style horn). AnchoredBOTTOMLEFTtoannounceIcon.BOTTOMRIGHT+ 3,0. Tooltip on hover surfaces the active tab + row count, e.g. "Anti-Spam — Anti-Spam names (1,247)"; tabs without a list show the "doesn't have a list of names to copy" fallback. statusbgright edge shrunk from-226to-249(24 px) to make room for the new icon + 3 px gap; help / minus / gear / close anchors unchanged.
Window-layer slider — let other addons render above (or below) FGI
Field request: "my WIM whisper windows appear under FGI's UI". Root cause: the AceGUI fork at Libs/GUI.lua hardcodes SetFrameStrata("FULLSCREEN_DIALOG") on every FGI Frame / TabGroup / ClearFrame widget, which sits well above WIM's MEDIUM strata. Fix: expose the strata/level as a single 0-100 slider so end-users can dial FGI's render-stack position up OR down without needing to know the difference between strata and level.
UI.GetWindowLayer(value)(GUI/UI.lua) — maps 0-100 to a(strata, level)pair: 0-19 →MEDIUM, 20-39 →HIGH, 40-49 →DIALOG, 50 →FULLSCREEN_DIALOG / level 0(the historical default), 51-100 →FULLSCREEN_DIALOGwith the frame level rising. Asymmetric split is intentional: going below 50 has to traverse three strata to reach "below WIM" territory, while going above just raises the frame level within the top strata.UI.ApplyWindowLayer(frameOrWidget)— unwraps AceGUI widgets via the.framefield, readsDB.global.windowLayer, and applies. Idempotent.UI.RefreshAllWindowLayers()— walks every currently-open FGI window (MainWindow / compactFrame / legacyMainFrame) and re-applies. Called from the Settings slider'ssetso live drags update without/reload.- Injection sites: GUI/MainWindow.lua:414 right after
self.frame = f(before child widgets anchor); Modules/compactFrame.lua via anOnShowhook (file-load-timeaddon.UIisn't loaded yet — Modules loads before GUI); GUI/LegacyMainWindow.lua right afterGUI:Create("ClearFrame"). - DB default
DB.global.windowLayer = 50(FGI_Core.lua) preserves the pre-v2.5.0 FULLSCREEN_DIALOG/0 behaviour for existing users. - Settings slider at GUI/SettingsPanel.lua
windowLayer = { type = "range", min = 0, max = 100, step = 1, bigStep = 10 }. Name carries the default value inline ("Window layer (default: 50)") since AceConfig'srangewidget has no built-in "default tick" mark; description spells out what 50 / below 50 / above 50 mean.
Guild Roster tab polish — auto-hide scrollbars + max-level count in summary
Field requests bundled with the v2.5.0 rebrand:
- Scrollbars auto-hide when content fits (GUI/Tabs/GuildRoster.lua
buildSection.setData).UIPanelScrollFrameTemplate'ssf.ScrollBar+ScrollUpButton+ScrollDownButtonstay visible by default even when there's nothing to scroll. On the Guild Roster tab those three sections sit side-by-side in narrow columns and the always-on scrollbar gutter wastes valuable horizontal real estate. v2.5.0 comparessc:GetHeight()(content) againstsf:GetHeight()(visible) insidesetDataand hides the whole scrollbar group when content fits. Re-evaluated on resize via acontainer:HookScript("OnSizeChanged", ...)that remembers the last aggregation snapshot — the newsetDataAndRememberwrapper capturesagso the resize callback has fresh data to re-compute against. - "Total Ns: <count>" in the summary where
Nis the current expansion's level cap fromGetMaxPlayerLevel(). Distinct from theBy Level-section's 5-level bucket (e.g. "86-90") because the user specifically wants exact-cap members (e.g. 80s, not 76-80s). Newresult.maxLevel/result.maxLevelCountfields filled during the aggregation walk; segment skipped whenmaxLevel == 0(very early load beforeGetMaxPlayerLevelreturns) or when no one's at the cap (avoids printing "Total 80s: 0" clutter). Auto-adapts on future expansion bumps — no client-version branching.
Multiple-filter combination flipped from AND to OR
Field-reported bug: with two filters checked (e.g. "Hunters 30-40" and "Druids 30-40"), the scan queue came back empty. Root cause: fn:filtered (functions.lua) combined whitelist (schemaVersion=2) filters with AND semantics — a candidate had to pass every active filter, so no one class could satisfy both a "Hunters" filter and a "Druids" filter simultaneously. The original design intent was "compose by dimension" (one filter for class, another for level, AND them together for the intersection), but field reports confirm nobody used Filters that way — every reporter set up self-contained "I want this kind of recruit" filters and expected OR.
- Whitelist combination flipped to OR. Two trackers (
hasActiveWhitelist,anyWhitelistPassed) replace the early-return-on-first-fail. A candidate is rejected only if every active whitelist filter said no; passing at least one is enough. Intra-filter criteria are still AND (a single filter's class + level + race conditions must all match for that filter to accept). - Legacy deny-list filters stay AND (any deny-list match still rejects immediately) because the "exclude" semantic doesn't fit OR — you can't "deny by union".
v.filteredCountstill increments per-filter on reject so the Filters tab's Count column tracks "how many candidates this filter said no to", same as before. Just the combination of those per-filter rejections changed.- Per-filter Message bindings still work —
matchedFiltersnow lists every whitelist filter that accepted the candidate; the resolver alpha-sorts and picks the first one with a binding, same as before. OR mode actually makes the binding feature MORE useful — under AND, every active filter had to accept, so only the alpha-first matter; under OR, you might have a "Hunters" filter (bound to a hunter-themed whisper) accept while a "Druids" filter (bound to a druid-themed whisper) doesn't, and the routing reflects the actual match. - Tooltip text in the Filters tab help, MainWindow
TAB_HELP, and theOncolumn header all updated to describe OR semantics + recommend "pile criteria into a single filter for AND-style narrowing".
Scan-interval floor — version-aware again (2 s on Classic-family, 8 s on Retail)
Field-validated: GuildRecruiter has run 2 s scan intervals on Anniversary realms without throttle, and the user reported the same. v2.2.4 tried to expose this as a per-version minimum but the implementation crashed AceConfig — min = function() ... end is rejected by AceConfigRegistry with "expected a number, got function" and the whole settings panel fails to render. v2.2.5 reverted to a single 8 s floor everywhere as a hotfix.
v2.5.0 brings the per-version floor back, done correctly:
fn.getMinScanInterval()(functions.lua) — returns8on Retail,2on Classic-family clients (Era / TBC / Wrath / Cata / MoP / Anniversary). Retail's 8 s floor stays because the v2.2.4 reporter's cascade (stuck scans / ticker stops at 4 / chunk-2 leak) reproduces below ~8 s on Retail; server caches identical/whoresponses and the client silently suppressesWHO_LIST_UPDATE. Classic-family/whorate-limiting is looser.- Slider
minevaluated at parse time (GUI/SettingsPanel.lua) —min = addon.functions.getMinScanInterval() or 8runs when the options table literal is constructed, so AceConfigRegistry sees a static number (2or8depending on client), not a function reference. This is the structural fix v2.2.4 was missing. By the time SettingsPanel.lua parses,functions.luahas already defined the function andFGI_Compatibility.luahas already setaddon.gameVersion.isRetail, so both are safe to call at module-load time. - DB default stays at
8globally (FGI_Core.lua) — fresh installs on every client land at the safe value. Classic users opt into the lower interval explicitly via the Settings slider. - Slider description is version-aware — Retail panel surfaces "Minimum is 8 seconds on Retail — below that the server's /who rate-limit kicks in"; Classic-family panel surfaces "Minimum is 2 seconds on Classic-family clients — Classic's /who rate-limiting is looser". The desc string is also resolved at parse time via the
and ... or ...ternary so AceConfig stores a plain string.
Misc
- Anti-Spam tab header tooltip (GUI/Tabs/AntiSpam.lua) — Unicode
→in"Settings → Main → 'Clear DB after'"replaced with ASCII>. WoW's default font fallback can't render the arrow on every client; the breadcrumb now displays everywhere.
[v2.4.0] (2026-05-29) — _outgoingWhispers rebuilt as a race-free presence table; AFK/DND text + tab suppression added; retail accept-counter migrated off the GUILD_ROSTER_UPDATE diff to LibGuildRoster.OnMemberJoined (S:33 A:188 → fixed); extended invite-outcome enum (NotFound / AFK / DND / Unresolved / in_guild / guild_reject); Anti-Spam tab search; FGI Debug chat tab; /fgi clearwhispers + /fgi debugtab[remove]; muteAnnounce + muteScan toggles
addon._outgoingWhispers — race-free presence-only state table
The v2.1.x → v2.3.2 whisper-echo / popup-tab suppression family went through four counter-and-time-window iterations (60s deadline + exact-bytes text-match + 2s recentSend backup + 4-sweep popup hide; then chunk-count + deferred clear; then drain-frame chunk-accurate decrement). Each iteration fixed the previous round's leak by adding another timing or counter knob, and each one introduced a fresh race. v2.4.0 collapses the whole thing to a presence-only table:
- Single value shape:
addon._outgoingWhispers[key] = true. No counter, no deferred flag, no timestamp, no chunk list. Presence in the table means “FGI sent this name a whisper and they haven't acknowledged it yet.” - Set sites:
fn.refreshWhisperHideDeadline(name)in functions.lua writestrueon every outgoing chunk's send. Idempotent — same key, same value, doesn't matter how many times the loop iterates or which path called it (recruit invite viafn:sendWhisperor auto-welcome viaFGI_Core.lua'sLibGuildRoster.OnMemberJoined). - Cleared exclusively by: (1) incoming
CHAT_MSG_WHISPERfrom this name —Modules/WhisperAlert.luaremoves the key and fires the recruit-reply alert. (2)/fgi clearwhispersmanual command. (3)PLAYER_LOGOUTsweep. (4)/reload(in-memory table wipes). - Outcome handlers do NOT touch the table.
fn:promotePendingToAntiSpam,fn:clearPending,fn:sweepStalePendingall stopped removing or flagging_outgoingWhispers[key]in v2.4.0 rev5. The previous design (rev3-rev4: setdeferred = trueon outcome, let the drain frame clear whenremaininghit 0) raced the per-chat-frame text filter dispatch: drain frame ran BEFORE the filter chain for the last chunk'sCHAT_MSG_WHISPER_INFORM, cleared the key, andhideWhisperfound nothing to suppress. Same race surfaced as the “[Chymp-Thunderlord] is Away:AFK” leak in field testing — outcome handler cleared the key between the FCFMgr OnEvent and the chat-frame filter dispatch.
The trade-off is documented at the top of functions.lua's _outgoingWhispers comment block: a recruit who replies-not-then-declines stays in the table for the rest of the session, so the user can't manually /w them without text suppression activating. /fgi clearwhispers is the escape valve. Field-acceptable because manual whispers to declined recruits are rare in the recruiter workflow.
Filters consume the presence marker
fn.hideWhisper (functions.lua) — CHAT_MSG_WHISPER_INFORM filter. Reads arg5 (target) of the event variadic, resolves through fn:fullPlayerName (with retail's secret-string pcall guard), and returns true (suppress text) if addon._outgoingWhispers[key] is set. No decrement, no counter, no shape inspection. Fires once per chat frame in the dispatch chain — each call independently checks presence and suppresses if true.
fn.hideAwayResponse (functions.lua, NEW in v2.4.0) — CHAT_MSG_AFK / CHAT_MSG_DND filter. Same presence check but reads sender (arg2 of the event variadic; local _, name = select(3, ...) → name = sender). Returns true to swallow the “[Name] is Away:AFK” line entirely when the sender is in _outgoingWhispers. Registered alongside hideWhisper in fn.updateWhisperEchoFilter — both gated on the same DB.realm.sendMSG toggle, both registered/unregistered via securecall(ChatFrame_AddMessageEventFilter, ...) to keep the chat filter table un-tainted on retail.
The FCFMgr OnEvent hook (installFloatingChatFrameManagerHook from v2.3.2) is unchanged and still gates CHAT_MSG_WHISPER_INFORM / CHAT_MSG_BN_WHISPER_INFORM / CHAT_MSG_AFK / CHAT_MSG_DND popup creation on the same presence marker. Two layers — popup creation killed at the manager, text dropped at the per-chat-frame filter — using one source of truth.
Retail accept counter: GUILD_ROSTER_UPDATE diff → LibGuildRoster.OnMemberJoined
Field-confirmed S:33 A:188 (5.7× over-count). Cause traced to the retail-only GUILD_ROSTER_UPDATE diff in functions.lua (pre-v2.4.0 ~line 814): STABLE_THRESHOLD = 2 declared the snapshot “stable” after just two consecutive same-count events. On retail the roster streams in across many GUILD_ROSTER_UPDATE events at login, /reload, and any addon-triggered C_GuildInfo.GuildRoster() call. Any two-frame plateau mid-stream locked stability against a truncated snapshot, and the next event with the full roster ran the diff loop on hundreds of “new” names. For every member that happened to be in DB.realm.alreadySended (historical anti-spam, accumulated over weeks/months), the diff fired addon.searchInfo.invited() + fn.history:onAccept + fn:promotePendingToAntiSpam — burst of false accepts in a single tick.
v2.4.0 deletes the entire diff block and re-homes the accept-tracking logic in FGI_Core.lua's LibGuildRoster.RegisterCallback(addon, "OnMemberJoined", ...):
- LibGuildRoster has
wasInitialized+ login-race retries built in. It never firesOnMemberJoinedduring the initial roster build, so the streaming-roster case cannot produce false “new” firings. - The callback is gated on
addon.gameVersion.isRetailto avoid double-counting on classic (which still uses theCHAT_MSG_SYSTEMaccept path —ERR_GUILD_INVITE_Sis reliable on classic-family clients; the secret-string taint that forced the diff workaround is retail-only). - Same five effects as the old diff:
addon.searchInfo.invited(),fn.history:onAccept(normalizedName),fn.history:logInvite(Accepted, ...),fn:promotePendingToAntiSpam(...), msgQueue cleanup. Functionally identical, just triggered from a source of truth that can't be fooled by partial rosters.
Welcome message / welcome whisper logic in the same callback is unchanged.
Extended invite-outcome enum
FGI_Constants.lua — FGI_INVITE_OUTCOME extended with six new values: NotFound, AFK, DND, Unresolved, in_guild, guild_reject. The first four already had detection sites; v2.4.0 wires them through fn.history:logInvite so they appear in the History tab + Statistics breakdowns. in_guild and guild_reject are new server-rejection paths:
in_guild—ERR_ALREADY_IN_GUILD_Ssystem message ("%s is already in a guild.") parsed inModules/Scan.lua'splayerHaveInvite. The recruit accepted a guild invite somewhere else between FGI's/whoand FGI's invite firing; treat as anti-spam (won't be re-invited this session) but skip theAcceptedcount + REMEMBER broadcast.guild_reject—ERR_GUILD_TRIAL_ACCOUNT_TRIAL+ERR_GUILD_TRIAL_ACCOUNT_VETERAN+ERR_GUILD_NOT_ALLIED. These error strings don't carry a player name ("You cannot invite trial accounts to a guild."etc.), so the handler looks up the most recentpendingInvitesentry vianext()and treats it as the target. Same anti-spam promotion asin_guild.
addon.searchInfo (functions.lua line ~160) gains four new counter buckets — notFound, afk, dnd, unresolved — each with a metatable __call hook that bumps self[1] and fans out to the compact-tray refresh + main-window + legacy-window refresh dispatchers. The compact frame's counter strip (Modules/compactFrame.lua) extends from 6 buckets (F:S:A:X:D) to 9 buckets (F:S:A:X:D:?:K:B:U:) where ? = notFound, K = AFK, B = DND, U = Unresolved. Tooltip on hover lists all buckets.
GUI sidebar updates: GUI/Tabs/History.lua colours new outcomes (not_found grey, afk/dnd soft blue, unresolved muted yellow); GUI/Tabs/Statistics.lua countInvitesByOutcomeAll / countInvitesByOutcomeSince extended; GUI/Tabs/Scan.lua counter-strip tooltip lists all 9 buckets.
pendingInvites state machine + deferred alreadySended write
Pre-v2.4.0 every fn:invitePlayer call wrote alreadySended[name] = ... upfront, on the theory that “we've now committed to inviting this person.” That polluted anti-spam with names where the invite failed (server-side reject, not found, AFK/DND) — those entries never cleared because no outcome handler removed them. v2.4.0 inverts the flow:
addon.pendingInvites[normalizedName] = { name, time, inviteType, classToken, level }— set at invite-send time. Session-only, never persisted to SavedVars. Carries enough metadata for the outcome handlers to write the alreadySended row correctly (class colour + level on the Anti-Spam tab columns; inviteType so Type 4's msgQueue follow-up branch can find its entry).- Type 1 / 2 / 4 (anything that fires a real
C_GuildInfo.Invite) — no upfrontalreadySendedwrite. Sits inpendingInvitesuntil a positive outcome (promotePendingToAntiSpam) or a negative outcome (clearPending). - Type 3 (whisper-only, no invite) keeps the upfront
alreadySendedwrite because there's no positive outcome event to trigger promotion. Negative outcomes (not_found/ AFK / DND) still delete fromalreadySendeddirectly. - Outcome handlers:
fn:promotePendingToAntiSpam(name)— positive outcome (accept). WritesalreadySended, fires REMEMBER broadcast, clears pending. Called from the ClassicCHAT_MSG_SYSTEMaccept path AND the retailLibGuildRoster.OnMemberJoinedcallback.fn:clearPending(name, outcome)— negative outcome (NotFound / AFK / DND / Unresolved). Clears pending, logs to history, bumps the per-outcome session counter. Does NOT writealreadySendedand does NOT broadcast REMEMBER (the target is free to reappear in the next/whoscan).fn:sweepStalePending(thresholdSeconds)— lazy state-driven sweep for pending entries older than 1 hour. Called opportunistically from any path that readspendingInvitesfor another reason. NOT a timer (user spec: state-driven cleanup, not timer-driven).
/fgi clearwhispers — manual _outgoingWhispers reset
FGI_Core.lua slash-command handler. Wipes every key in addon._outgoingWhispers and prints how many were cleared. Use case documented in the command's comment block: the user wants to manually /w a recruit who already received an FGI whisper this session — their key sits in _outgoingWhispers until /reload (by design — see the _outgoingWhispers comment block), and that key being present means the user's typed reply is text-suppressed and the popup tab is swallowed. This is the mid-session escape valve.
FGI Debug chat tab — /fgi debugtab + /fgi debugtabremove
Modeled after TOGBankClassic's debug tab pattern. fn.createDebugTab / fn.removeDebugTab in functions.lua create / remove a dedicated FrameXML chat tab named "FGI Debug". fn.debug (the existing primary debug call) and a new fn.debugPrint (thin helper for raw direct-print sites) both route through _fn_getDebugFrame() — if the FGI Debug tab is registered, output goes there; otherwise falls through to print() (main chat).
Five direct-print sites moved through fn.debugPrint: scan-debug, popup-debug, libwho-debug, cd-debug, decline-debug (the [FGI decline-debug] family in Modules/Scan.lua). Each site keeps its native colour prefix; only the routing changes.
Tabs don't persist across /reload by design (Blizzard's chat-tab system is in-memory only for user-created tabs). Comment in the command handler tells the user to re-run /fgi debugtab after each reload.
muteAnnounce + muteScan toggles
GUI/SettingsPanel.lua — two new toggles:
DB.global.muteAnnouncein General → Notifications. Gates theaddon.announce:Send()success/skip prints inModules/Announce.lua.DB.global.muteScanin General → Scan. Gates the scan-result, scan-dispatch, and scan-cooldown prints inModules/Scan.lua,Modules/compactFrame.lua,FGI_Core.lua(F5/F6OnClick traces).
Independent of the existing addon.debug flag (which gates [FGI prefix] debug lines). The mute toggles silence main-chat noise even when debug is off.
Anti-Spam tab search box
GUI/Tabs/AntiSpam.lua — SearchBoxTemplate widget at the top of the row container. Case-insensitive substring match on names; filtered re-render on every OnTextChanged. No debounce — the underlying list is in-memory, render cost is trivial.
Files modified
- functions.lua —
addon._outgoingWhispersrewritten to presence-only;fn.hideWhisperstripped to presence check; newfn.hideAwayResponsefor CHAT_MSG_AFK/DND;fn.updateWhisperEchoFilterregisters all three filters;_markOutgoingDeferredREMOVED; outcome handlers stopped touching_outgoingWhispers; old retail GUILD_ROSTER_UPDATE accept-counter diff DELETED;fn:sweepStalePendingsimplified; newaddon.searchInfobuckets (notFound,afk,dnd,unresolved);fn.createDebugTab/fn.removeDebugTab/fn.debugPrint. - FGI_Core.lua —
LibGuildRoster.OnMemberJoinedcallback gains the retail accept-counter / onAccept / promotePendingToAntiSpam logic (retail-gated);/fgi clearwhispers,/fgi debugtab,/fgi debugtabremoveslash commands;F5/F6OnClick traces gated onmuteScanand routed throughfn.debugPrint. - FGI_Constants.lua —
FGI_INVITE_OUTCOMEextended withNotFound/AFK/DND/Unresolved/in_guild/guild_reject. - Modules/Scan.lua —
playerHaveInviteparsesERR_ALREADY_IN_GUILD_S→in_guildandERR_GUILD_TRIAL_ACCOUNT_TRIAL/_VETERAN/ERR_GUILD_NOT_ALLIED→guild_reject; new CHAT_MSG_AFK / CHAT_MSG_DND handler (awayMsgFilter) callsclearPending; PLAYER_LOGOUT handler drains stale pending as Unresolved; outcome switch handlers forin_guildandguild_rejectroute throughpromotePendingToAntiSpam; all four[FGI decline-debug]prints route throughfn.debugPrint. - Modules/WhisperAlert.lua — recruit-detection switched to
_outgoingWhispers[key]presence check (wasalreadySended/pendingInvitescheck);_recruitAlertFiredflag table REMOVED (presence in_outgoingWhispersis now the single source of truth for “alert owed” semantics). - Modules/compactFrame.lua — counter strip extended to 9 fields (F:S:A:X:D:?:K:B:U:);
[FGI cd-debug]scan-cooldown prints + scan-dispatch prints route throughfn.debugPrint. - Modules/Announce.lua —
muteAnnouncegate on success/skip prints. - Libs/LibWho/LibWho.lua + Libs/LibWho/LibWho_Retail.lua —
[FGI libwho-debug]prints route throughFGI.functions.debugPrintwith fallback toprint. - GUI/SettingsPanel.lua —
muteAnnouncetoggle in General → Notifications;muteScantoggle in General → Scan. - GUI/Tabs/AntiSpam.lua — search box at top of row container.
- GUI/Tabs/History.lua — outcome colours for new buckets.
- GUI/Tabs/Statistics.lua —
countInvitesByOutcome*helpers extended. - GUI/Tabs/Scan.lua — counter-strip + tooltip extended for 9 buckets.
[v2.3.2] (2026-05-28) — Retail empty-conversation-tab + chunk-2 whisper-echo leak fixed at the framework chokepoint: pre-bail at FloatingChatFrameManager's OnEvent so FCF_OpenTemporaryWindow is never called for FGI's own outgoing-whisper echoes
Background — the v2.1.x / v2.2.x sweep was downstream of the actual creation point
On retail with whisperMode = "popout" (or "popout_and_inline"), every CHAT_MSG_WHISPER_INFORM event arrives at two separate places in Blizzard's chat framework:
FloatingChatFrameManager's OnEvent script (Blizzard_ChatFrameBase/Mainline/FloatingChatFrame.lua:2484-2519). This runs first. If no dedicated chat frame exists for the target (FCFManager_GetNumDedicatedFrames(chatGroup, chatTarget) == 0), it callsFCF_OpenTemporaryWindow(chatGroup, chatTarget)synchronously to create the popout tab, then manually re-fires the event onto that new frame.- Each chat frame's own OnEvent — runs through
ChatFrameUtil.ProcessMessageEventFilters, which is where FGI'sfn.hideWhisperlives and discards the chat-line text.
The ordering is fatal for FGI's outgoing-echo suppression: the tab gets created before our filter ever runs. The text gets discarded by hideWhisper; the tab does not. That's the empty-conversation-tab bug, exactly the field-reported "FGI's whispers leave empty tabs behind."
The v2.1.x / v2.2.x family of fixes treated this reactively. fn.suppressConversationTabFor scheduled four C_Timer.After(0/0.1/0.5/...) sweeps of CHAT_FRAMES looking for frame.chatType == "WHISPER" and frame.chatTarget == ourTarget, then frame:Hide() (NOT FCF_Close — that tainted Blizzard's chat history on retail; see v2.2.0 notes). The sweep worked but ran AFTER tab creation, so even at 0s the user saw a ~1 render-frame flash before the tab was hidden. Worse, under rapid-fire invite cadence with long messages, ChatThrottleLib queues chunk 2 of a multi-chunk whisper for late dispatch; by the time chunk 2's echo arrives, our text-match list may have been pruned or the counter window expired, and the chunk leaks through the filter entirely — text into chat, tab created and not hidden.
Fix — replace FloatingChatFrameManager:GetScript("OnEvent") on retail with a pre-bailing wrapper
Per the XML at FloatingChatFrame.xml:700, FloatingChatFrameManager is documented as the "single point of entry for events which must fire once and only once." That's the chokepoint. v2.3.2 saves the original OnEvent script at addon-load time and replaces it with a wrapper that:
- Only intercepts
CHAT_MSG_WHISPER_INFORMandCHAT_MSG_BN_WHISPER_INFORM. Every otherCHAT_MSG_*event the manager registered for (channel chat, system messages, etc.) passes through to the original unchanged. - Only intercepts WHISPER_INFORMs for FGI-active targets. Derives the key via
fn:fullPlayerName(arg2), then gates onaddon._whisperExpectedEchoes[key] > 0(we're still expecting an echo from a chunk we sent) ANDGetTime() - addon._whisperSentRecently[key] < 30s(staleness cutoff — if the counter has drifted from a long-ago send, bail safely rather than permanently swallow this target's incoming tabs). - Both gates true → swallow the event entirely. The original OnEvent never runs.
FCFManager_GetNumDedicatedFramesis never consulted.FCF_OpenTemporaryWindowis never called. The tab literally never gets created.hideWhisperis wired into each chat frame's own event handling (not through the manager), so it still runs and still suppresses the chat-line text — the wrapper only prevents the tab-creation side effect. - All other paths pass through. Non-FGI whispers, the user's own manual
/wto someone FGI never touched, recruit replies (which fireCHAT_MSG_WHISPER, not_INFORM, but the manager handles those too), the case whereDB.realm.sendMSGis off — every one of these hits thereturn orig(self, event, ...)at the bottom of the wrapper, identical behavior to vanilla Blizzard.
mgr:SetScript("OnEvent", function(self, event, ...)
if event == "CHAT_MSG_WHISPER_INFORM" or event == "CHAT_MSG_BN_WHISPER_INFORM" then
if DB and DB.realm and DB.realm.sendMSG then
local target = select(2, ...)
if type(target) == "string" then
local ok, key = pcall(fn.fullPlayerName, fn, target)
if ok and key then
local expected = addon._whisperExpectedEchoes[key] or 0
local sentAt = addon._whisperSentRecently[key]
local recentSend = type(sentAt) == "number" and (GetTime() - sentAt) < 30.0
if expected > 0 and recentSend then
return -- swallow; tab never gets created
end
end
end
end
end
return orig(self, event, ...)
end)
Why this beats the v2.2.x sweep
- Zero render-frame flash. The tab never gets created, so there's nothing to hide. The previous sweep had a ~1 frame visible flash before
frame:Hide()could catch it; v2.3.2 has none. - Multi-chunk whispers covered automatically. The gate is
_whisperExpectedEchoes[key] > 0. Every chunk sent viafn:sendWhisperincrements this counter viafn.refreshWhisperHideDeadline; every chunk's echo arriving decrements viafn.hideWhisper. As long as we have unaccounted-for chunk echoes outstanding, the wrapper suppresses tab creation — regardless of how long ChatThrottleLib delayed chunk 2's actual send. The "chunk 2 leaks under rapid fire" symptom from v2.2.4 is structurally fixed here, not just papered over with a wider timing window. - No protected APIs called from inside the wrapper. The v2.2.0 attempt at the same architecture tainted Blizzard's chat history globals (MONSTER_SAY secret-string crashes during dungeon fights) because it called
FCF_Closefrom insidehooksecurefunc. v2.3.2 doesn't call ANY protected API in the wrapper — it justreturns early. No re-entry into secure code from insecure context, no taint cascade.
Defense-in-depth — the existing sweep is kept on retail
fn.suppressConversationTabFor (the 4-sweep) is not removed on retail in v2.3.2. The pre-bail is now the primary suppression mechanism, but the sweep stays as a fallback for the case where:
- Another addon replaces
FloatingChatFrameManager:SetScript("OnEvent")without preserving our wrapper (load-order race or hostile-overwrite scenarios) - A future Blizzard patch reshuffles the popout-creation flow such that the manager's OnEvent isn't the chokepoint anymore
In the steady-state case where the pre-bail does its job, the sweep is a no-op (the frame the sweep looks for was never created). It's belt-and-suspenders cost: a few C_Timer.After allocations per suppressed echo on a hot recruitment path. Negligible. Comment block on fn.suppressConversationTabFor updated to label it as defense-in-depth.
Classic-family unchanged
Classic Era / TBC / Wrath / Cata / Anniversary stay on the existing hooksecurefunc FCF_OpenTemporaryWindow + frame:Hide() path. That hook works correctly on those clients (the synchronous-with-creation timing catches the popout exactly when it's made; no bobbing as long as FCF_Close is avoided). The whisperMode popout behavior the empty-tab bug surfaces from is mostly a retail story; replacing classic's working path with the OnEvent intercept buys nothing and risks regression. installFloatingChatFrameManagerHook is retail-gated on gv.isRetail; the classic-family hook is gated on not gv.isRetail. The dispatcher in fn.installConversationTabSuppressionHook routes the install based on which client is loaded.
Files modified
- functions.lua — new module-local
installFloatingChatFrameManagerHook,_fcfMgrHookInstalledidempotency flag,fn.installConversationTabSuppressionHookrewritten as a retail-vs-classic dispatcher. Comment block onfn.suppressConversationTabForupdated to mark it as defense-in-depth on retail. Call site inFGI_Core.luais unchanged — the samefn.installConversationTabSuppressionHook()call routes to the appropriate platform path internally.
[v2.3.1] (2026-05-27) — Retail taint fixes consulted against Blizzard's API docs: SetRaidTarget restored alongside FGI unit-frame submenu, profession-tooltip MoneyFrame crash root-caused to tip:Show() re-entering the secure layout from an insecure stack
Background — v2.2.4 reasoned without docs and got two retail bugs wrong
Two retail-only taint bugs were "fixed" in v2.2.4 by reasoning from the stack trace + analogy instead of consulting Blizzard's own implementation guides. Both fixes worked in the narrow "the immediate symptom stopped" sense but were architecturally wrong:
- SetRaidTarget taint on retail party/raid frames. v2.2.4 diagnosed this as "any
Menu.ModifyMenumodification to a unit-frame menu inherently taints sibling element handlers" and excluded every unit-frame tag (MENU_UNIT_PARTY,MENU_UNIT_RAID_PLAYER,MENU_UNIT_TARGET,MENU_UNIT_FOCUS, etc.) from FGI's chat-menu integration on retail. SetRaidTarget started working again, but at the cost of removing the FGI submenu from right-clicking a portrait / party frame / raid frame on retail — a feature the addon explicitly advertised. - Profession-tooltip MoneyFrame ADDON_ACTION_BLOCKED. v2.2.4's FGI_UnitTooltip path had a
tip:Show()call inside theTooltipDataProcessor.AddTooltipPostCallcallback that registers againstEnum.TooltipDataType.Unit. After hovering a guildie, hovering a tradeskill recipe in the trade-skill window threwInterface\FrameXML\MoneyFrame.lua:155 attempt to perform arithmetic on a nil valuefollowed byADDON_ACTION_BLOCKED: AddOn 'FastGuildInvite' tried to call the protected function 'MoneyFrame_SetType()'. The narrow workaround that almost shipped was "don't hover guildies before opening trade-skills" or "register against fewer tooltip types" — both leaving the underlying re-entrancy alone.
Both bugs got the wrong root cause because the Blizzard API docs at F:\Blizzard API Docs were not consulted. The docs explicitly document the taint-safe pattern for both subsystems. v2.3.1's fixes are derived from those docs and a new Claude memory file (feedback_consult_blizzard_api_docs.md) now requires future taint / secure-API / framework bugs to grep F:\Blizzard API Docs first.
Fix 1: SetRaidTarget taint (Modules/FGI_ChatMenu.lua) — defer secret-value reads to OnClick
F:\Blizzard API Docs\Blizzard_Menu\11_0_0_MenuImplementationGuide.lua's *** Taint *** section (lines 416-419) states: "The menu system was designed with consideration to better support addon customization without taint consequences. Addons should always be able to insert elements at any position in a menu without imparting taint to any of the surrounding element handlers." The menu system is taint-safe for addon insertions by design — but only as long as the addon's generator callback doesn't itself touch a secret value, because doing so taints the generator's execution and that taint leaks to sibling handlers (like SetRaidTarget's protected click callback) that run after the menu is built.
FGI's v2.2.4 generator was calling nameFromContextData(contextData) inside Menu.ModifyMenu(tag, function(_, rootDescription, contextData) ... end). That function read contextData.unit and called UnitName() on it. On retail's unit-frame tags, contextData.unit is sometimes a Blizzard secret value (cross-realm tokens, instanced creature tokens, certain protected unit tokens). Reading the field + the UnitName() call at menu-build time tainted the generator's execution and that taint propagated to SetRaidTarget when the user later picked one of the raid-marker children.
v2.3.1 changes the menu-build flow so secret-value access is deferred to AFTER the menu has finished building:
MENU_TAGSrestored to the full 10-tag list (Modules/FGI_ChatMenu.lua).MENU_UNIT_PLAYER,MENU_UNIT_FRIEND,MENU_UNIT_PARTY,MENU_UNIT_RAID_PLAYER,MENU_UNIT_RAID,MENU_UNIT_GUILD_MEMBER,MENU_UNIT_ENEMY_PLAYER,MENU_UNIT_TARGET,MENU_UNIT_FOCUS,MENU_UNIT_CHAT_ROSTER— every tag is re-registered on every client. The retail-only "exclude unit-frame tags" split is removed.Menu.ModifyMenugenerator callback no longer touchescontextData. It captures the table by closure (buildFGIChildren(fgiSub, contextData)) and lets the click handlers resolve the name themselves. The generator's only job now islocal fgiSub = rootDescription:CreateButton("FGI"); buildFGIChildren(fgiSub, contextData)— both of which are documented-safe insertions.buildFGIChildren(parentDesc, contextData)— signature changed from(parentDesc, name). Each of the three children (FGI - Guild Invite, FGI - Blacklist, FGI - Unblacklist) callslocal name = nameFromContextData(contextData)inside its own OnClick callback, AFTER the menu's secure-construction phase has finished. The menu is fully built and committed at that point; any taint the read might introduce is contained to FGI's own click execution and doesn't propagate.nameFromContextDatabody wrapped inpcall— so a secret-value throw during the read just yields nil (silent click no-op) instead of the error tearing the click stack down. Defense-in-depth on top of the timing change.attachMenuTooltipcalls switched to generic text — the player name isn't known at menu-build time (because it's no longer resolved until click), so the tooltip bodies now say "this player" instead of interpolating the name. Same tooltip content, just generic phrasing.
Result: right-clicking a unit frame on retail now opens the standard Blizzard menu with the FGI submenu attached, and SetRaidTarget (set raid marker on the unit) works from the same menu. Both features coexist, which is the design Blizzard documents.
Fix 2: Profession-tooltip MoneyFrame crash (Modules/FGI_UnitTooltip.lua) — don't re-enter the secure layout from inside AddTooltipPostCall
F:\Blizzard API Docs\Blizzard_SharedXMLGame\Tooltip\TooltipDataHandler.lua:67 shows that Blizzard wraps every addon-registered AddTooltipPostCall invocation in securecallfunction:
securecallfunction(processor, self, tooltipData, ...);
This is the framework's explicit taint-containment boundary. The intent is documented elsewhere in the file: addon post-call handlers can add lines, change colors, etc. without their taint leaking to other tooltip events, because securecallfunction quarantines any taint introduced inside processor to that one call. The pipeline that Blizzard's own code uses (AddLine → queued relayout via TooltipDataMixin) stays inside the secure boundary.
FGI's v2.2.x post-call was calling tip:Show() at the end of enrich(tip). That call breaches the secure boundary: Show() on the shared GameTooltip doesn't go through the queued-relayout path; it triggers an immediate GameTooltip_OnShow / layout pass that runs in the insecure (addon-tainted) execution context outside the securecallfunction quarantine. The taint sticks to the shared GameTooltip frame and surfaces on the very next tooltip the player hovers — including ones FGI's post-call doesn't even fire on, because retail's GameTooltip is a shared frame across tooltip types. Hover a guildie (FGI post-call fires, tip:Show() taints the frame), then hover a tradeskill recipe (different tooltip type, FGI doesn't fire, but the frame is still tainted from the previous hover) — Blizzard's MoneyFrame_SetType reads the tainted frame state and errors out with the ADDON_ACTION_BLOCKED.
AddLine on the modern path already queues a relayout via Blizzard's pipeline, so the explicit tip:Show() is redundant on retail's TooltipDataProcessor flow. On legacy clients (Classic Era 1.15.x / TBC / Wrath / Cata), the older OnTooltipSetUnit hook script does need the explicit Show() because the legacy tooltip system doesn't auto-relayout after AddLine.
v2.3.1 gates the call:
if not gv.isRetail then
tip:Show()
end
Result: hovering a guildie on retail still appends the FGI member-history lines (AddLine's queued relayout handles the visual update). Subsequent hovers on tradeskill recipes, mail items, professions UI, etc. no longer throw MoneyFrame ADDON_ACTION_BLOCKED. Classic-family behaviour is unchanged (the explicit Show() still fires on those clients because their tooltip pipeline genuinely needs it).
New behavioral memory: consult F:\Blizzard API Docs before designing taint / secure-API fixes
memory/feedback_consult_blizzard_api_docs.md (indexed in memory/MEMORY.md) instructs Claude to grep the Blizzard API/source docs before committing to a fix for any bug whose stack ends in Blizzard framework code with FGI named in a taint message. The memory specifically cites the v2.2.4 SetRaidTarget and v2.2.4 scanInterval.min as a function mistakes — both were directly answered by the docs and would not have shipped as blunt-instrument workarounds if the docs had been consulted. Going forward, "the menu system / tooltip framework just doesn't isolate addons" intuitions get treated as hypotheses to verify against the docs first, not facts.
[v2.3.0] (2026-05-27) — Legacy scan window (single-page v1.9.10-style UI, DB.global.useLegacyUI toggle)
New file: GUI/LegacyMainWindow.lua
Standalone recreation of the v1.9.10 single-page scan UI. Exposes addon.LegacyMainWindow (local alias LMW) with Open(), Close(), Toggle(), refresh, and setScanCooldown entry points. Stored in interface.legacyMainFrame — never shares interface.mainFrame or DB.global.mainFrame.
Key implementation notes:
- Container:
GUI:Create("ClearFrame"), geometry persisted toDB.global.legacyMainFrame({point, relativePoint, xOfs, yOfs, height}) - Invite-type dropdown: raw
UIDropDownMenuTemplateframe"FGILegacyInviteTypeDrop"+UIDropDownMenu_Initialize/UIDropDownMenu_SetSelectedID/UIDropDownMenu_SetText— mirrorsModules/Scan.lua'smodeDDpattern - Enable-filters: standard AceGUI
CheckBoxwith:SetCallback("OnValueChanged", ...) - Progress bar: raw
CreateFrame+CreateTextureproxy table withSetProgress/SetWidth/SetPoint/ClearAllPoints— AceGUI'sProgressBaris a standaloneFULLSCREEN_DIALOGpopup and cannot be embedded - Level-range spinners: raw
CreateFrameviamakeLvlSpinner() - Candidate list: 20-row scrollable list (
refreshRows()/relayoutList()) with scroll bar; row count recalculated on vertical resize - Confirm-clear: lazy-created
UIDropDownMenuTemplateviaensureClearDropdown()
Wire-up in existing files
functions.lua:LegacyMainWindow.refresh/setScanCooldownfan-out added at four sites:mt.__call(searchInfo metamethod),onListUpdate(), and both the clear branch and theC_Timer.NewTickercallback insidestartScanCooldownGUI/MainWindow.lua:compactModeIcon:SetScript("OnClick")checksDB.global.useLegacyUI; if true, opens the legacy window and closes the main window instead of toggling the compact trayGUI/SettingsPanel.lua:useLegacyUIAceConfigtoggleadded to Appearance section (order=17), backed byDB.global.useLegacyUIFGI_Core.lua:elseif str == "legacy"branch inConsole:FGIInputdispatches toaddon.LegacyMainWindow.Open()- All 5 TOCs:
GUI\LegacyMainWindow.luaadded afterGUI\MainWindow.lua
[v2.2.5] (2026-05-27) — HOTFIX: v2.2.4 settings panel crash (scanInterval.min as a function), scan-interval minimum standardized to 8s across all versions
Critical: v2.2.4 made the entire FGI settings panel fail to open
v2.2.4 made the scanInterval range widget's min a function so it could return a per-version floor (8 on retail, 5 on classic). AceConfig's range widget requires min / max / step to be literal numbers — AceConfigRegistry:ValidateOptionsTable rejects a function value with expected a number, got 'function: ...', and that validation failure aborts the render of the ENTIRE options table. The result: opening FGI's settings (via the compact tray gear, the minimap right-click, or ESC → Options → AddOns → FastGuildInvite) threw a Lua error and the panel never displayed. Field-reported immediately after the v2.2.4 push.
(desc, get, set, disabled, hidden, confirm, name, order CAN be functions in AceConfig. min / max / step on a range cannot — they're read once at validation time as numbers.)
Fix + simplification: 8-second scan interval minimum across all game versions
Rather than reintroduce per-version complexity (which would've needed two hidden-gated widgets to give retail a real 8-minimum slider track vs. classic's 5), v2.2.5 standardizes the floor at 8 seconds on every client:
fn.getMinScanInterval()(functions.lua) now returns8unconditionally (wasgv.isRetail and 8 or 5). Kept as a function rather than inlined so a future per-version tweak stays a one-line change.- The
scanIntervalslider'smin(GUI/SettingsPanel.lua) is a literal8again — the panel-crash fix. The slider track bottoms out at 8 on every client;get/setstill clamp throughfn.setScanIntervalas belt-and-suspenders.descreverted to a plain (non-function) string. DB.global.scanIntervaldefault bumped from5to8(FGI_Core.lua) so a fresh install doesn't start with a value below the slider's own minimum.- Existing users with
5saved are auto-migrated to8on first login: thePLAYER_LOGINhook callsfn.setScanInterval(DB.global.scanInterval), which clamps to the 8 floor and writes the clamped value back.
The 8-second floor is empirical (see v2.2.4 notes — 5s on retail reliably triggered the server /who rate-limit cascade behind the stuck-scan / timer-flicker / whisper-leak / decline-disappear symptoms). Applying it to classic-family too is conservative but doesn't hurt classic recruiting in practice, and the single consistent number is simpler to reason about than a version branch.
Dev-process note (not addon behaviour)
CLAUDE.md updated: the wow-version-replication.ps1 sync watcher should be started once at session start and left running (not stopped after each one-shot sync). Stopping it after every sync was the old habit and it let _retail_ go stale mid-session — testing a fix against stale retail code looks identical to "the fix didn't work," which has burned debugging time. The rule now also says to check for an already-running FGI-specific watcher first (don't double up) and that a watcher running for a different addon doesn't count.
Older releases (v2.2.4 and earlier) have been moved to CHANGELOG_ARCHIVE.md.

