promotional bannermobile promotional banner

Fast Guild Invite - Revived

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

File Details

FastGuildInvite-v2.6.1

  • R
  • Jun 9, 2026
  • 5.57 MB
  • 775
  • 12.0.7+6
  • Retail + 2

File Name

FastGuildInvite-FastGuildInvite-v2.6.1.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.6.1] (2026-06-09) — retail secret-value taint fixes + LibGuildRoster externalized

Fix — retail CHAT_MSG_SYSTEM secret-value taint (Modules/Scan.lua)

FGI's decline / not-found detector (playerHaveInvite, off CHAT_MSG_SYSTEM) ran strfind/strsub on every system message. On retail (11.x / 12.0) a CHAT_MSG_SYSTEM payload is a secret value during "chat messaging lockdown" — doc-confirmed (ChatConstantsDocumentation) as exactly three states: an active instance encounter, an active Mythic+/Challenge Mode, or an active PvP match (chat-line text/sender carry the SecretInChatMessagingLockdown flag). String ops on a secret payload tainted FGI's execution (taintLog: "string conversion on a secret value ... blocked because of taint from fastguildinvite"), and that taint leaked into shared UI — the world-map AreaPoiUtil SetPadding flood and the Crafting-Orders MoneyFrame "arithmetic on a secret number value" errors. Fix: gate the handler on the state, not the value — C_ChatInfo.InChatMessagingLockdown() (a taint-free state query) early-returns during lockdown, so FGI never operates on a secret payload. Outside lockdown (all normal play) the message is an ordinary string and parses exactly as before; feature-detected, so the Classic family is untouched. (An earlier canaccessvalue gate was rejected — it over-bails from a tainted handler and would drop legitimate events.)

Fix — retail world-map / delve-POI tooltip taint (GUI/LegacyMainWindow.lua)

The legacy single-page window's buildFrame() ran on PLAYER_LOGIN unconditionally — for every user, including modern-UI users who never open it. Building it inits the invite-type legacy UIDropDownMenu, which writes a tainted value into Blizzard's shared UIDROPDOWNMENU_MENU_LEVEL global, breaking the retail world-map delve-POI tooltips (AreaPoiUtil SetPadding, "Tainted value written to global UIDROPDOWNMENU_MENU_LEVEL by fastguildinvite" via taintLog). Fix: buildFrame() is deferred — built only when the legacy UI is actually used (DB.global.useLegacyUI), lazily via LMW.Open for a mid-session switch, and idempotent. Modern-UI users never create the tainting dropdown.

Change — LibGuildRoster-1.0 is now an external dependency

The vendored Libs/LibGuildRoster-1.0/LibGuildRoster-1.0.lua was removed from the addon and unloaded from the five TOCs. FGI now consumes the library as a separate required dependency (## Dependencies: … GuildRoster; .pkgmeta required-dependencies: libguildroster), matching the Ace3 / VersionCheck-1.0 pattern. Its own retail secret-value taint (the library's OnChatMsgSystem path) is fixed in the standalone library, so externalizing keeps FGI on the maintained, fixed copy instead of a vendored fork. NOTE: the GuildRoster addon must be installed/enabled — it's a hard dependency.

Fix — Wingman recruits in combat (Modules/Wingman.lua)

Wingman's per-click drain no longer bails in combat. The whisper / invite / scan it fires are not inherently combat-protected — FGI's earlier in-combat block was a taint symptom (the secret-string path above), not a combat restriction, so with that taint fixed all three work mid-fight. Two corrections:

  • Removed a blanket InCombatLockdown() early-return that had been added to Drain — it killed /who scanning and invites in combat entirely (reported as "/who not firing in Wingman mode while in combat").
  • Removed the drained() pcall wrapper around fn:nextSearch / fn:invitePlayer. The pcall boundary severed the click's hardware-event privilege, raising ADDON_ACTION_BLOCKED on the protected invite/whisper calls. They are called directly now; the draining re-entry flag is self-healed by a reset at drain entry instead.

Improvement — Wingman scope readout (Modules/Wingman.lua)

The on-enable readout now announces what the session will ACTUALLY recruit on — active filters (by name), selected custom scans (by name), and the default level sweep (range via fn:getEffectiveLevelRange, honouring the strip-vs-filters priority) — instead of just echoing the level-strip wheel.

Fix — GUILDLINK guild-link caching (functions.lua)

msgMod's GUILDLINK expansion wrote its link cache under one key (link:match(...)) but read it under another (GetGuildInfo("player")), so it never cached and hit the club-finder API on every GUILDLINK whisper. Now keyed consistently by guild name. The club-finder read (a secret value on retail) is isolated in fn.fetchGuildLink via securecallfunction so it can't taint the whisper path.

Fix — anti-spam re-whispering on AFK/DND/not-found (Modules/Scan.lua)

Whisper-Only mode writes the anti-spam entry upfront at whisper time, then deleted it on any negative outcome — a CHAT_MSG_AFK / CHAT_MSG_DND auto-response, or a not_found — so the target could be "re-attempted." With no cooldown, the next /who re-found the still-online player and re-whispered them. The worst cases are common: an AFK/idle player (the auto-response is proof they received the whisper) and a connected/cross-realm player whose realm-suffixed name fires a false ERR_CHAT_PLAYER_NOT_FOUND even though the whisper was delivered (formatNameForRetailAPI strips only your own realm). Field-reported: a single player whispered 3× while sitting in the anti-spam list, because the entry flickered (written on send, deleted on the response). Both Type-3 deletes are removed: once whispered, the anti-spam entry holds for the configured expiry window regardless of outcome. A genuinely-offline not_found is self-limiting — the player won't reappear in /who while offline.

[v2.6.0] (2026-06-05) — Wingman: input-driven hands-free recruiting

New — Wingman (Modules/Wingman.lua)

Opt-in session where each of the player's mouse clicks drains ONE queued recruitment action — a /who scan when due, otherwise an invite to the next filtered candidate. 1:1 with hardware input, throttled, and dead while the player is AFK: ToS-clean on the same basis as click-casting (one human hardware event per protected call), NOT automation/burst. FGI's own, independently-built implementation; no third-party code used. Toggled via the new toggle buttons or /fgi wingman.

  • Mechanism: a session-scoped, full-screen overlay on UIParent with SetPropagateMouseClicks(true) + SetPropagateMouseMotion(true). Its OnMouseDown fires inside the click's hardware-event context (unlocking the gated retail C_FriendList.SendWho / C_GuildInfo.Invite) AND the click still passes through to the game underneath — targeting, action bars, and UI all keep working. Verified in-game on retail and Classic.
  • Reuses the existing engine. The drain calls the same fn:nextSearch() / fn:invitePlayer() the manual buttons use, inheriting every filter, the configured invite Mode, anti-spam, and testingMode. Scans gate on the same interface.compactFrame.scanCooldown and (re)start via fn.startScanCooldown, so Wingman is paced exactly like manual scanning and can't trip retail's /who rate limit (the cause of an early-development scan "freeze", now gone).
  • Pacing: invite throttle DB.global.wingman.minInterval (default 1.5s); scans on the user's scanInterval; optional auto-stop DB.global.wingman.autoStopMinutes (default 0 = never).
  • Never persisted — OFF at every login//reload; prints a scope readout (level range · custom-scan count · testing-mode) on enable.
  • Multi-version safety: Start() feature-detects SetPropagateMouseClicks and refuses to run on any client lacking click pass-through, so a consuming overlay can never eat input.
  • Overlay-bounce hardening: overlay shown on the next frame (not inside the toggling click) + a 0.4s Toggle debounce, so the propagating overlay can't bounce the toggling click back through and flip the session on-then-off.

Wingman UI — toggle buttons (GUI/MainWindow.lua, Modules/compactFrame.lua)

  • Grey-when-off / lit-when-on toggle on BOTH the main-window bottom row (between announce and copy; status bar trimmed -249 → -272 to fit) and the compact tray (announce/scan/invite/counters shifted left 22 px, MIN_WIDTH 380 → 402). One shared active state via Wingman:UpdateToggles() (stored refs).
  • Main-window button registered in the existing OnClose raw-child release list (alongside _copyAllIcon, which had the same latent leak) so the recycled AceGUI frame never stacks a stale duplicate on rebuild — fixing an earlier toggle-double-fire across compact↔main view switches.

Wingman lock-in — manual controls frozen while active

  • Functional gate: fn:invitePlayer / fn:nextSearch no-op for manual callers while a session runs (IsActive() and not IsDraining()), blocking strip buttons, per-row icons, keybinds, and /fgi invite|nextSearch uniformly; the drain itself bypasses via its flag.
  • Visual lock: Scan-tab buttons grey + disable (with a "Locked — Wingman is on" tooltip, via SetMotionScriptsWhileDisabled(true)); the Filters and Custom Scan tabs get a click-blocking dimmed lock overlay. The live displays (counters, queue, scan countdown) keep updating throughout.

New — Disable all sync (DB.global.disableSync — Settings → Advanced → Sync)

Master opt-out for FGI's cross-guild data sharing, off by default. A new fn.syncDisabled() gates every sync path when ticked: the three per-action REMEMBER broadcasts, the entire FGISYNC stream protocol (single chokepoint in SendSyncAddonMessage), fn.startSync (login / manual / queue-flush), and the CHAT_MSG_ADDON receive handler — so the user neither broadcasts nor merges any guild-sync traffic and keeps a purely local, per-character data set. Scoped to FGI's own sync only: the version-check (separate VersionCheck-1.0 lib) and the GRM/GIL auto-imports are untouched. The "Sync now" button greys out while disabled.

Fixes

  • Reset-settings button no longer errors. Its confirm was a raw string, which AceConfig resolves as a handler METHOD name ("Method ... doesn't exist in handler for type confirm"). Wrapped as function() return "..." end so the string is used as the confirmation message.
  • Sync chat-output toggles relabeled to match behaviour. addonMSG (a mute, used everywhere as if not DB.global.addonMSG) was mislabeled "Print sync chat output" with a straight pass-through, so ticking it silenced. Renamed "Mute addon chat messages" and rescoped (it also gates the blacklist confirmation and the "50 or more results" notice, not just sync). muteSync's false "stronger than the toggle above" claim dropped — it's the sync-only mute.
  • Wingman drain hardened. The draining flag (which the manual-control gate keys on via IsActive() and not IsDraining()) now resets through a pcall helper that re-raises via geterrorhandler(), so an error inside a drained nextSearch/invitePlayer can't leave the manual lock-in silently re-opened — and the error still reaches BugSack instead of being swallowed.

Docs / misc

  • Scan-tab help ("i") gains a Wingman entry; /fgi wingman slash + DB.global defaults registered in all 5 TOCs; README + Curseforge description updated.

[v2.5.8] (2026-06-02) — in-instance Group Invite panel + retail unit-menu instance-gate (open-world right-click recruiting restored) + Crafting-Orders tooltip taint fix

New — Group Invite panel (Modules/FGI_GroupInvite.lua)

  • Standalone, FGI-owned popup listing the current party/raid filtered to invitable players (class-coloured); one click per row sends a guild invite. Opened with CTRL + left-click on the minimap button or the addon compartment (handleLauncherClick in FGI_Core.lua; the launcher now sets minimap.showInCompartment = true).
  • Taint-safe by construction: it only ever opens a frame FGI owns and the invite fires from the row-click hardware-event context, so it never touches a secure frame or Blizzard's unit menu. That is what makes recruiting work inside instances (M+, raids), where the native unit-frame marker menu can't host FGI entries on 12.0.x.
  • Reuses the existing RowList widget and the dialog styling from UI.ShowCopyAllPopup; built lazily on first open so load order vs GUI/RowList.lua doesn't matter. Live-refreshes on GROUP_ROSTER_UPDATE and re-collects ~1s after open so cross-realm members whose guild data streams in late drop out once resolved.

Shared invitability predicate — fn:isInvitable(name, realm, unit)

  • One predicate now backs BOTH the /who scanner (fn:addNewPlayer, unit=nil) and the Group Invite panel (unit set). Unit-gated checks (self / NPC / opposite faction / already-guilded) run only when a unit token is supplied; the scanner path is unchanged (bare name, same blacklist / leave / anti-spam / pending lookups).
  • Cross-realm correctness: when a realm is supplied it qualifies the lookup key to canonical Name-Realm before the name-keyed list checks. Without this a connected-realm groupmate normalizes to the LOCAL realm and would slip past the blacklist / anti-spam lists. The scanner passes realm=nil, so its lookups stay bare-name as before.

Retail unit-menu taint — instance-gate replaces the v2.5.6 tag removal (Modules/FGI_ChatMenu.lua)

  • v2.5.6 dropped FGI's submenu from every retail unit-frame menu because inserting near the raid-marker submenu tainted SetRaidTarget. Field testing on 2026-06-02 refined the root cause: on 12.0.x SetRaidTarget is protected and GetRaidTargetIndex is a secret value, but only inside instances — so the marker submenu's IsChecked secret-compare throws (LUA_WARNING + BugGrabber nag) only when IsInInstance() is true. Open-world inserts are clean (verified: open world = no warnings + marker works; follower dungeon = warnings).
  • So the marker-hosting tags (PLAYER / PARTY / RAID_PLAYER / RAID / ENEMY_PLAYER / TARGET / FOCUS) are registered on every client again, and the retail generator skips the insert only while IsInInstance() is true (the MARKER_HOSTING[tag] gate). The v2.3.1 deferred-read fix is kept. Net: right-click FGI invite/blacklist is back on open-world unit frames on retail; inside instances FGI stays out of those menus (use the Group Invite panel there). Chat / friend / guild-member tags were never marker-hosting, so they're unaffected everywhere.

Retail Crafting-Orders MoneyFrame taint fix (Modules/FGI_UnitTooltip.lua)

  • The native unit-tooltip member-history enrichment added lines to the shared GameTooltip from retail's insecure AddTooltipPostCall. securecallfunction restores our own execution's taint flag, but writes to the shared tooltip's pooled regions (incl. GameTooltipMoneyFrame1) stay tainted — which then throws on the next tooltip carrying a server "secret" money value: hovering a Crafting Orders recipe ran MoneyFrame_Update's width arithmetic on the secret commission and threw "arithmetic on a secret number value (execution tainted by 'fastguildinvite')", repeatedly. Removing tip:Show() in v2.3.1 killed one leak path, but plain AddLine on the shared tooltip taints the money region too, and there is no reliable taint-free way to write into the native tooltip from insecure code under the secret-value system. So retail no longer registers the unit-tooltip enrichment at all. Classic / TBC / Wrath / Cata have no secret values and keep full enrichment. On retail the same member info is still available via the chat-name hover tooltip and the Guild Roster tab.

[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 to 110207, 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 produced docs//*, which matches nothing — so the entire docs/ folder was being shipped in the release zip. Dropped the trailing slashes so the directory globs resolve correctly.
  • Collapsed redundant **/*.ps1 / **/*.bat into *.ps1 / *.bat (the packager's case glob already crosses /), and added CHANGELOG_ARCHIVE.md to the ignore list so the history file doesn't bloat the player zip.

Changelog archive split (release-body size limit)

  • CHANGELOG.md had 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 live CHANGELOG.md now keeps v2.5.7 → v2.2.5 (~100 KB) and everything from v2.2.4 down to the oldest entry moved verbatim to the new CHANGELOG_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 SetRaidTargetIconUnitPopupSharedButtonMixins.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.ModifyMenu against a menu that hosts SetRaidTarget taints 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):
    1. Tag exclusion at registrationMENU_TAGS is split into MENU_TAGS_SAFE (chat roster / friend / guild member — none host a protected submenu) and MENU_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 legacy UnitPopupButtons path doesn't cross-taint siblings). The in-generator gateAllowed() check was proven insufficient, so the gate is moved to which tags we register against at all.
    2. Combat gate in gateAllowed() — idea borrowed from GRIP's UnitPopupInvite: on retail, insert nothing into ANY menu while InCombatLockdown(). 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.
  • 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/FOCUS entirely and combat-gates its insertion, but still registers against PARTY/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 plain 90-90 query "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. results is every player the server returned (guilded + guildless); the guild/blacklist/anti-spam filtering happens later, one player at a time, in addNewPlayer ([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 subdivideHeader tooltip 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 >= 50 gate 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 FloatingChatFrameManager OnEvent 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]); whisperMode is a NON-SECURE CVar ([CVars.lua:1591] → secure=false), so SetCVar on it is legal in/out of combat and carries no Lua taint (engine-side). FGI flips whisperMode to inline around 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_INFORM echo 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) restored whisperMode to 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 chokepoint fn.refreshWhisperHideDeadline) and decrements as fn.hideWhisper suppresses each matching echo (fn.notifyEchoSuppressed). whisperMode is 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 same WHISPER_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), a PLAYER_LOGOUT handler (fires on logout and /reload, when the engine persists CVars), and an immediate restore when sendMSG is toggled off mid-window (fn.updateWhisperEchoFilter).
  • Retail-only: classic-family keeps its existing taint-free FCF_OpenTemporaryWindow hooksecurefunc path, which never needed this.

GUI/SettingsPanel.lua / .luarc.json

  • sendMSG tooltip 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 / GetCVar to 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') at HistoryKeeper.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 via mgr: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 to orig(self, event, ...) — Blizzard's manager router — running under FGI's execution taint. In "popout" whisper mode orig calls FCF_OpenTemporaryWindow and re-fires the event into the new frame ([FloatingChatFrame.lua:2501-2502]), whose MessageEventHandler calls ChatHistory_GetAccessID, whose nextAccessID = nextAccessID + 1 ([HistoryKeeper.lua:14]) is a module-local upvalue. Incrementing it under taint poisons it permanently, so every later ChatHistory_GetToken strlower() 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 FloatingChatFrameManager OnEvent 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 for FCF_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_OpenTemporaryWindow hooksecurefunc) is unchanged.

Guild Roster: brand-coloured, sortable, striped breakdowns

  • Section headers now route through the shared addon.UI.MakeLabel helper: brand colour + GameFontNormalSmall (matching the column headers on the other tabs) instead of the old oversized GameFontNormalLarge raw 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 a MoreArrow sort-direction indicator on the active column (same affordance as the RowList tables). Name sorts by nameSort (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. esMX mirrors esES (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's GetL() rebuild — it iterated ruRU's keys (so any key not in ruRU was dropped at runtime, e.g. the byteCount* 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 full settings.size table, 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-form L[ [=[…]=] ] 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 English or "…" 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 L key in code or as a locale left-hand-side key.
  • Version-agnostic: the locale tables and the summary.lua rebuild 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 to parent:GetFrameLevel() + offset (default 5), clearing both sizers. Defined on the addon table (callable anywhere as FGI.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 count and pct FontStrings 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 pct to the scroll child's TOPRIGHT and count to the left of pct, both right-justified, with the label filling 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:

  1. Gate checks. Refuse with a clear chat message if any of:
    • No active DB (addon.DB.global nil)
    • Recruitment scan in progress (#addon.search.whoQueryList > 0)
    • A /who is currently in-flight (libWho.isAddon)
    • Player not in a guild (GetGuildInfo("player") returns nil)
  2. Swap the libWho callback. Save libWho.callback to a module-local, then libWho:SetCallback(onBackfillResults). The gates above guarantee no scan-engine query is in flight at this moment, so swapping is safe.
  3. Send the query. libWho:GetWho('g-"' .. guildName .. '"'). The quotes around the guild name handle multi-word guild names; without them, g-Achievement Lounge would be parsed as g-Achievement + Lounge (a separate name filter).
  4. One-shot response handler. Walks the WhoInfo list, normalizes each name via fn:normalizePlayerName, looks up the key in DB.factionrealm.memberHistory and DB.realm.alreadySended. For each store: if entry exists and .race is nil-or-empty, stamp it with info.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.
  5. Restore the scan-engine callback. libWho:SetCallback(savedCallback) so subsequent recruitment-scan /who responses route back through searchWhoResultCallback. Failure to restore would silently break the next recruit scan.
  6. 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 /who truncates 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/who doesn'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/who is 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].race and alreadySended[k].race slots that v2.5.1 already defined.
  • No sync wire-format change — race remains 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 /who filter on every supported client; WhoInfo.raceStr ships 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:

  1. fn:rememberPlayer signature gains a 4th race arg. Same preserve-on-nil pattern as class / level — sync REMEMBER receives + accept-handler fallbacks call with nil and preserve any existing value rather than wiping. The alreadySended[k] table shape adds a race field; wire format stays bare timestamp so older peers continue to decode (race is local-only enrichment, mirrors how class and level were 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 (or e.race):

    • functions.lua fn:invitePlayerlocal race = entry.race captured next to classToken and level, then threaded through to all four pendingInvites entry shapes (Types 1 / 2 / 3 / 4) and both rememberPlayer calls (Type 3 immediate + decline-noInv branch). 6 sites in one function.
    • functions.lua fn:promotePendingToAntiSpam — picks entry.race off 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.playerName only and rightly passes nil for class / level / race; the preserve-on-nil pass in rememberPlayer keeps whatever local enrichment is already on the entry.
  2. MemberHistory:onJoin copies race forward from alreadySended. When a new guildie is detected by LibGuildRoster, FGI looks them up in DB.realm.alreadySended (they were just /who'd seconds-to-minutes ago for the invite, so the entry is fresh) and stamps race onto the new memberHistory[key] entry alongside joinedAt / 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,
    }
    
  3. FGI_ChatTooltip resolves race and inlines it. New private helper lookupRace(key, shortKey) in Modules/FGI_ChatTooltip.lua checks memberHistory first (longest-lived store, survives anti-spam expiry), then falls back to alreadySended, 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 "")
    end
    

    fn / key / shortKey hoisted from the if DB then block to the top of populate() 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.lua awayMsgFilter (NEW in v2.4.0 — the regression source) — was using pcall(function() return sender == "" end) to "defend" against secret-string senders. pcall catches the throw but doesn't strip the taint the addon acquired by touching the value. Replaced with if gv.isRetail and canaccessvalue and not canaccessvalue(sender) then return end BEFORE the equality / normalizePlayerName calls.
  • Modules/WhisperAlert.lua CHAT_MSG_WHISPER handler — same pattern fix on the sender arg before fn.fullPlayerName is called.
  • functions.lua FCFMgr OnEvent hook (installFloatingChatFrameManagerHook) — target arg gated through canaccessvalue before the pcall(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 from OnInitialize via fn.migrateAllMessageStores. Sync receivers tolerant: fn:getRndMsg accepts both the new table form AND the legacy string form (treating strings as enabled) so peers on pre-v2.5.0 builds broadcasting a string messageList still 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 (uses fn: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 in buildTabContent, TAB_MODULE entry for the resize-floor lookup, help-tooltip entry for the "i" icon.
  • TOC updatesGUI\Tabs\Messages.lua added to all 5 TOC files (FastGuildInvite.toc, _BCC, _Wrath, _Cata, _Mainline) after Filters.lua.
  • Body editor click-to-focus fixscrollFrame:GetWidth() returned 0 at Render time before the parent strip had been laid out, feeding bodyInput:SetWidth(0 - 8) = -8 and collapsing the EditBox click-target. Fixed by clamping to math.max(200, scrollFrame:GetWidth() - 8) for the initial width, gating the OnSizeChanged resnap on w > 8, and adding scrollFrame: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 the Name label above the input, matching Filters' placeLabelAbove pattern. STRIP_H 162 → 178 / MIN_HEIGHT 460 → 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 from for _, v in pairs(...) to for 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 to player.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 carryfn:addNewPlayer writes matchedFilters = p.matchedFilters onto 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:getRndMsg random 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 from fn:getRndMsg() to fn.getMsgForName(name) or fn:getRndMsg(). Defensive or covers file-load order edge cases.
  • Filters tab UI (GUI/Tabs/Filters.lua) — new msgDD UIDropDownMenuTemplate 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>)|r and are disabled. loadIntoForm / doSave thread form.boundMessagefilter.boundMessage. New MSG_DD_W = 140 constant.

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 to ROW1_Y = -5).
  • Row 2 (ROW2_Y = -33): [Sel All] [+(N)sel] [Skip (N)] [Decline (N)] [Blacklist (N)]Sel All moved from end-of-row-1 chain to TOPLEFT at Row 2; +(N)sel followed; three new batch buttons appended.
  • Skip (N) — pure table.remove over selected entries (iterated tail-forward for stable indices), honours DB.global.rememberSkipped. No protected API. Single refresh at end with preserveScroll = true.
  • Decline (N) — iterates selected, fn:invitePlayer(true, i) per entry — the noInv = true branch records the decline (history + searchInfo + promote-to-anti-spam) without calling C_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 selCount refresh in ScanTab.Refresh now pushes the same count onto Skip / Decline / Blacklist labels so all four batch buttons stay in lockstep.
  • lvl container / counterTip resized from STRIP_H to BTN_H so their hover zones don't poach Row 2 clicks; modeDD rechained from invSelBtn (now Row 2) to clearBtn (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.OnMemberJoined computes iInvited from DB.realm.alreadySended[normalizedName]. On retail, the accept-counter block above promotes pendingInvites → alreadySended before this gate runs; on classic, the CHAT_MSG_SYSTEM accept path does the same ahead of LibGuildRoster firing. Each welcome path gates on not 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: alreadySended is "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.byRank was keyed by rankName, so both rank-0 and rank-N members landed in the same bucket (61). result.rankOrder was keyed by rankIndex (correctly distinguished the two ranks), but the renderer looked up the count via byRank[name] → got the merged total → showed it twice.
  • Fix (GUI/Tabs/GuildRoster.lua): byRank now keyed by rankIndex; rankOrder carries { idx, name } records so the renderer reads byRank[rec.idx] for the count and rec.name for 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.

  • gmPolicyAntiSpam dropdown (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.get extended: when antiSpamMin == 1, force v = 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.
  • gmPolicyMessage field's bespoke r ~= 0 gate (GM only, excluded officers) also rewritten to use the shared canEditGuildPolicy so 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, clamps listOffset to 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 to SetData. 8 call sites updated to pass true: checkbox toggle, per-row Blacklist / Decline / Skip / Invite, strip +(N) invite-next, strip Sel 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 copyAllIcon on the main window's bottom-right strip (GUI/MainWindow.lua). 20×20, Interface\Buttons\UI-GuildButton-PublicNote-Up texture (matches the button-style icons on the row better than the icon-style horn). Anchored BOTTOMLEFT to announceIcon.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.
  • statusbg right edge shrunk from -226 to -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_DIALOG with 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 .frame field, reads DB.global.windowLayer, and applies. Idempotent.
  • UI.RefreshAllWindowLayers() — walks every currently-open FGI window (MainWindow / compactFrame / legacyMainFrame) and re-applies. Called from the Settings slider's set so live drags update without /reload.
  • Injection sites: GUI/MainWindow.lua:414 right after self.frame = f (before child widgets anchor); Modules/compactFrame.lua via an OnShow hook (file-load-time addon.UI isn't loaded yet — Modules loads before GUI); GUI/LegacyMainWindow.lua right after GUI: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's range widget 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's sf.ScrollBar + ScrollUpButton + ScrollDownButton stay 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 compares sc:GetHeight() (content) against sf:GetHeight() (visible) inside setData and hides the whole scrollbar group when content fits. Re-evaluated on resize via a container:HookScript("OnSizeChanged", ...) that remembers the last aggregation snapshot — the new setDataAndRemember wrapper captures ag so the resize callback has fresh data to re-compute against.
  • "Total Ns: <count>" in the summary where N is the current expansion's level cap from GetMaxPlayerLevel(). Distinct from the By Level-section's 5-level bucket (e.g. "86-90") because the user specifically wants exact-cap members (e.g. 80s, not 76-80s). New result.maxLevel / result.maxLevelCount fields filled during the aggregation walk; segment skipped when maxLevel == 0 (very early load before GetMaxPlayerLevel returns) 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.filteredCount still 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 workmatchedFilters now 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 the On column 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) — returns 8 on Retail, 2 on 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 /who responses and the client silently suppresses WHO_LIST_UPDATE. Classic-family /who rate-limiting is looser.
  • Slider min evaluated at parse time (GUI/SettingsPanel.lua) — min = addon.functions.getMinScanInterval() or 8 runs when the options table literal is constructed, so AceConfigRegistry sees a static number (2 or 8 depending on client), not a function reference. This is the structural fix v2.2.4 was missing. By the time SettingsPanel.lua parses, functions.lua has already defined the function and FGI_Compatibility.lua has already set addon.gameVersion.isRetail, so both are safe to call at module-load time.
  • DB default stays at 8 globally (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 writes true on 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 via fn:sendWhisper or auto-welcome via FGI_Core.lua's LibGuildRoster.OnMemberJoined).
  • Cleared exclusively by: (1) incoming CHAT_MSG_WHISPER from this name — Modules/WhisperAlert.lua removes the key and fires the recruit-reply alert. (2) /fgi clearwhispers manual command. (3) PLAYER_LOGOUT sweep. (4) /reload (in-memory table wipes).
  • Outcome handlers do NOT touch the table. fn:promotePendingToAntiSpam, fn:clearPending, fn:sweepStalePending all stopped removing or flagging _outgoingWhispers[key] in v2.4.0 rev5. The previous design (rev3-rev4: set deferred = true on outcome, let the drain frame clear when remaining hit 0) raced the per-chat-frame text filter dispatch: drain frame ran BEFORE the filter chain for the last chunk's CHAT_MSG_WHISPER_INFORM, cleared the key, and hideWhisper found 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 fires OnMemberJoined during the initial roster build, so the streaming-roster case cannot produce false “new” firings.
  • The callback is gated on addon.gameVersion.isRetail to avoid double-counting on classic (which still uses the CHAT_MSG_SYSTEM accept path — ERR_GUILD_INVITE_S is 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.luaFGI_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_guildERR_ALREADY_IN_GUILD_S system message ("%s is already in a guild.") parsed in Modules/Scan.lua's playerHaveInvite. The recruit accepted a guild invite somewhere else between FGI's /who and FGI's invite firing; treat as anti-spam (won't be re-invited this session) but skip the Accepted count + REMEMBER broadcast.
  • guild_rejectERR_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 recent pendingInvites entry via next() and treats it as the target. Same anti-spam promotion as in_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 upfront alreadySended write. Sits in pendingInvites until a positive outcome (promotePendingToAntiSpam) or a negative outcome (clearPending).
  • Type 3 (whisper-only, no invite) keeps the upfront alreadySended write because there's no positive outcome event to trigger promotion. Negative outcomes (not_found / AFK / DND) still delete from alreadySended directly.
  • Outcome handlers:
    • fn:promotePendingToAntiSpam(name) — positive outcome (accept). Writes alreadySended, fires REMEMBER broadcast, clears pending. Called from the Classic CHAT_MSG_SYSTEM accept path AND the retail LibGuildRoster.OnMemberJoined callback.
    • fn:clearPending(name, outcome) — negative outcome (NotFound / AFK / DND / Unresolved). Clears pending, logs to history, bumps the per-outcome session counter. Does NOT write alreadySended and does NOT broadcast REMEMBER (the target is free to reappear in the next /who scan).
    • fn:sweepStalePending(thresholdSeconds) — lazy state-driven sweep for pending entries older than 1 hour. Called opportunistically from any path that reads pendingInvites for 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:

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.luaSearchBoxTemplate 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.luaaddon._outgoingWhispers rewritten to presence-only; fn.hideWhisper stripped to presence check; new fn.hideAwayResponse for CHAT_MSG_AFK/DND; fn.updateWhisperEchoFilter registers all three filters; _markOutgoingDeferred REMOVED; outcome handlers stopped touching _outgoingWhispers; old retail GUILD_ROSTER_UPDATE accept-counter diff DELETED; fn:sweepStalePending simplified; new addon.searchInfo buckets (notFound, afk, dnd, unresolved); fn.createDebugTab / fn.removeDebugTab / fn.debugPrint.
  • FGI_Core.luaLibGuildRoster.OnMemberJoined callback gains the retail accept-counter / onAccept / promotePendingToAntiSpam logic (retail-gated); /fgi clearwhispers, /fgi debugtab, /fgi debugtabremove slash commands; F5/F6 OnClick traces gated on muteScan and routed through fn.debugPrint.
  • FGI_Constants.luaFGI_INVITE_OUTCOME extended with NotFound / AFK / DND / Unresolved / in_guild / guild_reject.
  • Modules/Scan.luaplayerHaveInvite parses ERR_ALREADY_IN_GUILD_Sin_guild and ERR_GUILD_TRIAL_ACCOUNT_TRIAL / _VETERAN / ERR_GUILD_NOT_ALLIEDguild_reject; new CHAT_MSG_AFK / CHAT_MSG_DND handler (awayMsgFilter) calls clearPending; PLAYER_LOGOUT handler drains stale pending as Unresolved; outcome switch handlers for in_guild and guild_reject route through promotePendingToAntiSpam; all four [FGI decline-debug] prints route through fn.debugPrint.
  • Modules/WhisperAlert.lua — recruit-detection switched to _outgoingWhispers[key] presence check (was alreadySended/pendingInvites check); _recruitAlertFired flag table REMOVED (presence in _outgoingWhispers is 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 through fn.debugPrint.
  • Modules/Announce.luamuteAnnounce gate on success/skip prints.
  • Libs/LibWho/LibWho.lua + Libs/LibWho/LibWho_Retail.lua[FGI libwho-debug] prints route through FGI.functions.debugPrint with fallback to print.
  • GUI/SettingsPanel.luamuteAnnounce toggle in General → Notifications; muteScan toggle 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.luacountInvitesByOutcome* 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:

  1. 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 calls FCF_OpenTemporaryWindow(chatGroup, chatTarget) synchronously to create the popout tab, then manually re-fires the event onto that new frame.
  2. Each chat frame's own OnEvent — runs through ChatFrameUtil.ProcessMessageEventFilters, which is where FGI's fn.hideWhisper lives 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_INFORM and CHAT_MSG_BN_WHISPER_INFORM. Every other CHAT_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 on addon._whisperExpectedEchoes[key] > 0 (we're still expecting an echo from a chunk we sent) AND GetTime() - 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_GetNumDedicatedFrames is never consulted. FCF_OpenTemporaryWindow is never called. The tab literally never gets created. hideWhisper is 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 /w to someone FGI never touched, recruit replies (which fire CHAT_MSG_WHISPER, not _INFORM, but the manager handles those too), the case where DB.realm.sendMSG is off — every one of these hits the return 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 via fn:sendWhisper increments this counter via fn.refreshWhisperHideDeadline; every chunk's echo arriving decrements via fn.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_Close from inside hooksecurefunc. v2.3.2 doesn't call ANY protected API in the wrapper — it just returns 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, _fcfMgrHookInstalled idempotency flag, fn.installConversationTabSuppressionHook rewritten as a retail-vs-classic dispatcher. Comment block on fn.suppressConversationTabFor updated to mark it as defense-in-depth on retail. Call site in FGI_Core.lua is unchanged — the same fn.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:

  1. SetRaidTarget taint on retail party/raid frames. v2.2.4 diagnosed this as "any Menu.ModifyMenu modification 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.
  2. Profession-tooltip MoneyFrame ADDON_ACTION_BLOCKED. v2.2.4's FGI_UnitTooltip path had a tip:Show() call inside the TooltipDataProcessor.AddTooltipPostCall callback that registers against Enum.TooltipDataType.Unit. After hovering a guildie, hovering a tradeskill recipe in the trade-skill window threw Interface\FrameXML\MoneyFrame.lua:155 attempt to perform arithmetic on a nil value followed by ADDON_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_TAGS restored 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.ModifyMenu generator callback no longer touches contextData. It captures the table by closure (buildFGIChildren(fgiSub, contextData)) and lets the click handlers resolve the name themselves. The generator's only job now is local 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) calls local 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.
  • nameFromContextData body wrapped in pcall — 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.
  • attachMenuTooltip calls 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 to DB.global.legacyMainFrame ({point, relativePoint, xOfs, yOfs, height})
  • Invite-type dropdown: raw UIDropDownMenuTemplate frame "FGILegacyInviteTypeDrop" + UIDropDownMenu_Initialize / UIDropDownMenu_SetSelectedID / UIDropDownMenu_SetText — mirrors Modules/Scan.lua's modeDD pattern
  • Enable-filters: standard AceGUI CheckBox with :SetCallback("OnValueChanged", ...)
  • Progress bar: raw CreateFrame + CreateTexture proxy table with SetProgress / SetWidth / SetPoint / ClearAllPoints — AceGUI's ProgressBar is a standalone FULLSCREEN_DIALOG popup and cannot be embedded
  • Level-range spinners: raw CreateFrame via makeLvlSpinner()
  • Candidate list: 20-row scrollable list (refreshRows() / relayoutList()) with scroll bar; row count recalculated on vertical resize
  • Confirm-clear: lazy-created UIDropDownMenuTemplate via ensureClearDropdown()

Wire-up in existing files

  • functions.lua: LegacyMainWindow.refresh / setScanCooldown fan-out added at four sites: mt.__call (searchInfo metamethod), onListUpdate(), and both the clear branch and the C_Timer.NewTicker callback inside startScanCooldown
  • GUI/MainWindow.lua: compactModeIcon:SetScript("OnClick") checks DB.global.useLegacyUI; if true, opens the legacy window and closes the main window instead of toggling the compact tray
  • GUI/SettingsPanel.lua: useLegacyUI AceConfig toggle added to Appearance section (order=17), backed by DB.global.useLegacyUI
  • FGI_Core.lua: elseif str == "legacy" branch in Console:FGIInput dispatches to addon.LegacyMainWindow.Open()
  • All 5 TOCs: GUI\LegacyMainWindow.lua added after GUI\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 numbersAceConfigRegistry: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 returns 8 unconditionally (was gv.isRetail and 8 or 5). Kept as a function rather than inlined so a future per-version tweak stays a one-line change.
  • The scanInterval slider's min (GUI/SettingsPanel.lua) is a literal 8 again — the panel-crash fix. The slider track bottoms out at 8 on every client; get / set still clamp through fn.setScanInterval as belt-and-suspenders. desc reverted to a plain (non-function) string.
  • DB.global.scanInterval default bumped from 5 to 8 (FGI_Core.lua) so a fresh install doesn't start with a value below the slider's own minimum.
  • Existing users with 5 saved are auto-migrated to 8 on first login: the PLAYER_LOGIN hook calls fn.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.