FastGuildInvite-v2.13.3
What's new
<FGI> FastGuildInvite
[v2.13.3] (2026-09-14) — a capped level band is cut where the players are, not in the middle
Changed — a level band that comes back full is cut at the level the sample is piled on, so a max-level realm reaches 90-90 in one split instead of seven
Field report (Spacedoc, Discord, 2026-09-14), retail: "if you have 15 people on levels 1-89 and
50+ on level 90 ... it is around 6-8 [splits]". Traced through the arithmetic halving it was
exactly seven: 1-90 → 46-90 → 68-90 → 79-90 → 85-90 → 88-90 → 89-90 → 90-90, every one a capped
/who returning the same level-90s again, plus the seven low halves each of those left — fifteen
level-only queries before the race/class fan-out that actually finds anyone could begin. The
reporter's count was right and the answer given on Discord ("three, maybe five") was not.
The cut now comes from the rows. The 50 rows a capped query returned are a sample of who is
standing in the band, and fn.levelSplitPoint(a, b, rows) cuts at the sample's upper median level
when that level holds at least half the sample — a pile — and halves the band as before when it
does not. For the reporter's realm the median is 90 and the first cut is 1-89 / 90-90: the
1-89 half returns the levellers in one uncapped /who, and the seven splits are one. The upper
median rather than the lower so a sample split evenly between 89 and 90 cuts between them; a pile
sitting on the band's floor is isolated the mirror way (1-1 / 2-90). Rows outside the band are
ignored and no usable row at all means the old middle. No setting, no new tag, nothing for a user
to configure — the same algorithm, cutting in a better place.
The pile guard is measured, not cautious, and it is the part worth reading. Nobody documents
which 50 a live /who returns when more match, so Tests/zz_level_split_point_spec.lua (29
examples) does what the Discord thread could not: a fake /who server over four synthetic realms,
the whole scan drained through the real registered callback under both cut rules, under three
server orderings (shuffled, lowest-50-first, highest-50-first), and every /who counted. An
unguarded median was worse than halving on an evenly spread realm whenever the server hands
back the lowest 50 — 37 /who against 23 — because the sample then describes only the bottom slice
of the band and its median peels the band off four levels at a time. With the guard the spread
realm is the old halving query for query under every ordering (asserted), and the piled realms
come in at or under it everywhere:
| realm | server returns | halving | this |
|---|---|---|---|
retail-shaped 1-90, 40 levellers + 1,500 at cap |
shuffled | 32 | 20 |
| lowest 50 first | 31 | 21 | |
| highest 50 first | 31 | 19 | |
classic-shaped 1-60, 120 levellers + 600 at cap |
shuffled | 29 | 23 |
| lowest / highest first | 30 | 24 | |
evenly spread 1-90, 400 players |
any | 23 | 23 |
starter pile 1-90, 300 at 1-10 + 40 above |
shuffled / lowest / highest | 70 | 70 / 64 / 68 |
The progress denominator is untouched: a band of n levels is priced n·leaf + (n−1) and two halves
of n levels price to the same number wherever the boundary falls, so the conservation the
subdivision spec asserts holds by identity — pinned at an off-centre cut. The descending-sweep
setting is applied after the cut as before.
Docs — the scan's "how a full search narrows" is now described where the features are
README.md never said how a search gets past the 50-result cap at all; the HTML said it in one
bullet. Both now carry the four-step narrowing (level, race, class, zone), that its order is a
setting, and that a full level band is cut where its players are. The HTML's guild-integration
line also lists MoP Classic, which it had left out.
[v2.13.2] (2026-09-14) — every officer online announced the same leaver, the scan still reached players inside instances, a grouped welcome short-changed the last joiner, and on Classic a recruit's reply never reached their record
Fixed — with several officers running FGI, a member leaving was announced to officer chat once per officer
Field report (Vishiswaz, TBC): "more than 1 person online with fgi in officer chat causes multiple
o chat messages for people leaving the guild" — two identical <FGI> Player Koje-Dreamscythe has been blacklisted. Reason - Left the guild lines, one from each officer's client, a second apart.
Reported as a regression, and it is one from the reporter's side: v2.13.0 made the auto-blacklist
route work on Classic for the first time, so the duplication it always had became visible.
Every client running FGI is in the same OnMemberLeft callback for the same leaver, and every one
of them called fn:blackList, which posts the officer line unconditionally. The entry landing
on every client is right — it is the same entry, and DeltaSync reconciles it — but the line has
to have one sender. fn:blackList takes a fourth argument, auto; both departure routes in
Modules/FGI_GuildEvents.lua pass it, and an automatic add posts the officer line only from the
elected announcer (Recruiter:CanPostAutoNotice, the announcer election under a new name — the
same list Q4 gave the welcomer, and for the same reason: it is who speaks for the guild, and every
client computes it from the same policy and roster). A person's own /fgibl or Blacklist-tab add
is unchanged — one person, one line. KNOWN COST, and it is the same shape as the welcomer's: a
guild running no policy at all still falls open, so several officers with FGI and no announcer list
still each post. Falling closed there would silence the line for every guild that never configured
a policy, and in the commonest case — one officer running FGI — falling open is one line and correct.
Found beside it: the officer line was worded from the UI-language override (L) while the chat
print next to it used the client locale. Both the add and the removal line now go through
fn.chatLoc(), the rule every other line into somebody else's chat frame follows.
Tests/guild_events_spec.lua pins that both routes pass the flag; Tests/zz_functions_spec.lua
(new block, six examples) drives the real fn:blackList: a manual add posts whatever the election
says, an automatic add posts only when elected, the entry lands either way, no election module
means post, the setting off means silence, and the wording comes from the client locale.
Fixed — the list of places that count as an instance is generated from the client's own data, for every flavour, and the zone step no longer runs a /who into one
Field report (Vishiswaz, TBC): "fgi still searching in instances" with z-"The Botanica" on
screen. The operator's reading of it, verbatim, which is the whole of the fix: "filter out anyone
in a dungeon/instance/raid/etc from the who results so you don't invite someone in a protected
state" — and on where the data comes from: "you have to get that data for EVERY flavor of the
game and maintain it for EVERY flavor of the game. it's a pain, but we have to do it as the API
isn't working well."
The quiet-zone set was never complete, on any flavour. It was 45 hand-typed area ids (fewer
instances than vanilla alone has) unioned with a walk of the client's Dungeon-typed maps — a walk
that finds nothing on Classic Era, which files its dungeons as ordinary Zone maps, and that on
every flavour depends on a map-type flag meaning something it does not quite mean. So a /who row
standing in an instance the set did not name passed the gate, was queued, and was invited.
The set is now generated. FGI_InstanceAreas.lua is every top-level area of every instanced
map in the four flavours' own DB2 tables (Map.InstanceType ~= 0 joined to AreaTable, plus the
area each instanced map names as its own — Classic Era's Onyxia's Lair row says it is on Kalimdor
and only the raid map's own pointer catches it), produced by tools/instance-areas.lua from
wago.tools exports and regenerated per patch; the directions are in the tool. 511 ids across
Classic Era (49), TBC (66), MoP (173) and retail (489). fn.getStaticAreas resolves this client's
ids through C_Map.GetAreaInfo, which is what makes it right in every locale, and counts what
resolved; the Dump window's Areas button prints the count. The Dungeon-map walk stays as a second
source, unioned as before.
Each id names the flavours that silence it, and that is not decoration. A name is not the same place in every game: Zul'Aman is the raid on TBC and MoP and, since Midnight, a whole levelling zone on retail; area 206 is a "Westfall" inside the Deadmines on Classic Era and Utgarde Keep on MoP and retail. The generator refuses to silence any name that is also an overworld ZONE in that flavour — a top-level area with sub-areas, on a WORLD map — and prints every refusal so the decision is auditable. What a world map is took four cuts, each learned from a wrong one: any top-level overworld row (dropped the outdoor doorstep rows of Blackrock Depths, Onyxia's Lair, The Violet Hold, so the very dungeons the list exists for fell out); any open map (retail's Death Knight campaign phase is one open map holding one "Scarlet Monastery" with sub-areas, which made the real one scannable); two or more zones (made Deepholm, one zone on its own map, a non-world, so a scenario copy silenced the real zone); a loading screen (retail's story phases of Icecrown Citadel, Firelands and Ny'alotha have one, so the raids fell out). A world is an open map holding two or more zones, or one zone plus a loading screen with no dungeon or raid of that name — a single-zone map named after a raid is that raid's story phase; a single-zone map only a scenario copies is the real place. KNOWN COST: a recruit standing on an instance's outdoor doorstep is silenced with the instance (they are about to be inside anyway); on retail Eye of Azshara, Tol Barad, Ashran and Wintergrasp share their name with a real zone and are therefore not silenced there; and Torghast is refused because its lobby is an open map of that name, so its runs are scannable.
And the zone step reads the results through the same gate. The fan-out over a capped query's
zones is still the sample — the zones the 50 rows were standing in, as it has always been — but the
sample is now filtered by IsInQuietZone, the predicate the rows themselves go through, so an
instance a row stood in never gets a query of its own. That is the z-"The Botanica" line.
The instance list is baked in; the Quiet Zones toggle is the user's list only. The operator,
verbatim: "the 'built in instance list' is NOT part of the quiet zone configuration, it's baked
in." Until now IsInQuietZone read DB.global.quietZones over the whole union, so switching the
toggle off let players inside dungeons through and searched their instances. fn.getAreas now
tags each name "instance" or "custom"; an instance is skipped unconditionally, a custom zone
while the toggle is on, and a name on both lists is an instance (the user cannot opt a dungeon back
in by adding it to their own list). The Quiet Zones tab's master-switch tooltip is rewritten to say
so (enUS; the 30 other locales carry the old key and fall back to English for that one tooltip), the
Dump window's Areas readout marks the user's entries (your list), and fn.isInstanceArea is the
public form of the question. Pinned in zz_add_new_player_spec (an instance is skipped with the
toggle off, and stays one when added to the user's list), zz_who_subdivision_spec (no query into
an instance with the toggle off; a custom zone is searched again once the toggle is off) and the
end-to-end block below.
Reverted the same day, before release: a draft of this fix had redesigned the fan-out to
union every Zone-typed map in the client's tree behind the sample (~55 queries per capped leaf on
TBC). The operator: "i didn't need you to change the design." fn.getZoneMaps and the tooltip
that described it are gone; the tooltip says what the step does.
Tests/zz_instance_areas_spec.lua (new, 23 examples) pins the generated table's shape, the eleven
instances the two TBC reports named silenced on TBC — including the seven Vishiswaz marked with
question marks, driven end to end as /who rows through the real registered callback on a TBC
client: none is queued, the Nagrand control is, the toggle changes nothing, and a client that cannot
name them queues all twelve — the seven vanilla dungeons and the Season of Discovery instances on
Classic Era, Zul'Aman silenced on TBC and MoP but not retail, Blackrock Depths everywhere, Deepholm
and Westfall nowhere, Icecrown Citadel despite its story phase, the flavour a client reads (Wrath
and Cata read MoP's column, the nearest data), fn.getStaticAreas per flavour and its count, and
the Quiet Zones tab's PvP group derived from the same table and filtered to this flavour.
Tests/zz_who_subdivision_spec.lua (block rewritten, seven examples) drives the real callback: a
sampled instance gets no query whatever the toggle says, only the sampled zones are asked about, a
sampled zone the map does not name is kept, the custom list is honoured while the toggle is on and
searched again when it is off, and a batch standing entirely in quiet zones answers no axis.
Fixed — a grouped welcome went out on the first joiner's clock, so a late joiner could be greeted before they could read it
Field report (Vishiswaz, TBC): "the welcome delay is to make sure that people can see their
welcome message … when grouping up multiple joiners together, the timer goes based off the first
person invited in the group, not the last, which can result in the last person not seeing their
message." Exactly right: PostGuildWelcome set the window's due time once, from the first joiner,
and a rider joining in its last second was greeted one second later. A late joiner now pushes the
due time out to at least the welcome delay from their join; the window still closes on time
when nobody joins late, and the ten-minute life is still counted from the first joiner, so a stream
of joiners cannot hold a welcome open forever. welcome_spec pins both.
Fixed — on Classic, a recruit's first reply opened a second History record instead of marking the attempt "replied"
Found by the coverage pass on Modules/WhisperAlert.lua, which had never had its alert path driven
offline (17 of 71 lines). The reply was recorded as fn.recruits:onReplied(key) with key the
realm-qualified fn:fullPlayerName(sender) ("Zugar-Realm"), while the attempt is counted in
fn:invitePlayer as onAttempt(playerName) with the raw /who name ("Zugar"). The roll-up keys
every record through fn:normalizePlayerName, which is the identity on every flavour but
retail — so on Classic the two spellings were two records: "Zugar: 4 attempts, tried" beside
"Zugar-Realm: replied", and the one line the History tab exists for was never written. Retail
resolves both spellings to one qualified key, which is why no field report ever named it.
The alert now hands onReplied the raw sender, exactly as the attempt does.
Tests/zz_whisper_alert_spec.lua (new, 19 examples) pins it with an attempt followed by a reply
and one record at the end; the rest of the file drives the three cues, their three settings, the
deferred chat-tab flash (matched case-folded because FCFManager_GetChatTarget upper-cases a
whisper target, FloatingChatFrame.lua:2286), the login-burst window and the lockdown gate.
Fixed — a click during an encounter dropped a joiner's welcome whisper for good
Modules/FGI_Welcome.lua's chat-messaging-lockdown check sat inside sendWhisper, which the click
pump called after removing the entry from the queue — so the whisper a recruit was owed went
nowhere if the recruiter's next click landed inside a Mythic+, and the comment beside it still
described the timer era. The pump now asks API.InChatLockdown() once per click and holds the
entry, unsent and still queued, for the first click after the encounter, inside its ten-minute life
like any other. welcome_spec pins it: nothing goes out during lockdown, the count stays at one,
the next click sends.
Tests — thirteen more modules at 100%, and the branches that were deleted instead
The method that closed functions.lua — read each uncovered branch against the Blizzard trees
before driving it — over the logic modules. At 100% now: WhisperAlert, RecruitGoals,
AnnounceSync, MemberHistory, RealmResolver, Welcome, GuildEvents, AltGroups, WidgetPool, Baton,
SettingsProfile, Presence, intro; Recruiter, DeltaSync and Wingman driven to their last lines
(re-measure pending — the coverage tool timed out twice on the box while the harness ran its own
gate). Suite 2873/0.
DeltaSync (Tests/zz_deltasync_bridge_spec.lua, new): the host callbacks driven at the
bridge with a recording host — OnSyncAccepted requests the delta with our baseline, a refused
send and a version broadcast reach the debug log, an untrusted query is dropped, a merged
anti-spam entry leaves the live scan queue and frees the P2P session slot; /fgids and
/fgids sync; the MINOR-15 floor refusing an older DeltaSync in chat; the single-revision and
stored-canon arms for a DeltaSync without MakeHashEntry / ComputeHashV2; the recruits
item stamped by statusAt. IsTrustedSender is one line now: LibGuildRoster's
IsInGuild answers a boolean on every path (its own pre-build arm scans the live roster), so the
hand-rolled roster scan and the "can't verify, allow" fallback behind ~= nil could not run — and
the second would have opened the gate.
Wingman (Tests/zz_wingman_session_spec.lua, new, plus the prompt and step-menu specs): the
configured step order and its fallback, the announce step (posts, falls through when nothing
posts, skipped in lockdown), the protected-state pause, auto-stop, the scope line's name caps,
the lock tooltip, the overlay's own click (pump first, and nothing over FGI's own windows); a
start inside combat that arms and finishes on PLAYER_REGEN_ENABLED, keeping its duty
provenance; cancelling while armed and the toggle debounce; the session restore and its
retries, and the first duty evaluation waiting out the warmup — all through the load-time
timers the prompt spec already recorded; the step menu's tick writing the setting and
repainting, and its outside-click close; the auto-stop under a policy that bars both roles.
Deleted as unreachable on every flavour, not covered, each read in F:\Blizzard API Docs
per flavour tree: the UIFrameFlash / FCF_StartAlertFlash / CHAT_FRAMES feature-detects in
WhisperAlert (Blizzard_SharedXMLBase is AllowLoad: Both, Blizzard_ChatFrameBase is
LoadFirst: 1); five no-C_Timer fallback arms across WhisperAlert and FGI_Presence (every
UITimerDocumentation.lua declares it); Presence's "client does not expose
C_ChatInfo.SendAddonMessage" refusal; WhisperAlert's inner canaccessvalue(sender) retest and
pcall(fn.fullPlayerName), and RealmResolver's inline lockdown return — all three sit behind
API.RegisterChatEvents' wrapper, which has already asked about every argument and returned
during lockdown before the body can run; MemberHistory's addon.API feature-detect;
SettingsProfile's locale() pcall (FGI:GetLocale is in the first file of every TOC);
Recruiter's listForRole "unknown role" arm (every caller passes isRole first); AltGroups'
two identical return false arms. Deleted as code with no production entry point:
RealmResolver.NoteFullName and .Count, Recruiter:AssignedMap (superseded by
AssignedWire at S48).
WhisperAlert reads addon.DB live rather than a snapshot taken at PLAYER_LOGIN:
/fgi resetDB replaces the table, and the snapshot would have kept gating the alert's three
settings on the discarded one.
A suite leak, fixed: Tests/zz_functions_spec.lua's conversation-tab block set
CHAT_FRAMES, ChatFrame7 and ChatFrame8 to nil in its teardown, written when the harness
modelled none of them. The harness owns all three now, so every later spec file ran with no
chat-frame list and two dead frames — invisible until the first file walked the array, which
failed in the full suite and passed alone. The block now drives the harness's real
FCF_OpenTemporaryWindow / FCF_Close and stands in nothing.
The harness pin moves to 2c7279f. UIFrameFlash, FCF_StartAlertFlash,
GetNumGroupMembers, IsInRaid and UnitFullName were raised (inbox thread 02b2da6bd297)
and delivered the same evening; the stand-ins are gone, and the whisper-alert spec asserts the
overlay's visibility and alpha on the clock and the chat tab's alerting flag rather than the
call. One specs-only entry point came with it: fn.forgetConversationTabSuppressionHook,
because the addon hooks FCF_OpenTemporaryWindow once per session behind a guard and the
harness reinstalls that global per spec file, which drops the wrapper while the guard stays set.
Nothing in the addon calls it.
Fixed — closing the Settings panel could take the compact tray with it, because the visibility fallback read the wrong SettingsPanel
GUI/SettingsPanel.lua's ShouldBlockEscClose — the check both ESC proxies ask before hiding
their window — has a flag for the panel being open and a fallback that reads the panel's own
visibility "in case the hook didn't work". The fallback was spelled
(Settings and SettingsPanel) or InterfaceOptionsFrame, and inside that file SettingsPanel is
our module table (line 595), which has no IsShown, so the read fell through; and
InterfaceOptionsFrame is declared in none of the four Blizzard trees, so the second half never
resolved either. The fallback answered false on every flavour since it was written. It reads
_G.SettingsPanel now — Blizzard_SettingsPanel.xml declares name="SettingsPanel" in
classic_era, classic, anniversary and live alike. Not run in a client: whether the flag path
already covered every close on Classic, so the fallback never decided anything, is exactly what
this could not tell. Found by reading the function to drive the compact tray's ESC proxy.
Tests — the compact tray at 100%, its handlers driven for the first time
Modules/compactFrame.lua measured 590/764 on 2026-09-13: three geometry specs had pinned its
layout to the pixel and never pressed a button. Tests/zz_compact_frame_spec.lua (new, 48
examples) drives every handler the way the client would — GetScript and call — the eleven
tooltips (including the gate that silences all of them, Wingman taking over the >> and invite
tooltips, the help icon's 500 px width hint, the broadcast horn's Ready / countdown / activity
rows), the three drag handles ending in one TOPLEFT save, the corner resize saving its width and
refusing the button row's floor, expand / close / gear (with the next-frame re-show) / Wingman
(right-click menu, warmup hold, toggle) / both horns, the warmup countdown overlay, the picker's
own drag, resize and wheel and its name-tie order, the queue rows filled class-coloured and sized,
the wheel clamped to the list, the four row icons including blacklist's fast-vs-confirm split,
the ESC proxy's two refusals, and the login restore of a saved position and width, a junk save,
and the reopen opt-in. 753/753 after.
The login restore is cf.restoreSavedLayout() now, called by the event handler, so a spec
can drive it with a chosen save: the event frame is an anonymous file-local that unregisters
itself on first delivery, and frames.fireEvent reaches every registered frame in the suite.
Deleted as unreachable on every flavour: the three elseif SetMinResize fallbacks (tray
bounds, picker build, picker floor) — SetResizeBounds is in every flavour's
SimpleFrameAPIDocumentation.lua and SetMinResize in none; makeRowIcon's
Interface\Icons\ TexCoord crop — all four row icons are Buttons/ and RaidFrame/ art.
GUI/UI.lua:71 and GUI/LegacyMainWindow.lua:318 carry the same SetMinResize fallback and
are left for the GUI pass.
One harness gap, raised and delivered the same hour: _G.SettingsPanel was built shown
where Blizzard_SettingsPanel.xml says hidden="true", so the fixed fallback blocked every
ESC-close example offline (inbox thread 861ddf9e). The harness pin moves to 82eb99e
(contains 557390a, the panel hidden after every reset); the before_each that hid it by hand
is gone. Whole suite 2922/0 at the new pin.
[v2.13.1] (2026-09-13) — FGI threw errors all through a Mythic +10, four retail races could never be scanned for, and the Guilded column called every kept candidate guilded
New — a template your character applies at every login, so an alt keeps a different setup from your main
The other half of the Discord request behind v2.13.0's templates: "allowing different characters to have different profiles." The operator, on how it should work, verbatim: "we SHOULD save the templates to SV, that's the only way the persist. we can change it whenever we want, and when we /reload it over writes it ... per user templates, that auto load on init."
Settings > Templates gains one dropdown at the top of "Your templates": Apply at login on this
character. Pick a template and it is applied now — behind the same warning the Apply button shows,
naming what it replaces — and again at every login on that character, overwriting whatever the
settings had drifted to since. Pick None to stop; the settings stay as they are. Each character
has its own choice, which is the point: a main that scans 55-60 with the officer whispers and a bank
alt that only welcomes. /fgi template auto <n>, /fgi template auto off and a bare
/fgi template auto are the slash equivalents, beside the other template verbs, and list marks
the one this character follows.
I had described this for a day as needing every setting moved into AceDB's profile scope — "an
irreversible saved-variables migration once shipped" — and offered to drop the feature on that
basis. That was the wrong shape, and the operator said so: a template is already in
SavedVariables, applying it is already the Apply the button runs, and re-running that at login is
one pointer in DB.char, nothing moved and nothing migrated. Modules/FGI_SettingsProfile.lua's
header said the two halves of the request "ship separately" because one of them was a migration; it
now says why that was wrong.
- The pointer is by name and author — the identity
Storede-duplicates on — not by position, so forgetting an unrelated template does not move it, and an author's corrected version of the same template is picked up on the next login without re-pointing. - A missing template is said, not silently cleared. If the one you follow has been forgotten, login prints that it is missing and leaves the choice in place; the dropdown shows it as "(no longer in your list)" rather than lying with None. Re-adding it under the same name resumes it.
- It runs in
OnInitializethe moment the database binds, before anything reads a setting. My first placement was afterfn:initDB(), and the self-audit moved it: the locale override bakesuiLanguageOverrideinto the locale table,icon:Registerhands LibDBIcon theminimaptable (whichApplyreplaces wholesale), andupdateEscFramesacts onkeepOpen— all before that point, so a template changing any of them would have taken one extra login to show. The minimap one was worse than that, as peer review put it: LibDBIcon would have kept writing the button's position into an orphaned table, so every drag would silently not save and the button would be "back where the template put it" on each login. It never shipped; if a v2.13.1 tester reports "my minimap button keeps moving back", this is where to look. There is an example that reads the source to pin the order against all four readers, since the offline fixture never runsOnInitialize. - Two deviations from the design as I had recorded it, reported rather than re-asked. Picking a
template in the dropdown applies it now as well as at every login — the recorded design said
the loss warning runs once at pick time, and a confirmed "apply this at every login" that then
left the settings untouched until tomorrow would be the one outcome nobody expects; if
record-only is wanted, it is one call to delete. And
/fgi template autotakes an index likeapplyandforget, not a name, for their stated reason: a name can match two templates. - It is not a setting.
char.autoTemplateis inNOT_A_SETTING, so a template can never carry one — applying template A must not re-point a recipient at template B.
KNOWN COST, and it is the model the operator chose rather than a defect: nothing in the catalogue is character-scoped — every setting a template carries is account-, realm- or faction-wide — so "per character" means exactly what they said, each login overwrites. None means the character does not apply a template at login; it still shares the account-wide settings with every other character, so it logs in to whatever the last login left. The dropdown's tooltip and the release notes say it in those words (peer review: anything softer re-invents the lie); the remedy for a character that must not inherit is to give it a template of its own.
Also pinned on review: applying a template that carries announcements replaces the profiles wholesale and leaves the per-character cooldown clocks alone — so a template re-applied at every login does not reset every announcement to Ready. One example asserts it rather than believing it.
Twelve examples in Tests/zz_templates_page_spec.lua.
Fixed — the chat limit threw a Lua error out of an announcement, and the message was lost
The report, four times in one BugSack: SendChatMessage(): Chat message limits exceeded, with a
stack through FGI_APICompat.lua's SendChatMessage ← Announce.lua chatSend ← sendToChannel
← Send ← Wingman's drain. A ~250-character guild advert to a numbered channel, each time.
BugSack captures Lua errors — so the limit is not a chat line to listen for, it is the API throwing
synchronously. The design on the todo list had it arriving as a CHAT_MSG_SYSTEM notice to match
against a global string; the operator's own screenshot, read properly, says otherwise. Before this,
the error unwound through Announce:Send after the item had been popped off the round and
before anything stamped or tracked it: the message was gone from the round, Wingman's drain
aborted mid-step, and the player got a Lua error about a message that simply had not been sent.
chatSend now pcalls the API. A refusal puts the item back at the head of the round, unstamped
and untracked, and the very next click sends it again, with one chat line saying so. That is the
whole of it — the limit is not a number anyone publishes (the ChatThrottleLib wiki page treats it as
opaque), so nothing here picks one, waits one, or gates on one. The refusal is the signal; the next
hardware event is the retry, which is what every other send here waits for anyway.
Once per item. A second refusal of the same item is not the limit any more but a send the client will not make at all, and requeuing it forever would wedge the round on one item. It falls through to the round's failure summary carrying the client's own words, and the round moves on.
Withdrawn earlier the same day and recorded above: a ChatThrottleLib "room to post" gate that
answered this same report by refusing nearly every announcement. This is the event-driven shape
that should have been built first.
Five examples in Tests/announce_guild_ready_spec.lua, driving a SendChatMessage that throws the
reported text. Not run in a client: the refused-then-resent path is asserted offline only.
Fixed — four audit findings that had sat on the board unanswered for up to three weeks
Peer review asked one question of the 66 board threads I had closed by pairing headings with response blocks: did the pairing leave any unpaired? Re-deriving it by reading the board found seven findings with no response anywhere after them. Three were investigation leads for the ElvUI stutter report, never defects with a fix (S15, S16, S18); the other four were real, all still live in the tree, and are fixed here. None of them is verified in a client.
- S22 (filed 2026-08-20) — a bare name read as blacklisted when a different realm's character
was.
fn:isInBlackListpassed the slash command'sfull = trueto the resolver, and that fallback ends in a prefix match written so/fgi unblacklist ninjacould reach an entry filed asNinja-Testrealm. Every read-time caller inherited it — the History page's status column, the auto-blacklist gates, the scan batch — so on the Classic family, where names are stored bare,Ninjawas "Blacklisted" wheneverNinja-AnyRealmwas, and the page stated it as fact about a different person. The wrapper now asks for an exact, case-insensitive match and never a prefix; the lenient form stays on the slash command, the one place a human types. - S43 (filed 2026-08-28) — click Alice's invite button, Bob gets the whisper. The invite loop had two gates for a row the user pointed at. The held gate refused that row and said why; the stale gate removed it and carried on at the same index, now holding whoever had shifted into the slot, and contacted them without a word. The head-of-queue drain (no index) still moves on, as it should — nobody is pointing at anything there. An indexed press on a stale row now drops the row, names it in chat with the reason, and contacts nobody. Three of the drain's own examples had been driving the indexed path all along, which is why nothing noticed; they drive the drain now.
- S44 (filed 2026-08-28) —
AltGroups:Snapshotpromised a copy and handed out LibGuildRoster's internal arrays. The docstring said a snapshot "cannot go stale, cannot disagree with the library, and cannot outlive a group the library dropped"; all three were false of everything inside the outer map, becauseGetAltGroupreturns the library's own sorted array and says mutating it corrupts the reverse index for every adopter. Copied on the way out now — a handful of names per owner on a path that already walks every owner. The promise is true, and two examples hold it there. - S13 (filed 2026-08-17, deferred as "release frozen" — three releases ago). The migration named
a legacy message row
"Message N"in English while the Messages tab invented the same name eight times through the locale, so a row arriving from an older peer read differently depending on which door wrapped it. One helper,fn.defaultMessageName, both doors, localised; a source-reading example found the eighth site the first sweep missed. - S40 restated (docstring only):
MayDisplace's comment said "when WE stamped it" where the code and the spec mean any FGI client. Reworded. - S9 (self-raised 2026-08-16, accepted, deletion left "for scope").
API.GetGuildRosterInfowasif gv.isRetail then return GetGuildRosterInfo(index) else return GetGuildRosterInfo(index) end— a flavour branch whose arms do not differ is a standing claim that the flavours differ. One line now; the two examples pinning "the flavour makes no difference" stay. Found by re-importingdocs/AUDIT.mdinto the inbox with writ's fixed importer (2026-09-11) and reading the 40 threads it opened against the board and the tree: 39 were settled on the board or in this release and are closed with the line that settles each; this one was still in the tree.
Fixed — the addon touched protected values for the whole of an encounter
The report, from a player: "Just got out of a Mythic +10 Kings Rest and Fast Guild Invite was throwing errors for having touched 'secret' or 'hidden' variables like guid. I can't find a way to shut this thing off inside an instance or raid or delve."
Modules/FGI_RealmResolver.lua reads the sender's GUID — argument twelve — off ELEVEN
CHAT_MSG_* events, and had no lockdown guard at all. Every one of those eleven carries
SecretInChatMessagingLockdown = true; checked one at a time against
Blizzard_APIDocumentationGenerated/ChatInfoDocumentation.lua rather than assumed. Inside an
encounter, a Mythic+/challenge run or a rated match the client marks most of a chat payload secret,
and the payload tables are explicit about which fields escape: they mark languageName,
channelBaseName, specialFlags and lineID as NeverSecret and pointedly do not mark
text, playerName or guid.
The touch is NoteGUID's own first line — type(guid) ~= "string" or guid == "" — which reads the
value in order to type it and compare it, before anything defensive can run. Its pcall around
GetPlayerInfoByGUID never got the chance, and would not have helped: pcall swallows the throw but
does not strip the execution taint acquired by touching the value, which is what turns one
mistake into a cascade in Blizzard's own chat code on every later message. Party chat and boss yells
fire continuously in a dungeon, so this ran on nearly every line.
Four more handlers had the same hole, found by reading sideways rather than by the report.
Modules/FGI_Welcome.lua's guild-chat handler had no protection of any kind — it compares
author, hands it to Ambiguate and pattern-matches msg. Modules/FGI_Presence.lua and the
auto-kick watcher in functions.lua both typed the message before their own bail-outs could spare
them. Modules/Announce.lua's delivery-confirmation guard was inverted: it checked
channelBaseName, which the docs mark NeverSecret, while text and sender, which are not,
flowed past it.
Two others — Modules/WhisperAlert.lua and Scan.lua's AFK/DND handler — consulted canaccessvalue
after using the value. Both cite Blizzard_ChatFrameBase/Shared/ChatFrameFilters.lua:37, which
checks access first; the guard is now where the cited reference puts it.
Why it shipped, which matters more than the fix. The offline harness installs no
GetPlayerInfoByGUID, so NoteGUID bailed at its own feature-detect and the entire GUID path was
unreachable in tests. No coverage number ever said so. Tests/zz_chat_lockdown_spec.lua now drives
these handlers through the real events with lockdown on and off — including Scan.lua's guard, which
shipped in v2.7.1 and had never been asserted by anything.
KNOWN COST, deliberate: no realm or race is learned from chat for the duration of an encounter,
and the peer-welcome and presence handlers stand down there too. Nobody recruits mid-Mythic+, the
resolver has four other sources, and a missed free lookup costs a later /who at worst.
NOT VERIFIED OFFLINE, stated rather than buried: the harness models the lockdown state but has
no secret value and no canaccessvalue, so nothing can prove that touching one throws, and the
two ordering corrections above cannot be tested at all. Raised as a harness contract.
Changed — the chat-lockdown guard is installed by the registration itself, not remembered at each handler
Self-audit S69, settled by peer review. Nine frames across seven files register CHAT_MSG_* events,
and five of them had no lockdown guard when the Mythic+ report arrived — even though Scan.lua had
carried one since v2.7.1. Nine registrations, one correct, across four minor versions: a measured
propagation rate of zero for knowledge-based protection. A shared predicate would still be
knowledge-based — the tenth handler forgets a call as easily as it forgets a condition.
API.RegisterChatEvents(frame, events, handler) registers the events, installs the handler, and
gates only CHAT_MSG_* on the taint-free lockdown state, so a frame mixing roster or login events
keeps receiving those. All nine sites converted — and a tenth (functions.lua:1366, the guild-join
detector, already guarded since v2.13.0) turned up only because converting forced an enumeration.
Tests/zz_chat_registration_spec.lua reads the shipped file list from the TOC and refuses any bare
RegisterEvent("CHAT_MSG_…"). Control-tested: one planted bare registration reddened exactly one
example, naming the file and line. Three spec fixtures that hand-built FGI.API now load the real
compat layer — a fallback in the modules would have reintroduced the bare path the sweep forbids.
The inline guards at each site remain, now redundant, carrying the reasoning; the comments say the
helper is the load-bearing one. Also from the same audit: the label-collision sweep now counts through
SP.ResolveArgName rather than a copy of it, and a template carrying nothing says so explicitly.
Fixed — a pasted template could put an unbounded structure into your saved variables
Peer review round 90, finding A. FGI_TemplateWire.lua caps a template at 64 KB — but that bounds
the compressed string, and what reaches SavedVariables is the decompressed structure, which
SP:Store deep-copies into global.settingsTemplates. Deflate ratios on repetitive input run to
three orders of magnitude, so nothing bounded what actually landed.
This addon has already lost saved variables once to an unbounded table — the 40 MB FGI_DEBUG
runaway that silently failed to load, which is the incident Store's own row-ceiling comment cites.
The shelf had a ceiling on rows; the rows had none.
SP:Decode now bounds the decompressed string before deserializing, returning a new "toobig"
reason. Checked there rather than in the wire because there are two doors and the wire is the safer
one: the paste box takes text from anywhere and never touches the wire cap. The reviewer could not
resolve whether the wire door is guildmates-only; it is not reliably — fn.isCommTrusted
(functions.lua:1289-1293) fails open, returning true when the DeltaSync bridge is absent.
Fixed — applying a template checked which settings it carried, never what was in them
Finding B. The key gate is strong and structural — Apply iterates the catalogue and indexes the
payload, never the reverse, so no uncatalogued key is reachable at all. The value was ungated, and
global.lowLimit = {}, realm.filtersList = 5 and global.minimap = "x" are all catalogued keys.
The damage lands far away and much later: the Filters tab iterating a number, LibDBIcon handed a
string where it expects its table — Lua errors with nothing pointing back at the template.
SP.TypeMatchesDefault derives the expected type from the shipped default rather than adding a
fourth hand-maintained table, and a refused value is not counted into the "settings changed" total.
It fails open for lazily-created settings, which have no default to compare against — refusing
everything untypeable would turn a hostile-input guard into a data-loss bug for ordinary users.
Finding C in the same pass: nothing asserted SP.HAND_BUILT — the collections the loss warning
covers — agreed with the catalogue. The dangerous direction is a curated collection added to one and
not the other, because a missing warning is indistinguishable from nothing to warn about. Two
sweeps now check both directions.
The depth cap in sameValue stays, and its justification was wrong. It claimed a hostile
hand-edited template could carry a cycle. It cannot: AceSerializer has no back-reference syntax, so
deserializing can only build a tree. The cap is defence-in-depth against a future input path — but a
guard defended by a reason that cannot happen invites the next reader to delete it, and that reader
would be right about the reason and wrong about the code.
Fixed — a template's row and its apply message counted different things
Peer review round 90. The library row counted payload.included, which SP:Export records for every
selected entry even when the value was absent — deliberately, so a support conversation can tell
"the author left this out" from "the author never set it". SP:Apply reports what actually landed.
An author who ticks everything and has never touched the lazily-created settings produced a row
reading "81 settings" and then "61 settings changed", seconds apart, and the obvious reading is that
twenty failed. The row counts payload.scopes now, which is exactly what will land; included
keeps the job it was built for. The announce entry drops out of the number for free — it was
previously counted and named by its own clause.
Two things the same review checked and found correct, recorded so nobody "fixes" them: clearing
keys during a pairs traversal is legal in Lua 5.1 (only assigning new fields is undefined), and
apply using the captured payload where forget re-looks-up is deliberate — the confirmation
computed what that payload would cost, so re-resolving could apply something the dialog never
described. That reasoning is now a comment where the asymmetry is visible.
Fixed — four retail races could never be scanned for, and one of them was reported
"pandaren race on retail I think is not showing" — Spacedoc. It was not, and nor were three others.
Retail's race table has three buckets — Horde, Alliance and Neutral — and the flatten was
L.raceByFaction[playerFaction], which picks one. So for any player who has chosen a side, which is
every retail player, the entire Neutral bucket was discarded: Pandaren, Dracthyr, EarthenDwarf
and Harronir.
Not a cosmetic gap. The scan's race fan-out iterates L.race, so a race missing from it is never
asked for — no /who issued, no recruit of that race findable by a race-subdivided scan. Everything
else about those four was already correct: all four have full, generated RaceClassCombo rows.
So v2.12.2's Earthen/Harronir fix never worked. That release added both races and said in its
notes that they could now be found. It also introduced the Neutral bucket to hold them — and this
line, three hundred lines later, threw it away. The claim shipped; the behaviour did not.
Neutral means "both sides", not "neither". GUI/Tabs/RaceClassMatrix.lua:179 already maps it to
"both" so those races survive either faction filter; only the flatten disagreed. MoP avoids the
whole thing by listing Pandaren under both factions rather than in a third bucket.
Why nothing caught it: the flatten was inline at file scope, run once at load off
UnitFactionGroup("player") and the flavour flags, so no spec could choose either. It is
FGI.RacesForFaction now, and Tests/zz_race_faction_flatten_spec.lua drives it with tables it
controls — control-tested by removing the fix and watching it name Pandaren on both factions. The
flatten also aliased a live faction subtable while its own comment said callers mutate the result;
it builds fresh now.
Withdrawn the same day — a ChatThrottleLib "room to post" gate on announcements, which silenced every one of them
The report it answered was real: 4x SendChatMessage(): Chat message limits exceeded from a live
client, at the bottom of four Wingman drains. It was never a loop — Announce:Send posts one
item per call; Wingman drains on any click, and the per-(profile, channel) cooldown spaces posts to
the same channel while doing nothing about posts to different ones.
For one afternoon chatSend asked ChatThrottleLib:UpdateAvail() whether the byte budget covered
the message and declined if not. The operator, within hours of it landing: "whatever you did with
the announce stuff, broke the soundboard" — then "announce just doesn't work anymore".
The misreading. self.avail is not the free budget; it is the undistributed budget. Whenever
anything is queued in the library — and FGI's own sync traffic keeps it queued for most of a session
in any guild with peers — ChatThrottleLib.OnUpdate hands the whole of it to the priority rings and
sets it to zero, every 80 ms. Read from outside a frame later it is at most 64 bytes, under any
message longer than about 24 characters once the 40-byte overhead is added. The gate refused
essentially every post, and passed only on a client with nothing in the library's queue — the
offline suite, and nobody's game.
The suite saw it and I explained it away. Loading the real library into the announce spec turned ten examples red at once. The previous entry here called that "correct behaviour against a clock that does not move" and the spec substituted a stub whose budget it set by hand. The real library refusing every send was this field report, a day early. The rule that was broken: when a real dependency makes the suite red, the first hypothesis is that the code is wrong, not that the dependency needs replacing.
The gate, its "throttled" outcome and its chat line are deleted; the send is unconditional, as it
was in v2.13.0. Tests/announce_guild_ready_spec.lua now drives the real ChatThrottleLib into
the busy state — past the post-login clamp, more addon traffic queued than one burst covers, one
despool tick run — asserts that state is the one the old gate keyed on, and then asserts the horn
and the soundboard both still post. Control-tested: re-inserting the gate reddens three examples,
including the one with the library idle, which is exactly the red the old spec dismissed.
KNOWN COST, the original report stands open — as this entry was written; closed later the same
day by the "chat limit threw a Lua error" fix above, which is the refusal shape this gate should have
been. At the time: a post the server drops for rate is reported by the echo sweep as "did NOT go
out" and its cooldown stays stamped. There is no read-only "is there room" question the library can
answer from outside its despooler, and it cannot be handed the send (it despools from a ticker;
retail's SendChatMessage is protected).
Fixed — the Guilded column drew a gold Y on every candidate kept from before v2.13.0
Peer review of the v2.13.0 self-audit, and it inverted the audit's own finding. The audit said a
restored row could never carry a Y and the docs wrongly claimed one meant "restored with an older
reading". Both true — and the row that predates the field did draw a gold Y, because the
RowList substitutes "" for a nil cell before the column formatter runs, the formatter tested
v == nil (dead code), and "" is truthy. Anyone with Keep the invite queue between sessions on
who upgraded saw every held row read Y beside its gold !: "all of these people are guilded",
about rows with no guild reading at all, on exactly the population the column was built to
adjudicate.
The formatter now tests the type (type(v) ~= "boolean" → dash), which is the nil-safety idiom
the sibling zone and seen columns already used — I copied their shape in v2.13.0 and not the
half that mattered. One new example feeds a row with no guilded key and asserts a dash; it failed
against the old formatter and passes now.
Seven surfaces said a Y meant a restored row with an older reading; none can. The only writer
of the field can only write false (the guild gate returns for anything but p.Guild == ""), and
restoreScanState pushes rows verbatim. A Y can only mean the gate was bypassed — a bug worth
reporting — and the header tooltip, both code comments, README, the CurseForge page's v2.13.0 block
and the popup now say that. The tooltip's locale key changed; it had no translations to orphan.
A held row still renders N for a reading FGI has suspended. Decided rather than left open: the
gold ! carries that caveat for its whole row, and the tooltip now says so, instead of a third cell
state.
Fixed — the rank breakdown kept the previous guild's rank names after you changed guild
Self-audit F2, confirmed by peer review. v2.13.0 cached the rank labels and dropped the cache on
GUILD_RANKS_UPDATE only. Leave guild A (rank 1 "Grunt"), join guild B (rank 1 "Recruit"): the
library rebuilt, so the counts were B's, but the label still read "Grunt" until somebody renamed
a rank in B or you reloaded — worse than the defect the cache fixed, because the old code read the
label off the member record and would have been right. The same frame now also takes
PLAYER_GUILD_UPDATE. Two examples: the registration, and a guild change modelled as a silent rank
change followed by the client's own signal, with the cache proven still primed in between so the
drop is attributable to the event.
A trap deleted from the path of that fix. FGI_Core.lua carried a commented-out
PLAYER_GUILD_UPDATE frame from years ago — the obvious thing to revive — and it would never have
fired: it wired SetScript('onEvent', ...) with a lowercase o. Gone, with a note saying why.
Fixed — the chat-lockdown guard now asks about the value as well as the state
Self-audit F3. The guard API.RegisterChatEvents installs on every chat frame gated on the
lockdown state, which leaves the transition race open: a payload generated mid-lockdown and
delivered a tick after the state clears is still secret. Modules/Scan.lua carried a pcall for
exactly that and the other nine frames did not — the same propagation failure the helper exists to
end.
The wrapper now also asks canaccessvalue() about every argument of a CHAT_MSG_* event and
drops the event on the first refusal. That is Blizzard's own question — the call its chat filter
makes per value — it does not touch the value (a pcall probe does, and leaves the taint), and
it is a real Classic Era global, read on the classic_era tree rather than assumed. On a client
without secret values it answers true for everything, nil included, so nothing changes there.
Four examples, driven against a stand-in canaccessvalue that refuses one named value, because
the pinned harness has no secret values yet (the delivery is in the harness working tree, unpushed).
The refusal example fires the same decline line as its control and differs only in the refusal —
a marker that would not have registered anyway proves nothing — and the every-argument example
banks a realm from an ordinary GUID first, then refuses the identical event with the GUID in slot
twelve. The stand-in is deleted the day the pin carries the real one.
Changed — a template's announcements arrive exactly as their author had them
v2.13.0 forced every imported announcement profile to enabled = false on arrival, argued two ways
(the recipient's first horn press should not broadcast unread words; "arrival does not act" applied
to the one field that transmits). Both arguments were mine and peer review's. The operator overruled
them, verbatim: "it should come through AS IS. whatever the user decides is what it should be. we
aren't driving templates, it's USER driven." The override, its constant and the picker tooltip that
advertised it are gone; the spec now pins both directions (an author's ON arrives on, an author's OFF
arrives off) so neither can quietly become a rewrite again. Apply was already a deliberate press
behind a confirmation that names what it replaces; that press is the recipient's decision.
Also from the same conversation, recorded here because the next session builds it: per-character templates that apply themselves at login. The operator: "per user templates, that auto load on init. we need to do this." The design is on the todo list; it is a character picking a library entry, not an AceDB profile-scope migration.
Fixed — the anti-spam window was measured on two different clocks
Found the moment the harness pin moved to 2406895, which gave the offline suite a GetServerTime
pinned to a different clock from time() — and ten examples went red. fn:rememberPlayer stamps
each anti-spam entry with fn.getTime(), the server clock, deliberately: alreadySended is
synced guild-wide and every peer has to agree on when an entry was made. The expiry sweep measured
its cutoff with time(), the local clock. Offline the two had always been the same function,
so no spec could see them disagree; in a client they differ by the player's PC clock skew, so a
machine hours off the server got an anti-spam window hours wrong, silently, in either direction.
The sweep now uses fn.getTime(), and functions.lua captures GetServerTime at load beside
time — identical on a client, where the global never changes after load.
Test-side, per the harness's own rule — copy the contract, keep the local clock:
Tests/support/addon.lua drives one clock through app.setNow; it now installs GetServerTime
against that same clock, so a stamp and its cutoff come from one place. The harness's staged
stand-ins are gone: the three nameless refusal strings (every key and value diffed against
env/wow.lua first, as the delivery asked) and the canaccessvalue stand-in — the value-gate
examples now drive FGI's guarded frames with a real wow.secretValue, and the realm resolver's GUID
path runs against the real GetPlayerInfoByGUID for the first time.
Suite: 2491 passed, 0 failed. luacheck and markdownlint clean. Only the announce fix has run in a client (operator: "ok, announcements working again"); nothing else here has.
Changed — "Notes on invite" is gone, because no client it was shown on could run it
Settings > Messages carried a Notes on invite section — write a public and/or officer note on the
new member when they join — hidden on retail with the note "Retail restricts these guild-note APIs
to the Blizzard UI". The code behind it, fn:setNote, opened with if C_GuildInfo and C_GuildInfo.SetNote then return end, on the reasoning that the note API is protected wherever that
spelling exists. Read against F:\Blizzard API Docs rather than assumed: C_GuildInfo.SetNote is
declared in all four flavour trees (GuildInfoDocumentation.lua), and the bare
GuildRosterSetPublicNote the wrapper fell back to exists in none of them — not even the root
GlobalAPI.lua. So the function returned on its second line on every Classic client too, and four
controls that could never act sat on screen for every Classic user, with a tooltip explaining what
they would do.
The section, the function, the 5-second hook off the join handler, the FGI_TooltipInput widget
whose only two users were those two note templates, and the FGI_NOTEMAXLENGTH constant are all
removed. The four DB keys (setNote, noteText, setOfficerNote, officerNoteText) leave the
template catalogue, so a saved template that still carries them imports with those four ignored —
which is what the catalogue gate is for. Its ten locale strings went with the orphaned-key sweep
below, along with 181 others. Found by the coverage catch-up below, which is the point
of running one: a guard that is true everywhere reads as "defensive" until something counts how
often the other branch runs.
Changed — Announce's "chat queue is busy" hold never held anything, and is gone
Announce:Send began with a gate reading ChatThrottleLib.Frame.size > 50 and printing "Announce
skipped: chat queue is busy". No version of ChatThrottleLib has that field — Frame is a plain
frame and the queue lives in Prio[*].Ring — so the gate answered false on every client and never
once skipped a post. Removed rather than repaired: an announcement is one hardware-event send that
does not go through the throttle's queue, so the queue's depth was never the right question, and
this file already records what gating a send on the throttle's budget cost (v2.13.1's
UpdateAvail afternoon). announceMsgThrottled is deleted from all 30 locale files. Grouper carries
the same read; it has been told.
Fixed — "Reset settings (keep anti-spam + blacklist)" did nothing, and so did /fgi resetsettings
fn:resetSettings opened with if not (addon.DB and FastGuildInvite and FastGuildInvite.db) then return end. FastGuildInvite there is a bare global, and nothing defines one: FGI_Core.lua's
FastGuildInvite is a file-local of the same name, AceAddon-3.0 publishes no global for an addon it
creates, and no other file spells it. So the guard was false on every client, and the Settings
button — behind a confirmation ending "This cannot be undone" — and the slash command both
returned silently, having reset nothing and said nothing. It reads addon.lib.db now, the object
OnInitialize actually sets. Found by the first spec ever to call it, which could not get past the
same line; four examples in Tests/zz_reset_settings_spec.lua now pin the default restore, all
seven kept record tables by name, and the in-memory mirrors cleared.
Fixed — invite type 4's whisper-on-decline was sent from a timer, so on retail it never went out (audit S83)
(Round 91 filed these four as S66–S69; the reviewer renumbered them S83–S86 the same evening because S66–S70 were already this repo's 2026-09-10 self-audit findings. The new numbers are used here and in the code comments.)
Peer-review round 91 read every C_Timer callback in the addon down to the deepest WoW API it
reaches — 57 sites in 24 files — and found one that ends in a restricted call: Modules/Scan.lua's
CHAT_MSG_SYSTEM decline branch, which on invite type 4 ("Invite, whisper if declined") queued
C_Timer.After(1, …) → fn:sendWhisper → C_ChatInfo.SendChatMessage. That function carries
HasRestrictions = true in every flavour tree, and neither a timer nor a system-message handler has
a hardware event on the stack; on retail the client raised ADDON_ACTION_BLOCKED, the whisper never
went out, and because a blocked call is not a Lua error, msgQueue[name] = nil ran anyway and the
debug line said "Sending decline message". The mode's one job silently failed with a log saying it
succeeded. This is the fourth instance of the class in this repo — the whisperDelay slider
(v2.2.4), the Recheck drip (v2.11.6) and the welcome's two timer sends (v2.13.0) were the first
three — and the audit board had listed this exact site beside the welcome's on 2026-08-20; the
welcome was fixed and this was not. functions.lua's S20 note recording that whisper-on-decline
"started working" after v2.10.2 and that the stated cause could not exist is answered: nothing had
changed, it was still a timer.
The fix has the welcome's shape, generalised. fn.pumpClick() in functions.lua is now THE click
pump: modules register a drainer (fn.registerClickPump), and every click handler that used to
call addon.Welcome:Pump() — nine of them, across FGI_Core, the compact tray, Wingman's overlay and
the main window's horn — calls fn.pumpClick() instead, which serves at most ONE owed message per
click because the client allows one chat message per hardware event. The welcome registers first,
so a joiner owed a greeting comes before a recruit owed a decline whisper; Welcome:Pump and its
flush/sendWhisper now report whether anything went out. The decline branch queues
{ name, queuedAt } and Scan.lua's drainer sends the oldest one still owed on the next click, drops
one whose msgQueue entry has since been cleared (an "already in a guild" for the same name) as the
timer's own check did, and drops one that waited over ten minutes with a chat line
(declineWhisperExpired, enUS only — the other locales fall through). A registry rather than a
second set of call sites because the compact tray had just demonstrated what happens to a fix that
lands on a subset (S84 below). Tests/zz_protected_decline_whisper_spec.lua drives the decline
through the real CHAT_MSG_SYSTEM event under the harness's hardware-event guard: nothing is sent
from the event, the drain with no click is BLOCKED, the drain inside a click sends, one per click,
the cleared-entry drop, and the expiry notice. zz_protected_welcome_spec.lua gains an example that
the welcome is reachable through fn.pumpClick, since its registration is guarded for the fixture
that loads the module alone.
Round 92, the same evening, found a second defect under the first. invitePlayer writes
msgQueue[playerName] with the queue row's own name — on retail always Name-Realm — while the
server's decline line for a same-realm player carries the bare Name, and the decline branch looked
the whisper up under the server's spelling. So on retail the owed entry was never found, the whisper
never queued (timer or click), and the debug line said "not in msgQueue" — the likeliest reason the
mode "sometimes worked" and nobody could say why (the S20 note). The branch now resolves the
spelling through the pending entry, which carries the row's name, and falls back to the raw name
only for an entry that has already gone. The spec had hidden this by seeding msgQueue under both
spellings; it seeds exactly what invitePlayer writes now, and a retail-shaped example declines a
Name-Realm row with a bare-name line. Also from round 92: "one chat message per hardware event" is
this repo citing itself, and is unmeasured — the cap in fn.pumpClick is kept because it costs
one click, and Welcome:Pump now obeys it within its own queue too (one send per click, the guild
line before a whisper) so the two queues have one rule. The one-line client test, when somebody is
at a client: two decline whispers owed, one click, /console scriptErrors 1.
Fixed — the compact tray's >> still overwrote the countdown and still carried the safety timer (audit S84)
S58's fix — the fn.startScanCooldown(DB.global.scanInterval) overwrite and the safety
C_Timer.After(libWho:GetInterval() + 5) both removed from the scan press — landed on the Scan tab
and the legacy window and not on Modules/compactFrame.lua:596-610. So a tray press inside the
/who rate floor showed the floor's remainder for a frame and then the scan interval, and the timer
— a timer deciding when the button unlocks, which the standing directive forbids — fired at 7-13 s
now that libWho.interval is the rate floor, against a give-up deadline of up to 30, clearing a
countdown that was still true and leaving the next press refused as "still in flight". Both are
gone from the tray, along with the cooldownGen tag and the file's libWho handle that only the
timer read. Tests/zz_compact_scan_press_spec.lua presses the real tray button: a refused press
leaves the floor's remainder on the button, and the press schedules nothing.
Changed — one scan press behind all four >> entry points (audit S84's cross-cutting note)
The compact tray, the Scan tab, the legacy window and the F6 keybind button each had their own
OnClick doing the same things, which is how S58's fix reached two of them and not the third. They
all call fn.pressScan(view) now: refuse while the view's countdown runs, refuse in chat without
invite permission, fn.pumpClick(), fn:nextSearch(), then the view's own repaint — and a view
hands in only its countdown flag and its after. Two behaviour changes fall out of unifying, both
stated: the Scan tab's and legacy window's buttons now drain one owed message on the press (they
never did; it is a real click), and the Scan tab's permission refusal prints in the client locale
like the other two rather than the UI override. The tray's /fgi debug breadcrumbs ("BLOCKED by
cooldown" / "Scan dispatch") now fire for every press, not just the tray's.
Tests/zz_scan_press_shared_spec.lua pins the press's four branches directly, then substitutes
fn.pressScan with a recorder and presses each real button — the legacy window builds offline,
which had never been tried — asserting each routes through it with a locked that reads its own
flag. LMW.scanButton is exposed for that.
Changed — code with no production entry point, three sites, deleted (audit S85)
fn.suppressConversationTabFor had no caller — fn.hideWhisper, which its comment named, stopped
calling it in v2.4.0 — and its body was unreachable on every flavour regardless: if gv.isRetail then return end followed by if not gv.isRetail then return end, so its three C_Timer sweeps had
inflated every timer sweep's denominator by three. API.SetPublicNote / API.SetOfficerNote lost
their only caller with "Notes on invite" earlier in this release; the wrappers and the four spec
examples that pinned a seam nothing crossed are gone. Announce:StartCooldownTicker had been a
no-op since v2.10.9 "so its callers stay safe", with all three callers in this repo, one of them a
load-time C_Timer.After(3, …) whose callback did nothing; the stub, the timer and
FGI_AnnounceSync's refreshCooldownUI are deleted together. The history of the whisper-tab sweep
is kept as a comment above closeDedicatedWhisperFramesForTarget, which stays live through the
FCF_OpenTemporaryWindow hook.
Changed — the per-frame refresh throttle exists once, in UI.CoalescePerFrame (audit S86)
The Guild Roster tab and the Eligibility tab each carried a copy of scheduleRefresh, the
coalescer between a burst of per-member LibGuildRoster callbacks and refresh(); Eligibility's
said "COPIED FROM THE SIBLING TAB". They had diverged: Eligibility kept a if not C_Timer.After then refresh() branch that GuildRoster had removed as one no client can take
(UITimerDocumentation.lua is in every tree). One function in GUI/UI.lua now, both tabs call it,
and the two tabs' existing refresh specs (zz_roster_refresh_spec, zz_eligibility_refresh_spec)
pin the same behaviour through it.
Also from round 91: the SetPropagateMouseClicks family carries HasRestrictions = true as well as
IsProtectedFunction, and for that family the flag means the COMBAT restriction, not a
hardware-event one — the harness's protected_names.lua labels the whole generated list "needs a
hardware event", which would call Wingman's load-time timer and restoreSession false hits.
Modules/Wingman.lua's overlay comment now says so, with the tree line; the InCombatLockdown
guard in applyPropagation is the restriction that applies. Two stale comments corrected:
FGI_Welcome.lua's "shared by the timer and click paths" (there is no timer path) and
FGI_MemberHistory.lua's "mirrors the 2.5s welcome-message delay" (the welcome no longer delays on
a timer). Suite: 2619 passed, 0 failed. Nothing in this section has run in a client.
Changed — 191 locale strings nothing read any more are gone from all 31 locale files
The locale files had been written to for six years and deleted from almost never, so every removed
control, retired sync engine, replaced tooltip and reworded slider left its strings behind — and
every one of those strings was still being carried in thirty languages, and still being handed to
translators as work. This is the enumerate-and-classify pass the "Notes on invite" removal above
promised, done the only way it can be done honestly: Locale/enUS.lua was executed to get the
runtime key set (1,429 top-level strings), every production file in the TOCs plus
Locale/summary.lua was lexed — every string literal in every quoting, every X.field — and a
key counted as referenced if any literal or field spelled it. Then every site that indexes a locale
table with something other than a literal was read by hand: the counter strip's abbr/label/desc
tables, L["batonRole_" .. role] (the three baton role headings, which a grep for batonRole_
would have called dead), the invite-mode and statistics-period tables, CL.helpVcheck and the other
seventeen slash-help lines read through fn.chatLoc() by field, the tab catalogue's loc[def.loc],
and the wrappers that all take literals. Two keys that survived only because an unrelated table had
a field of the same name (last.day, opts.filter) were classified as the orphans they are.
What went, by family: the pre-v2.5 sync engine's dialogue ("Sync with %s.", "Player %s wants to
sync %s. Allow?", "Sync type error", "Syncing..."); the legacy window's labels and its
\n-wrapped filter help; the counter legend's nine hand-coloured lines and the joined block, which
addon.CounterTooltip builds from scanDesc* now; the two scan-interval slider descriptions that
argued for the 2-second Classic floor the operator has since measured away; the old Wingman prompt's
four sentence-length captions; the date() format-code help ("AM" or "PM", the character name,
weekday) for the note templates; the announce* camelCase keys of the pre-profile announce panel;
RAID_DIFFICULTY_NAME_*, which were Blizzard globals copied in as if they were ours; and "Notes on
invite" with its nine companions. 191 in enUS, 172 in each of the thirty translations (the rest
were enUS-only additions that never got translated), 5,351 statements in all.
The removal was structural, not textual: each file was tokenized, only a statement of the exact
shape L[<string>] = <string> whose decoded key was on the list was cut, and each file was executed
again afterwards — the after-table had to equal the before-table minus the list, value for value,
or the file was not written. A backslash-CRLF inside a quoted key decodes to one newline in Lua's
lexer and to two characters in a naive one; the eight multi-line keys in the CRLF-ended locale files
matched only after the decoder learned that — and the verification step refused to write those
files until it did, which is the kind of thing a regex pass would have left behind silently. Suite:
2613 passed, 0 failed.
Fixed — the new reset-settings spec took ninety-five later examples down with it
Tests/zz_reset_settings_spec.lua captured print inside each example and restored it on the way
out — and its first example errored between the two: db:ResetDB() fires AceDB's OnDatabaseReset,
which FGI_Core.lua:869 answers with addon.API.ReloadUI(), and in the whole suite that callback is
on the shared database while the harness defines neither C_UI.Reload nor ReloadUI. The file
passed alone, where nothing had registered the callback. With print left captured, every slash
command spec after it printed into a dead table and asserted on nothing — 98 failures, three of them
real. Its second example then replaced DB.factionrealm.history with a table holding only
invites, which the keep-list faithfully carried through the reset and left live for every /who
spec after it (Modules/history.lua:35: table.insert(nil)). print and ReloadUI are now restored
in after_each, the reload is a spec-local no-op until the harness carries the API, and the example
writes its entries into the default tables and takes them out again.
Tests — the coverage catch-up begins, and finds three things
The 2026-08-16 order put coverage last, and it had never been run: the desk's coverage call was
being killed at ten minutes by the Claude Code hook default, not by writ. ~/.claude/settings.json's
desk-ingest hook now carries "timeout": 3600, and the gate runs. Starting numbers, four targets:
functions.lua 80.18%, Modules/Announce.lua 81.72%, GUI/Tabs/GuildRoster.lua 88.02%,
FGI_APICompat.lua 98.98% — 82.15% overall. After this pass: FGI_APICompat.lua and
GUI/Tabs/GuildRoster.lua at 100%, Announce.lua at 99.3%, functions.lua unchanged in
percentage but with the accept path, the auto-kick watcher and the dead note code accounted for.
What was found, beyond the two removals above.
FGI_APICompat.luaended with a debug line guarded onaddon.functions.debug, which does not exist untilfunctions.lualoads four files later — dead on every flavour, deleted.GuildRoster.luaguardedC_Timer.Aftertwice for a client without it (UITimerDocumentation.luais in every tree), kept anelsefor a percentage row with no members (a row only exists for a counted member), and had a two-line tie-break that is the one-linea.nameSort < b.nameSort; all simplified. Each was a branch no client could take, and each counted against the number.The LibWho harvest spec was a coin toss, and the coverage run is what showed it. Its reload of
LibWho.luabuilt awhoFramethat is a file-local no closure captures, so after the chunk returns only the env's weak registry holds it; the plain suite passed every time and the instrumented run — whose line hook allocates on every line and collects far more often — failed "one of the two queries never reported at all" every time.app.load()already pins FGI's own copy for exactly this reason (finding S57); the spec's reload now runs underframes.retainDuringtoo. Not a race in LibWho; a reachability hole in the fixture.New specs.
Tests/zz_roster_sections_spec.luarenders the Guild Roster tab over a real cast for the first time — offline members counted, class colours, the#header sorting descending then ascending with the arrow following, the empty-roster and library-missing lines, both baton tooltips, the Step aside / back in click, and the baton menu opening under its title. It stages two client facts the harness did not model, in the example bodies, and raised both with the harness:RAID_CLASS_COLORS, and theTextFontStringInterfaceOptionsCheckButtonTemplatecarries (CheckButtonTemplates.xml:54, all four trees). Delivered the same evening in harness7e929c2; pin moved tobc75950(commit85a8660), both stand-ins deleted. Two corrections from the delivery, read from the client's ownClassColors.lua: the Classic Era table has twelve keys, not ten (DEATHKNIGHT, MONK and DEMONHUNTER are declared), so the unknown-class fallback is driven by removing a key rather than naming one of those; and SHAMAN is Paladin-pink on Era. The colour example derives its expected hex from the harness's entry rather than a literal.Tests/announce_channels_spec.luacovers public channels end to end — slot resolution, the echo that confirms a post and the three lines that do not, activity counting by base name, the designate gate's three refusals, the chat limit retried once and then filed with the client's words, a guild announcement's cooldown broadcast, and the profile editor's Create/Duplicate/Delete including the rotation-membership sweep.Tests/zz_record_accept_spec.luadrives a guild join from the system message to the books: every join counted, attribution from each of the three pieces of evidence alone, the retention window, the thirty-second dedup, and the decline-whisper cleanup.Tests/zzzz_blacklist_autokick_spec.luadrives the setting rather than the sweep: the login sweep deferred until the library's roster is built (the v2.12.1 fix, asserted for the first time), the fallback rows, and the joiner watcher with its lockdown gate and one-frame guard.Also new:
Tests/zz_stale_pending_spec.lua— the state-driven sweep of invites that never resolved, which had never run offline: a Type-1 entry past the cutoff is filed Unresolved (a stat, never a History row) and forgotten, a Type-3 whisper-only entry is cleared silently because no outcome arriving is its happy path, the caller's own cutoff is honoured, andfn:clearPending's Unresolved branch counts on both the session counter and the lifetime bucket.Tests/zz_settings_widgets_spec.luais rewritten around its guard. The seven examples that droveFGI_TooltipInputwent with the widget; the orphan guard that caught it is generalised to everylocal Type, Version = "FGI_..."registration, and its twin — everydialogControlnames a registered widget, which the file had described as "already green" without writing it — is written.Later the same day:
Modules/Scan.luaandModules/Announce.luato 100%. Scan.lua's remaining 17 lines were four decline-debug sites each carrying anelse print(line)arm that could not run (functions.lua precedes this file in every TOC), thecanaccessvaluegate on the AFK/DND handler thatAPI.RegisterChatEventshad already made unreachable, the veteran-account refusal and the sent-invite English fallback nobody drove, the empty "invite sent" receipt branch, and the PLAYER_LOGOUT drain. The unreachable arms are deleted; the rest are driven — the logout drain through the real event, the retail pcall arm by making the classifier raise. Announce.lua's one line was a guild/officer guard insideresolveChannelIndexthat all three callers had already applied.
Suite: 2635 passed, 0 failed. Nothing in this section has run in a client.
Fixed — /fgi debugtabremove reset the player's entire chat layout, and /fgi debugtab could not find a free slot once any tab had ever been closed
The debug chat tab (functions.lua, "dedicated debug ChatFrame routing") sat at 0% because the
FCF_* globals it calls are not in the harness. Reading Blizzard_ChatFrameBase/Classic/FloatingChatFrame.lua
to model them found that all three of the cluster's assumptions about Blizzard's chat SV were wrong:
- Removing the tab wiped the whole chat layout.
fn.removeDebugTabcalledFCF_ResetChatWindows()— the client's Reset Chat Windows (:1525), which closes every tab but General and Combat Log, strips their message groups and channels, and resets their font size, colour and alpha. It also renamed the slot "Combat Log" first, with the loop index inFCF_SetWindowName'sdoNotSaveposition, so a second Combat Log tab was left behind. NowFCF_Close(f)on that one frame — the call the tab's own right-click Close Window makes. - "No available chat-frame slot" with eight free. The slot hunt looked for a saved name of
"", and Blizzard never clears a name on close (FCF_Close,:1432, undocks and hides). Once the player had ever created and closed any chat tab, every slot looked taken. The window now comes fromFCF_OpenNewWindow(name, true)— Blizzard's own New Window, present in every flavour — whose free-slot test isnot shown and not docked and not builtin(:433). - A closed tab was re-found after
/reload, so every debug line went into a hidden frame. Same fact the other way round: both finders matched by name alone. A live tab is now one that is shown or docked — both, because the dock hides every docked frame but the selected one and the frame's OnHide savesshown = false(FCFDock_UpdateTabs :1875,FloatingChatFrame.xml:589). - The re-found branch also force-docked a tab the player had deliberately undocked; it no longer
moves it.
SetTimeVisiblewent, since fading is off. The adopt-and-hook-OnShow block was written three times and is oneadoptDebugFrame; the OnShow hook now checks the frame is still the debug frame rather than that one exists, so a slot the player later reuses stays inert.
Staged Tests/support/chatframes.lua — the SV setters, the ScrollingMessageFrame methods and
eight FCF_* functions transcribed from the Blizzard source with line citations — and raised it with
the harness as a contract (inbox 01ef0f1874db). Tests/zz_debug_tab_spec.lua (20 examples) drives
create, route, repaint and remove through it; three examples are the defects above and fail against
the old code. .luarc.json loses the four FCF_* names nothing calls any more.
Delivered by the harness the same evening and pushed on 2026-09-13 as 1037beb; pin moved to
1211a3a, the stand-in deleted. The spec's helpers now read the window record through
GetChatWindowInfo and the selected tab through FCFDock_GetSelectedWindow, and each example starts
from FCF_ResetChatWindows() — the client's own starting state, General and Combat Log docked at 1
and 2 — instead of wiping the record table by hand. Three corrections from the delivery, none of
which touch the addon: isDocked is the number 1 on the frame and the saved docked is the dock
position, not a boolean (FloatingChatFrame_Update:253 feeds it back in as an index — the finder's
shown or docked was already truthiness); CHAT_NAME_TEMPLATE is "Chat %d"; and
AddMessageGroup consults ChatTypeGroup with exact case, so the strip-groups example now asserts
its "GUILD" was stored before asserting it was stripped. The same pin brings the harness's
string.format("%d") overflow check (raises over 2^31, as the client does) — the whole suite ran
green against it, so no %d in the addon takes a value that large.
Suite: 2676 passed, 0 failed. Not verified in a client — in particular, FCF_Close from a slash
command has not been checked against the retail taint history in the v2.5.4 entry; the call it
replaces ran FCF_Close on all ten frames.
Changed — the race/class tables are data for every branch, written in class keys, and asserted from one suite
functions.lua built only the running client's RaceClassCombo inside an if/elseif on the version
flags, so the retail, Mists and TBC literals were dead on a Classic Era client and unreachable
offline — a row lost in a regeneration (retail once shipped without three whole races) could not be
caught by a spec. They are now one RACE_CLASS_KEYS_BY_BRANCH table, all built at load, with
fn.raceClassBranch(gv) picking the branch and fn.raceClassComboFor(branch) materializing it.
Wrath/Cata is built from TBC's table plus Death Knight rather than listed — and as a copy, where the
old code inserted into the TBC table in place.
The entries are class KEYS, not localized names, and the first cut got that wrong. L.class is
per-flavour (Locale/summary.lua:82-93 adds Death Knight, Monk, Demon Hunter and Evoker only where
the client has them), so a retail table written in CLASS.Evoker names is full of nils on Era; the
spec's own failures found it. The key enum errors on an unknown key rather than yielding nil, so a
typo in a generated row fails at load instead of becoming a hole. Tests/zz_raceclass_branches_spec.lua
(11 examples) pins the flag-to-branch pick, the counts per branch (26 / 13 / 10 / 8), the two rows the
old hand-written Mists table had lost, Wrath = TBC + Death Knight with TBC untouched, and that this
client's table resolves every key.
Suite: 2686 passed, 0 failed. Not verified in a client.
Tests — functions.lua reaches 100%, and eleven arms that no client could take are gone
From 85.9% at the start of the evening to 3038/3038. What closed the gap was mostly reading, not specs: each remaining branch was traced to the Blizzard source or the loader before being driven, and most turned out to be unreachable on every flavour. Deleted rather than covered:
fn.getTime'sreturn time()— every flavour tree'sCommunitiesTicketManagerDialog.luacallsGetServerTime(), and the file captures it as a local at load, so the spec that "drove" the fallback by nilling the global never reached it and passed vacuously. Both gone.fn:messageSplit's character-counting splitter "for an older LLO withoutSplitToBytes" — LibLocaleOverride is a## Dependencies:entry and has carried the method since v0.2.0.- The
SetCVar/GetCVar/C_Timer.Afterexistence guards in the whisperMode block, theelseif ChatFrame_RemoveAllMessageGroupsdeprecation arms, the retailpcall(fn.fullPlayerName)arms inhideWhisper/hideAwayResponse(Blizzard's filter registry never calls a filter with a secret value —ChatFrameFilters.lua:115-119),safeLower's retail arm (its only caller is installed on the Classic family alone), the inline lockdown gate on the auto-kick handler (theRegisterChatEventswrapper installs it),locationSplit's empty-results guard (nextSubdivisionAxisis the one decision point), seven(loc and loc[k]) or "English"fallbacks on keys in enUS, and thedebugDBfile-local snapshot thatfn.debugtried before the live read it fell back to.
Driven, in nine spec files: the whisperMode save/restore end to end on the harness clock
(zz_whisper_popout_spec.lua, 11), the scan queue's three sources and the bucket wrap
(zz_scan_queue_population_spec.lua, 8), every scan refusal and receipt line with the mute off, the
race/class compatibility skips in both filter schemas, the female-form lookups on both paths,
fn.refreshQueueRun's five answers, the login auto-sync timers, and the per-row restore guard with a
keyer that raises. Two order-dependent leaks found by running files in a non-alphabetical batch —
zz_who_subdivision_spec left a subdivision tier off, the new queue spec left subdivideLvl off —
both now restored in after_each.
The rest of the addon, measured for the first time over its first twenty files: 72.84%, per-file
numbers on the todo list. Modules/FGI_ScanGroups.lua went from 68% to 100% the same session
(Tests/zz_scan_groups_data_spec.lua, 15 examples: the group and member helpers, the two sweeps the
custom-scan editor runs on delete and rename, and the bucket announcement).
Suite: 2760 passed, 0 failed. Not verified in a client.
[v2.13.0] (2026-08-27) — The guilded-players-on-the-scan-list bug is found and closed, policy lists learn about alts, auto-blacklist guild leavers comes back from the dead on Classic, and Clear moves next to Reset
New — settings templates: hand your whole setup to another recruiter
Operator's request, and it decides the shape of everything below: "this isn't a back up of 'data'. this is the backup of a UI settings format that user A with a ton of EXP and a curated setup can give to a new user B to get them up and running QUICKLY." And on who chooses what is in one: "YOU dont' chose what goes into a template, the USER does. you should expose ALL the settings, and then let the template create decide."
Four pieces, and they are deliberately separable. Modules/FGI_SettingsProfile.lua is the
engine — a catalogue of every user setting, an Export that takes the author's selection, a library
that received templates land in, and the one function that writes settings. Modules/FGI_TemplateWire.lua
is the transport. /fgi template is a slash surface. GUI/TemplatesPage.lua is Settings →
Templates, which is where a recruiter actually uses it.
A CATALOGUE, NOT AN ALLOW-LIST. The first draft had me deciding which settings counted as
"transferable expertise" and quietly dropping the rest — a policy about somebody else's setup,
written by the wrong person. Every user setting is offered; the author ticks. What stays out is only
what is not a setting, which is a different test from "not worth sharing": records of what
happened (blacklist, history, who has been asked), runtime state (which window was open), and
guild-replicated policy. Each exclusion carries its reason as a value rather than a comment, so
Tests/zz_settings_profile_spec.lua can assert every key is classified.
The catalogue was built by enumerating the Settings panel's real DB reads, not the defaults
block — the defaults-block version missed fifteen settings, including subdivideOrder (an
entire shipped feature) and autoWelcome, whose absence would have shared the welcome whisper
settings while silently dropping the welcome itself. A sweep now walks the real defaults and fails
the suite on any key in neither table, so the next setting added cannot go missing here.
Nothing the guild sync carries goes in a template. The operator: "we don't need to add anything
to the template that is synced. our sync already takes care of it." This is not merely redundancy —
a template is a snapshot, every DeltaSync item is a live arbitrated record, so a template copy
either loses the arbitration and did nothing or wins it with stale data. gmPolicy is the worst
case: one guild's recruitment rules pushed into another's. All eight sync items are excluded, and
the spec cross-references the live ITEM_KEYS so a ninth added later breaks the suite until somebody
confirms where it lives.
Arrival cannot change a setting. The operator: "it should just add a template to the recipients
list, that they can then enable or not as they see fit. it SHOULD NOT overwrite what they have."
That is a stronger safety property than the offer/accept handshake it replaced: with a library there
is nothing to negotiate, because the dangerous act does not happen. Store puts a template on a
shelf and writes no setting, ever; Apply is called only by a deliberate local press. The worst a
hostile sender achieves is a row the recipient deletes. The author is taken from the transport,
never from the payload — a payload-supplied author is a string the sender chose, so it could name
anybody.
Not through DeltaSync, and that is the load-bearing decision. DeltaSync is for guild-wide
convergent state: advertise a hash, arbitrate, agree. A template is point-to-point, one-shot, and
the recipient edits it immediately — there is nothing to converge on, and advertising a hash for "my
template" would be meaningless traffic. Modules/FGI_Baton.lua is the precedent and its header
records the same reasoning: own prefix, AceComm for chunking, AceCommQueue for retry and a real
delivery verdict. A ceiling of 64 KB is enforced on both sides, because the sender's cap is a
courtesy a hostile client would simply not apply — this addon has already lived through a 40 MB
saved-variables file that silently failed to load.
Settings → Templates. A picker with one tick box per setting, grouped the way the Settings panel groups them; a name; Save. Below it, every template you have saved or been sent, each with Apply, Forget, a Send box (with a dropdown of guildmates FGI has heard from, plus a typed name for anyone else), and the template as copyable text. At the bottom, a paste box for a template somebody sent you outside the game. Apply is the only destructive verb on the page and the only one behind a confirm — receiving, saving and sending change nothing, and there is no undo for applying.
The labels are READ OUT OF THE SETTINGS PANEL, not copied from it, and this was the actual work
in the page. The catalogue holds a scope and a DB key, and the one thing a picker may not do is show
a player global.autoBlacklistLeavers. Every one of those words already exists in the panel's own
AceConfig tree, keyed by the same DB key and already translated — so GUI/SettingsPanel.lua now
publishes its tree as addon.settingsOptions and SP:Label walks it. Keeping a second table of
eighty labels would mean the picker and the checkbox say different things the first time somebody
rewords one, silently. The walk is sorted and first-wins, so a key on two pages resolves the same
way every session. SP.LABELS covers only what the walk cannot answer: settings edited on a
main-window tab rather than in the panel, and the three keys the catalogue carries in two scopes
while the panel names one (addonMSG, messageList, curMessage).
Applying names what it will destroy, and that warning went in wrong first. Raised on the audit
board (round 90) before this page existed: applying replaces hand-built collections whole —
filters, custom scans, whispers — and nothing warned about it. SP:WouldReplace is now shared by
the page's confirm and by /fgi template apply, which had no confirmation at all while the page had
one and is now gated behind an explicit apply <n> yes. The first version warned whenever the
recipient's collection was non-empty, and four specs went red immediately — FGI ships default
whisper templates, so "they already have one" is true on a client that has never been touched, and
an ordinary save-then-apply round trip raised a loss warning about settings identical to its own.
That is precisely the dialog people learn to click through, so it now warns only when theirs is
non-empty and differs, by deep comparison with a depth cap of 12 rather than a seen table —
failing toward warning, because a cycle in a hand-edited payload would otherwise hang the client
inside a confirmation dialog. Replace-not-merge itself is unchanged and deliberate; the defect was
that the cost was silent.
The library cap was on the transport and belonged on the shelf — a defect this feature introduced
and I found by writing a review request rather than by testing. MAX_LIBRARY was checked in
FGI_TemplateWire's receive handler only, so it bounded templates that arrived. When the page
added a Save button and a paste-in box, both called Store directly and neither was bounded: the one
path a griefer can use was capped, and the two a player uses were not. It matters because this addon
has already lost saved variables once to an unbounded table — the FGI_DEBUG runaway, a 40 MB file
that silently failed to load. The cap now lives in SP:Store, so every future entry point gets it
for free, and the wire reads through rather than keeping its own copy. The ordering is load-bearing
and control-tested: the replace branch is checked before the cap, because replacing does not grow
the shelf — with the check first, a full library becomes read-only to its own owner.
A row that appears while you are looking at it. The library redraws from a callback on the library itself, not a poll or a rebuild-on-open — the case that matters is a template arriving over the wire while the recipient has the page open, and a silent page reads as the sender having failed to send it. Forget finds its row by name and author, never by the index it was drawn at, because something can land in between and deleting the wrong template is unrecoverable.
Covered in Tests/zz_templates_page_spec.lua (22 examples) alongside the engine and wire specs.
The label sweep is a completeness check rather than a behaviour one: it fails on any catalogue entry
that resolves to its own key, which is what falling through both the override table and the panel
walk looks like. Two examples drive the real AceConfigDialog and read the text off the rendered
checkboxes, because an args table can be perfectly well-formed and still draw a page of raw DB keys.
Control-tested: breaking SP:Label turns exactly the 61 derived labels red in all three label
examples, and removing the library callback turns every library-row example red — so neither half is
being carried by the other.
Still not verified in a client, and the wire has never carried a real payload. A template with filters plus custom scans is multi-KB against AceComm's ~250-byte chunking and a hard client throttle; the 64 KB cap is a wall, not a measurement.
New — right-clicking Wingman offers the baton as well as the steps
Operator's request: "Right click wingman also handle batons?"
Right-clicking the Wingman toggle already opened the step menu (announce / invite / scan ticks). It now carries the baton operations underneath them — Take, Request, Assign to, Back to the list order, per role — so duty can be moved from the compact tray without opening the Guild Roster tab.
The entries are the same function, not a copy. showBatonMenu was a closure inside
GuildRosterTab.Render, and that mattered: the entries did not exist until that tab had been
rendered, so a right-click from the compact tray — the most likely place to want them — would
have found nothing, and no spec would have caught it because a spec renders the tab first. It is now
GuildRosterTab.AddBatonEntries(level, menuList), which adds buttons rather than owning a frame, so
either menu hosts it. The tab's own Baton... button is now a thin host over the same call. This
follows the precedent already in Modules/Wingman.lua: ArmMenuCloseWatcher was exposed a version
ago for exactly this reason, because two menus each with their own outside-click watcher both call
CloseDropDownMenus and shut whichever menu is open.
Flat at level 1, not behind a submenu. The baton's own Assign to already opens a submenu, so nesting the whole block would put candidate names at level 3 to save one line.
A defect caught while writing it, worth recording because the wrong version looks right.
The first cut gated the new section on BatonOptions(role) ~= nil. That is always true whenever a
Recruiter exists — the function returns a table for every role, and it is the four booleans inside
that say whether anything can be done. So the menu would have drawn a "Recruiting duty" heading and
three role headings with nothing under them, for exactly the member most likely to see it: one who
holds no duty and is named in no policy list. The gate is now GuildRosterTab.HasBatonActions(), and
AddBatonEntries skips a role whose four booleans are all false — which also removes the same empty
headings from the Guild Roster tab's own menu, where they were already being drawn.
Covered in Tests/zz_roster_duty_line_spec.lua: the gate answers no when nothing applies and yes
as soon as one operation does, it does not raise before the Recruiter exists, and — the one that
matters — the gate and the drawer are asserted to agree, by driving the real AddBatonEntries
with the dropdown API captured and counting buttons. They are two functions reading the same four
booleans; that example is what stops one being edited without the other and putting the empty headings
back.
And covered where the feature actually is, in Tests/zz_wingman_step_menu_spec.lua — which the
first round of tests for this did not do. Those covered the extracted helper in isolation, so
deleting the entire baton block from Wingman:ShowStepMenu left the suite green. Four examples now
drive the real registered initialiser and read the real buttons the harness records: the duty section
appears when an operation applies, the three step ticks survive alongside it, no section is drawn when
nothing applies, and level 2 reaches the baton's assign candidates rather than redrawing the steps
into the submenu.
Control-tested, with the result recorded in the file rather than claimed. Backing the feature out turns two of the four red — the two that are the feature. The other two stay green and that is correct: one guards against the baton displacing the steps, and the other passes vacuously with the section gone, its real property being pinned by the button-counting example instead. That is written into the spec header so nobody reads four passing examples as four proofs.
Fixed — Q4: with no welcomer list configured, every officer welcomed the new member
The welcomer list exists so that a new joiner gets one welcome rather than one per officer running
FGI. But amDesignateFor answers true for a nil list, so leaving that one list blank turned the
protection off entirely — the exact duplication it was added to prevent, visible to the new member as
N identical whispers.
The remedy is the operator's and it is better than the three that were put to them (coordinate
over announceSync, auto-elect from the online FGI-running set, or close won't-fix): "why wouldn't
we just use one of the priority lists?" An unset welcomer list now inherits the announcer list.
welcomerList() is ownWelcomerList() or priorityList(), so a configured welcomer list always
wins — this is a fallback, never an override — and every consumer already routed through that one
function, so the election, CanWelcome, the baton's MayHold/CandidatesFor, the duty readout and
Settings all agree without being told separately.
Why inheriting beats coordinating. It is a pure function of replicated policy plus the live roster, so two clients cannot reach different answers. Coordination needed a join-time message exchange and a tie-break; auto-electing from "who is running FGI" leans on presence stamped by comm traffic, which is not symmetric between clients — it could produce two welcomes or none, failing as the very bug it fixes. Inheriting adds no message, no state and no clock.
It leaves the case that was already right alone. priorityList() is nil when the announcer list
is unset or the policy is inactive, so a guild running no policy still falls open — and with a
single FGI install that is exactly one welcome and correct. That is also why falling closed was
never the answer: it would trade a duplicate needing several FGI users for a silence needing none.
The announcer list and deliberately not the inviter list. The inviter list is separate precisely
because random invites get the throwaway alt server-wide /ignore'd — it is the list of characters
you do not want speaking for the guild. A welcome is a whisper from someone representing it.
Two defects came out of building it, both found by the suite. A single handover began printing
two duty notices — "You are now the designated announcer." followed by "…designated welcomer." —
because the inherited welcomer is the same election; both lines were true, which is what made it a
readout defect rather than a wrong answer, and the welcomer is no longer reported separately while
inheriting. And the control run caught a duplicated predicate in the fix itself: the readout's
"is it inheriting?" test re-derived the condition from the DB instead of from welcomerList, so with
the inheritance removed it stayed true and would have gone on announcing an inheritance that was not
happening — with all six new examples still passing.
Control-tested: removing the inheritance turns three of the six new examples red. The other three guard cases the fix must not change (a configured list still wins, no policy still falls open, an inactive policy is not inherited from), so its absence is not their failure mode — recorded in the spec header rather than left for six passes to be read as six proofs.
Because HasWelcomerPolicy() now answers true whenever the announcer list is set, the inheritance is
stated rather than silent: the Guild Roster duty line carries "(welcomer follows the announcer
list)" as a trailing note, the same shape as the existing "(you stepped aside)".
Fixed — nobody had ever clicked a baton menu entry, and the duty line was repainted twice when they did
Operator's question, which is the whole finding: "have you build an end to end test for the baton framework? we've done a lot of changes, and we need a full end to end test. this should include the wingman right click options as well." The answer was no, and the section above is why it looked like yes.
Two thorough halves with nothing joining them. Tests/baton_roundtrip_spec.lua drives the
protocol end to end — real TakeBaton/AssignBaton/ReleaseBaton produce a real payload and the
real OnCommReceived on a second client applies it — but it never opens a menu.
Tests/zz_wingman_step_menu_spec.lua drives the menu, but with a Recruiter double whose baton methods
are function() end, and it asserts button text. So no spec had ever invoked a baton info.func:
every click handler in AddBatonEntries could have had its body deleted with the suite still green,
and the shipped failure would be the entry drawing, the click landing, the menu closing, and nothing
happening anywhere.
Tests/zz_baton_e2e_spec.lua (new, 11 examples) closes it, and simulates only the radio. The
real menu is opened, the real buttons the harness records are clicked, the real Recruiter produces the
payload, SendCommMessage is captured, and that exact string is handed to the real OnCommReceived
on a second client whose election then has to move. All four operations: take, assign (with
the release driven through the menu after it, so the two stamps are in issue order), request —
including that the holder is actually told, which nothing asserted before, so an inert Q branch was
green — and pass, typed as /fgi stepaside through the real Recruiter rather than the dispatcher
double.
Then the defect it found. Every one of those handlers ended by repainting the Guild Roster tab's
duty line. But AssignBaton, ReleaseBaton, StepAside and StepBack all call onBatonChange()
before they return, and that already calls GuildRosterTab:RefreshDuty() — the same line. It was
painted twice on every baton click. Found by measurement, not by reading: the new example asserted
one repaint and got two. Three redundant calls deleted.
And a comment that defended one of them was false. The step-aside button's read "Not left to the
baton callback: StepAside/StepBack are guild-synced, and the round trip is what a user reads as the
button not having worked." There is no round trip to wait for. Worth recording that the first fix
kept that call and justified it as deliberately different — on the strength of that comment rather
than on reading StepAside, which took one look and reversed it. The replacement quotes the old
wording and says why it was wrong, and the deletion is pinned by its own example rather than by the
argument.
Control-tested, with the count in the spec header. Backing all four click handlers out — entries still drawn — turns seven of the eleven red. The four that stay green are named individually with the reason, including one that asserts an absence and therefore passes vacuously under the control; it is marked as not being evidence that Request works.
Fixed — the Eligibility tab's strip: a label clipped off the panel, and a search box in a spelling nothing else uses
Both reported from one screenshot, and both in GUI/Tabs/Eligibility.lua.
The "Show all guild members" label ran off the right edge of the panel.
InterfaceOptionsCheckButtonTemplate anchors its .Text to the box's right, and this control sits
at the strip's right edge — so the label grew out of the window and was clipped mid-word. Moving the
box left would only have traded one problem for another, because the label's width depends on the
locale and on the override font. The label is now anchored to the box's left, so the pair grows
into the strip at any width in any language, and the text is shortened to Show all ("it doesn't
need to be that long") with the full sentence kept as the tooltip title.
The strip builds its own FontString rather than re-anchoring the template's. Taking over a field
the template itself anchors, and then overriding it, depends on template internals — and the harness
does not model .Text at all, so the whole control was untestable offline. Owning the FontString
costs one line and removes both problems; the template's own label is emptied so nothing draws twice.
The tooltip is on the words as well as the box, and the words are clickable. The label is now the larger target and the thing the eye lands on, so a tooltip answering only over the 26px box read as no tooltip at all. A FontString cannot take mouse input, so an invisible button is anchored to the label's own corners. Clicking it toggles the box, which is what a label beside a checkbox means everywhere else in this UI.
The toggle is explicit rather than CheckButton:Click(). The label's handler set the value
directly instead of relying on Click() flipping the checked state for us — true in the client, and
something this code had no reason to depend on. One function now owns the local, the box and the
repaint, so they cannot disagree about which of the two controls was used.
The search box is SearchBoxTemplate, like every other list tab. It was an InputBoxTemplate
with a hand-written Find FontString glued to its left — one concept in a second spelling, and the
spelling nothing else in the addon uses. docs/AUDIT.md had already named exactly this shape as a
finding on the Blacklist tab. The template supplies the magnifier, the greyed Search placeholder and
the automatic X clear button, so the label had nothing left to say and went with it — which also
removes the cramped gap the report opened with. Handlers are HookScript, not SetScript, because
the template owns the placeholder and the X's visibility through its own; Esc wipes the filter
rather than merely dropping focus, matching History and AntiSpam.
New spec, Tests/zz_eligibility_strip_spec.lua. The other three Eligibility specs skip Render
by design — "it is anchor arithmetic" — which was a reasonable line right up until anchor arithmetic
was the defect. A screenshot is a bad place to find out that a label does not fit. It asserts the
label's right edge against the strip's own right edge rather than a magic number, that the label
is left of the box (so a longer translation cannot silently reintroduce the bug), the short label, a
mouse-catching frame over the words, the click-to-toggle, the absence of a Find label, exactly one
edit box, and Esc clearing the filter.
Fixed — a server refusal never stopped anything, so those players were re-invited forever
The whole branch was unreachable. When the server refuses a guild invite outright — a Free Trial account, a lapsed veteran account, or someone of the opposite faction — the message carries no player name, so the handler is written to attribute it to the one invite in flight. It never got the chance: a guard above it returned early on exactly the missing name that outcome is defined to have. So the refused player was never remembered, never cleared from the in-flight list, and every later scan offered them again.
Found by writing the coverage the tab had never had: those lines were uncovered because nothing could reach them, which is the one reading of a coverage gap worth more than the number.
Fixed — the fix above left a nameless outcome loose in branches that all assume a name
The way that unreachable branch was reached again was an exemption on the guard —
if not name and type ~= "guild_reject" then return end. It works, and it is the wrong shape: it
lets a nil name past into a chain whose other outcomes all index addon.msgQueue[name], and a nil
table key is a hard error in Lua rather than a silent no-op. Three language-server reports said so
at those exact lines; they were right, and only the single-in-flight invariant kept it from firing.
The refusal is now settled ahead of the guard, in handleGuildReject() beside handleNotFound()
— and exposed as addon.Scan.HandleGuildReject for the same reason that one is: the caller is a
load-time frame no zz_-sorted spec can drive. The guard goes back to a plain
if not name then return end, so every branch past it provably has a name. Behaviour is unchanged:
same next(addon.pendingInvites) attribution, same anti-spam promotion, same msgQueue clear.
Fixed — a second system-message handler could taint the interface on Retail
Modules/Scan.lua has carried a chat-lockdown guard since v2.7.1: during an encounter, a Mythic+
run or a rated match, the text of a system message is a protected value, and reading it both
raises and marks this addon as the cause — the taint cascade that has cost four separate releases.
There is a second handler that listens to the same event — the one that notices somebody joining your guild — and it never got that guard. It read the message directly. Same event, same window, same consequence; it had simply been missed when the first one was fixed. It now checks the same state, and the Classic family is unaffected either way because it has no protected values.
Fixed — a login error that silently caused the "it invited me twice" reports
Two reports that looked unrelated turned out to be one bug.
The error, from a retail client: functions.lua:1077: attempt to concatenate a nil value, in
normalizePlayerName, called from restoreScanState during addon start-up. The retail branch
appends your realm to a name, guarded by if GetNormalizedRealmName then — a check that the API
exists, which it always does. At login it returns nil, and name .. "-" .. nil raises. An
existence check reads like a safety check and is not one: the API being present says nothing about
whether it can answer yet. It now tests the return.
Why that also invited people twice. The restore puts each saved candidate back into the queue
and registers them in the session's already-queued set — the thing the scanner asks "do we
already have this person?" — in the same loop. The error aborted that loop, so rows landed in the
queue with their registrations never written. The scanner could not see them, so when a later /who
found the same player — typically after they had changed zone, which is what puts them in a
different query — it treated them as new and queued a second row. Both were then invited, and
because the first invite had not resolved yet, the anti-spam list was still empty and caught
nothing. Reported as "someone zoned and is on the list twice, that's where the already invited me
comes from".
The loop now registers each row independently, so a future error costs one row rather than the whole set, and says so in the debug log instead of failing silently.
New — a Guilded column on the Scan tab
Requested: "i need you to add another column to the scan tab, guilded and the data should be Y/N. i don't need to know which guild, just if they are."
Every queued candidate now shows whether they were in a guild the last time a /who saw them.
A scan already discards anyone found in a guild, so a freshly queued candidate always reads N —
which is what makes a Y worth seeing: it means the row reached the queue some other way, and
that is exactly the class of thing behind the standing "FGI whispers people already in a guild"
reports. A dash means the row predates the column. The reading was always on the row; it was simply
never shown, so every report became an argument about whether it was happening instead of a look at
the list.
Older releases have been moved to CHANGELOG_ARCHIVE.md — v2.12.2 and earlier in full, plus the thirty-eight oldest sections of v2.13.0 itself. Ten went on 2026-09-09, twelve more on 2026-09-10, eight on 2026-09-11, four on 2026-09-13 and four on 2026-09-14, as each later entry put this file back over the working ceiling. Expect to move more: every entry added pushes another one over.
This mod has no additional files

